Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor
T
Joseph Doherty aef9ef8452 feat(adminui): B2 Wave-B integration — wire real /raw modals + dialogs + refresh
Replace RawTree's 22 stub handlers with the real Wave-B modals and 3 new
small dialogs, plus post-mutation subtree refresh:

- New dialogs: RawNameDialog (create/rename, RawPaths.ValidateSegment inline),
  RawConfirmDialog (deletes), RawDriverTypeDialog (New driver type+name picker,
  incl. Calculation).
- Configure driver/device -> DriverConfigModal/DeviceModal (edit); New device ->
  DeviceModal (create); Edit tag -> RawTagModal; Manual entry / CSV import/export
  -> the Raw*Modal surfaces; New folder/tag-group/group + New driver + Rename +
  Delete + Toggle -> IRawTreeService directly via the dialogs.
- Refresh: reload the container after create-under, the parent after item-level
  ops (parent found by searching the loaded tree; robust, non-throwing).
- Rename warnings + blocked-delete errors surfaced via the repurposed RawStubModal
  message surface. Browse device stays a placeholder (Wave C). Driver-level
  'Test connect' menu item removed (test-connect now lives on the device modal).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 03:38:32 -04:00

779 lines
30 KiB
Plaintext

@* Recursive, lazily-expanding renderer for the v3 Raw project tree (/raw).
Unlike UnsTree (which eager-loads the whole structural tree and never touches
the RawNode lazy fields), THIS component actually exercises the lazy plumbing:
expanding an unloaded container calls IRawTreeService.LoadChildrenAsync, appends
the returned level into node.Children and flips Loaded/Loading, showing a spinner
in flight and an error row on failure. Per-node context menus (right-click surface
+ "⋯" fallback) are built per RawNodeKind.
Wave-B integration (this pass): every named handler now opens the REAL modal/dialog
(Configure driver/device, Edit/Manual/CSV tag surfaces) or calls IRawTreeService
directly (New folder/driver/group, Rename, Delete, Toggle) via the small dialogs
built alongside — RawNameDialog / RawConfirmDialog / RawDriverTypeDialog — and
refreshes the affected subtree afterwards (reload the container for create-under
ops, the parent for item-level ops). Only Browse device stays a placeholder (Wave C
owns it). The former RawStubModal is repurposed here as the shared message surface
for delete-blockers, rename warnings, and the Browse "coming soon" note. This
component is a single instance that recurses via the RenderNode RenderFragment, so it
owns one set of modals + one injected service for the whole tree. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@inject IRawTreeService Svc
@foreach (var root in Roots)
{
@RenderNode(root, 0)
}
@* --- Real modals + dialogs (one instance each for the whole recursive tree) --- *@
<DriverConfigModal @bind-Visible="_driverCfgVisible" DriverInstanceId="@_driverCfgId" OnSaved="DriverCfgSaved" />
<DeviceModal @bind-Visible="_deviceVisible" DeviceId="@_deviceEditId"
DriverInstanceId="@_deviceCreateDriverId" DriverType="@_deviceDriverType" OnSaved="DeviceSaved" />
<RawTagModal @bind-Visible="_tagVisible" TagId="@_tagId" DriverType="@_tagDriverType" OnSaved="TagSaved" />
<RawManualTagEntryModal @bind-Visible="_manualVisible" DeviceId="@_manualDeviceId"
TagGroupId="@_manualTagGroupId" DriverType="@_manualDriverType" OnSaved="ManualSaved" />
<RawCsvImportModal @bind-Visible="_csvImportVisible" DeviceId="@_csvImportDeviceId"
TagGroupId="@_csvImportTagGroupId" DriverType="@_csvImportDriverType" OnSaved="CsvImportSaved" />
<RawCsvExportModal @bind-Visible="_csvExportVisible" DeviceId="@_csvExportDeviceId"
TagGroupId="@_csvExportTagGroupId" DriverType="@_csvExportDriverType" />
<RawNameDialog @bind-Visible="_nameVisible" Title="@_nameTitle" Value="@_nameInitial" OnSubmit="NameSubmitted" />
<RawConfirmDialog @bind-Visible="_confirmVisible" Title="@_confirmTitle" Message="@_confirmMessage"
OnConfirm="Confirmed" />
<RawDriverTypeDialog @bind-Visible="_driverTypeVisible" OnSubmit="DriverTypeSubmitted" />
@* Shared message surface (repurposed RawStubModal) — delete blockers, rename warnings, Browse note. *@
<RawStubModal Visible="_msgVisible" Title="@_msgTitle" Message="@_msgMessage"
NodeName="@_msgNodeName" OnClose="CloseMessage" />
@code {
/// <summary>The top-level Raw nodes to render (the enterprise roots from <c>LoadRootsAsync</c>).</summary>
[Parameter, EditorRequired] public IReadOnlyList<RawNode> Roots { get; set; } = default!;
/// <summary>
/// Optional safety valve: raised when a mutation's affected node has no in-tree parent to reload
/// (only reachable for a cluster-less top-level node). Every menu-bearing node's parent is in the
/// tree today, so this is normally a no-op; the page may wire it to reload roots if that ever changes.
/// </summary>
[Parameter] public EventCallback OnRootsChanged { get; set; }
// --- Configure-driver modal (edit) ---
private bool _driverCfgVisible;
private string? _driverCfgId;
private RawNode? _driverCfgNode;
// --- Device modal (create + edit) ---
private bool _deviceVisible;
private string? _deviceEditId;
private string? _deviceCreateDriverId;
private string? _deviceDriverType;
private RawNode? _deviceNode;
private bool _deviceIsCreate;
// --- Edit-tag modal ---
private bool _tagVisible;
private string? _tagId;
private string? _tagDriverType;
private RawNode? _tagNode;
// --- Manual tag-entry modal ---
private bool _manualVisible;
private string _manualDeviceId = "";
private string? _manualTagGroupId;
private string _manualDriverType = "";
private RawNode? _manualNode;
// --- CSV import modal ---
private bool _csvImportVisible;
private string _csvImportDeviceId = "";
private string? _csvImportTagGroupId;
private string _csvImportDriverType = "";
private RawNode? _csvImportNode;
// --- CSV export modal (read-only, no refresh) ---
private bool _csvExportVisible;
private string _csvExportDeviceId = "";
private string? _csvExportTagGroupId;
private string _csvExportDriverType = "";
// --- Name dialog (New folder/group, Rename) ---
private bool _nameVisible;
private string _nameTitle = "";
private string _nameInitial = "";
private Func<string, Task>? _nameSubmit;
// --- Confirm dialog (deletes) ---
private bool _confirmVisible;
private string _confirmTitle = "";
private string _confirmMessage = "";
private Func<Task>? _confirmAction;
// --- Driver-type dialog (New driver) ---
private bool _driverTypeVisible;
private Func<(string DriverType, string Name), Task>? _driverTypeSubmit;
// --- Shared message surface (repurposed RawStubModal): delete blockers, rename warnings, Browse note ---
private bool _msgVisible;
private string _msgTitle = "";
private string _msgMessage = "";
private string? _msgNodeName;
// ---------------------------------------------------------------------------
// Row rendering
// ---------------------------------------------------------------------------
private RenderFragment RenderNode(RawNode node, int depth) => __builder =>
{
var indent = $"padding-left:{depth * 18}px";
var hasExpander = node.HasLazyChildren || node.Children.Count > 0;
var muted = node.Enabled == false && node.Kind is RawNodeKind.Driver or RawNodeKind.Device;
var items = MenuFor(node);
<div @key="node.Key" class="d-flex align-items-center gap-1 py-1" style="@indent">
@if (hasExpander)
{
<button type="button" class="btn btn-sm btn-link p-0" style="width:18px"
@onclick="@(() => ToggleAsync(node))">
@(node.Expanded ? "▼" : "▶")
</button>
}
else
{
<span style="width:18px"></span>
}
@if (items.Count > 0)
{
<ContextMenu Items="items" ShowEllipsis="true">
@RenderRowBody(node, muted)
</ContextMenu>
}
else
{
@RenderRowBody(node, muted)
}
</div>
@if (node.Expanded && node.Loading)
{
<div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
<span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
</div>
}
else if (node.Expanded && node.Error is not null)
{
<div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">
@node.Error
</div>
}
else if (node.Expanded)
{
@foreach (var child in node.Children)
{
@RenderNode(child, depth + 1)
}
}
};
// The icon + label + badge shown for a node — the ContextMenu right-click surface.
private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder =>
{
<span class="d-inline-flex align-items-center gap-1">
<span aria-hidden="true">@KindIcon(node.Kind)</span>
<span class="mono small @(muted ? "text-muted" : "")">@node.DisplayName</span>
@if (muted)
{
<span class="chip chip-idle ms-1" style="font-size:0.65rem">disabled</span>
}
@if (node.ChildCount > 0)
{
<span class="chip chip-idle ms-1" style="font-size:0.7rem">@node.ChildCount</span>
}
</span>
};
private static string KindIcon(RawNodeKind kind) => kind switch
{
RawNodeKind.Enterprise => "🏢",
RawNodeKind.Cluster => "🖥️",
RawNodeKind.Folder => "📁",
RawNodeKind.Driver => "🔌",
RawNodeKind.Device => "📟",
RawNodeKind.TagGroup => "🗂️",
RawNodeKind.Tag => "🏷️",
_ => "•",
};
// ---------------------------------------------------------------------------
// Lazy expansion — the whole point of WP2 (UNS never did this).
// ---------------------------------------------------------------------------
/// <summary>
/// Toggles a node's expansion. On first expand of an unloaded container this sets
/// <c>Loading</c>, awaits <see cref="IRawTreeService.LoadChildrenAsync"/>, appends the
/// returned level into <c>Children</c>, flips <c>Loaded</c>, and re-renders — showing a
/// spinner in flight and an error row on failure. A leaf (Tag) has no expander so never
/// reaches here.
/// </summary>
private async Task ToggleAsync(RawNode node)
{
if (node.Loading) { return; }
node.Expanded = !node.Expanded;
if (node.Expanded && node.HasLazyChildren && !node.Loaded)
{
node.Loading = true;
node.Error = null;
StateHasChanged();
try
{
var children = await Svc.LoadChildrenAsync(node);
node.Children.Clear();
node.Children.AddRange(children);
node.ChildCount = node.Children.Count;
node.Loaded = true;
}
catch (Exception ex)
{
node.Error = ex.Message;
}
finally
{
node.Loading = false;
}
StateHasChanged();
}
}
// ---------------------------------------------------------------------------
// Per-kind context menus. Every OnClick opens a placeholder in this wave; the
// named handlers below are the seam Wave B/C replaces with the real modals.
// ---------------------------------------------------------------------------
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node) => node.Kind switch
{
RawNodeKind.Cluster => ClusterMenu(node),
RawNodeKind.Folder => FolderMenu(node),
RawNodeKind.Driver => DriverMenu(node),
RawNodeKind.Device => DeviceMenu(node),
RawNodeKind.TagGroup => TagGroupMenu(node),
RawNodeKind.Tag => TagMenu(node),
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
};
private List<ContextMenuItem> ClusterMenu(RawNode node) => new()
{
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) },
};
private List<ContextMenuItem> FolderMenu(RawNode node) => new()
{
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) },
ContextMenuItem.Separator(),
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameFolder(node) },
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteFolder(node) },
};
private List<ContextMenuItem> DriverMenu(RawNode node) => new()
{
new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDriver(node) },
new() { Label = "New device", Icon = "📟", OnClick = () => OnNewDevice(node) },
ContextMenuItem.Separator(),
new() { Label = node.Enabled ? "Disable" : "Enable", Icon = node.Enabled ? "⏸" : "▶",
OnClick = () => OnToggleDriverEnabled(node) },
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameDriver(node) },
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDriver(node) },
};
private List<ContextMenuItem> DeviceMenu(RawNode node) => new()
{
new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDevice(node) },
new() { Label = "New tag-group", Icon = "🗂️", OnClick = () => OnNewTagGroup(node) },
ContextMenuItem.Separator(),
// "Add tags ▸" flattened — ContextMenu is single-level, so the submenu is a labelled group.
new() { Label = "Add tags", Disabled = true },
new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) },
new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) },
new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) },
new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) },
ContextMenuItem.Separator(),
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDevice(node) },
};
private List<ContextMenuItem> TagGroupMenu(RawNode node) => new()
{
new() { Label = "New group", Icon = "🗂️", OnClick = () => OnNewGroup(node) },
ContextMenuItem.Separator(),
new() { Label = "Add tags", Disabled = true },
new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) },
new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) },
new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) },
new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) },
ContextMenuItem.Separator(),
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameTagGroup(node) },
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTagGroup(node) },
};
private List<ContextMenuItem> TagMenu(RawNode node) => new()
{
new() { Label = "Edit", Icon = "✏️", OnClick = () => OnEditTag(node) },
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTag(node) },
};
// ---------------------------------------------------------------------------
// Named action handlers — each opens the real modal/dialog or calls the service
// directly, then refreshes the affected subtree. Only OnBrowseDevice is a
// placeholder (Wave C owns device browse).
// ---------------------------------------------------------------------------
// --- Folder / Cluster ---
// New folder under a Cluster (parentFolderId=null) or a Folder (parentFolderId=this folder).
private Task OnNewFolder(RawNode node)
{
var clusterId = node.Kind == RawNodeKind.Cluster ? (node.ClusterId ?? node.EntityId) : node.ClusterId;
var parentFolderId = node.Kind == RawNodeKind.Folder ? node.EntityId : null;
ShowNameDialog("New folder", "", async name =>
{
var res = await Svc.CreateFolderAsync(clusterId ?? "", parentFolderId, name);
if (!res.Ok) { ShowMessage("New folder failed", res.Error ?? "Create failed.", node.DisplayName); return; }
await ReloadNodeAsync(node);
});
return Task.CompletedTask;
}
// New driver: pick type + name, then create a minimal driver ("{}") — configure it afterward.
private Task OnNewDriver(RawNode node)
{
var clusterId = node.Kind == RawNodeKind.Cluster ? (node.ClusterId ?? node.EntityId) : node.ClusterId;
var folderId = node.Kind == RawNodeKind.Folder ? node.EntityId : null;
ShowDriverTypeDialog(async choice =>
{
var res = await Svc.CreateDriverAsync(clusterId ?? "", folderId, choice.Name, choice.DriverType, "{}");
if (!res.Ok) { ShowMessage("New driver failed", res.Error ?? "Create failed.", node.DisplayName); return; }
await ReloadNodeAsync(node);
});
return Task.CompletedTask;
}
private Task OnRenameFolder(RawNode node)
{
ShowNameDialog("Rename folder", node.DisplayName, async name =>
await AfterRename(await Svc.RenameFolderAsync(node.EntityId!, name, node.RowVersion), node));
return Task.CompletedTask;
}
private Task OnDeleteFolder(RawNode node)
{
ShowConfirm("Delete folder", $"Delete folder “{node.DisplayName}”?", async () =>
await AfterDelete(await Svc.DeleteFolderAsync(node.EntityId!, node.RowVersion), node));
return Task.CompletedTask;
}
// --- Driver ---
private Task OnConfigureDriver(RawNode node)
{
_driverCfgId = node.EntityId;
_driverCfgNode = node;
_driverCfgVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
private Task OnNewDevice(RawNode node)
{
_deviceIsCreate = true;
_deviceEditId = null;
_deviceCreateDriverId = node.EntityId;
_deviceDriverType = node.DriverType;
_deviceNode = node;
_deviceVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
private async Task OnToggleDriverEnabled(RawNode node)
{
var res = await Svc.SetDriverEnabledAsync(node.EntityId!, !node.Enabled, node.RowVersion);
if (!res.Ok) { ShowMessage("Update failed", res.Error ?? "Could not change enabled state.", node.DisplayName); return; }
await ReloadParentAsync(node);
}
private Task OnRenameDriver(RawNode node)
{
ShowNameDialog("Rename driver", node.DisplayName, async name =>
await AfterRename(await Svc.RenameDriverAsync(node.EntityId!, name, node.RowVersion), node));
return Task.CompletedTask;
}
private Task OnDeleteDriver(RawNode node)
{
ShowConfirm("Delete driver", $"Delete driver “{node.DisplayName}” and its empty devices?", async () =>
await AfterDelete(await Svc.DeleteDriverAsync(node.EntityId!, node.RowVersion), node));
return Task.CompletedTask;
}
// --- Device ---
private Task OnConfigureDevice(RawNode node)
{
_deviceIsCreate = false;
_deviceEditId = node.EntityId;
_deviceCreateDriverId = null;
_deviceDriverType = node.DriverType;
_deviceNode = node;
_deviceVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
private Task OnNewTagGroup(RawNode node)
{
// node is a Device — its root tag-groups.
ShowNameDialog("New tag-group", "", async name =>
{
var res = await Svc.CreateTagGroupAsync(node.EntityId!, null, name);
if (!res.Ok) { ShowMessage("New tag-group failed", res.Error ?? "Create failed.", node.DisplayName); return; }
await ReloadNodeAsync(node);
});
return Task.CompletedTask;
}
private Task OnDeleteDevice(RawNode node)
{
ShowConfirm("Delete device", $"Delete device “{node.DisplayName}”?", async () =>
await AfterDelete(await Svc.DeleteDeviceAsync(node.EntityId!, node.RowVersion), node));
return Task.CompletedTask;
}
// --- Add-tags surfaces (shared by Device + TagGroup) ---
private Task OnAddManualTags(RawNode node)
{
// Manual entry needs the device id: for a Device node it's node itself; for a TagGroup walk up.
_manualDeviceId = FindAncestorDevice(node)?.EntityId ?? "";
_manualTagGroupId = node.Kind == RawNodeKind.TagGroup ? node.EntityId : null;
_manualDriverType = node.DriverType ?? "";
_manualNode = node;
_manualVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
private Task OnImportCsv(RawNode node)
{
// Device node → DeviceId; TagGroup node → TagGroupId (the modal self-resolves its device).
if (node.Kind == RawNodeKind.Device)
{
_csvImportDeviceId = node.EntityId ?? "";
_csvImportTagGroupId = null;
}
else
{
_csvImportDeviceId = "";
_csvImportTagGroupId = node.EntityId;
}
_csvImportDriverType = node.DriverType ?? "";
_csvImportNode = node;
_csvImportVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
private Task OnExportCsv(RawNode node)
{
if (node.Kind == RawNodeKind.Device)
{
_csvExportDeviceId = node.EntityId ?? "";
_csvExportTagGroupId = null;
}
else
{
_csvExportDeviceId = "";
_csvExportTagGroupId = node.EntityId;
}
_csvExportDriverType = node.DriverType ?? "";
_csvExportVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
// Browse device stays a placeholder — Wave C (WP6) wires the discovery-browser modal.
private Task OnBrowseDevice(RawNode node)
{
ShowMessage("Browse device", "Device browse arrives in the browse wave (Wave C).", node.DisplayName);
return Task.CompletedTask;
}
// --- TagGroup ---
private Task OnNewGroup(RawNode node)
{
// node is a TagGroup — create a sub-group under it (needs its owning device id).
var deviceId = FindAncestorDevice(node)?.EntityId ?? "";
ShowNameDialog("New group", "", async name =>
{
var res = await Svc.CreateTagGroupAsync(deviceId, node.EntityId, name);
if (!res.Ok) { ShowMessage("New group failed", res.Error ?? "Create failed.", node.DisplayName); return; }
await ReloadNodeAsync(node);
});
return Task.CompletedTask;
}
private Task OnRenameTagGroup(RawNode node)
{
ShowNameDialog("Rename tag-group", node.DisplayName, async name =>
await AfterRename(await Svc.RenameTagGroupAsync(node.EntityId!, name, node.RowVersion), node));
return Task.CompletedTask;
}
private Task OnDeleteTagGroup(RawNode node)
{
ShowConfirm("Delete tag-group", $"Delete tag-group “{node.DisplayName}”?", async () =>
await AfterDelete(await Svc.DeleteTagGroupAsync(node.EntityId!, node.RowVersion), node));
return Task.CompletedTask;
}
// --- Tag ---
private Task OnEditTag(RawNode node)
{
_tagId = node.EntityId;
_tagDriverType = node.DriverType;
_tagNode = node;
_tagVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
private Task OnDeleteTag(RawNode node)
{
ShowConfirm("Delete tag", $"Delete tag “{node.DisplayName}”?", async () =>
await AfterDelete(await Svc.DeleteTagAsync(node.EntityId!, node.RowVersion), node));
return Task.CompletedTask;
}
// ---------------------------------------------------------------------------
// Modal OnSaved callbacks — refresh the affected subtree.
// ---------------------------------------------------------------------------
private async Task DriverCfgSaved()
{
if (_driverCfgNode is not null) { await ReloadParentAsync(_driverCfgNode); }
}
private async Task DeviceSaved()
{
if (_deviceNode is null) { return; }
// Create → reload the driver container; edit → reload the device's parent driver.
if (_deviceIsCreate) { await ReloadNodeAsync(_deviceNode); }
else { await ReloadParentAsync(_deviceNode); }
}
private async Task TagSaved()
{
if (_tagNode is not null) { await ReloadParentAsync(_tagNode); }
}
private async Task ManualSaved()
{
if (_manualNode is not null) { await ReloadNodeAsync(_manualNode); }
}
private async Task CsvImportSaved()
{
if (_csvImportNode is not null) { await ReloadNodeAsync(_csvImportNode); }
}
// ---------------------------------------------------------------------------
// Rename / delete outcome surfacing.
// ---------------------------------------------------------------------------
private async Task AfterRename(RawRenameResult res, RawNode node)
{
if (res.Ok) { await ReloadParentAsync(node); }
if (!res.Ok)
{
ShowMessage("Rename failed", res.Error ?? "Rename failed.", node.DisplayName);
}
else if (res.Warnings.Count > 0)
{
ShowMessage("Renamed — with warnings", string.Join(" • ", res.Warnings), node.DisplayName);
}
}
private async Task AfterDelete(UnsMutationResult res, RawNode node)
{
if (!res.Ok) { ShowMessage("Delete blocked", res.Error ?? "Delete failed.", node.DisplayName); return; }
await ReloadParentAsync(node);
}
// ---------------------------------------------------------------------------
// Dialog show-helpers (one instance each; a pending delegate carries the op).
// ---------------------------------------------------------------------------
private void ShowNameDialog(string title, string initial, Func<string, Task> submit)
{
_nameTitle = title;
_nameInitial = initial;
_nameSubmit = submit;
_nameVisible = true;
StateHasChanged();
}
private async Task NameSubmitted(string name)
{
var submit = _nameSubmit;
if (submit is not null) { await submit(name); }
}
private void ShowConfirm(string title, string message, Func<Task> action)
{
_confirmTitle = title;
_confirmMessage = message;
_confirmAction = action;
_confirmVisible = true;
StateHasChanged();
}
private async Task Confirmed()
{
var action = _confirmAction;
if (action is not null) { await action(); }
}
private void ShowDriverTypeDialog(Func<(string DriverType, string Name), Task> submit)
{
_driverTypeSubmit = submit;
_driverTypeVisible = true;
StateHasChanged();
}
private async Task DriverTypeSubmitted((string DriverType, string Name) choice)
{
var submit = _driverTypeSubmit;
if (submit is not null) { await submit(choice); }
}
// ---------------------------------------------------------------------------
// Shared message surface (repurposed RawStubModal).
// ---------------------------------------------------------------------------
private void ShowMessage(string title, string message, string? nodeName)
{
_msgTitle = title;
_msgMessage = message;
_msgNodeName = nodeName;
_msgVisible = true;
StateHasChanged();
}
private void CloseMessage()
{
_msgVisible = false;
_msgTitle = "";
_msgMessage = "";
_msgNodeName = null;
}
// ---------------------------------------------------------------------------
// Tree refresh + parent lookup.
// ---------------------------------------------------------------------------
/// <summary>
/// Reloads a container node's children in place (create-under refresh): clears its loaded level and
/// re-runs the lazy load, keeping it expanded. A refresh failure is captured on the node, never thrown.
/// </summary>
private async Task ReloadNodeAsync(RawNode container)
{
try
{
container.Expanded = true;
container.Loaded = false;
container.Children.Clear();
container.Error = null;
if (container.HasLazyChildren)
{
container.Loading = true;
StateHasChanged();
var children = await Svc.LoadChildrenAsync(container);
container.Children.Clear();
container.Children.AddRange(children);
container.ChildCount = container.Children.Count;
container.Loaded = true;
}
}
catch (Exception ex)
{
container.Error = ex.Message;
}
finally
{
container.Loading = false;
}
StateHasChanged();
}
/// <summary>
/// Reloads the parent container of an item-level node (rename/delete/toggle/edit refresh) so a changed
/// name / removed row / muted styling is reflected while preserving surrounding expansion state. Every
/// menu-bearing node's parent is in the loaded tree; the OnRootsChanged fallback is a belt-and-braces
/// path for a would-be cluster-less top-level node.
/// </summary>
private async Task ReloadParentAsync(RawNode node)
{
var parent = FindParent(node);
if (parent is not null) { await ReloadNodeAsync(parent); }
else { await OnRootsChanged.InvokeAsync(); }
}
/// <summary>Finds a node's parent by searching the currently-loaded tree; null for a root.</summary>
private RawNode? FindParent(RawNode target)
{
foreach (var root in Roots)
{
var found = FindParentIn(root, target);
if (found is not null) { return found; }
}
return null;
}
private static RawNode? FindParentIn(RawNode node, RawNode target)
{
foreach (var child in node.Children)
{
if (ReferenceEquals(child, target)) { return node; }
var deeper = FindParentIn(child, target);
if (deeper is not null) { return deeper; }
}
return null;
}
/// <summary>Walks up from <paramref name="node"/> (inclusive) to the nearest Device node, or null.</summary>
private RawNode? FindAncestorDevice(RawNode node)
{
var current = node;
while (current is not null)
{
if (current.Kind == RawNodeKind.Device) { return current; }
current = FindParent(current);
}
return null;
}
}