diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 67cfdb6963..6c10014235 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -25,3 +25,9 @@ updates:
target-branch: main
commit-message:
prefix: "Dependency (dependabot/npm):"
+ groups:
+ # react and react-dom must have the exact same version (otherwise the website build fails)
+ react:
+ patterns:
+ - "react"
+ - "react-dom"
diff --git a/Source/NETworkManager.Controls/ComboBoxHistoryBehavior.cs b/Source/NETworkManager.Controls/ComboBoxHistoryBehavior.cs
new file mode 100644
index 0000000000..f7e0ba7b87
--- /dev/null
+++ b/Source/NETworkManager.Controls/ComboBoxHistoryBehavior.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Threading;
+
+namespace NETworkManager.Controls;
+
+///
+/// Attached property that keeps the text of an editable that shows a history (e.g. the hosts of
+/// the Traceroute) when its selected entry is removed from the history.
+///
+/// The history is changed in place (ListHelper.Modify): the oldest entries are removed and, if the number of
+/// stored entries is 0, all of them. If the removed entry is the selected item (e.g. the host that was just used, or the
+/// host of another tab that shares the history), WPF clears the text of the ComboBox. The text is restored after the
+/// ComboBox has updated it. If the entry is only deselected because another text was typed, it is still in the list and
+/// nothing is changed.
+///
+public static class ComboBoxHistoryBehavior
+{
+ public static readonly DependencyProperty KeepTextWhenSelectedItemIsRemovedProperty =
+ DependencyProperty.RegisterAttached(
+ "KeepTextWhenSelectedItemIsRemoved",
+ typeof(bool),
+ typeof(ComboBoxHistoryBehavior),
+ new PropertyMetadata(false, OnKeepTextWhenSelectedItemIsRemovedChanged));
+
+ public static void SetKeepTextWhenSelectedItemIsRemoved(UIElement element, bool value) =>
+ element.SetValue(KeepTextWhenSelectedItemIsRemovedProperty, value);
+
+ public static bool GetKeepTextWhenSelectedItemIsRemoved(UIElement element) =>
+ (bool)element.GetValue(KeepTextWhenSelectedItemIsRemovedProperty);
+
+ private static void OnKeepTextWhenSelectedItemIsRemovedChanged(DependencyObject d,
+ DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not ComboBox comboBox)
+ return;
+
+ if ((bool)e.NewValue)
+ comboBox.SelectionChanged += ComboBox_SelectionChanged;
+ else
+ comboBox.SelectionChanged -= ComboBox_SelectionChanged;
+ }
+
+ private static void ComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (sender is not ComboBox { IsEditable: true } comboBox || comboBox.SelectedItem != null ||
+ e.RemovedItems.Count == 0)
+ return;
+
+ var removedItem = e.RemovedItems[0];
+
+ // Deselected because another text was typed: the entry is still in the list.
+ if (comboBox.Items.Contains(removedItem))
+ return;
+
+ var text = removedItem?.ToString();
+
+ if (string.IsNullOrEmpty(text))
+ return;
+
+ // After the ComboBox has cleared its text (and only if it did)
+ comboBox.Dispatcher.BeginInvoke(DispatcherPriority.DataBind, new Action(delegate
+ {
+ if (string.IsNullOrEmpty(comboBox.Text))
+ comboBox.Text = text;
+ }));
+ }
+}
diff --git a/Source/NETworkManager.Controls/ObservableRangeCollection.cs b/Source/NETworkManager.Controls/ObservableRangeCollection.cs
index f222a46263..88d6fb7af2 100644
--- a/Source/NETworkManager.Controls/ObservableRangeCollection.cs
+++ b/Source/NETworkManager.Controls/ObservableRangeCollection.cs
@@ -2,6 +2,7 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
+using System.Linq;
namespace NETworkManager.Controls;
@@ -23,9 +24,12 @@ public void ReplaceAll(IEnumerable items)
{
CheckReentrancy();
+ // Read the items first: if a lazy sequence throws, the collection is unchanged (and in sync with the views).
+ var newItems = items.ToList();
+
Items.Clear();
- foreach (var item in items)
+ foreach (var item in newItems)
Items.Add(item);
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index 33bee494ed..717cbe9f33 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -1,6 +1,7 @@
//------------------------------------------------------------------------------
//
// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
@@ -1490,6 +1491,15 @@ public static string BootstrapFilesAreUpToDate {
}
}
+ ///
+ /// Looks up a localized string similar to Bring window to foreground and open the run command.
+ ///
+ public static string BringWindowToForegroundAndOpenRunCommand {
+ get {
+ return ResourceManager.GetString("BringWindowToForegroundAndOpenRunCommand", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Broadcast.
///
@@ -1944,6 +1954,15 @@ public static string Client {
}
}
+ ///
+ /// Looks up a localized string similar to Clipboard.
+ ///
+ public static string Clipboard {
+ get {
+ return ResourceManager.GetString("Clipboard", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Close.
///
@@ -2053,7 +2072,16 @@ public static string CommandLineArguments {
}
///
- /// Looks up a localized string similar to The command "{0}" does not accept arguments!.
+ /// Looks up a localized string similar to Commands.
+ ///
+ public static string Commands {
+ get {
+ return ResourceManager.GetString("Commands", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The command "{0}" does not accept arguments!.
///
public static string CommandXDoesNotAcceptArguments {
get {
@@ -2062,7 +2090,7 @@ public static string CommandXDoesNotAcceptArguments {
}
///
- /// Looks up a localized string similar to The command "{0}" does not support profiles!.
+ /// Looks up a localized string similar to The command "{0}" does not support profiles!.
///
public static string CommandXDoesNotSupportProfiles {
get {
@@ -2088,6 +2116,15 @@ public static string Community {
}
}
+ ///
+ /// Looks up a localized string similar to Complete.
+ ///
+ public static string Complete {
+ get {
+ return ResourceManager.GetString("Complete", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Computer.
///
@@ -2395,7 +2432,7 @@ public static string CopyEntry {
}
///
- /// Looks up a localized string similar to The rule "{0}" uses the following properties, which are not supported by NETworkManager and are not copied:
+ /// Looks up a localized string similar to The rule "{0}" uses the following properties, which are not supported by NETworkManager and are not copied:
///
///{1}
///
@@ -2650,6 +2687,24 @@ public static string CtrlAltDel {
}
}
+ ///
+ /// Looks up a localized string similar to Ctrl+Enter.
+ ///
+ public static string CtrlEnter {
+ get {
+ return ResourceManager.GetString("CtrlEnter", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Ctrl+Shift+P.
+ ///
+ public static string CtrlShiftP {
+ get {
+ return ResourceManager.GetString("CtrlShiftP", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Currency.
///
@@ -3621,6 +3676,15 @@ public static string Domain {
}
}
+ ///
+ /// Looks up a localized string similar to This domain cannot be queried via RDAP. Try Whois instead..
+ ///
+ public static string DomainCannotBeQueriedViaRDAPTryWhois {
+ get {
+ return ResourceManager.GetString("DomainCannotBeQueriedViaRDAPTryWhois", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Domain name.
///
@@ -3803,7 +3867,7 @@ public static string EditEntryDots {
}
///
- /// Looks up a localized string similar to Editing rules that are not managed by NETworkManager is not supported (yet), because they may use properties that can't be shown in the dialog (e.g. rule group, service or ICMP types). Use "Copy as..." to create a managed copy of the rule..
+ /// Looks up a localized string similar to Editing rules that are not managed by NETworkManager is not supported (yet), because they may use properties that can't be shown in the dialog (e.g. rule group, service or ICMP types). Use "Copy as..." to create a managed copy of the rule..
///
public static string EditFirewallRuleNotManagedMessage {
get {
@@ -4174,6 +4238,15 @@ public static string EnterSubnetAndANewSubnetmaskToCreateSubnets {
}
}
+ ///
+ /// Looks up a localized string similar to Enter a subnet mask or CIDR between /{0} and /{1}!.
+ ///
+ public static string EnterSubnetmaskOrCIDRBetweenXAndY {
+ get {
+ return ResourceManager.GetString("EnterSubnetmaskOrCIDRBetweenXAndY", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Enter subnets to create a wide subnet....
///
@@ -4184,7 +4257,7 @@ public static string EnterSubnetsToCreateAWideSubnet {
}
///
- /// Looks up a localized string similar to Enter the name of a profile after "@"!.
+ /// Looks up a localized string similar to Enter the name of a profile after "@"!.
///
public static string EnterTheNameOfAProfileAfterAt {
get {
@@ -4193,7 +4266,7 @@ public static string EnterTheNameOfAProfileAfterAt {
}
///
- /// Looks up a localized string similar to Enter a valid AS number (e.g., "AS15169")!.
+ /// Looks up a localized string similar to Enter a valid AS number (e.g., "AS15169")!.
///
public static string EnterValidASN {
get {
@@ -4220,7 +4293,7 @@ public static string EnterValidDomain {
}
///
- /// Looks up a localized string similar to Enter a valid entity handle (e.g., "ORG-RIEN1-RIPE")!.
+ /// Looks up a localized string similar to Enter a valid entity handle (e.g., "ORG-RIEN1-RIPE")!.
///
public static string EnterValidEntityHandle {
get {
@@ -4328,7 +4401,7 @@ public static string EnterValidIPAddress {
}
///
- /// Looks up a localized string similar to Enter a valid IP address or CIDR (e.g., "192.0.2.0/24")!.
+ /// Looks up a localized string similar to Enter a valid IP address or CIDR (e.g., "192.0.2.0/24")!.
///
public static string EnterValidIPAddressOrCIDR {
get {
@@ -4373,7 +4446,7 @@ public static string EnterValidMACAddress {
}
///
- /// Looks up a localized string similar to Enter a valid nameserver (e.g., "ns1.example.com")!.
+ /// Looks up a localized string similar to Enter a valid nameserver (e.g., "ns1.example.com")!.
///
public static string EnterValidNameserver {
get {
@@ -4454,7 +4527,7 @@ public static string EnterValidSubnetmaskOrCIDR {
}
///
- /// Looks up a localized string similar to Enter a valid top-level domain (e.g., "de")!.
+ /// Looks up a localized string similar to Enter a valid top-level domain (e.g., "de")!.
///
public static string EnterValidTLD {
get {
@@ -4606,6 +4679,15 @@ public static string ExampleProfileFileName {
}
}
+ ///
+ /// Looks up a localized string similar to Examples.
+ ///
+ public static string Examples {
+ get {
+ return ResourceManager.GetString("Examples", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to production.
///
@@ -4876,6 +4958,15 @@ public static string FilePath {
}
}
+ ///
+ /// Looks up a localized string similar to Fill in without starting.
+ ///
+ public static string FillInWithoutStarting {
+ get {
+ return ResourceManager.GetString("FillInWithoutStarting", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Filter.
///
@@ -5138,7 +5229,7 @@ public static string FirewallRuleDirection_Outbound {
}
///
- /// Looks up a localized string similar to Managed by Group Policy. The rule can't be changed locally, but it can be copied..
+ /// Looks up a localized string similar to Managed by Group Policy. The rule can't be changed locally, but it can be copied..
///
public static string FirewallRuleManagedByGroupPolicyMessage {
get {
@@ -5147,7 +5238,7 @@ public static string FirewallRuleManagedByGroupPolicyMessage {
}
///
- /// Looks up a localized string similar to Managed by Mobile Device Management (MDM), e.g. Microsoft Intune. The rule can't be changed locally, but it can be copied..
+ /// Looks up a localized string similar to Managed by Mobile Device Management (MDM), e.g. Microsoft Intune. The rule can't be changed locally, but it can be copied..
///
public static string FirewallRuleManagedByMDMMessage {
get {
@@ -5156,7 +5247,7 @@ public static string FirewallRuleManagedByMDMMessage {
}
///
- /// Looks up a localized string similar to Created by Windows at runtime (e.g. dynamic or hardcoded rule). The rule can't be changed locally, but it can be copied..
+ /// Looks up a localized string similar to Created by Windows at runtime (e.g. dynamic or hardcoded rule). The rule can't be changed locally, but it can be copied..
///
public static string FirewallRuleManagedByOtherMessage {
get {
@@ -5201,7 +5292,7 @@ public static string FirewallRulesLoadedWithErrorsMessage {
}
///
- /// Looks up a localized string similar to At least one of the selected rules is managed by a policy (e.g. Group Policy or MDM) or created by Windows at runtime and can't be changed locally..
+ /// Looks up a localized string similar to At least one of the selected rules is managed by a policy (e.g. Group Policy or MDM) or created by Windows at runtime and can't be changed locally..
///
public static string FirewallRulesManagedByPolicyMessage {
get {
@@ -6734,6 +6825,24 @@ public static string KeyboardShortcuts {
}
}
+ ///
+ /// Looks up a localized string similar to Enter.
+ ///
+ public static string KeyEnter {
+ get {
+ return ResourceManager.GetString("KeyEnter", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Esc.
+ ///
+ public static string KeyEsc {
+ get {
+ return ResourceManager.GetString("KeyEsc", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Key must have 8 characters or more!.
///
@@ -6743,6 +6852,15 @@ public static string KeyMustHave8CharactersOrMore {
}
}
+ ///
+ /// Looks up a localized string similar to Tab.
+ ///
+ public static string KeyTab {
+ get {
+ return ResourceManager.GetString("KeyTab", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Key tag.
///
@@ -7896,7 +8014,7 @@ public static string NetworkInterfaceConfigurationAppliedSuccessfully {
}
///
- /// Looks up a localized string similar to Configuring the network interface requires elevated rights (administrator or member of the "Network Configuration Operators" group)!.
+ /// Looks up a localized string similar to Configuring the network interface requires elevated rights (administrator or member of the "Network Configuration Operators" group)!.
///
public static string NetworkInterfaceConfigureAdminMessage {
get {
@@ -8094,7 +8212,7 @@ public static string NoEnabledNetworkAdaptersFound {
}
///
- /// Looks up a localized string similar to No firewall rules found. Use "Add rule..." to create a rule or enable "Show all rules" in the settings to show all Windows Firewall rules..
+ /// Looks up a localized string similar to No firewall rules found. Use "Add rule..." to create a rule or enable "Show all rules" in the settings to show all Windows Firewall rules..
///
public static string NoFirewallRulesFoundMessage {
get {
@@ -8976,6 +9094,15 @@ public static string PortOrService {
}
}
+ ///
+ /// Looks up a localized string similar to Port profile.
+ ///
+ public static string PortProfile {
+ get {
+ return ResourceManager.GetString("PortProfile", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Port profiles.
///
@@ -9329,7 +9456,7 @@ public static string ProfileWithThisNameAlreadyExists {
}
///
- /// Looks up a localized string similar to Profile "{0}" not found!.
+ /// Looks up a localized string similar to Profile "{0}" not found!.
///
public static string ProfileXNotFound {
get {
@@ -9627,7 +9754,7 @@ public static string RDAPNetworkErrorX {
}
///
- /// Looks up a localized string similar to No RDAP server is known for "{0}". Entity handles can only be resolved if they end with a known service provider tag (e.g., "-RIPE" or "-ARIN")..
+ /// Looks up a localized string similar to No RDAP server is known for "{0}". Entity handles can only be resolved if they end with a known service provider tag (e.g., "-RIPE" or "-ARIN")..
///
public static string RDAPNoServerForEntityX {
get {
@@ -9636,7 +9763,7 @@ public static string RDAPNoServerForEntityX {
}
///
- /// Looks up a localized string similar to No RDAP server is known for "{0}". The registry may only provide Whois..
+ /// Looks up a localized string similar to No RDAP server is known for "{0}". The registry may only provide Whois..
///
public static string RDAPNoServerForX {
get {
@@ -9645,7 +9772,7 @@ public static string RDAPNoServerForX {
}
///
- /// Looks up a localized string similar to No data found for "{0}"..
+ /// Looks up a localized string similar to No data found for "{0}"..
///
public static string RDAPNotFoundX {
get {
@@ -9752,6 +9879,24 @@ public static string RDAPReferralFailedX {
}
}
+ ///
+ /// Looks up a localized string similar to The referral to the RDAP server of the registrar was not followed, because it does not use a secure connection (HTTPS): {0}.
+ ///
+ public static string RDAPReferralInsecureX {
+ get {
+ return ResourceManager.GetString("RDAPReferralInsecureX", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The referral to the RDAP server of the registrar was not followed, because it points to a local or private address: {0}.
+ ///
+ public static string RDAPReferralPrivateAddressX {
+ get {
+ return ResourceManager.GetString("RDAPReferralPrivateAddressX", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to The RDAP server did not respond within {0} seconds..
///
@@ -9771,7 +9916,7 @@ public static string RDAPTooManyRedirects {
}
///
- /// Looks up a localized string similar to "{0}" is not a registered domain, showing the registration data of "{1}"..
+ /// Looks up a localized string similar to "{0}" is not a registered domain, showing the registration data of "{1}"..
///
public static string RDAPXIsNotARegisteredDomainShowingY {
get {
@@ -10949,6 +11094,15 @@ public static string RemotePorts {
}
}
+ ///
+ /// Looks up a localized string similar to Remove from history.
+ ///
+ public static string RemoveFromHistory {
+ get {
+ return ResourceManager.GetString("RemoveFromHistory", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Remove IPv4 address.
///
@@ -11166,7 +11320,7 @@ public static string Resource_OUI_Description {
}
///
- /// Looks up a localized string similar to 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..
+ /// Looks up a localized string similar to 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..
///
public static string Resource_PublicSuffixList_Description {
get {
@@ -11412,6 +11566,24 @@ public static string Routing {
}
}
+ ///
+ /// Looks up a localized string similar to Run.
+ ///
+ public static string Run {
+ get {
+ return ResourceManager.GetString("Run", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Run and keep open.
+ ///
+ public static string RunAndKeepOpen {
+ get {
+ return ResourceManager.GetString("RunAndKeepOpen", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Run background job every x-minute.
///
@@ -11448,6 +11620,168 @@ public static string RunCommandDotsWithHotKey {
}
}
+ ///
+ /// Looks up a localized string similar to Ping it, trace it, scan it....
+ ///
+ public static string RunCommandWatermark1 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark1", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Before you reboot it, ping it....
+ ///
+ public static string RunCommandWatermark10 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark10", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Ping it, trace it, fix it....
+ ///
+ public static string RunCommandWatermark11 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark11", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Is it up? Let's find out....
+ ///
+ public static string RunCommandWatermark12 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark12", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Need to connect? Just type the host....
+ ///
+ public static string RunCommandWatermark13 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark13", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to It's not the network. Probably....
+ ///
+ public static string RunCommandWatermark14 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark14", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Blame the firewall later, ping first....
+ ///
+ public static string RunCommandWatermark15 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark15", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Works on my machine? Let's check yours....
+ ///
+ public static string RunCommandWatermark16 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark16", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Somewhere between here and there....
+ ///
+ public static string RunCommandWatermark17 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark17", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Ping first, panic later....
+ ///
+ public static string RunCommandWatermark18 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark18", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Got an IP address? Drop it here....
+ ///
+ public static string RunCommandWatermark2 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark2", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Your whole network, one command away....
+ ///
+ public static string RunCommandWatermark3 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark3", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Every tool, one line away....
+ ///
+ public static string RunCommandWatermark4 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark4", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Something down? Let's check it....
+ ///
+ public static string RunCommandWatermark5 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark5", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Is it DNS? It's always DNS....
+ ///
+ public static string RunCommandWatermark6 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark6", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Have you tried pinging it?.
+ ///
+ public static string RunCommandWatermark7 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark7", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Packets in, answers out....
+ ///
+ public static string RunCommandWatermark8 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark8", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Ready when your network isn't....
+ ///
+ public static string RunCommandWatermark9 {
+ get {
+ return ResourceManager.GetString("RunCommandWatermark9", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Run dialog (Win+R).
///
@@ -11628,6 +11962,15 @@ public static string Security {
}
}
+ ///
+ /// Looks up a localized string similar to Select.
+ ///
+ public static string Select {
+ get {
+ return ResourceManager.GetString("Select", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Select all.
///
@@ -11948,7 +12291,7 @@ public static string SettingsResetExclamationMark {
}
///
- /// Looks up a localized string similar to Several profiles are named "{0}", select one!.
+ /// Looks up a localized string similar to Several profiles are named "{0}", select one!.
///
public static string SeveralProfilesAreNamedXSelectOne {
get {
@@ -11965,6 +12308,24 @@ public static string ShareGatewayCredentialsWithRemoteComputer {
}
}
+ ///
+ /// Looks up a localized string similar to Shift+Del.
+ ///
+ public static string ShiftDel {
+ get {
+ return ResourceManager.GetString("ShiftDel", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Shift+Enter.
+ ///
+ public static string ShiftEnter {
+ get {
+ return ResourceManager.GetString("ShiftEnter", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Show.
///
@@ -12028,6 +12389,15 @@ public static string ShowErrorMessage {
}
}
+ ///
+ /// Looks up a localized string similar to Show key hints.
+ ///
+ public static string ShowKeyHints {
+ get {
+ return ResourceManager.GetString("ShowKeyHints", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Show local licenses.
///
@@ -12713,6 +13083,15 @@ public static string SuccessfullyConnectedToXXX {
}
}
+ ///
+ /// Looks up a localized string similar to Suggest the clipboard content (e.g. an IP address) as target.
+ ///
+ public static string SuggestTheClipboardAsTarget {
+ get {
+ return ResourceManager.GetString("SuggestTheClipboardAsTarget", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Support this project with a donation..
///
@@ -13317,7 +13696,7 @@ public static string Unknown {
}
///
- /// Looks up a localized string similar to Unknown argument "{0}"!.
+ /// Looks up a localized string similar to Unknown argument "{0}"!.
///
public static string UnknownArgumentX {
get {
@@ -13326,7 +13705,7 @@ public static string UnknownArgumentX {
}
///
- /// Looks up a localized string similar to Unknown command "{0}"!.
+ /// Looks up a localized string similar to Unknown command "{0}"!.
///
public static string UnknownCommandX {
get {
@@ -14194,32 +14573,5 @@ public static string ZipCode {
return ResourceManager.GetString("ZipCode", resourceCulture);
}
}
- ///
- /// Looks up a localized string similar to This domain cannot be queried via RDAP. Try Whois instead..
- ///
- public static string DomainCannotBeQueriedViaRDAPTryWhois {
- get {
- return ResourceManager.GetString("DomainCannotBeQueriedViaRDAPTryWhois", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to The referral to the RDAP server of the registrar was not followed, because it does not use a secure connection (HTTPS): {0}.
- ///
- public static string RDAPReferralInsecureX {
- get {
- return ResourceManager.GetString("RDAPReferralInsecureX", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to The referral to the RDAP server of the registrar was not followed, because it points to a local or private address: {0}.
- ///
- public static string RDAPReferralPrivateAddressX {
- get {
- return ResourceManager.GetString("RDAPReferralPrivateAddressX", resourceCulture);
- }
- }
-
}
}
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index 667accf8ac..a6cbcd9cf9 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -1,4 +1,4 @@
-
+
https://www.codeproject.com/Tips/216238/Regular-Expression-to-Validate-File-Path-and-Exten
public const string FullNameRegex =
@"^(?:[\w]\:|\\)(\\[a-zA-Z0-9_\-\s\.()~!@#$%^&=+';,{}\[\]]+)+\.[a-zA-z0-9]{1,4}$";
diff --git a/Source/NETworkManager.Validators/EmptyOrPortRangeValidator.cs b/Source/NETworkManager.Validators/EmptyOrPortRangeValidator.cs
index 888a9e101b..8a1524ac54 100644
--- a/Source/NETworkManager.Validators/EmptyOrPortRangeValidator.cs
+++ b/Source/NETworkManager.Validators/EmptyOrPortRangeValidator.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Windows.Controls;
using NETworkManager.Localization.Resources;
+using NETworkManager.Utilities;
namespace NETworkManager.Validators;
@@ -20,8 +21,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
if (int.TryParse(portRange[0], out var startPort) && int.TryParse(portRange[1], out var endPort))
{
- if (!(startPort > 0 && startPort < 65536 && endPort > 0 && endPort < 65536 &&
- startPort < endPort))
+ if (!PortRangeHelper.IsValidPortRange(startPort, endPort))
isValid = false;
}
else
@@ -33,7 +33,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
{
if (int.TryParse(portOrRange, out var portNumber))
{
- if (!(portNumber > 0 && portNumber < 65536))
+ if (!PortRangeHelper.IsValidPort(portNumber))
isValid = false;
}
else
diff --git a/Source/NETworkManager.Validators/PortRangeValidator.cs b/Source/NETworkManager.Validators/PortRangeValidator.cs
index 526b255662..8c12eb2303 100644
--- a/Source/NETworkManager.Validators/PortRangeValidator.cs
+++ b/Source/NETworkManager.Validators/PortRangeValidator.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Windows.Controls;
using NETworkManager.Localization.Resources;
+using NETworkManager.Utilities;
namespace NETworkManager.Validators;
@@ -20,8 +21,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
if (int.TryParse(portRange[0], out var startPort) && int.TryParse(portRange[1], out var endPort))
{
- if (!(startPort > 0 && startPort < 65536 && endPort > 0 && endPort < 65536 &&
- startPort < endPort))
+ if (!PortRangeHelper.IsValidPortRange(startPort, endPort))
isValid = false;
}
else
@@ -33,7 +33,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
{
if (int.TryParse(portOrRange, out var portNumber))
{
- if (!(portNumber > 0 && portNumber < 65536))
+ if (!PortRangeHelper.IsValidPort(portNumber))
isValid = false;
}
else
diff --git a/Source/NETworkManager.Validators/PortValidator.cs b/Source/NETworkManager.Validators/PortValidator.cs
index 6e51b0d372..bd50276604 100644
--- a/Source/NETworkManager.Validators/PortValidator.cs
+++ b/Source/NETworkManager.Validators/PortValidator.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Windows.Controls;
using NETworkManager.Localization.Resources;
+using NETworkManager.Utilities;
namespace NETworkManager.Validators;
@@ -11,7 +12,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
if (!int.TryParse(value as string, out var portNumber))
return new ValidationResult(false, Strings.EnterValidPort);
- if (portNumber > 0 && portNumber < 65536)
+ if (PortRangeHelper.IsValidPort(portNumber))
return ValidationResult.ValidResult;
return new ValidationResult(false, Strings.EnterValidPort);
diff --git a/Source/NETworkManager.Validators/RemoteDesktopHostnameAndPortValidator.cs b/Source/NETworkManager.Validators/RemoteDesktopHostnameAndPortValidator.cs
index 32b78db059..2f7efa003a 100644
--- a/Source/NETworkManager.Validators/RemoteDesktopHostnameAndPortValidator.cs
+++ b/Source/NETworkManager.Validators/RemoteDesktopHostnameAndPortValidator.cs
@@ -17,7 +17,7 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
{
var hostnameAndPortValues = hostnameAndPort.Split(':');
- if (RegexHelper.HostnameOrDomainRegex().IsMatch(hostnameAndPortValues[0]) &&
+ if (IsHost(hostnameAndPortValues[0]) &&
!string.IsNullOrEmpty(hostnameAndPortValues[1]) &&
Regex.IsMatch(hostnameAndPortValues[1], RegexHelper.PortRegex))
return ValidationResult.ValidResult;
@@ -25,8 +25,17 @@ public override ValidationResult Validate(object value, CultureInfo cultureInfo)
return new ValidationResult(false, Strings.EnterValidHostnameAndPort);
}
- return RegexHelper.HostnameOrDomainRegex().IsMatch((string)value)
+ return IsHost(hostnameAndPort)
? ValidationResult.ValidResult
: new ValidationResult(false, Strings.EnterValidHostname);
}
+
+ ///
+ /// Checks if the value is an IPv4 address or a hostname (a hostname must contain a letter, so IPv4 addresses are
+ /// checked separately).
+ ///
+ private static bool IsHost(string value)
+ {
+ return RegexHelper.IPv4AddressRegex().IsMatch(value) || RegexHelper.HostnameOrDomainRegex().IsMatch(value);
+ }
}
\ No newline at end of file
diff --git a/Source/NETworkManager/ApplicationRequest.cs b/Source/NETworkManager/ApplicationRequest.cs
index d9728eaf11..ad2953dbcf 100644
--- a/Source/NETworkManager/ApplicationRequest.cs
+++ b/Source/NETworkManager/ApplicationRequest.cs
@@ -1,5 +1,6 @@
using DnsClient;
using NETworkManager.Models;
+using NETworkManager.Models.Lookup;
using NETworkManager.Models.RDAP;
using NETworkManager.Profiles;
@@ -22,6 +23,13 @@ public record ApplicationRequest(ApplicationName Application)
/// Profile to run in the application with its settings. is ignored if set.
///
public ProfileInfo Profile { get; init; }
+
+ ///
+ /// Starts the application with the target or profile (e.g. the scan). If false, the input is only filled in, so
+ /// the options can be adjusted before starting. Remote tools always open the connect dialog (target) or connect
+ /// (profile).
+ ///
+ public bool Start { get; init; } = true;
}
///
@@ -72,3 +80,31 @@ public sealed record RDAPRequest() : ApplicationRequest(ApplicationName.RDAP)
///
public RDAPQueryType? QueryType { get; init; }
}
+
+///
+/// Request to open the Subnet Calculator. is the subnet, which is calculated
+/// (or subnetted / combined with / ).
+///
+public sealed record SubnetCalculatorRequest() : ApplicationRequest(ApplicationName.SubnetCalculator)
+{
+ ///
+ /// New subnet mask or CIDR (e.g. "/26") to split the subnet into (Subnetting).
+ ///
+ public string NewSubnetmask { get; init; }
+
+ ///
+ /// Second subnet to calculate the wide subnet with (Wide Subnet).
+ ///
+ public string Subnet2 { get; init; }
+}
+
+///
+/// Request to open the Lookup. Without a , only the lookup is shown.
+///
+public sealed record LookupRequest() : ApplicationRequest(ApplicationName.Lookup)
+{
+ ///
+ /// Lookup to show (and run with the target).
+ ///
+ public LookupType Type { get; init; }
+}
diff --git a/Source/NETworkManager/Controls/TitleBarPanel.cs b/Source/NETworkManager/Controls/TitleBarPanel.cs
new file mode 100644
index 0000000000..95ae742111
--- /dev/null
+++ b/Source/NETworkManager/Controls/TitleBarPanel.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Threading;
+
+namespace NETworkManager.Controls;
+
+///
+/// Panel for the title of a window: the first child (title) on the left, the second child (e.g. the run command)
+/// centered in the window (not only in the title area between the window commands, like the command center of VS
+/// Code). If there is not enough space in the center (e.g. wide window commands on the right), the second child is
+/// moved only as far as needed and only made smaller (e.g. text is trimmed) if the free space is smaller than its
+/// , so it doesn't overlap the title or the end of the panel.
+///
+public class TitleBarPanel : Panel
+{
+ ///
+ /// Minimum space between the title, the centered child and the end of the panel.
+ ///
+ private const double Spacing = 10;
+
+ ///
+ /// Position of the panel in the window and width of the window at the last measure.
+ ///
+ private double _measuredOffset = double.NaN;
+
+ private double _measuredWindowWidth = double.NaN;
+
+ private Window _window;
+
+ public TitleBarPanel()
+ {
+ // The position in the window and the width of the window are only known after the layout, so the children are
+ // measured again if they changed: after the first layout, when the window is resized (e.g. maximized) and when
+ // the panel is resized (e.g. the window commands change). Not on every layout pass (LayoutUpdated), which would
+ // also run while e.g. scan results are added.
+ Loaded += (_, _) =>
+ {
+ _window = Window.GetWindow(this);
+
+ if (_window != null)
+ _window.SizeChanged += Window_SizeChanged;
+
+ Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(MeasureIfMoved));
+ };
+
+ Unloaded += (_, _) =>
+ {
+ if (_window != null)
+ _window.SizeChanged -= Window_SizeChanged;
+
+ _window = null;
+ };
+
+ SizeChanged += (_, _) => MeasureIfMoved();
+ }
+
+ private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ MeasureIfMoved();
+ }
+
+ ///
+ /// Measures the children again if the position of the panel in the window or the width of the window changed
+ /// since the last measure.
+ ///
+ private void MeasureIfMoved()
+ {
+ var offset = GetOffsetInWindow();
+ var windowWidth = Window.GetWindow(this)?.ActualWidth ?? double.NaN;
+
+ if (double.IsNaN(offset) || double.IsNaN(windowWidth))
+ return;
+
+ // "!(... <= 0.5)" is also true if it was not measured yet (NaN).
+ if (!(Math.Abs(offset - _measuredOffset) <= 0.5) || !(Math.Abs(windowWidth - _measuredWindowWidth) <= 0.5))
+ InvalidateMeasure();
+ }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ var width = double.IsInfinity(availableSize.Width) ? 0 : availableSize.Width;
+ var height = 0.0;
+
+ _measuredOffset = GetOffsetInWindow();
+ _measuredWindowWidth = Window.GetWindow(this)?.ActualWidth ?? double.NaN;
+
+ if (InternalChildren.Count > 0)
+ {
+ var title = InternalChildren[0];
+
+ title.Measure(availableSize);
+ height = title.DesiredSize.Height;
+
+ if (InternalChildren.Count > 1)
+ {
+ var center = InternalChildren[1];
+ var (_, centerWidth) = GetCenterLayout(width, title.DesiredSize.Width, GetPreferredWidth(center));
+
+ center.Measure(new Size(centerWidth, availableSize.Height));
+ height = Math.Max(height, center.DesiredSize.Height);
+ }
+ }
+
+ // Use the full width (the title area of the window).
+ return new Size(width, height);
+ }
+
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ if (InternalChildren.Count == 0)
+ return finalSize;
+
+ var title = InternalChildren[0];
+ var titleWidth = Math.Min(title.DesiredSize.Width, finalSize.Width);
+
+ title.Arrange(new Rect(0, 0, titleWidth, finalSize.Height));
+
+ if (InternalChildren.Count < 2)
+ return finalSize;
+
+ var center = InternalChildren[1];
+ var (x, width) = GetCenterLayout(finalSize.Width, titleWidth, GetPreferredWidth(center));
+
+ center.Arrange(new Rect(x, 0, width, finalSize.Height));
+
+ return finalSize;
+ }
+
+ ///
+ /// Gets the position and width of the centered child.
+ ///
+ /// Width of the panel.
+ /// Width of the title.
+ /// Width of the child if there is enough space.
+ private (double X, double Width) GetCenterLayout(double panelWidth, double titleWidth, double preferredWidth)
+ {
+ // Center of the window in the coordinates of the panel (center of the panel until the window is measured)
+ var windowCenter = double.IsNaN(_measuredOffset) || !(_measuredWindowWidth > 0)
+ ? panelWidth / 2
+ : _measuredWindowWidth / 2 - _measuredOffset;
+
+ // Free space between the title and the end of the panel
+ var left = titleWidth + Spacing;
+ var right = panelWidth - Spacing;
+
+ // Only smaller than preferred if the free space is smaller, then as close to the center as possible.
+ var width = Math.Max(0, Math.Min(preferredWidth, right - left));
+ var x = Math.Clamp(windowCenter - width / 2, left, Math.Max(left, right - width));
+
+ return (x, width);
+ }
+
+ ///
+ /// Gets the width of the child if there is enough space: its maximum width, or the width of its content.
+ ///
+ private static double GetPreferredWidth(UIElement child)
+ {
+ if (child is FrameworkElement { MaxWidth: var maxWidth } && !double.IsInfinity(maxWidth))
+ return maxWidth;
+
+ child.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+
+ return child.DesiredSize.Width;
+ }
+
+ ///
+ /// Gets the horizontal position of the panel in its window, NaN if it is not in a window.
+ ///
+ private double GetOffsetInWindow()
+ {
+ var window = Window.GetWindow(this);
+
+ return window != null && window.IsAncestorOf(this) ? TranslatePoint(new Point(0, 0), window).X : double.NaN;
+ }
+}
diff --git a/Source/NETworkManager/DialogHelper.cs b/Source/NETworkManager/DialogHelper.cs
index ddf550274e..59d40aa040 100644
--- a/Source/NETworkManager/DialogHelper.cs
+++ b/Source/NETworkManager/DialogHelper.cs
@@ -17,6 +17,12 @@ namespace NETworkManager
///
public static class DialogHelper
{
+ ///
+ /// Occurs before a message or confirmation dialog is shown, e.g. so a dialog that stays open (the run command
+ /// with Ctrl+Enter) can close itself first instead of the dialogs being shown on top of each other.
+ ///
+ public static event EventHandler DialogOpening;
+
///
/// Displays a modal message dialog with an OK button, allowing the user to acknowledge the message before
/// continuing.
@@ -33,6 +39,8 @@ public static Task ShowMessageAsync(Window parentWindow, string title, string me
{
confirmButtonText ??= Strings.OK;
+ DialogOpening?.Invoke(null, EventArgs.Empty);
+
var childWindow = new MessageChildWindow();
var childWindowViewModel = new MessageViewModel(_ =>
@@ -72,6 +80,8 @@ public static async Task ShowConfirmationMessageAsync(Window parentWindow,
var result = false;
+ DialogOpening?.Invoke(null, EventArgs.Empty);
+
var childWindow = new MessageConfirmationChildWindow();
var childWindowViewModel = new MessageConfirmationViewModel(_ =>
diff --git a/Source/NETworkManager/MainWindow.xaml b/Source/NETworkManager/MainWindow.xaml
index a41009ad5a..d1aa88e1f2 100644
--- a/Source/NETworkManager/MainWindow.xaml
+++ b/Source/NETworkManager/MainWindow.xaml
@@ -7,6 +7,8 @@
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:dd="clr-namespace:GongSolutions.Wpf.DragDrop;assembly=GongSolutions.Wpf.DragDrop"
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
+ xmlns:shell="clr-namespace:ControlzEx.Windows.Shell;assembly=ControlzEx"
+ xmlns:controls="clr-namespace:NETworkManager.Controls"
xmlns:interactivity="http://schemas.microsoft.com/xaml/behaviors"
xmlns:asf="clr-namespace:AirspaceFixer;assembly=AirspaceFixer"
xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
@@ -18,7 +20,7 @@
Style="{DynamicResource ResourceKey=DefaultWindow}"
MinWidth="800" MinHeight="600"
Width="1024" Height="768"
- SaveWindowPosition="True" TitleAlignment="Left"
+ SaveWindowPosition="True" TitleAlignment="Stretch"
ContentRendered="MetroMainWindow_ContentRendered" StateChanged="MetroWindowMain_StateChanged"
Activated="MetroMainWindow_Activated" Closing="MetroWindowMain_Closing"
d:DataContext="{d:DesignInstance networkManager:MainWindow}">
@@ -89,19 +91,6 @@
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/NETworkManager/MainWindow.xaml.cs b/Source/NETworkManager/MainWindow.xaml.cs
index 0b448a7d49..b6f9bb0141 100644
--- a/Source/NETworkManager/MainWindow.xaml.cs
+++ b/Source/NETworkManager/MainWindow.xaml.cs
@@ -1062,7 +1062,8 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro
// Change application view
SelectedApplication = application;
- if (string.IsNullOrEmpty(request.Target) && request.Profile == null)
+ // Only open the application, unless the request shows a part of it (e.g. the port lookup)
+ if (string.IsNullOrEmpty(request.Target) && request.Profile == null && request is not LookupRequest)
return;
// Create a new tab / perform the action in the application (only if it is shown)
@@ -1099,19 +1100,29 @@ private void OpenRunAction()
if (ConfigurationManager.Current.IsChildWindowOpen)
return;
- var childWindow = new RunCommandChildWindow();
+ var childWindow = new RunCommandChildWindow(this);
+
+ // A message of an application that was run with Ctrl+Enter (the dialog stays open, e.g. an error of the Ping
+ // Monitor) closes the dialog first, so the dialogs are not shown on top of each other.
+ void DialogHelper_DialogOpening(object sender, EventArgs e) => CloseDialog();
void CloseDialog()
{
+ DialogHelper.DialogOpening -= DialogHelper_DialogOpening;
+
childWindow.IsOpen = false;
ConfigurationManager.Current.IsChildWindowOpen = false;
ConfigurationManager.OnDialogClose();
}
+ DialogHelper.DialogOpening += DialogHelper_DialogOpening;
+
var childWindowViewModel = new RunCommandViewModel(instance =>
{
- CloseDialog();
+ // Ctrl+Enter: the dialog stays open to run another command.
+ if (!instance.KeepOpen)
+ CloseDialog();
if (instance.IsSettings)
NavigationManager.OpenSettings();
@@ -1381,6 +1392,16 @@ private IntPtr HwndHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref
return IntPtr.Zero;
}
+ // Hotkey --> Show window and open the run command
+ if (msg == WmHotkey && wParam.ToInt32() == 2)
+ {
+ ShowWindow();
+ OpenRunAction();
+ handled = true;
+
+ return IntPtr.Zero;
+ }
+
// Window size events
switch (msg)
{
@@ -1443,6 +1464,7 @@ private void UpdateOnWindowResize()
/* ID | Command
* ---|-------------------
* 1 | ShowWindow()
+ * 2 | ShowWindow() + OpenRunAction()
*/
private readonly List _registeredHotKeys = [];
@@ -1450,11 +1472,24 @@ private void UpdateOnWindowResize()
private void RegisterHotKeys()
{
if (SettingsManager.Current.HotKey_ShowWindowEnabled)
- {
- RegisterHotKey(new WindowInteropHelper(this).Handle, 1, SettingsManager.Current.HotKey_ShowWindowModifier,
+ RegisterHotKey(1, SettingsManager.Current.HotKey_ShowWindowModifier,
SettingsManager.Current.HotKey_ShowWindowKey);
- _registeredHotKeys.Add(1);
- }
+
+ if (SettingsManager.Current.HotKey_RunCommandEnabled)
+ RegisterHotKey(2, SettingsManager.Current.HotKey_RunCommandModifier,
+ SettingsManager.Current.HotKey_RunCommandKey);
+ }
+
+ ///
+ /// Registers a hotkey. It fails if the combination is already used (e.g. by the other hotkey or another
+ /// application), then it is logged and not unregistered later.
+ ///
+ private void RegisterHotKey(int id, int modifier, int key)
+ {
+ if (RegisterHotKey(new WindowInteropHelper(this).Handle, id, modifier, key))
+ _registeredHotKeys.Add(id);
+ else
+ Log.Warn($"Hotkey {id} could not be registered, the key combination is already in use.");
}
private void UnregisterHotKeys()
diff --git a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
index 792cf30b8d..a248a87516 100644
--- a/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
+++ b/Source/NETworkManager/Resources/Styles/ComboBoxStyles.xaml
@@ -1,6 +1,7 @@
+ xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
+ xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls">
\ No newline at end of file
diff --git a/Source/NETworkManager/RunCommandHint.cs b/Source/NETworkManager/RunCommandHint.cs
new file mode 100644
index 0000000000..c33659b01e
--- /dev/null
+++ b/Source/NETworkManager/RunCommandHint.cs
@@ -0,0 +1,15 @@
+namespace NETworkManager;
+
+///
+/// Part of the syntax hint in the run command dialog (e.g. "[DNS server]").
+///
+/// Text of the part.
+/// The part is being typed (highlighted).
+public sealed record RunCommandHintPart(string Text, bool IsCurrent);
+
+///
+/// Value of the input as it was understood (e.g. "DNS server: 1.1.1.1"), shown below the input.
+///
+/// Name of the value (e.g. "DNS server").
+/// Value (e.g. "1.1.1.1").
+public sealed record RunCommandChip(string Label, string Value);
diff --git a/Source/NETworkManager/RunCommandManager.cs b/Source/NETworkManager/RunCommandManager.cs
index 9bb85f8466..f591928c21 100644
--- a/Source/NETworkManager/RunCommandManager.cs
+++ b/Source/NETworkManager/RunCommandManager.cs
@@ -2,6 +2,7 @@
using NETworkManager.Localization;
using NETworkManager.Localization.Resources;
using NETworkManager.Models;
+using NETworkManager.Models.Lookup;
using NETworkManager.Models.Network;
using NETworkManager.Models.RDAP;
using NETworkManager.Models.RunCommand;
@@ -35,6 +36,59 @@ public static class RunCommandManager
["entity"] = RDAPQueryType.Entity
};
+ ///
+ /// Type keywords of the Lookup and their lookup.
+ ///
+ private static readonly Dictionary LookupTypes = new()
+ {
+ ["oui"] = LookupType.OUI,
+ ["port"] = LookupType.Port
+ };
+
+ ///
+ /// Applications that are suggested for a target typed without a command, in the order they are suggested (if
+ /// they were not used with this kind of target before).
+ ///
+ private static readonly Dictionary TargetApplications = new()
+ {
+ // The Port Scanner is not suggested, since it needs ports (or a port profile).
+ [RunCommandTargetKind.Host] =
+ [
+ ApplicationName.PingMonitor, ApplicationName.Traceroute, ApplicationName.DNSLookup,
+ ApplicationName.RemoteDesktop, ApplicationName.PuTTY, ApplicationName.PowerShell, ApplicationName.TigerVNC,
+ ApplicationName.IPGeolocation, ApplicationName.Whois, ApplicationName.RDAP, ApplicationName.SNMP,
+ ApplicationName.IPScanner
+ ],
+ // Without a domain (e.g. in a company network): no tools for public hosts (Whois, RDAP, IP Geolocation)
+ [RunCommandTargetKind.Hostname] =
+ [
+ ApplicationName.PingMonitor, ApplicationName.Traceroute, ApplicationName.DNSLookup,
+ ApplicationName.RemoteDesktop, ApplicationName.PuTTY, ApplicationName.PowerShell, ApplicationName.TigerVNC,
+ ApplicationName.SNMP, ApplicationName.IPScanner
+ ],
+ [RunCommandTargetKind.Hosts] = [ApplicationName.PingMonitor, ApplicationName.DNSLookup, ApplicationName.IPScanner],
+ [RunCommandTargetKind.IPRange] = [ApplicationName.IPScanner, ApplicationName.PingMonitor],
+ [RunCommandTargetKind.Subnet] =
+ [
+ ApplicationName.IPScanner, ApplicationName.SubnetCalculator, ApplicationName.PingMonitor,
+ ApplicationName.RDAP
+ ],
+ [RunCommandTargetKind.MACAddress] = [ApplicationName.WakeOnLAN, ApplicationName.Lookup],
+ [RunCommandTargetKind.OUI] = [ApplicationName.Lookup],
+ [RunCommandTargetKind.Ports] = [ApplicationName.Lookup],
+ [RunCommandTargetKind.ASN] = [ApplicationName.RDAP],
+ [RunCommandTargetKind.URL] = [ApplicationName.WebConsole]
+ };
+
+ ///
+ /// Remote tools, which open the connect dialog for a target (instead of connecting directly).
+ ///
+ private static readonly HashSet ConnectDialogApplications =
+ [
+ ApplicationName.RemoteDesktop, ApplicationName.PowerShell, ApplicationName.PuTTY, ApplicationName.TigerVNC,
+ ApplicationName.WebConsole
+ ];
+
///
/// Gets the commands of the visible applications and the settings.
///
@@ -57,6 +111,23 @@ public static IEnumerable GetDNSServers()
return SettingsManager.Current.DNSLookup_DNSServers.Select(x => x.Name);
}
+ ///
+ /// Gets the port profiles of the Port Scanner (e.g. "Webserver"), which can be used instead of ports.
+ ///
+ /// Port profiles.
+ public static IEnumerable GetPortProfiles()
+ {
+ return SettingsManager.Current.PortScanner_PortProfiles;
+ }
+
+ ///
+ /// Gets the port profile with the name (case-insensitive), or null if there is none.
+ ///
+ private static PortProfileInfo GetPortProfile(string name)
+ {
+ return GetPortProfiles().FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase));
+ }
+
///
/// Gets the translated name of the application (or settings) of a command.
///
@@ -84,6 +155,55 @@ public static IEnumerable GetProfiles(RunCommandDefinition command)
.OrderBy(x => x.Group).ThenBy(x => x.Name);
}
+ ///
+ /// Gets the commands that accept a target typed without a command (e.g. "ping 10.0.0.1", "trace 10.0.0.1" for
+ /// "10.0.0.1"). Only valid commands are returned (validated like the input).
+ ///
+ /// Target (e.g. "10.0.0.1").
+ /// Available commands.
+ /// Kind of the target if it is already known, otherwise it is detected.
+ /// Commands with the input to run, in the default order of the kind of the target.
+ public static IEnumerable<(RunCommandDefinition Command, string Text)> GetCommandsForTarget(string target,
+ IReadOnlyList commands, RunCommandTargetKind? kind = null)
+ {
+ kind ??= RunCommandTarget.GetKind(target);
+
+ if (!TargetApplications.TryGetValue(kind.Value, out var applications))
+ yield break;
+
+ foreach (var application in applications)
+ {
+ var command = commands.FirstOrDefault(x => x.Application == application);
+
+ // Hidden application
+ if (command == null)
+ continue;
+
+ // The shortest name (e.g. "ping" instead of "pingmonitor")
+ var name = command.Aliases.FirstOrDefault() ?? command.Command;
+
+ var text = application == ApplicationName.Lookup
+ ? $"{name} {(kind == RunCommandTargetKind.Ports ? "port" : "oui")} {target}"
+ : $"{name} {target}";
+
+ if (TryCreateRequest(RunCommandParser.Parse(text, commands), null, out _) == null)
+ yield return (command, text);
+ }
+ }
+
+ ///
+ /// Checks if the request opens a dialog, so the run command dialog must be closed: the settings or the connect
+ /// dialog of a remote tool. Messages of the applications (e.g. an error or a confirmation) close the run command
+ /// dialog when they are shown ().
+ ///
+ /// Request, or null for the settings.
+ /// True if the request opens a dialog.
+ public static bool OpensDialog(ApplicationRequest request)
+ {
+ return request == null || request.Profile == null && !string.IsNullOrEmpty(request.Target) &&
+ ConnectDialogApplications.Contains(request.Application);
+ }
+
///
/// Validates the input and creates the request to run.
///
@@ -99,6 +219,15 @@ public static string TryCreateRequest(RunCommandParseResult result, ProfileInfo
{
request = null;
+ // A profile selected in the suggestions is run with its settings, also if its name can't be typed (e.g. it
+ // starts with a quote, which is parsed as a quoted name).
+ if (profile != null && result.Command is { Type: RunCommandType.Application })
+ {
+ request = new ApplicationRequest(result.Command.Application) { Profile = profile };
+
+ return null;
+ }
+
var error = GetParseErrorMessage(result);
if (error != null)
@@ -112,6 +241,10 @@ public static string TryCreateRequest(RunCommandParseResult result, ProfileInfo
{
if (profile == null)
{
+ // The profiles are not loaded until the (encrypted) profile file is unlocked.
+ if (ConfigurationManager.Current.ProfileManagerShowUnlock)
+ return Strings.UnlockTheProfileFileMessage;
+
// The name is compared like it is parsed (without surrounding whitespace and quotes).
var profiles = GetProfiles(result.Command)
.Where(x => string.Equals(x.Name.Trim().Trim('"').Trim(), result.ProfileName,
@@ -134,10 +267,10 @@ public static string TryCreateRequest(RunCommandParseResult result, ProfileInfo
return null;
}
- // Only open the application
+ // Only open the application (Lookup: show the lookup of the type)
if (result.Target == null)
{
- request = new ApplicationRequest(result.Command.Application);
+ request = result.Type != null ? CreateRequest(result) : new ApplicationRequest(result.Command.Application);
return null;
}
@@ -186,6 +319,8 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT
ApplicationName.Whois => new DomainValidator(),
ApplicationName.IPGeolocation => new IPAddressOrHostnameValidator(),
ApplicationName.WakeOnLAN => new MACAddressValidator(),
+ ApplicationName.SubnetCalculator => new IPv4IPv6SubnetValidator(),
+ ApplicationName.Lookup => new NotEndWithSemicolonValidator(),
_ => null
};
@@ -194,6 +329,10 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT
if (error != null)
return error;
+ // The Port Scanner needs ports (or a port profile), a new tab has none.
+ if (result.Command.Argument == RunCommandArgument.Ports && result.Argument == null)
+ return Strings.EnterValidPortOrPortRange;
+
if (result.Argument != null)
{
ValidationRule argumentValidator = result.Command.Argument switch
@@ -203,9 +342,14 @@ ApplicationName.Traceroute or ApplicationName.PowerShell or ApplicationName.PuTT
_ => null
};
- error = result.Command.Argument == RunCommandArgument.DNSServer
- ? ValidateDNSServer(result.Argument)
- : Validate(argumentValidator, result.Argument);
+ error = result.Command.Argument switch
+ {
+ RunCommandArgument.DNSServer => ValidateDNSServer(result.Argument),
+ // A port profile (e.g. "Webserver") or ports
+ RunCommandArgument.Ports when GetPortProfile(result.Argument) != null => null,
+ RunCommandArgument.Subnet => ValidateSubnetArgument(result.Target, result.Argument),
+ _ => Validate(argumentValidator, result.Argument)
+ };
if (error != null)
return error;
@@ -244,6 +388,29 @@ private static string ValidateDNSServer(string dnsServer)
return null;
}
+ ///
+ /// Validates the second argument of the Subnet Calculator: a new subnet mask or CIDR (Subnetting), which must be
+ /// between the CIDR of the subnet and the maximum of the address family, or a second subnet (Wide Subnet, IPv4
+ /// only like in the application).
+ ///
+ private static string ValidateSubnetArgument(string subnet, string argument)
+ {
+ if (!IsSubnetmaskOrCidr(argument))
+ return Validate(new IPv4SubnetValidator(), subnet) ?? Validate(new IPv4SubnetValidator(), argument);
+
+ var (minCidr, maxCidr) = Subnetmask.GetSubnettingCidrRange(IPNetwork2.Parse(subnet.Trim()));
+ var newCidr = Subnetmask.ConvertSubnetmaskOrCidrToCidr(argument);
+
+ return newCidr >= minCidr && newCidr <= maxCidr
+ ? null
+ : string.Format(Strings.EnterSubnetmaskOrCIDRBetweenXAndY, minCidr, maxCidr);
+ }
+
+ private static bool IsSubnetmaskOrCidr(string value)
+ {
+ return Validate(new IPv4IPv6SubnetmaskOrCIDRValidator(), value) == null;
+ }
+
private static string Validate(ValidationRule validator, string value)
{
if (validator == null)
@@ -270,9 +437,19 @@ private static ApplicationRequest CreateRequest(RunCommandParseResult result)
// 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 },
+ // A port profile is used with its ports.
+ ApplicationName.PortScanner => new PortScannerRequest
+ {
+ Target = result.Target,
+ Ports = GetPortProfile(result.Argument)?.Ports ?? result.Argument
+ },
ApplicationName.WakeOnLAN => new WakeOnLANRequest { Target = result.Target, Broadcast = result.Argument },
ApplicationName.RDAP => new RDAPRequest { Target = result.Target, QueryType = GetRDAPQueryType(result.Type) },
+ // The argument is a new subnet mask (Subnetting) or a second subnet (Wide Subnet).
+ ApplicationName.SubnetCalculator => result.Argument != null && IsSubnetmaskOrCidr(result.Argument)
+ ? new SubnetCalculatorRequest { Target = result.Target, NewSubnetmask = result.Argument }
+ : new SubnetCalculatorRequest { Target = result.Target, Subnet2 = result.Argument },
+ ApplicationName.Lookup => new LookupRequest { Target = result.Target, Type = LookupTypes[result.Type] },
_ => new ApplicationRequest(result.Command.Application) { Target = result.Target }
};
}
@@ -288,35 +465,54 @@ 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}]";
+ // Parts of the syntax, e.g. " [Ports]"
+ static RunCommandSyntaxPart Target(string name) =>
+ RunCommandSyntaxPart.Required(RunCommandSyntaxPartKind.Target, name);
+
+ static RunCommandSyntaxPart Argument(string name) =>
+ RunCommandSyntaxPart.Optional(RunCommandSyntaxPartKind.Argument, name);
+
+ var type = RunCommandSyntaxPart.Optional(RunCommandSyntaxPartKind.Type, Strings.Type);
var commands = ApplicationManager.GetNames().Where(x => x != ApplicationName.None).Select(name => name switch
{
- ApplicationName.IPScanner => Create(name, ["scan"], Required(Strings.IPRange), true),
+ ApplicationName.IPScanner => Create(name, ["ipscan"], [Target(Strings.IPRange)]),
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),
+ [
+ Target(Strings.Hosts),
+ RunCommandSyntaxPart.Required(RunCommandSyntaxPartKind.Argument,
+ $"{Strings.Ports} | {Strings.PortProfile}")
+ ], RunCommandArgument.Ports),
+ ApplicationName.PingMonitor => Create(name, ["ping"], [Target(Strings.Hosts)]),
+ ApplicationName.Traceroute => Create(name, ["trace", "tracert"], [Target(Strings.Host)]),
ApplicationName.DNSLookup => Create(name, ["dns", "nslookup"],
- $"{Required(Strings.Hosts)} {Optional(Strings.DNSServer)} {Optional(Strings.Type)}", true,
- RunCommandArgument.DNSServer,
+ [Target(Strings.Hosts), Argument(Strings.DNSServer), type], RunCommandArgument.DNSServer,
[.. DNSLookup.QueryTypes.Select(x => x.ToString().ToLowerInvariant()).Order()]),
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),
- 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)
+ [
+ new RunCommandSyntaxPart(RunCommandSyntaxPartKind.Target, Strings.Host,
+ $"<{Strings.Host}>[:<{Strings.Port}>]")
+ ]),
+ ApplicationName.PowerShell => Create(name, ["ps", "pwsh"], [Target(Strings.Host)]),
+ ApplicationName.PuTTY => Create(name, ["ssh"], [Target(Strings.Host)]),
+ ApplicationName.TigerVNC => Create(name, ["vnc"], [Target(Strings.Host)]),
+ ApplicationName.WebConsole => Create(name, ["web"], [Target(Strings.URL)]),
+ ApplicationName.SNMP => Create(name, [], [Target(Strings.Host)]),
+ ApplicationName.RDAP => Create(name, [], [Target(Strings.Query), type], types: [.. RDAPTypes.Keys]),
+ ApplicationName.Whois => Create(name, [], [Target(Strings.Domain)]),
+ ApplicationName.IPGeolocation => Create(name, ["geo"], [Target(Strings.Host)]),
+ ApplicationName.WakeOnLAN => Create(name, ["wol"], [Target(Strings.MACAddress), Argument(Strings.Broadcast)],
+ RunCommandArgument.Broadcast),
+ ApplicationName.SubnetCalculator => Create(name, ["subnet", "ipcalc"],
+ [Target(Strings.Subnet), Argument($"{Strings.NewSubnetMask} | {Strings.Subnet2}")],
+ RunCommandArgument.Subnet, supportsProfile: false),
+ ApplicationName.Lookup => Create(name, [],
+ [
+ new RunCommandSyntaxPart(RunCommandSyntaxPartKind.Type, Strings.Type,
+ $"<{string.Join("|", LookupTypes.Keys)}>"),
+ Target(Strings.Query)
+ ], types: [.. LookupTypes.Keys], supportsProfile: false, typeBeforeTarget: true),
+ _ => Create(name, [], null)
}).ToList();
commands.Add(new RunCommandDefinition
@@ -329,9 +525,25 @@ [.. DNSLookup.QueryTypes.Select(x => x.ToString().ToLowerInvariant()).Order()]),
return commands;
}
- private static RunCommandDefinition Create(ApplicationName application, string[] aliases, string syntax,
- bool supportsTarget, RunCommandArgument argument = RunCommandArgument.None, string[] types = null)
+ ///
+ /// Creates the command of an application.
+ ///
+ /// Application.
+ /// Short forms of the command.
+ /// Syntax of the arguments, or null if the command accepts no target.
+ /// Second argument after the target.
+ /// Type keywords.
+ /// The application has profiles (only with a target).
+ /// The type keyword comes before the target.
+ private static RunCommandDefinition Create(ApplicationName application, string[] aliases,
+ RunCommandSyntaxPart[] syntaxParts, RunCommandArgument argument = RunCommandArgument.None,
+ string[] types = null, bool supportsProfile = true, bool typeBeforeTarget = false)
{
+ var supportsTarget = syntaxParts != null;
+
+ // Applications that accept a target support profiles, unless they have none (e.g. Subnet Calculator).
+ supportsProfile &= supportsTarget;
+
return new RunCommandDefinition
{
Type = RunCommandType.Application,
@@ -339,11 +551,13 @@ private static RunCommandDefinition Create(ApplicationName application, string[]
Command = application.ToString().ToLowerInvariant(),
Aliases = aliases,
SupportsTarget = supportsTarget,
- // Every application that accepts a target also supports profiles.
- SupportsProfile = supportsTarget,
+ SupportsProfile = supportsProfile,
Argument = argument,
Types = types ?? [],
- Syntax = supportsTarget ? $"{syntax} | @<{Strings.Profile}>" : string.Empty
+ TypeBeforeTarget = typeBeforeTarget,
+ SyntaxParts = supportsProfile
+ ? [.. syntaxParts, RunCommandSyntaxPart.Profile(Strings.Profile)]
+ : syntaxParts ?? []
};
}
}
diff --git a/Source/NETworkManager/RunCommandRunMode.cs b/Source/NETworkManager/RunCommandRunMode.cs
new file mode 100644
index 0000000000..9254a23c10
--- /dev/null
+++ b/Source/NETworkManager/RunCommandRunMode.cs
@@ -0,0 +1,22 @@
+namespace NETworkManager;
+
+///
+/// How the run command dialog runs a command.
+///
+public enum RunCommandRunMode
+{
+ ///
+ /// Run the command and close the dialog (Enter).
+ ///
+ Run,
+
+ ///
+ /// Run the command and keep the dialog open to run another one (Ctrl+Enter), unless the command opens a dialog.
+ ///
+ RunAndKeepOpen,
+
+ ///
+ /// Open the application with the input filled in, without starting it (Shift+Enter).
+ ///
+ FillIn
+}
diff --git a/Source/NETworkManager/RunCommandSuggestion.cs b/Source/NETworkManager/RunCommandSuggestion.cs
index cd2d32bd85..57ce6dda23 100644
--- a/Source/NETworkManager/RunCommandSuggestion.cs
+++ b/Source/NETworkManager/RunCommandSuggestion.cs
@@ -8,11 +8,6 @@ namespace NETworkManager;
///
public enum RunCommandSuggestionKind
{
- ///
- /// Run the input as typed.
- ///
- Run,
-
///
/// Command of an application (or the settings).
///
@@ -36,7 +31,27 @@ public enum RunCommandSuggestionKind
///
/// DNS server configured in the DNS lookup.
///
- DNSServer
+ DNSServer,
+
+ ///
+ /// Port profile of the Port Scanner (e.g. "Webserver").
+ ///
+ PortProfile,
+
+ ///
+ /// Command for a target typed without a command (e.g. "ping 10.0.0.1" for "10.0.0.1").
+ ///
+ Target,
+
+ ///
+ /// Command for the target in the clipboard (e.g. "ping 10.0.0.1").
+ ///
+ Clipboard,
+
+ ///
+ /// Example command (empty input without history).
+ ///
+ Example
}
///
@@ -53,6 +68,16 @@ public sealed class RunCommandSuggestion
public string Title { get; init; }
+ ///
+ /// Typed text that is highlighted in the title and description.
+ ///
+ public string Highlight { get; init; }
+
+ ///
+ /// Header of the group in the list (e.g. "Recent"), null for no header.
+ ///
+ public string Group { get; init; }
+
///
/// Second line (e.g. group and host of a profile).
///
diff --git a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs
index 1fecad4366..1d95cb4978 100644
--- a/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/DNSLookupHostViewModel.cs
@@ -157,15 +157,16 @@ private static void CloseItemAction(ItemActionCallbackArgs args)
/// 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)
+ /// Starts it on the first load. If false, the input is only filled in.
+ public void AddTab(string host = null, string dnsServer = null, QueryType? queryType = null, bool start = true)
{
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),
- tabId));
+ TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab,
+ new DNSLookupView(tabId, host, dnsServer, queryType, start), tabId));
SelectedTabIndex = TabItems.Count - 1;
}
@@ -185,9 +186,10 @@ private static bool IsIPAddresses(string hosts)
/// Adds a new tab for the host of the specified profile.
///
/// The profile to lookup.
- public void AddTab(ProfileInfo profile)
+ /// Starts it on the first load. If false, the input is only filled in.
+ public void AddTab(ProfileInfo profile, bool start = true)
{
- AddTab(profile.DNSLookup_Host);
+ AddTab(profile.DNSLookup_Host, start: start);
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
index ac2b0d9122..5233973d29 100644
--- a/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
+++ b/Source/NETworkManager/ViewModels/DNSLookupViewModel.cs
@@ -250,8 +250,12 @@ private set
/// The host to lookup.
/// 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)
+ /// Starts it on the first load. If false, the input is only filled in.
+ public DNSLookupViewModel(Guid tabId, string host, string dnsServer = null, QueryType? queryType = null,
+ bool start = true)
{
+ _firstLoad = start;
+
_isLoading = true;
ConfigurationManager.Current.DNSLookupTabCount++;
diff --git a/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs b/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs
index 3b1e44685a..cae5201d56 100644
--- a/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPGeolocationHostViewModel.cs
@@ -147,12 +147,13 @@ private static void CloseItemAction(ItemActionCallbackArgs args)
/// Adds a new tab for the specified domain.
///
/// The domain to query.
- public void AddTab(string domain = null)
+ /// Starts it on the first load. If false, the input is only filled in.
+ public void AddTab(string domain = null, bool start = true)
{
var tabId = Guid.NewGuid();
TabItems.Add(new DragablzTabItem(domain ?? Strings.NewTab,
- new IPGeolocationView(tabId, domain), tabId));
+ new IPGeolocationView(tabId, domain, start), tabId));
SelectedTabIndex = TabItems.Count - 1;
}
@@ -161,9 +162,10 @@ public void AddTab(string domain = null)
/// Adds a new tab for the host of the specified profile.
///
/// The profile to query.
- public void AddTab(ProfileInfo profile)
+ /// Starts it on the first load. If false, the input is only filled in.
+ public void AddTab(ProfileInfo profile, bool start = true)
{
- AddTab(profile.IPGeolocation_Host);
+ AddTab(profile.IPGeolocation_Host, start: start);
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs b/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs
index ea79459e4e..8f24b9eabd 100644
--- a/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPGeolocationViewModel.cs
@@ -141,8 +141,11 @@ private set
///
/// The ID of the tab.
/// The host to query.
- public IPGeolocationViewModel(Guid tabId, string host)
+ /// Starts it on the first load. If false, the input is only filled in.
+ public IPGeolocationViewModel(Guid tabId, string host, bool start = true)
{
+ _firstLoad = start;
+
ConfigurationManager.Current.IPGeolocationTabCount++;
_tabId = tabId;
diff --git a/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs
index 4808a0e2c9..0667ecd3df 100644
--- a/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerHostViewModel.cs
@@ -147,11 +147,12 @@ private static void CloseItemAction(ItemActionCallbackArgs args)
/// Adds a new tab for the specified host or IP range.
///
/// The host or IP range to scan.
- public void AddTab(string hostOrIPRange = null)
+ /// Starts it on the first load. If false, the input is only filled in.
+ public void AddTab(string hostOrIPRange = null, bool start = true)
{
var tabId = Guid.NewGuid();
- TabItems.Add(new DragablzTabItem(Strings.NewTab, new IPScannerView(tabId, hostOrIPRange),
+ TabItems.Add(new DragablzTabItem(Strings.NewTab, new IPScannerView(tabId, hostOrIPRange, start),
tabId));
SelectedTabIndex = TabItems.Count - 1;
@@ -161,9 +162,10 @@ public void AddTab(string hostOrIPRange = null)
/// Adds a new tab for the specified profile.
///
/// The profile to scan.
- public void AddTab(ProfileInfo profile)
+ /// Starts it on the first load. If false, the input is only filled in.
+ public void AddTab(ProfileInfo profile, bool start = true)
{
- AddTab(profile.IPScanner_HostOrIPRange);
+ AddTab(profile.IPScanner_HostOrIPRange, start: start);
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index d4fe8eeafc..ca92e67b44 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -304,8 +304,11 @@ private set
///
/// The unique identifier for the tab.
/// The initial host or IP range to scan.
- public IPScannerViewModel(Guid tabId, string hostOrIPRange)
+ /// Starts it on the first load. If false, the input is only filled in.
+ public IPScannerViewModel(Guid tabId, string hostOrIPRange, bool start = true)
{
+ _firstLoad = start;
+
ConfigurationManager.Current.IPScannerTabCount++;
_tabId = tabId;
diff --git a/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs b/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs
index 9ae439de75..0a3ca4a7ff 100644
--- a/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs
+++ b/Source/NETworkManager/ViewModels/LookupOUILookupViewModel.cs
@@ -194,6 +194,23 @@ private bool OUILookup_CanExecute(object parameter)
!HasError;
}
+ ///
+ /// Looks up MAC addresses or vendors (e.g. from the run command).
+ ///
+ /// MAC addresses or vendors, separated by ";".
+ /// Starts the lookup. If false, the search is only filled in.
+ public void Lookup(string search, bool start = true)
+ {
+ // Not while a lookup is running (the results would be mixed).
+ if (IsRunning)
+ return;
+
+ Search = search;
+
+ if (start)
+ OUILookupAction();
+ }
+
///
/// Performs the OUI lookup.
///
@@ -211,6 +228,10 @@ private async void OUILookupAction()
{
var searchTrim = search.Trim();
+ // Empty entry (e.g. "Cisco;;Juniper"), which would find every vendor
+ if (searchTrim.Length == 0)
+ continue;
+
if (Regex.IsMatch(searchTrim, RegexHelper.MACAddressRegex) ||
Regex.IsMatch(searchTrim, RegexHelper.MACAddressFirst3BytesRegex))
macAddresses.Add(searchTrim);
diff --git a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs
index 955e6f8ee8..7a5cda2a85 100644
--- a/Source/NETworkManager/ViewModels/LookupPortViewModel.cs
+++ b/Source/NETworkManager/ViewModels/LookupPortViewModel.cs
@@ -14,7 +14,6 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
-using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Data;
@@ -192,6 +191,22 @@ private bool PortLookup_CanExecute(object parameter)
!ConfigurationManager.Current.IsChildWindowOpen && !HasError;
}
+ ///
+ /// Looks up ports or services (e.g. from the run command).
+ ///
+ /// Ports, port ranges or services, separated by ";".
+ /// Starts the lookup. If false, the search is only filled in.
+ public Task Lookup(string search, bool start = true)
+ {
+ // Not while a lookup is running (the results would be mixed).
+ if (IsRunning)
+ return Task.CompletedTask;
+
+ Search = search;
+
+ return start ? PortLookupAction() : Task.CompletedTask;
+ }
+
///
/// Performs the port lookup.
///
@@ -209,22 +224,26 @@ private async Task PortLookupAction()
{
var searchTrim = search.Trim();
+ // Empty entry (e.g. "22;;80"), which would find every service
+ if (searchTrim.Length == 0)
+ continue;
+
// Check if the search is a port number like 22
- if (Regex.IsMatch(searchTrim, "^[0-9]{1,5}$"))
+ if (RegexHelper.PortLookupPortRegex().IsMatch(searchTrim))
if (int.TryParse(searchTrim, out var port))
- if (port is > 0 and < 65536)
+ if (PortRangeHelper.IsValidPort(port))
{
ports.Add(port);
continue;
}
// Check if the search is a port number with protocol like 22/tcp
- if (Regex.IsMatch(searchTrim, "^[0-9]{1,5}/(tcp|udp|sctp)$"))
+ if (RegexHelper.PortLookupPortAndProtocolRegex().IsMatch(searchTrim))
{
var portAndProtocol = searchTrim.Split('/');
if (int.TryParse(portAndProtocol[0], out var port))
- if (port is > 0 and < 65536)
+ if (PortRangeHelper.IsValidPort(port))
{
portsAndProtocols.Add(new Tuple(port, portAndProtocol[1]));
continue;
@@ -232,12 +251,12 @@ private async Task PortLookupAction()
}
// Check if the search is a port range like 1-100
- if (Regex.IsMatch(searchTrim, "^[0-9]{1,5}-[0-9]{1,5}$"))
+ if (RegexHelper.PortLookupPortRangeRegex().IsMatch(searchTrim))
{
var portRange = searchTrim.Split('-');
if (int.TryParse(portRange[0], out var startPort) && int.TryParse(portRange[1], out var endPort))
- if (startPort is > 0 and < 65536 && endPort is > 0 and < 65536 && startPort < endPort)
+ if (PortRangeHelper.IsValidPortRange(startPort, endPort))
{
for (var i = startPort; i < endPort + 1; i++)
ports.Add(i);
@@ -247,14 +266,14 @@ private async Task PortLookupAction()
}
// Check if the search is a port range with protocol like 1-100/tcp
- if (Regex.IsMatch(searchTrim, "^[0-9]{1,5}-[0-9]{1,5}/(tcp|udp|sctp)$"))
+ if (RegexHelper.PortLookupPortRangeAndProtocolRegex().IsMatch(searchTrim))
{
var portRangeAndProtocol = searchTrim.Split('/');
var portRange = portRangeAndProtocol[0].Split('-');
if (int.TryParse(portRange[0], out var startPort) && int.TryParse(portRange[1], out var endPort))
- if (startPort is > 0 and < 65536 && endPort is > 0 and < 65536 && startPort < endPort)
+ if (PortRangeHelper.IsValidPortRange(startPort, endPort))
{
for (var i = startPort; i < endPort + 1; i++)
portsAndProtocols.Add(new Tuple(i, portRangeAndProtocol[1]));
diff --git a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
index 28a7e46956..7e3b50c6d3 100644
--- a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
@@ -264,9 +264,10 @@ private void PauseGroupAction(object group)
/// Adds the host(s) and starts the ping monitor.
///
/// Host(s) to ping.
- public void AddHost(string host)
+ /// Starts the ping monitor. If false, the host is only filled in.
+ public void AddHost(string host, bool start = true)
{
- if (SetHost(host))
+ if (SetHost(host) && start)
_ = Start();
}
@@ -274,9 +275,10 @@ public void AddHost(string host)
/// Adds the host(s) of the profile to its group and starts the ping monitor.
///
/// Profile to ping.
- public void AddHost(ProfileInfo profile)
+ /// Starts the ping monitor. If false, the host is only filled in.
+ public void AddHost(ProfileInfo profile, bool start = true)
{
- if (SetHost(profile.PingMonitor_Host, profile.Group))
+ if (SetHost(profile.PingMonitor_Host, profile.Group) && start)
_ = Start();
}
diff --git a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs
index a83df00221..7e86dae89d 100644
--- a/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerHostViewModel.cs
@@ -136,19 +136,20 @@ private static void CloseItemAction(ItemActionCallbackArgs args)
///
/// The host to scan.
/// The ports to scan.
- public void AddTab(string host = null, string ports = null)
+ /// Starts it on the first load. If false, the input is only filled in.
+ public void AddTab(string host = null, string ports = null, bool start = true)
{
var tabId = Guid.NewGuid();
TabItems.Add(new DragablzTabItem(string.IsNullOrEmpty(host) ? Strings.NewTab : host,
- new PortScannerView(tabId, host, ports), tabId));
+ new PortScannerView(tabId, host, ports, start), tabId));
SelectedTabIndex = TabItems.Count - 1;
}
- public void AddTab(ProfileInfo profile)
+ public void AddTab(ProfileInfo profile, bool start = true)
{
- AddTab(profile.PortScanner_Host, profile.PortScanner_Ports);
+ AddTab(profile.PortScanner_Host, profile.PortScanner_Ports, start: start);
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index d455551963..25de7c9763 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -301,8 +301,11 @@ private set
/// The unique identifier for the tab.
/// The initial host to scan.
/// The initial ports to scan.
- public PortScannerViewModel(Guid tabId, string host, string port)
+ /// Starts it on the first load. If false, the input is only filled in.
+ public PortScannerViewModel(Guid tabId, string host, string port, bool start = true)
{
+ _firstLoad = start;
+
ConfigurationManager.Current.PortScannerTabCount++;
_tabId = tabId;
diff --git a/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs b/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs
index 84ccc2acfa..eadb7b0d7b 100644
--- a/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/RDAPHostViewModel.cs
@@ -113,14 +113,15 @@ private static void CloseItemAction(ItemActionCallbackArgs args)
/// Type of the query. If null, the type is detected from the query (, e.g. data
/// redirected from another tool or a run command), or the last selected type is used for an empty tab.
///
- public void AddTab(string query = null, RDAPQueryType? queryType = null)
+ /// Starts it on the first load. If false, the input is only filled in.
+ public void AddTab(string query = null, RDAPQueryType? queryType = null, bool start = true)
{
if (queryType == null && !string.IsNullOrWhiteSpace(query))
queryType = RDAPQueryType.Auto;
var tabId = Guid.NewGuid();
- TabItems.Add(new DragablzTabItem(query ?? Strings.NewTab, new RDAPView(tabId, query, queryType),
+ TabItems.Add(new DragablzTabItem(query ?? Strings.NewTab, new RDAPView(tabId, query, queryType, start),
tabId));
SelectedTabIndex = TabItems.Count - 1;
@@ -130,9 +131,10 @@ public void AddTab(string query = null, RDAPQueryType? queryType = null)
/// Adds a new tab and starts the query of the profile.
///
/// Profile to query.
- public void AddTab(ProfileInfo profile)
+ /// Starts it on the first load. If false, the input is only filled in.
+ public void AddTab(ProfileInfo profile, bool start = true)
{
- AddTab(profile.RDAP_Query, profile.RDAP_QueryType);
+ AddTab(profile.RDAP_Query, profile.RDAP_QueryType, start: start);
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/RDAPViewModel.cs b/Source/NETworkManager/ViewModels/RDAPViewModel.cs
index 33f052ebb6..487560ab72 100644
--- a/Source/NETworkManager/ViewModels/RDAPViewModel.cs
+++ b/Source/NETworkManager/ViewModels/RDAPViewModel.cs
@@ -200,8 +200,10 @@ public bool IsOpenInWhoisVisible
#region Contructor, load settings
- public RDAPViewModel(Guid tabId, string query, RDAPQueryType? queryType)
+ public RDAPViewModel(Guid tabId, string query, RDAPQueryType? queryType, bool start = true)
{
+ _firstLoad = start;
+
_isLoading = true;
ConfigurationManager.Current.RDAPTabCount++;
diff --git a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
index d3868fd400..36b2a4e59b 100644
--- a/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
+++ b/Source/NETworkManager/ViewModels/RunCommandViewModel.cs
@@ -1,24 +1,34 @@
using NETworkManager.Localization.Resources;
+using NETworkManager.Models;
using NETworkManager.Models.RunCommand;
+using NETworkManager.Controls;
using NETworkManager.Profiles;
using NETworkManager.Settings;
using NETworkManager.Utilities;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.ComponentModel;
using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text.RegularExpressions;
+using System.Windows;
+using System.Windows.Data;
using System.Windows.Input;
+using System.Windows.Threading;
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.
+/// View model of the run command dialog: type a command (e.g. "ping 10.0.0.1") or only a target (e.g. "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.
+ /// Maximum number of suggestions per group shown for a target or argument (profiles, history, types, DNS
+ /// servers), so a large group (e.g. the DNS record types) doesn't hide the others. The commands are always shown
+ /// completely.
///
private const int MaxSuggestions = 10;
@@ -32,13 +42,46 @@ public class RunCommandViewModel : ViewModelBase
///
private const int MaxCommandHistorySuggestions = 3;
+ ///
+ /// Maximum number of commands suggested for the clipboard content (empty input).
+ ///
+ private const int MaxClipboardSuggestions = 3;
+
+ ///
+ /// Number of suggestions that PageUp and PageDown move the selection.
+ ///
+ private const int SuggestionsPerPage = 5;
+
+ ///
+ /// Maximum length of the clipboard content that is suggested as target.
+ ///
+ private const int MaxClipboardLength = 255;
+
+ ///
+ /// Time without typing after which the input is validated.
+ ///
+ private static readonly TimeSpan ValidationDelay = TimeSpan.FromMilliseconds(300);
+
+ ///
+ /// Examples shown with an empty input if there are no recent commands yet (only of available commands).
+ ///
+ private static readonly string[] ExampleCommands =
+ [
+ "ping 10.0.0.1",
+ "trace borntoberoot.net",
+ "dns borntoberoot.net 1.1.1.1 aaaa",
+ "subnet 10.0.0.0/16 /24",
+ "lookup port 22; 443"
+ ];
+
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).
+ /// Recent commands (parsed once, the history only changes while the dialog is open if a command is run and the
+ /// dialog is kept open, or an entry is removed).
///
- private readonly List<(string Text, RunCommandDefinition Command)> _history;
+ private readonly List _history;
///
/// Names (command and aliases) and translated name per command.
@@ -50,12 +93,43 @@ public class RunCommandViewModel : ViewModelBase
///
private readonly Dictionary> _profiles = [];
+ ///
+ /// Content of the clipboard if it is a target (e.g. an IP address) and the setting is enabled, otherwise null.
+ ///
+ private readonly string _clipboardTarget;
+
+ ///
+ /// Commands for the clipboard content (validated once, the clipboard is only read when the dialog is opened).
+ ///
+ private readonly List<(RunCommandDefinition Command, string Text)> _clipboardCommands = [];
+
+ private readonly RunCommandTargetKind _clipboardKind;
+
+ private readonly DispatcherTimer _validationTimer;
+
private RunCommandParseResult _result;
+ ///
+ /// Command that Enter runs for a target typed without a command (e.g. "ping 10.0.0.1" for "10.0.0.1"): the first
+ /// command for the target, also if it is shown as a recent command. Null if there is none.
+ ///
+ private string _targetOnlyPreselection;
+
+ ///
+ /// Recent command with the parsed values used for the suggestions.
+ ///
+ private sealed record HistoryEntry(
+ string Text,
+ RunCommandDefinition Command,
+ string ProfileName,
+ RunCommandTargetKind TargetKind);
+
///
/// Initializes a new instance of the class.
///
- /// Called with the request to run (, ).
+ ///
+ /// Called with the request to run (, , ).
+ ///
/// Called when the dialog is canceled.
public RunCommandViewModel(Action runHandler, Action cancelHandler)
{
@@ -71,10 +145,33 @@ public RunCommandViewModel(Action runHandler, Action (x, RunCommandParser.Parse(x, _commands).Command))
- .Where(x => x.Command != null)
+ .. SettingsManager.Current.RunCommand_History
+ // The setting may have been lowered (the history is trimmed with the next run).
+ .Take(SettingsManager.Current.RunCommand_HistoryEntries)
+ .Select(x => (Text: x, Result: RunCommandParser.Parse(x, _commands)))
+ .Where(x => x.Result.Command != null)
+ .Select(x => CreateHistoryEntry(x.Text, x.Result))
];
+ if (SettingsManager.Current.RunCommand_SuggestClipboard)
+ _clipboardTarget = GetClipboardTarget();
+
+ if (_clipboardTarget != null)
+ {
+ _clipboardKind = RunCommandTarget.GetKind(_clipboardTarget);
+ _clipboardCommands = [.. RunCommandManager.GetCommandsForTarget(_clipboardTarget, _commands, _clipboardKind)];
+ }
+
+ SuggestionsView = CollectionViewSource.GetDefaultView(Suggestions);
+ SuggestionsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(RunCommandSuggestion.Group)));
+
+ _validationTimer = new DispatcherTimer { Interval = ValidationDelay };
+ _validationTimer.Tick += (_, _) =>
+ {
+ _validationTimer.Stop();
+ Validate();
+ };
+
CancelCommand = new RelayCommand(_ => cancelHandler(this));
UpdateSuggestions();
@@ -84,6 +181,12 @@ .. SettingsManager.Current.RunCommand_History.Select(x => (x, RunCommandParser.P
public ICommand CancelCommand { get; }
+ ///
+ /// Placeholder of the input, a random line each time the dialog is opened (e.g. "Ping it, trace it, scan
+ /// it...").
+ ///
+ public string Watermark { get; } = GetRandomWatermark();
+
///
/// Input of the user (e.g. "dns borntoberoot.net aaaa").
///
@@ -98,12 +201,19 @@ public string Input
field = value;
OnPropertyChanged();
- ErrorMessage = null;
UpdateSuggestions();
}
} = string.Empty;
- public ObservableCollection Suggestions { get; } = [];
+ ///
+ /// Suggestions, replaced completely on every input (one notification for the grouped view).
+ ///
+ public ObservableRangeCollection Suggestions { get; } = [];
+
+ ///
+ /// Suggestions grouped by (e.g. "Recent", "Profiles").
+ ///
+ public ICollectionView SuggestionsView { get; }
public RunCommandSuggestion SelectedSuggestion
{
@@ -115,11 +225,53 @@ public RunCommandSuggestion SelectedSuggestion
field = value;
OnPropertyChanged();
+ OnPropertyChanged(nameof(CanRemoveSelectedSuggestion));
+
+ // The validation shows what Enter runs (the selected suggestion or the input).
+ RestartValidation();
}
}
///
- /// Syntax of the recognized command (e.g. "dnslookup <hosts> [server] [type] | @<profile>").
+ /// The selected suggestion is a recent command, which can be removed from the history (Shift+Del).
+ ///
+ public bool CanRemoveSelectedSuggestion => SelectedSuggestion?.Kind == RunCommandSuggestionKind.History;
+
+ ///
+ /// A command is recognized (its icon is shown in front of the input).
+ ///
+ public bool HasCommand
+ {
+ get;
+ private set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Application of the recognized command ( for the settings).
+ ///
+ public ApplicationName CommandApplication
+ {
+ get;
+ private set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Syntax of the recognized command (e.g. "dnslookup <hosts> [server] [type] | @<profile>"),
+ /// null if no command is recognized.
///
public string Hint
{
@@ -135,9 +287,75 @@ private set
}
///
- /// Error of the last run (e.g. invalid host), null if there is none.
+ /// Parts of the , the part that is being typed is highlighted.
+ ///
+ public IReadOnlyList HintParts
+ {
+ get;
+ private set
+ {
+ field = value;
+ OnPropertyChanged();
+ }
+ } = [];
+
+ ///
+ /// Values of the input as they were understood (e.g. hosts, DNS server, type).
+ ///
+ public IReadOnlyList Chips
+ {
+ get;
+ private set
+ {
+ field = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(HasChips));
+ }
+ } = [];
+
+ public bool HasChips => Chips.Count > 0;
+
+ ///
+ /// Show the key hints at the bottom of the dialog (setting).
+ ///
+ public bool ShowKeyHints { get; } = SettingsManager.Current.RunCommand_ShowKeyHints;
+
+ ///
+ /// What Enter runs is valid (checked while typing).
+ ///
+ public bool IsValid
+ {
+ get;
+ private set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// What Enter runs is not valid (checked while typing), see .
///
- public string ErrorMessage
+ public bool IsInvalid
+ {
+ get;
+ private set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Error of the validation while typing (tooltip of the warning icon), null if there is none.
+ ///
+ public string ValidationMessage
{
get;
private set
@@ -160,6 +378,12 @@ private set
///
public bool IsSettings { get; private set; }
+ ///
+ /// The dialog stays open after the run (set before the run handler is called), see
+ /// .
+ ///
+ public bool KeepOpen { get; private set; }
+
#endregion
#region Methods
@@ -180,6 +404,22 @@ public void SelectPrevious()
Select(-1);
}
+ ///
+ /// Selects the suggestion one page below (PageDown).
+ ///
+ public void SelectNextPage()
+ {
+ Select(SuggestionsPerPage);
+ }
+
+ ///
+ /// Selects the suggestion one page above (PageUp).
+ ///
+ public void SelectPreviousPage()
+ {
+ Select(-SuggestionsPerPage);
+ }
+
private void Select(int offset)
{
if (Suggestions.Count == 0)
@@ -187,7 +427,14 @@ private void Select(int offset)
var index = SelectedSuggestion == null ? -1 : Suggestions.IndexOf(SelectedSuggestion);
- SelectedSuggestion = Suggestions[Math.Clamp(index + offset, 0, Suggestions.Count - 1)];
+ // Nothing selected: Down and PageDown select the first. Up from the first suggestion selects none, so Enter runs
+ // the input as typed. A page up stops at the first.
+ SelectedSuggestion = (index < 0 && offset > 0 ? 0 : index + offset) switch
+ {
+ < 0 when index > 0 && offset < -1 => Suggestions[0],
+ < 0 => null,
+ var newIndex => Suggestions[Math.Min(newIndex, Suggestions.Count - 1)]
+ };
}
///
@@ -195,103 +442,400 @@ private void Select(int offset)
///
public void Complete()
{
- if (SelectedSuggestion == null || SelectedSuggestion.Kind == RunCommandSuggestionKind.Run)
+ // Nothing selected: the first value of the part that is being typed, in the order of the syntax, e.g. a profile
+ // ("rdp @ser" → the first matching profile) or a DNS server, port profile or type ("dns host c" → the DNS
+ // server "Cloudflare", then the types).
+ var suggestion = SelectedSuggestion ?? (_result.Position switch
+ {
+ RunCommandPosition.Target when _result.ProfileName is { Length: > 0 } =>
+ Suggestions.FirstOrDefault(x => x.Kind == RunCommandSuggestionKind.Profile),
+ RunCommandPosition.Argument when _result.CurrentWord.Length > 0 =>
+ Suggestions.FirstOrDefault(x => x.Kind is RunCommandSuggestionKind.DNSServer
+ or RunCommandSuggestionKind.PortProfile or RunCommandSuggestionKind.Type),
+ _ => null
+ });
+
+ if (suggestion == null)
return;
- Input = SelectedSuggestion.CompletionText;
+ Input = suggestion.CompletionText;
+
+ // Keep the completed profile selected, so Enter runs it (also if several profiles have the same name).
+ if (suggestion.Profile != null)
+ SelectedSuggestion = Suggestions.FirstOrDefault(x => x.Profile == suggestion.Profile) ?? SelectedSuggestion;
}
///
- /// Runs the selected suggestion, or the input if none is selected (Enter).
+ /// Runs the selected suggestion, or the input if none is selected.
///
- public void Run()
+ /// Run (Enter), run and keep the dialog open (Ctrl+Enter) or only fill in (Shift+Enter).
+ /// False if the input is not valid (the error is shown with the warning icon).
+ public bool Run(RunCommandRunMode mode = RunCommandRunMode.Run)
{
var suggestion = SelectedSuggestion;
var text = suggestion?.CompletionText ?? Input;
var result = RunCommandParser.Parse(text, _commands);
+ StopValidation();
+ KeepOpen = false;
+
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)
+ if (suggestion != null)
Input = text;
- ErrorMessage = error;
+ ShowRunError(error);
- return;
+ return false;
}
+ // Only fill in the input, so the options can be adjusted before starting (e.g. the scan).
+ if (mode == RunCommandRunMode.FillIn && request != null)
+ request = request with { Start = false };
+
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, StringComparer.OrdinalIgnoreCase);
+ // A dialog that is opened (e.g. the connect dialog of a remote tool) needs the dialog to be closed.
+ KeepOpen = mode == RunCommandRunMode.RunAndKeepOpen &&
+ !RunCommandManager.OpensDialog(IsSettings ? null : request);
+
+ // Commands are case-insensitive, so "Ping 10.0.0.1" and "ping 10.0.0.1" are the same entry. Not if the text can't
+ // be run again (e.g. a profile selected in the suggestions, whose name starts with a quote).
+ var historyText = text.Trim();
+
+ if (result.Error == RunCommandParseError.None)
+ ListHelper.Modify(SettingsManager.Current.RunCommand_History, historyText,
+ SettingsManager.Current.RunCommand_HistoryEntries, StringComparer.OrdinalIgnoreCase);
_runHandler(this);
+
+ if (!KeepOpen)
+ return true;
+
+ // Show the command in the recent commands and keep it in the input (e.g. "ping "), so it can be run with the
+ // next target.
+ if (result.Error == RunCommandParseError.None && SettingsManager.Current.RunCommand_HistoryEntries > 0)
+ {
+ _history.RemoveAll(x => string.Equals(x.Text, historyText, StringComparison.OrdinalIgnoreCase));
+ _history.Insert(0, CreateHistoryEntry(historyText, result));
+
+ // Like the stored history
+ if (_history.Count > SettingsManager.Current.RunCommand_HistoryEntries)
+ _history.RemoveRange(SettingsManager.Current.RunCommand_HistoryEntries,
+ _history.Count - SettingsManager.Current.RunCommand_HistoryEntries);
+ }
+
+ var commandPrefix = result.Command.TypeBeforeTarget && result.Type != null
+ ? $"{result.CommandText} {result.Type} "
+ : $"{result.CommandText} ";
+
+ if (Input == commandPrefix)
+ UpdateSuggestions();
+ else
+ Input = commandPrefix;
+
+ return true;
+ }
+
+ ///
+ /// Removes a recent command from the history (Shift+Del on the selected one, or its remove button).
+ ///
+ /// Suggestion of the recent command.
+ /// True if a recent command was removed.
+ public bool RemoveSuggestion(RunCommandSuggestion suggestion)
+ {
+ if (suggestion?.Kind != RunCommandSuggestionKind.History)
+ return false;
+
+ var text = suggestion.Title;
+ var index = Suggestions.IndexOf(suggestion);
+ var wasSelected = suggestion == SelectedSuggestion;
+
+ var entry = SettingsManager.Current.RunCommand_History.FirstOrDefault(x =>
+ string.Equals(x, text, StringComparison.OrdinalIgnoreCase));
+
+ if (entry != null)
+ SettingsManager.Current.RunCommand_History.Remove(entry);
+
+ _history.RemoveAll(x => string.Equals(x.Text, text, StringComparison.OrdinalIgnoreCase));
+
+ UpdateSuggestions();
+
+ // Keep the position in the list, so several entries can be removed one after the other.
+ if (wasSelected && Suggestions.Count > 0)
+ SelectedSuggestion = Suggestions[Math.Min(index, Suggestions.Count - 1)];
+
+ return true;
+ }
+
+ private static string GetRandomWatermark()
+ {
+ // Created on each call, since the lines are translated.
+ string[] watermarks =
+ [
+ Strings.RunCommandWatermark1, Strings.RunCommandWatermark2, Strings.RunCommandWatermark3,
+ Strings.RunCommandWatermark4, Strings.RunCommandWatermark5, Strings.RunCommandWatermark6,
+ Strings.RunCommandWatermark7, Strings.RunCommandWatermark8, Strings.RunCommandWatermark9,
+ Strings.RunCommandWatermark10, Strings.RunCommandWatermark11, Strings.RunCommandWatermark12,
+ Strings.RunCommandWatermark13, Strings.RunCommandWatermark14, Strings.RunCommandWatermark15,
+ Strings.RunCommandWatermark16, Strings.RunCommandWatermark17, Strings.RunCommandWatermark18
+ ];
+
+ return watermarks[Random.Shared.Next(watermarks.Length)];
+ }
+
+ private static HistoryEntry CreateHistoryEntry(string text, RunCommandParseResult result)
+ {
+ return new HistoryEntry(text, result.Command, result.ProfileName, RunCommandTarget.GetKind(result.Target));
}
///
- /// Updates the suggestions and the hint for the input.
+ /// Gets the clipboard content if it is a target (e.g. an IP address, a subnet or a MAC address).
+ ///
+ /// Target, or null if the clipboard contains no target.
+ private static string GetClipboardTarget()
+ {
+ try
+ {
+ if (!Clipboard.ContainsText())
+ return null;
+
+ // Whitespace around ";" is removed like in the input (e.g. "10.0.0.1; 10.0.0.2").
+ var text = Regex.Replace(Clipboard.GetText().Trim(), @"\s*;\s*", ";");
+
+ // A single word (host name without a domain) is not suggested, since it can be any copied text.
+ return text.Length <= MaxClipboardLength &&
+ RunCommandTarget.GetKind(text) is not (RunCommandTargetKind.None or RunCommandTargetKind.Hostname)
+ ? text
+ : null;
+ }
+ catch (ExternalException)
+ {
+ // The clipboard is used by another application.
+ return null;
+ }
+ }
+
+ ///
+ /// Updates the suggestions, the hint and the parsed values for the input.
///
private void UpdateSuggestions()
{
_result = RunCommandParser.Parse(Input, _commands);
- Hint = _result.Command == null
- ? null
- : $"{_result.Command.Command} {_result.Command.Syntax}".TrimEnd();
+ HasCommand = _result.Command != null;
+ CommandApplication = _result.Command?.Application ?? ApplicationName.None;
+
+ UpdateHint();
+ UpdateChips();
IEnumerable suggestions = _result.Position switch
{
- RunCommandPosition.Command => GetCommandSuggestions(),
- RunCommandPosition.Target when _result.ProfileName != null => GetProfileSuggestions(),
+ RunCommandPosition.Command when string.IsNullOrWhiteSpace(Input) => GetEmptyInputSuggestions(),
+ RunCommandPosition.Command => GetFirstWordSuggestions(),
+ RunCommandPosition.Type =>
+ [
+ .. GetTypeSuggestions().Take(MaxSuggestions),
+ .. GetHistorySuggestions().Take(MaxSuggestions)
+ ],
+ RunCommandPosition.Target when _result.ProfileName != null => GetProfileSuggestions().Take(MaxSuggestions),
RunCommandPosition.Target =>
[
- GetRunSuggestion(), .. GetHistorySuggestions().Take(MaxCommandHistorySuggestions),
- .. GetProfileSuggestions()
+ .. GetHistorySuggestions().Take(MaxCommandHistorySuggestions),
+ .. GetProfileSuggestions().Take(MaxSuggestions)
],
+ // In the order of the syntax (e.g. "[DNS server] [Type]")
_ =>
[
- GetRunSuggestion(), .. GetTypeSuggestions(), .. GetDNSServerSuggestions(),
- .. GetHistorySuggestions()
+ .. GetDNSServerSuggestions().Take(MaxSuggestions), .. GetPortProfileSuggestions().Take(MaxSuggestions),
+ .. GetTypeSuggestions().Take(MaxSuggestions), .. GetHistorySuggestions().Take(MaxSuggestions)
]
};
- if (_result.Position != RunCommandPosition.Command)
- suggestions = suggestions.Take(MaxSuggestions);
+ Suggestions.ReplaceAll(suggestions);
- Suggestions.Clear();
+ SelectedSuggestion = GetPreselectedSuggestion();
- foreach (var suggestion in suggestions)
- Suggestions.Add(suggestion);
+ RestartValidation();
+ }
- SelectedSuggestion = GetPreselectedSuggestion();
+ ///
+ /// Updates the hint with the syntax of the command and highlights the part that is being typed.
+ ///
+ private void UpdateHint()
+ {
+ var command = _result.Command;
+
+ if (command == null)
+ {
+ Hint = null;
+ HintParts = [];
+
+ return;
+ }
+
+ Hint = $"{command.Command} {command.Syntax}".TrimEnd();
+
+ var currentPart = _result.CurrentSyntaxPart;
+
+ var parts = new List
+ {
+ new(command.Command, _result.Position == RunCommandPosition.Command)
+ };
+
+ foreach (var part in command.SyntaxParts)
+ {
+ if (part.Kind == RunCommandSyntaxPartKind.Profile)
+ parts.Add(new RunCommandHintPart("|", false));
+
+ parts.Add(new RunCommandHintPart(part.Text, part.Kind == currentPart));
+ }
+
+ HintParts = parts;
}
///
- /// 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.
+ /// Updates the values of the input as they were understood, in the order of the syntax. The application is not
+ /// included, since its icon is shown in the input and its command in the hint.
+ ///
+ private void UpdateChips()
+ {
+ var command = _result.Command;
+
+ if (command == null || _result.Position == RunCommandPosition.Command ||
+ _result.Target == null && string.IsNullOrEmpty(_result.ProfileName) && _result.Type == null)
+ {
+ Chips = [];
+
+ return;
+ }
+
+ var chips = new List();
+
+ foreach (var part in command.SyntaxParts)
+ {
+ var value = part.Kind switch
+ {
+ RunCommandSyntaxPartKind.Target => _result.Target,
+ RunCommandSyntaxPartKind.Argument => _result.Argument,
+ RunCommandSyntaxPartKind.Type => _result.Type?.ToUpperInvariant(),
+ _ => string.IsNullOrEmpty(_result.ProfileName) ? null : "@" + _result.ProfileName
+ };
+
+ if (value != null)
+ chips.Add(new RunCommandChip(part.Name, value));
+ }
+
+ Chips = chips;
+ }
+
+ ///
+ /// Shows the error of a run with the warning icon, like the validation while typing (without waiting).
+ ///
+ private void ShowRunError(string error)
+ {
+ // A failed suggestion was put into the input, which restarted the validation.
+ _validationTimer.Stop();
+
+ IsValid = false;
+ IsInvalid = true;
+ ValidationMessage = error;
+ }
+
+ ///
+ /// Validates what Enter runs (the selected suggestion or the input) after a short time without typing.
+ ///
+ private void RestartValidation()
+ {
+ StopValidation();
+
+ if (ShouldValidate())
+ _validationTimer.Start();
+ }
+
+ private void StopValidation()
+ {
+ _validationTimer.Stop();
+
+ IsValid = false;
+ IsInvalid = false;
+ ValidationMessage = null;
+ }
+
+ ///
+ /// The input is validated after the command (or for an unknown command with arguments like "pin 10.0.0.1"), not
+ /// while the command is being typed.
+ ///
+ private bool ShouldValidate()
+ {
+ return !string.IsNullOrWhiteSpace(Input) &&
+ (_result.Position != RunCommandPosition.Command || _result.Command == null && HasArguments());
+ }
+
+ private void Validate()
+ {
+ if (!ShouldValidate())
+ return;
+
+ var suggestion = SelectedSuggestion;
+ var result = suggestion == null ? _result : RunCommandParser.Parse(suggestion.CompletionText, _commands);
+
+ var error = RunCommandManager.TryCreateRequest(result, suggestion?.Profile, out _);
+
+ IsValid = error == null;
+ IsInvalid = error != null;
+ ValidationMessage = error;
+ }
+
+ ///
+ /// Gets the suggestion that Enter runs without selecting one. Without a selected suggestion, Enter runs the input
+ /// as typed:
+ /// - Empty input: the first command for the clipboard content (if enabled), otherwise none.
+ /// - After the command: none (profiles, recent commands, types and DNS servers are selected with the arrow keys).
+ /// - While typing the command: the best matching command (or the first command for a target without a command,
+ /// e.g. "10.0.0.1", but not for a host name without a domain, which can also be a typo), but not if 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).
+ /// - While typing a type before the target (e.g. "lookup po"): the matching type, like the command.
///
private RunCommandSuggestion GetPreselectedSuggestion()
{
return _result.Position switch
{
- RunCommandPosition.Command when string.IsNullOrWhiteSpace(Input) ||
- _result.Command == null && GetInputAfterCommand().Length > 0 => null,
+ RunCommandPosition.Command when string.IsNullOrWhiteSpace(Input) =>
+ Suggestions.FirstOrDefault(x => x.Kind == RunCommandSuggestionKind.Clipboard),
+ RunCommandPosition.Command when _result.Command == null && HasArguments() => null,
RunCommandPosition.Target when _result.ProfileName != null => Suggestions.Count == 1 ? Suggestions[0] : null,
- _ => Suggestions.FirstOrDefault()
+ RunCommandPosition.Type when _result.CurrentWord.Length > 0 =>
+ Suggestions.FirstOrDefault(x => x.Kind == RunCommandSuggestionKind.Type),
+ // The best matching command, otherwise the first command for a typed target (also if it is shown as recent
+ // command). Other clipboard and recent commands are selected with the arrow keys.
+ RunCommandPosition.Command =>
+ Suggestions.FirstOrDefault(x => x.Kind == RunCommandSuggestionKind.Command) ??
+ (_targetOnlyPreselection == null
+ ? null
+ : Suggestions.FirstOrDefault(x =>
+ string.Equals(x.CompletionText, _targetOnlyPreselection, StringComparison.OrdinalIgnoreCase))),
+ _ => null
};
}
+ ///
+ /// Checks if the input has arguments after the first word (e.g. "pin 10.0.0.1"). Like in the parser, whitespace
+ /// around ";" doesn't separate arguments, so "10.0.0.1; 10.0.0.2" is one word.
+ ///
+ private bool HasArguments()
+ {
+ return Regex.Replace(Input.Trim(), @"\s*;\s*", ";").Any(char.IsWhiteSpace);
+ }
+
///
/// Gets the input after the first word (the command), e.g. "10.0.0.1" for "pin 10.0.0.1".
///
@@ -304,31 +848,85 @@ private string GetInputAfterCommand()
}
///
- /// Suggestion to run the input as typed.
+ /// Suggestions for an empty input: commands for the clipboard content, recent commands, examples (if there are
+ /// no recent commands yet) and all commands.
///
- private RunCommandSuggestion GetRunSuggestion()
+ private IEnumerable GetEmptyInputSuggestions()
{
- return new RunCommandSuggestion
- {
- Kind = RunCommandSuggestionKind.Run,
- Application = _result.Command.Application,
- Title = Input.Trim(),
- Description = RunCommandManager.GetTranslatedName(_result.Command),
- CompletionText = Input
- };
+ var suggestions = new List();
+
+ if (_clipboardTarget != null)
+ suggestions.AddRange(GetTargetSuggestions(_clipboardTarget, Strings.Clipboard,
+ RunCommandSuggestionKind.Clipboard, commands: _clipboardCommands, targetKind: _clipboardKind)
+ .Take(MaxClipboardSuggestions));
+
+ suggestions.AddRange(GetHistorySuggestions().Take(MaxHistorySuggestions));
+
+ if (_history.Count == 0)
+ suggestions.AddRange(GetExampleSuggestions());
+
+ suggestions.AddRange(GetCommandSuggestions());
+
+ return Distinct(suggestions);
}
///
- /// Recent commands (empty input) and the commands that match the typed command.
+ /// Suggestions while typing the first word: the matching commands, the recent commands that contain the word
+ /// (e.g. a host), the commands for a target typed without a command (e.g. "10.0.0.1") and the commands for the
+ /// clipboard content that start with the word (e.g. "pi" → "ping 10.0.0.1").
///
- private IEnumerable GetCommandSuggestions()
+ private IEnumerable GetFirstWordSuggestions()
{
- var text = _result.CurrentWord;
+ var word = _result.CurrentWord;
- var suggestions = new List();
+ // Detected once per input (also used for the ranking and the commands of the target)
+ var targetKind = RunCommandTarget.GetKind(_result.CommandText);
+
+ var targetOnlySuggestions = GetTargetOnlySuggestions(CommandStartsWith(word), targetKind).ToList();
+
+ // Not for a host name without a domain, which can also be a mistyped command (e.g. "pnig"), and not for a port,
+ // which can also be the start of an IP address (e.g. "192").
+ _targetOnlyPreselection = targetKind is RunCommandTargetKind.Hostname or RunCommandTargetKind.Ports
+ ? null
+ : targetOnlySuggestions.FirstOrDefault()?.CompletionText;
+
+ var suggestions = new List(GetCommandSuggestions());
+
+ suggestions.AddRange(GetFirstWordHistorySuggestions().Take(MaxHistorySuggestions));
+ suggestions.AddRange(targetOnlySuggestions);
+
+ if (_clipboardTarget != null)
+ suggestions.AddRange(GetTargetSuggestions(_clipboardTarget, Strings.Clipboard,
+ RunCommandSuggestionKind.Clipboard, word, _clipboardCommands, _clipboardKind));
+
+ // A command for the target that was already run is shown as recent command (only once).
+ return Distinct(suggestions);
+ }
+
+ ///
+ /// Removes suggestions with the same command as a previous one (e.g. a recent command that is also suggested
+ /// for the target or the clipboard). Not trimmed, so a command that is completed (e.g. "ping ") and a recent
+ /// command without a target (e.g. "ping") are both kept.
+ ///
+ private static IEnumerable Distinct(IEnumerable suggestions)
+ {
+ return suggestions.DistinctBy(x => x.CompletionText, StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Checks if a command or alias starts with the text (e.g. "pi" for "ping").
+ ///
+ private bool CommandStartsWith(string text)
+ {
+ return _commandNames.Any(x => x.Names.Any(name => name.StartsWith(text, StringComparison.OrdinalIgnoreCase)));
+ }
- if (string.IsNullOrEmpty(text))
- suggestions.AddRange(GetHistorySuggestions().Take(MaxHistorySuggestions));
+ ///
+ /// Commands that match the typed command.
+ ///
+ private IEnumerable GetCommandSuggestions()
+ {
+ var text = _result.CurrentWord;
// Completing the command keeps the arguments (e.g. "pin 10.0.0.1" → "ping 10.0.0.1").
var arguments = GetInputAfterCommand();
@@ -357,17 +955,92 @@ private IEnumerable GetCommandSuggestions()
Title = translatedName,
Description = string.Join(", ", names),
Info = command.Syntax,
- CompletionText = (name ?? command.Command) + " " + arguments
+ CompletionText = (name ?? command.Command) + " " + arguments,
+ Highlight = text,
+ Group = Strings.Applications
}));
}
- suggestions.AddRange(commands.OrderBy(x => x.Rank).Select(x => x.Suggestion));
+ return commands.OrderBy(x => x.Rank).Select(x => x.Suggestion);
+ }
- return suggestions;
+ ///
+ /// Commands for a target typed without a command (e.g. "ping 10.0.0.1", "trace 10.0.0.1" for "10.0.0.1").
+ ///
+ /// A command starts with the typed word.
+ /// Kind of the typed word as target.
+ private IEnumerable GetTargetOnlySuggestions(bool commandStartsWithWord,
+ RunCommandTargetKind targetKind)
+ {
+ if (_result.Command != null || HasArguments())
+ return [];
+
+ // A host name without a domain can also be a command that is being typed (e.g. "pi").
+ if (commandStartsWithWord && targetKind == RunCommandTargetKind.Hostname)
+ return [];
+
+ return GetTargetSuggestions(_result.CommandText, Strings.Commands, RunCommandSuggestionKind.Target,
+ targetKind: targetKind);
}
///
- /// Profiles of the command that match the typed profile name (or target).
+ /// Commands that accept the target. Commands that were used with this kind of target (e.g. IP addresses) come
+ /// first, the most used first.
+ ///
+ /// Target (e.g. "10.0.0.1").
+ /// Group of the suggestions.
+ /// Kind of the suggestions (typed target or clipboard).
+ /// Only commands of which a name starts with it (e.g. "pi" for "ping"), or null.
+ /// Commands for the target if they are already known (clipboard), otherwise null.
+ /// Kind of the target if it is already known, otherwise it is detected.
+ private IEnumerable GetTargetSuggestions(string target, string group,
+ RunCommandSuggestionKind suggestionKind, string commandPrefix = null,
+ IEnumerable<(RunCommandDefinition Command, string Text)> commands = null, RunCommandTargetKind? targetKind = null)
+ {
+ var kind = targetKind ?? RunCommandTarget.GetKind(target);
+
+ var usage = _history.Where(x => x.TargetKind == kind)
+ .GroupBy(x => x.Command)
+ .ToDictionary(x => x.Key, x => x.Count());
+
+ return (commands ?? RunCommandManager.GetCommandsForTarget(target, _commands, kind))
+ .Where(x => commandPrefix == null || _commandNames.Any(c =>
+ c.Command == x.Command &&
+ c.Names.Any(name => name.StartsWith(commandPrefix, StringComparison.OrdinalIgnoreCase))))
+ .OrderByDescending(x => usage.GetValueOrDefault(x.Command))
+ .Select(x => new RunCommandSuggestion
+ {
+ Kind = suggestionKind,
+ Application = x.Command.Application,
+ Title = x.Text,
+ Description = RunCommandManager.GetTranslatedName(x.Command),
+ CompletionText = x.Text,
+ Group = group
+ });
+ }
+
+ ///
+ /// Example commands of the available commands.
+ ///
+ private IEnumerable GetExampleSuggestions()
+ {
+ return ExampleCommands
+ .Select(x => (Text: x, RunCommandParser.Parse(x, _commands).Command))
+ .Where(x => x.Command != null)
+ .Select(x => new RunCommandSuggestion
+ {
+ Kind = RunCommandSuggestionKind.Example,
+ Application = x.Command.Application,
+ Title = x.Text,
+ Description = RunCommandManager.GetTranslatedName(x.Command),
+ CompletionText = x.Text,
+ Group = Strings.Examples
+ });
+ }
+
+ ///
+ /// Profiles of the command that match the typed profile name (or target). Recently run profiles of the command
+ /// come first within the same rank, since the recent commands are not shown while typing a profile ("@").
///
private IEnumerable GetProfileSuggestions()
{
@@ -396,11 +1069,22 @@ private IEnumerable GetProfileSuggestions()
_profiles[_result.Command] = profiles;
}
+ // Position of the profile name in the history of the command (most recent first)
+ var recentProfileNames = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var entry in _history.Where(x => x.Command == _result.Command && !string.IsNullOrEmpty(x.ProfileName)))
+ recentProfileNames.TryAdd(entry.ProfileName, recentProfileNames.Count);
+
+ // The name is compared like it is parsed (without surrounding whitespace and quotes).
+ int GetRecentIndex(ProfileInfo profile) =>
+ recentProfileNames.GetValueOrDefault(profile.Name.Trim().Trim('"').Trim(), int.MaxValue);
+
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)
.OrderBy(x => x.Rank)
+ .ThenBy(x => GetRecentIndex(x.Profile))
.Select(x => new RunCommandSuggestion
{
Kind = RunCommandSuggestionKind.Profile,
@@ -410,7 +1094,9 @@ private IEnumerable GetProfileSuggestions()
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
+ Profile = x.Profile,
+ Highlight = text,
+ Group = Strings.Profiles
});
}
@@ -431,29 +1117,61 @@ private IEnumerable GetTypeSuggestions()
Application = _result.Command.Application,
Title = x.ToUpperInvariant(),
Description = Strings.Type,
- CompletionText = _result.InputBeforeCurrentWord + x + " "
+ CompletionText = _result.InputBeforeCurrentWord + x + " ",
+ Highlight = _result.CurrentWord,
+ Group = Strings.Type
});
}
+ ///
+ /// Port profiles of the Port Scanner (e.g. "Webserver") that contain the typed word, if no ports were entered
+ /// yet.
+ ///
+ private IEnumerable GetPortProfileSuggestions()
+ {
+ return GetArgumentValueSuggestions(RunCommandArgument.Ports,
+ RunCommandManager.GetPortProfiles().Select(x => (x.Name, x.Ports)), RunCommandSuggestionKind.PortProfile,
+ Strings.PortProfiles);
+ }
+
///
/// 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.
+ /// entered yet.
///
private IEnumerable GetDNSServerSuggestions()
{
- if (_result.Command.Argument != RunCommandArgument.DNSServer ||
+ return GetArgumentValueSuggestions(RunCommandArgument.DNSServer,
+ RunCommandManager.GetDNSServers().Select(x => (x, Strings.DNSServer)), RunCommandSuggestionKind.DNSServer,
+ Strings.DNSServers);
+ }
+
+ ///
+ /// Named values of the second argument of the command (e.g. DNS servers, port profiles) that contain the typed
+ /// word, if the argument was not entered yet. Names with spaces are inserted in quotes.
+ ///
+ /// Argument the values are for.
+ /// Names and descriptions of the values.
+ /// Kind of the suggestions.
+ /// Group of the suggestions.
+ private IEnumerable GetArgumentValueSuggestions(RunCommandArgument argument,
+ IEnumerable<(string Name, string Description)> values, RunCommandSuggestionKind kind, string group)
+ {
+ if (_result.Command.Argument != argument ||
_result.Argument != null && !string.Equals(_result.Argument, _result.CurrentWord))
return [];
- return RunCommandManager.GetDNSServers()
- .Where(x => x.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase))
+ return values
+ .Where(x => x.Name.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase))
.Select(x => new RunCommandSuggestion
{
- Kind = RunCommandSuggestionKind.DNSServer,
+ Kind = kind,
Application = _result.Command.Application,
- Title = x,
- Description = Strings.DNSServer,
- CompletionText = _result.InputBeforeCurrentWord + (x.Contains(' ') ? $"\"{x}\"" : x) + " "
+ Title = x.Name,
+ Description = x.Description,
+ CompletionText =
+ _result.InputBeforeCurrentWord + (x.Name.Contains(' ') ? $"\"{x.Name}\"" : x.Name) + " ",
+ Highlight = _result.CurrentWord,
+ Group = group
});
}
@@ -470,15 +1188,34 @@ private IEnumerable GetHistorySuggestions()
x.Command == _result.Command &&
x.Text.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(x.Text, input, StringComparison.OrdinalIgnoreCase))
- .Select(x => new RunCommandSuggestion
- {
- 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
- });
+ .Select(CreateHistorySuggestion);
+ }
+
+ ///
+ /// Recent commands that contain the first word that is being typed (e.g. "server01" or "pi"), of any command.
+ ///
+ private IEnumerable GetFirstWordHistorySuggestions()
+ {
+ var input = Input.Trim();
+
+ return _history
+ .Where(x => x.Text.Contains(_result.CurrentWord, StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(x.Text, input, StringComparison.OrdinalIgnoreCase))
+ .Select(CreateHistorySuggestion);
+ }
+
+ private RunCommandSuggestion CreateHistorySuggestion(HistoryEntry entry)
+ {
+ return new RunCommandSuggestion
+ {
+ Kind = RunCommandSuggestionKind.History,
+ Application = entry.Command.Application,
+ Title = entry.Text,
+ Description = RunCommandManager.GetTranslatedName(entry.Command),
+ CompletionText = entry.Text,
+ Highlight = _result.CurrentWord,
+ Group = Strings.Recent
+ };
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/SettingsGeneralViewModel.cs b/Source/NETworkManager/ViewModels/SettingsGeneralViewModel.cs
index ddad5752de..288b24b1fe 100644
--- a/Source/NETworkManager/ViewModels/SettingsGeneralViewModel.cs
+++ b/Source/NETworkManager/ViewModels/SettingsGeneralViewModel.cs
@@ -61,6 +61,54 @@ public int HistoryListEntries
}
}
+ public int RunCommandHistoryEntries
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ if (!_isLoading)
+ SettingsManager.Current.RunCommand_HistoryEntries = value;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public bool RunCommandSuggestClipboard
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ if (!_isLoading)
+ SettingsManager.Current.RunCommand_SuggestClipboard = value;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public bool RunCommandShowKeyHints
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ if (!_isLoading)
+ SettingsManager.Current.RunCommand_ShowKeyHints = value;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
#endregion
#region Constructor, LoadSettings
@@ -85,6 +133,9 @@ private void LoadSettings()
BackgroundJobInterval = SettingsManager.Current.General_BackgroundJobInterval;
HistoryListEntries = SettingsManager.Current.General_HistoryListEntries;
+ RunCommandHistoryEntries = SettingsManager.Current.RunCommand_HistoryEntries;
+ RunCommandSuggestClipboard = SettingsManager.Current.RunCommand_SuggestClipboard;
+ RunCommandShowKeyHints = SettingsManager.Current.RunCommand_ShowKeyHints;
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/SettingsHotKeysViewModel.cs b/Source/NETworkManager/ViewModels/SettingsHotKeysViewModel.cs
index 1ef0e6974c..b26438b1b1 100644
--- a/Source/NETworkManager/ViewModels/SettingsHotKeysViewModel.cs
+++ b/Source/NETworkManager/ViewModels/SettingsHotKeysViewModel.cs
@@ -51,6 +51,47 @@ public HotKey HotKeyShowWindow
}
}
+ public bool HotKeyRunCommandEnabled
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ if (!_isLoading)
+ {
+ SettingsManager.Current.HotKey_RunCommandEnabled = value;
+
+ SettingsManager.HotKeysChanged = true;
+ }
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public HotKey HotKeyRunCommand
+ {
+ get;
+ set
+ {
+ if (Equals(value, field))
+ return;
+
+ if (!_isLoading && value != null)
+ {
+ SettingsManager.Current.HotKey_RunCommandKey = (int)HotKeys.WpfKeyToFormsKeys(value.Key);
+ SettingsManager.Current.HotKey_RunCommandModifier = HotKeys.GetModifierKeysSum(value.ModifierKeys);
+
+ SettingsManager.HotKeysChanged = true;
+ }
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
#endregion
#region Constructor, LoadSettings
@@ -69,6 +110,9 @@ private void LoadSettings()
HotKeyShowWindowEnabled = SettingsManager.Current.HotKey_ShowWindowEnabled;
HotKeyShowWindow = new HotKey(HotKeys.FormsKeysToWpfKey(SettingsManager.Current.HotKey_ShowWindowKey),
HotKeys.GetModifierKeysFromInt(SettingsManager.Current.HotKey_ShowWindowModifier));
+ HotKeyRunCommandEnabled = SettingsManager.Current.HotKey_RunCommandEnabled;
+ HotKeyRunCommand = new HotKey(HotKeys.FormsKeysToWpfKey(SettingsManager.Current.HotKey_RunCommandKey),
+ HotKeys.GetModifierKeysFromInt(SettingsManager.Current.HotKey_RunCommandModifier));
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs
index 6e9c117ab9..6c62b44e98 100644
--- a/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs
+++ b/Source/NETworkManager/ViewModels/SubnetCalculatorCalculatorViewModel.cs
@@ -102,6 +102,19 @@ private void CalculateAction()
#region Methods
+ ///
+ /// Calculates the subnet (e.g. from the run command).
+ ///
+ /// Subnet (e.g. 10.0.0.0/24).
+ /// Calculates the subnet. If false, the subnet is only filled in.
+ public void Calculate(string subnet, bool start = true)
+ {
+ Subnet = subnet;
+
+ if (start)
+ Calculate();
+ }
+
private void Calculate()
{
IsRunning = true;
diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs
index 8f4da94fba..904b9162e4 100644
--- a/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs
+++ b/Source/NETworkManager/ViewModels/SubnetCalculatorSubnettingViewModel.cs
@@ -13,13 +13,11 @@
using System.ComponentModel;
using System.Linq;
using System.Net;
-using System.Net.Sockets;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Threading;
-using IPAddress = System.Net.IPAddress;
namespace NETworkManager.ViewModels;
@@ -213,6 +211,24 @@ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Erro
#region Methods
+ ///
+ /// Splits the subnet into subnets (e.g. from the run command).
+ ///
+ /// Subnet (e.g. 10.0.0.0/16).
+ /// New subnet mask or CIDR (e.g. /24).
+ /// Calculates the subnets. If false, the values are only filled in.
+ public Task Calculate(string subnet, string newSubnetmask, bool start = true)
+ {
+ // Like the calculate button, not while a calculation is running (the results would be mixed).
+ if (IsRunning)
+ return Task.CompletedTask;
+
+ Subnet = subnet;
+ NewSubnetmask = newSubnetmask;
+
+ return start ? Calculate() : Task.CompletedTask;
+ }
+
private async Task Calculate()
{
IsRunning = true;
@@ -224,14 +240,24 @@ private async Task Calculate()
var ipNetwork = IPNetwork2.Parse(Subnet.Trim());
- var newCidr =
- // Support subnetmask like 255.255.255.0
- RegexHelper.SubnetmaskRegex().IsMatch(newSubnetmaskOrCidr)
- ? Convert.ToByte(Subnetmask.ConvertSubnetmaskToCidr(IPAddress.Parse(newSubnetmaskOrCidr)))
- : Convert.ToByte(newSubnetmaskOrCidr.TrimStart('/'));
+ // Subnet mask (e.g. 255.255.255.0) or CIDR (e.g. /24)
+ var newCidr = Subnetmask.ConvertSubnetmaskOrCidrToCidr(newSubnetmaskOrCidr);
+
+ var (minCidr, baseCidr) = Subnetmask.GetSubnettingCidrRange(ipNetwork);
+
+ // The new subnet mask must split the subnet (e.g. /24 into /26), otherwise the subnetting fails.
+ if (newCidr < minCidr || newCidr > baseCidr)
+ {
+ await DialogHelper.ShowMessageAsync(Application.Current.MainWindow, Strings.Error,
+ string.Format(Strings.EnterSubnetmaskOrCIDRBetweenXAndY, minCidr, baseCidr),
+ ChildWindowIcon.Error);
+
+ IsRunning = false;
+
+ return;
+ }
// Ask the user if there is a large calculation...
- var baseCidr = ipNetwork.AddressFamily == AddressFamily.InterNetwork ? 32 : 128;
if (65535 < Math.Pow(2, baseCidr - ipNetwork.Cidr) / Math.Pow(2, baseCidr - newCidr))
{
@@ -246,7 +272,7 @@ private async Task Calculate()
// This still slows the application / freezes the ui... there are to many updates to the ui thread...
await Task.Run(() =>
{
- foreach (var network in ipNetwork.Subnet(newCidr))
+ foreach (var network in ipNetwork.Subnet((byte)newCidr))
Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Normal,
new Action(delegate { Results.Add(new IPNetworkInfo(network)); }));
});
diff --git a/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs b/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs
index e743d11ca4..4410227493 100644
--- a/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs
+++ b/Source/NETworkManager/ViewModels/SubnetCalculatorWideSubnetViewModel.cs
@@ -120,6 +120,21 @@ private void CalculateAction()
#region Methods
+ ///
+ /// Calculates the wide subnet of two subnets (e.g. from the run command).
+ ///
+ /// First subnet (e.g. 10.0.0.0/24).
+ /// Second subnet (e.g. 10.0.1.0/24).
+ /// Calculates the wide subnet. If false, the subnets are only filled in.
+ public void Calculate(string subnet1, string subnet2, bool start = true)
+ {
+ Subnet1 = subnet1;
+ Subnet2 = subnet2;
+
+ if (start)
+ Calculate();
+ }
+
private void Calculate()
{
IsRunning = true;
diff --git a/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs b/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs
index f0ec9dacd8..c32f43070a 100644
--- a/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/TracerouteHostViewModel.cs
@@ -104,19 +104,19 @@ private void CloseItemAction(ItemActionCallbackArgs args)
#region Methods
- public void AddTab(string host = null)
+ public void AddTab(string host = null, bool start = true)
{
var tabId = Guid.NewGuid();
- TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab, new TracerouteView(tabId, host),
+ TabItems.Add(new DragablzTabItem(host ?? Strings.NewTab, new TracerouteView(tabId, host, start),
tabId));
SelectedTabIndex = TabItems.Count - 1;
}
- public void AddTab(ProfileInfo profile)
+ public void AddTab(ProfileInfo profile, bool start = true)
{
- AddTab(profile.Traceroute_Host);
+ AddTab(profile.Traceroute_Host, start: start);
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs
index 4fb34e6b97..1cb383e31e 100644
--- a/Source/NETworkManager/ViewModels/TracerouteViewModel.cs
+++ b/Source/NETworkManager/ViewModels/TracerouteViewModel.cs
@@ -217,8 +217,11 @@ public bool ExpandMapView
///
/// The unique identifier for the tab.
/// The initial host to trace.
- public TracerouteViewModel(Guid tabId, string host)
+ /// Starts it on the first load. If false, the input is only filled in.
+ public TracerouteViewModel(Guid tabId, string host, bool start = true)
{
+ _firstLoad = start;
+
ConfigurationManager.Current.TracerouteTabCount++;
_tabId = tabId;
diff --git a/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs b/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs
index 81a8ed8e66..f34adb2788 100644
--- a/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs
+++ b/Source/NETworkManager/ViewModels/WakeOnLANViewModel.cs
@@ -209,8 +209,17 @@ private void WakeUpProfileAction()
/// Sends the magic packet to the computer of the profile.
///
/// Profile to wake up.
- public void WakeUp(ProfileInfo profile)
+ /// Sends the magic packet. If false, the values of the profile are only filled in.
+ public void WakeUp(ProfileInfo profile, bool start = true)
{
+ if (!start)
+ {
+ MACAddress = profile.WakeOnLAN_MACAddress;
+ Broadcast = profile.WakeOnLAN_Broadcast;
+
+ return;
+ }
+
_ = WakeUp(NETworkManager.Profiles.Application.WakeOnLAN.CreateInfo(profile));
}
@@ -220,14 +229,15 @@ public void WakeUp(ProfileInfo profile)
///
/// MAC address of the computer.
/// Broadcast address. If null, the current broadcast address is used.
- public void WakeUp(string macAddress, string broadcast)
+ /// Sends the magic packet. If false, the values are only filled in.
+ public void WakeUp(string macAddress, string broadcast, bool start = true)
{
MACAddress = macAddress;
if (broadcast != null)
Broadcast = broadcast;
- if (Broadcast != null && RegexHelper.IPv4AddressRegex().IsMatch(Broadcast))
+ if (start && Broadcast != null && RegexHelper.IPv4AddressRegex().IsMatch(Broadcast))
WakeUpAction();
}
diff --git a/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs b/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs
index b4954a91d1..d6356fb6db 100644
--- a/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/WhoisHostViewModel.cs
@@ -104,19 +104,19 @@ private static void CloseItemAction(ItemActionCallbackArgs args)
#region Methods
- public void AddTab(string domain = null)
+ public void AddTab(string domain = null, bool start = true)
{
var tabId = Guid.NewGuid();
- TabItems.Add(new DragablzTabItem(domain ?? Strings.NewTab, new WhoisView(tabId, domain),
+ TabItems.Add(new DragablzTabItem(domain ?? Strings.NewTab, new WhoisView(tabId, domain, start),
tabId));
SelectedTabIndex = TabItems.Count - 1;
}
- public void AddTab(ProfileInfo profile)
+ public void AddTab(ProfileInfo profile, bool start = true)
{
- AddTab(profile.Whois_Domain);
+ AddTab(profile.Whois_Domain, start: start);
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/WhoisViewModel.cs b/Source/NETworkManager/ViewModels/WhoisViewModel.cs
index 9a6aeed3c5..03f0c4d8d5 100644
--- a/Source/NETworkManager/ViewModels/WhoisViewModel.cs
+++ b/Source/NETworkManager/ViewModels/WhoisViewModel.cs
@@ -111,8 +111,10 @@ private set
#region Contructor, load settings
- public WhoisViewModel(Guid tabId, string domain)
+ public WhoisViewModel(Guid tabId, string domain, bool start = true)
{
+ _firstLoad = start;
+
ConfigurationManager.Current.WhoisTabCount++;
_tabId = tabId;
diff --git a/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs b/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs
index 1b1f604aff..72c8b5c524 100644
--- a/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs
+++ b/Source/NETworkManager/Views/DNSLookupHostView.xaml.cs
@@ -16,13 +16,13 @@ public void Open(ApplicationRequest request)
{
if (request.Profile != null)
{
- _viewModel.AddTab(request.Profile);
+ _viewModel.AddTab(request.Profile, request.Start);
return;
}
var dnsLookupRequest = request as DNSLookupRequest;
- _viewModel.AddTab(request.Target, dnsLookupRequest?.DNSServer, dnsLookupRequest?.QueryType);
+ _viewModel.AddTab(request.Target, dnsLookupRequest?.DNSServer, dnsLookupRequest?.QueryType, request.Start);
}
public void OnViewHide()
diff --git a/Source/NETworkManager/Views/DNSLookupView.xaml.cs b/Source/NETworkManager/Views/DNSLookupView.xaml.cs
index 8edfa206d4..335981648f 100644
--- a/Source/NETworkManager/Views/DNSLookupView.xaml.cs
+++ b/Source/NETworkManager/Views/DNSLookupView.xaml.cs
@@ -11,11 +11,12 @@ public partial class DNSLookupView : IDragablzTabItem
{
private readonly DNSLookupViewModel _viewModel;
- public DNSLookupView(Guid tabId, string host = null, string dnsServer = null, QueryType? queryType = null)
+ public DNSLookupView(Guid tabId, string host = null, string dnsServer = null, QueryType? queryType = null,
+ bool start = true)
{
InitializeComponent();
- _viewModel = new DNSLookupViewModel(tabId, host, dnsServer, queryType);
+ _viewModel = new DNSLookupViewModel(tabId, host, dnsServer, queryType, start);
DataContext = _viewModel;
diff --git a/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs b/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs
index 43dc89ec95..6db9368339 100644
--- a/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs
+++ b/Source/NETworkManager/Views/IPGeolocationHostView.xaml.cs
@@ -15,9 +15,9 @@ public IPGeolocationHostView()
public void Open(ApplicationRequest request)
{
if (request.Profile != null)
- _viewModel.AddTab(request.Profile);
+ _viewModel.AddTab(request.Profile, request.Start);
else
- _viewModel.AddTab(request.Target);
+ _viewModel.AddTab(request.Target, start: request.Start);
}
public void OnViewHide()
diff --git a/Source/NETworkManager/Views/IPGeolocationView.xaml.cs b/Source/NETworkManager/Views/IPGeolocationView.xaml.cs
index daf31b4822..be635b9f20 100644
--- a/Source/NETworkManager/Views/IPGeolocationView.xaml.cs
+++ b/Source/NETworkManager/Views/IPGeolocationView.xaml.cs
@@ -9,11 +9,11 @@ public partial class IPGeolocationView : IDragablzTabItem
{
private readonly IPGeolocationViewModel _viewModel;
- public IPGeolocationView(Guid tabId, string domain = null)
+ public IPGeolocationView(Guid tabId, string domain = null, bool start = true)
{
InitializeComponent();
- _viewModel = new IPGeolocationViewModel(tabId, domain);
+ _viewModel = new IPGeolocationViewModel(tabId, domain, start);
DataContext = _viewModel;
diff --git a/Source/NETworkManager/Views/IPScannerHostView.xaml.cs b/Source/NETworkManager/Views/IPScannerHostView.xaml.cs
index 6593c40508..bb30ba9bb0 100644
--- a/Source/NETworkManager/Views/IPScannerHostView.xaml.cs
+++ b/Source/NETworkManager/Views/IPScannerHostView.xaml.cs
@@ -15,9 +15,9 @@ public IPScannerHostView()
public void Open(ApplicationRequest request)
{
if (request.Profile != null)
- _viewModel.AddTab(request.Profile);
+ _viewModel.AddTab(request.Profile, request.Start);
else
- _viewModel.AddTab(request.Target);
+ _viewModel.AddTab(request.Target, start: request.Start);
}
public void OnViewHide()
diff --git a/Source/NETworkManager/Views/IPScannerView.xaml.cs b/Source/NETworkManager/Views/IPScannerView.xaml.cs
index 322727dc1a..5f497fce09 100644
--- a/Source/NETworkManager/Views/IPScannerView.xaml.cs
+++ b/Source/NETworkManager/Views/IPScannerView.xaml.cs
@@ -17,11 +17,11 @@ public partial class IPScannerView : IDragablzTabItem
{
private readonly IPScannerViewModel _viewModel;
- public IPScannerView(Guid tabId, string hostOrIPRange = null)
+ public IPScannerView(Guid tabId, string hostOrIPRange = null, bool start = true)
{
InitializeComponent();
- _viewModel = new IPScannerViewModel(tabId, hostOrIPRange);
+ _viewModel = new IPScannerViewModel(tabId, hostOrIPRange, start);
DataContext = _viewModel;
diff --git a/Source/NETworkManager/Views/LookupHostView.xaml b/Source/NETworkManager/Views/LookupHostView.xaml
index c1e4ed3747..fbacd8c96e 100644
--- a/Source/NETworkManager/Views/LookupHostView.xaml
+++ b/Source/NETworkManager/Views/LookupHostView.xaml
@@ -8,11 +8,11 @@
xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:LookupHostViewModel}">
-
-
+
+
-
-
+
+
\ No newline at end of file
diff --git a/Source/NETworkManager/Views/LookupHostView.xaml.cs b/Source/NETworkManager/Views/LookupHostView.xaml.cs
index 6c4da5623f..d7667dbbab 100644
--- a/Source/NETworkManager/Views/LookupHostView.xaml.cs
+++ b/Source/NETworkManager/Views/LookupHostView.xaml.cs
@@ -1,8 +1,9 @@
-using NETworkManager.ViewModels;
+using NETworkManager.Models.Lookup;
+using NETworkManager.ViewModels;
namespace NETworkManager.Views;
-public partial class LookupHostView
+public partial class LookupHostView : IApplicationRequestHandler
{
private readonly LookupHostViewModel _viewModel = new();
@@ -12,6 +13,30 @@ public LookupHostView()
DataContext = _viewModel;
}
+ public void Open(ApplicationRequest request)
+ {
+ if (request is not LookupRequest lookupRequest)
+ return;
+
+ switch (lookupRequest.Type)
+ {
+ case LookupType.OUI:
+ TabItemOUILookup.IsSelected = true;
+
+ if (!string.IsNullOrEmpty(request.Target))
+ OUILookupView.Lookup(request.Target, request.Start);
+
+ break;
+ case LookupType.Port:
+ TabItemPortLookup.IsSelected = true;
+
+ if (!string.IsNullOrEmpty(request.Target))
+ PortLookupView.Lookup(request.Target, request.Start);
+
+ break;
+ }
+ }
+
public void OnViewHide()
{
_viewModel.OnViewHide();
diff --git a/Source/NETworkManager/Views/LookupOUILookupView.xaml.cs b/Source/NETworkManager/Views/LookupOUILookupView.xaml.cs
index 55fb466ff3..294366981c 100644
--- a/Source/NETworkManager/Views/LookupOUILookupView.xaml.cs
+++ b/Source/NETworkManager/Views/LookupOUILookupView.xaml.cs
@@ -15,6 +15,11 @@ public LookupOUILookupView()
DataContext = _viewModel;
}
+ public void Lookup(string search, bool start)
+ {
+ _viewModel.Lookup(search, start);
+ }
+
private void ContextMenu_Opened(object sender, RoutedEventArgs e)
{
if (sender is ContextMenu menu)
diff --git a/Source/NETworkManager/Views/LookupPortLookupView.xaml.cs b/Source/NETworkManager/Views/LookupPortLookupView.xaml.cs
index e27e35183c..7110d80c32 100644
--- a/Source/NETworkManager/Views/LookupPortLookupView.xaml.cs
+++ b/Source/NETworkManager/Views/LookupPortLookupView.xaml.cs
@@ -15,6 +15,11 @@ public LookupPortLookupView()
DataContext = _viewModel;
}
+ public void Lookup(string search, bool start)
+ {
+ _ = _viewModel.Lookup(search, start);
+ }
+
private void ContextMenu_Opened(object sender, RoutedEventArgs e)
{
if (sender is ContextMenu menu)
diff --git a/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs b/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs
index 7ff2b61c9b..fcc4350ddd 100644
--- a/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs
+++ b/Source/NETworkManager/Views/PingMonitorHostView.xaml.cs
@@ -17,9 +17,9 @@ public PingMonitorHostView()
public void Open(ApplicationRequest request)
{
if (request.Profile != null)
- _viewModel.AddHost(request.Profile);
+ _viewModel.AddHost(request.Profile, request.Start);
else
- _viewModel.AddHost(request.Target);
+ _viewModel.AddHost(request.Target, request.Start);
}
public void OnViewHide()
diff --git a/Source/NETworkManager/Views/PortScannerHostView.xaml.cs b/Source/NETworkManager/Views/PortScannerHostView.xaml.cs
index 99d078cc8c..61166f7bac 100644
--- a/Source/NETworkManager/Views/PortScannerHostView.xaml.cs
+++ b/Source/NETworkManager/Views/PortScannerHostView.xaml.cs
@@ -15,9 +15,9 @@ public PortScannerHostView()
public void Open(ApplicationRequest request)
{
if (request.Profile != null)
- _viewModel.AddTab(request.Profile);
+ _viewModel.AddTab(request.Profile, request.Start);
else
- _viewModel.AddTab(request.Target, (request as PortScannerRequest)?.Ports);
+ _viewModel.AddTab(request.Target, (request as PortScannerRequest)?.Ports, request.Start);
}
public void OnViewHide()
diff --git a/Source/NETworkManager/Views/PortScannerView.xaml.cs b/Source/NETworkManager/Views/PortScannerView.xaml.cs
index 4daa36e746..2265162acf 100644
--- a/Source/NETworkManager/Views/PortScannerView.xaml.cs
+++ b/Source/NETworkManager/Views/PortScannerView.xaml.cs
@@ -10,11 +10,11 @@ public partial class PortScannerView : IDragablzTabItem
{
private readonly PortScannerViewModel _viewModel;
- public PortScannerView(Guid tabId, string host = null, string ports = null)
+ public PortScannerView(Guid tabId, string host = null, string ports = null, bool start = true)
{
InitializeComponent();
- _viewModel = new PortScannerViewModel(tabId, host, ports);
+ _viewModel = new PortScannerViewModel(tabId, host, ports, start);
DataContext = _viewModel;
diff --git a/Source/NETworkManager/Views/RDAPHostView.xaml.cs b/Source/NETworkManager/Views/RDAPHostView.xaml.cs
index 36118d9575..6c03c3c6fd 100644
--- a/Source/NETworkManager/Views/RDAPHostView.xaml.cs
+++ b/Source/NETworkManager/Views/RDAPHostView.xaml.cs
@@ -15,9 +15,9 @@ public RDAPHostView()
public void Open(ApplicationRequest request)
{
if (request.Profile != null)
- _viewModel.AddTab(request.Profile);
+ _viewModel.AddTab(request.Profile, request.Start);
else
- _viewModel.AddTab(request.Target, (request as RDAPRequest)?.QueryType);
+ _viewModel.AddTab(request.Target, (request as RDAPRequest)?.QueryType, request.Start);
}
public void OnViewHide()
diff --git a/Source/NETworkManager/Views/RDAPView.xaml.cs b/Source/NETworkManager/Views/RDAPView.xaml.cs
index 2322c645ed..e1d428a5a3 100644
--- a/Source/NETworkManager/Views/RDAPView.xaml.cs
+++ b/Source/NETworkManager/Views/RDAPView.xaml.cs
@@ -12,11 +12,11 @@ public partial class RDAPView : IDragablzTabItem
{
private readonly RDAPViewModel _viewModel;
- public RDAPView(Guid tabId, string query = null, RDAPQueryType? queryType = null)
+ public RDAPView(Guid tabId, string query = null, RDAPQueryType? queryType = null, bool start = true)
{
InitializeComponent();
- _viewModel = new RDAPViewModel(tabId, query, queryType);
+ _viewModel = new RDAPViewModel(tabId, query, queryType, start);
DataContext = _viewModel;
diff --git a/Source/NETworkManager/Views/RunCommandChildWindow.xaml b/Source/NETworkManager/Views/RunCommandChildWindow.xaml
index 3b90ff6d5a..483da6535c 100644
--- a/Source/NETworkManager/Views/RunCommandChildWindow.xaml
+++ b/Source/NETworkManager/Views/RunCommandChildWindow.xaml
@@ -5,9 +5,11 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:viewModels="clr-namespace:NETworkManager.ViewModels"
xmlns:networkManager="clr-namespace:NETworkManager"
+ xmlns:views="clr-namespace:NETworkManager.Views"
xmlns:models="clr-namespace:NETworkManager.Models;assembly=NETworkManager.Models"
xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters"
xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
+ xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF"
xmlns:simpleChildWindow="clr-namespace:MahApps.Metro.SimpleChildWindow;assembly=MahApps.Metro.SimpleChildWindow"
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
@@ -15,7 +17,6 @@
Title="{x:Static localization:Strings.RunCommand}"
CloseButtonCommand="{Binding CancelCommand}"
AllowMove="False"
- ChildWindowWidth="650"
VerticalContentAlignment="Top"
Padding="0,80,0,0"
Loaded="ChildWindow_OnLoaded"
@@ -24,6 +25,10 @@
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs b/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs
index f3ccd7cd0c..33073b6c96 100644
--- a/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs
+++ b/Source/NETworkManager/Views/RunCommandChildWindow.xaml.cs
@@ -2,31 +2,122 @@
using System;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
using System.Windows.Input;
+using System.Windows.Media;
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.
+/// Run command dialog. The input keeps the focus; Up/Down (PageUp/PageDown) select a suggestion, Tab completes
+/// it, Enter runs it (Ctrl+Enter keeps the dialog open, Shift+Enter only fills in), Shift+Del removes a recent
+/// command and Escape closes the dialog.
///
public partial class RunCommandChildWindow
{
- public RunCommandChildWindow()
+ ///
+ /// Width of the dialog in relation to the main window, between and
+ /// .
+ ///
+ private const double DialogWidthFactor = 0.5;
+
+ private const double MinDialogWidth = 650;
+ private const double MaxDialogWidth = 800;
+
+ ///
+ /// Height of the main window that is not available for the dialog (title bar of the main window, the space
+ /// above the dialog (padding) and below it).
+ ///
+ private const double ReservedHeight = 160;
+
+ ///
+ /// Maximum height of the dialog: at least the default of the child windows, at most
+ /// . The suggestions get the height that is left after the other rows and scroll,
+ /// so the key hints at the bottom stay visible.
+ ///
+ private const double MinDialogHeight = 500;
+
+ private const double MaxDialogHeight = 900;
+
+ ///
+ /// Width of the dialog that is not available for the text on the right of a suggestion (e.g. the syntax of a
+ /// command): icon, name and description, margins and scroll bar.
+ ///
+ private const double ReservedInfoWidth = 300;
+
+ ///
+ /// Maximum width of the text on the right of a suggestion, grows with the dialog (see ).
+ ///
+ public static readonly DependencyProperty InfoMaxWidthProperty = DependencyProperty.Register(
+ nameof(InfoMaxWidth), typeof(double), typeof(RunCommandChildWindow), new PropertyMetadata(250.0));
+
+ public double InfoMaxWidth
+ {
+ get => (double)GetValue(InfoMaxWidthProperty);
+ set => SetValue(InfoMaxWidthProperty, value);
+ }
+
+ private readonly Window _parentWindow;
+
+ public RunCommandChildWindow(Window parentWindow)
{
InitializeComponent();
+
+ _parentWindow = parentWindow;
+
+ UpdateSize();
+
+ // Update the size when the main window is resized (while the dialog is open)
+ _parentWindow.SizeChanged += ParentWindow_SizeChanged;
+ Unloaded += (_, _) =>
+ {
+ _parentWindow.SizeChanged -= ParentWindow_SizeChanged;
+ ToolTipInvalid.IsOpen = false;
+ };
+ }
+
+ private void ParentWindow_SizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ UpdateSize();
+ }
+
+ ///
+ /// Uses more space for the dialog and the suggestions in a large main window.
+ ///
+ private void UpdateSize()
+ {
+ ChildWindowWidth = Math.Clamp(_parentWindow.ActualWidth * DialogWidthFactor, MinDialogWidth, MaxDialogWidth);
+ InfoMaxWidth = ChildWindowWidth - ReservedInfoWidth;
+ ChildWindowMaxHeight = Math.Clamp(_parentWindow.ActualHeight - ReservedHeight, MinDialogHeight,
+ MaxDialogHeight);
}
private RunCommandViewModel ViewModel => (RunCommandViewModel)DataContext;
private void ChildWindow_OnLoaded(object sender, RoutedEventArgs e)
{
- Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(delegate { TextBoxInput.Focus(); }));
+ FocusInput();
+ }
+
+ ///
+ /// Focuses the input with the caret at the end, after the opened application (if the dialog is kept open) is
+ /// loaded and may have taken the focus.
+ ///
+ private void FocusInput()
+ {
+ Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(delegate
+ {
+ TextBoxInput.Focus();
+ TextBoxInput.CaretIndex = TextBoxInput.Text.Length;
+ }));
}
private void TextBoxInput_OnPreviewKeyDown(object sender, KeyEventArgs e)
{
+ // The error of a failed run is shown until the next key.
+ ToolTipInvalid.IsOpen = false;
+
switch (e.Key)
{
case Key.Down:
@@ -35,12 +126,23 @@ private void TextBoxInput_OnPreviewKeyDown(object sender, KeyEventArgs e)
case Key.Up:
ViewModel.SelectPrevious();
break;
+ // Home and End move the caret in the input.
+ case Key.PageDown:
+ ViewModel.SelectNextPage();
+ break;
+ case Key.PageUp:
+ ViewModel.SelectPreviousPage();
+ break;
case Key.Tab:
ViewModel.Complete();
TextBoxInput.CaretIndex = TextBoxInput.Text.Length;
break;
case Key.Enter:
- ViewModel.Run();
+ RunAndShowError();
+ break;
+ // Only for a recent command, otherwise Shift+Del cuts the text.
+ case Key.Delete when Keyboard.Modifiers == ModifierKeys.Shift && ViewModel.CanRemoveSelectedSuggestion:
+ ViewModel.RemoveSuggestion(ViewModel.SelectedSuggestion);
break;
case Key.Escape:
ViewModel.CancelCommand.Execute(null);
@@ -54,16 +156,83 @@ private void TextBoxInput_OnPreviewKeyDown(object sender, KeyEventArgs e)
private void ListBoxSuggestions_OnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
+ // A button in the suggestion (e.g. remove from history) handles the click itself.
+ if (IsInButton((DependencyObject)e.OriginalSource))
+ return;
+
if (ItemsControl.ContainerFromElement(ListBoxSuggestions, (DependencyObject)e.OriginalSource) is not
ListBoxItem item)
return;
ViewModel.SelectedSuggestion = (RunCommandSuggestion)item.DataContext;
- ViewModel.Run();
- // The dialog stays open if the input is not valid (otherwise the opened application or dialog gets the focus).
+ RunAndShowError();
+ }
+
+ ///
+ /// Runs the selected suggestion or the input (Enter or click), with Ctrl to keep the dialog open and with Shift to
+ /// only fill in. The dialog stays open if the input is not valid (the error is shown) or with Ctrl, then the input
+ /// gets the focus again (otherwise the opened application or dialog gets the focus).
+ ///
+ private void RunAndShowError()
+ {
+ var success = ViewModel.Run(Keyboard.Modifiers switch
+ {
+ ModifierKeys.Control => RunCommandRunMode.RunAndKeepOpen,
+ ModifierKeys.Shift => RunCommandRunMode.FillIn,
+ _ => RunCommandRunMode.Run
+ });
+
+ if (!success)
+ ShowError();
+
if (IsOpen)
- TextBoxInput.Focus();
+ FocusInput();
+ }
+
+ ///
+ /// Opens the tooltip of the warning icon with the error of a failed run, so it is visible without hovering.
+ ///
+ private void ShowError()
+ {
+ // After the layout, since the icon may have just been shown.
+ Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(delegate
+ {
+ if (!RectangleInvalid.IsVisible)
+ return;
+
+ ToolTipInvalid.PlacementTarget = RectangleInvalid;
+ ToolTipInvalid.Placement = PlacementMode.Bottom;
+ ToolTipInvalid.IsOpen = true;
+ }));
+ }
+
+ private void RectangleInvalid_OnIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
+ {
+ // The input is valid again or was cleared (e.g. with the clear button).
+ if (!RectangleInvalid.IsVisible)
+ ToolTipInvalid.IsOpen = false;
+ }
+
+ private void ButtonRemoveSuggestion_OnClick(object sender, RoutedEventArgs e)
+ {
+ ViewModel.RemoveSuggestion((RunCommandSuggestion)((FrameworkElement)sender).DataContext);
+
+ TextBoxInput.Focus();
+ }
+
+ private bool IsInButton(DependencyObject element)
+ {
+ while (element != null && element != ListBoxSuggestions)
+ {
+ if (element is Button)
+ return true;
+
+ // Text elements (e.g. a Run in a TextBlock) are not visuals
+ element = element is Visual ? VisualTreeHelper.GetParent(element) : LogicalTreeHelper.GetParent(element);
+ }
+
+ return false;
}
private void ListBoxSuggestions_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
diff --git a/Source/NETworkManager/Views/SettingsGeneralView.xaml b/Source/NETworkManager/Views/SettingsGeneralView.xaml
index 4215a2dc17..358940e7ee 100644
--- a/Source/NETworkManager/Views/SettingsGeneralView.xaml
+++ b/Source/NETworkManager/Views/SettingsGeneralView.xaml
@@ -186,6 +186,16 @@
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Source/NETworkManager/Views/SettingsHotKeysView.xaml b/Source/NETworkManager/Views/SettingsHotKeysView.xaml
index 4697c1ffdb..94f6b4ea00 100644
--- a/Source/NETworkManager/Views/SettingsHotKeysView.xaml
+++ b/Source/NETworkManager/Views/SettingsHotKeysView.xaml
@@ -12,6 +12,10 @@
+
+
\ No newline at end of file
diff --git a/Source/NETworkManager/Views/SubnetCalculatorCalculatorView.xaml.cs b/Source/NETworkManager/Views/SubnetCalculatorCalculatorView.xaml.cs
index bbd799a8c4..ca1d865454 100644
--- a/Source/NETworkManager/Views/SubnetCalculatorCalculatorView.xaml.cs
+++ b/Source/NETworkManager/Views/SubnetCalculatorCalculatorView.xaml.cs
@@ -11,4 +11,9 @@ public SubnetCalculatorCalculatorView()
InitializeComponent();
DataContext = _viewModel;
}
+
+ public void Calculate(string subnet, bool start)
+ {
+ _viewModel.Calculate(subnet, start);
+ }
}
\ No newline at end of file
diff --git a/Source/NETworkManager/Views/SubnetCalculatorHostView.xaml b/Source/NETworkManager/Views/SubnetCalculatorHostView.xaml
index 182a6ec549..2c34a3d228 100644
--- a/Source/NETworkManager/Views/SubnetCalculatorHostView.xaml
+++ b/Source/NETworkManager/Views/SubnetCalculatorHostView.xaml
@@ -8,14 +8,14 @@
xmlns:viewModels="clr-namespace:NETworkManager.ViewModels"
mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:SubnetCalculatorHostViewModel}">
-
-
+
+
-
-
+
+
-
-
+
+
\ No newline at end of file
diff --git a/Source/NETworkManager/Views/SubnetCalculatorHostView.xaml.cs b/Source/NETworkManager/Views/SubnetCalculatorHostView.xaml.cs
index ce32f49bf8..61dc6489ee 100644
--- a/Source/NETworkManager/Views/SubnetCalculatorHostView.xaml.cs
+++ b/Source/NETworkManager/Views/SubnetCalculatorHostView.xaml.cs
@@ -2,7 +2,7 @@
namespace NETworkManager.Views;
-public partial class SubnetCalculatorHostView
+public partial class SubnetCalculatorHostView : IApplicationRequestHandler
{
private readonly SubnetCalculatorHostViewModel _viewModel = new();
@@ -12,6 +12,27 @@ public SubnetCalculatorHostView()
DataContext = _viewModel;
}
+ public void Open(ApplicationRequest request)
+ {
+ var subnetCalculatorRequest = request as SubnetCalculatorRequest;
+
+ if (subnetCalculatorRequest?.NewSubnetmask != null)
+ {
+ TabItemSubnetting.IsSelected = true;
+ SubnettingView.Calculate(request.Target, subnetCalculatorRequest.NewSubnetmask, request.Start);
+ }
+ else if (subnetCalculatorRequest?.Subnet2 != null)
+ {
+ TabItemWideSubnet.IsSelected = true;
+ WideSubnetView.Calculate(request.Target, subnetCalculatorRequest.Subnet2, request.Start);
+ }
+ else
+ {
+ TabItemCalculator.IsSelected = true;
+ CalculatorView.Calculate(request.Target, request.Start);
+ }
+ }
+
public void OnViewHide()
{
_viewModel.OnViewHide();
diff --git a/Source/NETworkManager/Views/SubnetCalculatorSubnettingView.xaml.cs b/Source/NETworkManager/Views/SubnetCalculatorSubnettingView.xaml.cs
index 6249b54769..de11eff792 100644
--- a/Source/NETworkManager/Views/SubnetCalculatorSubnettingView.xaml.cs
+++ b/Source/NETworkManager/Views/SubnetCalculatorSubnettingView.xaml.cs
@@ -17,6 +17,11 @@ public SubnetCalculatorSubnettingView()
Dispatcher.ShutdownStarted += Dispatcher_ShutdownStarted;
}
+ public void Calculate(string subnet, string newSubnetmask, bool start)
+ {
+ _ = _viewModel.Calculate(subnet, newSubnetmask, start);
+ }
+
private void Dispatcher_ShutdownStarted(object sender, EventArgs e)
{
_viewModel.OnShutdown();
diff --git a/Source/NETworkManager/Views/SubnetCalculatorWideSubnetView.xaml.cs b/Source/NETworkManager/Views/SubnetCalculatorWideSubnetView.xaml.cs
index 48bac713bd..3e5b368b3f 100644
--- a/Source/NETworkManager/Views/SubnetCalculatorWideSubnetView.xaml.cs
+++ b/Source/NETworkManager/Views/SubnetCalculatorWideSubnetView.xaml.cs
@@ -15,6 +15,11 @@ public SubnetCalculatorWideSubnetView()
Dispatcher.ShutdownStarted += Dispatcher_ShutdownStarted;
}
+ public void Calculate(string subnet1, string subnet2, bool start)
+ {
+ _viewModel.Calculate(subnet1, subnet2, start);
+ }
+
private void Dispatcher_ShutdownStarted(object sender, EventArgs e)
{
_viewModel.OnShutdown();
diff --git a/Source/NETworkManager/Views/TracerouteHostView.xaml.cs b/Source/NETworkManager/Views/TracerouteHostView.xaml.cs
index 84c6255eee..35c325fb2d 100644
--- a/Source/NETworkManager/Views/TracerouteHostView.xaml.cs
+++ b/Source/NETworkManager/Views/TracerouteHostView.xaml.cs
@@ -15,9 +15,9 @@ public TracerouteHostView()
public void Open(ApplicationRequest request)
{
if (request.Profile != null)
- _viewModel.AddTab(request.Profile);
+ _viewModel.AddTab(request.Profile, request.Start);
else
- _viewModel.AddTab(request.Target);
+ _viewModel.AddTab(request.Target, start: request.Start);
}
public void OnViewHide()
diff --git a/Source/NETworkManager/Views/TracerouteView.xaml.cs b/Source/NETworkManager/Views/TracerouteView.xaml.cs
index 24bdd78923..4cc94e8186 100644
--- a/Source/NETworkManager/Views/TracerouteView.xaml.cs
+++ b/Source/NETworkManager/Views/TracerouteView.xaml.cs
@@ -19,11 +19,11 @@ public partial class TracerouteView : IDragablzTabItem
// window size.
private const double MapRowMaxHeightFraction = 2.0 / 3.0;
- public TracerouteView(Guid tabId, string host = null)
+ public TracerouteView(Guid tabId, string host = null, bool start = true)
{
InitializeComponent();
- _viewModel = new TracerouteViewModel(tabId, host);
+ _viewModel = new TracerouteViewModel(tabId, host, start);
DataContext = _viewModel;
diff --git a/Source/NETworkManager/Views/WakeOnLANView.xaml.cs b/Source/NETworkManager/Views/WakeOnLANView.xaml.cs
index 6e5977a651..2728e3dba5 100644
--- a/Source/NETworkManager/Views/WakeOnLANView.xaml.cs
+++ b/Source/NETworkManager/Views/WakeOnLANView.xaml.cs
@@ -15,9 +15,9 @@ public WakeOnLANView()
public void Open(ApplicationRequest request)
{
if (request.Profile != null)
- _viewModel.WakeUp(request.Profile);
+ _viewModel.WakeUp(request.Profile, request.Start);
else
- _viewModel.WakeUp(request.Target, (request as WakeOnLANRequest)?.Broadcast);
+ _viewModel.WakeUp(request.Target, (request as WakeOnLANRequest)?.Broadcast, request.Start);
}
public void OnViewHide()
diff --git a/Source/NETworkManager/Views/WhoisHostView.xaml.cs b/Source/NETworkManager/Views/WhoisHostView.xaml.cs
index ba30c03c69..2340985f7b 100644
--- a/Source/NETworkManager/Views/WhoisHostView.xaml.cs
+++ b/Source/NETworkManager/Views/WhoisHostView.xaml.cs
@@ -15,9 +15,9 @@ public WhoisHostView()
public void Open(ApplicationRequest request)
{
if (request.Profile != null)
- _viewModel.AddTab(request.Profile);
+ _viewModel.AddTab(request.Profile, request.Start);
else
- _viewModel.AddTab(request.Target);
+ _viewModel.AddTab(request.Target, start: request.Start);
}
public void OnViewHide()
diff --git a/Source/NETworkManager/Views/WhoisView.xaml.cs b/Source/NETworkManager/Views/WhoisView.xaml.cs
index de23752f5a..372f6f5835 100644
--- a/Source/NETworkManager/Views/WhoisView.xaml.cs
+++ b/Source/NETworkManager/Views/WhoisView.xaml.cs
@@ -9,11 +9,11 @@ public partial class WhoisView : IDragablzTabItem
{
private readonly WhoisViewModel _viewModel;
- public WhoisView(Guid tabId, string domain = null)
+ public WhoisView(Guid tabId, string domain = null, bool start = true)
{
InitializeComponent();
- _viewModel = new WhoisViewModel(tabId, domain);
+ _viewModel = new WhoisViewModel(tabId, domain, start);
DataContext = _viewModel;
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index a280bc5a22..2f9d19a0f8 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -28,6 +28,10 @@ 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. [#3644](https://github.com/BornToBeRoot/NETworkManager/pull/3644)
+- New run command targets for the **Subnet Calculator** (`subnet 10.0.0.0/24`, `subnet 10.0.0.0/16 /24` for subnetting, `subnet 10.0.0.0/24 10.0.1.0/24` for the wide subnet) and the **Lookup** (`lookup oui 00:11:22`, `lookup port 22; ssh`). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+- New global hotkey to bring the window to the foreground and open the run command (default `Ctrl+Alt+P`, disabled by default), see [Settings > HotKeys](../settings/hotkeys.md). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+- Run command: the **Port Scanner** needs ports, which can also be the name of a port profile (e.g. `portscan 10.0.0.1 webserver`, suggested after the host). Own history size for the run command (default 20, [Settings > General](../settings/general.md#number-of-stored-entries-run-command)). `Tab` also completes profiles and port profiles, `PageUp`/`PageDown` move the selection by 5 suggestions. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+- Run command dialog redesigned: the icon of the recognized feature, the syntax with the part being typed highlighted, the values as they were understood (e.g. `Hosts: borntoberoot.net`, `Type: AAAA`), validation while typing, grouped suggestions with the typed text highlighted, examples and key hints (can be hidden in the settings). The dialog uses more space in a large window. The title bar shows a **Run command...** box in the center (like the command center of VS Code) instead of the icon. Type only a target (e.g. `10.0.0.1`, `server-01`, `00:11:22:33:44:55`, `10.0.0.0/24`) to get the commands that accept it, recent commands are also suggested while typing (e.g. a host); optionally, a target in the clipboard is suggested ([Settings > General](../settings/general.md#suggest-the-clipboard-content-as-target)). `Ctrl+Enter` runs and keeps the dialog open, `Shift+Enter` fills in the input without starting, `Shift+Del` or the remove button removes a recent command. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
**RDAP**
@@ -184,6 +188,25 @@ Release date: **xx.xx.2026**
- Fixed the connection status message (e.g. "Connecting to ...") being hidden too early, while still connecting, if a previous message (e.g. after connecting or disconnecting) was shown shortly before. Each message is now shown for 15 seconds. [#3638](https://github.com/BornToBeRoot/NETworkManager/pull/3638)
+**History**
+
+- Fixed adding an entry to the history of an input (e.g. hosts, ports, domains) failing when the number of stored entries is set to `0` in [Settings > General](../settings/general.md#number-of-stored-entries). With `0`, no entries are stored. Removing an entry from the history (also the oldest one, shared by several tabs) no longer clears the input that shows it. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+
+**Input validation**
+
+- Fixed host names being accepted with a hyphen at the start or end of a label other than the first (e.g. `server.-bad-.com`) and with only digits (e.g. `10.0.0.` or `192.168.1`, which are an incomplete IPv4 address). The same applies to the domain in **Whois**. IPv4 addresses are still accepted in all host fields, now checked explicitly in the **Remote Desktop** host field. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+- Fixed MAC addresses and their first 3 bytes (**Wake on LAN**, **Lookup** > **OUI**, profiles) being accepted with any character instead of a dot in the format `0000.0000.0000` (e.g. `0011 2233 4455`). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+- Fixed IPv6 subnets in the **Subnet Calculator** being accepted with a leading `s` (e.g. `sss2001:db8::/32`) and rejected with an embedded IPv4 address (e.g. `::ffff:192.168.0.1/96`). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+- Fixed the port of the **Remote Desktop** host field (`host:port`) being accepted empty or as `0`. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+- Fixed the application freezing when validating an invalid domain with many labels in **Whois** (exponential backtracking of the regular expression). [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+- Fixed IPv6 subnets with whitespace or a zone index (e.g. `fe80::1%eth0/64`) being accepted in the **Subnet Calculator**, which can't calculate them. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+- Fixed an empty entry in **Lookup** > **OUI** / **Port** (e.g. `22;;80`) finding every vendor or service. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+- Fixed **Lookup** > **Port** searching for a port with an upper-case protocol (e.g. `22/TCP`) as a service instead of the port and protocol. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+
+**Subnet Calculator**
+
+- Fixed **Subnetting** getting stuck when the new subnet mask is smaller than the subnet mask of the subnet (e.g. `10.0.0.0/24` with `/16`) or too large for the address family (e.g. `/40` for IPv4). An error is shown instead. [#3646](https://github.com/BornToBeRoot/NETworkManager/pull/3646)
+
## Dependencies, Refactoring & Documentation
- Code cleanup & refactoring
diff --git a/Website/docs/run-command.md b/Website/docs/run-command.md
index 859e7310af..81f9fcfc4e 100644
--- a/Website/docs/run-command.md
+++ b/Website/docs/run-command.md
@@ -8,7 +8,7 @@ keywords: [NETworkManager, run command, command palette, keyboard shortcut, Ctrl
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.
+The run command can also be opened with the **Run command...** box in the center of the title bar, or from anywhere with a global hotkey (default `Ctrl+Alt+P`, disabled by default), which brings the window to the foreground, see [Settings > HotKeys](./settings/hotkeys.md).
## Syntax
@@ -28,8 +28,8 @@ The input is validated like the input field of the feature (e.g. a valid host or
| Command | Aliases | Arguments | Example |
| ------------------ | ------------------ | ----------------------------------- | ---------------------------------------- |
-| `ipscanner` | `scan` | `` | `scan 10.0.0.0/24` |
-| `portscanner` | `portscan` | ` [ports]` | `portscan 10.0.0.1 22; 80; 443` |
+| `ipscanner` | `ipscan` | `` | `ipscan 10.0.0.0/24` |
+| `portscanner` | `portscan` | `` | `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` |
@@ -43,38 +43,79 @@ The input is validated like the input field of the feature (e.g. a valid host or
| `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` |
+| `subnetcalculator` | `subnet`, `ipcalc` | ` [new mask \| subnet 2]` | `subnet 10.0.0.0/16 /24` |
+| `lookup` | | `` | `lookup port 22; 443; ssh` |
| `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.
+All features that accept a target also accept a profile (`@profile`), except the Subnet Calculator and the Lookup, which have no profiles. The other features (e.g. `firewall`, `listeners`) 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 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)).
+- **Subnet Calculator:** A subnet (e.g. `subnet 10.0.0.0/24`) is calculated in **Calculator**. With a new subnet mask or CIDR (e.g. `/26`, `26` or `255.255.255.192`), it is split in **Subnetting**; the new mask must be between the CIDR of the subnet and `/32` (IPv4) or `/128` (IPv6). With a second subnet (e.g. `subnet 10.0.0.0/24 10.0.1.0/24`), the **Wide Subnet** is calculated (IPv4 only).
+- **Lookup:** The first word after the command is the lookup: `oui` (MAC addresses or vendors, e.g. `lookup oui 00:11:22`) or `port` (ports, port ranges or services, e.g. `lookup port 22; 80-90/tcp; ssh`). The rest of the input is the query, so vendors can contain spaces without quotes (e.g. `lookup oui Cisco Systems`). Without a query (e.g. `lookup port`), the lookup is only shown.
+- **Port Scanner:** The ports are required (a new tab has none): ports and port ranges (e.g. `22; 80; 443`, `1-1024`) or the name of a port profile of the Port Scanner (case-insensitive, e.g. `portscan 10.0.0.1 webserver`; a name with spaces in quotes). The port profiles are suggested after the host.
+- **SNMP:** Opens a tab with the host; the query is started in the feature (as before), also with `Enter`.
- **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.
:::
+## Target without a command
+
+Type only the target (e.g. `10.0.0.1`) to get the commands that accept it, e.g. `ping 10.0.0.1`, `trace 10.0.0.1`, `dns 10.0.0.1`. Commands that were used with the same kind of target before (e.g. IP addresses) come first, otherwise they are in the order of the table. `Enter` runs the first command, which is selected; if it was already run with this target, it is shown under the recent commands, which are listed above. A host without a domain and a port are not selected, since they can also be a mistyped command or the start of an IP address (e.g. `192`). A shortened or partly typed IP address (e.g. `192.168.1`) is not a target.
+
+| Target | Suggested commands |
+| --------------------------------------------------- | ----------------------------------------------------------------------------------- |
+| IP address or host with a domain (`server.lan`) | Ping Monitor, Traceroute, DNS Lookup, remote tools, IP Geolocation, Whois, … |
+| Host without a domain (`server01`) | Ping Monitor, Traceroute, DNS Lookup, remote tools, SNMP, IP Scanner |
+| Several hosts (`10.0.0.1; 10.0.0.2`) | Ping Monitor, DNS Lookup, IP Scanner |
+| IP range (`10.0.0.1-100`) or subnet (`10.0.0.0/24`) | IP Scanner, Subnet Calculator (subnet), Ping Monitor, RDAP (subnet) |
+| MAC address (`00:11:22:33:44:55`) | Wake on LAN, `lookup oui` |
+| First 3 bytes of a MAC address (`00:11:22`) | `lookup oui` |
+| Port(s) (`22`, `80-90/tcp`) | `lookup port` |
+| AS number (`AS13335`) | RDAP |
+| URL (`https://...`) | Web Console |
+
+A command always has priority (e.g. `web` is the Web Console). A list (e.g. `10.0.0.1; server01`) is a target if at least one entry is an IP address, a host with a domain, a range or a subnet; with a range or subnet, it is handled like an IP range. Ports are `1` to `65535`. A host without a domain (e.g. `server01`) can also be a mistyped command, so its commands are only suggested if no command starts with the word (e.g. not for `pi`), and none is selected: select one with the arrow keys.
+
+**Clipboard:** If enabled in [Settings > General](./settings/general.md#suggest-the-clipboard-content-as-target), the commands for a target in the clipboard (e.g. a copied IP address) are suggested when the dialog is opened (up to 3), and `Enter` runs the first one. While typing the command, the commands for the clipboard that start with it are suggested (e.g. `pi` → `ping 10.0.0.1`). The clipboard is read when the dialog is opened; a single word (host without a domain) is not suggested. Disabled by default.
+
+## Dialog
+
+- **Icon:** The icon of the recognized feature is shown in front of the input.
+- **Syntax:** Below the input, the syntax of the command is shown; the part that is being typed is highlighted (e.g. `[DNS server]`). After the target, the argument comes before the type (e.g. DNS server, then record type), unless a complete type is typed (e.g. `aaaa`).
+- **Values:** The values as they were understood are shown (e.g. `Hosts: borntoberoot.net` · `DNS server: 1.1.1.1` · `Type: AAAA`).
+- **Validation:** While typing, what `Enter` runs is validated: a green check mark for valid input, or a red warning icon with the error as tooltip (e.g. an invalid host). If `Enter` is pressed with an invalid input, the dialog stays open and the tooltip with the error is shown until the next key.
+- **Size:** The dialog and the suggestions use more space in a large window.
+
## Suggestions
-While typing, suggestions are shown below the input:
+While typing, suggestions are shown below the input, grouped (e.g. _Recent_, _Profiles_, _Type_). The typed text is highlighted.
| Input | Suggestions |
| ---------------------------- | ------------------------------------------------------------------------ |
-| _empty_ | Recently run commands and all features |
-| Command (e.g. `tr`) | Matching features |
-| 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 |
-
-`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 |
-| ------------ | ---------------------------------------------- |
-| `Up`, `Down` | Select a suggestion |
-| `Tab` | Insert the selected suggestion into the input |
-| `Enter` | Run the selected suggestion |
-| `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)).
+| _empty_ | Commands for the clipboard (if enabled), recently run commands, examples (without recent commands) and all features |
+| Command (e.g. `tr`) | Matching features, recent commands that contain the word (e.g. a host) and commands for the clipboard (if enabled) that start with it |
+| Target (e.g. `10.0.0.1`) | Recent commands that contain the target and the commands that accept it, see [Target without a command](#target-without-a-command) |
+| After the command (`rdp `) | Recent commands of the feature and its profiles |
+| After `lookup ` | The lookups (`oui`, `port`) and recent commands |
+| `@` (e.g. `rdp @web`) | Profiles of the feature (with group and host); exact names first, recently run profiles first within the same match |
+| After the target | Configured DNS servers (DNS lookup), port profiles (Port Scanner), types of the feature (e.g. DNS record types) and recent commands, up to 10 of each |
+
+`Enter` runs what was typed if no suggestion is selected: after the command, nothing is selected; profiles, recent commands, types and DNS servers are selected with the arrow keys (`Up` on the first suggestion selects none again). 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 an unknown command has arguments; for a target without a command, see [Target without a command](#target-without-a-command). With an empty input, only the first command for the clipboard is selected (if enabled). While typing `@`, a profile is only selected if exactly one matches. While typing the lookup (e.g. `lookup po`), the matching lookup is selected.
+
+| Key | Action |
+| ------------- | ----------------------------------------------------------------------------------------------- |
+| `Up`, `Down` | Select a suggestion (`PageUp`, `PageDown`: 5 suggestions) |
+| `Tab` | Insert the selected suggestion into the input. If none is selected, the first profile, DNS server, port profile or type that matches the typed word (e.g. `dns borntoberoot.net c` → `cloudflare`) |
+| `Enter` | Run the selected suggestion, or the input if none is selected |
+| `Ctrl+Enter` | Run and keep the dialog open, with the command in the input (e.g. `ping `) to run it with the next target. Commands that open a dialog (connect dialog of the remote tools, settings) close the dialog, as does a message of a feature (e.g. an error or the confirmation of a large subnetting). |
+| `Shift+Enter` | Open the feature with the input filled in, without starting it (e.g. to adjust the options of a scan). The remote tools open the connect dialog (target) or connect (profile) as with `Enter`. |
+| `Shift+Del` | Remove the selected recent command from the history (or click the remove button of a recent command) |
+| `Esc` | Close the dialog (or the close button) |
+
+The keys are also shown at the bottom of the dialog (can be hidden in [Settings > General](./settings/general.md#show-key-hints)).
+
+Run commands are saved in a history (20 entries by default, set in [Settings > General](./settings/general.md#number-of-stored-entries-run-command)).
diff --git a/Website/docs/settings/general.md b/Website/docs/settings/general.md
index 7b10fb579c..8f85399d7d 100644
--- a/Website/docs/settings/general.md
+++ b/Website/docs/settings/general.md
@@ -42,8 +42,32 @@ The value 0 will disable the background job. Changes to this value will take eff
### Number of stored entries
-Maximum number of entries stored in the history for several application inputs.
+Maximum number of entries stored in the history for several application inputs. With `0`, no entries are stored (existing entries are removed with the next input).
Type: `Integer`
Default: `5` [Min `0`, Max `25`]
+
+### Number of stored entries (Run command)
+
+Maximum number of commands stored in the history of the [run command](../run-command.md#suggestions). With `0`, no commands are stored.
+
+Type: `Integer`
+
+Default: `20` [Min `0`, Max `50`]
+
+### Suggest the clipboard content as target
+
+Suggest the commands for a target in the clipboard (e.g. a copied IP address, subnet or MAC address) when the [run command](../run-command.md#target-without-a-command) is opened. `Enter` runs the first one. While typing a command, the commands for the clipboard that start with it are suggested (e.g. `pi` → `ping 10.0.0.1`). A single word (host without a domain) is not suggested. The clipboard is only read when the run command is opened and is not stored.
+
+Type: `Boolean`
+
+Default: `Disabled`
+
+### Show key hints
+
+Show the keys (e.g. `Tab`, `Ctrl+Enter`) at the bottom of the [run command](../run-command.md#suggestions).
+
+Type: `Boolean`
+
+Default: `Enabled`
diff --git a/Website/docs/settings/hotkeys.md b/Website/docs/settings/hotkeys.md
index 8a305d082d..2ae75f2ed4 100644
--- a/Website/docs/settings/hotkeys.md
+++ b/Website/docs/settings/hotkeys.md
@@ -1,6 +1,6 @@
---
sidebar_position: 6
-description: "Configure keyboard shortcuts in NETworkManager to quickly bring the application to the foreground from the system tray."
+description: "Configure global keyboard shortcuts in NETworkManager to bring the application to the foreground from the system tray or to open the run command."
keywords: [NETworkManager, hotkeys, keyboard shortcuts, key bindings, system tray shortcut]
---
@@ -14,4 +14,14 @@ Untray the application and bring it to the foreground.
**Default:** `Disabled`
-**Default Hotkey:** `ALT + STRG + O`
+**Default Hotkey:** `Ctrl+Alt+O`
+
+### Bring window to foreground and open the run command
+
+Untray the application, bring it to the foreground and open the [run command](../run-command.md), so a feature can be run from anywhere (e.g. `ping 10.0.0.1`).
+
+**Type:** `Boolean`
+
+**Default:** `Disabled`
+
+**Default Hotkey:** `Ctrl+Alt+P`
diff --git a/Website/package.json b/Website/package.json
index dcd258afcc..f361ca429e 100644
--- a/Website/package.json
+++ b/Website/package.json
@@ -24,7 +24,7 @@
"clsx": "^2.0.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.3.1",
- "react-dom": "^19.3.0",
+ "react-dom": "^19.3.1",
"react-image-gallery": "^2.1.2"
},
"devDependencies": {
diff --git a/Website/yarn.lock b/Website/yarn.lock
index 12b66dbadf..56a5f31622 100644
--- a/Website/yarn.lock
+++ b/Website/yarn.lock
@@ -7683,10 +7683,10 @@ rc@1.2.8:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
-react-dom@^19.3.0:
- version "19.3.0"
- resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.3.0.tgz#a0ff19161d7f4e9be5469a07278b0c06e00f9001"
- integrity sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==
+react-dom@^19.3.1:
+ version "19.3.1"
+ resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.3.1.tgz#b384c53cfcbe9c2a5d09d863b98455de9d68afe2"
+ integrity sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==
dependencies:
scheduler "^0.28.0"