Add tree context menu, missing connection settings, and fix lazy-load browse

Right-click on browse tree nodes to Subscribe (multi-select) or View History
(Variable nodes only), with automatic tab switching. Add missing UI controls
for failover URLs, session timeout, auto-accept certificates, and certificate
store path. Fix tree expansion by adding two-way IsExpanded binding on
TreeViewItem.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-30 17:45:29 -04:00
parent a2883b82d9
commit 55ef854612
8 changed files with 348 additions and 7 deletions

View File

@@ -1,3 +1,4 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ZB.MOM.WW.LmxOpcUa.Client.Shared;
@@ -26,6 +27,20 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty]
private SecurityMode _selectedSecurityMode = SecurityMode.None;
[ObservableProperty]
private string? _failoverUrls;
[ObservableProperty]
private int _sessionTimeoutSeconds = 60;
[ObservableProperty]
private bool _autoAcceptCertificates = true;
[ObservableProperty]
private string _certificateStorePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LmxOpcUaClient", "pki");
/// <summary>All available security modes.</summary>
public IReadOnlyList<SecurityMode> SecurityModes { get; } = Enum.GetValues<SecurityMode>();
@@ -51,6 +66,15 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty]
private int _subscriptionCount;
[ObservableProperty]
private int _selectedTabIndex;
[ObservableProperty]
private bool _isHistoryEnabledForSelection;
/// <summary>The currently selected tree nodes (supports multi-select).</summary>
public ObservableCollection<TreeNodeViewModel> SelectedTreeNodes { get; } = new();
public BrowseTreeViewModel BrowseTree { get; }
public ReadWriteViewModel ReadWrite { get; }
public SubscriptionsViewModel Subscriptions { get; }
@@ -135,7 +159,11 @@ public partial class MainWindowViewModel : ObservableObject
EndpointUrl = EndpointUrl,
Username = Username,
Password = Password,
SecurityMode = SelectedSecurityMode
SecurityMode = SelectedSecurityMode,
FailoverUrls = ParseFailoverUrls(FailoverUrls),
SessionTimeoutSeconds = SessionTimeoutSeconds,
AutoAcceptCertificates = AutoAcceptCertificates,
CertificateStorePath = CertificateStorePath
};
settings.Validate();
@@ -195,4 +223,56 @@ public partial class MainWindowViewModel : ObservableObject
});
}
}
/// <summary>
/// Subscribes all selected tree nodes and switches to the Subscriptions tab.
/// </summary>
[RelayCommand]
private async Task SubscribeSelectedNodesAsync()
{
if (SelectedTreeNodes.Count == 0 || !IsConnected) return;
var nodes = SelectedTreeNodes.ToList();
foreach (var node in nodes)
{
await Subscriptions.AddSubscriptionForNodeAsync(node.NodeId);
}
SubscriptionCount = Subscriptions.SubscriptionCount;
SelectedTabIndex = 1; // Subscriptions tab
}
/// <summary>
/// Sets the history tab's selected node and switches to the History tab.
/// </summary>
[RelayCommand]
private void ViewHistoryForSelectedNode()
{
if (SelectedTreeNodes.Count == 0 || !IsConnected) return;
var node = SelectedTreeNodes[0];
History.SelectedNodeId = node.NodeId;
SelectedTabIndex = 3; // History tab
}
/// <summary>
/// Updates whether "View History" should be enabled based on the selected node's type.
/// Only Variable nodes can have history.
/// </summary>
public void UpdateHistoryEnabledForSelection()
{
IsHistoryEnabledForSelection = IsConnected
&& SelectedTreeNodes.Count > 0
&& SelectedTreeNodes[0].NodeClass == "Variable";
}
private static string[]? ParseFailoverUrls(string? csv)
{
if (string.IsNullOrWhiteSpace(csv))
return null;
return csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(u => !string.IsNullOrEmpty(u))
.ToArray();
}
}

View File

@@ -114,6 +114,33 @@ public partial class SubscriptionsViewModel : ObservableObject
}
}
/// <summary>
/// Subscribes to a node by ID (used by context menu). Skips if already subscribed.
/// </summary>
public async Task AddSubscriptionForNodeAsync(string nodeIdStr, int intervalMs = 1000)
{
if (!IsConnected || string.IsNullOrWhiteSpace(nodeIdStr)) return;
// Skip if already subscribed
if (ActiveSubscriptions.Any(s => s.NodeId == nodeIdStr)) return;
try
{
var nodeId = NodeId.Parse(nodeIdStr);
await _service.SubscribeAsync(nodeId, intervalMs);
_dispatcher.Post(() =>
{
ActiveSubscriptions.Add(new SubscriptionItemViewModel(nodeIdStr, intervalMs));
SubscriptionCount = ActiveSubscriptions.Count;
});
}
catch
{
// Subscription failed
}
}
/// <summary>
/// Clears all subscriptions and resets state.
/// </summary>