From 038e868f8d43bf0205b1c2edfde392f0639d3937 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:36:18 +0200 Subject: [PATCH 01/14] Refactor: NavigationManager with typed application requests Replaces the EventSystem (Models), which passed a single string, with NavigationManager in the main project: - ApplicationRequest (target or profile) with derived requests for options: DNSLookupRequest (DNS server, query type), PortScannerRequest (ports), RDAPRequest (query type). - Host views implement IApplicationRequestHandler; MainWindow passes the request to the shown view instead of a switch per application. - Profile actions of the tools are public (e.g. ConnectProfile(profile)) so a request can run a profile. - Web Console accepts a URL (connect dialog), like the other remote tools. - All callers migrated (IP Scanner, Traceroute, Network Interface, RDAP, open settings). Traceroute > RDAP > AS number queries with type ASN. - The application search is cleared before a request, so a filtered application list doesn't make the request fail. Co-Authored-By: Claude Opus 5.5 --- .../EventSystem/EventSystem.cs | 22 ---- .../EventSystem/EventSystemRedirectArgs.cs | 15 --- Source/NETworkManager/ApplicationRequest.cs | 63 +++++++++++ .../IApplicationRequestHandler.cs | 14 +++ Source/NETworkManager/MainWindow.xaml.cs | 100 ++++-------------- Source/NETworkManager/NavigationManager.cs | 36 +++++++ .../ViewModels/DNSLookupHostViewModel.cs | 18 +++- .../ViewModels/DNSLookupViewModel.cs | 11 +- .../ViewModels/IPScannerHostViewModel.cs | 2 +- .../ViewModels/IPScannerViewModel.cs | 11 +- .../ViewModels/NetworkInterfaceViewModel.cs | 7 +- .../ViewModels/PingMonitorHostViewModel.cs | 23 +++- .../ViewModels/PortScannerHostViewModel.cs | 2 +- .../ViewModels/PowerShellHostViewModel.cs | 14 +-- .../ViewModels/PuTTYHostViewModel.cs | 13 ++- .../ViewModels/RDAPHostViewModel.cs | 11 +- .../ViewModels/RDAPViewModel.cs | 3 +- .../ViewModels/RemoteDesktopHostViewModel.cs | 10 +- .../ViewModels/SNMPHostViewModel.cs | 2 +- .../ViewModels/TigerVNCHostViewModel.cs | 13 ++- .../ViewModels/TracerouteViewModel.cs | 22 ++-- .../ViewModels/WebConsoleHostViewModel.cs | 30 ++++-- .../ViewModels/WhoisHostViewModel.cs | 7 +- .../Views/DNSLookupHostView.xaml.cs | 14 ++- .../Views/DNSLookupView.xaml.cs | 5 +- .../Views/IPScannerHostView.xaml.cs | 9 +- .../Views/PingMonitorHostView.xaml.cs | 10 +- .../Views/PortScannerHostView.xaml.cs | 9 +- .../Views/PowerShellHostView.xaml.cs | 13 ++- .../Views/PuTTYHostView.xaml.cs | 13 ++- .../NETworkManager/Views/RDAPHostView.xaml.cs | 9 +- .../Views/RemoteDesktopHostView.xaml.cs | 9 +- .../NETworkManager/Views/SNMPHostView.xaml.cs | 9 +- .../Views/TigerVNCHostView.xaml.cs | 13 ++- .../Views/TracerouteHostView.xaml.cs | 15 ++- .../NETworkManager/Views/TracerouteView.xaml | 2 +- .../Views/WebConsoleHostView.xaml | 1 + .../Views/WebConsoleHostView.xaml.cs | 24 ++++- .../Views/WhoisHostView.xaml.cs | 9 +- 39 files changed, 391 insertions(+), 222 deletions(-) delete mode 100644 Source/NETworkManager.Models/EventSystem/EventSystem.cs delete mode 100644 Source/NETworkManager.Models/EventSystem/EventSystemRedirectArgs.cs create mode 100644 Source/NETworkManager/ApplicationRequest.cs create mode 100644 Source/NETworkManager/IApplicationRequestHandler.cs create mode 100644 Source/NETworkManager/NavigationManager.cs diff --git a/Source/NETworkManager.Models/EventSystem/EventSystem.cs b/Source/NETworkManager.Models/EventSystem/EventSystem.cs deleted file mode 100644 index a5f6617773..0000000000 --- a/Source/NETworkManager.Models/EventSystem/EventSystem.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; - -namespace NETworkManager.Models.EventSystem; - -public static class EventSystem -{ - // This will notify the main window, to change the view to another application and redirect some data (hostname, ip) - public static event EventHandler OnRedirectDataToApplicationEvent; - - public static void RedirectToApplication(ApplicationName application, string data) - { - OnRedirectDataToApplicationEvent?.Invoke(null, new EventSystemRedirectArgs(application, data)); - } - - // This will notify the main window, to change the view to the settings... - public static event EventHandler OnRedirectToSettingsEvent; - - public static void RedirectToSettings() - { - OnRedirectToSettingsEvent?.Invoke(null, EventArgs.Empty); - } -} \ No newline at end of file diff --git a/Source/NETworkManager.Models/EventSystem/EventSystemRedirectArgs.cs b/Source/NETworkManager.Models/EventSystem/EventSystemRedirectArgs.cs deleted file mode 100644 index 42c47ece31..0000000000 --- a/Source/NETworkManager.Models/EventSystem/EventSystemRedirectArgs.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace NETworkManager.Models.EventSystem; - -public class EventSystemRedirectArgs : EventArgs -{ - public EventSystemRedirectArgs(ApplicationName application, string args) - { - Application = application; - Args = args; - } - - public ApplicationName Application { get; set; } - public string Args { get; set; } -} \ No newline at end of file diff --git a/Source/NETworkManager/ApplicationRequest.cs b/Source/NETworkManager/ApplicationRequest.cs new file mode 100644 index 0000000000..9bada77199 --- /dev/null +++ b/Source/NETworkManager/ApplicationRequest.cs @@ -0,0 +1,63 @@ +using DnsClient; +using NETworkManager.Models; +using NETworkManager.Models.RDAP; +using NETworkManager.Profiles; + +namespace NETworkManager; + +/// +/// Request to open an application (tool), optionally with a target or a profile, e.g. from the run command or from +/// another tool ("Open in ..."). Applications that support more options have a derived request. +/// +/// Application to open. +public record ApplicationRequest(ApplicationName Application) +{ + /// + /// Target of the application, e.g. host(s), IP range, query or URL. If null (and no is set), + /// the application is only opened. + /// + public string Target { get; init; } + + /// + /// Profile to run in the application with its settings. is ignored if set. + /// + public ProfileInfo Profile { get; init; } +} + +/// +/// Request to open the DNS Lookup. +/// +public sealed record DNSLookupRequest() : ApplicationRequest(ApplicationName.DNSLookup) +{ + /// + /// DNS server(s) to use (e.g. "1.1.1.1"). If null, the DNS server selected in the application is used. + /// + public string DNSServer { get; init; } + + /// + /// Query type. If null, the query type selected in the application is used. + /// + public QueryType? QueryType { get; init; } +} + +/// +/// Request to open the Port Scanner. +/// +public sealed record PortScannerRequest() : ApplicationRequest(ApplicationName.PortScanner) +{ + /// + /// Ports to scan (e.g. "22;80;443" or "1-1024"). If null, the ports of the application are used. + /// + public string Ports { get; init; } +} + +/// +/// Request to open RDAP. +/// +public sealed record RDAPRequest() : ApplicationRequest(ApplicationName.RDAP) +{ + /// + /// Query type. If null, the type is detected from the query (). + /// + public RDAPQueryType? QueryType { get; init; } +} diff --git a/Source/NETworkManager/IApplicationRequestHandler.cs b/Source/NETworkManager/IApplicationRequestHandler.cs new file mode 100644 index 0000000000..aad22b5fc9 --- /dev/null +++ b/Source/NETworkManager/IApplicationRequestHandler.cs @@ -0,0 +1,14 @@ +namespace NETworkManager; + +/// +/// Application view that can handle an with a target or a profile (e.g. open a new +/// tab and start a scan). Views without this interface are only opened. +/// +public interface IApplicationRequestHandler +{ + /// + /// Handles the request. Called after the application view is shown. + /// + /// Request with a target or a profile. + void Open(ApplicationRequest request); +} diff --git a/Source/NETworkManager/MainWindow.xaml.cs b/Source/NETworkManager/MainWindow.xaml.cs index 1f9f9be04a..e5dd61ae76 100644 --- a/Source/NETworkManager/MainWindow.xaml.cs +++ b/Source/NETworkManager/MainWindow.xaml.cs @@ -6,7 +6,6 @@ using NETworkManager.Localization; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.Network; using NETworkManager.Profiles; using NETworkManager.Settings; @@ -438,11 +437,11 @@ public MainWindow() // Set window title Title = $"NETworkManager {AssemblyManager.Current.Version}"; - // Register event system... + // Register navigation requests (e.g. from the run command or other applications) SettingsManager.Current.PropertyChanged += SettingsManager_PropertyChanged; - EventSystem.OnRedirectDataToApplicationEvent += EventSystem_RedirectDataToApplicationEvent; - EventSystem.OnRedirectToSettingsEvent += EventSystem_RedirectToSettingsEvent; + NavigationManager.ApplicationRequested += NavigationManager_ApplicationRequested; + NavigationManager.SettingsRequested += NavigationManager_SettingsRequested; _isLoading = false; } @@ -1048,92 +1047,38 @@ private void ClearSearchOnApplicationListMinimize() ListViewApplication.ScrollIntoView(SelectedApplication); } - private async void EventSystem_RedirectDataToApplicationEvent(object sender, EventArgs e) + private async void NavigationManager_ApplicationRequested(object sender, ApplicationRequest request) { - if (e is not EventSystemRedirectArgs data) - return; + // Clear the search, otherwise the application may be filtered out + ApplicationSearch = string.Empty; - // Try to find the application - var application = Applications.Cast().FirstOrDefault(x => x.Name == data.Application); + // Try to find the application (hidden applications can't be opened) + var application = Applications.Cast().FirstOrDefault(x => x.Name == request.Application); // Show error message if the application was not found if (application == null) { await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Error, string.Format(Strings.CouldNotFindApplicationXXXMessage, - $"{data.Application}"), ChildWindowIcon.Error); + $"{request.Application}"), ChildWindowIcon.Error); return; } + if (SettingsViewIsOpen) + CloseSettings(); + // Change application view SelectedApplication = application; - if (string.IsNullOrEmpty(data.Args)) + if (string.IsNullOrEmpty(request.Target) && request.Profile == null) return; - // Crate a new tab / perform action - switch (data.Application) - { - case ApplicationName.Dashboard: - case ApplicationName.NetworkInterface: - case ApplicationName.WiFi: - break; - case ApplicationName.IPScanner: - _ipScannerHostView.AddTab(data.Args); - break; - case ApplicationName.PortScanner: - _portScannerHostView.AddTab(data.Args); - break; - case ApplicationName.PingMonitor: - _pingMonitorHostView.AddHost(data.Args); - break; - case ApplicationName.Traceroute: - _tracerouteHostView.AddTab(data.Args); - break; - case ApplicationName.DNSLookup: - _dnsLookupHostView.AddTab(data.Args); - break; - case ApplicationName.RemoteDesktop: - _remoteDesktopHostView.AddTab(data.Args); - break; - case ApplicationName.PowerShell: - _powerShellHostView.AddTab(data.Args); - break; - case ApplicationName.PuTTY: - _puttyHostView.AddTab(data.Args); - break; - case ApplicationName.TigerVNC: - _tigerVNCHostView.AddTab(data.Args); - break; - case ApplicationName.WebConsole: - break; - case ApplicationName.SNMP: - _snmpHostView.AddTab(data.Args); - break; - case ApplicationName.RDAP: - _rdapHostView.AddTab(data.Args); - break; - case ApplicationName.Whois: - _whoisHostView.AddTab(data.Args); - break; - case ApplicationName.SNTPLookup: - case ApplicationName.HostsFileEditor: - case ApplicationName.Firewall: - case ApplicationName.DiscoveryProtocol: - case ApplicationName.WakeOnLAN: - case ApplicationName.IPGeolocation: - case ApplicationName.SubnetCalculator: - case ApplicationName.BitCalculator: - case ApplicationName.Lookup: - case ApplicationName.Connections: - case ApplicationName.Listeners: - case ApplicationName.NeighborTable: - break; - default: - Log.Error($"Cannot redirect data to unknown application: {data.Application}"); - break; - } + // Create a new tab / perform the action in the application + if (ContentControlApplication.Content is IApplicationRequestHandler handler) + handler.Open(request); + else + Log.Warn($"Application {request.Application} cannot handle a request with a target or profile."); } /// @@ -1271,15 +1216,12 @@ private void RunCommandDo() { // Redirect to application case RunCommandType.Application: - if (SettingsViewIsOpen) - CloseSettings(); - var applicationName = (ApplicationName)Enum.Parse(typeof(ApplicationName), SelectedRunCommand.Name); - EventSystem.RedirectToApplication(applicationName, string.Empty); + NavigationManager.Open(new ApplicationRequest(applicationName)); break; // Redirect to settings case RunCommandType.Setting: - EventSystem.RedirectToSettings(); + NavigationManager.OpenSettings(); break; } @@ -1348,7 +1290,7 @@ private void OpenSettings() SettingsViewIsOpen = true; } - private void EventSystem_RedirectToSettingsEvent(object sender, EventArgs e) + private void NavigationManager_SettingsRequested(object sender, EventArgs e) { OpenSettings(); } diff --git a/Source/NETworkManager/NavigationManager.cs b/Source/NETworkManager/NavigationManager.cs new file mode 100644 index 0000000000..1212629d41 --- /dev/null +++ b/Source/NETworkManager/NavigationManager.cs @@ -0,0 +1,36 @@ +using System; + +namespace NETworkManager; + +/// +/// Navigation between the applications (tools) and the settings. The main window handles the requests. +/// +public static class NavigationManager +{ + /// + /// Occurs when an application should be opened, see . + /// + public static event EventHandler ApplicationRequested; + + /// + /// Occurs when the settings should be opened, see . + /// + public static event EventHandler SettingsRequested; + + /// + /// Opens an application and passes the request (target, profile, options) to it. + /// + /// Request with the application to open. + public static void Open(ApplicationRequest request) + { + ApplicationRequested?.Invoke(null, request); + } + + /// + /// Opens the settings of the current application. + /// + public static void OpenSettings() + { + SettingsRequested?.Invoke(null, EventArgs.Empty); + } +} diff --git a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs index 14e9865f44..eff2371440 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs @@ -1,3 +1,4 @@ +using DnsClient; using Dragablz; using NETworkManager.Controls; using NETworkManager.Localization.Resources; @@ -128,7 +129,7 @@ private bool LookupProfile_CanExecute(object obj) /// private void LookupProfileAction() { - AddTab(SelectedProfile.DNSLookup_Host); + AddTab(SelectedProfile); } /// @@ -152,15 +153,26 @@ private static void CloseItemAction(ItemActionCallbackArgs args) /// Adds a new tab for the specified host. /// /// The host to lookup. - public void AddTab(string host = null) + /// DNS server(s) to use. If null, the selected DNS server is used. + /// Query type. If null, the selected query type is used. + public void AddTab(string host = null, string dnsServer = null, QueryType? queryType = null) { var tabId = Guid.NewGuid(); - TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab, new DNSLookupView(tabId, host), + TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab, new DNSLookupView(tabId, host, dnsServer, queryType), tabId)); SelectedTabIndex = TabItems.Count - 1; } + /// + /// Adds a new tab for the host of the specified profile. + /// + /// The profile to lookup. + public void AddTab(ProfileInfo profile) + { + AddTab(profile.DNSLookup_Host); + } + #endregion } diff --git a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs index 0f38a429a9..fc88fedb4d 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs @@ -248,7 +248,9 @@ private set /// /// The ID of the tab. /// The host to lookup. - public DNSLookupViewModel(Guid tabId, string host) + /// DNS server(s) to use (not saved as default). If null, the last selected one is used. + /// Query type (not saved as default). If null, the last selected one is used. + public DNSLookupViewModel(Guid tabId, string host, string dnsServer = null, QueryType? queryType = null) { _isLoading = true; @@ -276,6 +278,13 @@ public DNSLookupViewModel(Guid tabId, string host) LoadSettings(); + // Options of a request (e.g. run command), which are not saved as default. + if (!string.IsNullOrEmpty(dnsServer)) + DNSServer = dnsServer; + + if (queryType != null && QueryTypes.Contains(queryType.Value)) + QueryType = queryType.Value; + _isLoading = false; } diff --git a/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs index 9ca9e59493..83d1cb9347 100644 --- a/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs @@ -165,7 +165,7 @@ public void AddTab(string hostOrIPRange = null) /// Adds a new tab for the specified profile. /// /// The profile to scan. - private void AddTab(ProfileInfo profile) + public void AddTab(ProfileInfo profile) { AddTab(profile.IPScanner_HostOrIPRange); } diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 4905fdb68d..1311d0d288 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -5,7 +5,6 @@ using NETworkManager.Localization; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.Export; using NETworkManager.Models.Network; using NETworkManager.Profiles; @@ -385,7 +384,7 @@ private void RedirectDataToApplicationAction(object name) ? SelectedResult.Hostname : SelectedResult.PingInfo.IPAddress.ToString(); - EventSystem.RedirectToApplication(applicationName, host); + NavigationManager.Open(new ApplicationRequest(applicationName) { Target = host }); } /// @@ -395,7 +394,7 @@ private void RedirectDataToApplicationAction(object name) private void PerformDNSLookupIPAddressAction() { - EventSystem.RedirectToApplication(ApplicationName.DNSLookup, SelectedResult.PingInfo.IPAddress.ToString()); + NavigationManager.Open(new DNSLookupRequest { Target = SelectedResult.PingInfo.IPAddress.ToString() }); } /// @@ -405,7 +404,7 @@ private void PerformDNSLookupIPAddressAction() private void PerformDNSLookupHostnameAction() { - EventSystem.RedirectToApplication(ApplicationName.DNSLookup, SelectedResult.Hostname); + NavigationManager.Open(new DNSLookupRequest { Target = SelectedResult.Hostname }); } /// @@ -415,7 +414,7 @@ private void PerformDNSLookupHostnameAction() private void PerformRDAPQueryIPAddressAction() { - EventSystem.RedirectToApplication(ApplicationName.RDAP, SelectedResult.PingInfo.IPAddress.ToString()); + NavigationManager.Open(new RDAPRequest { Target = SelectedResult.PingInfo.IPAddress.ToString() }); } /// @@ -425,7 +424,7 @@ private void PerformRDAPQueryIPAddressAction() private void PerformRDAPQueryHostnameAction() { - EventSystem.RedirectToApplication(ApplicationName.RDAP, SelectedResult.Hostname); + NavigationManager.Open(new RDAPRequest { Target = SelectedResult.Hostname }); } /// diff --git a/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs b/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs index dc48aa71b0..e71645b52b 100644 --- a/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs +++ b/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs @@ -9,7 +9,6 @@ using MahApps.Metro.SimpleChildWindow; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.Export; using NETworkManager.Models.Network; using NETworkManager.Profiles; @@ -899,8 +898,10 @@ private void IPScannerAction() if (ipTuple == null) return; - EventSystem.RedirectToApplication(ApplicationName.IPScanner, - $"{ipTuple.Item1}/{Subnetmask.ConvertSubnetmaskToCidr(ipTuple.Item2)}"); + NavigationManager.Open(new ApplicationRequest(ApplicationName.IPScanner) + { + Target = $"{ipTuple.Item1}/{Subnetmask.ConvertSubnetmaskToCidr(ipTuple.Item2)}" + }); } /// diff --git a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs index a25bf063a8..fc4b5ad6b3 100644 --- a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs @@ -225,8 +225,7 @@ private bool PingProfile_CanExecute(object obj) private void PingProfileAction() { - if (SetHost(SelectedProfile.PingMonitor_Host, SelectedProfile.Group)) - _ = Start(); + AddHost(SelectedProfile); } /// @@ -265,6 +264,26 @@ private void PauseGroupAction(object group) #region Methods + /// + /// Adds the host(s) and starts the ping monitor. + /// + /// Host(s) to ping. + public void AddHost(string host) + { + if (SetHost(host)) + _ = Start(); + } + + /// + /// Adds the host(s) of the profile to its group and starts the ping monitor. + /// + /// Profile to ping. + public void AddHost(ProfileInfo profile) + { + if (SetHost(profile.PingMonitor_Host, profile.Group)) + _ = Start(); + } + /// /// Set the host to ping. /// diff --git a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs index d2b982af47..2813e69bda 100644 --- a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs @@ -150,7 +150,7 @@ public void AddTab(string host = null, string ports = null) SelectedTabIndex = TabItems.Count - 1; } - private void AddTab(ProfileInfo profile) + public void AddTab(ProfileInfo profile) { AddTab(profile.PortScanner_Host, profile.PortScanner_Ports); } diff --git a/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs b/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs index 2f3607b342..42372a93a5 100644 --- a/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs @@ -4,7 +4,6 @@ using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.PowerShell; using NETworkManager.Profiles; using NETworkManager.Settings; @@ -233,7 +232,7 @@ private bool ConnectProfile_CanExecute(object obj) private void ConnectProfileAction() { - ConnectProfile(); + ConnectProfile(SelectedProfile); } public ICommand ConnectProfileExternalCommand => new RelayCommand(_ => ConnectProfileExternalAction()); @@ -264,7 +263,7 @@ private void ClearSearchAction() private static void OpenSettingsAction() { - EventSystem.RedirectToSettings(); + NavigationManager.OpenSettings(); } #endregion @@ -430,10 +429,13 @@ private Task Connect(string host = null) return Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } - private void ConnectProfile() + /// + /// Connects to the profile. + /// + /// Profile to connect to. + public void ConnectProfile(ProfileInfo profileInfo) { - Connect(PowerShellProfile.CreateSessionInfo(SelectedProfile), - SelectedProfile.Name); + Connect(PowerShellProfile.CreateSessionInfo(profileInfo), profileInfo.Name); } private void ConnectProfileExternal() diff --git a/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs b/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs index 9ffc59c06d..b26862a6f5 100644 --- a/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs @@ -4,7 +4,6 @@ using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.PuTTY; using NETworkManager.Profiles; using NETworkManager.Settings; @@ -241,7 +240,7 @@ private bool ConnectProfile_CanExecute(object obj) private void ConnectProfileAction() { - ConnectProfile(); + ConnectProfile(SelectedProfile); } public ICommand ConnectProfileExternalCommand => new RelayCommand(_ => ConnectProfileExternalAction()); @@ -265,7 +264,7 @@ public ICommand TextBoxSearchLostFocusCommand private static void OpenSettingsAction() { - EventSystem.RedirectToSettings(); + NavigationManager.OpenSettings(); } #endregion @@ -362,9 +361,13 @@ private Task Connect(string host = null) return Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } - private void ConnectProfile() + /// + /// Connects to the profile. + /// + /// Profile to connect to. + public void ConnectProfile(ProfileInfo profileInfo) { - Connect(NETworkManager.Profiles.Application.PuTTY.CreateSessionInfo(SelectedProfile), SelectedProfile.Name); + Connect(NETworkManager.Profiles.Application.PuTTY.CreateSessionInfo(profileInfo), profileInfo.Name); } private void ConnectProfileExternal() diff --git a/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs b/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs index 72fe499c95..2b9ec398e1 100644 --- a/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs @@ -95,7 +95,7 @@ private bool QueryProfile_CanExecute(object obj) private void QueryProfileAction() { - AddTab(SelectedProfile.RDAP_Query, SelectedProfile.RDAP_QueryType); + AddTab(SelectedProfile); } public ItemActionCallback CloseItemCommand => CloseItemAction; @@ -130,5 +130,14 @@ public void AddTab(string query = null, RDAPQueryType? queryType = null) SelectedTabIndex = TabItems.Count - 1; } + /// + /// Adds a new tab and starts the query of the profile. + /// + /// Profile to query. + public void AddTab(ProfileInfo profile) + { + AddTab(profile.RDAP_Query, profile.RDAP_QueryType); + } + #endregion } diff --git a/Source/NETworkManager/ViewModels/RDAPViewModel.cs b/Source/NETworkManager/ViewModels/RDAPViewModel.cs index 5dfa44cb1d..326f3b7c08 100644 --- a/Source/NETworkManager/ViewModels/RDAPViewModel.cs +++ b/Source/NETworkManager/ViewModels/RDAPViewModel.cs @@ -4,7 +4,6 @@ using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.Export; using NETworkManager.Models.RDAP; using NETworkManager.Settings; @@ -263,7 +262,7 @@ private void QueryAction() private void OpenInWhoisAction() { - EventSystem.RedirectToApplication(ApplicationName.Whois, _whoisDomain); + NavigationManager.Open(new ApplicationRequest(ApplicationName.Whois) { Target = _whoisDomain }); } /// diff --git a/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs b/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs index 1de5001b77..c5a00d2282 100644 --- a/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs @@ -213,7 +213,7 @@ private bool ConnectProfile_CanExecute(object obj) private void ConnectProfileAction() { - ConnectProfile(); + ConnectProfile(SelectedProfile); } public ICommand ConnectProfileAsCommand => new RelayCommand(_ => ConnectProfileAsAction()); @@ -315,10 +315,12 @@ private Task Connect(string host = null) } // Connect via Profile - private void ConnectProfile() + /// + /// Connects to the profile. + /// + /// Profile to connect to. + public void ConnectProfile(ProfileInfo profileInfo) { - var profileInfo = SelectedProfile; - var sessionInfo = RemoteDesktop.CreateSessionInfo(profileInfo); Connect(sessionInfo, profileInfo.Name); diff --git a/Source/NETworkManager/ViewModels/SNMPHostViewModel.cs b/Source/NETworkManager/ViewModels/SNMPHostViewModel.cs index f73e4a0601..b97a87db98 100644 --- a/Source/NETworkManager/ViewModels/SNMPHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/SNMPHostViewModel.cs @@ -132,7 +132,7 @@ public void AddTab(string host) AddTab(sessionInfo); } - private void AddTab(ProfileInfo profile) + public void AddTab(ProfileInfo profile) { var sessionInfo = SNMP.CreateSessionInfo(profile); diff --git a/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs b/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs index e9f47e675d..30c8530dfb 100644 --- a/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs @@ -3,7 +3,6 @@ using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.TigerVNC; using NETworkManager.Profiles; using NETworkManager.Settings; @@ -150,7 +149,7 @@ private bool ConnectProfile_CanExecute(object obj) private void ConnectProfileAction() { - ConnectProfile(); + ConnectProfile(SelectedProfile); } public ICommand ConnectProfileExternalCommand => new RelayCommand(_ => ConnectProfileExternalAction()); @@ -164,7 +163,7 @@ private void ConnectProfileExternalAction() private static void OpenSettingsAction() { - EventSystem.RedirectToSettings(); + NavigationManager.OpenSettings(); } #endregion @@ -223,9 +222,13 @@ private Task Connect(string host = null) return Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } - private void ConnectProfile() + /// + /// Connects to the profile. + /// + /// Profile to connect to. + public void ConnectProfile(ProfileInfo profileInfo) { - Connect(TigerVNC.CreateSessionInfo(SelectedProfile), SelectedProfile.Name); + Connect(TigerVNC.CreateSessionInfo(profileInfo), profileInfo.Name); } private void ConnectProfileExternal() diff --git a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs index c5b9f66207..fd69b06fcf 100644 --- a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs +++ b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs @@ -4,9 +4,9 @@ using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.Export; using NETworkManager.Models.Network; +using NETworkManager.Models.RDAP; using NETworkManager.Settings; using NETworkManager.Utilities; using NETworkManager.Views; @@ -307,7 +307,7 @@ private void RedirectDataToApplicationAction(object name) ? SelectedResult.Hostname : SelectedResult.IPAddress.ToString(); - EventSystem.RedirectToApplication(applicationName, host); + NavigationManager.Open(new ApplicationRequest(applicationName) { Target = host }); } /// @@ -317,19 +317,29 @@ private void RedirectDataToApplicationAction(object name) private void PerformDNSLookupAction(object data) { - EventSystem.RedirectToApplication(ApplicationName.DNSLookup, data.ToString()); + NavigationManager.Open(new DNSLookupRequest { Target = data.ToString() }); } /// - /// Gets the command to query the registration data of the selected hop's IP address, hostname (its registered - /// domain) or AS number via RDAP. + /// Gets the command to query the registration data of the selected hop's IP address or hostname (its registered + /// domain) via RDAP. /// public ICommand PerformRDAPQueryCommand => new RelayCommand(PerformRDAPQueryAction); private void PerformRDAPQueryAction(object data) + { + NavigationManager.Open(new RDAPRequest { Target = data.ToString() }); + } + + /// + /// Gets the command to query the registration data of the selected hop's AS number via RDAP. + /// + public ICommand PerformRDAPQueryASNCommand => new RelayCommand(PerformRDAPQueryASNAction); + + private void PerformRDAPQueryASNAction(object data) { // The AS of the IP geolocation contains the name as well (e.g. "AS15169 Google LLC"). - EventSystem.RedirectToApplication(ApplicationName.RDAP, data.ToString().Split(' ')[0]); + NavigationManager.Open(new RDAPRequest { Target = data.ToString().Split(' ')[0], QueryType = RDAPQueryType.ASN }); } /// diff --git a/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs b/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs index 951174d9eb..d7ed6c10e5 100644 --- a/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs @@ -4,7 +4,6 @@ using NETworkManager.Controls; using NETworkManager.Localization.Resources; using NETworkManager.Models; -using NETworkManager.Models.EventSystem; using NETworkManager.Models.WebConsole; using NETworkManager.Profiles; using NETworkManager.Profiles.Application; @@ -150,14 +149,14 @@ private bool ConnectProfile_CanExecute(object obj) private void ConnectProfileAction() { - ConnectProfile(); + ConnectProfile(SelectedProfile); } public ICommand OpenSettingsCommand => new RelayCommand(_ => OpenSettingsAction()); private static void OpenSettingsAction() { - EventSystem.RedirectToSettings(); + NavigationManager.OpenSettings(); } public ICommand OpenWebsiteCommand => new RelayCommand(OpenWebsiteAction); @@ -171,7 +170,7 @@ private static void OpenWebsiteAction(object url) #region Methods - private Task Connect() + private Task Connect(string url = null) { var childWindow = new WebConsoleConnectChildWindow(); @@ -200,7 +199,10 @@ private Task Connect() ConfigurationManager.Current.IsChildWindowOpen = false; ConfigurationManager.OnDialogClose(); - }); + }) + { + Url = url + }; childWindow.Title = Strings.Connect; @@ -213,10 +215,22 @@ private Task Connect() return Application.Current.MainWindow.ShowChildWindowAsync(childWindow); } - private void ConnectProfile() + /// + /// Connects to the profile. + /// + /// Profile to connect to. + public void ConnectProfile(ProfileInfo profileInfo) + { + Connect(WebConsole.CreateSessionInfo(profileInfo), profileInfo.Name); + } + + /// + /// Opens the connect dialog with the URL. + /// + /// URL to connect to. + public void AddTab(string url) { - Connect(WebConsole.CreateSessionInfo(SelectedProfile), - SelectedProfile.Name); + _ = Connect(url); } private void Connect(WebConsoleSessionInfo sessionInfo, string header = null) diff --git a/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs b/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs index 846daada81..56398a281b 100644 --- a/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs @@ -94,7 +94,7 @@ private bool QueryProfile_CanExecute(object obj) private void QueryProfileAction() { - AddTab(SelectedProfile.Whois_Domain); + AddTab(SelectedProfile); } public ItemActionCallback CloseItemCommand => CloseItemAction; @@ -118,5 +118,10 @@ public void AddTab(string domain = null) SelectedTabIndex = TabItems.Count - 1; } + public void AddTab(ProfileInfo profile) + { + AddTab(profile.Whois_Domain); + } + #endregion } diff --git a/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs b/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs index fc6e032285..1b1f604aff 100644 --- a/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs +++ b/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class DNSLookupHostView +public partial class DNSLookupHostView : IApplicationRequestHandler { private readonly DNSLookupHostViewModel _viewModel = new(); @@ -12,9 +12,17 @@ public DNSLookupHostView() DataContext = _viewModel; } - public void AddTab(string host) + public void Open(ApplicationRequest request) { - _viewModel.AddTab(host); + if (request.Profile != null) + { + _viewModel.AddTab(request.Profile); + return; + } + + var dnsLookupRequest = request as DNSLookupRequest; + + _viewModel.AddTab(request.Target, dnsLookupRequest?.DNSServer, dnsLookupRequest?.QueryType); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/DNSLookupView.xaml.cs b/Source/NETworkManager/Views/DNSLookupView.xaml.cs index b00f030485..8edfa206d4 100644 --- a/Source/NETworkManager/Views/DNSLookupView.xaml.cs +++ b/Source/NETworkManager/Views/DNSLookupView.xaml.cs @@ -1,6 +1,7 @@ using System; using System.Windows; using System.Windows.Controls; +using DnsClient; using NETworkManager.Controls; using NETworkManager.ViewModels; @@ -10,11 +11,11 @@ public partial class DNSLookupView : IDragablzTabItem { private readonly DNSLookupViewModel _viewModel; - public DNSLookupView(Guid tabId, string host = null) + public DNSLookupView(Guid tabId, string host = null, string dnsServer = null, QueryType? queryType = null) { InitializeComponent(); - _viewModel = new DNSLookupViewModel(tabId, host); + _viewModel = new DNSLookupViewModel(tabId, host, dnsServer, queryType); DataContext = _viewModel; diff --git a/Source/NETworkManager/Views/IPScannerHostView.xaml.cs b/Source/NETworkManager/Views/IPScannerHostView.xaml.cs index 25d1d3a18d..6593c40508 100644 --- a/Source/NETworkManager/Views/IPScannerHostView.xaml.cs +++ b/Source/NETworkManager/Views/IPScannerHostView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class IPScannerHostView +public partial class IPScannerHostView : IApplicationRequestHandler { private readonly IPScannerHostViewModel _viewModel = new(); @@ -12,9 +12,12 @@ public IPScannerHostView() DataContext = _viewModel; } - public void AddTab(string host) + public void Open(ApplicationRequest request) { - _viewModel.AddTab(host); + if (request.Profile != null) + _viewModel.AddTab(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs b/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs index 42366d6a88..7ff2b61c9b 100644 --- a/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs +++ b/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs @@ -4,7 +4,7 @@ namespace NETworkManager.Views; -public partial class PingMonitorHostView +public partial class PingMonitorHostView : IApplicationRequestHandler { private readonly PingMonitorHostViewModel _viewModel = new(); @@ -14,10 +14,12 @@ public PingMonitorHostView() DataContext = _viewModel; } - public void AddHost(string host) + public void Open(ApplicationRequest request) { - if (_viewModel.SetHost(host)) - _ = _viewModel.Start(); + if (request.Profile != null) + _viewModel.AddHost(request.Profile); + else + _viewModel.AddHost(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/PortScannerHostView.xaml.cs b/Source/NETworkManager/Views/PortScannerHostView.xaml.cs index 30e612a289..99d078cc8c 100644 --- a/Source/NETworkManager/Views/PortScannerHostView.xaml.cs +++ b/Source/NETworkManager/Views/PortScannerHostView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class PortScannerHostView +public partial class PortScannerHostView : IApplicationRequestHandler { private readonly PortScannerHostViewModel _viewModel = new(); @@ -12,9 +12,12 @@ public PortScannerHostView() DataContext = _viewModel; } - public void AddTab(string host) + public void Open(ApplicationRequest request) { - _viewModel.AddTab(host); + if (request.Profile != null) + _viewModel.AddTab(request.Profile); + else + _viewModel.AddTab(request.Target, (request as PortScannerRequest)?.Ports); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/PowerShellHostView.xaml.cs b/Source/NETworkManager/Views/PowerShellHostView.xaml.cs index 9c80c79016..5c1b7252f9 100644 --- a/Source/NETworkManager/Views/PowerShellHostView.xaml.cs +++ b/Source/NETworkManager/Views/PowerShellHostView.xaml.cs @@ -4,7 +4,7 @@ namespace NETworkManager.Views; -public partial class PowerShellHostView +public partial class PowerShellHostView : IApplicationRequestHandler { private readonly PowerShellHostViewModel _viewModel = new(); @@ -21,15 +21,20 @@ private void UserControl_Loaded(object sender, RoutedEventArgs e) _loaded = true; } - public async void AddTab(string host) + public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new profile... // MahApps will throw an exception... while (!_loaded) await Task.Delay(250); - if (_viewModel.IsExecutableConfigured) - _viewModel.AddTab(host); + if (!_viewModel.IsExecutableConfigured) + return; + + if (request.Profile != null) + _viewModel.ConnectProfile(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/PuTTYHostView.xaml.cs b/Source/NETworkManager/Views/PuTTYHostView.xaml.cs index 819d3310cb..02344d55b4 100644 --- a/Source/NETworkManager/Views/PuTTYHostView.xaml.cs +++ b/Source/NETworkManager/Views/PuTTYHostView.xaml.cs @@ -4,7 +4,7 @@ namespace NETworkManager.Views; -public partial class PuTTYHostView +public partial class PuTTYHostView : IApplicationRequestHandler { private readonly PuTTYHostViewModel _viewModel = new(); @@ -21,15 +21,20 @@ private void UserControl_Loaded(object sender, RoutedEventArgs e) _loaded = true; } - public async void AddTab(string host) + public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new profile... // MahApps will throw an exception... while (!_loaded) await Task.Delay(250); - if (_viewModel.IsExecutableConfigured) - _viewModel.AddTab(host); + if (!_viewModel.IsExecutableConfigured) + return; + + if (request.Profile != null) + _viewModel.ConnectProfile(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/RDAPHostView.xaml.cs b/Source/NETworkManager/Views/RDAPHostView.xaml.cs index 13405e9924..36118d9575 100644 --- a/Source/NETworkManager/Views/RDAPHostView.xaml.cs +++ b/Source/NETworkManager/Views/RDAPHostView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class RDAPHostView +public partial class RDAPHostView : IApplicationRequestHandler { private readonly RDAPHostViewModel _viewModel = new(); @@ -12,9 +12,12 @@ public RDAPHostView() DataContext = _viewModel; } - public void AddTab(string query) + public void Open(ApplicationRequest request) { - _viewModel.AddTab(query); + if (request.Profile != null) + _viewModel.AddTab(request.Profile); + else + _viewModel.AddTab(request.Target, (request as RDAPRequest)?.QueryType); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs b/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs index d33633292b..45f3ca49a9 100644 --- a/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs +++ b/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs @@ -4,7 +4,7 @@ namespace NETworkManager.Views; -public partial class RemoteDesktopHostView +public partial class RemoteDesktopHostView : IApplicationRequestHandler { private readonly RemoteDesktopHostViewModel _viewModel = new(); @@ -21,14 +21,17 @@ private void UserControl_Loaded(object sender, RoutedEventArgs e) _loaded = true; } - public async void AddTab(string host) + public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new Profile... // MahApps will throw an exception... while (!_loaded) await Task.Delay(250); - _viewModel.AddTab(host); + if (request.Profile != null) + _viewModel.ConnectProfile(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/SNMPHostView.xaml.cs b/Source/NETworkManager/Views/SNMPHostView.xaml.cs index fb7a33a8b7..2755b8e09e 100644 --- a/Source/NETworkManager/Views/SNMPHostView.xaml.cs +++ b/Source/NETworkManager/Views/SNMPHostView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class SNMPHostView +public partial class SNMPHostView : IApplicationRequestHandler { private readonly SNMPHostViewModel _viewModel = new(); @@ -12,9 +12,12 @@ public SNMPHostView() DataContext = _viewModel; } - public void AddTab(string host) + public void Open(ApplicationRequest request) { - _viewModel.AddTab(host); + if (request.Profile != null) + _viewModel.AddTab(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/TigerVNCHostView.xaml.cs b/Source/NETworkManager/Views/TigerVNCHostView.xaml.cs index d556299387..dc19c32529 100644 --- a/Source/NETworkManager/Views/TigerVNCHostView.xaml.cs +++ b/Source/NETworkManager/Views/TigerVNCHostView.xaml.cs @@ -4,7 +4,7 @@ namespace NETworkManager.Views; -public partial class TigerVNCHostView +public partial class TigerVNCHostView : IApplicationRequestHandler { private readonly TigerVNCHostViewModel _viewModel = new(); @@ -21,15 +21,20 @@ private void UserControl_Loaded(object sender, RoutedEventArgs e) _loaded = true; } - public async void AddTab(string host) + public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new profile... // MahApps will throw an exception... while (!_loaded) await Task.Delay(250); - if (_viewModel.IsConfigured) - _viewModel.AddTab(host); + if (!_viewModel.IsConfigured) + return; + + if (request.Profile != null) + _viewModel.ConnectProfile(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/TracerouteHostView.xaml.cs b/Source/NETworkManager/Views/TracerouteHostView.xaml.cs index 2805de869b..84c6255eee 100644 --- a/Source/NETworkManager/Views/TracerouteHostView.xaml.cs +++ b/Source/NETworkManager/Views/TracerouteHostView.xaml.cs @@ -1,9 +1,8 @@ -using NETworkManager.Profiles; using NETworkManager.ViewModels; namespace NETworkManager.Views; -public partial class TracerouteHostView +public partial class TracerouteHostView : IApplicationRequestHandler { private readonly TracerouteHostViewModel _viewModel = new(); @@ -13,14 +12,12 @@ public TracerouteHostView() DataContext = _viewModel; } - public void AddTab(string host) + public void Open(ApplicationRequest request) { - _viewModel.AddTab(host); - } - - public void AddTab(ProfileInfo profile) - { - _viewModel.AddTab(profile); + if (request.Profile != null) + _viewModel.AddTab(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() diff --git a/Source/NETworkManager/Views/TracerouteView.xaml b/Source/NETworkManager/Views/TracerouteView.xaml index f1cd088f0c..35b5eb05ad 100644 --- a/Source/NETworkManager/Views/TracerouteView.xaml +++ b/Source/NETworkManager/Views/TracerouteView.xaml @@ -285,7 +285,7 @@ diff --git a/Source/NETworkManager/Views/WebConsoleHostView.xaml b/Source/NETworkManager/Views/WebConsoleHostView.xaml index 2b23a34b9c..a385483b03 100644 --- a/Source/NETworkManager/Views/WebConsoleHostView.xaml +++ b/Source/NETworkManager/Views/WebConsoleHostView.xaml @@ -18,6 +18,7 @@ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:networkManager="clr-namespace:NETworkManager" xmlns:hostControls="clr-namespace:NETworkManager.Views.Controls" + Loaded="UserControl_Loaded" mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:WebConsoleHostViewModel}"> diff --git a/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs b/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs index 2f5f3c937c..84bb2fcd61 100644 --- a/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs +++ b/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs @@ -1,17 +1,39 @@ using NETworkManager.ViewModels; +using System.Threading.Tasks; +using System.Windows; namespace NETworkManager.Views; -public partial class WebConsoleHostView +public partial class WebConsoleHostView : IApplicationRequestHandler { private readonly WebConsoleHostViewModel _viewModel = new(); + private bool _loaded; + public WebConsoleHostView() { InitializeComponent(); DataContext = _viewModel; } + private void UserControl_Loaded(object sender, RoutedEventArgs e) + { + _loaded = true; + } + + public async void Open(ApplicationRequest request) + { + // Wait for the interface to load, before displaying the dialog to connect a new profile... + // MahApps will throw an exception... + while (!_loaded) + await Task.Delay(250); + + if (request.Profile != null) + _viewModel.ConnectProfile(request.Profile); + else + _viewModel.AddTab(request.Target); + } + public void OnViewHide() { _viewModel.OnViewHide(); diff --git a/Source/NETworkManager/Views/WhoisHostView.xaml.cs b/Source/NETworkManager/Views/WhoisHostView.xaml.cs index 8debf8621c..ba30c03c69 100644 --- a/Source/NETworkManager/Views/WhoisHostView.xaml.cs +++ b/Source/NETworkManager/Views/WhoisHostView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class WhoisHostView +public partial class WhoisHostView : IApplicationRequestHandler { private readonly WhoisHostViewModel _viewModel = new(); @@ -12,9 +12,12 @@ public WhoisHostView() DataContext = _viewModel; } - public void AddTab(string domain) + public void Open(ApplicationRequest request) { - _viewModel.AddTab(domain); + if (request.Profile != null) + _viewModel.AddTab(request.Profile); + else + _viewModel.AddTab(request.Target); } public void OnViewHide() From f7d89fb5d89e545726b6ece2f10bbea1e16dcae0 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:47:50 +0200 Subject: [PATCH 02/14] Feature: Run command parser with unit tests - RunCommandParser (Models): "command target [argument] [type]" or "command @profile". Whitespace around ";" is removed, so lists can be typed with spaces; a type keyword and the second argument (e.g. ports, DNS server) can be in any order; the rest after "@" is the profile name (names can contain spaces). - First test project of the solution (NETworkManager.Tests, xUnit v3) with tests for the parser. dotnet test uses Microsoft.Testing.Platform (global.json). Co-Authored-By: Claude Opus 5.5 --- .../RunCommand/RunCommandArgument.cs | 11 + .../RunCommand/RunCommandDefinition.cs | 68 ++++ .../RunCommand/RunCommandParseError.cs | 15 + .../RunCommand/RunCommandParseResult.cs | 63 ++++ .../RunCommand/RunCommandParser.cs | 147 ++++++++ .../RunCommand/RunCommandPosition.cs | 11 + .../RunCommand/RunCommandType.cs | 10 + .../NETworkManager.Tests.csproj | 18 + .../RunCommand/RunCommandParserTests.cs | 232 ++++++++++++ Source/NETworkManager.sln | 22 ++ .../Controls/RunCommandComboBox.cs | 337 ------------------ Source/NETworkManager/RunCommandInfo.cs | 36 -- Source/NETworkManager/RunCommandType.cs | 8 - Source/global.json | 3 + 14 files changed, 600 insertions(+), 381 deletions(-) create mode 100644 Source/NETworkManager.Models/RunCommand/RunCommandArgument.cs create mode 100644 Source/NETworkManager.Models/RunCommand/RunCommandDefinition.cs create mode 100644 Source/NETworkManager.Models/RunCommand/RunCommandParseError.cs create mode 100644 Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs create mode 100644 Source/NETworkManager.Models/RunCommand/RunCommandParser.cs create mode 100644 Source/NETworkManager.Models/RunCommand/RunCommandPosition.cs create mode 100644 Source/NETworkManager.Models/RunCommand/RunCommandType.cs create mode 100644 Source/NETworkManager.Tests/NETworkManager.Tests.csproj create mode 100644 Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs delete mode 100644 Source/NETworkManager/Controls/RunCommandComboBox.cs delete mode 100644 Source/NETworkManager/RunCommandInfo.cs delete mode 100644 Source/NETworkManager/RunCommandType.cs diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandArgument.cs b/Source/NETworkManager.Models/RunCommand/RunCommandArgument.cs new file mode 100644 index 0000000000..c7559dce9e --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandArgument.cs @@ -0,0 +1,11 @@ +namespace NETworkManager.Models.RunCommand; + +/// +/// Second argument of a run command after the target. +/// +public enum RunCommandArgument +{ + None, + Ports, + DNSServer +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandDefinition.cs b/Source/NETworkManager.Models/RunCommand/RunCommandDefinition.cs new file mode 100644 index 0000000000..3b79913499 --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandDefinition.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NETworkManager.Models.RunCommand; + +/// +/// Command of the run command dialog (e.g. "dnslookup" with the alias "dns"), which opens an application or the +/// settings. +/// +public sealed class RunCommandDefinition +{ + /// + /// Whether the command opens an application or the settings. + /// + public RunCommandType Type { get; init; } + + /// + /// Application to open ( for the settings). + /// + public ApplicationName Application { get; init; } + + /// + /// Command (lowercase, e.g. "dnslookup"). + /// + public string Command { get; init; } + + /// + /// Short forms of the command (lowercase, e.g. "dns", "nslookup"). + /// + public IReadOnlyList Aliases { get; init; } = []; + + /// + /// The command accepts a target (e.g. host, IP range, query). + /// + public bool SupportsTarget { get; init; } + + /// + /// The command accepts a profile ("@name") instead of a target. + /// + public bool SupportsProfile { get; init; } + + /// + /// Second argument after the target (e.g. ports or DNS server). + /// + public RunCommandArgument Argument { get; init; } + + /// + /// Type keywords that can follow the target (lowercase, e.g. "a", "aaaa" for the DNS lookup). + /// + public IReadOnlyList Types { get; init; } = []; + + /// + /// Syntax of the arguments for the hint (e.g. "<hosts> [server] [type]"), empty if there are none. + /// + public string Syntax { get; init; } = string.Empty; + + /// + /// Checks if the text is the command or one of its aliases (case-insensitive). + /// + /// Command text. + /// True if the text matches. + public bool Matches(string text) + { + return string.Equals(Command, text, StringComparison.OrdinalIgnoreCase) || + Aliases.Any(x => string.Equals(x, text, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParseError.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParseError.cs new file mode 100644 index 0000000000..bac326a81b --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandParseError.cs @@ -0,0 +1,15 @@ +namespace NETworkManager.Models.RunCommand; + +/// +/// Errors of a run command input. +/// +public enum RunCommandParseError +{ + None, + Empty, + UnknownCommand, + NoArguments, + NoProfiles, + MissingProfileName, + UnknownArgument +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs new file mode 100644 index 0000000000..898166f108 --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandParseResult.cs @@ -0,0 +1,63 @@ +namespace NETworkManager.Models.RunCommand; + +/// +/// Result of . +/// +public sealed class RunCommandParseResult +{ + /// + /// Command that matches the first word, or null if there is none. + /// + public RunCommandDefinition Command { get; init; } + + /// + /// First word as typed. + /// + public string CommandText { get; init; } = string.Empty; + + /// + /// Target (e.g. "10.0.0.1;10.0.0.2"), or null if there is none. + /// + public string Target { get; init; } + + /// + /// Name of the profile ("@name"), or null if no profile is used. Empty if only "@" was typed. + /// + public string ProfileName { get; init; } + + /// + /// Second argument (e.g. ports or DNS server), or null if there is none. + /// + public string Argument { get; init; } + + /// + /// Type keyword in lowercase (e.g. "aaaa"), or null if there is none. + /// + public string Type { get; init; } + + /// + /// Part of the input that is being typed. + /// + public RunCommandPosition Position { get; init; } + + /// + /// Word that is being typed (empty after a space), used to filter the suggestions. + /// + public string CurrentWord { get; init; } = string.Empty; + + /// + /// Input without the word that is being typed (e.g. "dns borntoberoot.net " for "dns borntoberoot.net aa"), + /// used to complete a suggestion. + /// + public string InputBeforeCurrentWord { get; init; } = string.Empty; + + /// + /// Error, if the input can't be run. + /// + public RunCommandParseError Error { get; init; } + + /// + /// Value the error refers to (e.g. the unknown command or argument). + /// + public string ErrorValue { get; init; } +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs new file mode 100644 index 0000000000..df43757b23 --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace NETworkManager.Models.RunCommand; + +/// +/// Parses the input of the run command dialog: "command target [argument] [type]" or "command @profile". +/// +public static partial class RunCommandParser +{ + /// + /// A list separator with whitespace around it ("10.0.0.1; 10.0.0.2"). + /// + [GeneratedRegex(@"\s*;\s*")] + private static partial Regex ListSeparatorRegex(); + + /// + /// Parses the input. + /// - Lists use ";" as everywhere in the application. Whitespace around ";" is removed, so whitespace only + /// separates the arguments. + /// - The first word is the command (name or alias), the second word the target. "@name" (the rest of the input, + /// since profile names can contain spaces) is a profile instead of a target. + /// - Further words are a type keyword of the command or its second argument (e.g. ports, DNS server), in any + /// order. + /// + /// Input of the dialog. + /// Available commands. + /// Parse result, see . + public static RunCommandParseResult Parse(string input, IEnumerable commands) + { + input = (input ?? string.Empty).TrimStart(); + + var endsWithWhitespace = input.Length > 0 && char.IsWhiteSpace(input[^1]); + var words = ListSeparatorRegex().Replace(input, ";").Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + + if (words.Length == 0) + return new RunCommandParseResult { Position = RunCommandPosition.Command, Error = RunCommandParseError.Empty }; + + var commandText = words[0]; + var command = commands.FirstOrDefault(x => x.Matches(commandText)); + + // The command is being typed. + if (command == null || words.Length == 1 && !endsWithWhitespace) + return new RunCommandParseResult + { + Command = command, + CommandText = commandText, + Position = RunCommandPosition.Command, + CurrentWord = commandText, + Error = command == null ? RunCommandParseError.UnknownCommand : RunCommandParseError.None, + ErrorValue = command == null ? commandText : null + }; + + var commandPrefix = commandText + " "; + + // Only the command (e.g. "ping "). + if (words.Length == 1) + return new RunCommandParseResult + { + Command = command, + CommandText = commandText, + Position = RunCommandPosition.Target, + InputBeforeCurrentWord = commandPrefix + }; + + // Profile: the rest of the input after "@" is the name. + if (words[1].StartsWith('@')) + { + var profileName = input[(input.IndexOf('@') + 1)..].Trim(); + + var error = !command.SupportsProfile + ? RunCommandParseError.NoProfiles + : profileName.Length == 0 + ? RunCommandParseError.MissingProfileName + : RunCommandParseError.None; + + return new RunCommandParseResult + { + Command = command, + CommandText = commandText, + ProfileName = profileName, + Position = RunCommandPosition.Target, + CurrentWord = "@" + profileName, + InputBeforeCurrentWord = commandPrefix, + Error = error, + ErrorValue = error == RunCommandParseError.NoProfiles ? command.Command : null + }; + } + + if (!command.SupportsTarget) + return new RunCommandParseResult + { + Command = command, + CommandText = commandText, + Position = RunCommandPosition.Target, + CurrentWord = words[1], + InputBeforeCurrentWord = commandPrefix, + Error = RunCommandParseError.NoArguments, + ErrorValue = command.Command + }; + + // Further words: type keyword or second argument, in any order. A type keyword is never the second argument. + string type = null; + string argument = null; + string unknownArgument = null; + + foreach (var word in words[2..]) + { + var keyword = word.ToLowerInvariant(); + + if (command.Types.Contains(keyword)) + { + if (type == null) + type = keyword; + else + unknownArgument ??= word; + } + else if (argument == null && command.Argument != RunCommandArgument.None) + { + argument = word; + } + else + { + unknownArgument ??= word; + } + } + + var isTypingTarget = words.Length == 2 && !endsWithWhitespace; + var currentWord = endsWithWhitespace ? string.Empty : words[^1]; + + return new RunCommandParseResult + { + Command = command, + CommandText = commandText, + Target = words[1], + Argument = argument, + Type = type, + Position = isTypingTarget ? RunCommandPosition.Target : RunCommandPosition.Argument, + CurrentWord = currentWord, + InputBeforeCurrentWord = string.Join(" ", words[..^(currentWord.Length == 0 ? 0 : 1)]) + " ", + Error = unknownArgument == null ? RunCommandParseError.None : RunCommandParseError.UnknownArgument, + ErrorValue = unknownArgument + }; + } +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandPosition.cs b/Source/NETworkManager.Models/RunCommand/RunCommandPosition.cs new file mode 100644 index 0000000000..66efa88f6b --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandPosition.cs @@ -0,0 +1,11 @@ +namespace NETworkManager.Models.RunCommand; + +/// +/// Part of the input that is being typed (used for the suggestions). +/// +public enum RunCommandPosition +{ + Command, + Target, + Argument +} diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandType.cs b/Source/NETworkManager.Models/RunCommand/RunCommandType.cs new file mode 100644 index 0000000000..6f10bbbc56 --- /dev/null +++ b/Source/NETworkManager.Models/RunCommand/RunCommandType.cs @@ -0,0 +1,10 @@ +namespace NETworkManager.Models.RunCommand; + +/// +/// What a run command opens. +/// +public enum RunCommandType +{ + Application, + Setting +} diff --git a/Source/NETworkManager.Tests/NETworkManager.Tests.csproj b/Source/NETworkManager.Tests/NETworkManager.Tests.csproj new file mode 100644 index 0000000000..233c58941c --- /dev/null +++ b/Source/NETworkManager.Tests/NETworkManager.Tests.csproj @@ -0,0 +1,18 @@ + + + Exe + NETworkManager.Tests + NETworkManager.Tests + net10.0-windows10.0.22621.0 + false + true + + + + + + + + + + diff --git a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs new file mode 100644 index 0000000000..770a1f096d --- /dev/null +++ b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs @@ -0,0 +1,232 @@ +using NETworkManager.Models; +using NETworkManager.Models.RunCommand; +using Xunit; + +namespace NETworkManager.Tests.RunCommand; + +public class RunCommandParserTests +{ + private static readonly RunCommandDefinition[] Commands = + [ + new() + { + Type = RunCommandType.Application, Application = ApplicationName.PingMonitor, Command = "pingmonitor", + Aliases = ["ping"], SupportsTarget = true, SupportsProfile = true + }, + new() + { + Type = RunCommandType.Application, Application = ApplicationName.DNSLookup, Command = "dnslookup", + Aliases = ["dns", "nslookup"], SupportsTarget = true, SupportsProfile = true, + Argument = RunCommandArgument.DNSServer, Types = ["a", "aaaa", "cname", "ptr"] + }, + new() + { + Type = RunCommandType.Application, Application = ApplicationName.PortScanner, Command = "portscanner", + Aliases = ["portscan"], SupportsTarget = true, SupportsProfile = true, Argument = RunCommandArgument.Ports + }, + new() + { + Type = RunCommandType.Application, Application = ApplicationName.SubnetCalculator, + Command = "subnetcalculator" + }, + new() { Type = RunCommandType.Setting, Command = "settings" } + ]; + + private static RunCommandParseResult Parse(string input) + { + return RunCommandParser.Parse(input, Commands); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Empty(string input) + { + var result = Parse(input); + + Assert.Equal(RunCommandParseError.Empty, result.Error); + Assert.Equal(RunCommandPosition.Command, result.Position); + } + + [Theory] + [InlineData("ping", "pingmonitor")] + [InlineData("PING", "pingmonitor")] + [InlineData("nslookup", "dnslookup")] + [InlineData("dnslookup", "dnslookup")] + public void CommandAndAliases(string input, string command) + { + var result = Parse(input); + + Assert.Equal(command, result.Command.Command); + Assert.Equal(RunCommandParseError.None, result.Error); + Assert.Equal(RunCommandPosition.Command, result.Position); + Assert.Equal(input, result.CurrentWord); + } + + [Fact] + public void UnknownCommand() + { + var result = Parse("pin 10.0.0.1"); + + Assert.Null(result.Command); + Assert.Equal(RunCommandParseError.UnknownCommand, result.Error); + Assert.Equal("pin", result.ErrorValue); + } + + [Fact] + public void CommandWithoutTarget() + { + var result = Parse("ping "); + + Assert.Equal(RunCommandParseError.None, result.Error); + Assert.Null(result.Target); + Assert.Equal(RunCommandPosition.Target, result.Position); + Assert.Equal("ping ", result.InputBeforeCurrentWord); + } + + [Fact] + public void Target() + { + var result = Parse("ping 10.0.0.1"); + + Assert.Equal("10.0.0.1", result.Target); + Assert.Equal(RunCommandPosition.Target, result.Position); + Assert.Equal("10.0.0.1", result.CurrentWord); + Assert.Equal("ping ", result.InputBeforeCurrentWord); + } + + [Theory] + [InlineData("ping 10.0.0.1; 10.0.0.2")] + [InlineData("ping 10.0.0.1 ;10.0.0.2")] + [InlineData("ping 10.0.0.1 ; 10.0.0.2")] + public void ListWithWhitespace(string input) + { + var result = Parse(input); + + Assert.Equal("10.0.0.1;10.0.0.2", result.Target); + Assert.Equal(RunCommandParseError.None, result.Error); + } + + [Theory] + [InlineData("dns borntoberoot.net 1.1.1.1 cname")] + [InlineData("dns borntoberoot.net CNAME 1.1.1.1")] + public void DNSServerAndTypeInAnyOrder(string input) + { + var result = Parse(input); + + Assert.Equal("borntoberoot.net", result.Target); + Assert.Equal("1.1.1.1", result.Argument); + Assert.Equal("cname", result.Type); + Assert.Equal(RunCommandParseError.None, result.Error); + } + + [Fact] + public void HostsAndType() + { + var result = Parse("dns 1.1.1.1; 1.0.0.1 ptr"); + + Assert.Equal("1.1.1.1;1.0.0.1", result.Target); + Assert.Equal("ptr", result.Type); + Assert.Null(result.Argument); + } + + [Fact] + public void PortsWithWhitespace() + { + var result = Parse("portscan 10.0.0.1; 10.0.0.2 22; 80; 443"); + + Assert.Equal("10.0.0.1;10.0.0.2", result.Target); + Assert.Equal("22;80;443", result.Argument); + Assert.Equal(RunCommandParseError.None, result.Error); + } + + [Fact] + public void TargetThatLooksLikeAType() + { + // The second word is always the target. + var result = Parse("dns a"); + + Assert.Equal("a", result.Target); + Assert.Null(result.Type); + } + + [Theory] + [InlineData("ping 10.0.0.1 foo", "foo")] + [InlineData("dns borntoberoot.net 1.1.1.1 8.8.8.8", "8.8.8.8")] + [InlineData("dns borntoberoot.net a aaaa 1.1.1.1", "aaaa")] + public void UnknownArgument(string input, string unknown) + { + var result = Parse(input); + + Assert.Equal(RunCommandParseError.UnknownArgument, result.Error); + Assert.Equal(unknown, result.ErrorValue); + } + + [Theory] + [InlineData("subnetcalculator 10.0.0.0/24")] + [InlineData("settings general")] + public void NoArguments(string input) + { + var result = Parse(input); + + Assert.Equal(RunCommandParseError.NoArguments, result.Error); + } + + [Theory] + [InlineData("ping @dns-01", "dns-01")] + [InlineData("ping @Web Server 01", "Web Server 01")] + [InlineData("ping @Web Server 01 ", "Web Server 01")] + [InlineData("ping @a;b", "a;b")] + public void Profile(string input, string profileName) + { + var result = Parse(input); + + Assert.Equal(profileName, result.ProfileName); + Assert.Null(result.Target); + Assert.Equal(RunCommandParseError.None, result.Error); + Assert.Equal(RunCommandPosition.Target, result.Position); + } + + [Fact] + public void ProfileWithoutName() + { + var result = Parse("ping @"); + + Assert.Equal(string.Empty, result.ProfileName); + Assert.Equal(RunCommandParseError.MissingProfileName, result.Error); + Assert.Equal("@", result.CurrentWord); + } + + [Fact] + public void ProfileNotSupported() + { + var result = Parse("settings @dns-01"); + + Assert.Equal(RunCommandParseError.NoProfiles, result.Error); + } + + [Fact] + public void HostWithAtSign() + { + // "@" is only a profile at the start of the target (e.g. "ssh admin@server01"). + var result = Parse("ping admin@server01"); + + Assert.Equal("admin@server01", result.Target); + Assert.Null(result.ProfileName); + } + + [Theory] + [InlineData("dns borntoberoot.net ", RunCommandPosition.Argument, "", "dns borntoberoot.net ")] + [InlineData("dns borntoberoot.net aa", RunCommandPosition.Argument, "aa", "dns borntoberoot.net ")] + [InlineData("dns borntoberoot.net 1.1.1.1 cn", RunCommandPosition.Argument, "cn", "dns borntoberoot.net 1.1.1.1 ")] + [InlineData("dns border", RunCommandPosition.Target, "border", "dns ")] + public void PositionForSuggestions(string input, RunCommandPosition position, string currentWord, string before) + { + var result = Parse(input); + + Assert.Equal(position, result.Position); + Assert.Equal(currentWord, result.CurrentWord); + Assert.Equal(before, result.InputBeforeCurrentWord); + } +} diff --git a/Source/NETworkManager.sln b/Source/NETworkManager.sln index bffc498bae..feaef76599 100644 --- a/Source/NETworkManager.sln +++ b/Source/NETworkManager.sln @@ -43,6 +43,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dragablz", "3rdparty\Dragab EndProject Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "NETworkManager.Setup", "NETworkManager.Setup\NETworkManager.Setup.wixproj", "{B474758D-E657-4DA6-AB2B-63764C5FBAF1}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NETworkManager.Tests", "NETworkManager.Tests\NETworkManager.Tests.csproj", "{53C05A40-D2B3-42F1-AA1D-E220BBF9E426}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -338,6 +340,26 @@ Global {B474758D-E657-4DA6-AB2B-63764C5FBAF1}.Release|x64.Build.0 = Release|x64 {B474758D-E657-4DA6-AB2B-63764C5FBAF1}.Release|x86.ActiveCfg = Release|x86 {B474758D-E657-4DA6-AB2B-63764C5FBAF1}.Release|x86.Build.0 = Release|x86 + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|Any CPU.Build.0 = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|ARM.ActiveCfg = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|ARM.Build.0 = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|ARM64.Build.0 = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|x64.ActiveCfg = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|x64.Build.0 = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|x86.ActiveCfg = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Debug|x86.Build.0 = Debug|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|Any CPU.ActiveCfg = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|Any CPU.Build.0 = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|ARM.ActiveCfg = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|ARM.Build.0 = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|ARM64.ActiveCfg = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|ARM64.Build.0 = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|x64.ActiveCfg = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|x64.Build.0 = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|x86.ActiveCfg = Release|Any CPU + {53C05A40-D2B3-42F1-AA1D-E220BBF9E426}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Source/NETworkManager/Controls/RunCommandComboBox.cs b/Source/NETworkManager/Controls/RunCommandComboBox.cs deleted file mode 100644 index df6489c31c..0000000000 --- a/Source/NETworkManager/Controls/RunCommandComboBox.cs +++ /dev/null @@ -1,337 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Windows.Controls; -using System.Windows.Controls.Primitives; -using System.Windows.Data; -using System.Windows.Input; - -namespace NETworkManager.Controls; - -/* - * Modified from https://stackoverflow.com/a/58066259 - */ - -public class RunCommandComboBox : ComboBox -{ - private readonly UserChange _isDropDownOpenUc; - private string _currentFilter = string.Empty; - private bool _textBoxFreezed; - - public RunCommandComboBox() - { - _isDropDownOpenUc = new UserChange(v => IsDropDownOpen = v); - DropDownOpened += FilteredComboBox_DropDownOpened; - - IsEditable = true; - IsTextSearchEnabled = false; - StaysOpenOnEdit = true; - IsReadOnly = false; - - Loaded += (_, _) => - { - if (EditableTextBox != null) - new TextBoxBaseUserChangeTracker(EditableTextBox).UserTextChanged += FilteredComboBox_UserTextChange; - }; - } - - private TextBox EditableTextBox => GetTemplateChild("PART_EditableTextBox") as TextBox; - - /// - /// Open the dropdown when the control gets focus. - /// - /// Focus change information. - protected override void OnPreviewGotKeyboardFocus(KeyboardFocusChangedEventArgs e) - { - base.OnPreviewGotKeyboardFocus(e); - - Width = 350; - IsDropDownOpen = true; - } - - protected override void OnPreviewLostKeyboardFocus(KeyboardFocusChangedEventArgs e) - { - base.OnPreviewLostKeyboardFocus(e); - - Width = 250; - } - - protected override void OnPreviewKeyDown(KeyEventArgs e) - { - switch (e.Key) - { - case Key.Down when !IsDropDownOpen: - IsDropDownOpen = true; - e.Handled = true; - break; - case Key.Escape: - ClearFilter(); - Text = string.Empty; - IsDropDownOpen = true; - e.Handled = true; - break; - // Auto-complete the text when the user presses the enter key, tab key, or right arrow - // and the drop-down is open. - case Key.Enter: - case Key.Tab: - case Key.Right: - if (IsDropDownOpen) - { - // Insert the selected item into the text box. - var result = InsertSelectedItem(); - - // Close the drop-down. - IsDropDownOpen = false; - - // If the enter key was pressed and the item has no arguments then use the default - // behavior (which will fire the enter key command in the control - to redirect - // to another application). - if (e.Key == Key.Enter && result == 0) - { - e.Handled = false; - break; - } - - // Handle the key press (tab, enter or right arrow) to auto-complete the text and - // don't pass it to the control. - if (result != -1) - e.Handled = true; - - // If no item was selected, the result will be -1 and the key press will be - // passed to the control. - } - - break; - } - - base.OnPreviewKeyDown(e); - } - - /// - /// Inserts the selected item into the text box. - /// - /// - /// Returns 0 if the item was inserted, 1 if the item was inserted with example arguments, and -1 if the item was - /// not inserted. - /// - private int InsertSelectedItem() - { - if (SelectedItem is not RunCommandInfo info) - return -1; - - Text = $"{info.Command}"; - - if (info.CanHandleArguments) - { - Text += $" {info.ExampleArguments}"; - EditableTextBox.SelectionStart = Text.Length - info.ExampleArguments.Length; - EditableTextBox.SelectionLength = info.ExampleArguments.Length; - - return 1; - } - - EditableTextBox.SelectionStart = Text.Length; - EditableTextBox.SelectionLength = 0; - - return 0; - } - - private void ClearFilter() - { - if (string.IsNullOrEmpty(_currentFilter)) - return; - - _currentFilter = string.Empty; - - CollectionViewSource.GetDefaultView(ItemsSource).Refresh(); - } - - private void FilteredComboBox_DropDownOpened(object sender, EventArgs e) - { - if (_isDropDownOpenUc.IsUserChange) - ClearFilter(); - } - - private void FilteredComboBox_UserTextChange(object sender, EventArgs e) - { - if (_textBoxFreezed) - return; - - var tb = EditableTextBox; - _currentFilter = tb.SelectionStart + tb.SelectionLength == tb.Text.Length - ? tb.Text[..tb.SelectionStart].ToLower() - : tb.Text.ToLower(); - - RefreshFilter(); - } - - protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue) - { - if (newValue != null) - { - var view = CollectionViewSource.GetDefaultView(newValue); - view.Filter += FilterItem; - } - - if (oldValue != null) - { - var view = CollectionViewSource.GetDefaultView(oldValue); - if (view != null) view.Filter -= FilterItem; - } - - base.OnItemsSourceChanged(oldValue, newValue); - } - - private void RefreshFilter() - { - if (ItemsSource == null) - return; - - var view = CollectionViewSource.GetDefaultView(ItemsSource); - - FreezeTextBoxState(() => - { - var isDropDownOpen = IsDropDownOpen; - //always hide because showing it enables the user to pick with up and down keys, otherwise it's not working because of the glitch in view.Refresh() - _isDropDownOpenUc.Set(false); - view.Refresh(); - - if (!string.IsNullOrEmpty(_currentFilter) || isDropDownOpen) - _isDropDownOpenUc.Set(true); - - if (SelectedItem != null) - return; - - // Loop through all items - foreach (RunCommandInfo item in ItemsSource) - { - // Check if any item contains the text - if (!item.TranslatedName.Contains(Text, StringComparison.OrdinalIgnoreCase) && - !item.Name.Contains(Text, StringComparison.OrdinalIgnoreCase) && - !item.Command.Contains(Text, StringComparison.OrdinalIgnoreCase)) - continue; - - SelectedItem = item; - break; - } - }); - } - - private void FreezeTextBoxState(Action action) - { - _textBoxFreezed = true; - var tb = EditableTextBox; - var text = Text; - var selStart = tb.SelectionStart; - var selLen = tb.SelectionLength; - action(); - Text = text; - tb.SelectionStart = selStart; - tb.SelectionLength = selLen; - _textBoxFreezed = false; - } - - private bool FilterItem(object value) - { - if (value is not RunCommandInfo info) - return false; - - return _currentFilter.Length == 0 || - info.TranslatedName.Contains(Text, StringComparison.OrdinalIgnoreCase) || - info.Name.Contains(Text, StringComparison.OrdinalIgnoreCase) || - info.Command.Contains(Text, StringComparison.OrdinalIgnoreCase); - - //value.ToString()!.ToLower().Contains(_currentFilter); - } - - private class TextBoxBaseUserChangeTracker - { - private readonly List _pressedKeys = new(); - - public TextBoxBaseUserChangeTracker(TextBoxBase textBoxBase) - { - TextBoxBase = textBoxBase; - var lastText = TextBoxBase.ToString(); - - textBoxBase.PreviewTextInput += (_, _) => { IsTextInput = true; }; - - textBoxBase.TextChanged += (_, e) => - { - var isUserChange = _pressedKeys.Count > 0 || IsTextInput || lastText == TextBoxBase.ToString(); - IsTextInput = false; - lastText = TextBoxBase.ToString(); - if (isUserChange) - UserTextChanged?.Invoke(this, e); - }; - - textBoxBase.PreviewKeyDown += (_, e) => - { - switch (e.Key) - { - case Key.Back: - case Key.Space: - if (!_pressedKeys.Contains(e.Key)) - _pressedKeys.Add(e.Key); - break; - } - - if (e.Key != Key.Back) - return; - - var textBox = textBoxBase as TextBox; - if (textBox!.SelectionStart <= 0 || textBox.SelectionLength <= 0 || - textBox.SelectionStart + textBox.SelectionLength != textBox.Text.Length) - return; - - textBox.SelectionStart--; - textBox.SelectionLength++; - - e.Handled = true; - - UserTextChanged?.Invoke(this, e); - }; - - textBoxBase.PreviewKeyUp += (_, e) => - { - if (_pressedKeys.Contains(e.Key)) - _pressedKeys.Remove(e.Key); - }; - - textBoxBase.LostFocus += (_, _) => - { - _pressedKeys.Clear(); - IsTextInput = false; - }; - } - - private bool IsTextInput { get; set; } - - private TextBoxBase TextBoxBase { get; } - public event EventHandler UserTextChanged; - } - - private class UserChange - { - private readonly Action _action; - - public UserChange(Action action) - { - _action = action; - } - - public bool IsUserChange { get; private set; } = true; - - public void Set(T val) - { - try - { - IsUserChange = false; - _action(val); - } - finally - { - IsUserChange = true; - } - } - } -} \ No newline at end of file diff --git a/Source/NETworkManager/RunCommandInfo.cs b/Source/NETworkManager/RunCommandInfo.cs deleted file mode 100644 index 4a86602f5b..0000000000 --- a/Source/NETworkManager/RunCommandInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace NETworkManager; - -public class RunCommandInfo -{ - public RunCommandType Type { get; init; } - - /// - /// Name. - /// - public string Name { get; init; } - - /// - /// Translated name. - /// - public string TranslatedName { get; init; } - - /// - /// Command to run. - /// - public string Command { get; set; } - - /// - /// Indicates whether (optional) arguments can be passed. - /// - public bool CanHandleArguments { get; init; } - - /// - /// Example arguments. - /// - public string ExampleArguments { get; set; } - - public override string ToString() - { - return $"{Command}"; - } -} \ No newline at end of file diff --git a/Source/NETworkManager/RunCommandType.cs b/Source/NETworkManager/RunCommandType.cs deleted file mode 100644 index ba94a8abc0..0000000000 --- a/Source/NETworkManager/RunCommandType.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace NETworkManager; - -public enum RunCommandType -{ - Application, - - Setting -} \ No newline at end of file diff --git a/Source/global.json b/Source/global.json index d258315f46..a688739275 100644 --- a/Source/global.json +++ b/Source/global.json @@ -3,5 +3,8 @@ "version": "10.0.401", "rollForward": "latestFeature", "allowPrerelease": false + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } \ No newline at end of file From 084d5037093888bd28a80b786dfe52ddeb01f514 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:47:50 +0200 Subject: [PATCH 03/14] Feature: Run command dialog with arguments, profiles and suggestions - The run command (Ctrl+Shift+P) opens a dialog instead of the flyout: type e.g. "ping 10.0.0.1", "dns borntoberoot.net 1.1.1.1 aaaa" or "rdp @server01" and press Enter. Esc closes the dialog. - Suggestions: recent commands, applications (with aliases like ping, dns, rdp, ssh), profiles of the application (@name) and type values. Up/Down select, Tab completes, Enter runs the selection (the input as typed is preselected). - The target is validated with the validators of the application's input fields before the application is opened. - Only visible applications are offered. - Run command history (setting RunCommand_History). - Removed the flyout and the unused RunCommandComboBox. Co-Authored-By: Claude Opus 5.5 --- .../Resources/Strings.Designer.cs | 90 +++++ .../Resources/Strings.resx | 30 ++ Source/NETworkManager.Profiles/ProfileInfo.cs | 30 ++ .../NETworkManager.Settings/SettingsInfo.cs | 19 + Source/NETworkManager/MainWindow.xaml | 107 ------ Source/NETworkManager/MainWindow.xaml.cs | 187 +-------- Source/NETworkManager/RunCommandManager.cs | 335 +++++++++++++--- Source/NETworkManager/RunCommandSuggestion.cs | 70 ++++ .../ViewModels/RunCommandViewModel.cs | 363 ++++++++++++++++++ .../Views/RunCommandChildWindow.xaml | 188 +++++++++ .../Views/RunCommandChildWindow.xaml.cs | 72 ++++ 11 files changed, 1156 insertions(+), 335 deletions(-) create mode 100644 Source/NETworkManager/RunCommandSuggestion.cs create mode 100644 Source/NETworkManager/ViewModels/RunCommandViewModel.cs create mode 100644 Source/NETworkManager/Views/RunCommandChildWindow.xaml create mode 100644 Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index 9bdb5486be..33bee494ed 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -2052,6 +2052,24 @@ public static string CommandLineArguments { } } + /// + /// Looks up a localized string similar to The command "{0}" does not accept arguments!. + /// + public static string CommandXDoesNotAcceptArguments { + get { + return ResourceManager.GetString("CommandXDoesNotAcceptArguments", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The command "{0}" does not support profiles!. + /// + public static string CommandXDoesNotSupportProfiles { + get { + return ResourceManager.GetString("CommandXDoesNotSupportProfiles", resourceCulture); + } + } + /// /// Looks up a localized string similar to Comment. /// @@ -4057,6 +4075,15 @@ public static string EndTime { } } + /// + /// Looks up a localized string similar to Enter a command!. + /// + public static string EnterACommand { + get { + return ResourceManager.GetString("EnterACommand", resourceCulture); + } + } + /// /// Looks up a localized string similar to Enter a domain to query whois.... /// @@ -4156,6 +4183,15 @@ public static string EnterSubnetsToCreateAWideSubnet { } } + /// + /// Looks up a localized string similar to Enter the name of a profile after "@"!. + /// + public static string EnterTheNameOfAProfileAfterAt { + get { + return ResourceManager.GetString("EnterTheNameOfAProfileAfterAt", resourceCulture); + } + } + /// /// Looks up a localized string similar to Enter a valid AS number (e.g., "AS15169")!. /// @@ -9292,6 +9328,15 @@ public static string ProfileWithThisNameAlreadyExists { } } + /// + /// Looks up a localized string similar to Profile "{0}" not found!. + /// + public static string ProfileXNotFound { + get { + return ResourceManager.GetString("ProfileXNotFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to Program. /// @@ -9752,6 +9797,15 @@ public static string ReceivedLost { } } + /// + /// Looks up a localized string similar to Recent. + /// + public static string Recent { + get { + return ResourceManager.GetString("Recent", resourceCulture); + } + } + /// /// Looks up a localized string similar to Reconnect. /// @@ -11367,6 +11421,15 @@ public static string RunBackgroundJobEveryXMinute { } } + /// + /// Looks up a localized string similar to Run command. + /// + public static string RunCommand { + get { + return ResourceManager.GetString("RunCommand", resourceCulture); + } + } + /// /// Looks up a localized string similar to Run command.... /// @@ -11884,6 +11947,15 @@ public static string SettingsResetExclamationMark { } } + /// + /// Looks up a localized string similar to Several profiles are named "{0}", select one!. + /// + public static string SeveralProfilesAreNamedXSelectOne { + get { + return ResourceManager.GetString("SeveralProfilesAreNamedXSelectOne", resourceCulture); + } + } + /// /// Looks up a localized string similar to Share Gateway credentials with remote computer. /// @@ -13244,6 +13316,24 @@ public static string Unknown { } } + /// + /// Looks up a localized string similar to Unknown argument "{0}"!. + /// + public static string UnknownArgumentX { + get { + return ResourceManager.GetString("UnknownArgumentX", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unknown command "{0}"!. + /// + public static string UnknownCommandX { + get { + return ResourceManager.GetString("UnknownCommandX", resourceCulture); + } + } + /// /// Looks up a localized string similar to Unkown error!. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 17a09901cd..667accf8ac 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -4857,4 +4857,34 @@ Cloudflare may log your IP address and network information. See Cloudflare's pri List of public suffixes (e.g. "co.uk") from publicsuffix.org (MPL-2.0), which is used to get the registered domain of a hostname for RDAP queries. + + Profile "{0}" not found! + + + Several profiles are named "{0}", select one! + + + Enter a command! + + + Unknown command "{0}"! + + + The command "{0}" does not accept arguments! + + + The command "{0}" does not support profiles! + + + Enter the name of a profile after "@"! + + + Unknown argument "{0}"! + + + Run command + + + Recent + \ No newline at end of file diff --git a/Source/NETworkManager.Profiles/ProfileInfo.cs b/Source/NETworkManager.Profiles/ProfileInfo.cs index 05f61c63bb..8ddbb9e16c 100644 --- a/Source/NETworkManager.Profiles/ProfileInfo.cs +++ b/Source/NETworkManager.Profiles/ProfileInfo.cs @@ -1,5 +1,6 @@ //using System.Collections.Generic; using NETworkManager.Controls; +using NETworkManager.Models; using NETworkManager.Models.Network; using NETworkManager.Models.PowerShell; using NETworkManager.Models.PuTTY; @@ -504,4 +505,33 @@ public ProfileInfo(ProfileInfo profile) public bool IPGeolocation_Enabled { get; set; } public bool IPGeolocation_InheritHost { get; set; } = true; public string IPGeolocation_Host { get; set; } + + /// + /// Checks if the profile is enabled for an application. + /// + /// Application to check. + /// True if the profile is enabled for the application. + public bool IsEnabled(ApplicationName application) + { + return application switch + { + ApplicationName.NetworkInterface => NetworkInterface_Enabled, + ApplicationName.IPScanner => IPScanner_Enabled, + ApplicationName.PortScanner => PortScanner_Enabled, + ApplicationName.PingMonitor => PingMonitor_Enabled, + ApplicationName.Traceroute => Traceroute_Enabled, + ApplicationName.DNSLookup => DNSLookup_Enabled, + ApplicationName.RemoteDesktop => RemoteDesktop_Enabled, + ApplicationName.PowerShell => PowerShell_Enabled, + ApplicationName.PuTTY => PuTTY_Enabled, + ApplicationName.TigerVNC => TigerVNC_Enabled, + ApplicationName.WebConsole => WebConsole_Enabled, + ApplicationName.SNMP => SNMP_Enabled, + ApplicationName.WakeOnLAN => WakeOnLAN_Enabled, + ApplicationName.RDAP => RDAP_Enabled, + ApplicationName.Whois => Whois_Enabled, + ApplicationName.IPGeolocation => IPGeolocation_Enabled, + _ => false + }; + } } \ No newline at end of file diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index 2338dddb2e..3281631f50 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -808,6 +808,22 @@ public bool ExpandApplicationView } } + // Run command + + public ObservableCollection RunCommand_History + { + get; + set + { + if (value == field) + return; + + UpdateCollectionChanged(field, value); + field = value; + OnPropertyChanged(); + } + } = []; + #endregion #region Dashboard @@ -4056,6 +4072,9 @@ public SettingsInfo() // General General_ApplicationList.CollectionChanged += CollectionChanged; + // Run command + RunCommand_History.CollectionChanged += CollectionChanged; + // Network Network_CustomDNSServers.CollectionChanged += CollectionChanged; diff --git a/Source/NETworkManager/MainWindow.xaml b/Source/NETworkManager/MainWindow.xaml index 2fb168ba6e..cea22b8737 100644 --- a/Source/NETworkManager/MainWindow.xaml +++ b/Source/NETworkManager/MainWindow.xaml @@ -90,7 +90,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Source/NETworkManager/MainWindow.xaml.cs b/Source/NETworkManager/MainWindow.xaml.cs index e5dd61ae76..755edf96af 100644 --- a/Source/NETworkManager/MainWindow.xaml.cs +++ b/Source/NETworkManager/MainWindow.xaml.cs @@ -304,19 +304,6 @@ public bool SettingsViewIsOpen } } - public bool FlyoutRunCommandIsOpen - { - get; - set - { - if (value == field) - return; - - field = value; - OnPropertyChanged(); - } - } - public bool IsRestartRequired { get; @@ -536,9 +523,6 @@ private void Load() // Load application list, filter, sort, etc. LoadApplicationList(); - // Load run commands - SetRunCommandsView(); - // Load the profiles LoadProfiles(); @@ -1099,175 +1083,46 @@ private void TextBoxApplicationSearch_OnPreviewExecuted(object sender, ExecutedR #region Run Command - #region Variables - - public ICollectionView RunCommands - { - get; - private set - { - if (value == field) - return; - - field = value; - OnPropertyChanged(); - } - } - - public RunCommandInfo SelectedRunCommand - { - get; - set - { - if (value == field) - return; - - field = value; - OnPropertyChanged(); - } - } - - public string RunCommandSearch - { - get; - set - { - if (value == field) - return; - - field = value; - - RefreshRunCommandsView(); - - OnPropertyChanged(); - } - } - - #endregion - - #region ICommands & Actions - public ICommand OpenRunCommand => new RelayCommand(_ => OpenRunAction()); private void OpenRunAction() { - ConfigurationManager.OnDialogOpen(); - - FlyoutRunCommandIsOpen = true; - } - - public ICommand RunCommandDoCommand => new RelayCommand(_ => RunCommandDoAction()); - - private void RunCommandDoAction() - { - RunCommandDo(); - } - - public ICommand RunCommandCloseCommand => new RelayCommand(_ => RunCommandCloseAction()); - - private void RunCommandCloseAction() - { - RunCommandFlyoutClose(); - } - - #endregion + // Only one dialog at a time (e.g. the shortcut while another dialog is open) + if (ConfigurationManager.Current.IsChildWindowOpen) + return; - #region Methods + var childWindow = new RunCommandChildWindow(); - private void SetRunCommandsView(RunCommandInfo selectedRunCommand = null) - { - RunCommands = new CollectionViewSource { Source = RunCommandManager.GetList() }.View; - - RunCommands.Filter = o => + var childWindowViewModel = new RunCommandViewModel(instance => { - if (o is not RunCommandInfo info) - return false; - - if (string.IsNullOrEmpty(RunCommandSearch)) - return true; - - return info.TranslatedName.IndexOf(RunCommandSearch, StringComparison.OrdinalIgnoreCase) > -1 || - info.Name.IndexOf(RunCommandSearch, StringComparison.OrdinalIgnoreCase) > -1; - }; - - if (selectedRunCommand != null) - SelectedRunCommand = RunCommands.Cast() - .FirstOrDefault(x => x.Name == selectedRunCommand.Name); - - SelectedRunCommand ??= RunCommands.Cast().FirstOrDefault(); - } - - private void RefreshRunCommandsView() - { - SetRunCommandsView(SelectedRunCommand); - } + childWindow.IsOpen = false; + ConfigurationManager.Current.IsChildWindowOpen = false; - /// - /// Execute the selected run command. - /// - private void RunCommandDo() - { - // Do nothing if no command is selected - if (SelectedRunCommand == null) - return; + ConfigurationManager.OnDialogClose(); - // Do the command - switch (SelectedRunCommand.Type) - { - // Redirect to application - case RunCommandType.Application: - var applicationName = (ApplicationName)Enum.Parse(typeof(ApplicationName), SelectedRunCommand.Name); - NavigationManager.Open(new ApplicationRequest(applicationName)); - break; - // Redirect to settings - case RunCommandType.Setting: + if (instance.IsSettings) NavigationManager.OpenSettings(); - break; - } - - // Close the flyout - RunCommandFlyoutClose(true); - } - - /// - /// Close the run command flyout and clear the search. - /// - private void RunCommandFlyoutClose(bool clearSearch = false) - { - if (!FlyoutRunCommandIsOpen) - return; - - FlyoutRunCommandIsOpen = false; - - ConfigurationManager.OnDialogClose(); - - // Clear the search - if (clearSearch) - RunCommandSearch = string.Empty; - } + else + NavigationManager.Open(instance.Request); + }, _ => + { + childWindow.IsOpen = false; + ConfigurationManager.Current.IsChildWindowOpen = false; - #endregion + ConfigurationManager.OnDialogClose(); + }); - #region Events + childWindow.DataContext = childWindowViewModel; - private void ListViewRunCommand_OnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) - { - RunCommandDo(); - } + ConfigurationManager.Current.IsChildWindowOpen = true; - private void FlyoutRunCommand_IsKeyboardFocusWithinChanged(object sender, DependencyPropertyChangedEventArgs e) - { - // Close flyout if the focus is lost. - if (e.NewValue is not false) - return; + ConfigurationManager.OnDialogOpen(); - RunCommandFlyoutClose(); + _ = this.ShowChildWindowAsync(childWindow); } #endregion - #endregion - #region Settings private void OpenSettings() diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs index 8308c96170..8d6392fca8 100644 --- a/Source/NETworkManager/RunCommandManager.cs +++ b/Source/NETworkManager/RunCommandManager.cs @@ -1,82 +1,293 @@ -using System.Collections.Generic; -using System.Linq; +using DnsClient; using NETworkManager.Localization; using NETworkManager.Localization.Resources; using NETworkManager.Models; +using NETworkManager.Models.Network; +using NETworkManager.Models.RDAP; +using NETworkManager.Models.RunCommand; +using NETworkManager.Profiles; +using NETworkManager.Settings; +using NETworkManager.Validators; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Windows.Controls; namespace NETworkManager; +/// +/// Commands of the run command dialog: definitions, profiles, validation and the request to run. +/// public static class RunCommandManager { - private const string DefaultArguments = ""; + /// + /// Type keywords of RDAP and their query type. + /// + private static readonly Dictionary RDAPTypes = new() + { + ["domain"] = RDAPQueryType.Domain, + ["nameserver"] = RDAPQueryType.Nameserver, + ["tld"] = RDAPQueryType.TLD, + ["ip"] = RDAPQueryType.IPAddress, + ["asn"] = RDAPQueryType.ASN, + ["entity"] = RDAPQueryType.Entity + }; + + private static readonly IReadOnlyList Commands = CreateCommands(); - public static IEnumerable GetList() + /// + /// Gets the commands of the visible applications and the settings. + /// + /// Available commands. + public static IReadOnlyList GetCommands() { - return GetApplicationList().Concat(GetSettingsList()); + var visibleApplications = SettingsManager.Current.General_ApplicationList.Where(x => x.IsVisible) + .Select(x => x.Name).ToHashSet(); + + return [.. Commands.Where(x => x.Type == RunCommandType.Setting || visibleApplications.Contains(x.Application))]; } - private static IEnumerable GetSettingsList() + /// + /// Gets the translated name of the application (or settings) of a command. + /// + /// Command. + /// Translated name. + public static string GetTranslatedName(RunCommandDefinition command) { - return - [ - new RunCommandInfo - { - Type = RunCommandType.Setting, - Name = "Settings", - TranslatedName = Strings.Settings, - Command = "settings", - CanHandleArguments = false, - ExampleArguments = string.Empty - } - ]; + return command.Type == RunCommandType.Setting + ? Strings.Settings + : ResourceTranslator.Translate(ResourceIdentifier.ApplicationName, command.Application); } - private static IEnumerable GetApplicationList() + /// + /// Gets the profiles that are enabled for the application of a command. + /// + /// Command. + /// Profiles, ordered by group and name. + public static IEnumerable GetProfiles(RunCommandDefinition command) { - return ApplicationManager.GetNames().Where(name => name != ApplicationName.None).Select(name => - new RunCommandInfo + if (!command.SupportsProfile || ProfileManager.LoadedProfileFileData == null) + return []; + + return ProfileManager.LoadedProfileFileData.Groups.SelectMany(x => x.Profiles) + .Where(x => x.IsEnabled(command.Application)) + .OrderBy(x => x.Group).ThenBy(x => x.Name); + } + + /// + /// Validates the input and creates the request to run. + /// + /// Parsed input. + /// + /// Profile selected in the suggestions. If null, the profile is looked up by its name ("@name"), which must be + /// unique. + /// + /// Request to open the application, or null for the settings or on error. + /// Error message, or null if the input is valid. + public static string TryCreateRequest(RunCommandParseResult result, ProfileInfo profile, + out ApplicationRequest request) + { + request = null; + + var error = GetParseErrorMessage(result); + + if (error != null) + return error; + + if (result.Command.Type == RunCommandType.Setting) + return null; + + // Profile ("@name" or selected in the suggestions) + if (result.ProfileName != null) + { + if (profile == null) { - Type = RunCommandType.Application, - Name = name.ToString(), - TranslatedName = ResourceTranslator.Translate(ResourceIdentifier.ApplicationName, name), - Command = name switch - { - _ => name.ToString().ToLower() - }, - CanHandleArguments = name switch - { - ApplicationName.IPScanner => true, - ApplicationName.PortScanner => true, - ApplicationName.PingMonitor => true, - ApplicationName.Traceroute => true, - ApplicationName.DNSLookup => true, - ApplicationName.RemoteDesktop => true, - ApplicationName.PowerShell => true, - ApplicationName.PuTTY => true, - ApplicationName.TigerVNC => true, - ApplicationName.WebConsole => true, - ApplicationName.SNMP => true, - ApplicationName.RDAP => true, - ApplicationName.Whois => true, - _ => false - }, - ExampleArguments = name switch + var profiles = GetProfiles(result.Command) + .Where(x => string.Equals(x.Name, result.ProfileName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + switch (profiles.Count) { - ApplicationName.IPScanner => "<10.0.0.0/24>", - ApplicationName.PortScanner => "<10.0.0.10;10.0.0.20> <1-1024>", - ApplicationName.PingMonitor => DefaultArguments, - ApplicationName.Traceroute => DefaultArguments, - ApplicationName.DNSLookup => " ", - ApplicationName.RemoteDesktop => DefaultArguments, - ApplicationName.PowerShell => DefaultArguments, - ApplicationName.PuTTY => DefaultArguments, - ApplicationName.TigerVNC => DefaultArguments, - ApplicationName.WebConsole => "", - ApplicationName.SNMP => DefaultArguments, - ApplicationName.RDAP => "", - ApplicationName.Whois => "", - _ => string.Empty + case 0: + return string.Format(Strings.ProfileXNotFound, result.ProfileName); + case > 1: + return string.Format(Strings.SeveralProfilesAreNamedXSelectOne, result.ProfileName); } - }).ToList(); + + profile = profiles[0]; + } + + request = new ApplicationRequest(result.Command.Application) { Profile = profile }; + + return null; + } + + // Only open the application + if (result.Target == null) + { + request = new ApplicationRequest(result.Command.Application); + + return null; + } + + error = ValidateTarget(result); + + if (error != null) + return error; + + request = CreateRequest(result); + + return null; + } + + /// + /// Gets the message for a parse error. + /// + private static string GetParseErrorMessage(RunCommandParseResult result) + { + return result.Error switch + { + RunCommandParseError.None => null, + RunCommandParseError.Empty => Strings.EnterACommand, + RunCommandParseError.UnknownCommand => string.Format(Strings.UnknownCommandX, result.ErrorValue), + RunCommandParseError.NoArguments => string.Format(Strings.CommandXDoesNotAcceptArguments, result.ErrorValue), + RunCommandParseError.NoProfiles => string.Format(Strings.CommandXDoesNotSupportProfiles, result.ErrorValue), + RunCommandParseError.MissingProfileName => Strings.EnterTheNameOfAProfileAfterAt, + RunCommandParseError.UnknownArgument => string.Format(Strings.UnknownArgumentX, result.ErrorValue), + _ => throw new ArgumentOutOfRangeException(nameof(result), result.Error, null) + }; + } + + /// + /// Validates the target and the second argument with the validators of the application's input fields. + /// + private static string ValidateTarget(RunCommandParseResult result) + { + ValidationRule targetValidator = result.Command.Application switch + { + ApplicationName.IPScanner or ApplicationName.PortScanner or ApplicationName.PingMonitor => + new MultipleHostsRangeValidator(), + ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTTY + or ApplicationName.TigerVNC => new IPAddressOrHostnameValidator(), + ApplicationName.RemoteDesktop => new RemoteDesktopHostnameAndPortValidator(), + ApplicationName.WebConsole => new HttpAndHttpsUriValidator(), + ApplicationName.Whois => new DomainValidator(), + _ => null + }; + + var error = Validate(targetValidator, result.Target); + + if (error != null) + return error; + + if (result.Command.Argument == RunCommandArgument.Ports && result.Argument != null) + return Validate(new PortRangeValidator(), result.Argument); + + // RDAP: the query must match the type (e.g. "1.10" as ASN) + if (result.Command.Application == ApplicationName.RDAP) + { + var parseError = RDAPQueryParser.TryParse(GetRDAPQueryType(result.Type), result.Target, out _); + + if (parseError != RDAPQueryParseError.None) + return RDAPQueryValidator.GetErrorMessage(parseError); + } + + return null; + } + + private static string Validate(ValidationRule validator, string value) + { + if (validator == null) + return null; + + var validationResult = validator.Validate(value, CultureInfo.CurrentCulture); + + return validationResult.IsValid ? null : validationResult.ErrorContent as string; + } + + /// + /// Creates the request with the options of the application. + /// + private static ApplicationRequest CreateRequest(RunCommandParseResult result) + { + return result.Command.Application switch + { + ApplicationName.DNSLookup => new DNSLookupRequest + { + Target = result.Target, + DNSServer = result.Argument, + QueryType = result.Type != null + ? Enum.Parse(result.Type, true) + // Only IP addresses: reverse lookup + : result.Target.Split(';').All(x => IPAddress.TryParse(x, out _)) + ? QueryType.PTR + : null + }, + ApplicationName.PortScanner => new PortScannerRequest { Target = result.Target, Ports = result.Argument }, + ApplicationName.RDAP => new RDAPRequest { Target = result.Target, QueryType = GetRDAPQueryType(result.Type) }, + _ => new ApplicationRequest(result.Command.Application) { Target = result.Target } + }; + } + + private static RDAPQueryType GetRDAPQueryType(string type) + { + return type != null ? RDAPTypes[type] : RDAPQueryType.Auto; + } + + /// + /// Creates the commands of all applications and the settings. Commands and aliases are lowercase English keywords + /// (not translated); the applications are shown with their translated name. + /// + private static List CreateCommands() + { + var commands = ApplicationManager.GetNames().Where(x => x != ApplicationName.None).Select(name => name switch + { + ApplicationName.IPScanner => Create(name, ["scan"], "", true), + ApplicationName.PortScanner => Create(name, ["portscan"], " [ports]", true, + RunCommandArgument.Ports), + ApplicationName.PingMonitor => Create(name, ["ping"], "", true), + ApplicationName.Traceroute => Create(name, ["trace", "tracert"], "", true), + ApplicationName.DNSLookup => Create(name, ["dns", "nslookup"], " [server] [type]", true, + RunCommandArgument.DNSServer, + [.. DNSLookup.QueryTypes.Select(x => x.ToString().ToLowerInvariant()).Order()]), + ApplicationName.RemoteDesktop => Create(name, ["rdp", "mstsc"], "", true), + ApplicationName.PowerShell => Create(name, ["ps", "pwsh"], "", true), + ApplicationName.PuTTY => Create(name, ["ssh"], "", true), + ApplicationName.TigerVNC => Create(name, ["vnc"], "", true), + ApplicationName.WebConsole => Create(name, ["web"], "", true), + ApplicationName.SNMP => Create(name, [], "", true), + ApplicationName.RDAP => Create(name, [], " [type]", true, types: [.. RDAPTypes.Keys]), + ApplicationName.Whois => Create(name, [], "", true), + _ => Create(name, [], string.Empty, false) + }).ToList(); + + commands.Add(new RunCommandDefinition + { + Type = RunCommandType.Setting, + Application = ApplicationName.None, + Command = "settings" + }); + + return commands; + } + + private static RunCommandDefinition Create(ApplicationName application, string[] aliases, string syntax, + bool supportsTarget, RunCommandArgument argument = RunCommandArgument.None, string[] types = null) + { + return new RunCommandDefinition + { + Type = RunCommandType.Application, + Application = application, + Command = application.ToString().ToLowerInvariant(), + Aliases = aliases, + SupportsTarget = supportsTarget, + // Every application that accepts a target also supports profiles. + SupportsProfile = supportsTarget, + Argument = argument, + Types = types ?? [], + Syntax = supportsTarget ? $"{syntax} | @" : string.Empty + }; } -} \ No newline at end of file +} diff --git a/Source/NETworkManager/RunCommandSuggestion.cs b/Source/NETworkManager/RunCommandSuggestion.cs new file mode 100644 index 0000000000..c23a8587d0 --- /dev/null +++ b/Source/NETworkManager/RunCommandSuggestion.cs @@ -0,0 +1,70 @@ +using NETworkManager.Models; +using NETworkManager.Profiles; + +namespace NETworkManager; + +/// +/// Kind of a suggestion in the run command dialog. +/// +public enum RunCommandSuggestionKind +{ + /// + /// Run the input as typed. + /// + Run, + + /// + /// Command of an application (or the settings). + /// + Command, + + /// + /// Profile of the application. + /// + Profile, + + /// + /// Recently run command. + /// + History, + + /// + /// Type keyword of the command (e.g. DNS record type). + /// + Type +} + +/// +/// Suggestion in the run command dialog. +/// +public sealed class RunCommandSuggestion +{ + public RunCommandSuggestionKind Kind { get; init; } + + /// + /// Application of the suggestion (for the icon), for the settings. + /// + public ApplicationName Application { get; init; } + + public string Title { get; init; } + + /// + /// Second line (e.g. group and host of a profile). + /// + public string Description { get; init; } + + /// + /// Text on the right (e.g. "@name" of a profile). + /// + public string Info { get; init; } + + /// + /// Input after completing the suggestion (Tab) and that is run (Enter). + /// + public string CompletionText { get; init; } + + /// + /// Profile to run (). + /// + public ProfileInfo Profile { get; init; } +} diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs new file mode 100644 index 0000000000..35037607fe --- /dev/null +++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs @@ -0,0 +1,363 @@ +using NETworkManager.Localization.Resources; +using NETworkManager.Models.RunCommand; +using NETworkManager.Profiles; +using NETworkManager.Settings; +using NETworkManager.Utilities; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Windows.Input; + +namespace NETworkManager.ViewModels; + +/// +/// View model of the run command dialog: type a command (e.g. "ping 10.0.0.1"), select a suggestion and run it. +/// +public class RunCommandViewModel : ViewModelBase +{ + /// + /// Maximum number of suggestions shown for a target or argument (profiles, history, types). The commands are + /// always shown completely. + /// + private const int MaxSuggestions = 10; + + /// + /// Maximum number of recent commands shown in the suggestions. + /// + private const int MaxHistorySuggestions = 5; + + private readonly IReadOnlyList _commands; + private readonly Action _runHandler; + + private RunCommandParseResult _result; + + /// + /// Initializes a new instance of the class. + /// + /// Called with the request to run (, ). + /// Called when the dialog is canceled. + public RunCommandViewModel(Action runHandler, Action cancelHandler) + { + _commands = RunCommandManager.GetCommands(); + _runHandler = runHandler; + + CancelCommand = new RelayCommand(_ => cancelHandler(this)); + + UpdateSuggestions(); + } + + #region Properties + + public ICommand CancelCommand { get; } + + /// + /// Input of the user (e.g. "dns borntoberoot.net aaaa"). + /// + public string Input + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + + ErrorMessage = null; + UpdateSuggestions(); + } + } = string.Empty; + + public ObservableCollection Suggestions { get; } = []; + + public RunCommandSuggestion SelectedSuggestion + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Syntax of the recognized command (e.g. "dnslookup <hosts> [server] [type] | @<profile>"). + /// + public string Hint + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Error of the last run (e.g. invalid host), null if there is none. + /// + public string ErrorMessage + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// Request to run (set before the run handler is called), null for the settings. + /// + public ApplicationRequest Request { get; private set; } + + /// + /// The settings should be opened (set before the run handler is called). + /// + public bool IsSettings { get; private set; } + + #endregion + + #region Methods + + /// + /// Selects the next suggestion (Down). + /// + public void SelectNext() + { + Select(1); + } + + /// + /// Selects the previous suggestion (Up). + /// + public void SelectPrevious() + { + Select(-1); + } + + private void Select(int offset) + { + if (Suggestions.Count == 0) + return; + + var index = SelectedSuggestion == null ? -1 : Suggestions.IndexOf(SelectedSuggestion); + + SelectedSuggestion = Suggestions[Math.Clamp(index + offset, 0, Suggestions.Count - 1)]; + } + + /// + /// Inserts the selected suggestion into the input (Tab). + /// + public void Complete() + { + if (SelectedSuggestion == null || SelectedSuggestion.Kind == RunCommandSuggestionKind.Run) + return; + + Input = SelectedSuggestion.CompletionText; + } + + /// + /// Runs the selected suggestion, or the input if none is selected (Enter). + /// + public void Run() + { + var suggestion = SelectedSuggestion; + + var text = suggestion?.CompletionText ?? Input; + var result = RunCommandParser.Parse(text, _commands); + + ErrorMessage = RunCommandManager.TryCreateRequest(result, suggestion?.Profile, out var request); + + if (ErrorMessage != null) + return; + + Request = request; + IsSettings = result.Command.Type == RunCommandType.Setting; + + AddToHistory(text.Trim()); + + _runHandler(this); + } + + /// + /// Updates the suggestions and the hint for the input. + /// + private void UpdateSuggestions() + { + _result = RunCommandParser.Parse(Input, _commands); + + Hint = _result.Command == null + ? null + : $"{_result.Command.Command} {_result.Command.Syntax}".TrimEnd(); + + IEnumerable suggestions = _result.Position switch + { + RunCommandPosition.Command => GetCommandSuggestions(), + RunCommandPosition.Target when _result.ProfileName != null => GetProfileSuggestions(), + RunCommandPosition.Target => [GetRunSuggestion(), .. GetProfileSuggestions(), .. GetHistorySuggestions()], + _ => [GetRunSuggestion(), .. GetTypeSuggestions(), .. GetHistorySuggestions()] + }; + + if (_result.Position != RunCommandPosition.Command) + suggestions = suggestions.Take(MaxSuggestions); + + Suggestions.Clear(); + + foreach (var suggestion in suggestions) + Suggestions.Add(suggestion); + + // The input as typed is the first suggestion, so Enter runs what was typed. Profiles, history entries and + // types are selected with Up/Down. + SelectedSuggestion = Suggestions.FirstOrDefault(); + } + + /// + /// Suggestion to run the input as typed. + /// + private RunCommandSuggestion GetRunSuggestion() + { + return new RunCommandSuggestion + { + Kind = RunCommandSuggestionKind.Run, + Application = _result.Command.Application, + Title = Input.Trim(), + Description = RunCommandManager.GetTranslatedName(_result.Command), + CompletionText = Input + }; + } + + /// + /// Recent commands (empty input) and the commands that match the typed command. + /// + private IEnumerable GetCommandSuggestions() + { + var text = _result.CurrentWord; + + var suggestions = new List(); + + if (string.IsNullOrEmpty(text)) + suggestions.AddRange(GetHistorySuggestions().Take(MaxHistorySuggestions)); + + foreach (var command in _commands) + { + var names = new[] { command.Command }.Concat(command.Aliases).ToList(); + var translatedName = RunCommandManager.GetTranslatedName(command); + + // Complete with the command or alias that was typed (e.g. "ping" instead of "pingmonitor"). + var name = names.FirstOrDefault(x => string.Equals(x, text, StringComparison.OrdinalIgnoreCase)) ?? + names.FirstOrDefault(x => x.StartsWith(text, StringComparison.OrdinalIgnoreCase)); + + if (name == null && !translatedName.Contains(text, StringComparison.OrdinalIgnoreCase)) + continue; + + suggestions.Add(new RunCommandSuggestion + { + Kind = RunCommandSuggestionKind.Command, + Application = command.Application, + Title = translatedName, + Description = string.Join(", ", names), + Info = command.Syntax, + CompletionText = (name ?? command.Command) + " " + }); + } + + return suggestions; + } + + /// + /// Profiles of the command that match the typed profile name (or target). + /// + private IEnumerable GetProfileSuggestions() + { + var text = _result.ProfileName ?? _result.CurrentWord; + + return RunCommandManager.GetProfiles(_result.Command) + .Where(x => string.IsNullOrEmpty(text) || + x.Name.Contains(text, StringComparison.OrdinalIgnoreCase) || + (x.Host?.Contains(text, StringComparison.OrdinalIgnoreCase) ?? false)) + .Select(x => new RunCommandSuggestion + { + Kind = RunCommandSuggestionKind.Profile, + Application = _result.Command.Application, + Title = x.Name, + Description = string.IsNullOrEmpty(x.Host) ? x.Group : $"{x.Group} · {x.Host}", + Info = "@" + x.Name, + CompletionText = _result.InputBeforeCurrentWord + "@" + x.Name, + Profile = x + }); + } + + /// + /// Type keywords of the command that start with the typed word. + /// + private IEnumerable GetTypeSuggestions() + { + // A complete type was already entered (and is not being typed). + if (_result.Type != null && !string.Equals(_result.Type, _result.CurrentWord, StringComparison.OrdinalIgnoreCase)) + return []; + + return _result.Command.Types + .Where(x => x.StartsWith(_result.CurrentWord, StringComparison.OrdinalIgnoreCase)) + .Select(x => new RunCommandSuggestion + { + Kind = RunCommandSuggestionKind.Type, + Application = _result.Command.Application, + Title = x.ToUpperInvariant(), + Description = Strings.Type, + CompletionText = _result.InputBeforeCurrentWord + x + " " + }); + } + + /// + /// Recent commands. With a command, only its entries that contain the input. + /// + private IEnumerable GetHistorySuggestions() + { + var input = Input.Trim(); + + return SettingsManager.Current.RunCommand_History + .Where(x => _result.Command == null && string.IsNullOrEmpty(input) || + _result.Command != null && + RunCommandParser.Parse(x, _commands).Command == _result.Command && + x.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase) && + !string.Equals(x, input, StringComparison.OrdinalIgnoreCase)) + .Select(x => + { + var command = RunCommandParser.Parse(x, _commands).Command; + + return new RunCommandSuggestion + { + Kind = RunCommandSuggestionKind.History, + Application = command?.Application ?? Models.ApplicationName.None, + Title = x, + Description = command == null ? null : RunCommandManager.GetTranslatedName(command), + Info = Strings.Recent, + CompletionText = x + }; + }); + } + + private static void AddToHistory(string text) + { + var list = ListHelper.Modify(SettingsManager.Current.RunCommand_History.ToList(), text, + SettingsManager.Current.General_HistoryListEntries); + + SettingsManager.Current.RunCommand_History.Clear(); + + list.ForEach(x => SettingsManager.Current.RunCommand_History.Add(x)); + } + + #endregion +} diff --git a/Source/NETworkManager/Views/RunCommandChildWindow.xaml b/Source/NETworkManager/Views/RunCommandChildWindow.xaml new file mode 100644 index 0000000000..191ee83a83 --- /dev/null +++ b/Source/NETworkManager/Views/RunCommandChildWindow.xaml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs b/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs new file mode 100644 index 0000000000..07067ee092 --- /dev/null +++ b/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs @@ -0,0 +1,72 @@ +using NETworkManager.ViewModels; +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Threading; + +namespace NETworkManager.Views; + +/// +/// Run command dialog. The input keeps the focus; Up/Down select a suggestion, Tab completes it, Enter runs it and +/// Escape closes the dialog. +/// +public partial class RunCommandChildWindow +{ + public RunCommandChildWindow() + { + InitializeComponent(); + } + + private RunCommandViewModel ViewModel => (RunCommandViewModel)DataContext; + + private void ChildWindow_OnLoaded(object sender, RoutedEventArgs e) + { + Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(delegate { TextBoxInput.Focus(); })); + } + + private void TextBoxInput_OnPreviewKeyDown(object sender, KeyEventArgs e) + { + switch (e.Key) + { + case Key.Down: + ViewModel.SelectNext(); + break; + case Key.Up: + ViewModel.SelectPrevious(); + break; + case Key.Tab: + ViewModel.Complete(); + TextBoxInput.CaretIndex = TextBoxInput.Text.Length; + break; + case Key.Enter: + ViewModel.Run(); + break; + case Key.Escape: + ViewModel.CancelCommand.Execute(null); + break; + default: + return; + } + + e.Handled = true; + } + + private void ListBoxSuggestions_OnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (ItemsControl.ContainerFromElement(ListBoxSuggestions, (DependencyObject)e.OriginalSource) is not + ListBoxItem item) + return; + + ViewModel.SelectedSuggestion = (RunCommandSuggestion)item.DataContext; + ViewModel.Run(); + + TextBoxInput.Focus(); + } + + private void ListBoxSuggestions_OnSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (ListBoxSuggestions.SelectedItem != null) + ListBoxSuggestions.ScrollIntoView(ListBoxSuggestions.SelectedItem); + } +} From 7eac71c44d529450548a703903ee9ab30f199d09 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:48:50 +0200 Subject: [PATCH 04/14] Docs: Run command page and changelog Co-Authored-By: Claude Opus 5.5 --- Website/docs/changelog/next-release.md | 6 ++ Website/docs/commandline-arguments.md | 2 +- Website/docs/faq/_category_.json | 2 +- Website/docs/run-command.md | 77 ++++++++++++++++++++++++++ Website/docs/system-wide-policies.md | 2 +- 5 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 Website/docs/run-command.md diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 5057cd278b..136aee391f 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -25,6 +25,10 @@ Release date: **xx.xx.2026** - ARM64 builds are now available. [#3538](https://github.com/BornToBeRoot/NETworkManager/issues/3538) +**Run command** + +- The [**Run command**](../run-command.md) (`Ctrl+Shift+P`) now runs a feature directly, e.g. `ping 10.0.0.1`, `dns borntoberoot.net 1.1.1.1 aaaa`, `rdap 1.10 asn` or `rdp @server-01` (profile). It opens in a dialog instead of a flyout, with short aliases (e.g. `ping`, `dns`, `rdp`, `ssh`), suggestions for recent commands, profiles of the feature and types (`Up`/`Down` to select, `Tab` to complete, `Enter` to run, `Esc` to close), and validates the input like the feature does. Only visible features are offered. + **RDAP** - New [**RDAP**](../application/rdap.md) tool to look up registration data via the Registration Data Access Protocol, the standardized, JSON-based successor of Whois. Supports domains, nameservers, top-level domains, IP addresses/CIDR prefixes, AS numbers and entity handles; the type is detected automatically or can be selected. The RDAP server is determined automatically using the IANA bootstrap files, which are cached locally and can be updated in the new **Settings > RDAP** page. Results are shown in a structured view (details, status, events, nameservers, DNSSEC, networks and AS numbers of an organization, contacts, notices) or as raw JSON, and can be exported as JSON or text. For domains (e.g. `.com`/`.net`), the referral to the registrar's RDAP server is followed as well (can be disabled in **Settings > RDAP**). Includes profile support. If no RDAP server is known for a domain, it can be opened in **Whois** with a single click. IP addresses and hostnames can be queried from the context menu of **IP Scanner** and **Traceroute**, AS numbers from **Traceroute**; for a hostname (e.g. `www.borntoberoot.net`), the registered domain is queried (determined with the included [Public Suffix List](https://publicsuffix.org/)). [#3607](https://github.com/BornToBeRoot/NETworkManager/pull/3607) [#3642](https://github.com/BornToBeRoot/NETworkManager/pull/3642) @@ -181,5 +185,7 @@ Release date: **xx.xx.2026** - Converted the **IP Scanner** and **Port Scanner** engines from `Parallel.ForEach` with blocking calls to genuinely asynchronous `Parallel.ForEachAsync`, removing the risk of exhausting the application's ThreadPool at high concurrency settings. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) - Refactored the **Network Connection** widget's check logic to reduce duplicated code and share the local IP address detection between the Computer and Router checks instead of detecting it twice. [#3553](https://github.com/BornToBeRoot/NETworkManager/pull/3553) - Consolidated the duplicated **Profiles** side panel (search, tag filter, grouped list, context menu, add/edit/copy/delete) used across 15 tool views into a single shared control, reducing code duplication. As part of this, the profile panel's expanded/width state is now shared across all tools instead of being tracked per tool. [#3537](https://github.com/BornToBeRoot/NETworkManager/pull/3537) +- Replaced the internal event system for opening a feature with data (e.g. **Open in RDAP** from the **IP Scanner**) with typed requests, which can also carry options (e.g. DNS server and record type) and profiles. Features no longer fail to open from another feature while the application list is filtered by a search. +- Added a unit test project (xUnit) with tests for the run command parser. - Language files updated via [#transifex](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Ftransifex-integration) - Dependencies updated via [#dependabot](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Fdependabot) diff --git a/Website/docs/commandline-arguments.md b/Website/docs/commandline-arguments.md index 2ea97809a9..fb9d4a400c 100644 --- a/Website/docs/commandline-arguments.md +++ b/Website/docs/commandline-arguments.md @@ -1,5 +1,5 @@ --- -sidebar_position: 5 +sidebar_position: 6 description: "Use command line arguments to control NETworkManager startup behavior, reset settings, and open a specific feature on launch." keywords: [NETworkManager, command line arguments, CLI, command line options, automation, scripting, parameters] --- diff --git a/Website/docs/faq/_category_.json b/Website/docs/faq/_category_.json index 689a9e74de..47d98a233e 100644 --- a/Website/docs/faq/_category_.json +++ b/Website/docs/faq/_category_.json @@ -1,6 +1,6 @@ { "label": "FAQ", - "position": 7, + "position": 8, "link": { "type": "generated-index", "description": "Frequently Asked Questions" diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md new file mode 100644 index 0000000000..b032c3d8da --- /dev/null +++ b/Website/docs/run-command.md @@ -0,0 +1,77 @@ +--- +sidebar_position: 5 +description: "Open a feature, run a lookup or connect to a host directly from the keyboard with the run command (Ctrl+Shift+P), including profiles and suggestions." +keywords: [NETworkManager, run command, command palette, keyboard shortcut, Ctrl+Shift+P, quick launch, ping, DNS lookup, remote desktop] +--- + +# Run Command + +The **Run Command** opens a feature and runs it directly from the keyboard: press `Ctrl+Shift+P`, type a command like `ping 10.0.0.1` and press `Enter`. + +The run command can also be opened with the **Run command...** button in the title bar. + +## Syntax + +``` +command [target] [argument] [type] +command @profile +``` + +- **Command:** Name or alias of the feature (e.g. `dnslookup` or `dns`), see [Commands](#commands). Without a target, the feature is only opened. +- **Target:** Host(s), IP range, query or URL, depending on the feature. Lists are separated with `;` as everywhere in NETworkManager, spaces around `;` are allowed (e.g. `ping 10.0.0.1; 10.0.0.2`). +- **Argument** and **type:** Additional values of some features (e.g. DNS server and record type of the DNS lookup), in any order after the target. +- **Profile:** `@` followed by the name of a profile runs the profile with its settings (e.g. `rdp @server-01`). Only profiles that are enabled for the feature can be used. The name can contain spaces (e.g. `rdp @Web Server 01`). An `@` that is not at the beginning of the target is part of the host (e.g. `admin@server-01`). + +The input is validated like the input field of the feature (e.g. a valid host or IP range). If it is not valid, an error is shown and the dialog stays open. + +## Commands + +| Command | Aliases | Arguments | Example | +| ------------------ | ------------------ | ----------------------------------- | ---------------------------------------- | +| `ipscanner` | `scan` | `` | `scan 10.0.0.0/24` | +| `portscanner` | `portscan` | ` [ports]` | `portscan 10.0.0.1 22; 80; 443` | +| `pingmonitor` | `ping` | `` | `ping 10.0.0.1; 10.0.0.2` | +| `traceroute` | `trace`, `tracert` | `` | `trace borntoberoot.net` | +| `dnslookup` | `dns`, `nslookup` | ` [server] [type]` | `dns borntoberoot.net 1.1.1.1 aaaa` | +| `remotedesktop` | `rdp`, `mstsc` | `` | `rdp server-01` | +| `powershell` | `ps`, `pwsh` | `` | `ps server-01` | +| `putty` | `ssh` | `` | `ssh server-01` | +| `tigervnc` | `vnc` | `` | `vnc server-01` | +| `webconsole` | `web` | `` | `web https://borntoberoot.net` | +| `snmp` | | `` | `snmp 10.0.0.1` | +| `rdap` | | ` [type]` | `rdap 1.10 asn` | +| `whois` | | `` | `whois borntoberoot.net` | +| `settings` | | | `settings` | + +All features that accept a target also accept a profile (`@profile`). The other features (e.g. `subnetcalculator`, `firewall`) can be opened with their name. Features that are hidden in [Settings > General](./settings/general.md) are not available. + +:::note + +- **Remote Desktop, PowerShell, PuTTY, TigerVNC and Web Console** open the connect dialog with the host, so the connection settings can be adjusted before connecting. A profile connects directly with its settings. +- **DNS lookup:** The type is the DNS record type (`a`, `aaaa`, `any`, `caa`, `cname`, `dnskey`, `mx`, `ns`, `ptr`, `soa`, `srv`, `txt`). Without a type, IP addresses are looked up with `ptr` and other hosts with the type selected in the feature. The DNS server is an IP address or a configured DNS server. +- **RDAP:** The type is `domain`, `nameserver`, `tld`, `ip`, `asn` or `entity`. Without a type, it is detected from the query (see [RDAP](./application/rdap.md#example-inputs)). + +::: + +## Suggestions + +While typing, suggestions are shown below the input: + +| Input | Suggestions | +| ---------------------------- | ------------------------------------------------------------------------ | +| _empty_ | Recently run commands and all features | +| Command (e.g. `tr`) | Matching features | +| After the command (`rdp `) | The input as typed, profiles of the feature and recent commands | +| `@` (e.g. `rdp @web`) | Profiles of the feature (with group and host) | +| After the target | The input as typed, types of the feature (e.g. DNS record types) and recent commands | + +The input as typed is always the first suggestion, so `Enter` runs what was typed. Profiles, recent commands and types are selected with the arrow keys. + +| Key | Action | +| ------------ | ---------------------------------------------- | +| `Up`, `Down` | Select a suggestion | +| `Tab` | Insert the selected suggestion into the input | +| `Enter` | Run the selected suggestion | +| `Esc` | Close the dialog | + +Run commands are saved in a history (the number of entries is set in [Settings > General](./settings/general.md)). diff --git a/Website/docs/system-wide-policies.md b/Website/docs/system-wide-policies.md index 929f50c0fa..f0dc43cffa 100644 --- a/Website/docs/system-wide-policies.md +++ b/Website/docs/system-wide-policies.md @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 7 description: "Configure system-wide policies to enforce NETworkManager settings for all users in enterprise environments." keywords: [NETworkManager, system-wide policies, enterprise deployment, Group Policy, Intune, SCCM, centralized configuration, IT administration] --- From 7250925d8fbdfc0fda20c19b15fa48c87f88c030 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:10:17 +0200 Subject: [PATCH 05/14] Fix: Scroll to the selected application in the application list The application list only scrolled to the selected application on startup and after clearing the search. An application opened from somewhere else (dashboard, another application, run command) could stay out of view. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager/MainWindow.xaml | 1 + Source/NETworkManager/MainWindow.xaml.cs | 11 +++++++++++ Website/docs/changelog/next-release.md | 1 + 3 files changed, 13 insertions(+) diff --git a/Source/NETworkManager/MainWindow.xaml b/Source/NETworkManager/MainWindow.xaml index cea22b8737..a41009ad5a 100644 --- a/Source/NETworkManager/MainWindow.xaml +++ b/Source/NETworkManager/MainWindow.xaml @@ -533,6 +533,7 @@ Style="{StaticResource FadeOutScrollViewer}"> + /// Scrolls the selected application into view, also if it was selected from somewhere else (e.g. the dashboard, + /// another application or the run command). + /// + private void ListViewApplication_OnSelectionChanged(object sender, + System.Windows.Controls.SelectionChangedEventArgs e) + { + if (ListViewApplication.SelectedItem != null) + ListViewApplication.ScrollIntoView(ListViewApplication.SelectedItem); + } + private async void NavigationManager_ApplicationRequested(object sender, ApplicationRequest request) { // Clear the search, otherwise the application may be filtered out diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 136aee391f..7e256ef7ae 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -116,6 +116,7 @@ Release date: **xx.xx.2026** - Fixed changes to lists in the settings (e.g. query histories, port profiles, custom commands) not being saved if no other setting was changed in the same session. This happened since the settings are stored as JSON. [#3607](https://github.com/BornToBeRoot/NETworkManager/pull/3607) - Fixed the profile search (profile list in the tools, **Profiles** settings and import result) filtering the profiles while still typing. The profiles are now filtered once after the user stopped typing, instead of every 750 ms after the first key press. [#3638](https://github.com/BornToBeRoot/NETworkManager/pull/3638) +- Fixed the application list not scrolling to the selected application if it was opened from somewhere else (e.g. from the **Dashboard**, another application or the run command). **Firewall** From d829031c8b58d848004539fc8d841e20ecca7c81 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:49:59 +0200 Subject: [PATCH 06/14] Refactor: Update input histories in place ListHelper.Modify works on the ObservableCollection itself: an existing entry is moved to the top, a new one inserted, and the oldest ones are removed. Replaces the pattern copy/clear/refill (with a workaround to restore the input text after the clear) and replacing the collection in 34 places in 22 view models. Unit tests for the helper. Co-Authored-By: Claude Opus 5.5 --- .../Utilities/ListHelperTests.cs | 92 +++++++++++++++++++ Source/NETworkManager.Utilities/ListHelper.cs | 40 ++++---- .../ViewModels/BitCalculatorViewModel.cs | 10 +- .../ViewModels/DNSLookupViewModel.cs | 10 +- .../ViewModels/IPGeolocationViewModel.cs | 10 +- .../ViewModels/IPScannerViewModel.cs | 10 +- .../ViewModels/LookupOUILookupViewModel.cs | 13 +-- .../ViewModels/LookupPortViewModel.cs | 10 +- .../ViewModels/PingMonitorHostViewModel.cs | 10 +- .../ViewModels/PortScannerViewModel.cs | 20 +--- .../ViewModels/PowerShellHostViewModel.cs | 5 +- .../ViewModels/PuTTYHostViewModel.cs | 35 +++---- .../ViewModels/RDAPViewModel.cs | 10 +- .../ViewModels/RemoteDesktopHostViewModel.cs | 5 +- .../ViewModels/RunCommandViewModel.cs | 6 +- .../ViewModels/SNMPViewModel.cs | 20 +--- .../SubnetCalculatorCalculatorViewModel.cs | 10 +- .../SubnetCalculatorSubnettingViewModel.cs | 22 +---- .../SubnetCalculatorWideSubnetViewModel.cs | 20 +--- .../ViewModels/TigerVNCHostViewModel.cs | 10 +- .../ViewModels/TracerouteViewModel.cs | 10 +- .../ViewModels/WakeOnLANViewModel.cs | 20 +--- .../ViewModels/WebConsoleHostViewModel.cs | 5 +- .../ViewModels/WhoisViewModel.cs | 10 +- Website/docs/changelog/next-release.md | 3 +- 25 files changed, 165 insertions(+), 251 deletions(-) create mode 100644 Source/NETworkManager.Tests/Utilities/ListHelperTests.cs diff --git a/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs b/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs new file mode 100644 index 0000000000..7f13bce94f --- /dev/null +++ b/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using NETworkManager.Utilities; +using Xunit; + +namespace NETworkManager.Tests.Utilities; + +public class ListHelperTests +{ + [Fact] + public void AddsNewEntryAtTheTop() + { + ObservableCollection list = ["b", "c"]; + + ListHelper.Modify(list, "a", 5); + + Assert.Equal(["a", "b", "c"], list); + } + + [Fact] + public void MovesExistingEntryToTheTop() + { + ObservableCollection list = ["a", "b", "c"]; + + ListHelper.Modify(list, "c", 5); + + Assert.Equal(["c", "a", "b"], list); + } + + [Fact] + public void RemovesOldestEntriesBeyondTheLength() + { + ObservableCollection list = ["b", "c", "d"]; + + ListHelper.Modify(list, "a", 3); + + Assert.Equal(["a", "b", "c"], list); + } + + [Fact] + public void ShortensAListThatIsTooLong() + { + // E.g. after the history length was reduced in the settings. + ObservableCollection list = ["a", "b", "c", "d"]; + + ListHelper.Modify(list, "a", 2); + + Assert.Equal(["a", "b"], list); + } + + [Fact] + public void RemovesDuplicatesOfTheEntry() + { + ObservableCollection list = [1, 2, 3, 2]; + + ListHelper.Modify(list, 2, 10); + + Assert.Equal([2, 1, 3], list); + } + + [Fact] + public void DoesNotChangeTheListIfTheEntryIsAlreadyTheNewest() + { + ObservableCollection list = ["a", "b"]; + var changes = 0; + list.CollectionChanged += (_, _) => changes++; + + ListHelper.Modify(list, "a", 5); + + Assert.Equal(0, changes); + } + + [Fact] + public void MovesInsteadOfResetting() + { + // A reset (Clear) would remove the entry that a ComboBox shows, which can clear its text. + ObservableCollection list = ["a", "b", "c"]; + var actions = new System.Collections.Generic.List(); + list.CollectionChanged += (_, e) => actions.Add(e.Action); + + ListHelper.Modify(list, "b", 5); + + Assert.Equal([NotifyCollectionChangedAction.Move], actions); + } + + [Fact] + public void ThrowsIfTheLengthIsNotPositive() + { + Assert.Throws(() => ListHelper.Modify(new ObservableCollection(), "a", 0)); + } +} diff --git a/Source/NETworkManager.Utilities/ListHelper.cs b/Source/NETworkManager.Utilities/ListHelper.cs index 93815c0c3c..c8463288cf 100644 --- a/Source/NETworkManager.Utilities/ListHelper.cs +++ b/Source/NETworkManager.Utilities/ListHelper.cs @@ -1,38 +1,44 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; namespace NETworkManager.Utilities; /// -/// Helper class for modifying lists by adding new entries and removing old ones if the list exceeds a specified length. +/// Helper class for history lists: add an entry as the newest one and remove the oldest ones if the list exceeds a +/// specified length. /// public static class ListHelper { /// - /// Modify a list by adding the and removing the oldest entry if the list is full. - /// If an entry or multiple ones already exist in the list, they will be removed before adding the new entry. + /// Adds the as the newest (first) entry of the list. An existing entry is moved to the top + /// (duplicates are removed) and the oldest entries are removed if the list exceeds the . + /// The list is changed in place, so controls bound to it (e.g. the history of a ComboBox) keep their text and are + /// notified only about the actual changes. /// - /// List to modify. Used with to add and remove entries. + /// List to modify (e.g. a history of the settings). /// Entry to add to the list. /// Maximum length of the list. Oldest entries will be removed if the list exceeds this length. /// Type of the list entries. Currently or . - /// Modified list with the new entry added and oldest entries removed if necessary. - public static List Modify(List list, T entry, int length) + public static void Modify(ObservableCollection list, T entry, int length) { - int index; - - while ((index = list.IndexOf(entry)) != -1) - { - list.RemoveAt(index); - } - if (length <= 0) throw new ArgumentOutOfRangeException(nameof(length), "Length must be greater than zero."); - while (list.Count >= length) - list.RemoveAt(list.Count - 1); + var index = list.IndexOf(entry); - list.Insert(0, entry); + if (index > 0) + list.Move(index, 0); + else if (index < 0) + list.Insert(0, entry); - return list; + // Remove duplicates (e.g. from older settings files) + for (var i = list.Count - 1; i > 0; i--) + { + if (EqualityComparer.Default.Equals(list[i], entry)) + list.RemoveAt(i); + } + + while (list.Count > length) + list.RemoveAt(list.Count - 1); } } diff --git a/Source/NETworkManager/ViewModels/BitCalculatorViewModel.cs b/Source/NETworkManager/ViewModels/BitCalculatorViewModel.cs index 01a24481e0..b2997403e9 100644 --- a/Source/NETworkManager/ViewModels/BitCalculatorViewModel.cs +++ b/Source/NETworkManager/ViewModels/BitCalculatorViewModel.cs @@ -275,16 +275,8 @@ private async void Calculate() /// The input string. private void AddInputToHistory(string input) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.BitCalculator_InputHistory.ToList(), input, + ListHelper.Modify(SettingsManager.Current.BitCalculator_InputHistory, input, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.BitCalculator_InputHistory.Clear(); - OnPropertyChanged(nameof(Input)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.BitCalculator_InputHistory.Add(x)); } /// diff --git a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs index fc88fedb4d..33c3d18dcd 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs @@ -523,16 +523,8 @@ private void AppendStatusMessage(string message) /// The host to add. private void AddHostToHistory(string host) { - // Create the new list - var list = ListHelper.Modify([.. SettingsManager.Current.DNSLookup_HostHistory], host, + ListHelper.Modify(SettingsManager.Current.DNSLookup_HostHistory, host, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.DNSLookup_HostHistory.Clear(); - OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.DNSLookup_HostHistory.Add(x)); } diff --git a/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs b/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs index 40aa8d54e3..ccbae5f669 100644 --- a/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs @@ -341,16 +341,8 @@ public void OnClose() /// The host to add. private void AddHostToHistory(string host) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.IPGeolocation_HostHistory.ToList(), host, + ListHelper.Modify(SettingsManager.Current.IPGeolocation_HostHistory, host, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.IPGeolocation_HostHistory.Clear(); - OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.IPGeolocation_HostHistory.Add(x)); } #endregion diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 1311d0d288..37d3818755 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -705,16 +705,8 @@ private async Task CustomCommand(object guid) /// The host or IP range to add. private void AddHostToHistory(string ipRange) { - // Create the new list - var list = ListHelper.Modify([.. SettingsManager.Current.IPScanner_HostHistory], ipRange, + ListHelper.Modify(SettingsManager.Current.IPScanner_HostHistory, ipRange, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.IPScanner_HostHistory.Clear(); - OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(SettingsManager.Current.IPScanner_HostHistory.Add); } /// diff --git a/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs b/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs index fdd320882b..a1734f39a7 100644 --- a/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs +++ b/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs @@ -51,17 +51,8 @@ public LookupOUILookupViewModel() /// The MAC address or vendor. private void AddSearchToHistory(string macAddressOrVendor) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.Lookup_OUI_SearchHistory.ToList(), - macAddressOrVendor, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.Lookup_OUI_SearchHistory.Clear(); - OnPropertyChanged( - nameof(Search)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.Lookup_OUI_SearchHistory.Add(x)); + ListHelper.Modify(SettingsManager.Current.Lookup_OUI_SearchHistory, macAddressOrVendor, + SettingsManager.Current.General_HistoryListEntries); } #endregion diff --git a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs index 62e6aea1e6..c078201d32 100644 --- a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs +++ b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs @@ -48,16 +48,8 @@ public LookupPortLookupViewModel() /// The port or service. private void AddSearchToHistory(string portOrService) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.Lookup_Port_SearchHistory.ToList(), portOrService, + ListHelper.Modify(SettingsManager.Current.Lookup_Port_SearchHistory, portOrService, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.Lookup_Port_SearchHistory.Clear(); - OnPropertyChanged(nameof(Search)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.Lookup_Port_SearchHistory.Add(x)); } #endregion diff --git a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs index fc4b5ad6b3..a61f01ed8b 100644 --- a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs @@ -411,16 +411,8 @@ private void RemoveHostByGuid(Guid hostId) private void AddHostToHistory(string host) { - // Create the new list - var list = ListHelper.Modify([.. SettingsManager.Current.PingMonitor_HostHistory], host, + ListHelper.Modify(SettingsManager.Current.PingMonitor_HostHistory, host, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.PingMonitor_HostHistory.Clear(); - OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(SettingsManager.Current.PingMonitor_HostHistory.Add); } #endregion diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 926037f288..378f5a6349 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -573,30 +573,14 @@ await DialogHelper.ShowMessageAsync(window, Strings.Error, private void AddHostToHistory(string host) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.PortScanner_HostHistory.ToList(), host, + ListHelper.Modify(SettingsManager.Current.PortScanner_HostHistory, host, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.PortScanner_HostHistory.Clear(); - OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.PortScanner_HostHistory.Add(x)); } private void AddPortToHistory(string port) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory.ToList(), port, + ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, port, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.PortScanner_PortHistory.Clear(); - OnPropertyChanged(nameof(Ports)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.PortScanner_PortHistory.Add(x)); } #endregion diff --git a/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs b/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs index 42372a93a5..424026c8de 100644 --- a/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs @@ -475,9 +475,8 @@ private static void AddHostToHistory(string host) if (string.IsNullOrEmpty(host)) return; - SettingsManager.Current.PowerShell_HostHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PowerShell_HostHistory.ToList(), host, - SettingsManager.Current.General_HistoryListEntries)); + ListHelper.Modify(SettingsManager.Current.PowerShell_HostHistory, host, + SettingsManager.Current.General_HistoryListEntries); } public void FocusEmbeddedWindow() diff --git a/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs b/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs index b26862a6f5..e8459a73f5 100644 --- a/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs @@ -411,9 +411,8 @@ private static void AddHostToHistory(string host) if (string.IsNullOrEmpty(host)) return; - SettingsManager.Current.PuTTY_HostHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PuTTY_HostHistory.ToList(), host, - SettingsManager.Current.General_HistoryListEntries)); + ListHelper.Modify(SettingsManager.Current.PuTTY_HostHistory, host, + SettingsManager.Current.General_HistoryListEntries); } private static void AddSerialLineToHistory(string serialLine) @@ -421,9 +420,8 @@ private static void AddSerialLineToHistory(string serialLine) if (string.IsNullOrEmpty(serialLine)) return; - SettingsManager.Current.PuTTY_SerialLineHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PuTTY_SerialLineHistory.ToList(), serialLine, - SettingsManager.Current.General_HistoryListEntries)); + ListHelper.Modify(SettingsManager.Current.PuTTY_SerialLineHistory, serialLine, + SettingsManager.Current.General_HistoryListEntries); } private static void AddPortToHistory(int port) @@ -431,9 +429,8 @@ private static void AddPortToHistory(int port) if (port == 0) return; - SettingsManager.Current.PuTTY_PortHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PuTTY_PortHistory.ToList(), port.ToString(), - SettingsManager.Current.General_HistoryListEntries)); + ListHelper.Modify(SettingsManager.Current.PuTTY_PortHistory, port.ToString(), + SettingsManager.Current.General_HistoryListEntries); } private static void AddBaudToHistory(int baud) @@ -441,9 +438,8 @@ private static void AddBaudToHistory(int baud) if (baud == 0) return; - SettingsManager.Current.PuTTY_BaudHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PuTTY_BaudHistory.ToList(), baud.ToString(), - SettingsManager.Current.General_HistoryListEntries)); + ListHelper.Modify(SettingsManager.Current.PuTTY_BaudHistory, baud.ToString(), + SettingsManager.Current.General_HistoryListEntries); } private static void AddUsernameToHistory(string username) @@ -451,9 +447,8 @@ private static void AddUsernameToHistory(string username) if (string.IsNullOrEmpty(username)) return; - SettingsManager.Current.PuTTY_UsernameHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PuTTY_UsernameHistory.ToList(), username, - SettingsManager.Current.General_HistoryListEntries)); + ListHelper.Modify(SettingsManager.Current.PuTTY_UsernameHistory, username, + SettingsManager.Current.General_HistoryListEntries); } private static void AddPrivateKeyToHistory(string privateKey) @@ -461,9 +456,8 @@ private static void AddPrivateKeyToHistory(string privateKey) if (string.IsNullOrEmpty(privateKey)) return; - SettingsManager.Current.PuTTY_PrivateKeyFileHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PuTTY_PrivateKeyFileHistory.ToList(), privateKey, - SettingsManager.Current.General_HistoryListEntries)); + ListHelper.Modify(SettingsManager.Current.PuTTY_PrivateKeyFileHistory, privateKey, + SettingsManager.Current.General_HistoryListEntries); } private static void AddProfileToHistory(string profile) @@ -471,9 +465,8 @@ private static void AddProfileToHistory(string profile) if (string.IsNullOrEmpty(profile)) return; - SettingsManager.Current.PuTTY_ProfileHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.PuTTY_ProfileHistory.ToList(), profile, - SettingsManager.Current.General_HistoryListEntries)); + ListHelper.Modify(SettingsManager.Current.PuTTY_ProfileHistory, profile, + SettingsManager.Current.General_HistoryListEntries); } public void FocusEmbeddedWindow() diff --git a/Source/NETworkManager/ViewModels/RDAPViewModel.cs b/Source/NETworkManager/ViewModels/RDAPViewModel.cs index 326f3b7c08..57bcd4d026 100644 --- a/Source/NETworkManager/ViewModels/RDAPViewModel.cs +++ b/Source/NETworkManager/ViewModels/RDAPViewModel.cs @@ -448,16 +448,8 @@ public void OnClose() private void AddQueryToHistory(string query) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.RDAP_QueryHistory.ToList(), query, + ListHelper.Modify(SettingsManager.Current.RDAP_QueryHistory, query, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.RDAP_QueryHistory.Clear(); - OnPropertyChanged(nameof(Query)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.RDAP_QueryHistory.Add(x)); } /// diff --git a/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs b/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs index c5a00d2282..3e9193a1ae 100644 --- a/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs @@ -401,9 +401,8 @@ private static void AddHostToHistory(string host) if (string.IsNullOrEmpty(host)) return; - SettingsManager.Current.RemoteDesktop_HostHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.RemoteDesktop_HostHistory.ToList(), host, - SettingsManager.Current.General_HistoryListEntries)); + ListHelper.Modify(SettingsManager.Current.RemoteDesktop_HostHistory, host, + SettingsManager.Current.General_HistoryListEntries); } #endregion diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs index 35037607fe..bbcc89f9a2 100644 --- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs +++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs @@ -351,12 +351,8 @@ private IEnumerable GetHistorySuggestions() private static void AddToHistory(string text) { - var list = ListHelper.Modify(SettingsManager.Current.RunCommand_History.ToList(), text, + ListHelper.Modify(SettingsManager.Current.RunCommand_History, text, SettingsManager.Current.General_HistoryListEntries); - - SettingsManager.Current.RunCommand_History.Clear(); - - list.ForEach(x => SettingsManager.Current.RunCommand_History.Add(x)); } #endregion diff --git a/Source/NETworkManager/ViewModels/SNMPViewModel.cs b/Source/NETworkManager/ViewModels/SNMPViewModel.cs index 8119896d37..fac08c3be9 100644 --- a/Source/NETworkManager/ViewModels/SNMPViewModel.cs +++ b/Source/NETworkManager/ViewModels/SNMPViewModel.cs @@ -635,30 +635,14 @@ private async Task OpenOIDProfileSelection() private void AddHostToHistory(string host) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.SNMP_HostHistory.ToList(), host, + ListHelper.Modify(SettingsManager.Current.SNMP_HostHistory, host, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.SNMP_HostHistory.Clear(); - OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.SNMP_HostHistory.Add(x)); } private void AddOidToHistory(string oid) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.SNMP_OidHistory.ToList(), oid, + ListHelper.Modify(SettingsManager.Current.SNMP_OidHistory, oid, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.SNMP_OidHistory.Clear(); - OnPropertyChanged(nameof(Oid)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.SNMP_OidHistory.Add(x)); } private Task Export() diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs index 947254e44e..b8d35de196 100644 --- a/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs +++ b/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs @@ -119,16 +119,8 @@ private void Calculate() private void AddSubnetToHistory(string subnet) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Calculator_SubnetHistory.ToList(), subnet, + ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Calculator_SubnetHistory, subnet, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.SubnetCalculator_Calculator_SubnetHistory.Clear(); - OnPropertyChanged(nameof(Subnet)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.SubnetCalculator_Calculator_SubnetHistory.Add(x)); } #endregion diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs index 45dcc67af1..a1aa5cea52 100644 --- a/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs +++ b/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs @@ -261,30 +261,14 @@ await Task.Run(() => private void AddSubnetToHistory(string subnet) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Subnetting_SubnetHistory.ToList(), subnet, + ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Subnetting_SubnetHistory, subnet, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.SubnetCalculator_Subnetting_SubnetHistory.Clear(); - OnPropertyChanged(nameof(Subnet)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.SubnetCalculator_Subnetting_SubnetHistory.Add(x)); } private void AddNewSubnetmaskOrCidrToHistory(string newSubnetmaskOrCidr) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Subnetting_NewSubnetmaskHistory.ToList(), - newSubnetmaskOrCidr, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.SubnetCalculator_Subnetting_NewSubnetmaskHistory.Clear(); - OnPropertyChanged(nameof(NewSubnetmask)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.SubnetCalculator_Subnetting_NewSubnetmaskHistory.Add(x)); + ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Subnetting_NewSubnetmaskHistory, newSubnetmaskOrCidr, + SettingsManager.Current.General_HistoryListEntries); } public void OnShutdown() diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs index c351a0dbad..c9885f28a8 100644 --- a/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs +++ b/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs @@ -142,30 +142,14 @@ private void Calculate() private void AddSubnet1ToHistory(string subnet) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet1.ToList(), subnet, + ListHelper.Modify(SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet1, subnet, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet1.Clear(); - OnPropertyChanged(nameof(Subnet1)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet1.Add(x)); } private void AddSubnet2ToHistory(string subnet) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet2.ToList(), subnet, + ListHelper.Modify(SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet2, subnet, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet2.Clear(); - OnPropertyChanged(nameof(Subnet2)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet2.Add(x)); } public void OnShutdown() diff --git a/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs b/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs index 30c8530dfb..fd21a789ea 100644 --- a/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs @@ -264,9 +264,8 @@ private static void AddHostToHistory(string host) if (string.IsNullOrEmpty(host)) return; - SettingsManager.Current.TigerVNC_HostHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.TigerVNC_HostHistory.ToList(), host, - SettingsManager.Current.General_HistoryListEntries)); + ListHelper.Modify(SettingsManager.Current.TigerVNC_HostHistory, host, + SettingsManager.Current.General_HistoryListEntries); } private static void AddPortToHistory(int port) @@ -274,9 +273,8 @@ private static void AddPortToHistory(int port) if (port == 0) return; - SettingsManager.Current.TigerVNC_PortHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.TigerVNC_PortHistory.ToList(), port, - SettingsManager.Current.General_HistoryListEntries)); + ListHelper.Modify(SettingsManager.Current.TigerVNC_PortHistory, port, + SettingsManager.Current.General_HistoryListEntries); } #endregion diff --git a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs index fd69b06fcf..4efac228ec 100644 --- a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs +++ b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs @@ -492,16 +492,8 @@ await DialogHelper.ShowMessageAsync(window, Strings.Error, private void AddHostToHistory(string host) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.Traceroute_HostHistory.ToList(), host, + ListHelper.Modify(SettingsManager.Current.Traceroute_HostHistory, host, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.Traceroute_HostHistory.Clear(); - OnPropertyChanged(nameof(Host)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.Traceroute_HostHistory.Add(x)); } public void OnClose() diff --git a/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs b/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs index 335d64d2f8..ae729cece6 100644 --- a/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs +++ b/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs @@ -233,30 +233,14 @@ private async Task WakeUp(WakeOnLANInfo info) private void AddMACAddressToHistory(string macAddress) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.WakeOnLan_MACAddressHistory.ToList(), macAddress, + ListHelper.Modify(SettingsManager.Current.WakeOnLan_MACAddressHistory, macAddress, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.WakeOnLan_MACAddressHistory.Clear(); - OnPropertyChanged(nameof(MACAddress)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.WakeOnLan_MACAddressHistory.Add(x)); } private void AddBroadcastToHistory(string broadcast) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.WakeOnLan_BroadcastHistory.ToList(), broadcast, + ListHelper.Modify(SettingsManager.Current.WakeOnLan_BroadcastHistory, broadcast, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.WakeOnLan_BroadcastHistory.Clear(); - OnPropertyChanged(nameof(Broadcast)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.WakeOnLan_BroadcastHistory.Add(x)); } #endregion diff --git a/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs b/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs index d7ed6c10e5..c7986a01c4 100644 --- a/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs @@ -248,9 +248,8 @@ private static void AddUrlToHistory(string url) if (string.IsNullOrEmpty(url)) return; - SettingsManager.Current.WebConsole_UrlHistory = new ObservableCollection( - ListHelper.Modify(SettingsManager.Current.WebConsole_UrlHistory.ToList(), url, - SettingsManager.Current.General_HistoryListEntries)); + ListHelper.Modify(SettingsManager.Current.WebConsole_UrlHistory, url, + SettingsManager.Current.General_HistoryListEntries); } #endregion diff --git a/Source/NETworkManager/ViewModels/WhoisViewModel.cs b/Source/NETworkManager/ViewModels/WhoisViewModel.cs index c272c874ca..86f0e3f085 100644 --- a/Source/NETworkManager/ViewModels/WhoisViewModel.cs +++ b/Source/NETworkManager/ViewModels/WhoisViewModel.cs @@ -217,16 +217,8 @@ public void OnClose() private void AddDomainToHistory(string domain) { - // Create the new list - var list = ListHelper.Modify(SettingsManager.Current.Whois_DomainHistory.ToList(), domain, + ListHelper.Modify(SettingsManager.Current.Whois_DomainHistory, domain, SettingsManager.Current.General_HistoryListEntries); - - // Clear the old items - SettingsManager.Current.Whois_DomainHistory.Clear(); - OnPropertyChanged(nameof(Domain)); // Raise property changed again, after the collection has been cleared - - // Fill with the new items - list.ForEach(x => SettingsManager.Current.Whois_DomainHistory.Add(x)); } private Task Export() diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 7e256ef7ae..13b0e30f70 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -187,6 +187,7 @@ Release date: **xx.xx.2026** - Refactored the **Network Connection** widget's check logic to reduce duplicated code and share the local IP address detection between the Computer and Router checks instead of detecting it twice. [#3553](https://github.com/BornToBeRoot/NETworkManager/pull/3553) - Consolidated the duplicated **Profiles** side panel (search, tag filter, grouped list, context menu, add/edit/copy/delete) used across 15 tool views into a single shared control, reducing code duplication. As part of this, the profile panel's expanded/width state is now shared across all tools instead of being tracked per tool. [#3537](https://github.com/BornToBeRoot/NETworkManager/pull/3537) - Replaced the internal event system for opening a feature with data (e.g. **Open in RDAP** from the **IP Scanner**) with typed requests, which can also carry options (e.g. DNS server and record type) and profiles. Features no longer fail to open from another feature while the application list is filtered by a search. -- Added a unit test project (xUnit) with tests for the run command parser. +- Input histories (e.g. hosts, domains, ports) are updated in place instead of being cleared and refilled, which avoids clearing the text of the input field and unnecessary updates. +- Added a unit test project (xUnit) with tests for the run command parser and the history lists. - Language files updated via [#transifex](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Ftransifex-integration) - Dependencies updated via [#dependabot](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Fdependabot) From f5a0b1e63d0b0bdf7980c2e2b021e4b7063295a6 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:55:21 +0200 Subject: [PATCH 07/14] Refactor: Inline the AddXToHistory methods The 34 AddXToHistory methods only called ListHelper.Modify and were each called once. ListHelper.Modify now ignores default values (null, 0) and empty strings, which the remote tools checked in their methods, so the call sites call it directly. PuTTY keeps its check for port and baud, which are converted to strings. Co-Authored-By: Claude Opus 5.5 --- .../Utilities/ListHelperTests.cs | 23 +++++ Source/NETworkManager.Utilities/ListHelper.cs | 6 +- .../ViewModels/BitCalculatorViewModel.cs | 13 +-- .../ViewModels/DNSLookupViewModel.cs | 13 +-- .../ViewModels/IPGeolocationViewModel.cs | 13 +-- .../ViewModels/IPScannerViewModel.cs | 13 +-- .../ViewModels/LookupOUILookupViewModel.cs | 16 +--- .../ViewModels/LookupPortViewModel.cs | 16 +--- .../ViewModels/PingMonitorHostViewModel.cs | 9 +- .../ViewModels/PortScannerViewModel.cs | 18 +--- .../ViewModels/PowerShellHostViewModel.cs | 12 +-- .../ViewModels/PuTTYHostViewModel.cs | 90 +++++-------------- .../ViewModels/RDAPViewModel.cs | 9 +- .../ViewModels/RemoteDesktopHostViewModel.cs | 12 +-- .../ViewModels/RunCommandViewModel.cs | 9 +- .../ViewModels/SNMPViewModel.cs | 18 +--- .../SubnetCalculatorCalculatorViewModel.cs | 9 +- .../SubnetCalculatorSubnettingViewModel.cs | 14 +-- .../SubnetCalculatorWideSubnetViewModel.cs | 18 +--- .../ViewModels/TigerVNCHostViewModel.cs | 24 +---- .../ViewModels/TracerouteViewModel.cs | 9 +- .../ViewModels/WakeOnLANViewModel.cs | 18 +--- .../ViewModels/WebConsoleHostViewModel.cs | 12 +-- .../ViewModels/WhoisViewModel.cs | 9 +- 24 files changed, 100 insertions(+), 303 deletions(-) diff --git a/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs b/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs index 7f13bce94f..92590e6ef1 100644 --- a/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs +++ b/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs @@ -84,6 +84,29 @@ public void MovesInsteadOfResetting() Assert.Equal([NotifyCollectionChangedAction.Move], actions); } + [Theory] + [InlineData(null)] + [InlineData("")] + public void IgnoresEmptyEntries(string entry) + { + ObservableCollection list = ["a"]; + + ListHelper.Modify(list, entry, 5); + + Assert.Equal(["a"], list); + } + + [Fact] + public void IgnoresDefaultValue() + { + // E.g. a port of 0 if the port is not used. + ObservableCollection list = [22]; + + ListHelper.Modify(list, 0, 5); + + Assert.Equal([22], list); + } + [Fact] public void ThrowsIfTheLengthIsNotPositive() { diff --git a/Source/NETworkManager.Utilities/ListHelper.cs b/Source/NETworkManager.Utilities/ListHelper.cs index c8463288cf..0707e15786 100644 --- a/Source/NETworkManager.Utilities/ListHelper.cs +++ b/Source/NETworkManager.Utilities/ListHelper.cs @@ -13,7 +13,8 @@ public static class ListHelper /// Adds the as the newest (first) entry of the list. An existing entry is moved to the top /// (duplicates are removed) and the oldest entries are removed if the list exceeds the . /// The list is changed in place, so controls bound to it (e.g. the history of a ComboBox) keep their text and are - /// notified only about the actual changes. + /// notified only about the actual changes. Default values (null, 0) and empty strings are not added (e.g. optional + /// inputs). /// /// List to modify (e.g. a history of the settings). /// Entry to add to the list. @@ -24,6 +25,9 @@ public static void Modify(ObservableCollection list, T entry, int length) if (length <= 0) throw new ArgumentOutOfRangeException(nameof(length), "Length must be greater than zero."); + if (EqualityComparer.Default.Equals(entry, default) || entry is string text && string.IsNullOrEmpty(text)) + return; + var index = list.IndexOf(entry); if (index > 0) diff --git a/Source/NETworkManager/ViewModels/BitCalculatorViewModel.cs b/Source/NETworkManager/ViewModels/BitCalculatorViewModel.cs index b2997403e9..9d9141aa88 100644 --- a/Source/NETworkManager/ViewModels/BitCalculatorViewModel.cs +++ b/Source/NETworkManager/ViewModels/BitCalculatorViewModel.cs @@ -264,21 +264,12 @@ private async void Calculate() IsResultVisible = true; - AddInputToHistory(Input); + ListHelper.Modify(SettingsManager.Current.BitCalculator_InputHistory, Input, + SettingsManager.Current.General_HistoryListEntries); IsRunning = false; } - /// - /// Adds the input to the history. - /// - /// The input string. - private void AddInputToHistory(string input) - { - ListHelper.Modify(SettingsManager.Current.BitCalculator_InputHistory, input, - SettingsManager.Current.General_HistoryListEntries); - } - /// /// Called when the view becomes visible. /// diff --git a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs index 33c3d18dcd..ac2b0d9122 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs @@ -385,7 +385,8 @@ private async Task QueryAsync() DragablzTabItem.SetTabHeader(_tabId, Host); - AddHostToHistory(Host); + ListHelper.Modify(SettingsManager.Current.DNSLookup_HostHistory, Host, + SettingsManager.Current.General_HistoryListEntries); DNSLookupSettings dnsSettings = new() { @@ -517,16 +518,6 @@ private void AppendStatusMessage(string message) } // Modify history list - /// - /// Adds the host to the history. - /// - /// The host to add. - private void AddHostToHistory(string host) - { - ListHelper.Modify(SettingsManager.Current.DNSLookup_HostHistory, host, - SettingsManager.Current.General_HistoryListEntries); - } - /// /// Exports the results. diff --git a/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs b/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs index ccbae5f669..ea79459e4e 100644 --- a/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs @@ -262,7 +262,8 @@ private async Task Query() Result = result.Info; IsResultVisible = true; - AddHostToHistory(Host); + ListHelper.Modify(SettingsManager.Current.IPGeolocation_HostHistory, Host, + SettingsManager.Current.General_HistoryListEntries); } catch (Exception ex) { @@ -335,15 +336,5 @@ public void OnClose() ConfigurationManager.Current.IPGeolocationTabCount--; } - /// - /// Adds the host to the history. - /// - /// The host to add. - private void AddHostToHistory(string host) - { - ListHelper.Modify(SettingsManager.Current.IPGeolocation_HostHistory, host, - SettingsManager.Current.General_HistoryListEntries); - } - #endregion } \ No newline at end of file diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs index 37d3818755..d4fe8eeafc 100644 --- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs @@ -552,7 +552,8 @@ private async Task Start() PreparingScan = false; // Add host(s) to the history - AddHostToHistory(Host); + ListHelper.Modify(SettingsManager.Current.IPScanner_HostHistory, Host, + SettingsManager.Current.General_HistoryListEntries); var ipScanner = new IPScanner(new IPScannerOptions( SettingsManager.Current.IPScanner_MaxHostThreads, @@ -699,16 +700,6 @@ private async Task CustomCommand(object guid) } } - /// - /// Adds the scanned host/range to the history. - /// - /// The host or IP range to add. - private void AddHostToHistory(string ipRange) - { - ListHelper.Modify(SettingsManager.Current.IPScanner_HostHistory, ipRange, - SettingsManager.Current.General_HistoryListEntries); - } - /// /// Exports the scan results. /// diff --git a/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs b/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs index a1734f39a7..9ae439de75 100644 --- a/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs +++ b/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs @@ -43,19 +43,6 @@ public LookupOUILookupViewModel() #endregion - #region Methods - - /// - /// Adds the search term to the history. - /// - /// The MAC address or vendor. - private void AddSearchToHistory(string macAddressOrVendor) - { - ListHelper.Modify(SettingsManager.Current.Lookup_OUI_SearchHistory, macAddressOrVendor, - SettingsManager.Current.General_HistoryListEntries); - } - - #endregion #region Variables @@ -251,7 +238,8 @@ private async void OUILookupAction() NothingFound = Results.Count == 0; // Add the MAC-Address or Vendor to the history - AddSearchToHistory(Search); + ListHelper.Modify(SettingsManager.Current.Lookup_OUI_SearchHistory, Search, + SettingsManager.Current.General_HistoryListEntries); IsRunning = false; } diff --git a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs index c078201d32..955e6f8ee8 100644 --- a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs +++ b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs @@ -40,19 +40,6 @@ public LookupPortLookupViewModel() #endregion - #region Methods - - /// - /// Adds the search term to the history. - /// - /// The port or service. - private void AddSearchToHistory(string portOrService) - { - ListHelper.Modify(SettingsManager.Current.Lookup_Port_SearchHistory, portOrService, - SettingsManager.Current.General_HistoryListEntries); - } - - #endregion #region Variables @@ -312,7 +299,8 @@ await PortLookup.LookupByPortAndProtocolAsync( NothingFound = Results.Count == 0; // Add the MAC-Address or Vendor to the history - AddSearchToHistory(Search); + ListHelper.Modify(SettingsManager.Current.Lookup_Port_SearchHistory, Search, + SettingsManager.Current.General_HistoryListEntries); IsRunning = false; } diff --git a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs index a61f01ed8b..66369d96a9 100644 --- a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs @@ -344,7 +344,8 @@ public async Task Start() } // Add host(s) to history - AddHostToHistory(Host); + ListHelper.Modify(SettingsManager.Current.PingMonitor_HostHistory, Host, + SettingsManager.Current.General_HistoryListEntries); // Add host(s) to list and start the ping foreach (var hostView in hosts.hosts.Select(currentHost => @@ -409,12 +410,6 @@ private void RemoveHostByGuid(Guid hostId) Hosts.RemoveAt(i); } - private void AddHostToHistory(string host) - { - ListHelper.Modify(SettingsManager.Current.PingMonitor_HostHistory, host, - SettingsManager.Current.General_HistoryListEntries); - } - #endregion #region Event diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs index 378f5a6349..d455551963 100644 --- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs @@ -487,8 +487,10 @@ private async Task Start() PreparingScan = false; // Add host(s) to the history - AddHostToHistory(Host); - AddPortToHistory(Ports); + ListHelper.Modify(SettingsManager.Current.PortScanner_HostHistory, Host, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, Ports, + SettingsManager.Current.General_HistoryListEntries); var portScanner = new PortScanner(new PortScannerOptions( SettingsManager.Current.PortScanner_MaxHostThreads, @@ -571,18 +573,6 @@ await DialogHelper.ShowMessageAsync(window, Strings.Error, return window.ShowChildWindowAsync(childWindow); } - private void AddHostToHistory(string host) - { - ListHelper.Modify(SettingsManager.Current.PortScanner_HostHistory, host, - SettingsManager.Current.General_HistoryListEntries); - } - - private void AddPortToHistory(string port) - { - ListHelper.Modify(SettingsManager.Current.PortScanner_PortHistory, port, - SettingsManager.Current.General_HistoryListEntries); - } - #endregion #region Events diff --git a/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs b/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs index 424026c8de..e8a2679935 100644 --- a/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs @@ -407,7 +407,8 @@ private Task Connect(string host = null) // Add to history // Note: The history can only be updated after the values have been read. // Otherwise, in some cases, incorrect values are taken over. - AddHostToHistory(instance.Host); + ListHelper.Modify(SettingsManager.Current.PowerShell_HostHistory, instance.Host, + SettingsManager.Current.General_HistoryListEntries); Connect(sessionInfo); }, _ => @@ -470,15 +471,6 @@ public void AddTab(string host) } // Modify history list - private static void AddHostToHistory(string host) - { - if (string.IsNullOrEmpty(host)) - return; - - ListHelper.Modify(SettingsManager.Current.PowerShell_HostHistory, host, - SettingsManager.Current.General_HistoryListEntries); - } - public void FocusEmbeddedWindow() { /* Don't continue if diff --git a/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs b/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs index e8459a73f5..5f48ee3fdd 100644 --- a/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs @@ -333,13 +333,26 @@ private Task Connect(string host = null) // Add to history // Note: The history can only be updated after the values have been read. // Otherwise, in some cases, incorrect values are taken over. - AddHostToHistory(instance.Host); - AddSerialLineToHistory(instance.SerialLine); - AddPortToHistory(instance.Port); - AddBaudToHistory(instance.Baud); - AddUsernameToHistory(instance.Username); - AddPrivateKeyToHistory(instance.PrivateKeyFile); - AddProfileToHistory(instance.Profile); + ListHelper.Modify(SettingsManager.Current.PuTTY_HostHistory, instance.Host, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.PuTTY_SerialLineHistory, instance.SerialLine, + SettingsManager.Current.General_HistoryListEntries); + + // Port and baud are 0 if they are not set. + if (instance.Port > 0) + ListHelper.Modify(SettingsManager.Current.PuTTY_PortHistory, instance.Port.ToString(), + SettingsManager.Current.General_HistoryListEntries); + + if (instance.Baud > 0) + ListHelper.Modify(SettingsManager.Current.PuTTY_BaudHistory, instance.Baud.ToString(), + SettingsManager.Current.General_HistoryListEntries); + + ListHelper.Modify(SettingsManager.Current.PuTTY_UsernameHistory, instance.Username, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.PuTTY_PrivateKeyFileHistory, instance.PrivateKeyFile, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.PuTTY_ProfileHistory, instance.Profile, + SettingsManager.Current.General_HistoryListEntries); Connect(sessionInfo); }, _ => @@ -406,69 +419,6 @@ public void AddTab(string host) } // Modify history list - private static void AddHostToHistory(string host) - { - if (string.IsNullOrEmpty(host)) - return; - - ListHelper.Modify(SettingsManager.Current.PuTTY_HostHistory, host, - SettingsManager.Current.General_HistoryListEntries); - } - - private static void AddSerialLineToHistory(string serialLine) - { - if (string.IsNullOrEmpty(serialLine)) - return; - - ListHelper.Modify(SettingsManager.Current.PuTTY_SerialLineHistory, serialLine, - SettingsManager.Current.General_HistoryListEntries); - } - - private static void AddPortToHistory(int port) - { - if (port == 0) - return; - - ListHelper.Modify(SettingsManager.Current.PuTTY_PortHistory, port.ToString(), - SettingsManager.Current.General_HistoryListEntries); - } - - private static void AddBaudToHistory(int baud) - { - if (baud == 0) - return; - - ListHelper.Modify(SettingsManager.Current.PuTTY_BaudHistory, baud.ToString(), - SettingsManager.Current.General_HistoryListEntries); - } - - private static void AddUsernameToHistory(string username) - { - if (string.IsNullOrEmpty(username)) - return; - - ListHelper.Modify(SettingsManager.Current.PuTTY_UsernameHistory, username, - SettingsManager.Current.General_HistoryListEntries); - } - - private static void AddPrivateKeyToHistory(string privateKey) - { - if (string.IsNullOrEmpty(privateKey)) - return; - - ListHelper.Modify(SettingsManager.Current.PuTTY_PrivateKeyFileHistory, privateKey, - SettingsManager.Current.General_HistoryListEntries); - } - - private static void AddProfileToHistory(string profile) - { - if (string.IsNullOrEmpty(profile)) - return; - - ListHelper.Modify(SettingsManager.Current.PuTTY_ProfileHistory, profile, - SettingsManager.Current.General_HistoryListEntries); - } - public void FocusEmbeddedWindow() { /* Don't continue if diff --git a/Source/NETworkManager/ViewModels/RDAPViewModel.cs b/Source/NETworkManager/ViewModels/RDAPViewModel.cs index 57bcd4d026..33f052ebb6 100644 --- a/Source/NETworkManager/ViewModels/RDAPViewModel.cs +++ b/Source/NETworkManager/ViewModels/RDAPViewModel.cs @@ -349,7 +349,8 @@ private async Task RunQuery() if (messages.Count > 0) ShowStatusMessage(string.Join(Environment.NewLine, messages)); - AddQueryToHistory(Query); + ListHelper.Modify(SettingsManager.Current.RDAP_QueryHistory, Query, + SettingsManager.Current.General_HistoryListEntries); } catch (OperationCanceledException) { @@ -446,12 +447,6 @@ public void OnClose() ConfigurationManager.Current.RDAPTabCount--; } - private void AddQueryToHistory(string query) - { - ListHelper.Modify(SettingsManager.Current.RDAP_QueryHistory, query, - SettingsManager.Current.General_HistoryListEntries); - } - /// /// Gets the content for the export. JSON contains the responses exactly as sent by the server(s), as an array /// if the registrar referral was followed. diff --git a/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs b/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs index 3e9193a1ae..a0e8c0ce50 100644 --- a/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs @@ -289,7 +289,8 @@ private Task Connect(string host = null) // Add to history // Note: The history can only be updated after the values have been read. // Otherwise, in some cases, incorrect values are taken over. - AddHostToHistory(instance.Host); + ListHelper.Modify(SettingsManager.Current.RemoteDesktop_HostHistory, instance.Host, + SettingsManager.Current.General_HistoryListEntries); Connect(sessionInfo); }, _ => @@ -396,14 +397,5 @@ public void AddTab(string host) } // Modify history list - private static void AddHostToHistory(string host) - { - if (string.IsNullOrEmpty(host)) - return; - - ListHelper.Modify(SettingsManager.Current.RemoteDesktop_HostHistory, host, - SettingsManager.Current.General_HistoryListEntries); - } - #endregion } diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs index bbcc89f9a2..318049d984 100644 --- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs +++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs @@ -186,7 +186,8 @@ public void Run() Request = request; IsSettings = result.Command.Type == RunCommandType.Setting; - AddToHistory(text.Trim()); + ListHelper.Modify(SettingsManager.Current.RunCommand_History, text.Trim(), + SettingsManager.Current.General_HistoryListEntries); _runHandler(this); } @@ -349,11 +350,5 @@ private IEnumerable GetHistorySuggestions() }); } - private static void AddToHistory(string text) - { - ListHelper.Modify(SettingsManager.Current.RunCommand_History, text, - SettingsManager.Current.General_HistoryListEntries); - } - #endregion } diff --git a/Source/NETworkManager/ViewModels/SNMPViewModel.cs b/Source/NETworkManager/ViewModels/SNMPViewModel.cs index fac08c3be9..ac69b07627 100644 --- a/Source/NETworkManager/ViewModels/SNMPViewModel.cs +++ b/Source/NETworkManager/ViewModels/SNMPViewModel.cs @@ -581,8 +581,10 @@ await DNSClientHelper.ResolveAorAaaaAsync(Host, _cancellationTokenSource.CancelAfter(SettingsManager.Current.SNMP_Timeout); // Add to history... - AddHostToHistory(Host); - AddOidToHistory(Oid); + ListHelper.Modify(SettingsManager.Current.SNMP_HostHistory, Host, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.SNMP_OidHistory, Oid, + SettingsManager.Current.General_HistoryListEntries); } private void StopWork() @@ -633,18 +635,6 @@ private async Task OpenOIDProfileSelection() await window.ShowChildWindowAsync(childWindow); } - private void AddHostToHistory(string host) - { - ListHelper.Modify(SettingsManager.Current.SNMP_HostHistory, host, - SettingsManager.Current.General_HistoryListEntries); - } - - private void AddOidToHistory(string oid) - { - ListHelper.Modify(SettingsManager.Current.SNMP_OidHistory, oid, - SettingsManager.Current.General_HistoryListEntries); - } - private Task Export() { var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive); diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs index b8d35de196..6e9c117ab9 100644 --- a/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs +++ b/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs @@ -112,15 +112,10 @@ private void Calculate() IsResultVisible = true; - AddSubnetToHistory(subnet); - - IsRunning = false; - } - - private void AddSubnetToHistory(string subnet) - { ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Calculator_SubnetHistory, subnet, SettingsManager.Current.General_HistoryListEntries); + + IsRunning = false; } #endregion diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs index a1aa5cea52..8f4da94fba 100644 --- a/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs +++ b/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs @@ -253,22 +253,12 @@ await Task.Run(() => IsResultVisible = true; - AddSubnetToHistory(subnet); - AddNewSubnetmaskOrCidrToHistory(newSubnetmaskOrCidr); - - IsRunning = false; - } - - private void AddSubnetToHistory(string subnet) - { ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Subnetting_SubnetHistory, subnet, SettingsManager.Current.General_HistoryListEntries); - } - - private void AddNewSubnetmaskOrCidrToHistory(string newSubnetmaskOrCidr) - { ListHelper.Modify(SettingsManager.Current.SubnetCalculator_Subnetting_NewSubnetmaskHistory, newSubnetmaskOrCidr, SettingsManager.Current.General_HistoryListEntries); + + IsRunning = false; } public void OnShutdown() diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs index c9885f28a8..e743d11ca4 100644 --- a/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs +++ b/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs @@ -134,22 +134,12 @@ private void Calculate() IsResultVisible = true; - AddSubnet1ToHistory(subnet1); - AddSubnet2ToHistory(subnet2); - - IsRunning = false; - } - - private void AddSubnet1ToHistory(string subnet) - { - ListHelper.Modify(SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet1, subnet, + ListHelper.Modify(SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet1, subnet1, SettingsManager.Current.General_HistoryListEntries); - } - - private void AddSubnet2ToHistory(string subnet) - { - ListHelper.Modify(SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet2, subnet, + ListHelper.Modify(SettingsManager.Current.SubnetCalculator_WideSubnet_Subnet2, subnet2, SettingsManager.Current.General_HistoryListEntries); + + IsRunning = false; } public void OnShutdown() diff --git a/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs b/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs index fd21a789ea..fcf5a0cd91 100644 --- a/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs @@ -197,8 +197,10 @@ private Task Connect(string host = null) // Add to history // Note: The history can only be updated after the values have been read. // Otherwise, in some cases, incorrect values are taken over. - AddHostToHistory(instance.Host); - AddPortToHistory(instance.Port); + ListHelper.Modify(SettingsManager.Current.TigerVNC_HostHistory, instance.Host, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.TigerVNC_PortHistory, instance.Port, + SettingsManager.Current.General_HistoryListEntries); // Connect Connect(sessionInfo); @@ -259,24 +261,6 @@ public void AddTab(string host) } // Modify history list - private static void AddHostToHistory(string host) - { - if (string.IsNullOrEmpty(host)) - return; - - ListHelper.Modify(SettingsManager.Current.TigerVNC_HostHistory, host, - SettingsManager.Current.General_HistoryListEntries); - } - - private static void AddPortToHistory(int port) - { - if (port == 0) - return; - - ListHelper.Modify(SettingsManager.Current.TigerVNC_PortHistory, port, - SettingsManager.Current.General_HistoryListEntries); - } - #endregion #region Event diff --git a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs index 4efac228ec..4fb34e6b97 100644 --- a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs +++ b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs @@ -433,7 +433,8 @@ await DNSClientHelper.ResolveAorAaaaAsync(Host, traceroute.TraceAsync(ipAddress, _cancellationTokenSource.Token); // Add the host to history - AddHostToHistory(Host); + ListHelper.Modify(SettingsManager.Current.Traceroute_HostHistory, Host, + SettingsManager.Current.General_HistoryListEntries); } catch (Exception ex) // This will catch any exception { @@ -490,12 +491,6 @@ await DialogHelper.ShowMessageAsync(window, Strings.Error, return window.ShowChildWindowAsync(childWindow); } - private void AddHostToHistory(string host) - { - ListHelper.Modify(SettingsManager.Current.Traceroute_HostHistory, host, - SettingsManager.Current.General_HistoryListEntries); - } - public void OnClose() { // Prevent multiple calls diff --git a/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs b/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs index ae729cece6..2da94b0ae5 100644 --- a/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs +++ b/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs @@ -187,8 +187,10 @@ private void WakeUpAction() Port = SettingsManager.Current.WakeOnLAN_Port }; - AddMACAddressToHistory(MACAddress); - AddBroadcastToHistory(Broadcast); + ListHelper.Modify(SettingsManager.Current.WakeOnLan_MACAddressHistory, MACAddress, + SettingsManager.Current.General_HistoryListEntries); + ListHelper.Modify(SettingsManager.Current.WakeOnLan_BroadcastHistory, Broadcast, + SettingsManager.Current.General_HistoryListEntries); _ = WakeUp(info); } @@ -231,17 +233,5 @@ private async Task WakeUp(WakeOnLANInfo info) IsRunning = false; } - private void AddMACAddressToHistory(string macAddress) - { - ListHelper.Modify(SettingsManager.Current.WakeOnLan_MACAddressHistory, macAddress, - SettingsManager.Current.General_HistoryListEntries); - } - - private void AddBroadcastToHistory(string broadcast) - { - ListHelper.Modify(SettingsManager.Current.WakeOnLan_BroadcastHistory, broadcast, - SettingsManager.Current.General_HistoryListEntries); - } - #endregion } diff --git a/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs b/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs index c7986a01c4..504b0ecad6 100644 --- a/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs @@ -190,7 +190,8 @@ private Task Connect(string url = null) // Add to history // Note: The history can only be updated after the values have been read. // Otherwise, in some cases, incorrect values are taken over. - AddUrlToHistory(instance.Url); + ListHelper.Modify(SettingsManager.Current.WebConsole_UrlHistory, instance.Url, + SettingsManager.Current.General_HistoryListEntries); Connect(info); }, _ => @@ -243,14 +244,5 @@ private void Connect(WebConsoleSessionInfo sessionInfo, string header = null) } // Modify history list - private static void AddUrlToHistory(string url) - { - if (string.IsNullOrEmpty(url)) - return; - - ListHelper.Modify(SettingsManager.Current.WebConsole_UrlHistory, url, - SettingsManager.Current.General_HistoryListEntries); - } - #endregion } diff --git a/Source/NETworkManager/ViewModels/WhoisViewModel.cs b/Source/NETworkManager/ViewModels/WhoisViewModel.cs index 86f0e3f085..9a6aeed3c5 100644 --- a/Source/NETworkManager/ViewModels/WhoisViewModel.cs +++ b/Source/NETworkManager/ViewModels/WhoisViewModel.cs @@ -192,7 +192,8 @@ private async Task Query() Result = await Whois.QueryAsync(Domain, whoisServer); IsResultVisible = true; - AddDomainToHistory(Domain); + ListHelper.Modify(SettingsManager.Current.Whois_DomainHistory, Domain, + SettingsManager.Current.General_HistoryListEntries); } } catch (Exception ex) @@ -215,12 +216,6 @@ public void OnClose() ConfigurationManager.Current.WhoisTabCount--; } - private void AddDomainToHistory(string domain) - { - ListHelper.Modify(SettingsManager.Current.Whois_DomainHistory, domain, - SettingsManager.Current.General_HistoryListEntries); - } - private Task Export() { var window = Application.Current.Windows.OfType().FirstOrDefault(x => x.IsActive); From ce661b1bdb45519c53127a12c720a5d2c76cb7c4 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:54:32 +0200 Subject: [PATCH 08/14] Refactor: Profile target and enabled check in ProfileInfo ProfileInfo.IsEnabled(application) and GetTarget(application) (the tool's host, IP range, domain, URL, ...) replace the IsProfileEnabled and GetSearchableField overrides of 16 view models. The profile panel and the run command use the same mappings. Co-Authored-By: Claude Opus 5.5 --- Source/NETworkManager.Profiles/ProfileInfo.cs | 28 +++++++++++++++++++ .../ViewModels/DNSLookupHostViewModel.cs | 4 --- .../ViewModels/IPGeolocationHostViewModel.cs | 4 --- .../ViewModels/IPScannerHostViewModel.cs | 4 --- .../ViewModels/NetworkInterfaceViewModel.cs | 5 ---- .../ViewModels/PingMonitorHostViewModel.cs | 4 --- .../ViewModels/PortScannerHostViewModel.cs | 4 --- .../ViewModels/PowerShellHostViewModel.cs | 4 --- .../ViewModels/ProfileHostViewModelBase.cs | 6 ++-- .../ViewModels/PuTTYHostViewModel.cs | 4 --- .../ViewModels/RDAPHostViewModel.cs | 4 --- .../ViewModels/RemoteDesktopHostViewModel.cs | 4 --- .../ViewModels/SNMPHostViewModel.cs | 4 --- .../ViewModels/TigerVNCHostViewModel.cs | 4 --- .../ViewModels/TracerouteHostViewModel.cs | 4 --- .../ViewModels/WakeOnLANViewModel.cs | 4 --- .../ViewModels/WebConsoleHostViewModel.cs | 4 --- .../ViewModels/WhoisHostViewModel.cs | 4 --- 18 files changed, 31 insertions(+), 68 deletions(-) diff --git a/Source/NETworkManager.Profiles/ProfileInfo.cs b/Source/NETworkManager.Profiles/ProfileInfo.cs index 8ddbb9e16c..ebbe5562f2 100644 --- a/Source/NETworkManager.Profiles/ProfileInfo.cs +++ b/Source/NETworkManager.Profiles/ProfileInfo.cs @@ -534,4 +534,32 @@ public bool IsEnabled(ApplicationName application) _ => false }; } + + /// + /// Gets the target of the profile in an application, e.g. the host, IP range, domain, URL or MAC address. + /// + /// Application to get the target for. + /// Target, or null if the application has none. + public string GetTarget(ApplicationName application) + { + return application switch + { + ApplicationName.IPScanner => IPScanner_HostOrIPRange, + ApplicationName.PortScanner => PortScanner_Host, + ApplicationName.PingMonitor => PingMonitor_Host, + ApplicationName.Traceroute => Traceroute_Host, + ApplicationName.DNSLookup => DNSLookup_Host, + ApplicationName.RemoteDesktop => RemoteDesktop_Host, + ApplicationName.PowerShell => PowerShell_Host, + ApplicationName.PuTTY => PuTTY_HostOrSerialLine, + ApplicationName.TigerVNC => TigerVNC_Host, + ApplicationName.WebConsole => WebConsole_Url, + ApplicationName.SNMP => SNMP_Host, + ApplicationName.WakeOnLAN => WakeOnLAN_MACAddress, + ApplicationName.RDAP => RDAP_Query, + ApplicationName.Whois => Whois_Domain, + ApplicationName.IPGeolocation => IPGeolocation_Host, + _ => null + }; + } } \ No newline at end of file diff --git a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs index eff2371440..bc4fcdbc8e 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs @@ -90,10 +90,6 @@ public DNSLookupHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.DNSLookup; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.DNSLookup_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.DNSLookup_Host; - #endregion #region ICommand & Actions diff --git a/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs b/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs index dc6cac0a84..af8bca6e24 100644 --- a/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs @@ -88,10 +88,6 @@ public IPGeolocationHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.IPGeolocation; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.IPGeolocation_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.IPGeolocation_Host; - #endregion #region ICommand & Actions diff --git a/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs index 83d1cb9347..4808a0e2c9 100644 --- a/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs @@ -88,10 +88,6 @@ public IPScannerHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.IPScanner; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.IPScanner_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.IPScanner_HostOrIPRange; - #endregion #region ICommand & Actions diff --git a/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs b/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs index e71645b52b..70f1e90188 100644 --- a/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs +++ b/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs @@ -732,11 +732,6 @@ private async Task LoadNetworkInterfaces() protected override ApplicationName ApplicationName => ApplicationName.NetworkInterface; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.NetworkInterface_Enabled; - - // Unlike other tools, profile search here only matches the profile name (no secondary field). - protected override string GetSearchableField(ProfileInfo profile) => string.Empty; - #endregion #region ICommands & Actions diff --git a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs index 66369d96a9..28a7e46956 100644 --- a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs @@ -185,10 +185,6 @@ public PingMonitorHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.PingMonitor; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.PingMonitor_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.PingMonitor_Host; - #endregion #region ICommands & Actions diff --git a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs index 2813e69bda..a83df00221 100644 --- a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs @@ -88,10 +88,6 @@ public PortScannerHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.PortScanner; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.PortScanner_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.PortScanner_Host; - #endregion #region ICommand & Actions diff --git a/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs b/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs index e8a2679935..80527ee038 100644 --- a/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs @@ -135,10 +135,6 @@ public PowerShellHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.PowerShell; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.PowerShell_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.PowerShell_Host; - /// /// Also mirrors the popup state into so the main window can suppress /// global focus-stealing behavior (e.g. re-focusing the embedded terminal) while the filter popup is open. diff --git a/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs b/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs index dd58d3e331..1cf7e58328 100644 --- a/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs +++ b/Source/NETworkManager/ViewModels/ProfileHostViewModelBase.cs @@ -36,13 +36,13 @@ public abstract class ProfileHostViewModelBase : ViewModelBase, IProfileHostView /// /// Determines whether the given profile is enabled for this tool. /// - protected abstract bool IsProfileEnabled(ProfileInfo profile); + private bool IsProfileEnabled(ProfileInfo profile) => profile.IsEnabled(ApplicationName); /// - /// Gets the tool-specific field of the given profile that should also be matched against the search text + /// Gets the tool-specific field of the given profile (e.g. host) that is also matched against the search text /// (in addition to the profile name). /// - protected abstract string GetSearchableField(ProfileInfo profile); + private string GetSearchableField(ProfileInfo profile) => profile.GetTarget(ApplicationName) ?? string.Empty; /// /// Gets the view for the profiles. diff --git a/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs b/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs index 5f48ee3fdd..244e2fb7ad 100644 --- a/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/PuTTYHostViewModel.cs @@ -135,10 +135,6 @@ public PuTTYHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.PuTTY; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.PuTTY_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.PuTTY_HostOrSerialLine; - /// /// Also mirrors the popup state into so the main window can suppress /// global focus-stealing behavior (e.g. re-focusing the embedded terminal) while the filter popup is open. diff --git a/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs b/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs index 2b9ec398e1..84ccc2acfa 100644 --- a/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs @@ -71,10 +71,6 @@ public RDAPHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.RDAP; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.RDAP_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.RDAP_Query; - #endregion #region ICommand & Actions diff --git a/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs b/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs index a0e8c0ce50..27b990ecb7 100644 --- a/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/RemoteDesktopHostViewModel.cs @@ -74,10 +74,6 @@ public RemoteDesktopHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.RemoteDesktop; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.RemoteDesktop_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.RemoteDesktop_Host; - public override void OnProfileManagerDialogOpen() { ConfigurationManager.OnDialogOpen(); diff --git a/Source/NETworkManager/ViewModels/SNMPHostViewModel.cs b/Source/NETworkManager/ViewModels/SNMPHostViewModel.cs index b97a87db98..4d215d8002 100644 --- a/Source/NETworkManager/ViewModels/SNMPHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/SNMPHostViewModel.cs @@ -68,10 +68,6 @@ public SNMPHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.SNMP; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.SNMP_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.SNMP_Host; - #endregion #region ICommand & Actions diff --git a/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs b/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs index fcf5a0cd91..0656b89157 100644 --- a/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/TigerVNCHostViewModel.cs @@ -92,10 +92,6 @@ public TigerVNCHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.TigerVNC; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.TigerVNC_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.TigerVNC_Host; - public override void OnProfileManagerDialogOpen() { ConfigurationManager.OnDialogOpen(); diff --git a/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs b/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs index 40407b6d5f..f0ec9dacd8 100644 --- a/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs @@ -70,10 +70,6 @@ public TracerouteHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.Traceroute; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.Traceroute_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.Traceroute_Host; - #endregion #region ICommand & Actions diff --git a/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs b/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs index 2da94b0ae5..09ccfc61fb 100644 --- a/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs +++ b/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs @@ -158,10 +158,6 @@ public WakeOnLANViewModel() protected override ApplicationName ApplicationName => ApplicationName.WakeOnLAN; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.WakeOnLAN_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.WakeOnLAN_MACAddress; - #endregion #region ICommands & Actions diff --git a/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs b/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs index 504b0ecad6..e359419d6a 100644 --- a/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/WebConsoleHostViewModel.cs @@ -99,10 +99,6 @@ public WebConsoleHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.WebConsole; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.WebConsole_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.WebConsole_Url; - public override void OnProfileManagerDialogOpen() { ConfigurationManager.OnDialogOpen(); diff --git a/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs b/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs index 56398a281b..b4954a91d1 100644 --- a/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs @@ -70,10 +70,6 @@ public WhoisHostViewModel() protected override ApplicationName ApplicationName => ApplicationName.Whois; - protected override bool IsProfileEnabled(ProfileInfo profile) => profile.Whois_Enabled; - - protected override string GetSearchableField(ProfileInfo profile) => profile.Whois_Domain; - #endregion #region ICommand & Actions From 8b515aebd48cb82142113f55b90766448bfef80c Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:54:32 +0200 Subject: [PATCH 09/14] Refactor: Wait for the view to be loaded without polling WaitForLoadedAsync (Utilities.WPF) completes on the Loaded event (or immediately if the view is already loaded) and replaces the "while (!_loaded) await Task.Delay(250)" loops of the remote tools. Co-Authored-By: Claude Opus 5.5 --- .../FrameworkElementExtensions.cs | 34 +++++++++++++++++++ .../Views/PowerShellHostView.xaml | 1 - .../Views/PowerShellHostView.xaml.cs | 13 ++----- .../NETworkManager/Views/PuTTYHostView.xaml | 1 - .../Views/PuTTYHostView.xaml.cs | 13 ++----- .../Views/RemoteDesktopHostView.xaml | 1 - .../Views/RemoteDesktopHostView.xaml.cs | 13 ++----- .../Views/TigerVNCHostView.xaml | 1 - .../Views/TigerVNCHostView.xaml.cs | 13 ++----- .../Views/WebConsoleHostView.xaml | 1 - .../Views/WebConsoleHostView.xaml.cs | 13 ++----- 11 files changed, 44 insertions(+), 60 deletions(-) create mode 100644 Source/NETworkManager.Utilities.WPF/FrameworkElementExtensions.cs diff --git a/Source/NETworkManager.Utilities.WPF/FrameworkElementExtensions.cs b/Source/NETworkManager.Utilities.WPF/FrameworkElementExtensions.cs new file mode 100644 index 0000000000..62e73f6d5a --- /dev/null +++ b/Source/NETworkManager.Utilities.WPF/FrameworkElementExtensions.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using System.Windows; + +namespace NETworkManager.Utilities.WPF; + +/// +/// Extension methods for . +/// +public static class FrameworkElementExtensions +{ + /// + /// Waits until the element is loaded (e.g. before showing a dialog from a view that was just created, which + /// MahApps doesn't allow before the view is loaded). Completes immediately if the element is already loaded. + /// + /// Element to wait for. + /// Task that completes when the element is loaded. + public static Task WaitForLoadedAsync(this FrameworkElement element) + { + if (element.IsLoaded) + return Task.CompletedTask; + + var taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnLoaded(object sender, RoutedEventArgs e) + { + element.Loaded -= OnLoaded; + taskCompletionSource.TrySetResult(); + } + + element.Loaded += OnLoaded; + + return taskCompletionSource.Task; + } +} diff --git a/Source/NETworkManager/Views/PowerShellHostView.xaml b/Source/NETworkManager/Views/PowerShellHostView.xaml index 4b402065b6..2b2966419a 100644 --- a/Source/NETworkManager/Views/PowerShellHostView.xaml +++ b/Source/NETworkManager/Views/PowerShellHostView.xaml @@ -18,7 +18,6 @@ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:networkManager="clr-namespace:NETworkManager" xmlns:hostControls="clr-namespace:NETworkManager.Views.Controls" - Loaded="UserControl_Loaded" mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:PowerShellHostViewModel}"> diff --git a/Source/NETworkManager/Views/PowerShellHostView.xaml.cs b/Source/NETworkManager/Views/PowerShellHostView.xaml.cs index 5c1b7252f9..52447a191c 100644 --- a/Source/NETworkManager/Views/PowerShellHostView.xaml.cs +++ b/Source/NETworkManager/Views/PowerShellHostView.xaml.cs @@ -1,6 +1,5 @@ +using NETworkManager.Utilities.WPF; using NETworkManager.ViewModels; -using System.Threading.Tasks; -using System.Windows; namespace NETworkManager.Views; @@ -8,25 +7,17 @@ public partial class PowerShellHostView : IApplicationRequestHandler { private readonly PowerShellHostViewModel _viewModel = new(); - private bool _loaded; - public PowerShellHostView() { InitializeComponent(); DataContext = _viewModel; } - private void UserControl_Loaded(object sender, RoutedEventArgs e) - { - _loaded = true; - } - public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new profile... // MahApps will throw an exception... - while (!_loaded) - await Task.Delay(250); + await this.WaitForLoadedAsync(); if (!_viewModel.IsExecutableConfigured) return; diff --git a/Source/NETworkManager/Views/PuTTYHostView.xaml b/Source/NETworkManager/Views/PuTTYHostView.xaml index f158446330..ea813e7f0c 100644 --- a/Source/NETworkManager/Views/PuTTYHostView.xaml +++ b/Source/NETworkManager/Views/PuTTYHostView.xaml @@ -18,7 +18,6 @@ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:networkManager="clr-namespace:NETworkManager" xmlns:hostControls="clr-namespace:NETworkManager.Views.Controls" - Loaded="UserControl_Loaded" mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:PuTTYHostViewModel}"> diff --git a/Source/NETworkManager/Views/PuTTYHostView.xaml.cs b/Source/NETworkManager/Views/PuTTYHostView.xaml.cs index 02344d55b4..7bc6410bc6 100644 --- a/Source/NETworkManager/Views/PuTTYHostView.xaml.cs +++ b/Source/NETworkManager/Views/PuTTYHostView.xaml.cs @@ -1,6 +1,5 @@ +using NETworkManager.Utilities.WPF; using NETworkManager.ViewModels; -using System.Threading.Tasks; -using System.Windows; namespace NETworkManager.Views; @@ -8,25 +7,17 @@ public partial class PuTTYHostView : IApplicationRequestHandler { private readonly PuTTYHostViewModel _viewModel = new(); - private bool _loaded; - public PuTTYHostView() { InitializeComponent(); DataContext = _viewModel; } - private void UserControl_Loaded(object sender, RoutedEventArgs e) - { - _loaded = true; - } - public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new profile... // MahApps will throw an exception... - while (!_loaded) - await Task.Delay(250); + await this.WaitForLoadedAsync(); if (!_viewModel.IsExecutableConfigured) return; diff --git a/Source/NETworkManager/Views/RemoteDesktopHostView.xaml b/Source/NETworkManager/Views/RemoteDesktopHostView.xaml index a47620e7b8..6ed47f1c38 100644 --- a/Source/NETworkManager/Views/RemoteDesktopHostView.xaml +++ b/Source/NETworkManager/Views/RemoteDesktopHostView.xaml @@ -17,7 +17,6 @@ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:networkManager="clr-namespace:NETworkManager" xmlns:hostControls="clr-namespace:NETworkManager.Views.Controls" - Loaded="UserControl_Loaded" mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:RemoteDesktopHostViewModel}"> diff --git a/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs b/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs index 45f3ca49a9..cfdbc89bad 100644 --- a/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs +++ b/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs @@ -1,6 +1,5 @@ +using NETworkManager.Utilities.WPF; using NETworkManager.ViewModels; -using System.Threading.Tasks; -using System.Windows; namespace NETworkManager.Views; @@ -8,25 +7,17 @@ public partial class RemoteDesktopHostView : IApplicationRequestHandler { private readonly RemoteDesktopHostViewModel _viewModel = new(); - private bool _loaded; - public RemoteDesktopHostView() { InitializeComponent(); DataContext = _viewModel; } - private void UserControl_Loaded(object sender, RoutedEventArgs e) - { - _loaded = true; - } - public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new Profile... // MahApps will throw an exception... - while (!_loaded) - await Task.Delay(250); + await this.WaitForLoadedAsync(); if (request.Profile != null) _viewModel.ConnectProfile(request.Profile); diff --git a/Source/NETworkManager/Views/TigerVNCHostView.xaml b/Source/NETworkManager/Views/TigerVNCHostView.xaml index 228da7c123..a50e0a27d7 100644 --- a/Source/NETworkManager/Views/TigerVNCHostView.xaml +++ b/Source/NETworkManager/Views/TigerVNCHostView.xaml @@ -17,7 +17,6 @@ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:networkManager="clr-namespace:NETworkManager" xmlns:hostControls="clr-namespace:NETworkManager.Views.Controls" - Loaded="UserControl_Loaded" mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:TigerVNCHostViewModel}"> diff --git a/Source/NETworkManager/Views/TigerVNCHostView.xaml.cs b/Source/NETworkManager/Views/TigerVNCHostView.xaml.cs index dc19c32529..5edf541d6b 100644 --- a/Source/NETworkManager/Views/TigerVNCHostView.xaml.cs +++ b/Source/NETworkManager/Views/TigerVNCHostView.xaml.cs @@ -1,6 +1,5 @@ +using NETworkManager.Utilities.WPF; using NETworkManager.ViewModels; -using System.Threading.Tasks; -using System.Windows; namespace NETworkManager.Views; @@ -8,25 +7,17 @@ public partial class TigerVNCHostView : IApplicationRequestHandler { private readonly TigerVNCHostViewModel _viewModel = new(); - private bool _loaded; - public TigerVNCHostView() { InitializeComponent(); DataContext = _viewModel; } - private void UserControl_Loaded(object sender, RoutedEventArgs e) - { - _loaded = true; - } - public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new profile... // MahApps will throw an exception... - while (!_loaded) - await Task.Delay(250); + await this.WaitForLoadedAsync(); if (!_viewModel.IsConfigured) return; diff --git a/Source/NETworkManager/Views/WebConsoleHostView.xaml b/Source/NETworkManager/Views/WebConsoleHostView.xaml index a385483b03..2b23a34b9c 100644 --- a/Source/NETworkManager/Views/WebConsoleHostView.xaml +++ b/Source/NETworkManager/Views/WebConsoleHostView.xaml @@ -18,7 +18,6 @@ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:networkManager="clr-namespace:NETworkManager" xmlns:hostControls="clr-namespace:NETworkManager.Views.Controls" - Loaded="UserControl_Loaded" mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:WebConsoleHostViewModel}"> diff --git a/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs b/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs index 84bb2fcd61..abc5649393 100644 --- a/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs +++ b/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs @@ -1,6 +1,5 @@ +using NETworkManager.Utilities.WPF; using NETworkManager.ViewModels; -using System.Threading.Tasks; -using System.Windows; namespace NETworkManager.Views; @@ -8,25 +7,17 @@ public partial class WebConsoleHostView : IApplicationRequestHandler { private readonly WebConsoleHostViewModel _viewModel = new(); - private bool _loaded; - public WebConsoleHostView() { InitializeComponent(); DataContext = _viewModel; } - private void UserControl_Loaded(object sender, RoutedEventArgs e) - { - _loaded = true; - } - public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new profile... // MahApps will throw an exception... - while (!_loaded) - await Task.Delay(250); + await this.WaitForLoadedAsync(); if (request.Profile != null) _viewModel.ConnectProfile(request.Profile); From fe9631f58292269caf10329310ae309ddacfa2e6 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:54:32 +0200 Subject: [PATCH 10/14] Fix: Run command review findings - Suggestions: exact command/alias first (e.g. "lookup" no longer opens DNS Lookup), exact profile name first in "@" mode, recent commands of a command before its profiles, the tool's target of a profile (e.g. RDP host) instead of the general host. - DNS servers: configured servers as suggestions, matched by name case-insensitively; values with spaces can be quoted ("Google Public DNS"). - DNS Lookup uses PTR for IP addresses from any request (run command, IP Scanner, Traceroute, profiles); shortened IPv4 forms like "10" are host names. - A failed suggestion (e.g. a recent command with an ambiguous profile name) is put into the input to correct it. - Syntax hints use resource strings; history entries are parsed once. - Docs: quotes, DNS servers, duplicate profile names, "@" only at the start of the target. Co-Authored-By: Claude Opus 5.5 --- .../RunCommand/RunCommandParser.cs | 34 ++++- .../RunCommand/RunCommandParserTests.cs | 45 ++++++ Source/NETworkManager/RunCommandManager.cs | 64 +++++---- Source/NETworkManager/RunCommandSuggestion.cs | 7 +- .../ViewModels/DNSLookupHostViewModel.cs | 22 ++- .../ViewModels/RunCommandViewModel.cs | 133 +++++++++++++----- .../Views/RunCommandChildWindow.xaml | 7 + Website/docs/changelog/next-release.md | 4 + Website/docs/run-command.md | 14 +- 9 files changed, 258 insertions(+), 72 deletions(-) diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs index df43757b23..acb94d933c 100644 --- a/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs +++ b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs @@ -16,12 +16,20 @@ public static partial class RunCommandParser [GeneratedRegex(@"\s*;\s*")] private static partial Regex ListSeparatorRegex(); + /// + /// A word: a value in quotes (can contain spaces, the closing quote can be missing while typing) or a value + /// without spaces. + /// + [GeneratedRegex("\"[^\"]*(?:\"|$)|[^\\s\"]+")] + private static partial Regex WordRegex(); + /// /// Parses the input. /// - Lists use ";" as everywhere in the application. Whitespace around ";" is removed, so whitespace only /// separates the arguments. /// - The first word is the command (name or alias), the second word the target. "@name" (the rest of the input, /// since profile names can contain spaces) is a profile instead of a target. + /// - A value with spaces is written in quotes (e.g. a DNS server "Google Public DNS"). /// - Further words are a type keyword of the command or its second argument (e.g. ports, DNS server), in any /// order. /// @@ -32,8 +40,13 @@ public static RunCommandParseResult Parse(string input, IEnumerable 0 && char.IsWhiteSpace(input[^1]); - var words = ListSeparatorRegex().Replace(input, ";").Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + // Words as typed (with quotes, to rebuild the input) and their values (without quotes) + var tokens = WordRegex().Matches(ListSeparatorRegex().Replace(input, ";")).Select(x => x.Value).ToArray(); + var words = tokens.Select(Unquote).ToArray(); + + // Whitespace in an open quote (e.g. "Google ) is part of the word that is being typed. + var endsWithWhitespace = input.Length > 0 && char.IsWhiteSpace(input[^1]) && + !(tokens.Length > 0 && IsOpenQuote(tokens[^1])); if (words.Length == 0) return new RunCommandParseResult { Position = RunCommandPosition.Command, Error = RunCommandParseError.Empty }; @@ -68,7 +81,7 @@ public static RunCommandParseResult Parse(string input, IEnumerable + /// Removes the quotes of a value (e.g. "Google Public DNS"). + /// + private static string Unquote(string word) + { + return word.StartsWith('"') ? word.Trim('"') : word; + } + + private static bool IsOpenQuote(string token) + { + return token.StartsWith('"') && (token.Length == 1 || !token.EndsWith('"')); + } } diff --git a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs index 770a1f096d..769a44ba8e 100644 --- a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs +++ b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs @@ -14,6 +14,11 @@ public class RunCommandParserTests Aliases = ["ping"], SupportsTarget = true, SupportsProfile = true }, new() + { + Type = RunCommandType.Application, Application = ApplicationName.RemoteDesktop, Command = "remotedesktop", + Aliases = ["rdp"], SupportsTarget = true, SupportsProfile = true + }, + new() { Type = RunCommandType.Application, Application = ApplicationName.DNSLookup, Command = "dnslookup", Aliases = ["dns", "nslookup"], SupportsTarget = true, SupportsProfile = true, @@ -121,6 +126,46 @@ public void DNSServerAndTypeInAnyOrder(string input) Assert.Equal(RunCommandParseError.None, result.Error); } + [Theory] + [InlineData("dns borntoberoot.net \"Google Public DNS\" aaaa")] + [InlineData("dns borntoberoot.net aaaa \"Google Public DNS\"")] + public void QuotedArgument(string input) + { + var result = Parse(input); + + Assert.Equal("Google Public DNS", result.Argument); + Assert.Equal("aaaa", result.Type); + Assert.Equal(RunCommandParseError.None, result.Error); + } + + [Fact] + public void OpenQuoteIsTheWordThatIsBeingTyped() + { + // Whitespace in an open quote doesn't start a new word. + var result = Parse("dns borntoberoot.net \"Google "); + + Assert.Equal(RunCommandPosition.Argument, result.Position); + Assert.Equal("Google ", result.CurrentWord); + Assert.Equal("dns borntoberoot.net ", result.InputBeforeCurrentWord); + } + + [Fact] + public void CompletionKeepsQuotes() + { + var result = Parse("dns borntoberoot.net \"Google Public DNS\" aa"); + + Assert.Equal("aa", result.CurrentWord); + Assert.Equal("dns borntoberoot.net \"Google Public DNS\" ", result.InputBeforeCurrentWord); + } + + [Fact] + public void QuotedProfileName() + { + var result = Parse("rdp @\"Web Server 01\""); + + Assert.Equal("Web Server 01", result.ProfileName); + } + [Fact] public void HostsAndType() { diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs index 8d6392fca8..acd928b485 100644 --- a/Source/NETworkManager/RunCommandManager.cs +++ b/Source/NETworkManager/RunCommandManager.cs @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Net; using System.Windows.Controls; namespace NETworkManager; @@ -35,8 +34,6 @@ public static class RunCommandManager ["entity"] = RDAPQueryType.Entity }; - private static readonly IReadOnlyList Commands = CreateCommands(); - /// /// Gets the commands of the visible applications and the settings. /// @@ -46,7 +43,17 @@ public static IReadOnlyList GetCommands() var visibleApplications = SettingsManager.Current.General_ApplicationList.Where(x => x.IsVisible) .Select(x => x.Name).ToHashSet(); - return [.. Commands.Where(x => x.Type == RunCommandType.Setting || visibleApplications.Contains(x.Application))]; + // Created on each call, since the syntax is translated. + return [.. CreateCommands().Where(x => x.Type == RunCommandType.Setting || visibleApplications.Contains(x.Application))]; + } + + /// + /// Gets the names of the DNS servers configured in the DNS lookup (e.g. "Cloudflare"). + /// + /// Names of the DNS servers. + public static IEnumerable GetDNSServers() + { + return SettingsManager.Current.DNSLookup_DNSServers.Select(x => x.Name); } /// @@ -217,13 +224,11 @@ private static ApplicationRequest CreateRequest(RunCommandParseResult result) ApplicationName.DNSLookup => new DNSLookupRequest { Target = result.Target, - DNSServer = result.Argument, - QueryType = result.Type != null - ? Enum.Parse(result.Type, true) - // Only IP addresses: reverse lookup - : result.Target.Split(';').All(x => IPAddress.TryParse(x, out _)) - ? QueryType.PTR - : null + // A configured DNS server is matched by its name (case-insensitive), otherwise it's a custom server. + DNSServer = GetDNSServers().FirstOrDefault(x => + string.Equals(x, result.Argument, StringComparison.OrdinalIgnoreCase)) ?? result.Argument, + // Without a type, the DNS lookup uses PTR for IP addresses and its selected type for hosts. + QueryType = result.Type != null ? Enum.Parse(result.Type, true) : null }, ApplicationName.PortScanner => new PortScannerRequest { Target = result.Target, Ports = result.Argument }, ApplicationName.RDAP => new RDAPRequest { Target = result.Target, QueryType = GetRDAPQueryType(result.Type) }, @@ -242,24 +247,31 @@ private static RDAPQueryType GetRDAPQueryType(string type) /// private static List CreateCommands() { + // Syntax of the arguments, e.g. " [Ports]" + static string Required(string name) => $"<{name}>"; + static string Optional(string name) => $"[{name}]"; + var commands = ApplicationManager.GetNames().Where(x => x != ApplicationName.None).Select(name => name switch { - ApplicationName.IPScanner => Create(name, ["scan"], "", true), - ApplicationName.PortScanner => Create(name, ["portscan"], " [ports]", true, - RunCommandArgument.Ports), - ApplicationName.PingMonitor => Create(name, ["ping"], "", true), - ApplicationName.Traceroute => Create(name, ["trace", "tracert"], "", true), - ApplicationName.DNSLookup => Create(name, ["dns", "nslookup"], " [server] [type]", true, + ApplicationName.IPScanner => Create(name, ["scan"], Required(Strings.IPRange), true), + ApplicationName.PortScanner => Create(name, ["portscan"], + $"{Required(Strings.Hosts)} {Optional(Strings.Ports)}", true, RunCommandArgument.Ports), + ApplicationName.PingMonitor => Create(name, ["ping"], Required(Strings.Hosts), true), + ApplicationName.Traceroute => Create(name, ["trace", "tracert"], Required(Strings.Host), true), + ApplicationName.DNSLookup => Create(name, ["dns", "nslookup"], + $"{Required(Strings.Hosts)} {Optional(Strings.DNSServer)} {Optional(Strings.Type)}", true, RunCommandArgument.DNSServer, [.. DNSLookup.QueryTypes.Select(x => x.ToString().ToLowerInvariant()).Order()]), - ApplicationName.RemoteDesktop => Create(name, ["rdp", "mstsc"], "", true), - ApplicationName.PowerShell => Create(name, ["ps", "pwsh"], "", true), - ApplicationName.PuTTY => Create(name, ["ssh"], "", true), - ApplicationName.TigerVNC => Create(name, ["vnc"], "", true), - ApplicationName.WebConsole => Create(name, ["web"], "", true), - ApplicationName.SNMP => Create(name, [], "", true), - ApplicationName.RDAP => Create(name, [], " [type]", true, types: [.. RDAPTypes.Keys]), - ApplicationName.Whois => Create(name, [], "", true), + ApplicationName.RemoteDesktop => Create(name, ["rdp", "mstsc"], + $"{Required(Strings.Host)}[:{Required(Strings.Port)}]", true), + ApplicationName.PowerShell => Create(name, ["ps", "pwsh"], Required(Strings.Host), true), + ApplicationName.PuTTY => Create(name, ["ssh"], Required(Strings.Host), true), + ApplicationName.TigerVNC => Create(name, ["vnc"], Required(Strings.Host), true), + ApplicationName.WebConsole => Create(name, ["web"], Required(Strings.URL), true), + ApplicationName.SNMP => Create(name, [], Required(Strings.Host), true), + ApplicationName.RDAP => Create(name, [], $"{Required(Strings.Query)} {Optional(Strings.Type)}", true, + types: [.. RDAPTypes.Keys]), + ApplicationName.Whois => Create(name, [], Required(Strings.Domain), true), _ => Create(name, [], string.Empty, false) }).ToList(); @@ -287,7 +299,7 @@ private static RunCommandDefinition Create(ApplicationName application, string[] SupportsProfile = supportsTarget, Argument = argument, Types = types ?? [], - Syntax = supportsTarget ? $"{syntax} | @" : string.Empty + Syntax = supportsTarget ? $"{syntax} | @<{Strings.Profile}>" : string.Empty }; } } diff --git a/Source/NETworkManager/RunCommandSuggestion.cs b/Source/NETworkManager/RunCommandSuggestion.cs index c23a8587d0..cd2d32bd85 100644 --- a/Source/NETworkManager/RunCommandSuggestion.cs +++ b/Source/NETworkManager/RunCommandSuggestion.cs @@ -31,7 +31,12 @@ public enum RunCommandSuggestionKind /// /// Type keyword of the command (e.g. DNS record type). /// - Type + Type, + + /// + /// DNS server configured in the DNS lookup. + /// + DNSServer } /// diff --git a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs index bc4fcdbc8e..1fecad4366 100644 --- a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs @@ -9,6 +9,9 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; +using System.Net; +using System.Net.Sockets; using System.Windows.Input; namespace NETworkManager.ViewModels; @@ -150,9 +153,15 @@ private static void CloseItemAction(ItemActionCallbackArgs args) /// /// The host to lookup. /// DNS server(s) to use. If null, the selected DNS server is used. - /// Query type. If null, the selected query type is used. + /// + /// Query type. If null, PTR is used for IP addresses (e.g. from the IP Scanner) and the selected query type for + /// hosts. + /// public void AddTab(string host = null, string dnsServer = null, QueryType? queryType = null) { + if (queryType == null && IsIPAddresses(host)) + queryType = QueryType.PTR; + var tabId = Guid.NewGuid(); TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab, new DNSLookupView(tabId, host, dnsServer, queryType), @@ -161,6 +170,17 @@ public void AddTab(string host = null, string dnsServer = null, QueryType? query SelectedTabIndex = TabItems.Count - 1; } + /// + /// Checks if the host(s) are only IP addresses (shortened IPv4 forms like "10" are host names). + /// + private static bool IsIPAddresses(string hosts) + { + return !string.IsNullOrWhiteSpace(hosts) && hosts.Split(';').Select(x => x.Trim()).All(x => + RegexHelper.IPv4AddressRegex().IsMatch(x) || + x.Contains(':') && IPAddress.TryParse(x, out var ipAddress) && + ipAddress.AddressFamily == AddressFamily.InterNetworkV6); + } + /// /// Adds a new tab for the host of the specified profile. /// diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs index 318049d984..b9d8dcc0bb 100644 --- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs +++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs @@ -23,10 +23,15 @@ public class RunCommandViewModel : ViewModelBase private const int MaxSuggestions = 10; /// - /// Maximum number of recent commands shown in the suggestions. + /// Maximum number of recent commands shown with an empty input (before the commands). /// private const int MaxHistorySuggestions = 5; + /// + /// Maximum number of recent commands of a command shown before its profiles. + /// + private const int MaxCommandHistorySuggestions = 3; + private readonly IReadOnlyList _commands; private readonly Action _runHandler; @@ -178,10 +183,19 @@ public void Run() var text = suggestion?.CompletionText ?? Input; var result = RunCommandParser.Parse(text, _commands); - ErrorMessage = RunCommandManager.TryCreateRequest(result, suggestion?.Profile, out var request); + var error = RunCommandManager.TryCreateRequest(result, suggestion?.Profile, out var request); + + if (error != null) + { + // Show the text of a failed suggestion (e.g. a recent command with an ambiguous profile name), so it can be + // corrected or completed with the suggestions. + if (suggestion != null && suggestion.Kind != RunCommandSuggestionKind.Run) + Input = text; + + ErrorMessage = error; - if (ErrorMessage != null) return; + } Request = request; IsSettings = result.Command.Type == RunCommandType.Setting; @@ -207,8 +221,16 @@ private void UpdateSuggestions() { RunCommandPosition.Command => GetCommandSuggestions(), RunCommandPosition.Target when _result.ProfileName != null => GetProfileSuggestions(), - RunCommandPosition.Target => [GetRunSuggestion(), .. GetProfileSuggestions(), .. GetHistorySuggestions()], - _ => [GetRunSuggestion(), .. GetTypeSuggestions(), .. GetHistorySuggestions()] + RunCommandPosition.Target => + [ + GetRunSuggestion(), .. GetHistorySuggestions().Take(MaxCommandHistorySuggestions), + .. GetProfileSuggestions() + ], + _ => + [ + GetRunSuggestion(), .. GetTypeSuggestions(), .. GetDNSServerSuggestions(), + .. GetHistorySuggestions() + ] }; if (_result.Position != RunCommandPosition.Command) @@ -251,19 +273,25 @@ private IEnumerable GetCommandSuggestions() if (string.IsNullOrEmpty(text)) suggestions.AddRange(GetHistorySuggestions().Take(MaxHistorySuggestions)); + var commands = new List<(int Rank, RunCommandSuggestion Suggestion)>(); + foreach (var command in _commands) { var names = new[] { command.Command }.Concat(command.Aliases).ToList(); var translatedName = RunCommandManager.GetTranslatedName(command); // Complete with the command or alias that was typed (e.g. "ping" instead of "pingmonitor"). - var name = names.FirstOrDefault(x => string.Equals(x, text, StringComparison.OrdinalIgnoreCase)) ?? - names.FirstOrDefault(x => x.StartsWith(text, StringComparison.OrdinalIgnoreCase)); + var exactName = names.FirstOrDefault(x => string.Equals(x, text, StringComparison.OrdinalIgnoreCase)); + var name = exactName ?? names.FirstOrDefault(x => x.StartsWith(text, StringComparison.OrdinalIgnoreCase)); if (name == null && !translatedName.Contains(text, StringComparison.OrdinalIgnoreCase)) continue; - suggestions.Add(new RunCommandSuggestion + // Exact command or alias first, then commands that start with the text, then translated names that contain + // it (e.g. "lookup" before "DNS Lookup"). + var rank = exactName != null ? 0 : name != null ? 1 : 2; + + commands.Add((rank, new RunCommandSuggestion { Kind = RunCommandSuggestionKind.Command, Application = command.Application, @@ -271,9 +299,11 @@ private IEnumerable GetCommandSuggestions() Description = string.Join(", ", names), Info = command.Syntax, CompletionText = (name ?? command.Command) + " " - }); + })); } + suggestions.AddRange(commands.OrderBy(x => x.Rank).Select(x => x.Suggestion)); + return suggestions; } @@ -283,20 +313,39 @@ private IEnumerable GetCommandSuggestions() private IEnumerable GetProfileSuggestions() { var text = _result.ProfileName ?? _result.CurrentWord; + var application = _result.Command.Application; + + // Exact name first, then names that start with the text, then names and targets (e.g. host) that contain it. + static int? GetRank(ProfileInfo profile, string target, string text) + { + if (string.IsNullOrEmpty(text) || string.Equals(profile.Name, text, StringComparison.OrdinalIgnoreCase)) + return 0; + + if (profile.Name.StartsWith(text, StringComparison.OrdinalIgnoreCase)) + return 1; + + if (profile.Name.Contains(text, StringComparison.OrdinalIgnoreCase) || + (target?.Contains(text, StringComparison.OrdinalIgnoreCase) ?? false)) + return 2; + + return null; + } return RunCommandManager.GetProfiles(_result.Command) - .Where(x => string.IsNullOrEmpty(text) || - x.Name.Contains(text, StringComparison.OrdinalIgnoreCase) || - (x.Host?.Contains(text, StringComparison.OrdinalIgnoreCase) ?? false)) + .Select(x => (Profile: x, Target: x.GetTarget(application))) + .Select(x => (x.Profile, x.Target, Rank: GetRank(x.Profile, x.Target, text))) + .Where(x => x.Rank != null) + .OrderBy(x => x.Rank) .Select(x => new RunCommandSuggestion { Kind = RunCommandSuggestionKind.Profile, - Application = _result.Command.Application, - Title = x.Name, - Description = string.IsNullOrEmpty(x.Host) ? x.Group : $"{x.Group} · {x.Host}", - Info = "@" + x.Name, - CompletionText = _result.InputBeforeCurrentWord + "@" + x.Name, - Profile = x + Application = application, + Title = x.Profile.Name, + // The target of the application (e.g. the RDP host), which can differ from the general host. + Description = string.IsNullOrEmpty(x.Target) ? x.Profile.Group : $"{x.Profile.Group} · {x.Target}", + Info = "@" + x.Profile.Name, + CompletionText = _result.InputBeforeCurrentWord + "@" + x.Profile.Name, + Profile = x.Profile }); } @@ -321,6 +370,28 @@ private IEnumerable GetTypeSuggestions() }); } + /// + /// DNS servers configured in the DNS lookup (e.g. "Cloudflare") that contain the typed word, if no DNS server was + /// entered yet. Names with spaces are inserted in quotes. + /// + private IEnumerable GetDNSServerSuggestions() + { + if (_result.Command.Argument != RunCommandArgument.DNSServer || + _result.Argument != null && !string.Equals(_result.Argument, _result.CurrentWord)) + return []; + + return RunCommandManager.GetDNSServers() + .Where(x => x.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase)) + .Select(x => new RunCommandSuggestion + { + Kind = RunCommandSuggestionKind.DNSServer, + Application = _result.Command.Application, + Title = x, + Description = Strings.DNSServer, + CompletionText = _result.InputBeforeCurrentWord + (x.Contains(' ') ? $"\"{x}\"" : x) + " " + }); + } + /// /// Recent commands. With a command, only its entries that contain the input. /// @@ -329,24 +400,20 @@ private IEnumerable GetHistorySuggestions() var input = Input.Trim(); return SettingsManager.Current.RunCommand_History + .Select(x => (Text: x, RunCommandParser.Parse(x, _commands).Command)) .Where(x => _result.Command == null && string.IsNullOrEmpty(input) || _result.Command != null && - RunCommandParser.Parse(x, _commands).Command == _result.Command && - x.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase) && - !string.Equals(x, input, StringComparison.OrdinalIgnoreCase)) - .Select(x => + x.Command == _result.Command && + x.Text.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase) && + !string.Equals(x.Text, input, StringComparison.OrdinalIgnoreCase)) + .Select(x => new RunCommandSuggestion { - var command = RunCommandParser.Parse(x, _commands).Command; - - return new RunCommandSuggestion - { - Kind = RunCommandSuggestionKind.History, - Application = command?.Application ?? Models.ApplicationName.None, - Title = x, - Description = command == null ? null : RunCommandManager.GetTranslatedName(command), - Info = Strings.Recent, - CompletionText = x - }; + Kind = RunCommandSuggestionKind.History, + Application = x.Command?.Application ?? Models.ApplicationName.None, + Title = x.Text, + Description = x.Command == null ? null : RunCommandManager.GetTranslatedName(x.Command), + Info = Strings.Recent, + CompletionText = x.Text }); } diff --git a/Source/NETworkManager/Views/RunCommandChildWindow.xaml b/Source/NETworkManager/Views/RunCommandChildWindow.xaml index 191ee83a83..bdaf33513e 100644 --- a/Source/NETworkManager/Views/RunCommandChildWindow.xaml +++ b/Source/NETworkManager/Views/RunCommandChildWindow.xaml @@ -32,6 +32,8 @@ Visual="{iconPacks:Material Kind=History}" /> + @@ -143,6 +145,11 @@ + + + diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 13b0e30f70..83d9b9694a 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -88,6 +88,10 @@ Release date: **xx.xx.2026** - Added **RDAP...** to the context menu to query the registration data of the IP address, hostname or AS number of a hop in the [**RDAP**](../application/rdap.md) tool. [#3642](https://github.com/BornToBeRoot/NETworkManager/pull/3642) +**DNS Lookup** + +- IP addresses opened from another feature (e.g. **IP Scanner** > **DNS lookup** > **IP address**), from a profile or with the run command are looked up with `PTR` instead of the selected record type. + **Hosts File Editor** - Added **Copy as...** to the context menu to create a new entry based on an existing one. The copy is added directly below the original entry. [0544698](https://github.com/BornToBeRoot/NETworkManager/commit/0544698aefc325ab36ca4c8e80250e1fe0b62d4a) diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md index b032c3d8da..090ad8d5b5 100644 --- a/Website/docs/run-command.md +++ b/Website/docs/run-command.md @@ -19,8 +19,8 @@ command @profile - **Command:** Name or alias of the feature (e.g. `dnslookup` or `dns`), see [Commands](#commands). Without a target, the feature is only opened. - **Target:** Host(s), IP range, query or URL, depending on the feature. Lists are separated with `;` as everywhere in NETworkManager, spaces around `;` are allowed (e.g. `ping 10.0.0.1; 10.0.0.2`). -- **Argument** and **type:** Additional values of some features (e.g. DNS server and record type of the DNS lookup), in any order after the target. -- **Profile:** `@` followed by the name of a profile runs the profile with its settings (e.g. `rdp @server-01`). Only profiles that are enabled for the feature can be used. The name can contain spaces (e.g. `rdp @Web Server 01`). An `@` that is not at the beginning of the target is part of the host (e.g. `admin@server-01`). +- **Argument** and **type:** Additional values of some features (e.g. DNS server and record type of the DNS lookup), in any order after the target. A value with spaces is written in quotes (e.g. `dns borntoberoot.net "Google Public DNS" aaaa`). +- **Profile:** `@` at the beginning of the target, followed by the name of a profile, runs the profile with its settings (e.g. `rdp @server-01`). Only profiles that are enabled for the feature can be used. The name can contain spaces (e.g. `rdp @Web Server 01`). If several profiles have the same name, select the profile in the [suggestions](#suggestions), which show its group. The input is validated like the input field of the feature (e.g. a valid host or IP range). If it is not valid, an error is shown and the dialog stays open. @@ -48,7 +48,7 @@ All features that accept a target also accept a profile (`@profile`). The other :::note - **Remote Desktop, PowerShell, PuTTY, TigerVNC and Web Console** open the connect dialog with the host, so the connection settings can be adjusted before connecting. A profile connects directly with its settings. -- **DNS lookup:** The type is the DNS record type (`a`, `aaaa`, `any`, `caa`, `cname`, `dnskey`, `mx`, `ns`, `ptr`, `soa`, `srv`, `txt`). Without a type, IP addresses are looked up with `ptr` and other hosts with the type selected in the feature. The DNS server is an IP address or a configured DNS server. +- **DNS lookup:** The type is the DNS record type (`a`, `aaaa`, `any`, `caa`, `cname`, `dnskey`, `mx`, `ns`, `ptr`, `soa`, `srv`, `txt`). Without a type, IP addresses are looked up with `ptr` and other hosts with the type selected in the feature. The DNS server is the name of a configured DNS server (case-insensitive, e.g. `cloudflare`) or a custom DNS server (e.g. `1.1.1.1`). - **RDAP:** The type is `domain`, `nameserver`, `tld`, `ip`, `asn` or `entity`. Without a type, it is detected from the query (see [RDAP](./application/rdap.md#example-inputs)). ::: @@ -61,11 +61,11 @@ While typing, suggestions are shown below the input: | ---------------------------- | ------------------------------------------------------------------------ | | _empty_ | Recently run commands and all features | | Command (e.g. `tr`) | Matching features | -| After the command (`rdp `) | The input as typed, profiles of the feature and recent commands | -| `@` (e.g. `rdp @web`) | Profiles of the feature (with group and host) | -| After the target | The input as typed, types of the feature (e.g. DNS record types) and recent commands | +| After the command (`rdp `) | The input as typed, recent commands of the feature and its profiles | +| `@` (e.g. `rdp @web`) | Profiles of the feature (with group and host); exact names first | +| After the target | The input as typed, types of the feature (e.g. DNS record types), configured DNS servers (DNS lookup) and recent commands | -The input as typed is always the first suggestion, so `Enter` runs what was typed. Profiles, recent commands and types are selected with the arrow keys. +The input as typed is always the first suggestion, so `Enter` runs what was typed. Profiles, recent commands, types and DNS servers are selected with the arrow keys. While typing `@`, the best matching profile is selected instead. | Key | Action | | ------------ | ---------------------------------------------- | From 4de3fdb33efc5367e538d35c4d6398a022755273 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:35:30 +0200 Subject: [PATCH 11/14] Fix: Run command second review findings, IP Geolocation and Wake on LAN - Enter runs what was typed: no preselected suggestion for an empty input or an unknown command with arguments (e.g. "pin 10.0.0.1"); completing a command keeps the arguments. - "@": a profile is only preselected if exactly one matches, so duplicate names show the error and the profiles to select. - Parser: a list that is being typed ("10.0.0.1; ") stays the target, ";" in quotes is kept, quotes around "@name" are removed. - Web Console requests require the WebView2 runtime. - The run command "settings" keeps the open settings page. - History and profiles are loaded once per dialog. - New commands: ipgeolocation (geo) and wakeonlan (wol [broadcast]). - Application list: removed a redundant scroll on startup. Co-Authored-By: Claude Opus 5.5 --- .../RunCommand/RunCommandArgument.cs | 3 +- .../RunCommand/RunCommandParser.cs | 17 +++-- .../RunCommand/RunCommandParserTests.cs | 27 +++++++- Source/NETworkManager/ApplicationRequest.cs | 11 ++++ Source/NETworkManager/MainWindow.xaml.cs | 10 +-- Source/NETworkManager/RunCommandManager.cs | 22 ++++++- .../ViewModels/IPGeolocationHostViewModel.cs | 13 +++- .../ViewModels/RunCommandViewModel.cs | 62 ++++++++++++++++--- .../ViewModels/WakeOnLANViewModel.cs | 28 ++++++++- .../Views/IPGeolocationHostView.xaml.cs | 10 ++- .../Views/WakeOnLANView.xaml.cs | 10 ++- .../Views/WebConsoleHostView.xaml.cs | 4 ++ Website/docs/changelog/next-release.md | 2 +- Website/docs/run-command.md | 7 ++- 14 files changed, 194 insertions(+), 32 deletions(-) diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandArgument.cs b/Source/NETworkManager.Models/RunCommand/RunCommandArgument.cs index c7559dce9e..0271cb442f 100644 --- a/Source/NETworkManager.Models/RunCommand/RunCommandArgument.cs +++ b/Source/NETworkManager.Models/RunCommand/RunCommandArgument.cs @@ -7,5 +7,6 @@ public enum RunCommandArgument { None, Ports, - DNSServer + DNSServer, + Broadcast } diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs index acb94d933c..1d9acb7000 100644 --- a/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs +++ b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs @@ -11,10 +11,10 @@ namespace NETworkManager.Models.RunCommand; public static partial class RunCommandParser { /// - /// A list separator with whitespace around it ("10.0.0.1; 10.0.0.2"). + /// A list separator with whitespace around it ("10.0.0.1; 10.0.0.2"), or a value in quotes, which is kept as it is. /// - [GeneratedRegex(@"\s*;\s*")] - private static partial Regex ListSeparatorRegex(); + [GeneratedRegex("\"[^\"]*(?:\"|$)|\\s*;\\s*")] + private static partial Regex ListSeparatorOrQuoteRegex(); /// /// A word: a value in quotes (can contain spaces, the closing quote can be missing while typing) or a value @@ -40,12 +40,16 @@ public static RunCommandParseResult Parse(string input, IEnumerable x.Value.StartsWith('"') ? x.Value : ";"); + // Words as typed (with quotes, to rebuild the input) and their values (without quotes) - var tokens = WordRegex().Matches(ListSeparatorRegex().Replace(input, ";")).Select(x => x.Value).ToArray(); + var tokens = WordRegex().Matches(normalized).Select(x => x.Value).ToArray(); var words = tokens.Select(Unquote).ToArray(); // Whitespace in an open quote (e.g. "Google ) is part of the word that is being typed. - var endsWithWhitespace = input.Length > 0 && char.IsWhiteSpace(input[^1]) && + var endsWithWhitespace = normalized.Length > 0 && char.IsWhiteSpace(normalized[^1]) && !(tokens.Length > 0 && IsOpenQuote(tokens[^1])); if (words.Length == 0) @@ -81,7 +85,8 @@ public static RunCommandParseResult Parse(string input, IEnumerable +/// Request to open Wake on LAN. +/// +public sealed record WakeOnLANRequest() : ApplicationRequest(ApplicationName.WakeOnLAN) +{ + /// + /// Broadcast address. If null, the broadcast address of the application is used. + /// + public string Broadcast { get; init; } +} + /// /// Request to open RDAP. /// diff --git a/Source/NETworkManager/MainWindow.xaml.cs b/Source/NETworkManager/MainWindow.xaml.cs index f8f46a9e72..2e5a9158d7 100644 --- a/Source/NETworkManager/MainWindow.xaml.cs +++ b/Source/NETworkManager/MainWindow.xaml.cs @@ -644,10 +644,6 @@ private void LoadApplicationList() SelectedApplication = applicationList.FirstOrDefault(x => x.IsDefault) ?? applicationList.FirstOrDefault(x => x.IsVisible); - // Scroll into view - if (SelectedApplication != null) - ListViewApplication.ScrollIntoView(SelectedApplication); - // Expand application view ApplicationViewIsExpanded = SettingsManager.Current.ExpandApplicationView; } @@ -1027,7 +1023,7 @@ private void ClearSearchOnApplicationListMinimize() ApplicationSearch = string.Empty; - // Scroll into view + // Scroll into view. The selection doesn't change here, so ListViewApplication_OnSelectionChanged doesn't scroll. ListViewApplication.ScrollIntoView(SelectedApplication); } @@ -1158,6 +1154,10 @@ private void OpenSettings() private void NavigationManager_SettingsRequested(object sender, EventArgs e) { + // The settings are already open (e.g. run command "settings"), keep the current page. + if (SettingsViewIsOpen) + return; + OpenSettings(); } diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs index acd928b485..88ae1a3317 100644 --- a/Source/NETworkManager/RunCommandManager.cs +++ b/Source/NETworkManager/RunCommandManager.cs @@ -181,6 +181,8 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT ApplicationName.RemoteDesktop => new RemoteDesktopHostnameAndPortValidator(), ApplicationName.WebConsole => new HttpAndHttpsUriValidator(), ApplicationName.Whois => new DomainValidator(), + ApplicationName.IPGeolocation => new IPAddressOrHostnameValidator(), + ApplicationName.WakeOnLAN => new MACAddressValidator(), _ => null }; @@ -189,8 +191,20 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT if (error != null) return error; - if (result.Command.Argument == RunCommandArgument.Ports && result.Argument != null) - return Validate(new PortRangeValidator(), result.Argument); + if (result.Argument != null) + { + ValidationRule argumentValidator = result.Command.Argument switch + { + RunCommandArgument.Ports => new PortRangeValidator(), + RunCommandArgument.Broadcast => new IPv4AddressValidator(), + _ => null + }; + + error = Validate(argumentValidator, result.Argument); + + if (error != null) + return error; + } // RDAP: the query must match the type (e.g. "1.10" as ASN) if (result.Command.Application == ApplicationName.RDAP) @@ -231,6 +245,7 @@ private static ApplicationRequest CreateRequest(RunCommandParseResult result) QueryType = result.Type != null ? Enum.Parse(result.Type, true) : null }, ApplicationName.PortScanner => new PortScannerRequest { Target = result.Target, Ports = result.Argument }, + ApplicationName.WakeOnLAN => new WakeOnLANRequest { Target = result.Target, Broadcast = result.Argument }, ApplicationName.RDAP => new RDAPRequest { Target = result.Target, QueryType = GetRDAPQueryType(result.Type) }, _ => new ApplicationRequest(result.Command.Application) { Target = result.Target } }; @@ -272,6 +287,9 @@ [.. DNSLookup.QueryTypes.Select(x => x.ToString().ToLowerInvariant()).Order()]), ApplicationName.RDAP => Create(name, [], $"{Required(Strings.Query)} {Optional(Strings.Type)}", true, types: [.. RDAPTypes.Keys]), ApplicationName.Whois => Create(name, [], Required(Strings.Domain), true), + ApplicationName.IPGeolocation => Create(name, ["geo"], Required(Strings.Host), true), + ApplicationName.WakeOnLAN => Create(name, ["wol"], + $"{Required(Strings.MACAddress)} {Optional(Strings.Broadcast)}", true, RunCommandArgument.Broadcast), _ => Create(name, [], string.Empty, false) }).ToList(); diff --git a/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs b/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs index af8bca6e24..3b1e44685a 100644 --- a/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs +++ b/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs @@ -123,7 +123,7 @@ private bool QueryProfile_CanExecute(object obj) /// private void QueryProfileAction() { - AddTab(SelectedProfile.IPGeolocation_Host); + AddTab(SelectedProfile); } /// @@ -147,7 +147,7 @@ private static void CloseItemAction(ItemActionCallbackArgs args) /// Adds a new tab for the specified domain. /// /// The domain to query. - private void AddTab(string domain = null) + public void AddTab(string domain = null) { var tabId = Guid.NewGuid(); @@ -157,5 +157,14 @@ private void AddTab(string domain = null) SelectedTabIndex = TabItems.Count - 1; } + /// + /// Adds a new tab for the host of the specified profile. + /// + /// The profile to query. + public void AddTab(ProfileInfo profile) + { + AddTab(profile.IPGeolocation_Host); + } + #endregion } diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs index b9d8dcc0bb..ad6128100d 100644 --- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs +++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs @@ -35,6 +35,16 @@ public class RunCommandViewModel : ViewModelBase private readonly IReadOnlyList _commands; private readonly Action _runHandler; + /// + /// Recent commands with their command (parsed once, the history doesn't change while the dialog is open). + /// + private readonly List<(string Text, RunCommandDefinition Command)> _history; + + /// + /// Profiles per command (loaded once per command while the dialog is open). + /// + private readonly Dictionary> _profiles = []; + private RunCommandParseResult _result; /// @@ -47,6 +57,8 @@ public RunCommandViewModel(Action runHandler, Action (x, RunCommandParser.Parse(x, _commands).Command))]; + CancelCommand = new RelayCommand(_ => cancelHandler(this)); UpdateSuggestions(); @@ -241,9 +253,37 @@ .. GetHistorySuggestions() foreach (var suggestion in suggestions) Suggestions.Add(suggestion); - // The input as typed is the first suggestion, so Enter runs what was typed. Profiles, history entries and - // types are selected with Up/Down. - SelectedSuggestion = Suggestions.FirstOrDefault(); + SelectedSuggestion = GetPreselectedSuggestion(); + } + + /// + /// Gets the suggestion that Enter runs without selecting one, so Enter runs what was typed: + /// - After the command: the input as typed (first suggestion). + /// - While typing the command: the best matching command, but not if the input is empty or an unknown command has + /// arguments (e.g. a typo like "pin 10.0.0.1"), which would drop the input. + /// - While typing a profile ("@"): the profile, only if exactly one matches. Otherwise, the typed name is used, + /// which must be unique (e.g. two profiles with the same name in different groups). + /// + private RunCommandSuggestion GetPreselectedSuggestion() + { + return _result.Position switch + { + RunCommandPosition.Command when string.IsNullOrWhiteSpace(Input) || + _result.Command == null && GetInputAfterCommand().Length > 0 => null, + RunCommandPosition.Target when _result.ProfileName != null => Suggestions.Count == 1 ? Suggestions[0] : null, + _ => Suggestions.FirstOrDefault() + }; + } + + /// + /// Gets the input after the first word (the command), e.g. "10.0.0.1" for "pin 10.0.0.1". + /// + private string GetInputAfterCommand() + { + var input = Input.TrimStart(); + var index = input.IndexOfAny([' ', '\t']); + + return index < 0 ? string.Empty : input[index..].Trim(); } /// @@ -273,6 +313,9 @@ private IEnumerable GetCommandSuggestions() if (string.IsNullOrEmpty(text)) suggestions.AddRange(GetHistorySuggestions().Take(MaxHistorySuggestions)); + // Completing the command keeps the arguments (e.g. "pin 10.0.0.1" → "ping 10.0.0.1"). + var arguments = GetInputAfterCommand(); + var commands = new List<(int Rank, RunCommandSuggestion Suggestion)>(); foreach (var command in _commands) @@ -298,7 +341,7 @@ private IEnumerable GetCommandSuggestions() Title = translatedName, Description = string.Join(", ", names), Info = command.Syntax, - CompletionText = (name ?? command.Command) + " " + CompletionText = (name ?? command.Command) + " " + arguments })); } @@ -331,7 +374,13 @@ private IEnumerable GetProfileSuggestions() return null; } - return RunCommandManager.GetProfiles(_result.Command) + if (!_profiles.TryGetValue(_result.Command, out var profiles)) + { + profiles = [.. RunCommandManager.GetProfiles(_result.Command)]; + _profiles[_result.Command] = profiles; + } + + return profiles .Select(x => (Profile: x, Target: x.GetTarget(application))) .Select(x => (x.Profile, x.Target, Rank: GetRank(x.Profile, x.Target, text))) .Where(x => x.Rank != null) @@ -399,8 +448,7 @@ private IEnumerable GetHistorySuggestions() { var input = Input.Trim(); - return SettingsManager.Current.RunCommand_History - .Select(x => (Text: x, RunCommandParser.Parse(x, _commands).Command)) + return _history .Where(x => _result.Command == null && string.IsNullOrEmpty(input) || _result.Command != null && x.Command == _result.Command && diff --git a/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs b/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs index 09ccfc61fb..81a8ed8e66 100644 --- a/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs +++ b/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs @@ -198,13 +198,39 @@ private void WakeUpAction() private void WakeUpProfileAction() { - _ = WakeUp(NETworkManager.Profiles.Application.WakeOnLAN.CreateInfo(SelectedProfile)); + WakeUp(SelectedProfile); } #endregion #region Methods + /// + /// Sends the magic packet to the computer of the profile. + /// + /// Profile to wake up. + public void WakeUp(ProfileInfo profile) + { + _ = WakeUp(NETworkManager.Profiles.Application.WakeOnLAN.CreateInfo(profile)); + } + + /// + /// Sets the MAC address (and broadcast address) and sends the magic packet, if the broadcast address is valid + /// (e.g. from a previous wake up). Otherwise, the values are only filled in. + /// + /// MAC address of the computer. + /// Broadcast address. If null, the current broadcast address is used. + public void WakeUp(string macAddress, string broadcast) + { + MACAddress = macAddress; + + if (broadcast != null) + Broadcast = broadcast; + + if (Broadcast != null && RegexHelper.IPv4AddressRegex().IsMatch(Broadcast)) + WakeUpAction(); + } + private async Task WakeUp(WakeOnLANInfo info) { IsStatusMessageDisplayed = false; diff --git a/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs b/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs index 4f8813acd3..43dc89ec95 100644 --- a/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs +++ b/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class IPGeolocationHostView +public partial class IPGeolocationHostView : IApplicationRequestHandler { private readonly IPGeolocationHostViewModel _viewModel = new(); @@ -12,6 +12,14 @@ public IPGeolocationHostView() DataContext = _viewModel; } + public void Open(ApplicationRequest request) + { + if (request.Profile != null) + _viewModel.AddTab(request.Profile); + else + _viewModel.AddTab(request.Target); + } + public void OnViewHide() { _viewModel.OnViewHide(); diff --git a/Source/NETworkManager/Views/WakeOnLANView.xaml.cs b/Source/NETworkManager/Views/WakeOnLANView.xaml.cs index 07e67ab28d..6e5977a651 100644 --- a/Source/NETworkManager/Views/WakeOnLANView.xaml.cs +++ b/Source/NETworkManager/Views/WakeOnLANView.xaml.cs @@ -2,7 +2,7 @@ namespace NETworkManager.Views; -public partial class WakeOnLANView +public partial class WakeOnLANView : IApplicationRequestHandler { private readonly WakeOnLANViewModel _viewModel = new(); @@ -12,6 +12,14 @@ public WakeOnLANView() DataContext = _viewModel; } + public void Open(ApplicationRequest request) + { + if (request.Profile != null) + _viewModel.WakeUp(request.Profile); + else + _viewModel.WakeUp(request.Target, (request as WakeOnLANRequest)?.Broadcast); + } + public void OnViewHide() { _viewModel.OnViewHide(); diff --git a/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs b/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs index abc5649393..c98abce5c2 100644 --- a/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs +++ b/Source/NETworkManager/Views/WebConsoleHostView.xaml.cs @@ -19,6 +19,10 @@ public async void Open(ApplicationRequest request) // MahApps will throw an exception... await this.WaitForLoadedAsync(); + // WebView2 runtime is required for the web console. + if (!_viewModel.IsRuntimeAvailable) + return; + if (request.Profile != null) _viewModel.ConnectProfile(request.Profile); else diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 83d9b9694a..97ce8e373a 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -27,7 +27,7 @@ Release date: **xx.xx.2026** **Run command** -- The [**Run command**](../run-command.md) (`Ctrl+Shift+P`) now runs a feature directly, e.g. `ping 10.0.0.1`, `dns borntoberoot.net 1.1.1.1 aaaa`, `rdap 1.10 asn` or `rdp @server-01` (profile). It opens in a dialog instead of a flyout, with short aliases (e.g. `ping`, `dns`, `rdp`, `ssh`), suggestions for recent commands, profiles of the feature and types (`Up`/`Down` to select, `Tab` to complete, `Enter` to run, `Esc` to close), and validates the input like the feature does. Only visible features are offered. +- The [**Run command**](../run-command.md) (`Ctrl+Shift+P`) now runs a feature directly, e.g. `ping 10.0.0.1`, `dns borntoberoot.net 1.1.1.1 aaaa`, `rdap 1.10 asn` or `rdp @server-01` (profile). It opens in a dialog instead of a flyout, with short aliases (e.g. `ping`, `dns`, `rdp`, `ssh`, `geo`, `wol`), suggestions for recent commands, profiles of the feature and types (`Up`/`Down` to select, `Tab` to complete, `Enter` to run, `Esc` to close), and validates the input like the feature does. Only visible features are offered. **RDAP** diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md index 090ad8d5b5..c50de2ee5a 100644 --- a/Website/docs/run-command.md +++ b/Website/docs/run-command.md @@ -20,7 +20,7 @@ command @profile - **Command:** Name or alias of the feature (e.g. `dnslookup` or `dns`), see [Commands](#commands). Without a target, the feature is only opened. - **Target:** Host(s), IP range, query or URL, depending on the feature. Lists are separated with `;` as everywhere in NETworkManager, spaces around `;` are allowed (e.g. `ping 10.0.0.1; 10.0.0.2`). - **Argument** and **type:** Additional values of some features (e.g. DNS server and record type of the DNS lookup), in any order after the target. A value with spaces is written in quotes (e.g. `dns borntoberoot.net "Google Public DNS" aaaa`). -- **Profile:** `@` at the beginning of the target, followed by the name of a profile, runs the profile with its settings (e.g. `rdp @server-01`). Only profiles that are enabled for the feature can be used. The name can contain spaces (e.g. `rdp @Web Server 01`). If several profiles have the same name, select the profile in the [suggestions](#suggestions), which show its group. +- **Profile:** `@` at the beginning of the target, followed by the name of a profile, runs the profile with its settings (e.g. `rdp @server-01`). Only profiles that are enabled for the feature can be used. The name can contain spaces (e.g. `rdp @Web Server 01`). If several profiles have the same name, an error is shown; select the profile in the [suggestions](#suggestions), which show its group. The input is validated like the input field of the feature (e.g. a valid host or IP range). If it is not valid, an error is shown and the dialog stays open. @@ -41,6 +41,8 @@ The input is validated like the input field of the feature (e.g. a valid host or | `snmp` | | `` | `snmp 10.0.0.1` | | `rdap` | | ` [type]` | `rdap 1.10 asn` | | `whois` | | `` | `whois borntoberoot.net` | +| `ipgeolocation` | `geo` | `` | `geo 1.1.1.1` | +| `wakeonlan` | `wol` | ` [broadcast]` | `wol 00:11:22:33:44:55 192.168.1.255` | | `settings` | | | `settings` | All features that accept a target also accept a profile (`@profile`). The other features (e.g. `subnetcalculator`, `firewall`) can be opened with their name. Features that are hidden in [Settings > General](./settings/general.md) are not available. @@ -50,6 +52,7 @@ All features that accept a target also accept a profile (`@profile`). The other - **Remote Desktop, PowerShell, PuTTY, TigerVNC and Web Console** open the connect dialog with the host, so the connection settings can be adjusted before connecting. A profile connects directly with its settings. - **DNS lookup:** The type is the DNS record type (`a`, `aaaa`, `any`, `caa`, `cname`, `dnskey`, `mx`, `ns`, `ptr`, `soa`, `srv`, `txt`). Without a type, IP addresses are looked up with `ptr` and other hosts with the type selected in the feature. The DNS server is the name of a configured DNS server (case-insensitive, e.g. `cloudflare`) or a custom DNS server (e.g. `1.1.1.1`). - **RDAP:** The type is `domain`, `nameserver`, `tld`, `ip`, `asn` or `entity`. Without a type, it is detected from the query (see [RDAP](./application/rdap.md#example-inputs)). +- **Wake on LAN:** The magic packet is sent to the broadcast address (or the one last used in the feature). If there is no valid broadcast address yet, the MAC address is only filled in. A profile sends the magic packet with its settings. ::: @@ -65,7 +68,7 @@ While typing, suggestions are shown below the input: | `@` (e.g. `rdp @web`) | Profiles of the feature (with group and host); exact names first | | After the target | The input as typed, types of the feature (e.g. DNS record types), configured DNS servers (DNS lookup) and recent commands | -The input as typed is always the first suggestion, so `Enter` runs what was typed. Profiles, recent commands, types and DNS servers are selected with the arrow keys. While typing `@`, the best matching profile is selected instead. +`Enter` runs what was typed: after the command, the input as typed is the first suggestion; profiles, recent commands, types and DNS servers are selected with the arrow keys. While typing the command, the best matching feature is selected (completing it keeps the arguments, e.g. `pin 10.0.0.1` → `ping 10.0.0.1`), but not if the input is empty or an unknown command has arguments. While typing `@`, a profile is only selected if exactly one matches. | Key | Action | | ------------ | ---------------------------------------------- | From b77a73b2f2f66ebe85d45fa6569819e875793972 Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:05:40 +0200 Subject: [PATCH 12/14] Fix: Run command third review findings - Run command "settings" with a filtered application list no longer crashes (the search is cleared first). - The dialog has a title bar with a close button. - The DNS server argument is validated (configured name or IP address / host with a domain); a mistyped type like "aaa" is reported. - A request is only passed to the application if it is shown. - After running a suggestion with the mouse, the focus isn't taken back by the closing dialog. - History: entries of hidden applications are not shown; commands are stored case-insensitively (ListHelper.Modify with a comparer). - Text after a quoted profile name is an unknown argument. Co-Authored-By: Claude Opus 5.5 --- .../RunCommand/RunCommandParser.cs | 34 ++++++++++++++++--- .../RunCommand/RunCommandParserTests.cs | 10 ++++++ .../Utilities/ListHelperTests.cs | 10 ++++++ Source/NETworkManager.Utilities/ListHelper.cs | 34 +++++++++++++++---- Source/NETworkManager/MainWindow.xaml.cs | 8 +++-- Source/NETworkManager/RunCommandManager.cs | 25 +++++++++++++- .../ViewModels/RunCommandViewModel.cs | 10 ++++-- .../Views/RunCommandChildWindow.xaml | 4 +-- .../Views/RunCommandChildWindow.xaml.cs | 4 ++- Website/docs/run-command.md | 4 +-- 10 files changed, 123 insertions(+), 20 deletions(-) diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs index 1d9acb7000..26a595eafe 100644 --- a/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs +++ b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs @@ -85,14 +85,35 @@ public static RunCommandParseResult Parse(string input, IEnumerable 0) + { + profileName = rest[1..closingQuote].Trim(); + + var remainder = rest[(closingQuote + 1)..].Trim(); + + if (remainder.Length > 0) + textAfterName = remainder; + } + else + { + // Quotes around the whole target (e.g. "@My Server") or an open quote while typing are removed. + profileName = rest.Trim('"').Trim(); + } var error = !command.SupportsProfile ? RunCommandParseError.NoProfiles : profileName.Length == 0 ? RunCommandParseError.MissingProfileName - : RunCommandParseError.None; + : textAfterName != null + ? RunCommandParseError.UnknownArgument + : RunCommandParseError.None; return new RunCommandParseResult { @@ -103,7 +124,12 @@ public static RunCommandParseResult Parse(string input, IEnumerable command.Command, + RunCommandParseError.UnknownArgument => textAfterName, + _ => null + } }; } diff --git a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs index 2af0e6ae36..7672497506 100644 --- a/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs +++ b/Source/NETworkManager.Tests/RunCommand/RunCommandParserTests.cs @@ -168,6 +168,16 @@ public void QuotedProfileName(string input) Assert.Equal("Web Server 01", result.ProfileName); } + [Fact] + public void TextAfterAQuotedProfileName() + { + var result = Parse("rdp @\"Web Server\" x"); + + Assert.Equal("Web Server", result.ProfileName); + Assert.Equal(RunCommandParseError.UnknownArgument, result.Error); + Assert.Equal("x", result.ErrorValue); + } + [Fact] public void ListThatIsBeingTyped() { diff --git a/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs b/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs index 92590e6ef1..924f9a1aad 100644 --- a/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs +++ b/Source/NETworkManager.Tests/Utilities/ListHelperTests.cs @@ -107,6 +107,16 @@ public void IgnoresDefaultValue() Assert.Equal([22], list); } + [Fact] + public void UsesTheComparerAndKeepsTheLatestSpelling() + { + ObservableCollection list = ["a", "ping 10.0.0.1"]; + + ListHelper.Modify(list, "Ping 10.0.0.1", 5, StringComparer.OrdinalIgnoreCase); + + Assert.Equal(["Ping 10.0.0.1", "a"], list); + } + [Fact] public void ThrowsIfTheLengthIsNotPositive() { diff --git a/Source/NETworkManager.Utilities/ListHelper.cs b/Source/NETworkManager.Utilities/ListHelper.cs index 0707e15786..e1336b997c 100644 --- a/Source/NETworkManager.Utilities/ListHelper.cs +++ b/Source/NETworkManager.Utilities/ListHelper.cs @@ -19,26 +19,48 @@ public static class ListHelper /// List to modify (e.g. a history of the settings). /// Entry to add to the list. /// Maximum length of the list. Oldest entries will be removed if the list exceeds this length. + /// Comparer for existing entries (e.g. case-insensitive). If null, the default comparer is used. /// Type of the list entries. Currently or . - public static void Modify(ObservableCollection list, T entry, int length) + public static void Modify(ObservableCollection list, T entry, int length, + IEqualityComparer comparer = null) { + comparer ??= EqualityComparer.Default; + if (length <= 0) throw new ArgumentOutOfRangeException(nameof(length), "Length must be greater than zero."); if (EqualityComparer.Default.Equals(entry, default) || entry is string text && string.IsNullOrEmpty(text)) return; - var index = list.IndexOf(entry); + var index = -1; + + for (var i = 0; i < list.Count; i++) + { + if (!comparer.Equals(list[i], entry)) + continue; + + index = i; + break; + } - if (index > 0) - list.Move(index, 0); - else if (index < 0) + if (index < 0) + { list.Insert(0, entry); + } + else + { + if (index > 0) + list.Move(index, 0); + + // Keep the latest spelling (e.g. with a case-insensitive comparer) + if (!EqualityComparer.Default.Equals(list[0], entry)) + list[0] = entry; + } // Remove duplicates (e.g. from older settings files) for (var i = list.Count - 1; i > 0; i--) { - if (EqualityComparer.Default.Equals(list[i], entry)) + if (comparer.Equals(list[i], entry)) list.RemoveAt(i); } diff --git a/Source/NETworkManager/MainWindow.xaml.cs b/Source/NETworkManager/MainWindow.xaml.cs index 2e5a9158d7..410bdbd221 100644 --- a/Source/NETworkManager/MainWindow.xaml.cs +++ b/Source/NETworkManager/MainWindow.xaml.cs @@ -1065,8 +1065,9 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro if (string.IsNullOrEmpty(request.Target) && request.Profile == null) return; - // Create a new tab / perform the action in the application - if (ContentControlApplication.Content is IApplicationRequestHandler handler) + // Create a new tab / perform the action in the application (only if it is shown) + if (SelectedApplication?.Name == request.Application && + ContentControlApplication.Content is IApplicationRequestHandler handler) handler.Open(request); else Log.Warn($"Application {request.Application} cannot handle a request with a target or profile."); @@ -1158,6 +1159,9 @@ private void NavigationManager_SettingsRequested(object sender, EventArgs e) if (SettingsViewIsOpen) return; + // Clear the search, otherwise no application may be selected (the settings page depends on it) + ApplicationSearch = string.Empty; + OpenSettings(); } diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs index 88ae1a3317..8fd732d868 100644 --- a/Source/NETworkManager/RunCommandManager.cs +++ b/Source/NETworkManager/RunCommandManager.cs @@ -12,6 +12,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Net; using System.Windows.Controls; namespace NETworkManager; @@ -200,7 +201,9 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT _ => null }; - error = Validate(argumentValidator, result.Argument); + error = result.Command.Argument == RunCommandArgument.DNSServer + ? ValidateDNSServer(result.Argument) + : Validate(argumentValidator, result.Argument); if (error != null) return error; @@ -218,6 +221,26 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT return null; } + /// + /// Validates the DNS server argument: the name of a configured DNS server, or custom DNS server(s) in the format + /// of the DNS lookup (IP address or host with a domain, optional port). A single word that is neither (e.g. a + /// mistyped type like "aaa") is an unknown argument instead of a DNS server. + /// + private static string ValidateDNSServer(string dnsServer) + { + if (GetDNSServers().Any(x => string.Equals(x, dnsServer, StringComparison.OrdinalIgnoreCase))) + return null; + + foreach (var server in dnsServer.Split(';')) + { + if (!ServerConnectionInfo.TryParse(server, out var serverInfo, 53, TransportProtocol.Udp) || + !IPAddress.TryParse(serverInfo.Server, out _) && !serverInfo.Server.Contains('.')) + return string.Format(Strings.UnknownArgumentX, server); + } + + return null; + } + private static string Validate(ValidationRule validator, string value) { if (validator == null) diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs index ad6128100d..673d54cca8 100644 --- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs +++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs @@ -57,7 +57,12 @@ public RunCommandViewModel(Action runHandler, Action (x, RunCommandParser.Parse(x, _commands).Command))]; + // Entries of commands that are not available anymore (e.g. a hidden application) are not shown. + _history = + [ + .. SettingsManager.Current.RunCommand_History.Select(x => (x, RunCommandParser.Parse(x, _commands).Command)) + .Where(x => x.Command != null) + ]; CancelCommand = new RelayCommand(_ => cancelHandler(this)); @@ -212,8 +217,9 @@ public void Run() Request = request; IsSettings = result.Command.Type == RunCommandType.Setting; + // Commands are case-insensitive, so "Ping 10.0.0.1" and "ping 10.0.0.1" are the same entry. ListHelper.Modify(SettingsManager.Current.RunCommand_History, text.Trim(), - SettingsManager.Current.General_HistoryListEntries); + SettingsManager.Current.General_HistoryListEntries, StringComparer.OrdinalIgnoreCase); _runHandler(this); } diff --git a/Source/NETworkManager/Views/RunCommandChildWindow.xaml b/Source/NETworkManager/Views/RunCommandChildWindow.xaml index bdaf33513e..d81a19501b 100644 --- a/Source/NETworkManager/Views/RunCommandChildWindow.xaml +++ b/Source/NETworkManager/Views/RunCommandChildWindow.xaml @@ -12,8 +12,8 @@ xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls" xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks" Style="{StaticResource DefaultChildWindow}" - ShowTitleBar="False" - ShowCloseButton="False" + Title="{x:Static localization:Strings.RunCommand}" + CloseButtonCommand="{Binding CancelCommand}" AllowMove="False" ChildWindowWidth="650" VerticalContentAlignment="Top" diff --git a/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs b/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs index 07067ee092..f3ccd7cd0c 100644 --- a/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs +++ b/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs @@ -61,7 +61,9 @@ private void ListBoxSuggestions_OnPreviewMouseLeftButtonUp(object sender, MouseB ViewModel.SelectedSuggestion = (RunCommandSuggestion)item.DataContext; ViewModel.Run(); - TextBoxInput.Focus(); + // The dialog stays open if the input is not valid (otherwise the opened application or dialog gets the focus). + if (IsOpen) + TextBoxInput.Focus(); } private void ListBoxSuggestions_OnSelectionChanged(object sender, SelectionChangedEventArgs e) diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md index c50de2ee5a..859e7310af 100644 --- a/Website/docs/run-command.md +++ b/Website/docs/run-command.md @@ -50,7 +50,7 @@ All features that accept a target also accept a profile (`@profile`). The other :::note - **Remote Desktop, PowerShell, PuTTY, TigerVNC and Web Console** open the connect dialog with the host, so the connection settings can be adjusted before connecting. A profile connects directly with its settings. -- **DNS lookup:** The type is the DNS record type (`a`, `aaaa`, `any`, `caa`, `cname`, `dnskey`, `mx`, `ns`, `ptr`, `soa`, `srv`, `txt`). Without a type, IP addresses are looked up with `ptr` and other hosts with the type selected in the feature. The DNS server is the name of a configured DNS server (case-insensitive, e.g. `cloudflare`) or a custom DNS server (e.g. `1.1.1.1`). +- **DNS lookup:** The type is the DNS record type (`a`, `aaaa`, `any`, `caa`, `cname`, `dnskey`, `mx`, `ns`, `ptr`, `soa`, `srv`, `txt`). Without a type, IP addresses are looked up with `ptr` and other hosts with the type selected in the feature. The DNS server is the name of a configured DNS server (case-insensitive, e.g. `cloudflare`) or custom DNS server(s): an IP address or a host with a domain, with an optional port, separated by `;` (e.g. `1.1.1.1`, `dns.google:53`). - **RDAP:** The type is `domain`, `nameserver`, `tld`, `ip`, `asn` or `entity`. Without a type, it is detected from the query (see [RDAP](./application/rdap.md#example-inputs)). - **Wake on LAN:** The magic packet is sent to the broadcast address (or the one last used in the feature). If there is no valid broadcast address yet, the MAC address is only filled in. A profile sends the magic packet with its settings. @@ -75,6 +75,6 @@ While typing, suggestions are shown below the input: | `Up`, `Down` | Select a suggestion | | `Tab` | Insert the selected suggestion into the input | | `Enter` | Run the selected suggestion | -| `Esc` | Close the dialog | +| `Esc` | Close the dialog (or the close button) | Run commands are saved in a history (the number of entries is set in [Settings > General](./settings/general.md)). From fe651cc59d4cae1a952ddb5aba8c5bdadd2bb9ab Mon Sep 17 00:00:00 2001 From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:14:48 +0200 Subject: [PATCH 13/14] Fix: Run command final review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The suggestion list is limited to 340 px, so it fits into the dialog (max. 500 px) and the selected entry is always visible. - WaitForLoadedAsync stops waiting after 5 seconds, so a request to a view that wasn't shown doesn't run later when the view is opened. - Completing a command uses the shortest matching name ("pin" → "ping"). - Profile names are compared like they are parsed (trimmed, without quotes), so recent commands with such names work. - Empty DNS server list entries ("1.1.1.1; ") are ignored. - Commands without arguments use the last word for the suggestions. - Command names are computed once per dialog; one close function for the run command dialog. Co-Authored-By: Claude Opus 5.5 --- .../RunCommand/RunCommandParser.cs | 4 +-- .../RunCommand/RunCommandParserTests.cs | 9 ++++++ .../FrameworkElementExtensions.cs | 31 +++++++++++++++---- Source/NETworkManager/MainWindow.xaml.cs | 15 +++++---- Source/NETworkManager/RunCommandManager.cs | 7 +++-- .../ViewModels/RunCommandViewModel.cs | 22 +++++++++---- .../Views/PowerShellHostView.xaml.cs | 3 +- .../Views/PuTTYHostView.xaml.cs | 3 +- .../Views/RemoteDesktopHostView.xaml.cs | 3 +- .../Views/RunCommandChildWindow.xaml | 2 +- .../Views/TigerVNCHostView.xaml.cs | 3 +- .../Views/WebConsoleHostView.xaml.cs | 3 +- 12 files changed, 75 insertions(+), 30 deletions(-) diff --git a/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs index 26a595eafe..51787a4ff7 100644 --- a/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs +++ b/Source/NETworkManager.Models/RunCommand/RunCommandParser.cs @@ -139,8 +139,8 @@ public static RunCommandParseResult Parse(string input, IEnumerable public static class FrameworkElementExtensions { + /// + /// Default time to wait for the event. A view that is shown is loaded + /// within milliseconds. + /// + private static readonly TimeSpan DefaultLoadedTimeout = TimeSpan.FromSeconds(5); + /// /// Waits until the element is loaded (e.g. before showing a dialog from a view that was just created, which /// MahApps doesn't allow before the view is loaded). Completes immediately if the element is already loaded. + /// Stops waiting after the timeout (e.g. the user switched to another view before it was loaded), so an action + /// doesn't run unexpectedly when the view is shown later. /// /// Element to wait for. - /// Task that completes when the element is loaded. - public static Task WaitForLoadedAsync(this FrameworkElement element) + /// Maximum time to wait. Default: 5 seconds. + /// True if the element is loaded, false if the timeout elapsed. + public static async Task WaitForLoadedAsync(this FrameworkElement element, TimeSpan? timeout = null) { if (element.IsLoaded) - return Task.CompletedTask; + return true; var taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); void OnLoaded(object sender, RoutedEventArgs e) { - element.Loaded -= OnLoaded; taskCompletionSource.TrySetResult(); } element.Loaded += OnLoaded; - return taskCompletionSource.Task; + try + { + var completedTask = await Task.WhenAny(taskCompletionSource.Task, + Task.Delay(timeout ?? DefaultLoadedTimeout)); + + return completedTask == taskCompletionSource.Task; + } + finally + { + element.Loaded -= OnLoaded; + } } } diff --git a/Source/NETworkManager/MainWindow.xaml.cs b/Source/NETworkManager/MainWindow.xaml.cs index 410bdbd221..0b448a7d49 100644 --- a/Source/NETworkManager/MainWindow.xaml.cs +++ b/Source/NETworkManager/MainWindow.xaml.cs @@ -1101,24 +1101,23 @@ private void OpenRunAction() var childWindow = new RunCommandChildWindow(); - var childWindowViewModel = new RunCommandViewModel(instance => + void CloseDialog() { childWindow.IsOpen = false; ConfigurationManager.Current.IsChildWindowOpen = false; ConfigurationManager.OnDialogClose(); + } + + var childWindowViewModel = new RunCommandViewModel(instance => + { + CloseDialog(); if (instance.IsSettings) NavigationManager.OpenSettings(); else NavigationManager.Open(instance.Request); - }, _ => - { - childWindow.IsOpen = false; - ConfigurationManager.Current.IsChildWindowOpen = false; - - ConfigurationManager.OnDialogClose(); - }); + }, _ => CloseDialog()); childWindow.DataContext = childWindowViewModel; diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs index 8fd732d868..9bb85f8466 100644 --- a/Source/NETworkManager/RunCommandManager.cs +++ b/Source/NETworkManager/RunCommandManager.cs @@ -112,8 +112,10 @@ public static string TryCreateRequest(RunCommandParseResult result, ProfileInfo { if (profile == null) { + // The name is compared like it is parsed (without surrounding whitespace and quotes). var profiles = GetProfiles(result.Command) - .Where(x => string.Equals(x.Name, result.ProfileName, StringComparison.OrdinalIgnoreCase)) + .Where(x => string.Equals(x.Name.Trim().Trim('"').Trim(), result.ProfileName, + StringComparison.OrdinalIgnoreCase)) .ToList(); switch (profiles.Count) @@ -231,7 +233,8 @@ private static string ValidateDNSServer(string dnsServer) if (GetDNSServers().Any(x => string.Equals(x, dnsServer, StringComparison.OrdinalIgnoreCase))) return null; - foreach (var server in dnsServer.Split(';')) + foreach (var server in dnsServer.Split(';', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (!ServerConnectionInfo.TryParse(server, out var serverInfo, 53, TransportProtocol.Udp) || !IPAddress.TryParse(serverInfo.Server, out _) && !serverInfo.Server.Contains('.')) diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs index 673d54cca8..d3868fd400 100644 --- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs +++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs @@ -40,6 +40,11 @@ public class RunCommandViewModel : ViewModelBase /// private readonly List<(string Text, RunCommandDefinition Command)> _history; + /// + /// Names (command and aliases) and translated name per command. + /// + private readonly List<(RunCommandDefinition Command, List Names, string TranslatedName)> _commandNames; + /// /// Profiles per command (loaded once per command while the dialog is open). /// @@ -57,6 +62,12 @@ public RunCommandViewModel(Action runHandler, Action (x, new List { x.Command }.Concat(x.Aliases).ToList(), + RunCommandManager.GetTranslatedName(x))) + ]; + // Entries of commands that are not available anymore (e.g. a hidden application) are not shown. _history = [ @@ -324,14 +335,13 @@ private IEnumerable GetCommandSuggestions() var commands = new List<(int Rank, RunCommandSuggestion Suggestion)>(); - foreach (var command in _commands) + foreach (var (command, names, translatedName) in _commandNames) { - var names = new[] { command.Command }.Concat(command.Aliases).ToList(); - var translatedName = RunCommandManager.GetTranslatedName(command); - - // Complete with the command or alias that was typed (e.g. "ping" instead of "pingmonitor"). + // Complete with the command or alias that was typed, or the shortest one that starts with the text (e.g. + // "pin" → "ping" instead of "pingmonitor"). var exactName = names.FirstOrDefault(x => string.Equals(x, text, StringComparison.OrdinalIgnoreCase)); - var name = exactName ?? names.FirstOrDefault(x => x.StartsWith(text, StringComparison.OrdinalIgnoreCase)); + var name = exactName ?? names.Where(x => x.StartsWith(text, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.Length).FirstOrDefault(); if (name == null && !translatedName.Contains(text, StringComparison.OrdinalIgnoreCase)) continue; diff --git a/Source/NETworkManager/Views/PowerShellHostView.xaml.cs b/Source/NETworkManager/Views/PowerShellHostView.xaml.cs index 52447a191c..bc3e33362e 100644 --- a/Source/NETworkManager/Views/PowerShellHostView.xaml.cs +++ b/Source/NETworkManager/Views/PowerShellHostView.xaml.cs @@ -17,7 +17,8 @@ public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new profile... // MahApps will throw an exception... - await this.WaitForLoadedAsync(); + if (!await this.WaitForLoadedAsync()) + return; if (!_viewModel.IsExecutableConfigured) return; diff --git a/Source/NETworkManager/Views/PuTTYHostView.xaml.cs b/Source/NETworkManager/Views/PuTTYHostView.xaml.cs index 7bc6410bc6..b14aef3d1b 100644 --- a/Source/NETworkManager/Views/PuTTYHostView.xaml.cs +++ b/Source/NETworkManager/Views/PuTTYHostView.xaml.cs @@ -17,7 +17,8 @@ public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new profile... // MahApps will throw an exception... - await this.WaitForLoadedAsync(); + if (!await this.WaitForLoadedAsync()) + return; if (!_viewModel.IsExecutableConfigured) return; diff --git a/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs b/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs index cfdbc89bad..92040196a3 100644 --- a/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs +++ b/Source/NETworkManager/Views/RemoteDesktopHostView.xaml.cs @@ -17,7 +17,8 @@ public async void Open(ApplicationRequest request) { // Wait for the interface to load, before displaying the dialog to connect a new Profile... // MahApps will throw an exception... - await this.WaitForLoadedAsync(); + if (!await this.WaitForLoadedAsync()) + return; if (request.Profile != null) _viewModel.ConnectProfile(request.Profile); diff --git a/Source/NETworkManager/Views/RunCommandChildWindow.xaml b/Source/NETworkManager/Views/RunCommandChildWindow.xaml index d81a19501b..3b90ff6d5a 100644 --- a/Source/NETworkManager/Views/RunCommandChildWindow.xaml +++ b/Source/NETworkManager/Views/RunCommandChildWindow.xaml @@ -89,7 +89,7 @@ Date: Wed, 21 Oct 2026 22:32:27 +0200 Subject: [PATCH 14/14] Docs: Add PR #3644 to changelog Co-Authored-By: Claude Opus 5.5 --- Website/docs/changelog/next-release.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md index 97ce8e373a..a280bc5a22 100644 --- a/Website/docs/changelog/next-release.md +++ b/Website/docs/changelog/next-release.md @@ -27,7 +27,7 @@ Release date: **xx.xx.2026** **Run command** -- The [**Run command**](../run-command.md) (`Ctrl+Shift+P`) now runs a feature directly, e.g. `ping 10.0.0.1`, `dns borntoberoot.net 1.1.1.1 aaaa`, `rdap 1.10 asn` or `rdp @server-01` (profile). It opens in a dialog instead of a flyout, with short aliases (e.g. `ping`, `dns`, `rdp`, `ssh`, `geo`, `wol`), suggestions for recent commands, profiles of the feature and types (`Up`/`Down` to select, `Tab` to complete, `Enter` to run, `Esc` to close), and validates the input like the feature does. Only visible features are offered. +- The [**Run command**](../run-command.md) (`Ctrl+Shift+P`) now runs a feature directly, e.g. `ping 10.0.0.1`, `dns borntoberoot.net 1.1.1.1 aaaa`, `rdap 1.10 asn` or `rdp @server-01` (profile). It opens in a dialog instead of a flyout, with short aliases (e.g. `ping`, `dns`, `rdp`, `ssh`, `geo`, `wol`), suggestions for recent commands, profiles of the feature and types (`Up`/`Down` to select, `Tab` to complete, `Enter` to run, `Esc` to close), and validates the input like the feature does. Only visible features are offered. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) **RDAP** @@ -90,7 +90,7 @@ Release date: **xx.xx.2026** **DNS Lookup** -- IP addresses opened from another feature (e.g. **IP Scanner** > **DNS lookup** > **IP address**), from a profile or with the run command are looked up with `PTR` instead of the selected record type. +- IP addresses opened from another feature (e.g. **IP Scanner** > **DNS lookup** > **IP address**), from a profile or with the run command are looked up with `PTR` instead of the selected record type. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) **Hosts File Editor** @@ -120,7 +120,7 @@ Release date: **xx.xx.2026** - Fixed changes to lists in the settings (e.g. query histories, port profiles, custom commands) not being saved if no other setting was changed in the same session. This happened since the settings are stored as JSON. [#3607](https://github.com/BornToBeRoot/NETworkManager/pull/3607) - Fixed the profile search (profile list in the tools, **Profiles** settings and import result) filtering the profiles while still typing. The profiles are now filtered once after the user stopped typing, instead of every 750 ms after the first key press. [#3638](https://github.com/BornToBeRoot/NETworkManager/pull/3638) -- Fixed the application list not scrolling to the selected application if it was opened from somewhere else (e.g. from the **Dashboard**, another application or the run command). +- Fixed the application list not scrolling to the selected application if it was opened from somewhere else (e.g. from the **Dashboard**, another application or the run command). [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) **Firewall** @@ -190,8 +190,8 @@ Release date: **xx.xx.2026** - Converted the **IP Scanner** and **Port Scanner** engines from `Parallel.ForEach` with blocking calls to genuinely asynchronous `Parallel.ForEachAsync`, removing the risk of exhausting the application's ThreadPool at high concurrency settings. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564) - Refactored the **Network Connection** widget's check logic to reduce duplicated code and share the local IP address detection between the Computer and Router checks instead of detecting it twice. [#3553](https://github.com/BornToBeRoot/NETworkManager/pull/3553) - Consolidated the duplicated **Profiles** side panel (search, tag filter, grouped list, context menu, add/edit/copy/delete) used across 15 tool views into a single shared control, reducing code duplication. As part of this, the profile panel's expanded/width state is now shared across all tools instead of being tracked per tool. [#3537](https://github.com/BornToBeRoot/NETworkManager/pull/3537) -- Replaced the internal event system for opening a feature with data (e.g. **Open in RDAP** from the **IP Scanner**) with typed requests, which can also carry options (e.g. DNS server and record type) and profiles. Features no longer fail to open from another feature while the application list is filtered by a search. -- Input histories (e.g. hosts, domains, ports) are updated in place instead of being cleared and refilled, which avoids clearing the text of the input field and unnecessary updates. -- Added a unit test project (xUnit) with tests for the run command parser and the history lists. +- Replaced the internal event system for opening a feature with data (e.g. **Open in RDAP** from the **IP Scanner**) with typed requests, which can also carry options (e.g. DNS server and record type) and profiles. Features no longer fail to open from another feature while the application list is filtered by a search. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) +- Input histories (e.g. hosts, domains, ports) are updated in place instead of being cleared and refilled, which avoids clearing the text of the input field and unnecessary updates. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) +- Added a unit test project (xUnit) with tests for the run command parser and the history lists. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644) - Language files updated via [#transifex](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Ftransifex-integration) - Dependencies updated via [#dependabot](https://github.com/BornToBeRoot/NETworkManager/pulls?q=author%3Aapp%2Fdependabot)