# TreeView Component Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

**Goal:** Build a reusable generic `TreeView<TItem>` Blazor component and integrate it into the Instances, Data Connections, and Areas pages.

**Architecture:** Generic component in `Components/Shared/` with `@typeparam TItem`, render fragments for content/context menu, internal expand/collapse state with sessionStorage persistence via JS interop, and ARIA accessibility. Three pages refactored to use it.

**Tech Stack:** Blazor Server, Bootstrap 5, bUnit 2.0.33-preview, xUnit, IJSRuntime for sessionStorage.

---

### Task 1: Create TreeView.razor — Core Rendering (R1, R2, R3, R4, R14)

**Files:**
- Create: `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor`

**Step 1: Write the failing tests**

Create test file with core rendering tests.

Create: `tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs`

```csharp
using Bunit;
using ScadaLink.CentralUI.Components.Shared;

namespace ScadaLink.CentralUI.Tests;

public class TreeViewTests : BunitContext
{
    // Test model
    record TestNode(string Key, string Label, List<TestNode> Children);

    private static List<TestNode> SimpleRoots() => new()
    {
        new("a", "Alpha", new()
        {
            new("a1", "Alpha-1", new()),
            new("a2", "Alpha-2", new()
            {
                new("a2x", "Alpha-2-X", new())
            })
        }),
        new("b", "Beta", new()),
    };

    private IRenderedComponent<TreeView<TestNode>> RenderTree(
        List<TestNode>? items = null,
        Action<ComponentParameterCollectionBuilder<TreeView<TestNode>>>? configure = null)
    {
        return Render<TreeView<TestNode>>(parameters =>
        {
            parameters
                .Add(p => p.Items, items ?? SimpleRoots())
                .Add(p => p.ChildrenSelector, n => n.Children)
                .Add(p => p.HasChildrenSelector, n => n.Children.Count > 0)
                .Add(p => p.KeySelector, n => n.Key)
                .Add(p => p.NodeContent, node => $"<span>{node.Label}</span>");
            configure?.Invoke(parameters);
        });
    }

    [Fact]
    public void Renders_RootLevelItems()
    {
        var cut = RenderTree();
        var rootItems = cut.FindAll("[role='tree'] > li[role='treeitem']");
        Assert.Equal(2, rootItems.Count);
        Assert.Contains("Alpha", rootItems[0].TextContent);
        Assert.Contains("Beta", rootItems[1].TextContent);
    }

    [Fact]
    public void Renders_EmptyContent_WhenNoItems()
    {
        var cut = RenderTree(items: new(), configure: p =>
            p.Add(x => x.EmptyContent, "<span class='empty'>Nothing here</span>"));
        Assert.Contains("Nothing here", cut.Markup);
    }

    [Fact]
    public void LeafNodes_HaveNoToggle()
    {
        var cut = RenderTree();
        // Beta is a leaf (no children)
        var betaItem = cut.FindAll("li[role='treeitem']")
            .First(li => li.TextContent.Contains("Beta"));
        Assert.Empty(betaItem.QuerySelectorAll(".tv-toggle"));
    }

    [Fact]
    public void BranchNodes_ShowCollapsedToggle()
    {
        var cut = RenderTree();
        var alphaItem = cut.FindAll("li[role='treeitem']")
            .First(li => li.TextContent.Contains("Alpha"));
        var toggle = alphaItem.QuerySelector(".tv-toggle");
        Assert.NotNull(toggle);
        Assert.Equal("false", alphaItem.GetAttribute("aria-expanded"));
    }

    [Fact]
    public void CollapsedBranch_ChildrenNotInDom()
    {
        var cut = RenderTree();
        // Children of collapsed Alpha should not be in the DOM
        Assert.DoesNotContain("Alpha-1", cut.Markup);
    }

    [Fact]
    public void ClickToggle_ExpandsNode_ShowsChildren()
    {
        var cut = RenderTree();
        var toggle = cut.Find(".tv-toggle");
        toggle.Click();
        Assert.Contains("Alpha-1", cut.Markup);
        Assert.Contains("Alpha-2", cut.Markup);
    }

    [Fact]
    public void ClickToggle_CollapseExpandedNode()
    {
        var cut = RenderTree();
        var toggle = cut.Find(".tv-toggle");
        toggle.Click(); // expand
        Assert.Contains("Alpha-1", cut.Markup);
        toggle.Click(); // collapse
        Assert.DoesNotContain("Alpha-1", cut.Markup);
    }

    [Fact]
    public void DeepNesting_ExpandParentThenChild()
    {
        var cut = RenderTree();
        // Expand Alpha
        cut.Find(".tv-toggle").Click();
        // Now find Alpha-2's toggle and expand it
        var a2Item = cut.FindAll("li[role='treeitem']")
            .First(li => li.TextContent.Contains("Alpha-2"));
        a2Item.QuerySelector(".tv-toggle")!.Click();
        Assert.Contains("Alpha-2-X", cut.Markup);
    }

    [Fact]
    public void InitiallyExpanded_ExpandsMatchingNodes()
    {
        var cut = RenderTree(configure: p =>
            p.Add(x => x.InitiallyExpanded, n => n.Key == "a"));
        // Alpha should be expanded showing children
        Assert.Contains("Alpha-1", cut.Markup);
        Assert.Contains("Alpha-2", cut.Markup);
    }

    [Fact]
    public void RootUl_HasTreeRole()
    {
        var cut = RenderTree();
        Assert.NotNull(cut.Find("ul[role='tree']"));
    }

    [Fact]
    public void NodeLi_HasTreeitemRole()
    {
        var cut = RenderTree();
        var items = cut.FindAll("li[role='treeitem']");
        Assert.True(items.Count >= 2);
    }

    [Fact]
    public void ExpandedBranch_HasAriaExpandedTrue()
    {
        var cut = RenderTree(configure: p =>
            p.Add(x => x.InitiallyExpanded, n => n.Key == "a"));
        var alphaItem = cut.FindAll("li[role='treeitem']")
            .First(li => li.TextContent.Contains("Alpha"));
        Assert.Equal("true", alphaItem.GetAttribute("aria-expanded"));
    }

    [Fact]
    public void ChildGroup_HasGroupRole()
    {
        var cut = RenderTree(configure: p =>
            p.Add(x => x.InitiallyExpanded, n => n.Key == "a"));
        Assert.NotNull(cut.Find("ul[role='group']"));
    }

    [Fact]
    public void Indentation_ChildrenIndentedByIndentPx()
    {
        var cut = RenderTree(configure: p =>
        {
            p.Add(x => x.InitiallyExpanded, n => n.Key == "a");
            p.Add(x => x.IndentPx, 30);
        });
        // Alpha-1 is depth 1 → padding-left should be 30px
        var a1Row = cut.FindAll(".tv-row")
            .First(r => r.TextContent.Contains("Alpha-1"));
        Assert.Contains("padding-left: 30px", a1Row.GetAttribute("style") ?? "");
    }
}
```

**Step 2: Run tests to verify they fail**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal`
Expected: Compilation error — `TreeView` component doesn't exist yet.

**Step 3: Implement TreeView.razor**

Create: `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor`

The component should:
- Accept `@typeparam TItem` with all parameters from the API summary in the requirements doc.
- Use a `HashSet<object> _expandedKeys` for expand/collapse state.
- Render recursively: a private `RenderNode(TItem item, int depth)` method outputting `<li role="treeitem">` with toggle span (`.tv-toggle`), content area (`.tv-content`), and recursive `<ul role="group">` for children.
- Each row `<div class="tv-row">` has `style="padding-left: {depth * IndentPx}px"`.
- Branch toggle: `<span class="tv-toggle" @onclick="() => ToggleExpand(item)">` showing `+` or `−`.
- Leaf nodes: no toggle, but same left padding alignment (the content area starts at the same x as branch content — add a spacer element the same width as the toggle).
- `aria-expanded` on branch `<li>`, `role="group"` on child `<ul>`.
- When collapsed, do NOT render child `<ul>` at all (not just `display:none`).
- `EmptyContent` shown when `Items` is null or empty.
- Apply `InitiallyExpanded` in `OnParametersSet` on first render only: walk all items recursively, add matching keys to `_expandedKeys`.

**Step 4: Run tests to verify they pass**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal`
Expected: All tests PASS.

**Step 5: Commit**

```bash
git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add TreeView<TItem> component with core rendering, expand/collapse, ARIA (R1-R4, R14)"
```

---

### Task 2: Add Selection Support (R5)

**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor`
- Modify: `tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs`

**Step 1: Write the failing tests**

Add to `TreeViewTests.cs`:

```csharp
[Fact]
public void Selection_Disabled_ClickDoesNotFireCallback()
{
    object? selected = null;
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, false);
        p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
    });
    cut.Find(".tv-content").Click();
    Assert.Null(selected);
}

[Fact]
public void Selection_Enabled_ClickContentFiresCallback()
{
    object? selected = null;
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
    });
    // Click Alpha's content area
    var alphaContent = cut.FindAll(".tv-content")
        .First(c => c.TextContent.Contains("Alpha"));
    alphaContent.Click();
    Assert.Equal("a", selected);
}

[Fact]
public void Selection_ClickToggle_DoesNotChangeSelection()
{
    object? selected = null;
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
    });
    cut.Find(".tv-toggle").Click();
    Assert.Null(selected);
}

[Fact]
public void Selection_SelectedNode_HasCssClass()
{
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKey, (object)"a");
    });
    var alphaRow = cut.FindAll(".tv-row")
        .First(r => r.TextContent.Contains("Alpha"));
    Assert.Contains("bg-primary", alphaRow.ClassList);
}

[Fact]
public void Selection_CustomCssClass_Applied()
{
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKey, (object)"a");
        p.Add(x => x.SelectedCssClass, "my-highlight");
    });
    var alphaRow = cut.FindAll(".tv-row")
        .First(r => r.TextContent.Contains("Alpha"));
    Assert.Contains("my-highlight", alphaRow.ClassList);
}

[Fact]
public void Selection_AriaSelected_SetOnSelectedNode()
{
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKey, (object)"a");
    });
    var alphaItem = cut.FindAll("li[role='treeitem']")
        .First(li => li.TextContent.Contains("Alpha"));
    Assert.Equal("true", alphaItem.GetAttribute("aria-selected"));
}
```

**Step 2: Run tests to verify they fail**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.Selection" -v minimal`
Expected: FAIL

**Step 3: Implement selection in TreeView.razor**

Add to the component:
- When `Selectable` is true and the `.tv-content` area is clicked, invoke `SelectedKeyChanged` with the node's key.
- When rendering, if a node's key equals `SelectedKey`, add `SelectedCssClass` to the `.tv-row` div.
- Add `aria-selected="true"` on the selected `<li>`.
- The `.tv-toggle` click handler must NOT propagate to selection (use `@onclick:stopPropagation` or separate event handling).

**Step 4: Run tests to verify they pass**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal`
Expected: All PASS.

**Step 5: Commit**

```bash
git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add selection support to TreeView (R5)"
```

---

### Task 3: Add Session Storage Persistence (R11)

**Files:**
- Create: `src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js`
- Modify: `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor`
- Modify: `tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs`

**Step 1: Write the failing tests**

Add to `TreeViewTests.cs`:

```csharp
[Fact]
public void SessionStorage_Null_NoJsInteropCalls()
{
    var cut = RenderTree(configure: p =>
        p.Add(x => x.StorageKey, (string?)null));
    // Expand a node
    cut.Find(".tv-toggle").Click();
    // No JS interop should have been invoked
    Assert.Empty(JSInterop.Invocations);
}

[Fact]
public void SessionStorage_Set_ExpandWritesToStorage()
{
    JSInterop.SetupVoid("treeviewStorage.save", _ => true);
    JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
    var cut = RenderTree(configure: p =>
        p.Add(x => x.StorageKey, "test-tree"));
    // Expand Alpha
    cut.Find(".tv-toggle").Click();
    var saveInvocations = JSInterop.Invocations
        .Where(i => i.Identifier == "treeviewStorage.save");
    Assert.NotEmpty(saveInvocations);
}

[Fact]
public void SessionStorage_RestoresExpandedOnMount()
{
    // Pre-populate storage with Alpha expanded
    JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
        .SetResult("[\"a\"]");
    JSInterop.SetupVoid("treeviewStorage.save", _ => true);
    var cut = RenderTree(configure: p =>
        p.Add(x => x.StorageKey, "test-tree"));
    // Alpha should be expanded from storage
    Assert.Contains("Alpha-1", cut.Markup);
}

[Fact]
public void SessionStorage_TakesPrecedenceOverInitiallyExpanded()
{
    // Storage says nothing expanded, but InitiallyExpanded says expand Alpha
    JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
        .SetResult("[]");
    JSInterop.SetupVoid("treeviewStorage.save", _ => true);
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.StorageKey, "test-tree");
        p.Add(x => x.InitiallyExpanded, n => n.Key == "a");
    });
    // Storage (empty) takes precedence — Alpha should NOT be expanded
    Assert.DoesNotContain("Alpha-1", cut.Markup);
}
```

**Step 2: Run tests to verify they fail**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.SessionStorage" -v minimal`
Expected: FAIL

**Step 3: Create JS interop file**

Create: `src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js`

```javascript
window.treeviewStorage = {
    save: function (storageKey, keysJson) {
        sessionStorage.setItem("treeview:" + storageKey, keysJson);
    },
    load: function (storageKey) {
        return sessionStorage.getItem("treeview:" + storageKey);
    }
};
```

Add a `<script>` reference in the app's `_Host.cshtml` or `App.razor` layout (wherever other scripts are included):
```html
<script src="js/treeview-storage.js"></script>
```

**Step 4: Implement persistence in TreeView.razor**

- Inject `[Inject] private IJSRuntime JSRuntime { get; set; } = default!;`
- In `OnAfterRenderAsync(bool firstRender)`: if `firstRender && StorageKey != null`, call `JSRuntime.InvokeAsync<string?>("treeviewStorage.load", StorageKey)`. If result is not null, deserialize JSON array of keys into `_expandedKeys` and call `StateHasChanged()`. Set a `_storageLoaded = true` flag.
- Only apply `InitiallyExpanded` when `StorageKey` is null OR storage returned null (no prior state).
- On every `ToggleExpand`, if `StorageKey != null`, serialize `_expandedKeys` to JSON and call `JSRuntime.InvokeVoidAsync("treeviewStorage.save", StorageKey, json)`.

**Step 5: Run tests to verify they pass**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal`
Expected: All PASS.

**Step 6: Commit**

```bash
git add src/ScadaLink.CentralUI/wwwroot/js/treeview-storage.js src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add sessionStorage persistence for TreeView expansion state (R11)"
```

---

### Task 4: Add ExpandAll, CollapseAll, RevealNode (R12, R13)

**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor`
- Modify: `tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs`

**Step 1: Write the failing tests**

Add to `TreeViewTests.cs`:

```csharp
[Fact]
public void ExpandAll_ExpandsAllBranches()
{
    var cut = RenderTree();
    cut.Instance.ExpandAll();
    cut.Render();
    // All nested content should be visible
    Assert.Contains("Alpha-1", cut.Markup);
    Assert.Contains("Alpha-2", cut.Markup);
    Assert.Contains("Alpha-2-X", cut.Markup);
}

[Fact]
public void CollapseAll_CollapsesAllBranches()
{
    var cut = RenderTree(configure: p =>
        p.Add(x => x.InitiallyExpanded, _ => true));
    Assert.Contains("Alpha-1", cut.Markup);
    cut.Instance.CollapseAll();
    cut.Render();
    Assert.DoesNotContain("Alpha-1", cut.Markup);
}

[Fact]
public void RevealNode_ExpandsAncestors()
{
    var cut = RenderTree();
    // Alpha-2-X is at depth 2 — both Alpha and Alpha-2 need expanding
    cut.Instance.RevealNode("a2x");
    cut.Render();
    Assert.Contains("Alpha-2-X", cut.Markup);
    // Intermediate nodes should also be visible
    Assert.Contains("Alpha-2", cut.Markup);
}

[Fact]
public void RevealNode_WithSelect_SelectsNode()
{
    object? selected = null;
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
    });
    cut.Instance.RevealNode("a2x", select: true);
    cut.Render();
    Assert.Equal("a2x", selected);
}

[Fact]
public void RevealNode_UnknownKey_NoOp()
{
    var cut = RenderTree();
    // Should not throw
    cut.Instance.RevealNode("nonexistent");
    cut.Render();
    // Alpha still collapsed
    Assert.DoesNotContain("Alpha-1", cut.Markup);
}
```

**Step 2: Run tests to verify they fail**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.ExpandAll|CollapseAll|RevealNode" -v minimal`
Expected: FAIL (methods don't exist)

**Step 3: Implement the public methods**

In `TreeView.razor`:

- `ExpandAll()`: Walk the entire tree recursively starting from `Items`. For each node where `HasChildrenSelector` returns true, add its key to `_expandedKeys`. Call `PersistExpandedState()` and `StateHasChanged()`.
- `CollapseAll()`: Clear `_expandedKeys`. Call `PersistExpandedState()` and `StateHasChanged()`.
- `RevealNode(object key, bool select = false)`: Build a parent lookup dictionary by walking the full tree (key → parent key). Starting from the target key, walk up through parents, adding each ancestor key to `_expandedKeys`. If `select` is true, set `SelectedKey` and invoke `SelectedKeyChanged`. Call `PersistExpandedState()` and `StateHasChanged()`. If key not found in the tree, return silently.
- Extract `PersistExpandedState()` private method that serializes and writes to sessionStorage if `StorageKey` is set.

**Step 4: Run tests to verify they pass**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal`
Expected: All PASS.

**Step 5: Commit**

```bash
git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add ExpandAll, CollapseAll, RevealNode to TreeView (R12, R13)"
```

---

### Task 5: Add Context Menu (R15)

**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor`
- Modify: `tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs`

**Step 1: Write the failing tests**

Add to `TreeViewTests.cs`:

```csharp
[Fact]
public void ContextMenu_Null_NoMenuRendered()
{
    var cut = RenderTree();
    // Right-click Alpha — no context menu should appear
    var alphaRow = cut.FindAll(".tv-row")
        .First(r => r.TextContent.Contains("Alpha"));
    alphaRow.ContextMenu();
    Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find(".dropdown-menu"));
}

[Fact]
public void ContextMenu_RightClickShowsMenu()
{
    var cut = RenderTree(configure: p =>
        p.Add(x => x.ContextMenu, (TestNode node) =>
            $"<button class='dropdown-item'>Action for {node.Label}</button>"));
    var alphaRow = cut.FindAll(".tv-row")
        .First(r => r.TextContent.Contains("Alpha"));
    alphaRow.ContextMenu();
    var menu = cut.Find(".dropdown-menu");
    Assert.Contains("Action for Alpha", menu.TextContent);
}

[Fact]
public void ContextMenu_EmptyFragment_NoMenu()
{
    // ContextMenu renders nothing for leaves
    var cut = RenderTree(configure: p =>
        p.Add(x => x.ContextMenu, (TestNode node) =>
            node.Children.Count > 0 ? $"<button class='dropdown-item'>Edit</button>" : ""));
    // Right-click Beta (leaf) — fragment renders empty
    var betaRow = cut.FindAll(".tv-row")
        .First(r => r.TextContent.Contains("Beta"));
    betaRow.ContextMenu();
    Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find(".dropdown-menu"));
}

[Fact]
public void ContextMenu_RightClickDifferentNode_ReplacesMenu()
{
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.InitiallyExpanded, n => n.Key == "a");
        p.Add(x => x.ContextMenu, (TestNode node) =>
            $"<button class='dropdown-item'>{node.Label}</button>");
    });
    // Right-click Alpha
    cut.FindAll(".tv-row").First(r => r.TextContent.Contains("Alpha")).ContextMenu();
    Assert.Contains("Alpha", cut.Find(".dropdown-menu").TextContent);
    // Right-click Alpha-1
    cut.FindAll(".tv-row").First(r => r.TextContent.Contains("Alpha-1")).ContextMenu();
    var menus = cut.FindAll(".dropdown-menu");
    Assert.Single(menus);
    Assert.Contains("Alpha-1", menus[0].TextContent);
}
```

**Step 2: Run tests to verify they fail**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.ContextMenu" -v minimal`
Expected: FAIL

**Step 3: Implement context menu**

In `TreeView.razor`:

- Add `[Parameter] public RenderFragment<TItem>? ContextMenu { get; set; }`
- Add state: `private TItem? _contextMenuItem`, `private double _contextMenuX`, `private double _contextMenuY`, `private bool _showContextMenu`
- On the `.tv-row` div, add `@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="_showContextMenu"`.
- `OnContextMenu(MouseEventArgs e, TItem item)`: set `_contextMenuItem = item`, `_contextMenuX = e.ClientX`, `_contextMenuY = e.ClientY`, `_showContextMenu = true`.
- Render the menu: if `_showContextMenu && _contextMenuItem != null && ContextMenu != null`, render a `<div class="dropdown-menu show" style="position:fixed; top:{_contextMenuY}px; left:{_contextMenuX}px; z-index:1050;">` containing `@ContextMenu(_contextMenuItem)`.
- Handle empty fragment: after rendering, if the dropdown-menu has no child elements, hide it. Alternatively, always render and use CSS or check for content. The simplest approach: always render the container, and the test for "empty" checks whether any `.dropdown-item` children exist. If not, suppress `preventDefault` so browser default menu shows.
- Dismiss: add a `@onclick` handler on a full-page overlay `<div>` (transparent, z-index below the menu) that sets `_showContextMenu = false`. Also handle Escape via `@onkeydown`.
- Clicking a menu item: the consumer's `@onclick` handler runs, then the overlay catch dismisses the menu on the next render.

**Step 4: Run tests to verify they pass**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal`
Expected: All PASS.

**Step 5: Commit**

```bash
git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "feat(ui): add right-click context menu to TreeView (R15)"
```

---

### Task 6: Add External Filtering Tests (R8)

**Files:**
- Modify: `tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs`

This task adds tests for filtering behavior that should already work from the core implementation (the component just renders whatever `Items` it receives). These tests verify that expansion state is preserved across re-renders with different items.

**Step 1: Write the tests**

Add to `TreeViewTests.cs`:

```csharp
[Fact]
public void Filtering_ReducedItems_HidesRemovedRoots()
{
    var cut = RenderTree();
    Assert.Contains("Alpha", cut.Markup);
    Assert.Contains("Beta", cut.Markup);
    // Re-render with only Alpha
    cut.SetParametersAndRender(p =>
        p.Add(x => x.Items, SimpleRoots().Where(n => n.Key == "a").ToList()));
    Assert.Contains("Alpha", cut.Markup);
    Assert.DoesNotContain("Beta", cut.Markup);
}

[Fact]
public void Filtering_ExpansionStatePreserved()
{
    var cut = RenderTree();
    // Expand Alpha
    cut.Find(".tv-toggle").Click();
    Assert.Contains("Alpha-1", cut.Markup);
    // Filter to only Alpha
    cut.SetParametersAndRender(p =>
        p.Add(x => x.Items, SimpleRoots().Where(n => n.Key == "a").ToList()));
    // Alpha should still be expanded
    Assert.Contains("Alpha-1", cut.Markup);
    // Restore full list — Alpha still expanded
    cut.SetParametersAndRender(p =>
        p.Add(x => x.Items, SimpleRoots()));
    Assert.Contains("Alpha-1", cut.Markup);
}

[Fact]
public void Filtering_SelectionCleared_WhenNodeDisappears()
{
    object? selected = "b"; // Beta selected
    var cut = RenderTree(configure: p =>
    {
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKey, (object)"b");
        p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
    });
    // Filter to only Alpha — Beta disappears
    cut.SetParametersAndRender(p =>
    {
        p.Add(x => x.Items, SimpleRoots().Where(n => n.Key == "a").ToList());
        p.Add(x => x.Selectable, true);
        p.Add(x => x.SelectedKey, (object)"b");
        p.Add(x => x.SelectedKeyChanged, (object? key) => selected = key);
    });
    // Selection should be cleared
    Assert.Null(selected);
}
```

**Step 2: Run tests**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests.Filtering" -v minimal`
Expected: PASS (behavior comes from core implementation). If any fail, fix in TreeView.razor.

**Step 3: Commit**

```bash
git add tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs
git commit -m "test(ui): add external filtering tests for TreeView (R8)"
```

---

### Task 7: Integrate TreeView into Data Connections Page

**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor`

This is the simplest integration (two-level tree, no recursion).

**Step 1: Read the current file**

Read: `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor`

**Step 2: Implement the changes**

The page currently has a flat table. Replace it with a TreeView:

- Add a `TreeNode` record and `NodeKind` enum in the `@code` block:
  ```csharp
  record TreeNode(string Key, string Label, NodeKind Kind, List<TreeNode> Children,
      DataConnection? Connection = null, int? SiteId = null);
  enum NodeKind { Site, DataConnection }
  ```

- Add `_treeRoots` field built in `LoadDataAsync()`:
  ```csharp
  private List<TreeNode> _treeRoots = new();
  ```

- In `LoadDataAsync()`, after loading sites and connections, build the tree:
  ```csharp
  var connBySite = _connections.GroupBy(c => c.SiteId).ToDictionary(g => g.Key, g => g.ToList());
  _treeRoots = _sites.Select(site => new TreeNode(
      Key: $"site-{site.Id}",
      Label: site.Name,
      Kind: NodeKind.Site,
      Children: (connBySite.GetValueOrDefault(site.Id) ?? new())
          .Select(c => new TreeNode(
              Key: $"conn-{c.Id}",
              Label: c.Name,
              Kind: NodeKind.DataConnection,
              Children: new(),
              Connection: c))
          .ToList(),
      SiteId: site.Id
  )).ToList();
  ```

- Replace the `<table>` with:
  ```razor
  <TreeView TItem="TreeNode" Items="_treeRoots"
            ChildrenSelector="n => n.Children"
            HasChildrenSelector="n => n.Children.Count > 0"
            KeySelector="n => n.Key"
            StorageKey="data-connections-tree">
      <NodeContent Context="node">
          @if (node.Kind == NodeKind.Site)
          {
              <span class="fw-semibold">@node.Label</span>
              <span class="badge bg-secondary ms-1">@node.Children.Count</span>
          }
          else
          {
              <span>@node.Label</span>
              <span class="badge bg-info ms-2">@node.Connection!.Protocol</span>
          }
      </NodeContent>
      <ContextMenu Context="node">
          @if (node.Kind == NodeKind.DataConnection)
          {
              <button class="dropdown-item"
                      @onclick='() => NavigationManager.NavigateTo($"/admin/data-connections/{node.Connection!.Id}/edit")'>
                  Edit
              </button>
              <div class="dropdown-divider"></div>
              <button class="dropdown-item text-danger"
                      @onclick="() => DeleteConnection(node.Connection!)">
                  Delete
              </button>
          }
      </ContextMenu>
      <EmptyContent>
          <span class="text-muted fst-italic">No data connections configured.</span>
      </EmptyContent>
  </TreeView>
  ```

- Remove the old `<table>` / `<thead>` / `<tbody>` markup and inline Edit/Delete buttons.
- Keep `_toast`, `_confirmDialog`, the "Create" button header, loading/error handling, and the `DeleteConnection` method.

**Step 3: Build and verify**

Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
Expected: Build succeeds.

**Step 4: Commit**

```bash
git add src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor
git commit -m "refactor(ui): replace data connections table with TreeView grouped by site"
```

---

### Task 8: Integrate TreeView into Areas Page

**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor`

**Step 1: Read the current file**

Read: `src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor`

**Step 2: Implement the changes**

The Areas page has a two-panel layout. Keep the left site list panel. Replace the right panel's custom flat-tree rendering with a TreeView using the `Area` entity directly as `TItem`.

- Replace the manual tree rendering block (the `@foreach (var node in _flatTree)` loop with indentation) with:
  ```razor
  <TreeView TItem="Area" Items="_rootAreas"
            ChildrenSelector="a => a.Children.ToList()"
            HasChildrenSelector="a => a.Children.Any()"
            KeySelector="a => (object)a.Id"
            StorageKey="areas-tree">
      <NodeContent Context="area">
          <span>@area.Name</span>
      </NodeContent>
      <ContextMenu Context="area">
          <button class="dropdown-item" @onclick="() => EditArea(area)">
              Edit
          </button>
          <div class="dropdown-divider"></div>
          <button class="dropdown-item text-danger" @onclick="() => DeleteArea(area)">
              Delete
          </button>
      </ContextMenu>
      <EmptyContent>
          <span class="text-muted fst-italic">No areas for this site. Add one above.</span>
      </EmptyContent>
  </TreeView>
  ```

- Add `_rootAreas` field computed from `_areas`:
  ```csharp
  private List<Area> _rootAreas => _areas.Where(a => a.ParentAreaId == null).ToList();
  ```

- Remove: `BuildFlatTree()`, `AddChildren()`, the `AreaTreeNode` record, `_flatTree` field, and all manual indentation CSS.
- Keep: site list panel, add/edit form, `LoadAreasAsync()`, `SaveArea()`, `DeleteArea()`, `GetAreaPath()`.

**Step 3: Build and verify**

Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
Expected: Build succeeds.

**Step 4: Commit**

```bash
git add src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor
git commit -m "refactor(ui): replace manual area tree rendering with TreeView component"
```

---

### Task 9: Integrate TreeView into Instances Page

**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor`

This is the most complex integration — deep hierarchy with filtering and context menu.

**Step 1: Read the current file**

Read: `src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor`

**Step 2: Add tree model and builder**

Add to `@code` block:

```csharp
record InstanceTreeNode(string Key, string Label, InstanceNodeKind Kind,
    List<InstanceTreeNode> Children, Instance? Instance = null,
    bool IsStale = false);
enum InstanceNodeKind { Site, Area, Instance }

private List<InstanceTreeNode> _treeRoots = new();

private void BuildTree()
{
    // Build the hierarchy: Site → Area (recursive) → Instance
    _treeRoots = _sites.Select(site =>
    {
        var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
        var siteInstances = _filteredInstances.Where(i => i.SiteId == site.Id).ToList();

        var areaNodes = BuildAreaNodes(siteAreas, siteInstances, parentId: null);

        // Instances with no area attach directly under the site
        var unassigned = siteInstances
            .Where(i => i.AreaId == null)
            .Select(MakeInstanceNode)
            .ToList();

        var children = areaNodes.Concat(unassigned).ToList();

        return new InstanceTreeNode(
            Key: $"site-{site.Id}",
            Label: site.Name,
            Kind: InstanceNodeKind.Site,
            Children: children);
    })
    .Where(s => s.Children.Count > 0) // Prune empty sites
    .ToList();
}

private List<InstanceTreeNode> BuildAreaNodes(List<Area> allAreas,
    List<Instance> instances, int? parentId)
{
    return allAreas
        .Where(a => a.ParentAreaId == parentId)
        .Select(area =>
        {
            var childAreas = BuildAreaNodes(allAreas, instances, area.Id);
            var areaInstances = instances
                .Where(i => i.AreaId == area.Id)
                .Select(MakeInstanceNode)
                .ToList();
            var children = childAreas.Concat(areaInstances).ToList();
            return new InstanceTreeNode(
                Key: $"area-{area.Id}",
                Label: area.Name,
                Kind: InstanceNodeKind.Area,
                Children: children);
        })
        .Where(a => a.Children.Count > 0) // Prune empty areas
        .ToList();
}

private InstanceTreeNode MakeInstanceNode(Instance inst) => new(
    Key: $"inst-{inst.Id}",
    Label: inst.UniqueName,
    Kind: InstanceNodeKind.Instance,
    Children: new(),
    Instance: inst,
    IsStale: _stalenessMap.GetValueOrDefault(inst.Id));
```

**Step 3: Update ApplyFilters to build tree**

In `ApplyFilters()`, after the existing filtering logic, replace `UpdatePage()` with `BuildTree()`. Remove `_totalPages`, `_currentPage`, `_pagedInstances`, `GoToPage()`, `UpdatePage()`, and `PageSize`.

**Step 4: Replace table with TreeView**

Replace the `<table>` and pagination markup with:

```razor
<TreeView @ref="_instanceTree" TItem="InstanceTreeNode" Items="_treeRoots"
          ChildrenSelector="n => n.Children"
          HasChildrenSelector="n => n.Children.Count > 0"
          KeySelector="n => n.Key"
          StorageKey="instances-tree"
          Selectable="true"
          SelectedKey="_selectedKey"
          SelectedKeyChanged="key => { _selectedKey = key; }">
    <NodeContent Context="node">
        @switch (node.Kind)
        {
            case InstanceNodeKind.Site:
                <span class="fw-semibold">@node.Label</span>
                break;
            case InstanceNodeKind.Area:
                <span class="text-secondary">@node.Label</span>
                break;
            case InstanceNodeKind.Instance:
                <span>@node.Label</span>
                <span class="badge @GetStateBadge(node.Instance!.State) ms-1">@node.Instance!.State</span>
                @if (node.Instance!.State != InstanceState.NotDeployed)
                {
                    <span class="badge @(node.IsStale ? "bg-warning text-dark" : "bg-light text-dark") ms-1">
                        @(node.IsStale ? "Stale" : "Current")
                    </span>
                }
                break;
        }
    </NodeContent>
    <ContextMenu Context="node">
        @if (node.Kind == InstanceNodeKind.Instance)
        {
            var inst = node.Instance!;
            var isStale = node.IsStale;
            <button class="dropdown-item" @onclick="() => DeployInstance(inst)"
                    disabled="@_actionInProgress">@(isStale ? "Redeploy" : "Deploy")</button>
            @if (inst.State == InstanceState.Enabled)
            {
                <button class="dropdown-item" @onclick="() => DisableInstance(inst)"
                        disabled="@_actionInProgress">Disable</button>
            }
            else if (inst.State == InstanceState.Disabled)
            {
                <button class="dropdown-item" @onclick="() => EnableInstance(inst)"
                        disabled="@_actionInProgress">Enable</button>
            }
            <button class="dropdown-item"
                    @onclick='() => NavigationManager.NavigateTo($"/deployment/instances/{inst.Id}/configure")'>
                Configure
            </button>
            <button class="dropdown-item" @onclick="() => ShowDiff(inst)"
                    disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
            <div class="dropdown-divider"></div>
            <button class="dropdown-item text-danger" @onclick="() => DeleteInstance(inst)"
                    disabled="@_actionInProgress">Delete</button>
        }
    </ContextMenu>
    <EmptyContent>
        <span class="text-muted fst-italic">No instances match the current filters.</span>
    </EmptyContent>
</TreeView>

<div class="text-muted small mt-2">
    @_filteredInstances.Count instance(s) total
</div>
```

Add fields:
```csharp
private TreeView<InstanceTreeNode> _instanceTree = default!;
private object? _selectedKey;
```

**Step 5: Remove old table/pagination code**

Remove from `@code`:
- `_pagedInstances`, `_currentPage`, `_totalPages`, `PageSize`
- `GoToPage()`, `UpdatePage()`
- Keep: `_allInstances`, `_filteredInstances`, `_allAreas`, `_sites`, `_templates`, `_stalenessMap`, `ApplyFilters()` (modified), all action methods, diff modal

**Step 6: Build and verify**

Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
Expected: Build succeeds.

**Step 7: Commit**

```bash
git add src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor
git commit -m "refactor(ui): replace instances table with hierarchical TreeView (Site → Area → Instance)"
```

---

### Task 10: Full Build Verification

**Files:**
- No changes — verification only.

**Step 1: Build entire solution**

Run: `dotnet build ScadaLink.slnx`
Expected: Build succeeds with 0 errors.

**Step 2: Run all tests**

Run: `dotnet test ScadaLink.slnx -v minimal`
Expected: All tests pass (including the new TreeView tests and existing tests).

**Step 3: Run TreeView tests specifically**

Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter "FullyQualifiedName~TreeViewTests" -v minimal`
Expected: All TreeView tests pass.

**Step 4: Commit (if any fixes needed)**

Only if build/test failures required fixes:
```bash
git add -A
git commit -m "fix(ui): resolve build/test issues from TreeView integration"
```
