diff --git a/.github/assets/quickssh/action-confirmation.png b/.github/assets/quickssh/action-confirmation.png new file mode 100644 index 0000000..e9a71f9 Binary files /dev/null and b/.github/assets/quickssh/action-confirmation.png differ diff --git a/.github/assets/quickssh/main-menu.png b/.github/assets/quickssh/main-menu.png new file mode 100644 index 0000000..f017506 Binary files /dev/null and b/.github/assets/quickssh/main-menu.png differ diff --git a/.github/assets/quickssh/profiles.png b/.github/assets/quickssh/profiles.png new file mode 100644 index 0000000..1031d79 Binary files /dev/null and b/.github/assets/quickssh/profiles.png differ diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 47d25e7..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,50 +0,0 @@ -# AGENTS.md - -## Purpose - -This repository uses coding agents and automation. -Any agent working on this repository must follow these rules. - -## Required rules for all agents - -1. Never mark a Pull Request as complete if user-facing behavior changed and `README.md` was not updated. -2. If you add, remove, rename, or change a command, you must update: - - the command list in `README.md` - - usage examples in `README.md` - - any related behavior description in `README.md` -3. If you change installation, release, manifest, shell, import/export, config parsing, or profile behavior, update the relevant README sections. -4. If you add tests for a new feature, verify that the feature is also documented for users when applicable. -5. Do not downgrade workflow versions or reintroduce outdated workflow files from older branches. -6. Keep Pull Requests in Draft until: - - CI passes - - documentation is updated when required -7. For release intent, use labels: - - `release:patch` - - `release:minor` - - `release:major` - - `skip-release` - -## Pull Request completion checklist for agents - -Before marking a PR ready for review, confirm: - -- [ ] Code changes are complete -- [ ] CI passes -- [ ] `README.md` was updated if user-facing behavior changed -- [ ] Workflow files were kept aligned with current `main` -- [ ] Correct release label strategy is expected - -## Documentation policy - -When in doubt, update `README.md`. - -User-facing changes include, but are not limited to: -- new commands -- renamed commands -- changed command syntax -- changed examples -- changed shell behavior -- changed import/export behavior -- changed config parsing behavior -- changed profile management behavior -- changed installation or release flow diff --git a/ActionCommandBuilder.cs b/ActionCommandBuilder.cs new file mode 100644 index 0000000..cd659f4 --- /dev/null +++ b/ActionCommandBuilder.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; + +namespace Flow.Launcher.Plugin.QuickSSH +{ + /// + /// Builds an SSH command for a saved action without mutating the stored connection profile. + /// + public static class ActionCommandBuilder + { + /// + /// Creates a complete SSH command from a connection profile and a reusable remote action. + /// Returns false for missing data, SCP profiles, unsupported action kinds, or unsafe commands. + /// + public static bool TryBuild( + SshProfile profile, + CommandProfile action, + out string command) + { + command = string.Empty; + if (!TryCreateExecutionProfile(profile, action, out var executionProfile)) + return false; + + command = executionProfile.ToCommandLine(); + return !string.IsNullOrWhiteSpace(command); + } + + /// + /// Creates the same command in display form so Windows paths remain human-readable. + /// + public static bool TryBuildDisplay( + SshProfile profile, + CommandProfile action, + out string command) + { + command = string.Empty; + if (!TryCreateExecutionProfile(profile, action, out var executionProfile)) + return false; + + command = executionProfile.ToDisplayString(); + return !string.IsNullOrWhiteSpace(command); + } + + private static bool TryCreateExecutionProfile( + SshProfile profile, + CommandProfile action, + out SshProfile executionProfile) + { + executionProfile = null; + + if (profile == null || action == null) + return false; + + if (string.Equals(profile.Type, "scp", StringComparison.OrdinalIgnoreCase)) + return false; + + if (string.IsNullOrWhiteSpace(profile.HostName)) + return false; + + if (!action.IsSupportedKind || !CommandProfile.IsSafeToStore(action.Command)) + return false; + + executionProfile = new SshProfile + { + Type = "ssh", + HostName = profile.HostName, + User = profile.User, + Port = profile.Port, + IdentityFile = profile.IdentityFile, + IdentitiesOnly = profile.IdentitiesOnly, + RemoteCommand = action.Command, + RequestTTY = string.IsNullOrWhiteSpace(action.RequestTTY) + ? profile.RequestTTY + : action.RequestTTY, + LocalForward = profile.LocalForward == null + ? null + : new List(profile.LocalForward), + RemoteForward = profile.RemoteForward == null + ? null + : new List(profile.RemoteForward), + DynamicForward = profile.DynamicForward, + ProxyJump = profile.ProxyJump, + ProxyCommand = profile.ProxyCommand, + ExtraArgs = profile.ExtraArgs + }; + + return true; + } + } +} diff --git a/AutoCompleter.cs b/AutoCompleter.cs index 029cc8a..1970fbb 100644 --- a/AutoCompleter.cs +++ b/AutoCompleter.cs @@ -13,7 +13,7 @@ public class AutoCompleter /// private static readonly string[] VisibleCommands = new[] { - "profiles", "keys", "shell", "config", "help" + "profiles", "actions", "tools", "help" }; /// @@ -21,7 +21,15 @@ public class AutoCompleter /// private static readonly string[] ProfilesSubCommands = new[] { - "add", "remove", "rename", "copy", "export", "import" + "add", "manage", "rename", "remove", "copy", "export", "import" + }; + + /// + /// Sub-commands of "actions" that appear in suggestions after "actions ". + /// + private static readonly string[] ActionsSubCommands = new[] + { + "run", "add", "manage" }; /// @@ -29,7 +37,7 @@ public class AutoCompleter /// private static readonly string[] ShellSubCommands = new[] { - "add", "remove" + "manage", "add", "remove" }; /// @@ -37,7 +45,7 @@ public class AutoCompleter /// private static readonly string[] KeysSubCommands = new[] { - "add", "generate", "install", "remove", "rename", "copy-path", "copy-pub", "scan" + "install", "manage", "add", "generate", "rename", "remove", "copy-path", "copy-pub", "scan" }; /// @@ -46,9 +54,9 @@ public class AutoCompleter public static List GetSuggestions( string actionKeyword, string input, - UserData userData, + UserData? userData, string iconPath, - IPublicAPI api = null) + IPublicAPI? api = null) { var results = new List(); var trimmed = input.Trim().ToLowerInvariant(); @@ -65,7 +73,7 @@ public static List GetSuggestions( var autoText = actionKeyword + " " + cmd + " "; results.Add(new Result { - Title = cmd, + Title = GetCommandTitle(cmd), SubTitle = GetCommandDescription(cmd), IcoPath = iconPath, Score = GetTopLevelScore(cmd), @@ -98,7 +106,7 @@ public static List GetSuggestions( var autoText = actionKeyword + " " + cmd + " "; results.Add(new Result { - Title = cmd, + Title = GetCommandTitle(cmd), SubTitle = GetCommandDescription(cmd), IcoPath = iconPath, Action = _ => @@ -127,7 +135,7 @@ public static List GetSuggestions( { Title = sub, SubTitle = GetProfilesSubCommandDescription(sub), - IcoPath = iconPath, + IcoPath = QuickSsh.GetSemanticIconPath(sub), Action = _ => { api?.ChangeQuery(autoText, true); @@ -157,7 +165,70 @@ public static List GetSuggestions( { Title = profileName, SubTitle = profileDisplay, - IcoPath = iconPath, + IcoPath = QuickSsh.GetSemanticIconPath("saved"), + Action = _ => + { + api?.ChangeQuery(autoText, true); + return false; + }, + AutoCompleteText = autoText + }); + } + } + } + } + + // After "actions " suggest sub-commands and saved action names. + bool isActionsPrefix = prefixCheck.StartsWith("actions "); + if (isActionsPrefix) + { + var search = prefixCheck.Substring(8); // length of "actions " + + bool hasActions = userData?.CommandProfiles != null && + userData.CommandProfiles.Count > 0; + + foreach (var sub in ActionsSubCommands) + { + // A new user only needs Add. Run and management become useful + // after at least one action exists. + if (!hasActions && sub != "add") + continue; + + if (string.IsNullOrEmpty(search) || sub.StartsWith(search)) + { + var autoText = actionKeyword + " actions " + sub + " "; + results.Add(new Result + { + Title = sub, + SubTitle = GetActionsSubCommandDescription(sub), + IcoPath = QuickSsh.GetSemanticIconPath(sub), + Action = _ => + { + api?.ChangeQuery(autoText, true); + return false; + }, + AutoCompleteText = autoText + }); + } + } + + bool isExactSubCmd = ActionsSubCommands.Any(s => s == search); + if (!isExactSubCmd && userData?.CommandProfiles != null) + { + foreach (var entry in userData.CommandProfiles) + { + var actionName = entry.Key; + var actionDisplay = entry.Value?.ToDisplayString() ?? ""; + + if (string.IsNullOrEmpty(search) || + actionName.ToLowerInvariant().Contains(search)) + { + var autoText = actionKeyword + " actions use " + actionName + " "; + results.Add(new Result + { + Title = actionName, + SubTitle = actionDisplay, + IcoPath = QuickSsh.GetSemanticIconPath("saved"), Action = _ => { api?.ChangeQuery(autoText, true); @@ -185,7 +256,7 @@ public static List GetSuggestions( { Title = sub, SubTitle = GetShellSubCommandDescription(sub), - IcoPath = iconPath, + IcoPath = QuickSsh.GetSemanticIconPath(sub), Action = _ => { api?.ChangeQuery(autoText, true); @@ -213,7 +284,7 @@ public static List GetSuggestions( { Title = sub, SubTitle = GetKeysSubCommandDescription(sub), - IcoPath = iconPath, + IcoPath = QuickSsh.GetSemanticIconPath(sub), Action = _ => { api?.ChangeQuery(autoText, true); @@ -241,7 +312,7 @@ public static List GetSuggestions( { Title = keyAlias, SubTitle = keyDisplay, - IcoPath = iconPath, + IcoPath = QuickSsh.GetSemanticIconPath("saved"), Action = _ => { api?.ChangeQuery(autoText, true); @@ -257,18 +328,39 @@ public static List GetSuggestions( return results; } + private static string GetCommandTitle(string command) + { + var key = command switch + { + "profiles" => "plugin_quickssh_title_commandprofiles", + "actions" => "plugin_quickssh_title_commandactions", + "tools" => "plugin_quickssh_title_commandtools", + "help" => "plugin_quickssh_title_commandhelp", + _ => null + }; + return GetLocalizedText(key, command); + } + private static string GetCommandDescription(string command) { var key = command switch { - "profiles" => "plugin_quickssh_subtitle_commandprofiles", - "keys" => "plugin_quickssh_subtitle_commandkeys", - "config" => "plugin_quickssh_subtitle_commandconfig_usage", - "shell" => "plugin_quickssh_subtitle_commandshell_help", - "help" => "plugin_quickssh_subtitle_commandhelp_usage", + "profiles" => "plugin_quickssh_subtitle_root_profiles", + "actions" => "plugin_quickssh_subtitle_root_actions", + "tools" => "plugin_quickssh_subtitle_root_tools", + "help" => "plugin_quickssh_subtitle_root_help", _ => null }; - return key != null ? QuickSsh.GetTranslation(key) : ""; + return GetLocalizedText(key, string.Empty); + } + + private static string GetLocalizedText(string key, string fallback) + { + if (string.IsNullOrEmpty(key)) + return fallback; + + var translated = QuickSsh.GetTranslation(key); + return translated == key ? fallback : translated; } /// @@ -279,9 +371,8 @@ private static string GetCommandDescription(string command) private static int GetTopLevelScore(string command) => command switch { "profiles" => QuickSsh.ScoreTopLevelProfiles, - "keys" => QuickSsh.ScoreTopLevelKeys, - "shell" => QuickSsh.ScoreTopLevelShell, - "config" => QuickSsh.ScoreTopLevelConfig, + "actions" => QuickSsh.ScoreTopLevelActions, + "tools" => QuickSsh.ScoreTopLevelTools, "help" => QuickSsh.ScoreTopLevelHelp, _ => 0 }; @@ -291,6 +382,7 @@ private static string GetProfilesSubCommandDescription(string subCmd) var key = subCmd switch { "add" => "plugin_quickssh_subtitle_commandprofiles_add", + "manage" => "plugin_quickssh_subtitle_commandprofiles_manage", "remove" => "plugin_quickssh_subtitle_commandprofiles_remove", "rename" => "plugin_quickssh_subtitle_commandprofiles_rename", "copy" => "plugin_quickssh_subtitle_commandprofiles_copy_usage", @@ -301,10 +393,23 @@ private static string GetProfilesSubCommandDescription(string subCmd) return key != null ? QuickSsh.GetTranslation(key) : ""; } + private static string GetActionsSubCommandDescription(string subCmd) + { + var key = subCmd switch + { + "run" => "plugin_quickssh_subtitle_commandactions_run", + "add" => "plugin_quickssh_subtitle_commandactions_add", + "manage" => "plugin_quickssh_subtitle_commandactions_manage", + _ => null + }; + return key != null ? QuickSsh.GetTranslation(key) : ""; + } + private static string GetShellSubCommandDescription(string subCmd) { var key = subCmd switch { + "manage" => "plugin_quickssh_subtitle_commandshell_help", "add" => "plugin_quickssh_subtitle_commandshell_add_usage", "remove" => "plugin_quickssh_subtitle_commandshell_remove", _ => null @@ -317,6 +422,7 @@ private static string GetKeysSubCommandDescription(string subCmd) var key = subCmd switch { "add" => "plugin_quickssh_subtitle_commandkeys_add", + "manage" => "plugin_quickssh_subtitle_commandkeys_manage", "generate" => "plugin_quickssh_subtitle_commandkeys_generate", "install" => "plugin_quickssh_subtitle_commandkeys_install", "remove" => "plugin_quickssh_subtitle_commandkeys_remove", diff --git a/CommandInputGuard.cs b/CommandInputGuard.cs new file mode 100644 index 0000000..b444fea --- /dev/null +++ b/CommandInputGuard.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; + +namespace Flow.Launcher.Plugin.QuickSSH +{ + /// + /// Normalizes menu-assisted query input and validates names used for saved profiles and actions. + /// + public static class CommandInputGuard + { + private static readonly HashSet ReservedNames = new HashSet( + new[] + { + "ssh", "profiles", "actions", "keys", "shell", "config", "help", + "add", "run", "use", "manage", "remove", "rename", "copy", "export", "import", + "install", "generate", "scan", "copy-path", "copy-pub" + }, + StringComparer.OrdinalIgnoreCase); + + /// + /// Removes a command prefix that the user pasted again after selecting a menu item. + /// Repeats until nested duplicates are gone. + /// + public static string NormalizeNestedCommandInput( + string input, + string actionKeyword, + string commandPath) + { + var value = (input ?? string.Empty).Trim(); + var canonical = (commandPath ?? string.Empty).Trim(); + var keyword = (actionKeyword ?? string.Empty).Trim(); + + if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(canonical)) + return value; + + var prefixes = string.IsNullOrEmpty(keyword) + ? new[] { canonical } + : new[] { keyword + " " + canonical, canonical }; + + bool changed; + do + { + changed = false; + foreach (var prefix in prefixes) + { + if (value.Equals(prefix, StringComparison.OrdinalIgnoreCase)) + return string.Empty; + + var prefixWithSpace = prefix + " "; + if (value.StartsWith(prefixWithSpace, StringComparison.OrdinalIgnoreCase)) + { + value = value.Substring(prefixWithSpace.Length).TrimStart(); + changed = true; + break; + } + } + } + while (changed && !string.IsNullOrEmpty(value)); + + return value; + } + + /// + /// Returns true for concise names that are safe to use inside QuickSSH query navigation. + /// Unicode letters and digits are supported; separators are limited to dot, dash, and underscore. + /// + public static bool IsValidSavedName(string name) + { + if (string.IsNullOrWhiteSpace(name) || name.Length > 64) + return false; + + if (!char.IsLetterOrDigit(name[0])) + return false; + + foreach (var c in name) + { + if (char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == '.') + continue; + return false; + } + + return true; + } + + /// Returns true when a name collides with a QuickSSH command or sub-command. + public static bool IsReservedSavedName(string name) => + !string.IsNullOrWhiteSpace(name) && ReservedNames.Contains(name.Trim()); + + /// Finds the stored spelling of a key using case-insensitive matching. + public static string FindExistingName( + IEnumerable> entries, + string candidate) + { + if (entries == null || string.IsNullOrWhiteSpace(candidate)) + return null; + + foreach (var entry in entries) + { + if (string.Equals(entry.Key, candidate.Trim(), StringComparison.OrdinalIgnoreCase)) + return entry.Key; + } + + return null; + } + } +} diff --git a/CommandProfile.cs b/CommandProfile.cs new file mode 100644 index 0000000..56d5ce9 --- /dev/null +++ b/CommandProfile.cs @@ -0,0 +1,60 @@ +using System; +using Newtonsoft.Json; + +namespace Flow.Launcher.Plugin.QuickSSH +{ + /// + /// Persisted, reusable remote action definition. + /// Actions are executed through a separately selected SSH connection profile. + /// + public class CommandProfile + { + /// Canonical kind identifier for reusable remote-command actions. + public const string RemoteCommandKind = "remote-command"; + + /// Action kind. Unknown kinds are retained but never treated as supported. + [JsonProperty] + public string Kind { get; set; } = RemoteCommandKind; + + /// Single-line remote command text. Must not contain private key material. + [JsonProperty] + public string Command { get; set; } + + /// Optional human-readable description. + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string Description { get; set; } + + /// Optional future TTY preference: force, yes, or no. + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string RequestTTY { get; set; } + + /// Gets whether this action kind is supported by the current plugin version. + [JsonIgnore] + public bool IsSupportedKind => + string.Equals(Kind, RemoteCommandKind, StringComparison.OrdinalIgnoreCase); + + /// + /// Rejects multiline/null-byte input and recognizable private-key payload markers. + /// This is a storage guard, not a general shell-safety validator. + /// + public static bool IsSafeToStore(string command) + { + if (string.IsNullOrWhiteSpace(command)) + return false; + + if (command.IndexOf('\r') >= 0 || command.IndexOf('\n') >= 0 || command.IndexOf('\0') >= 0) + return false; + + return command.IndexOf("PRIVATE KEY-----", StringComparison.OrdinalIgnoreCase) < 0 && + command.IndexOf("BEGIN OPENSSH PRIVATE KEY", StringComparison.OrdinalIgnoreCase) < 0; + } + + /// Returns the human-readable action description used in Flow Launcher results. + public string ToDisplayString() + { + if (!string.IsNullOrWhiteSpace(Description)) + return Description + " — " + (Command ?? ""); + return Command ?? ""; + } + } +} diff --git a/Flow.Launcher.Plugin.QuickSSH.csproj b/Flow.Launcher.Plugin.QuickSSH.csproj index 1c94943..46e058b 100644 --- a/Flow.Launcher.Plugin.QuickSSH.csproj +++ b/Flow.Launcher.Plugin.QuickSSH.csproj @@ -12,6 +12,8 @@ true false true + annotations + true diff --git a/Images/app-orange.png b/Images/app-orange.png new file mode 100644 index 0000000..35524c3 Binary files /dev/null and b/Images/app-orange.png differ diff --git a/Languages/de.xaml b/Languages/de.xaml index 6eda7d9..e15dfca 100644 --- a/Languages/de.xaml +++ b/Languages/de.xaml @@ -15,32 +15,32 @@ Keine Profile gespeichert. - Keine Shell-Profile gespeichert. + Keine Shells gespeichert. Speichern: Verbinden: Shell hinzufügen: - Profilverwaltung - Verwendung: profiles add | remove | rename | copy | export | import + Profile + Mit einem gespeicherten Server verbinden. Profil hinzufügen - Verwendung: profiles add <Name> <SSH-Befehl> + Einen neuen SSH-Befehl als Profil speichern. Profil entfernen - Verwendung: profiles remove [Filter] + Ein Profil auswählen und das Entfernen bestätigen. Profil umbenennen - Verwendung: profiles rename <Alter Name> <Neuer Name> + Den Namen eines gespeicherten Profils ändern. Profil nicht gefunden. SSH-Befehl kopieren Kopieren: - Verwendung: profiles copy [Filter] + Den Befehl eines ausgewählten Profils kopieren. Kopieren in die Zwischenablage fehlgeschlagen. Stellen Sie sicher, dass Flow Launcher auf dem UI-Thread läuft. SSH-Befehl in die Zwischenablage kopiert. Schlüsselpfad in die Zwischenablage kopiert. @@ -49,14 +49,16 @@ Profile exportieren Profile exportieren nach: {0} - Verwendung: profiles export + Profile in einer Exportdatei speichern. {0} Profil(e) nach {1} exportiert Profile importieren - Verwendung: profiles import [Filter] + Profile aus einer Exportdatei laden. + Profile verwalten + Gespeicherte Profile hinzufügen und verwalten. Keine .sshconfig- oder .json-Dateien gefunden in: {0} - {0} Profil(e) erfolgreich importiert. + Importiert: {0}; bereits vorhandene Profile übersprungen: {1}. Keine Profile in der ausgewählten Datei gefunden. @@ -65,12 +67,12 @@ Verwendung: ssh root@host | ssh -p 22 root@host | ssh -i "key" root@host - Shell-Verwaltung - Shell-Profil hinzufügen - Verwendung: shell add <exe> oder shell add <Name> <exe + Argumente> - Shell-Profil entfernen - Verwendung: shell remove [Filter] - Verwendung: shell add ; remove + Shell + Shell hinzufügen + Eine neue Shell zum Ausführen von Befehlen speichern. + Shell entfernen + Eine gespeicherte Shell auswählen und entfernen. + Eine Shell auswählen oder verwalten. (ausgewählt) @@ -86,26 +88,82 @@ Verwendung: help - (legacy) + (älteres Format) - Command renamed: use "profiles add" - The "add" command is now "profiles add <name> <ssh-command>". Press Enter or Tab to navigate. + Befehl umbenannt: „profiles add“ verwenden + Der Befehl „add“ heißt jetzt „profiles add <Name> <SSH-Befehl>“. Drücken Sie Enter, um fortzufahren. - ← Zurück zu {0} + ← Zurück {0} + + + + Aktionen + Gespeicherte Remote-Befehle über SSH-Profile ausführen. + Aktion ausführen + SSH-Profil und anschließend Aktion auswählen. + Aktion hinzufügen + Einen neuen Remote-Befehl speichern. + Aktion entfernen + Eine Aktion auswählen und das Entfernen bestätigen. + Aktion umbenennen + Den Namen einer gespeicherten Aktion ändern. + Keine SSH-Aktionen gespeichert. + Erste Aktion hinzufügen + Noch keine SSH-Aktionen + Remote-Befehl speichern und über ein SSH-Profil ausführen. + Gespeicherte Aktion: {0} + Aktion „{0}“ speichern + Aktion abgelehnt: nur eine Zeile und niemals privates Schlüsselmaterial verwenden. + Aktion nicht gefunden. + SSH-Profil nicht gefunden oder SCP-Profil. + Vor dem Ausführen einer Aktion ein SSH-Profil speichern. + SSH-Profil hinzufügen + Profil: {0} + Aktion: {0} + Aktion ausführen + Wählen Sie das SSH-Profil aus, über das die Aktion ausgeführt wird. + Wählen Sie den auszuführenden Remote-Befehl aus. + Prüfen Sie Profil und Remote-Befehl. + SSH-Befehl + „{0}“ über Profil „{1}“ ausführen. + Aktion kann nicht ausgeführt werden + Profil oder Aktion ist unvollständig oder nicht unterstützt. + Aktion „{0}“ entfernen + 1–64 Buchstaben oder Ziffern und nur Punkt, Bindestrich oder Unterstrich verwenden. + Dieser Name ist für QuickSSH reserviert. + Ein Eintrag mit diesem Namen ist bereits vorhanden. + Profilbeispiel einfügen + server ssh user@host + Aktionsbeispiel einfügen + check hostname + SSH-Schlüsselbeispiel einfügen + server-key ~/.ssh/private_key + Profil „{0}“ speichern + + Aktionen verwalten + Gespeicherte Aktionen hinzufügen und verwalten. + Wählen Sie ein SSH-Profil für „{0}“. + Ausführung bestätigen + „{0}“ ausführen + Über Profil „{0}“ ausführen. - SSH-Schlüsselverwaltung - Verwendung: keys install | add | generate | remove | rename | copy-path | copy-pub | scan + SSH-Schlüssel + Öffentliche Schlüssel installieren oder gespeicherte SSH-Schlüssel verwalten. + SSH-Schlüssel verwalten + Gespeicherte SSH-Schlüssel hinzufügen und verwalten. + Öffentlicher Schlüssel • + Privater Schlüssel • SSH-Schlüssel registrieren - Verwendung: keys add <Alias> <Pfad-zum-Schlüssel> + Einen vorhandenen privaten Schlüssel unter eigenem Namen speichern. SSH-Schlüsselpaar generieren - Verwendung: keys generate <Alias> [Benutzerdefinierter-Pfad] + Ein neues privates und öffentliches Schlüsselpaar erstellen. Generieren: {0} {0} → {1} (ohne Passphrase) - Ungültiger Alias — enthält nur ungültige Zeichen. - Alias existiert bereits: {0} + Ungültiger Schlüsselname — enthält nur ungültige Zeichen. + Ein Schlüssel mit diesem Namen existiert bereits: {0} Schlüsseldatei existiert bereits: {0} Ungültiger Pfad: {0} Pfad ist ein Verzeichnis, keine Schlüsseldatei: {0} @@ -117,24 +175,24 @@ Privat: {1} Öffentlich: {2} Schlüsselgenerierung abgebrochen oder fehlgeschlagen. Schlüsselgenerierung fehlgeschlagen: {0} - SSH-Schlüssel entfernen - Verwendung: keys remove [Filter] - SSH-Schlüsselalias entfernt: {0} -Nur Registrierungseintrag entfernt. -Dateien auf der Festplatte behalten: {1} - Schlüssel-Alias umbenennen - Verwendung: keys rename <alter-Alias> <neuer-Alias> - Schlüssel-Alias nicht gefunden. - Ein Alias mit diesem Namen existiert bereits. + Gespeicherten Schlüssel entfernen + Aus der Liste entfernen; die Schlüsseldatei bleibt unverändert. + Gespeicherter SSH-Schlüssel entfernt: {0} +Nur der gespeicherte Eintrag wurde entfernt. +Dateien unverändert belassen: {1} + Schlüssel umbenennen + Den Namen eines gespeicherten Schlüssels ändern. + Gespeicherter Schlüssel nicht gefunden. + Ein Schlüssel mit diesem Namen existiert bereits. Schlüsselpfad kopieren - Verwendung: keys copy-path [Filter] + Den vollständigen Pfad zum privaten Schlüssel kopieren. Pfad kopieren: Öffentlichen Schlüssel kopieren - Verwendung: keys copy-pub [Filter] + Den Inhalt des öffentlichen Schlüssels kopieren. .pub kopieren: Öffentliche Schlüsseldatei (.pub) nicht gefunden: Schlüssel suchen - ~/.ssh/ nach Schlüsseldateien durchsuchen + Vorhandene SSH-Schlüssel in ~/.ssh finden. Verzeichnis ~/.ssh/ nicht gefunden. Keine Schlüsseldateien in ~/.ssh/ gefunden (bereits registriert) @@ -143,4 +201,114 @@ Dateien auf der Festplatte behalten: {1} (Datei nicht gefunden) Identitätsdatei: + + Öffentlichen Schlüssel installieren + Ausgewählten Schlüssel auf einem entfernten Linux-Server hinzufügen. + Eingeben: {0} keys install {1} user@host + Gespeichertes SSH-Profil auswählen oder user@host manuell eingeben. + Ziel manuell eingeben + Dieses Profil kann nicht für die Schlüsselinstallation verwendet werden. + "{0}" installieren → {1} + Remote-Einrichtungsbefehl ausführen + Remote-Einrichtungsbefehl kopieren + Öffentlichen Schlüssel kopieren + Vollständigen SSH-Befehl in die Zwischenablage kopieren + Öffentlichen Schlüssel kopieren, der auf dem entfernten Server hinzugefügt wird + Gespeicherter Schlüssel nicht gefunden: {0} + Öffentliche Schlüsseldatei nicht gefunden: {0} + Inhalt der öffentlichen Schlüsseldatei ist ungültig oder unsicher. + Ungültiges Ziel — Format user@host erwartet. + Remote-Einrichtungsbefehl in die Zwischenablage kopiert. + + Werkzeuge + Mit einem gespeicherten Server verbinden. + Einen gespeicherten Remote-Befehl ausführen. + SSH-Schlüssel, Shell und Konfigurationsimport. + Dokumentation und Befehlsübersicht. + zum Hauptmenü + SSH-Schlüssel verwenden oder verwalten. + Die Shell zum Ausführen von Befehlen auswählen oder verwalten. + Profile aus ~/.ssh/config importieren. + + + Shells verwalten + Über Profil „{0}“ • {1} + SSH-Befehl kopieren + zu den Profilen + zur Profilverwaltung + zu den Aktionen + zur Aktionsverwaltung + zur Profilauswahl + zur Aktionsauswahl + zu den SSH-Schlüsseln + zur SSH-Schlüsselverwaltung + zur Shell + zur Shell-Verwaltung + zu den Werkzeugen + zur Profilauswahl + Profil „{0}“ entfernen + + Beispielnamen einfügen: „{0}“ + Schritt 1 von 4 • Oder oben einen eigenen Profilnamen eingeben. + Schritt 2 von 2: SSH-Befehl eingeben + Nach dem Namen zum Beispiel „ssh user@host“ eingeben. + Beispielnamen einfügen: „{0}“ + Schritt 1 von 2 • Oder oben einen eigenen Aktionsnamen eingeben. + Beispielbefehl einfügen: „hostname“ + Schritt 2 von 2 • Oder oben einen eigenen Remote-Befehl eingeben. + Beispielnamen einfügen: „{0}“ + Schritt 1 von 2 • Oder oben einen eigenen Schlüsselnamen eingeben. + Beispielpfad einfügen: „~/.ssh/private_key“ + Schritt 2 von 2 • Oder oben den Pfad zum privaten Schlüssel eingeben. + Beispielnamen einfügen: „{0}“ + Schritt 1 von 2 • Oder oben einen eigenen Shell-Namen eingeben. + Beispielprogramm einfügen: „pwsh.exe -NoLogo“ + Schritt 2 von 2 • Oder oben Programm und optionale Argumente eingeben. + „{0}“ auch als Programmnamen verwenden. + Namensvorschlag einfügen: „{0}“ + Aktueller Name: „{0}“. Oder oben einen eigenen neuen Namen eingeben. + Namensvorschlag einfügen: „{0}“ + Aktueller Name: „{0}“. Oder oben einen eigenen neuen Namen eingeben. + Namensvorschlag einfügen: „{0}“ + Aktueller Name: „{0}“. Oder oben einen eigenen neuen Namen eingeben. + Der neue Name entspricht dem bisherigen Namen. + Einen gültigen SSH-Befehl eingeben. + „{0}“ in „{1}“ umbenennen + SSH-Befehl und Profileinstellungen bleiben unverändert. + „{0}“ in „{1}“ umbenennen + Der Remote-Befehl bleibt unverändert. + „{0}“ in „{1}“ umbenennen + Schlüsseldatei und Pfad bleiben unverändert. + Der Schlüsselpfad existiert nicht + Schlüssel „{0}“ speichern + Shell „{0}“ speichern + Beispielserver einfügen: „user@host“ + Schritt 2 von 4 • Oder oben ein eigenes Ziel eingeben, z. B. vaio@10.0.0.10. + Standardport 22 verwenden + Schritt 3 von 4 • Üblicher SSH-Port; er wird nicht ausdrücklich gespeichert. + Anderen Port eingeben + Schritt 3 von 4 • Eine Zahl von 1 bis 65535 eingeben. + Ungültiger SSH-Port + Eine ganze Zahl von 1 bis 65535 eingeben. + Schritt 4 von 4: Anmeldung wählen + Einen gespeicherten privaten Schlüssel auswählen oder SSH-Agent/Konfiguration verwenden. + SSH-Agent oder Konfiguration verwenden + Das Profil ohne fest vorgegebenen privaten Schlüssel speichern. + Vollständigen SSH-Befehl eingeben + Für Tunnel, SCP, ProxyJump oder weitere erweiterte Optionen verwenden. + Kein verwendbarer privater Schlüssel gespeichert + Einen privaten Schlüssel hinzufügen oder erzeugen, oder ohne Schlüssel fortfahren. + Datei des privaten Schlüssels nicht gefunden: {0} + Ein öffentlicher Schlüssel kann nicht zur Anmeldung verwendet werden. Privaten Schlüssel wählen: {0} + Die Datei wird nicht als privater SSH-Schlüssel erkannt: {0} + Gespeicherter privater Schlüssel nicht gefunden. + Einen gültigen Server als user@host oder host eingeben. + Das letzte „{0}“ bearbeiten oder den Vorschlag verwenden. + Das letzte „{0}“ bearbeiten oder den Vorschlag verwenden. + Das letzte „{0}“ bearbeiten oder den Vorschlag verwenden. + + Die ausgewählte Shell „{0}“ ist nicht mehr vorhanden. Der Befehl wurde nicht ausgeführt. + Die Shell „{0}“ enthält eine ungültige Programmdefinition. Der Befehl wurde nicht ausgeführt. + Das Programm für die Shell „{0}“ wurde nicht gefunden. Der Befehl wurde nicht ausgeführt. + Die Shell „{0}“ konnte nicht gestartet werden. Der Befehl wurde nicht ausgeführt. Fehler: {1} diff --git a/Languages/en.xaml b/Languages/en.xaml index 3706631..4f9c8d7 100644 --- a/Languages/en.xaml +++ b/Languages/en.xaml @@ -15,32 +15,32 @@ No profiles saved. - No shell profiles saved. + No shells saved. Save: Connect: Add shell: - Profile management - Usage: profiles add | remove | rename | copy | export | import + Profiles + Connect to a saved server. Add profile - Usage: profiles add <name> <ssh-command> + Save a new SSH command as a profile. Remove profile - Usage: profiles remove [filter] + Select a profile and confirm its removal. Rename profile - Usage: profiles rename <oldname> <newname> + Change the name of a saved profile. Profile not found. Copy SSH command Copy: - Usage: profiles copy [filter] + Copy the command of a selected profile. Could not copy to clipboard. Ensure Flow Launcher is running on the UI thread. SSH command copied to clipboard. Key path copied to clipboard. @@ -49,14 +49,16 @@ Export profiles Export profiles to: {0} - Usage: profiles export + Save profiles to an export file. Exported {0} profile(s) to {1} Import profiles - Usage: profiles import [filter] + Load profiles from an export file. + Manage profiles + Add and manage saved profiles. No .sshconfig or .json files found in: {0} - Imported {0} profile(s) successfully. + Imported {0} profile(s); skipped {1} existing profile(s). No profiles found in the selected file. (legacy) @@ -64,7 +66,7 @@ Command renamed: use "profiles add" - The "add" command is now "profiles add <name> <ssh-command>". Press Enter or Tab to navigate. + The "add" command is now "profiles add <name> <ssh-command>". Press Enter to continue. Direct connect @@ -72,16 +74,16 @@ Usage: ssh root@host | ssh -p 22 root@host | ssh -i "key" root@host - Shell management - Add shell profile - Usage: shell add <exe> or shell add <name> <exe + args> - Remove shell profile - Usage: shell remove [filter] - Usage: shell add ; remove + Shell + Add shell + Save a new shell for running commands. + Remove shell + Select a saved shell and remove it. + Choose or manage a shell. (selected) - Import SSH config + Import SSH configuration Import hosts from ~/.ssh/config file. Usage: config (imports from ~/.ssh/config) Imported {0} host(s) from SSH config. @@ -95,17 +97,73 @@ ← Back to {0} + + + Actions + Run saved remote commands through SSH profiles. + Run action + Select an SSH profile, then an action. + Add action + Save a new remote command. + Remove action + Select an action and confirm its removal. + Rename action + Change the name of a saved action. + No SSH actions saved. + Add your first action + No SSH actions yet + Save a remote command to run it through an SSH profile. + Saved action: {0} + Save action “{0}” + Action rejected: use one line and never include private key material. + Action not found. + SSH profile not found or it is an SCP profile. + Save an SSH profile before running actions. + Add SSH profile + Profile: {0} + Action: {0} + Run action + Choose the SSH profile that will run the action. + Choose the remote command to run. + Review the profile and remote command. + SSH command + Run “{0}” through profile “{1}”. + Action cannot be run + The profile or action is incomplete or unsupported. + Remove action “{0}” + Use 1–64 letters or digits and only dot, dash, or underscore separators. + This name is reserved by QuickSSH. + An item with this name already exists. + Fill in profile example + server ssh user@host + Fill in action example + check hostname + Fill in SSH key example + server-key ~/.ssh/private_key + Save profile “{0}” + + Manage actions + Add and manage saved actions. + Select an SSH profile for “{0}”. + Confirm run + Run “{0}” + Execute through profile “{0}”. + - SSH key management - Usage: keys install | add | generate | remove | rename | copy-path | copy-pub | scan + SSH keys + Install public keys or manage saved SSH keys. + Manage SSH keys + Add and manage saved SSH keys. + Public key • + Private key • Register SSH key - Usage: keys add <alias> <path-to-key> + Save an existing private key under a custom name. Generate SSH keypair - Usage: keys generate <alias> [custom-path] + Create a new private and public key pair. Generate: {0} {0} → {1} (no passphrase) - Invalid alias — contains only invalid characters. - Alias already exists: {0} + Invalid key name — contains only invalid characters. + A key with this name already exists: {0} Key file already exists: {0} Invalid path: {0} Path is a directory, not a key file: {0} @@ -117,24 +175,24 @@ Private: {1} Public: {2} Key generation cancelled or failed. Key generation failed: {0} - Remove SSH key - Usage: keys remove [filter] - SSH key alias removed: {0} -Registry entry removed only. -Files kept on disk: {1} - Rename SSH key alias - Usage: keys rename <old-alias> <new-alias> - Key alias not found. - An alias with this name already exists. + Remove saved key + Remove it from the list; the key file stays unchanged. + Saved SSH key removed: {0} +Only the saved entry was removed. +Files left unchanged: {1} + Rename key + Change the name of a saved key. + Saved key not found. + A key with this name already exists. Copy key path - Usage: keys copy-path [filter] + Copy the full path to the private key. Copy path: Copy public key - Usage: keys copy-pub [filter] + Copy the public key content. Copy .pub: Public key file (.pub) not found: Scan for keys - Scan ~/.ssh/ for key files to register + Find existing SSH keys in ~/.ssh. ~/.ssh/ directory not found. No key files found in ~/.ssh/ (already registered) @@ -144,19 +202,113 @@ Files kept on disk: {1} Identity file: - Install SSH public key on remote server - Add the selected public key to ~/.ssh/authorized_keys on a remote Linux host + Install public key + Add the selected key to a remote Linux server. Type: {0} keys install {1} user@host + Select a saved SSH profile or type user@host manually. + Type destination manually + This profile cannot be used for key installation. Install "{0}" → {1} Run remote setup command Copy remote setup command Copy public key Copy the full SSH command to clipboard Copy the public key that will be added to the remote server - Key alias not found: {0} + Saved key not found: {0} Public key file not found: {0} Public key file content is invalid or unsafe. Invalid destination — expected user@host format. Remote setup command copied to clipboard. + Tools + Connect to a saved server. + Run a saved remote command. + SSH keys, shell, and configuration import. + Documentation and command overview. + Main menu + Use or manage SSH keys. + Choose or manage the shell used to run commands. + Import profiles from ~/.ssh/config. + + + Manage shells + Using profile “{0}” • {1} + Copy SSH command + profiles + profile management + actions + action management + profile selection + action selection + SSH keys + SSH key management + shell + shell management + tools + profile selection + Remove profile “{0}” + + Fill example name: “{0}” + Step 1 of 4 • Or type your own profile name above. + Step 2 of 2: Enter an SSH command + After the name, type for example “ssh user@host”. + Fill example name: “{0}” + Step 1 of 2 • Or type your own action name above. + Fill example command: “hostname” + Step 2 of 2 • Or type your own remote command above. + Fill example name: “{0}” + Step 1 of 2 • Or type your own key name above. + Fill example path: “~/.ssh/private_key” + Step 2 of 2 • Or type the path to your private key above. + Fill example name: “{0}” + Step 1 of 2 • Or type your own shell name above. + Fill example program: “pwsh.exe -NoLogo” + Step 2 of 2 • Or type a program and optional arguments above. + Use “{0}” as the program name too. + Fill suggested new name: “{0}” + Current name: “{0}”. Or type your own new name above. + Fill suggested new name: “{0}” + Current name: “{0}”. Or type your own new name above. + Fill suggested new name: “{0}” + Current name: “{0}”. Or type your own new name above. + The new name is the same as the current name. + Enter a valid SSH command. + Rename “{0}” to “{1}” + The SSH command and profile settings will stay unchanged. + Rename “{0}” to “{1}” + The remote command will stay unchanged. + Rename “{0}” to “{1}” + The key file and its path will stay unchanged. + The key path does not exist + Save key “{0}” + Save shell “{0}” + Fill example server: “user@host” + Step 2 of 4 • Or type your own target above, for example vaio@10.0.0.10. + Use default port 22 + Step 3 of 4 • Standard SSH port; it will not be stored explicitly. + Enter a different port + Step 3 of 4 • Type a number from 1 to 65535. + Invalid SSH port + Enter a whole number from 1 to 65535. + Step 4 of 4: Choose sign-in + Select a saved private key, or continue with SSH agent/config. + Use SSH agent or configuration + Save the profile without forcing a specific private key. + Enter a full SSH command + Use this for tunnels, SCP, ProxyJump, or other advanced options. + No usable private key is saved + Add or generate a private key, or continue without one. + Private key file not found: {0} + A public key cannot be used to sign in. Select its private key: {0} + The file is not recognized as a private SSH key: {0} + Saved private key not found. + Enter a valid server as user@host or host. + Edit the last “{0}” or use the suggested name. + Edit the last “{0}” or use the suggested name. + Edit the last “{0}” or use the suggested name. + + Selected shell “{0}” no longer exists. The command was not run. + Shell “{0}” has an invalid program definition. The command was not run. + The program for shell “{0}” was not found. The command was not run. + Shell “{0}” could not be started. The command was not run. Error: {1} diff --git a/Languages/es.xaml b/Languages/es.xaml index c6c095a..129de7d 100644 --- a/Languages/es.xaml +++ b/Languages/es.xaml @@ -8,40 +8,40 @@ QuickSSH - OpenSSH client not found on this system. + No se encontró el cliente OpenSSH en este sistema. QuickSSH - Profiles database could not be created. + No se pudo crear la base de datos de perfiles. - No profiles saved. - No shell profiles saved. - Save: - Connect: - Add shell: + No hay perfiles guardados. + No hay shells guardados. + Guardar: + Conectar: + Añadir shell: - Gestión de perfiles - Uso: profiles add | remove | rename | copy | export | import + Perfiles + Conectarse a un servidor guardado. Añadir perfil - Uso: profiles add <nombre> <comando-ssh> + Guardar un nuevo comando SSH como perfil. Eliminar perfil - Uso: profiles remove [filtro] + Seleccionar un perfil y confirmar su eliminación. Renombrar perfil - Uso: profiles rename <nombre antiguo> <nombre nuevo> - Profile not found. + Cambiar el nombre de un perfil guardado. + No se encontró el perfil. Copiar comando SSH Copiar: - Uso: profiles copy [filtro] - Could not copy to clipboard. Ensure Flow Launcher is running on the UI thread. + Copiar el comando del perfil seleccionado. + No se pudo copiar al portapapeles. Asegúrese de que Flow Launcher se esté ejecutando en el hilo de la interfaz de usuario. Comando SSH copiado al portapapeles. Ruta de la clave copiada al portapapeles. Clave pública copiada al portapapeles. @@ -49,63 +49,121 @@ Exportar perfiles Exportar perfiles a: {0} - Uso: profiles export - Exported {0} profile(s) to {1} + Guardar perfiles en un archivo de exportación. + Se exportaron {0} perfiles a {1} Importar perfiles - Uso: profiles import [filtro] + Cargar perfiles desde un archivo de exportación. + Gestionar perfiles + Añadir y administrar perfiles guardados. No se encontraron archivos .sshconfig o .json en: {0} - Imported {0} profile(s) successfully. - No profiles found in the selected file. + Perfiles importados: {0}; perfiles existentes omitidos: {1}. + No se encontraron perfiles en el archivo seleccionado. - Direct connect - Direct connect: - Usage: ssh root@host | ssh -p 22 root@host | ssh -i "key" root@host + Conexión directa + Conexión directa: + Uso: ssh root@host | ssh -p 22 root@host | ssh -i "key" root@host - Shell management - Add shell profile - Usage: shell add <exe> or shell add <name> <exe + args> - Remove shell profile - Usage: shell remove [filter] - Usage: shell add ; remove - (selected) + Shell + Añadir shell + Guardar un nuevo shell para ejecutar comandos. + Eliminar shell + Seleccionar un shell guardado y eliminarlo. + Elegir o administrar un shell. + (seleccionado) - Import SSH config - Import hosts from ~/.ssh/config file. + Importar configuración SSH + Importar hosts desde el archivo ~/.ssh/config. Uso: config (importa desde ~/.ssh/config) - Imported {0} host(s) from SSH config. - SSH config file not found at ~/.ssh/config. + Se importaron {0} hosts desde la configuración SSH. + No se encontró el archivo de configuración SSH en ~/.ssh/config. - Help - Open plugin documentation on GitHub. - Usage: help + Ayuda + Abrir la documentación del plugin en GitHub. + Uso: help - (legacy) + (formato anterior) - Command renamed: use "profiles add" - The "add" command is now "profiles add <name> <ssh-command>". Press Enter or Tab to navigate. + Comando renombrado: use «profiles add» + El comando «add» ahora se llama «profiles add <nombre> <comando-ssh>». Pulse Intro para continuar. - ← Volver a {0} + ← Volver {0} + + + + Acciones + Ejecute comandos remotos guardados mediante perfiles SSH. + Ejecutar acción + Seleccione un perfil SSH y después una acción. + Añadir acción + Guardar un nuevo comando remoto. + Eliminar acción + Seleccionar una acción y confirmar su eliminación. + Renombrar acción + Cambiar el nombre de una acción guardada. + No hay acciones SSH guardadas. + Añadir la primera acción + Aún no hay acciones SSH + Guarde un comando remoto para ejecutarlo mediante un perfil SSH. + Acción guardada: {0} + Guardar acción “{0}” + Acción rechazada: use una sola línea y nunca incluya una clave privada. + Acción no encontrada. + Perfil SSH no encontrado o perfil SCP. + Guarde un perfil SSH antes de ejecutar acciones. + Añadir perfil SSH + Perfil: {0} + Acción: {0} + Ejecutar acción + Seleccione el perfil SSH que ejecutará la acción. + Seleccione el comando remoto que desea ejecutar. + Revise el perfil y el comando remoto. + Comando SSH + Ejecutar “{0}” mediante el perfil “{1}”. + No se puede ejecutar la acción + El perfil o la acción está incompleto o no es compatible. + Eliminar acción “{0}” + Use de 1 a 64 letras o dígitos y solo punto, guion o guion bajo. + Este nombre está reservado por QuickSSH. + Ya existe un elemento con este nombre. + Completar ejemplo de perfil + server ssh user@host + Completar ejemplo de acción + check hostname + Completar ejemplo de clave SSH + server-key ~/.ssh/private_key + Guardar perfil “{0}” + + Administrar acciones + Añadir y administrar acciones guardadas. + Seleccione un perfil SSH para «{0}». + Confirmar ejecución + Ejecutar «{0}» + Ejecutar mediante el perfil «{0}». - Gestión de claves SSH - Uso: keys install | add | generate | remove | rename | copy-path | copy-pub | scan + Claves SSH + Instalar claves públicas o gestionar claves SSH guardadas. + Gestionar claves SSH + Añadir y administrar claves SSH guardadas. + Clave pública • + Clave privada • Registrar clave SSH - Uso: keys add <alias> <ruta-a-la-clave> + Guardar una clave privada existente con un nombre propio. Generar par de claves SSH - Uso: keys generate <alias> [ruta-personalizada] + Crear un nuevo par de claves privada y pública. Generar: {0} {0} → {1} (sin contraseña) - Alias no válido — contiene solo caracteres no válidos. - El alias ya existe: {0} + Nombre de clave no válido — contiene solo caracteres no válidos. + Ya existe una clave con este nombre: {0} El archivo de clave ya existe: {0} Ruta no válida: {0} La ruta es un directorio, no un archivo de clave: {0} @@ -117,24 +175,24 @@ Privada: {1} Pública: {2} Generación de clave cancelada o fallida. Fallo en la generación de clave: {0} - Eliminar clave SSH - Uso: keys remove [filtro] - Alias de clave SSH eliminado: {0} -Solo se eliminó la entrada del registro. -Archivos conservados en disco: {1} - Renombrar alias de clave - Uso: keys rename <alias-antiguo> <alias-nuevo> - Alias de clave no encontrado. - Ya existe un alias con este nombre. + Eliminar clave guardada + Quitarla de la lista; el archivo de clave no se modifica. + Clave SSH guardada eliminada: {0} +Solo se eliminó la entrada guardada. +Archivos sin cambios: {1} + Renombrar clave + Cambiar el nombre de una clave guardada. + No se encontró la clave guardada. + Ya existe una clave con este nombre. Copiar ruta de clave - Uso: keys copy-path [filtro] + Copiar la ruta completa de la clave privada. Copiar ruta: Copiar clave pública - Uso: keys copy-pub [filtro] + Copiar el contenido de la clave pública. Copiar .pub: Archivo de clave pública (.pub) no encontrado: Buscar claves - Escanear ~/.ssh/ en busca de archivos de claves + Buscar claves SSH existentes en ~/.ssh. Directorio ~/.ssh/ no encontrado. No se encontraron archivos de claves en ~/.ssh/ (ya registrado) @@ -143,4 +201,114 @@ Archivos conservados en disco: {1} (archivo no encontrado) Archivo de identidad: + + Instalar clave pública + Añadir la clave seleccionada a un servidor Linux remoto. + Escribe: {0} keys install {1} user@host + Selecciona un perfil SSH guardado o escribe user@host manualmente. + Escribir destino manualmente + Este perfil no se puede usar para instalar claves. + Instalar "{0}" → {1} + Ejecutar comando de configuración remota + Copiar comando de configuración remota + Copiar clave pública + Copiar el comando SSH completo al portapapeles + Copiar la clave pública que se añadirá al servidor remoto + No se encontró la clave guardada: {0} + Archivo de clave pública no encontrado: {0} + El contenido del archivo de clave pública no es válido o no es seguro. + Destino no válido — se espera el formato user@host. + Comando de configuración remota copiado al portapapeles. + + Herramientas + Conectarse a un servidor guardado. + Ejecutar un comando remoto guardado. + Claves SSH, shell e importación de configuración. + Documentación y resumen de comandos. + al menú principal + Usar o administrar claves SSH. + Elegir o administrar el shell usado para ejecutar comandos. + Importar perfiles desde ~/.ssh/config. + + + Administrar shells + Mediante el perfil «{0}» • {1} + Copiar comando SSH + a los perfiles + a la gestión de perfiles + a las acciones + a la gestión de acciones + a la selección de perfil + a la selección de acción + a las claves SSH + a la gestión de claves SSH + al shell + a la gestión de shells + a las herramientas + a la selección de perfil + Eliminar el perfil «{0}» + + Completar nombre de ejemplo: «{0}» + Paso 1 de 4 • O escriba arriba su propio nombre de perfil. + Paso 2 de 2: Introduce el comando SSH + Después del nombre escribe, por ejemplo, «ssh user@host». + Completar nombre de ejemplo: «{0}» + Paso 1 de 2 • O escriba arriba su propio nombre de acción. + Completar comando de ejemplo: «hostname» + Paso 2 de 2 • O escriba arriba su propio comando remoto. + Completar nombre de ejemplo: «{0}» + Paso 1 de 2 • O escriba arriba su propio nombre de clave. + Completar ruta de ejemplo: «~/.ssh/private_key» + Paso 2 de 2 • O escriba arriba la ruta de su clave privada. + Completar nombre de ejemplo: «{0}» + Paso 1 de 2 • O escriba arriba su propio nombre de shell. + Completar programa de ejemplo: «pwsh.exe -NoLogo» + Paso 2 de 2 • O escriba arriba el programa y los argumentos opcionales. + Usar «{0}» también como nombre del programa. + Completar nuevo nombre sugerido: «{0}» + Nombre actual: «{0}». O escriba arriba su propio nombre nuevo. + Completar nuevo nombre sugerido: «{0}» + Nombre actual: «{0}». O escriba arriba su propio nombre nuevo. + Completar nuevo nombre sugerido: «{0}» + Nombre actual: «{0}». O escriba arriba su propio nombre nuevo. + El nuevo nombre es igual al nombre actual. + Introduce un comando SSH válido. + Cambiar «{0}» por «{1}» + El comando SSH y la configuración del perfil no cambiarán. + Cambiar «{0}» por «{1}» + El comando remoto no cambiará. + Cambiar «{0}» por «{1}» + El archivo de clave y su ruta no cambiarán. + La ruta de la clave no existe + Guardar la clave «{0}» + Guardar el shell «{0}» + Completar servidor de ejemplo: «user@host» + Paso 2 de 4 • O escriba arriba su propio destino, por ejemplo vaio@10.0.0.10. + Usar el puerto predeterminado 22 + Paso 3 de 4 • Puerto SSH habitual; no se guardará de forma explícita. + Introducir otro puerto + Paso 3 de 4 • Escriba un número del 1 al 65535. + Puerto SSH no válido + Introduzca un número entero del 1 al 65535. + Paso 4 de 4: Elija el acceso + Seleccione una clave privada guardada o use el agente/configuración SSH. + Usar el agente o la configuración SSH + Guardar el perfil sin forzar una clave privada concreta. + Introducir un comando SSH completo + Úselo para túneles, SCP, ProxyJump u otras opciones avanzadas. + No hay ninguna clave privada utilizable guardada + Añada o genere una clave privada, o continúe sin una. + No se encontró el archivo de la clave privada: {0} + Una clave pública no puede usarse para iniciar sesión. Seleccione su clave privada: {0} + El archivo no se reconoce como una clave SSH privada: {0} + No se encontró la clave privada guardada. + Introduzca un servidor válido como usuario@host o host. + Edite el último «{0}» o use el nombre sugerido. + Edite el último «{0}» o use el nombre sugerido. + Edite el último «{0}» o use el nombre sugerido. + + El shell seleccionado «{0}» ya no existe. El comando no se ejecutó. + El shell «{0}» tiene una definición de programa no válida. El comando no se ejecutó. + No se encontró el programa del shell «{0}». El comando no se ejecutó. + No se pudo iniciar el shell «{0}». El comando no se ejecutó. Error: {1} diff --git a/Languages/fr.xaml b/Languages/fr.xaml index 03b7775..74a36c6 100644 --- a/Languages/fr.xaml +++ b/Languages/fr.xaml @@ -15,32 +15,32 @@ Aucun profil enregistré. - Aucun profil de shell enregistré. + Aucun shell enregistré. Enregistrer : Connecter : Ajouter le shell : - Gestion des profils - Utilisation : profiles add | remove | rename | copy | export | import + Profils + Se connecter à un serveur enregistré. Ajouter un profil - Utilisation : profiles add <nom> <commande-ssh> + Enregistrer une nouvelle commande SSH comme profil. Supprimer un profil - Utilisation : profiles remove [filtre] + Sélectionner un profil et confirmer sa suppression. Renommer un profil - Utilisation : profiles rename <ancien nom> <nouveau nom> + Modifier le nom d’un profil enregistré. Profil introuvable. Copier la commande SSH Copier : - Utilisation : profiles copy [filtre] + Copier la commande du profil sélectionné. Impossible de copier dans le presse-papiers. Assurez-vous que Flow Launcher s'exécute sur le thread UI. Commande SSH copiée dans le presse-papiers. Chemin de la clé copié dans le presse-papiers. @@ -49,14 +49,16 @@ Exporter les profils Exporter les profils vers : {0} - Utilisation : profiles export + Enregistrer les profils dans un fichier d’export. Exporté {0} profil(s) vers {1} Importer des profils - Utilisation : profiles import [filtre] + Charger les profils depuis un fichier d’export. + Gérer les profils + Ajouter et gérer les profils enregistrés. Aucun fichier .sshconfig ou .json trouvé dans : {0} - Importé {0} profil(s) avec succès. + Profils importés : {0} ; profils existants ignorés : {1}. Aucun profil trouvé dans le fichier sélectionné. @@ -65,16 +67,16 @@ Utilisation : ssh root@host | ssh -p 22 root@host | ssh -i "key" root@host - Gestion des shells - Ajouter un profil de shell - Utilisation : shell add <exe> ou shell add <nom> <exe + args> - Supprimer un profil de shell - Utilisation : shell remove [filtre] - Utilisation : shell add ; remove + Shell + Ajouter un shell + Enregistrer un nouveau shell pour exécuter les commandes. + Supprimer le shell + Sélectionner un shell enregistré et le supprimer. + Choisir ou gérer un shell. (sélectionné) - Importer la config SSH + Importer la configuration SSH Importer les hôtes depuis le fichier ~/.ssh/config. Utilisation : config (importe depuis ~/.ssh/config) Importé {0} hôte(s) depuis la config SSH. @@ -86,26 +88,82 @@ Utilisation : help - (legacy) + (ancien format) - Command renamed: use "profiles add" - The "add" command is now "profiles add <name> <ssh-command>". Press Enter or Tab to navigate. + Commande renommée : utilisez « profiles add » + La commande « add » s’appelle désormais « profiles add <nom> <commande-ssh> ». Appuyez sur Entrée pour continuer. - ← Retour à {0} + ← Retour {0} + + + + Actions + Exécuter des commandes distantes enregistrées via des profils SSH. + Exécuter l’action + Sélectionnez un profil SSH puis une action. + Ajouter une action + Enregistrer une nouvelle commande distante. + Supprimer une action + Sélectionner une action et confirmer sa suppression. + Renommer une action + Modifier le nom d’une action enregistrée. + Aucune action SSH enregistrée. + Ajouter la première action + Aucune action SSH pour le moment + Enregistrez une commande distante à exécuter via un profil SSH. + Action enregistrée : {0} + Enregistrer l’action « {0} » + Action refusée : utilisez une seule ligne et n’incluez jamais de clé privée. + Action introuvable. + Profil SSH introuvable ou profil SCP. + Enregistrez un profil SSH avant d’exécuter des actions. + Ajouter un profil SSH + Profil : {0} + Action : {0} + Exécuter l’action + Sélectionnez le profil SSH qui exécutera l’action. + Sélectionnez la commande distante à exécuter. + Vérifiez le profil et la commande distante. + Commande SSH + Exécuter « {0} » via le profil « {1} ». + Impossible d’exécuter l’action + Le profil ou l’action est incomplet ou non pris en charge. + Supprimer l’action « {0} » + Utilisez 1 à 64 lettres ou chiffres et uniquement point, tiret ou soulignement. + Ce nom est réservé par QuickSSH. + Un élément portant ce nom existe déjà. + Compléter l’exemple de profil + server ssh user@host + Compléter l’exemple d’action + check hostname + Compléter l’exemple de clé SSH + server-key ~/.ssh/private_key + Enregistrer le profil « {0} » + + Gérer les actions + Ajouter et gérer les actions enregistrées. + Sélectionnez un profil SSH pour « {0} ». + Confirmer l’exécution + Exécuter « {0} » + Exécuter via le profil « {0} ». - Gestion des clés SSH - Utilisation : keys install | add | generate | remove | rename | copy-path | copy-pub | scan + Clés SSH + Installer des clés publiques ou gérer les clés SSH enregistrées. + Gérer les clés SSH + Ajouter et gérer les clés SSH enregistrées. + Clé publique • + Clé privée • Enregistrer une clé SSH - Utilisation : keys add <alias> <chemin-clé> + Enregistrer une clé privée existante sous un nom personnalisé. Générer une paire de clés SSH - Utilisation : keys generate <alias> [chemin-personnalisé] + Créer une nouvelle paire de clés privée et publique. Générer : {0} {0} → {1} (sans phrase secrète) - Alias invalide — ne contient que des caractères non valides. - L'alias existe déjà : {0} + Nom de clé invalide — ne contient que des caractères non valides. + Une clé portant ce nom existe déjà : {0} Le fichier de clé existe déjà : {0} Chemin invalide : {0} Le chemin est un répertoire, pas un fichier de clé : {0} @@ -117,24 +175,24 @@ Privée : {1} Publique : {2} Génération de clé annulée ou échouée. Échec de la génération de clé : {0} - Supprimer une clé SSH - Utilisation : keys remove [filtre] - Alias de clé SSH supprimé : {0} -Entrée de registre supprimée uniquement. -Fichiers conservés sur le disque : {1} - Renommer l'alias de clé - Utilisation : keys rename <ancien-alias> <nouvel-alias> - Alias de clé introuvable. - Un alias avec ce nom existe déjà. + Supprimer la clé enregistrée + La retirer de la liste ; le fichier de clé reste inchangé. + Clé SSH enregistrée supprimée : {0} +Seule l’entrée enregistrée a été supprimée. +Fichiers laissés inchangés : {1} + Renommer la clé + Modifier le nom d’une clé enregistrée. + Clé enregistrée introuvable. + Une clé portant ce nom existe déjà. Copier le chemin de la clé - Utilisation : keys copy-path [filtre] + Copier le chemin complet de la clé privée. Copier le chemin : Copier la clé publique - Utilisation : keys copy-pub [filtre] + Copier le contenu de la clé publique. Copier .pub : Fichier de clé publique (.pub) introuvable : Rechercher des clés - Analyser ~/.ssh/ pour les fichiers de clés + Rechercher les clés SSH existantes dans ~/.ssh. Répertoire ~/.ssh/ introuvable. Aucun fichier de clé trouvé dans ~/.ssh/ (déjà enregistré) @@ -143,4 +201,114 @@ Fichiers conservés sur le disque : {1} (fichier introuvable) Fichier d'identité : + + Installer la clé publique + Ajouter la clé sélectionnée à un serveur Linux distant. + Tapez : {0} keys install {1} user@host + Sélectionnez un profil SSH enregistré ou saisissez user@host manuellement. + Saisir la destination manuellement + Ce profil ne peut pas être utilisé pour installer une clé. + Installer "{0}" → {1} + Exécuter la commande de configuration distante + Copier la commande de configuration distante + Copier la clé publique + Copier la commande SSH complète dans le presse-papiers + Copier la clé publique qui sera ajoutée au serveur distant + Clé enregistrée introuvable : {0} + Fichier de clé publique introuvable : {0} + Le contenu du fichier de clé publique est invalide ou non sûr. + Destination invalide — format user@host attendu. + Commande de configuration distante copiée dans le presse-papiers. + + Outils + Se connecter à un serveur enregistré. + Exécuter une commande distante enregistrée. + Clés SSH, shell et import de configuration. + Documentation et aperçu des commandes. + au menu principal + Utiliser ou gérer les clés SSH. + Choisir ou gérer le shell utilisé pour exécuter les commandes. + Importer les profils depuis ~/.ssh/config. + + + Gérer les shells + Via le profil « {0} » • {1} + Copier la commande SSH + aux profils + à la gestion des profils + aux actions + à la gestion des actions + à la sélection du profil + à la sélection de l’action + aux clés SSH + à la gestion des clés SSH + au shell + à la gestion des shells + aux outils + à la sélection du profil + Supprimer le profil « {0} » + + Insérer un exemple de nom : « {0} » + Étape 1 sur 4 • Ou saisissez votre propre nom de profil ci-dessus. + Étape 2 sur 2 : saisir la commande SSH + Après le nom, saisissez par exemple « ssh user@host ». + Insérer un exemple de nom : « {0} » + Étape 1 sur 2 • Ou saisissez votre propre nom d’action ci-dessus. + Insérer un exemple de commande : « hostname » + Étape 2 sur 2 • Ou saisissez votre propre commande distante ci-dessus. + Insérer un exemple de nom : « {0} » + Étape 1 sur 2 • Ou saisissez votre propre nom de clé ci-dessus. + Insérer un exemple de chemin : « ~/.ssh/private_key » + Étape 2 sur 2 • Ou saisissez le chemin de votre clé privée ci-dessus. + Insérer un exemple de nom : « {0} » + Étape 1 sur 2 • Ou saisissez votre propre nom de shell ci-dessus. + Insérer un exemple de programme : « pwsh.exe -NoLogo » + Étape 2 sur 2 • Ou saisissez un programme et des arguments facultatifs ci-dessus. + Utiliser aussi « {0} » comme nom du programme. + Insérer le nouveau nom proposé : « {0} » + Nom actuel : « {0} ». Ou saisissez votre propre nouveau nom ci-dessus. + Insérer le nouveau nom proposé : « {0} » + Nom actuel : « {0} ». Ou saisissez votre propre nouveau nom ci-dessus. + Insérer le nouveau nom proposé : « {0} » + Nom actuel : « {0} ». Ou saisissez votre propre nouveau nom ci-dessus. + Le nouveau nom est identique au nom actuel. + Saisissez une commande SSH valide. + Renommer « {0} » en « {1} » + La commande SSH et les paramètres du profil resteront inchangés. + Renommer « {0} » en « {1} » + La commande distante restera inchangée. + Renommer « {0} » en « {1} » + Le fichier de clé et son chemin resteront inchangés. + Le chemin de la clé n’existe pas + Enregistrer la clé « {0} » + Enregistrer le shell « {0} » + Insérer un exemple de serveur : « user@host » + Étape 2 sur 4 • Ou saisissez votre propre cible ci-dessus, par exemple vaio@10.0.0.10. + Utiliser le port 22 par défaut + Étape 3 sur 4 • Port SSH standard ; il ne sera pas enregistré explicitement. + Saisir un autre port + Étape 3 sur 4 • Saisissez un nombre de 1 à 65535. + Port SSH invalide + Saisissez un nombre entier de 1 à 65535. + Étape 4 sur 4 : Choisissez la connexion + Sélectionnez une clé privée enregistrée ou utilisez l’agent/la configuration SSH. + Utiliser l’agent ou la configuration SSH + Enregistrer le profil sans imposer de clé privée précise. + Saisir une commande SSH complète + À utiliser pour les tunnels, SCP, ProxyJump ou d’autres options avancées. + Aucune clé privée utilisable enregistrée + Ajoutez ou générez une clé privée, ou continuez sans clé. + Fichier de clé privée introuvable : {0} + Une clé publique ne peut pas servir à la connexion. Sélectionnez sa clé privée : {0} + Le fichier n’est pas reconnu comme une clé SSH privée : {0} + Clé privée enregistrée introuvable. + Saisissez un serveur valide sous la forme utilisateur@hôte ou hôte. + Modifiez le dernier « {0} » ou utilisez le nom proposé. + Modifiez le dernier « {0} » ou utilisez le nom proposé. + Modifiez le dernier « {0} » ou utilisez le nom proposé. + + Le shell sélectionné « {0} » n’existe plus. La commande n’a pas été exécutée. + Le shell « {0} » contient une définition de programme non valide. La commande n’a pas été exécutée. + Le programme du shell « {0} » est introuvable. La commande n’a pas été exécutée. + Le shell « {0} » n’a pas pu être démarré. La commande n’a pas été exécutée. Erreur : {1} diff --git a/Languages/pl.xaml b/Languages/pl.xaml index 24f99b4..3fcf5dc 100644 --- a/Languages/pl.xaml +++ b/Languages/pl.xaml @@ -8,40 +8,40 @@ QuickSSH - OpenSSH client not found on this system. + Nie znaleziono klienta OpenSSH w tym systemie. QuickSSH - Profiles database could not be created. + Nie udało się utworzyć bazy danych profili. - No profiles saved. - No shell profiles saved. - Save: - Connect: - Add shell: + Brak zapisanych profili. + Nie zapisano żadnych powłok. + Zapisz: + Połącz: + Dodaj powłokę: - Zarządzanie profilami - Użycie: profiles add | remove | rename | copy | export | import + Profile + Połącz się z zapisanym serwerem. Dodaj profil - Użycie: profiles add <nazwa> <polecenie-ssh> + Zapisz nowe polecenie SSH jako profil. Usuń profil - Użycie: profiles remove [filtr] + Wybierz profil i potwierdź jego usunięcie. Zmień nazwę profilu - Użycie: profiles rename <stara nazwa> <nowa nazwa> - Profile not found. + Zmień nazwę zapisanego profilu. + Nie znaleziono profilu. Kopiuj polecenie SSH Kopiuj: - Użycie: profiles copy [filtr] - Could not copy to clipboard. Ensure Flow Launcher is running on the UI thread. + Skopiuj polecenie wybranego profilu. + Nie udało się skopiować do schowka. Upewnij się, że Flow Launcher działa w wątku interfejsu użytkownika. Polecenie SSH skopiowane do schowka. Ścieżka klucza skopiowana do schowka. Klucz publiczny skopiowany do schowka. @@ -49,63 +49,121 @@ Eksportuj profile Eksportuj profile do: {0} - Użycie: profiles export - Exported {0} profile(s) to {1} + Zapisz profile do pliku eksportu. + Wyeksportowano {0} profili do {1} Importuj profile - Użycie: profiles import [filtr] + Wczytaj profile z pliku eksportu. + Zarządzaj profilami + Dodawaj zapisane profile i zarządzaj nimi. Brak plików .sshconfig lub .json w: {0} - Imported {0} profile(s) successfully. - No profiles found in the selected file. + Zaimportowano: {0}; pominięto istniejące profile: {1}. + Nie znaleziono profili w wybranym pliku. - Direct connect - Direct connect: - Usage: ssh root@host | ssh -p 22 root@host | ssh -i "key" root@host + Połączenie bezpośrednie + Połączenie bezpośrednie: + Użycie: ssh root@host | ssh -p 22 root@host | ssh -i "key" root@host - Shell management - Add shell profile - Usage: shell add <exe> or shell add <name> <exe + args> - Remove shell profile - Usage: shell remove [filter] - Usage: shell add ; remove - (selected) + Powłoka + Dodaj powłokę + Zapisz nową powłokę do uruchamiania poleceń. + Usuń powłokę + Wybierz zapisaną powłokę i usuń ją. + Wybierz powłokę lub zarządzaj nią. + (wybrana) - Import SSH config - Import hosts from ~/.ssh/config file. + Importuj konfigurację SSH + Importuj hosty z pliku ~/.ssh/config. Użycie: config (importuje z ~/.ssh/config) - Imported {0} host(s) from SSH config. - SSH config file not found at ~/.ssh/config. + Zaimportowano {0} hostów z konfiguracji SSH. + Nie znaleziono pliku konfiguracji SSH w ~/.ssh/config. - Help - Open plugin documentation on GitHub. - Usage: help + Pomoc + Otwórz dokumentację wtyczki w serwisie GitHub. + Użycie: help - (legacy) + (starszy format) - Command renamed: use "profiles add" - The "add" command is now "profiles add <name> <ssh-command>". Press Enter or Tab to navigate. + Zmieniono nazwę polecenia: użyj „profiles add” + Polecenie „add” nosi teraz nazwę „profiles add <nazwa> <polecenie-ssh>”. Naciśnij Enter, aby kontynuować. - ← Wróć do {0} + ← Wróć {0} + + + + Akcje + Uruchamiaj zapisane polecenia zdalne przez profile SSH. + Uruchom akcję + Wybierz profil SSH, a następnie akcję. + Dodaj akcję + Zapisz nowe polecenie zdalne. + Usuń akcję + Wybierz akcję i potwierdź jej usunięcie. + Zmień nazwę akcji + Zmień nazwę zapisanej akcji. + Brak zapisanych akcji SSH. + Dodaj pierwszą akcję + Brak akcji SSH + Zapisz polecenie zdalne, aby uruchamiać je przez profil SSH. + Zapisana akcja: {0} + Zapisz akcję „{0}” + Akcja odrzucona: użyj jednego wiersza i nigdy nie umieszczaj klucza prywatnego. + Nie znaleziono akcji. + Nie znaleziono profilu SSH lub jest to profil SCP. + Zapisz profil SSH przed uruchamianiem akcji. + Dodaj profil SSH + Profil: {0} + Akcja: {0} + Uruchom akcję + Wybierz profil SSH, przez który zostanie uruchomiona akcja. + Wybierz polecenie zdalne do uruchomienia. + Sprawdź profil i polecenie zdalne. + Polecenie SSH + Uruchom „{0}” przez profil „{1}”. + Nie można uruchomić akcji + Profil lub akcja jest niekompletna albo nieobsługiwana. + Usuń akcję „{0}” + Użyj 1–64 liter lub cyfr oraz tylko kropki, myślnika lub podkreślenia. + Ta nazwa jest zarezerwowana przez QuickSSH. + Element o tej nazwie już istnieje. + Uzupełnij przykład profilu + server ssh user@host + Uzupełnij przykład akcji + check hostname + Uzupełnij przykład klucza SSH + server-key ~/.ssh/private_key + Zapisz profil „{0}” + + Zarządzaj akcjami + Dodawaj zapisane akcje i zarządzaj nimi. + Wybierz profil SSH dla „{0}”. + Potwierdź uruchomienie + Uruchom „{0}” + Wykonaj przez profil „{0}”. - Zarządzanie kluczami SSH - Użycie: keys install | add | generate | remove | rename | copy-path | copy-pub | scan + Klucze SSH + Instaluj klucze publiczne lub zarządzaj zapisanymi kluczami SSH. + Zarządzaj kluczami SSH + Dodawaj zapisane klucze SSH i zarządzaj nimi. + Klucz publiczny • + Klucz prywatny • Zarejestruj klucz SSH - Użycie: keys add <alias> <ścieżka-do-klucza> + Zapisz istniejący klucz prywatny pod własną nazwą. Wygeneruj parę kluczy SSH - Użycie: keys generate <alias> [własna-ścieżka] + Utwórz nową parę kluczy prywatnego i publicznego. Wygeneruj: {0} {0} → {1} (bez hasła) - Nieprawidłowy alias — zawiera tylko nieprawidłowe znaki. - Alias już istnieje: {0} + Nieprawidłowa nazwa klucza — zawiera tylko nieprawidłowe znaki. + Klucz o tej nazwie już istnieje: {0} Plik klucza już istnieje: {0} Nieprawidłowa ścieżka: {0} Ścieżka jest katalogiem, nie plikiem klucza: {0} @@ -117,24 +175,24 @@ Prywatny: {1} Publiczny: {2} Generowanie klucza anulowane lub nie powiodło się. Generowanie klucza nie powiodło się: {0} - Usuń klucz SSH - Użycie: keys remove [filtr] - Alias klucza SSH usunięty: {0} -Usunięto tylko wpis rejestru. -Pliki zachowane na dysku: {1} - Zmień alias klucza - Użycie: keys rename <stary-alias> <nowy-alias> - Alias klucza nie znaleziony. - Alias o tej nazwie już istnieje. + Usuń zapisany klucz + Usuń go z listy; plik klucza pozostanie bez zmian. + Usunięto zapisany klucz SSH: {0} +Usunięto tylko zapis z listy. +Pliki pozostawiono bez zmian: {1} + Zmień nazwę klucza + Zmień nazwę zapisanego klucza. + Nie znaleziono zapisanego klucza. + Klucz o tej nazwie już istnieje. Kopiuj ścieżkę klucza - Użycie: keys copy-path [filtr] + Skopiuj pełną ścieżkę do klucza prywatnego. Kopiuj ścieżkę: Kopiuj klucz publiczny - Użycie: keys copy-pub [filtr] + Skopiuj zawartość klucza publicznego. Kopiuj .pub: Plik klucza publicznego (.pub) nie znaleziony: Szukaj kluczy - Skanuj ~/.ssh/ w poszukiwaniu plików kluczy + Znajdź istniejące klucze SSH w ~/.ssh. Katalog ~/.ssh/ nie znaleziony. Nie znaleziono plików kluczy w ~/.ssh/ (już zarejestrowany) @@ -143,4 +201,114 @@ Pliki zachowane na dysku: {1} (plik nie znaleziony) Plik tożsamości: + + Zainstaluj klucz publiczny + Dodaj wybrany klucz do zdalnego serwera Linux. + Wpisz: {0} keys install {1} user@host + Wybierz zapisany profil SSH albo wpisz user@host ręcznie. + Wpisz cel ręcznie + Tego profilu nie można użyć do instalacji klucza. + Zainstaluj "{0}" → {1} + Uruchom zdalne polecenie konfiguracji + Kopiuj zdalne polecenie konfiguracji + Kopiuj klucz publiczny + Kopiuj pełne polecenie SSH do schowka + Kopiuj klucz publiczny, który zostanie dodany na zdalnym serwerze + Nie znaleziono zapisanego klucza: {0} + Plik klucza publicznego nie znaleziony: {0} + Zawartość pliku klucza publicznego jest nieprawidłowa lub niebezpieczna. + Nieprawidłowy cel — oczekiwany format user@host. + Zdalne polecenie konfiguracji skopiowane do schowka. + + Narzędzia + Połącz się z zapisanym serwerem. + Uruchom zapisane polecenie zdalne. + Klucze SSH, powłoka i import konfiguracji. + Dokumentacja i przegląd poleceń. + do menu głównego + Użyj kluczy SSH lub nimi zarządzaj. + Wybierz lub zarządzaj powłoką używaną do uruchamiania poleceń. + Importuj profile z ~/.ssh/config. + + + Zarządzaj powłokami + Przez profil „{0}” • {1} + Kopiuj polecenie SSH + do profili + do zarządzania profilami + do akcji + do zarządzania akcjami + do wyboru profilu + do wyboru akcji + do kluczy SSH + do zarządzania kluczami SSH + do powłoki + do zarządzania powłokami + do narzędzi + do wyboru profilu + Usuń profil „{0}” + + Wstaw przykładową nazwę: „{0}” + Krok 1 z 4 • Lub wpisz wyżej własną nazwę profilu. + Krok 2 z 2: Podaj polecenie SSH + Po nazwie wpisz na przykład „ssh user@host”. + Wstaw przykładową nazwę: „{0}” + Krok 1 z 2 • Lub wpisz wyżej własną nazwę akcji. + Wstaw przykładowe polecenie: „hostname” + Krok 2 z 2 • Lub wpisz wyżej własne polecenie zdalne. + Wstaw przykładową nazwę: „{0}” + Krok 1 z 2 • Lub wpisz wyżej własną nazwę klucza. + Wstaw przykładową ścieżkę: „~/.ssh/private_key” + Krok 2 z 2 • Lub wpisz wyżej ścieżkę do swojego klucza prywatnego. + Wstaw przykładową nazwę: „{0}” + Krok 1 z 2 • Lub wpisz wyżej własną nazwę powłoki. + Wstaw przykładowy program: „pwsh.exe -NoLogo” + Krok 2 z 2 • Lub wpisz wyżej program i opcjonalne argumenty. + Użyj „{0}” także jako nazwy programu. + Wstaw proponowaną nową nazwę: „{0}” + Bieżąca nazwa: „{0}”. Lub wpisz wyżej własną nową nazwę. + Wstaw proponowaną nową nazwę: „{0}” + Bieżąca nazwa: „{0}”. Lub wpisz wyżej własną nową nazwę. + Wstaw proponowaną nową nazwę: „{0}” + Bieżąca nazwa: „{0}”. Lub wpisz wyżej własną nową nazwę. + Nowa nazwa jest taka sama jak bieżąca. + Podaj prawidłowe polecenie SSH. + Zmień nazwę „{0}” na „{1}” + Polecenie SSH i ustawienia profilu pozostaną bez zmian. + Zmień nazwę „{0}” na „{1}” + Polecenie zdalne pozostanie bez zmian. + Zmień nazwę „{0}” na „{1}” + Plik klucza i jego ścieżka pozostaną bez zmian. + Ścieżka klucza nie istnieje + Zapisz klucz „{0}” + Zapisz powłokę „{0}” + Wstaw przykładowy serwer: „user@host” + Krok 2 z 4 • Lub wpisz wyżej własny cel, np. vaio@10.0.0.10. + Użyj domyślnego portu 22 + Krok 3 z 4 • Standardowy port SSH; nie zostanie zapisany jawnie. + Wpisz inny port + Krok 3 z 4 • Wpisz liczbę od 1 do 65535. + Nieprawidłowy port SSH + Wpisz liczbę całkowitą od 1 do 65535. + Krok 4 z 4: Wybierz logowanie + Wybierz zapisany klucz prywatny albo użyj agenta/konfiguracji SSH. + Użyj agenta lub konfiguracji SSH + Zapisz profil bez wymuszania konkretnego klucza prywatnego. + Wpisz pełne polecenie SSH + Użyj dla tuneli, SCP, ProxyJump lub innych opcji zaawansowanych. + Brak zapisanego użytecznego klucza prywatnego + Dodaj lub wygeneruj klucz prywatny albo kontynuuj bez niego. + Nie znaleziono pliku klucza prywatnego: {0} + Klucza publicznego nie można użyć do logowania. Wybierz jego klucz prywatny: {0} + Plik nie został rozpoznany jako prywatny klucz SSH: {0} + Nie znaleziono zapisanego klucza prywatnego. + Podaj prawidłowy serwer jako użytkownik@host lub host. + Edytuj ostatnie „{0}” lub użyj proponowanej nazwy. + Edytuj ostatnie „{0}” lub użyj proponowanej nazwy. + Edytuj ostatnie „{0}” lub użyj proponowanej nazwy. + + Wybrana powłoka „{0}” już nie istnieje. Polecenie nie zostało uruchomione. + Powłoka „{0}” ma nieprawidłową definicję programu. Polecenie nie zostało uruchomione. + Nie znaleziono programu dla powłoki „{0}”. Polecenie nie zostało uruchomione. + Nie udało się uruchomić powłoki „{0}”. Polecenie nie zostało uruchomione. Błąd: {1} diff --git a/Languages/ru.xaml b/Languages/ru.xaml index 21b2632..bd5f35b 100644 --- a/Languages/ru.xaml +++ b/Languages/ru.xaml @@ -8,40 +8,40 @@ QuickSSH - OpenSSH client not found on this system. + Клиент OpenSSH не найден в этой системе. QuickSSH - Profiles database could not be created. + Не удалось создать базу данных профилей. - No profiles saved. - No shell profiles saved. - Save: - Connect: - Add shell: + Нет сохранённых профилей. + Нет сохранённых оболочек. + Сохранить: + Подключиться: + Добавить оболочку: - Управление профилями - Использование: profiles add | remove | rename | copy | export | import + Профили + Подключиться к сохранённому серверу. Добавить профиль - Использование: profiles add <имя> <ssh-команда> + Сохранить новую SSH-команду как профиль. Удалить профиль - Использование: profiles remove [фильтр] + Выбрать профиль и подтвердить его удаление. Переименовать профиль - Использование: profiles rename <старое имя> <новое имя> - Profile not found. + Изменить имя сохранённого профиля. + Профиль не найден. Скопировать SSH-команду Копировать: - Использование: profiles copy [фильтр] - Could not copy to clipboard. Ensure Flow Launcher is running on the UI thread. + Скопировать команду выбранного профиля. + Не удалось скопировать в буфер обмена. Убедитесь, что Flow Launcher работает в потоке пользовательского интерфейса. Команда SSH скопирована в буфер обмена. Путь к ключу скопирован в буфер обмена. Открытый ключ скопирован в буфер обмена. @@ -49,63 +49,121 @@ Экспорт профилей Экспортировать профили в: {0} - Использование: profiles export - Exported {0} profile(s) to {1} + Сохранить профили в файл экспорта. + Экспортировано {0} профилей в {1} Импорт профилей - Использование: profiles import [фильтр] + Загрузить профили из файла экспорта. + Управлять профилями + Добавлять сохранённые профили и управлять ими. Файлы .sshconfig или .json не найдены в: {0} - Imported {0} profile(s) successfully. - No profiles found in the selected file. + Импортировано профилей: {0}; пропущено существующих: {1}. + В выбранном файле профили не найдены. - Direct connect - Direct connect: - Usage: ssh root@host | ssh -p 22 root@host | ssh -i "key" root@host + Прямое подключение + Прямое подключение: + Использование: ssh root@host | ssh -p 22 root@host | ssh -i "key" root@host - Shell management - Add shell profile - Usage: shell add <exe> or shell add <name> <exe + args> - Remove shell profile - Usage: shell remove [filter] - Usage: shell add ; remove - (selected) + Оболочка + Добавить оболочку + Сохранить новую оболочку для запуска команд. + Удалить оболочку + Выбрать сохранённую оболочку и удалить её. + Выбрать оболочку или управлять ею. + (выбрана) - Import SSH config - Import hosts from ~/.ssh/config file. + Импорт конфигурации SSH + Импортировать хосты из файла ~/.ssh/config. Использование: config (импорт из ~/.ssh/config) - Imported {0} host(s) from SSH config. - SSH config file not found at ~/.ssh/config. + Импортировано {0} хостов из конфигурации SSH. + Файл конфигурации SSH не найден: ~/.ssh/config. - Help - Open plugin documentation on GitHub. - Usage: help + Справка + Открыть документацию плагина на GitHub. + Использование: help - (legacy) + (старый формат) - Command renamed: use "profiles add" - The "add" command is now "profiles add <name> <ssh-command>". Press Enter or Tab to navigate. + Команда переименована: используйте «profiles add» + Команда «add» теперь называется «profiles add <имя> <ssh-команда>». Нажмите Enter, чтобы продолжить. - ← Назад к {0} + ← Назад {0} + + + + Действия + Запускайте сохранённые удалённые команды через SSH-профили. + Запустить действие + Выберите SSH-профиль, затем действие. + Добавить действие + Сохранить новую удалённую команду. + Удалить действие + Выбрать действие и подтвердить его удаление. + Переименовать действие + Изменить имя сохранённого действия. + Нет сохранённых SSH-действий. + Добавить первое действие + SSH-действий пока нет + Сохраните удалённую команду для запуска через SSH-профиль. + Сохранённое действие: {0} + Сохранить действие «{0}» + Действие отклонено: используйте одну строку и никогда не добавляйте закрытый ключ. + Действие не найдено. + SSH-профиль не найден или это профиль SCP. + Сохраните SSH-профиль перед запуском действий. + Добавить SSH-профиль + Профиль: {0} + Действие: {0} + Запустить действие + Выберите SSH-профиль, через который будет запущено действие. + Выберите удалённую команду для запуска. + Проверьте профиль и удалённую команду. + Команда SSH + Запустить «{0}» через профиль «{1}». + Невозможно запустить действие + Профиль или действие неполное либо не поддерживается. + Удалить действие «{0}» + Используйте 1–64 буквы или цифры и только точку, дефис или подчёркивание. + Это имя зарезервировано QuickSSH. + Элемент с таким именем уже существует. + Заполнить пример профиля + server ssh user@host + Заполнить пример действия + check hostname + Заполнить пример SSH-ключа + server-key ~/.ssh/private_key + Сохранить профиль «{0}» + + Управление действиями + Добавлять сохранённые действия и управлять ими. + Выберите профиль SSH для «{0}». + Подтвердить запуск + Запустить «{0}» + Выполнить через профиль «{0}». - Управление SSH-ключами - Использование: keys install | add | generate | remove | rename | copy-path | copy-pub | scan + SSH-ключи + Устанавливать открытые ключи или управлять сохранёнными SSH-ключами. + Управлять SSH-ключами + Добавлять сохранённые SSH-ключи и управлять ими. + Открытый ключ • + Закрытый ключ • Зарегистрировать SSH-ключ - Использование: keys add <псевдоним> <путь-к-ключу> + Сохранить существующий закрытый ключ под своим именем. Сгенерировать пару SSH-ключей - Использование: keys generate <псевдоним> [свой-путь] + Создать новую пару закрытого и открытого ключей. Сгенерировать: {0} {0} → {1} (без пароля) - Недопустимый псевдоним — содержит только недопустимые символы. - Псевдоним уже существует: {0} + Недопустимое имя ключа — содержит только недопустимые символы. + Ключ с таким именем уже существует: {0} Файл ключа уже существует: {0} Недопустимый путь: {0} Путь является каталогом, а не файлом ключа: {0} @@ -117,24 +175,24 @@ Публичный: {2} Генерация ключа отменена или не удалась. Ошибка генерации ключа: {0} - Удалить SSH-ключ - Использование: keys remove [фильтр] - Псевдоним SSH-ключа удалён: {0} -Удалена только запись в реестре. -Файлы на диске сохранены: {1} - Переименовать псевдоним ключа - Использование: keys rename <старый-псевдоним> <новый-псевдоним> - Псевдоним ключа не найден. - Псевдоним с таким именем уже существует. + Удалить сохранённый ключ + Удалить из списка; файл ключа останется без изменений. + Сохранённый SSH-ключ удалён: {0} +Удалена только запись из списка. +Файлы оставлены без изменений: {1} + Переименовать ключ + Изменить имя сохранённого ключа. + Сохранённый ключ не найден. + Ключ с таким именем уже существует. Копировать путь к ключу - Использование: keys copy-path [фильтр] + Скопировать полный путь к закрытому ключу. Копировать путь: Копировать открытый ключ - Использование: keys copy-pub [фильтр] + Скопировать содержимое открытого ключа. Копировать .pub: Файл открытого ключа (.pub) не найден: Найти ключи - Сканировать ~/.ssh/ на файлы ключей + Найти существующие SSH-ключи в ~/.ssh. Каталог ~/.ssh/ не найден. Файлы ключей в ~/.ssh/ не найдены (уже зарегистрирован) @@ -143,4 +201,114 @@ (файл не найден) Файл идентификации: + + Установить открытый ключ + Добавить выбранный ключ на удалённый Linux-сервер. + Введите: {0} keys install {1} user@host + Выберите сохранённый SSH-профиль или введите user@host вручную. + Ввести назначение вручную + Этот профиль нельзя использовать для установки ключа. + Установить "{0}" → {1} + Выполнить удалённую команду настройки + Копировать удалённую команду настройки + Копировать открытый ключ + Копировать полную SSH-команду в буфер обмена + Копировать открытый ключ, который будет добавлен на удалённый сервер + Сохранённый ключ не найден: {0} + Файл открытого ключа не найден: {0} + Содержимое файла открытого ключа недействительно или небезопасно. + Недопустимое назначение — ожидается формат user@host. + Удалённая команда настройки скопирована в буфер обмена. + + Инструменты + Подключиться к сохранённому серверу. + Выполнить сохранённую удалённую команду. + SSH-ключи, оболочка и импорт конфигурации. + Документация и обзор команд. + в главное меню + Использовать или управлять SSH-ключами. + Выбрать или настроить оболочку для запуска команд. + Импортировать профили из ~/.ssh/config. + + + Управление оболочками + Через профиль «{0}» • {1} + Копировать SSH-команду + к профилям + к управлению профилями + к действиям + к управлению действиями + к выбору профиля + к выбору действия + к SSH-ключам + к управлению SSH-ключами + к оболочке + к управлению оболочками + к инструментам + к выбору профиля + Удалить профиль «{0}» + + Вставить пример имени: «{0}» + Шаг 1 из 4 • Или введите выше своё имя профиля. + Шаг 2 из 2: введите команду SSH + После имени введите, например, «ssh user@host». + Вставить пример имени: «{0}» + Шаг 1 из 2 • Или введите выше своё имя действия. + Вставить пример команды: «hostname» + Шаг 2 из 2 • Или введите выше свою удалённую команду. + Вставить пример имени: «{0}» + Шаг 1 из 2 • Или введите выше своё имя ключа. + Вставить пример пути: «~/.ssh/private_key» + Шаг 2 из 2 • Или введите выше путь к своему закрытому ключу. + Вставить пример имени: «{0}» + Шаг 1 из 2 • Или введите выше своё имя оболочки. + Вставить пример программы: «pwsh.exe -NoLogo» + Шаг 2 из 2 • Или введите выше программу и необязательные аргументы. + Использовать «{0}» также как имя программы. + Вставить предложенное новое имя: «{0}» + Текущее имя: «{0}». Или введите выше своё новое имя. + Вставить предложенное новое имя: «{0}» + Текущее имя: «{0}». Или введите выше своё новое имя. + Вставить предложенное новое имя: «{0}» + Текущее имя: «{0}». Или введите выше своё новое имя. + Новое имя совпадает с текущим. + Введите корректную команду SSH. + Переименовать «{0}» в «{1}» + Команда SSH и настройки профиля не изменятся. + Переименовать «{0}» в «{1}» + Удалённая команда не изменится. + Переименовать «{0}» в «{1}» + Файл ключа и его путь не изменятся. + Путь к ключу не существует + Сохранить ключ «{0}» + Сохранить оболочку «{0}» + Вставить пример сервера: «user@host» + Шаг 2 из 4 • Или введите выше свою цель, например vaio@10.0.0.10. + Использовать порт 22 по умолчанию + Шаг 3 из 4 • Стандартный порт SSH; отдельно сохраняться не будет. + Указать другой порт + Шаг 3 из 4 • Введите число от 1 до 65535. + Недопустимый порт SSH + Введите целое число от 1 до 65535. + Шаг 4 из 4: Выберите вход + Выберите сохранённый закрытый ключ или используйте SSH-агент/конфигурацию. + Использовать SSH-агент или конфигурацию + Сохранить профиль без принудительного выбора конкретного закрытого ключа. + Ввести полную команду SSH + Используйте для туннелей, SCP, ProxyJump и других расширенных параметров. + Нет сохранённого подходящего закрытого ключа + Добавьте или создайте закрытый ключ либо продолжите без него. + Файл закрытого ключа не найден: {0} + Открытый ключ нельзя использовать для входа. Выберите соответствующий закрытый ключ: {0} + Файл не распознан как закрытый ключ SSH: {0} + Сохранённый закрытый ключ не найден. + Введите корректный сервер в виде user@host или host. + Измените последнее «{0}» или используйте предложенное имя. + Измените последнее «{0}» или используйте предложенное имя. + Измените последнее «{0}» или используйте предложенное имя. + + Выбранная оболочка «{0}» больше не существует. Команда не была запущена. + Оболочка «{0}» содержит неверное описание программы. Команда не была запущена. + Программа для оболочки «{0}» не найдена. Команда не была запущена. + Не удалось запустить оболочку «{0}». Команда не была запущена. Ошибка: {1} diff --git a/Languages/sk.xaml b/Languages/sk.xaml index 688840a..1347ff7 100644 --- a/Languages/sk.xaml +++ b/Languages/sk.xaml @@ -15,32 +15,32 @@ Žiadne profily neboli uložené. - Žiadne shell profily neboli uložené. + Žiadne shelly neboli uložené. Uložiť: Pripojiť: Pridať shell: - Správa profilov - Použitie: profiles add | remove | rename | copy | export | import + Profily + Pripojiť sa k uloženému serveru. Pridať profil - Použitie: profiles add <názov> <ssh-príkaz> + Uložiť nový SSH príkaz ako profil. Odstrániť profil - Použitie: profiles remove [filter] + Vybrať profil a potvrdiť jeho odstránenie. Premenovať profil - Použitie: profiles rename <starý názov> <nový názov> + Zmeniť názov uloženého profilu. Profil sa nenašiel. Kopírovať SSH príkaz Kopírovať: - Použitie: profiles copy [filter] + Skopírovať príkaz vybraného profilu. Nepodarilo sa skopírovať do schránky. Uistite sa, že Flow Launcher beží na UI vlákne. SSH príkaz skopírovaný do schránky. Cesta ku kľúču skopírovaná do schránky. @@ -49,14 +49,16 @@ Exportovať profily Exportovať profily do: {0} - Použitie: profiles export + Uložiť profily do exportného súboru. Exportovaných {0} profil(ov) do {1} Importovať profily - Použitie: profiles import [filter] + Načítať profily z exportného súboru. + Spravovať profily + Pridávať a spravovať uložené profily. Žiadne súbory .sshconfig alebo .json nenájdené v: {0} - Importovaných {0} profil(ov) úspešne. + Importovaných {0} profilov; preskočených {1} existujúcich profilov. V zvolenom súbore sa nenašli žiadne profily. @@ -65,16 +67,16 @@ Použitie: ssh root@host | ssh -p 22 root@host | ssh -i "key" root@host - Správa shellov - Pridať shell profil - Použitie: shell add <exe> alebo shell add <názov> <exe + argumenty> - Odstrániť shell profil - Použitie: shell remove [filter] - Použitie: shell add ; remove + Shell + Pridať shell + Uložiť nový shell na spúšťanie príkazov. + Odstrániť shell + Vybrať uložený shell a odstrániť ho. + Vybrať alebo spravovať shell. (vybraný) - Importovať SSH config + Importovať SSH konfiguráciu Importovať hosty zo súboru ~/.ssh/config. Použitie: config (importuje z ~/.ssh/config) Importovaných {0} host(ov) zo SSH config. @@ -86,26 +88,82 @@ Použitie: help - (legacy) + (starší formát) - Command renamed: use "profiles add" - The "add" command is now "profiles add <name> <ssh-command>". Press Enter or Tab to navigate. + Príkaz bol premenovaný: použite „profiles add“ + Príkaz „add“ sa teraz volá „profiles add <názov> <ssh-príkaz>“. Stlačením Enter pokračujte. ← Späť na {0} + + + Akcie + Spúšťajte uložené vzdialené príkazy cez SSH profily. + Spustiť akciu + Vyberte SSH profil a potom akciu. + Pridať akciu + Uložiť nový vzdialený príkaz. + Odstrániť akciu + Vybrať akciu a potvrdiť jej odstránenie. + Premenovať akciu + Zmeniť názov uloženej akcie. + Nie sú uložené žiadne SSH akcie. + Pridať prvú akciu + Zatiaľ nemáte žiadne SSH akcie + Uložte vzdialený príkaz a spúšťajte ho cez SSH profil. + Uložená akcia: {0} + Uložiť akciu „{0}“ + Akcia bola odmietnutá: použite jeden riadok a nikdy nevkladajte privátny kľúč. + Akcia sa nenašla. + SSH profil sa nenašiel alebo ide o SCP profil. + Pred spustením akcie si uložte SSH profil. + Pridať SSH profil + Profil: {0} + Akcia: {0} + Spustiť akciu + Vyberte SSH profil, cez ktorý sa akcia spustí. + Vyberte vzdialený príkaz, ktorý sa má spustiť. + Skontrolujte profil a vzdialený príkaz. + SSH príkaz + Vykonať „{0}“ cez profil „{1}“. + Akciu nemožno spustiť + Profil alebo akcia je neúplná alebo nepodporovaná. + Odstrániť akciu „{0}“ + Použite 1 až 64 písmen alebo číslic a iba bodku, pomlčku alebo podčiarkovník. + Tento názov je vyhradený pre QuickSSH. + Položka s týmto názvom už existuje. + Doplniť vzor profilu + server ssh user@host + Doplniť vzor akcie + check hostname + Doplniť vzor SSH kľúča + server-key ~/.ssh/private_key + Uložiť profil „{0}“ + + Spravovať akcie + Pridávať a spravovať uložené akcie. + Vyberte SSH profil pre akciu „{0}“. + Potvrdiť spustenie + Spustiť „{0}“ + Vykonať cez profil „{0}“. + - Správa SSH kľúčov - Použitie: keys install | add | generate | remove | rename | copy-path | copy-pub | scan - Registrovať SSH kľúč - Použitie: keys add <alias> <cesta-ku-kľúču> - Generovať SSH kľúčový pár - Použitie: keys generate <alias> [vlastná-cesta] + SSH kľúče + Inštalovať verejné kľúče alebo spravovať uložené SSH kľúče. + Spravovať SSH kľúče + Pridávať a spravovať uložené SSH kľúče. + Verejný kľúč • + Súkromný kľúč • + Pridať existujúci SSH kľúč + Uložiť existujúci súkromný kľúč pod vlastným názvom. + Vygenerovať pár SSH kľúčov + Vytvoriť nový súkromný a verejný kľúč. Generovať: {0} {0} → {1} (bez hesla) - Neplatný alias — obsahuje len neplatné znaky. - Alias už existuje: {0} + Neplatný názov kľúča — obsahuje len neplatné znaky. + Kľúč s týmto názvom už existuje: {0} Súbor kľúča už existuje: {0} Neplatná cesta: {0} Cesta je adresár, nie súbor kľúča: {0} @@ -117,24 +175,24 @@ Súkromný: {1} Verejný: {2} Generovanie kľúča bolo zrušené alebo zlyhalo. Generovanie kľúča zlyhalo: {0} - Odstrániť SSH kľúč - Použitie: keys remove [filter] - SSH alias kľúča odstránený: {0} -Iba záznam v registri bol odstránený. -Súbory na disku ponechané: {1} - Premenovať alias kľúča - Použitie: keys rename <starý-alias> <nový-alias> - Alias kľúča sa nenašiel. - Alias s týmto názvom už existuje. + Odstrániť uložený kľúč + Odstrániť záznam zo zoznamu; súbor kľúča zostane zachovaný. + Uložený SSH kľúč odstránený: {0} +Odstránený bol iba záznam zo zoznamu. +Súbory zostali zachované: {1} + Premenovať kľúč + Zmeniť názov uloženého kľúča. + Uložený kľúč sa nenašiel. + Kľúč s týmto názvom už existuje. Kopírovať cestu ku kľúču - Použitie: keys copy-path [filter] + Skopírovať úplnú cestu k súkromnému kľúču. Kopírovať cestu: Kopírovať verejný kľúč - Použitie: keys copy-pub [filter] + Skopírovať obsah verejného kľúča. Kopírovať .pub: Súbor verejného kľúča (.pub) sa nenašiel: Vyhľadať kľúče - Prehľadať ~/.ssh/ na súbory kľúčov + Nájsť existujúce SSH kľúče v priečinku ~/.ssh. Adresár ~/.ssh/ sa nenašiel. Žiadne súbory kľúčov v ~/.ssh/ (už registrovaný) @@ -143,4 +201,114 @@ Súbory na disku ponechané: {1} (súbor sa nenašiel) Identifikačný súbor: + + Nainštalovať verejný kľúč + Pridať vybraný kľúč na vzdialený Linux server. + Napíš: {0} keys install {1} user@host + Vyberte uložený SSH profil alebo ručne napíšte user@host. + Zadať cieľ ručne + Tento profil sa nedá použiť na inštaláciu kľúča. + Nainštalovať "{0}" → {1} + Spustiť vzdialený nastavovací príkaz + Kopírovať vzdialený nastavovací príkaz + Kopírovať verejný kľúč + Kopírovať celý SSH príkaz do schránky + Kopírovať verejný kľúč, ktorý sa pridá na vzdialený server + Uložený kľúč sa nenašiel: {0} + Súbor verejného kľúča sa nenašiel: {0} + Obsah súboru verejného kľúča je neplatný alebo nebezpečný. + Neplatný cieľ — očakáva sa formát user@host. + Vzdialený nastavovací príkaz skopírovaný do schránky. + + Nástroje + Pripojiť sa k uloženému serveru. + Spustiť uložený vzdialený príkaz. + SSH kľúče, shell a import konfigurácie. + Dokumentácia a prehľad príkazov. + hlavné menu + Použiť alebo spravovať SSH kľúče. + Vybrať alebo spravovať shell na spúšťanie príkazov. + Importovať profily zo súboru ~/.ssh/config. + + + Spravovať shelly + Cez profil „{0}“ • {1} + Kopírovať SSH príkaz + profily + správu profilov + akcie + správu akcií + výber profilu + výber akcie + SSH kľúče + správu SSH kľúčov + shell + správu shellov + nástroje + výber profilu + Odstrániť profil „{0}“ + + Doplniť príklad názvu: „{0}“ + Krok 1 zo 4 • Alebo hore napíšte vlastný názov profilu. + Krok 2 z 2: Zadajte SSH príkaz + Za názov napíšte napríklad „ssh user@host“. + Doplniť príklad názvu: „{0}“ + Krok 1 z 2 • Alebo hore napíšte vlastný názov akcie. + Doplniť príklad príkazu: „hostname“ + Krok 2 z 2 • Alebo hore napíšte vlastný vzdialený príkaz. + Doplniť príklad názvu: „{0}“ + Krok 1 z 2 • Alebo hore napíšte vlastný názov kľúča. + Doplniť príklad cesty: „~/.ssh/private_key“ + Krok 2 z 2 • Alebo hore napíšte cestu k svojmu súkromnému kľúču. + Doplniť príklad názvu: „{0}“ + Krok 1 z 2 • Alebo hore napíšte vlastný názov shellu. + Doplniť príklad programu: „pwsh.exe -NoLogo“ + Krok 2 z 2 • Alebo hore napíšte program a voliteľné argumenty. + Použiť „{0}“ aj ako názov programu. + Doplniť návrh nového názvu: „{0}“ + Aktuálny názov: „{0}“. Alebo hore napíšte vlastný nový názov. + Doplniť návrh nového názvu: „{0}“ + Aktuálny názov: „{0}“. Alebo hore napíšte vlastný nový názov. + Doplniť návrh nového názvu: „{0}“ + Aktuálny názov: „{0}“. Alebo hore napíšte vlastný nový názov. + Nový názov je rovnaký ako pôvodný. + Zadajte platný SSH príkaz. + Premenovať „{0}“ na „{1}“ + SSH príkaz a nastavenia profilu zostanú nezmenené. + Premenovať „{0}“ na „{1}“ + Vzdialený príkaz zostane nezmenený. + Premenovať „{0}“ na „{1}“ + Súbor kľúča a jeho cesta zostanú nezmenené. + Cesta ku kľúču neexistuje + Uložiť kľúč „{0}“ + Uložiť shell „{0}“ + Doplniť príklad servera: „user@host“ + Krok 2 zo 4 • Alebo hore napíšte vlastný cieľ, napríklad vaio@10.0.0.10. + Použiť predvolený port 22 + Krok 3 zo 4 • Bežný SSH port; do profilu sa osobitne neuloží. + Zadať iný port + Krok 3 zo 4 • Napíšte číslo od 1 do 65535. + Neplatný SSH port + Zadajte celé číslo od 1 do 65535. + Krok 4 zo 4: Vyberte prihlásenie + Vyberte uložený súkromný kľúč alebo použite SSH agenta či konfiguráciu. + Použiť SSH agenta alebo konfiguráciu + Uložiť profil bez pevne zvoleného súkromného kľúča. + Zadať celý SSH príkaz + Použiť pre tunely, SCP, ProxyJump alebo ďalšie pokročilé možnosti. + Nie je uložený použiteľný súkromný kľúč + Pridajte alebo vygenerujte súkromný kľúč, prípadne pokračujte bez neho. + Súbor súkromného kľúča sa nenašiel: {0} + Verejný kľúč nemožno použiť na prihlásenie. Vyberte jeho súkromný kľúč: {0} + Súbor nie je rozpoznaný ako súkromný SSH kľúč: {0} + Uložený súkromný kľúč sa nenašiel. + Zadajte platný server ako používateľ@server alebo server. + Upravte posledné „{0}“ alebo použite navrhnutý názov. + Upravte posledné „{0}“ alebo použite navrhnutý názov. + Upravte posledné „{0}“ alebo použite navrhnutý názov. + + Vybraný shell „{0}“ už neexistuje. Príkaz sa nespustil. + Shell „{0}“ má neplatne zadaný program. Príkaz sa nespustil. + Program pre shell „{0}“ sa nenašiel. Príkaz sa nespustil. + Shell „{0}“ sa nepodarilo spustiť. Príkaz sa nespustil. Chyba: {1} diff --git a/Main.cs b/Main.cs index 5fcd4f6..582a0e9 100644 --- a/Main.cs +++ b/Main.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.IO; +using System.Globalization; using System.Linq; using System.Text; using Newtonsoft.Json; @@ -18,6 +18,8 @@ public class QuickSsh : IPlugin, IPluginI18n private ProfileManager _profileManager; private const string CommandProfiles = "profiles"; + private const string CommandActions = "actions"; + private const string CommandTools = "tools"; private const string CommandCustomShell = "shell"; private const string CommandKeys = "keys"; private const string CommandConfig = "config"; @@ -30,7 +32,7 @@ public class QuickSsh : IPlugin, IPluginI18n /// private static readonly string[] AllCommandVerbs = new[] { - CommandProfiles, CommandCustomShell, CommandKeys, CommandConfig, CommandHelp, "add" + CommandProfiles, CommandActions, CommandTools, CommandCustomShell, CommandKeys, CommandConfig, CommandHelp, "add" }; // Sub-commands of "profiles" @@ -40,17 +42,31 @@ public class QuickSsh : IPlugin, IPluginI18n private const string ProfilesSubCopy = "copy"; private const string ProfilesSubExport = "export"; private const string ProfilesSubImport = "import"; + private const string ProfilesSubManage = "manage"; private static readonly string[] ProfilesSubCommands = new[] { ProfilesSubAdd, ProfilesSubRemove, ProfilesSubRename, - ProfilesSubCopy, ProfilesSubExport, ProfilesSubImport + ProfilesSubCopy, ProfilesSubExport, ProfilesSubImport, ProfilesSubManage + }; + + // Sub-commands of "actions" + private const string ActionsSubRun = "run"; // profile-first compatibility route + private const string ActionsSubUse = "use"; // action-first guided route + private const string ActionsSubAdd = "add"; + private const string ActionsSubManage = "manage"; + private const string ActionsSubRemove = "remove"; + private const string ActionsSubRename = "rename"; + + private static readonly string[] ActionsSubCommands = new[] + { + ActionsSubRun, ActionsSubAdd, ActionsSubManage, ActionsSubRemove, ActionsSubRename }; // Sub-commands of "shell" private static readonly string[] ShellSubCommands = new[] { - "add", "remove" + "add", "remove", "manage" }; // Sub-commands of "keys" @@ -62,73 +78,136 @@ public class QuickSsh : IPlugin, IPluginI18n private const string KeysSubCopyPath = "copy-path"; private const string KeysSubCopyPub = "copy-pub"; private const string KeysSubScan = "scan"; + private const string KeysSubManage = "manage"; private static readonly string[] KeysSubCommands = new[] { - KeysSubAdd, KeysSubGenerate, KeysSubInstall, KeysSubRemove, KeysSubRename, KeysSubCopyPath, KeysSubCopyPub, KeysSubScan + KeysSubInstall, KeysSubAdd, KeysSubGenerate, KeysSubRename, KeysSubRemove, KeysSubCopyPath, KeysSubCopyPub, KeysSubScan, KeysSubManage }; private const string AppIconPath = "Images\\app.png"; private const string AppIconGreenPath = "Images\\app-green.png"; + private const string AppIconOrangePath = "Images\\app-orange.png"; private const string AppIconRedPath = "Images\\app-red.png"; + /// + /// Returns the icon that represents an operation consistently across every menu, + /// submenu, autocomplete result, selection step, and confirmation screen. + /// + internal static string GetSemanticIconPath(string operation) + { + switch ((operation ?? string.Empty).Trim().ToLowerInvariant()) + { + case "add": + case "create": + case "generate": + case "import": + case "export": + case "install": + case "scan": + case "run": + case "use": + case "save": + case "saved": + case "connect": + case "execute": + return AppIconGreenPath; + + case "rename": + case "edit": + case "update": + return AppIconOrangePath; + + case "remove": + case "delete": + return AppIconRedPath; + + default: + return AppIconPath; + } + } + // ── Submenu ordering scores (Flow Launcher sorts higher score first) ────── - // Consistent layout for every submenu: - // 1. management/usage row (ScoreSubMenuManagement = int.MaxValue) - // 2. action rows (ScoreXxxAction* range, ≥ 1010) - // 3. saved items (starting from ScoreXxxSavedItem = 500, decremented per entry) - // - // Action row scores must be in the 1000+ range so they cannot be bridged by - // Flow Launcher's built-in fuzzy-match bonus that can boost Score=0 results. - internal const int ScoreSubMenuManagement = int.MaxValue; - - // Back-navigation row — always pinned directly below the usage/management hint and - // above every action row. Allows users to return to the parent command level by - // pressing Enter on the first actionable row instead of manually clearing text. - internal const int ScoreBackNavigation = int.MaxValue - 1; - - // "profiles" submenu — mirrors the scale used by the "shell" submenu. - internal const int ScoreProfilesActionAdd = 1060; - internal const int ScoreProfilesActionRemove = 1050; - internal const int ScoreProfilesActionRename = 1040; - internal const int ScoreProfilesActionCopy = 1030; - internal const int ScoreProfilesActionExport = 1020; - internal const int ScoreProfilesActionImport = 1010; - internal const int ScoreProfilesSavedItem = 500; // decremented per additional profile - - // "shell" submenu — action rows must be strictly above any shell entry - internal const int ScoreShellActionAdd = 1100; - internal const int ScoreShellActionRemove = 1050; - internal const int ScoreShellSelected = 1000; - internal const int ScoreShellOtherStart = 500; // decremented per additional shell + // Consistent submenu layout: + // 1. back navigation + // 2. saved items / primary actions + // 3. manage row + // Operation-specific usage hints remain directly below Back. + internal const int ScoreBackNavigation = int.MaxValue; + internal const int ScoreSubMenuManagement = int.MaxValue - 1; + + // "profiles" submenu — saved profiles are primary; all mutations live under Manage. + internal const int ScoreProfilesSavedItem = 900_000; // decremented per additional profile + internal const int ScoreProfilesActionManage = 100_000; + internal const int ScoreProfilesManageAdd = 9000; + internal const int ScoreProfilesManageRename = 8000; + internal const int ScoreProfilesManageCopy = 7000; + internal const int ScoreProfilesManageExport = 6000; + internal const int ScoreProfilesManageImport = 5000; + internal const int ScoreProfilesManageRemove = 1000; + + // Profile creation steps. Port selection is shown before authentication; + // saved private keys stay together above default-auth and advanced choices. + internal const int ScoreProfilesWizardDefaultPort = 900_000; + internal const int ScoreProfilesWizardCustomPort = 800_000; + internal const int ScoreProfilesWizardSavedKeyStart = 900_000; + internal const int ScoreProfilesWizardManageKeys = 300_000; + internal const int ScoreProfilesWizardDefaultAuth = 200_000; + internal const int ScoreProfilesWizardAdvanced = 100_000; + + // "shell" submenu — saved shells are primary; add/remove live under Manage. + internal const int ScoreShellSelected = 900_000; + internal const int ScoreShellOtherStart = 899_000; // decremented per additional shell + internal const int ScoreShellActionManage = 100_000; + internal const int ScoreShellManageAdd = 9000; + internal const int ScoreShellManageRemove = 8000; // ── Top-level command ordering (root "ssh" menu) ──────────────────────── // Gaps of 100 000 ensure Flow Launcher's internal usage-history / fuzzy-match // bonus (which can add thousands of points for frequently-selected items) // cannot reorder the root menu. - internal const int ScoreTopLevelProfiles = 500_000; - internal const int ScoreTopLevelKeys = 400_000; - internal const int ScoreTopLevelShell = 300_000; - internal const int ScoreTopLevelConfig = 200_000; + internal const int ScoreTopLevelProfiles = 400_000; + internal const int ScoreTopLevelActions = 300_000; + internal const int ScoreTopLevelTools = 200_000; internal const int ScoreTopLevelHelp = 100_000; - // "keys" submenu — action rows above saved key entries - // Gaps of 1000 prevent Flow Launcher's usage-history bonus from reordering rows. - internal const int ScoreKeysActionInstall = 9000; - internal const int ScoreKeysActionAdd = 8000; - internal const int ScoreKeysActionGenerate = 7000; - internal const int ScoreKeysActionRemove = 6000; - internal const int ScoreKeysActionRename = 5000; - internal const int ScoreKeysActionCopyPath = 4000; - internal const int ScoreKeysActionCopyPub = 3000; - internal const int ScoreKeysActionScan = 2000; - internal const int ScoreKeysSavedItem = 500; // decremented per additional key + // "tools" submenu — direct navigation to less frequent setup operations. + internal const int ScoreToolsKeys = 900_000; + internal const int ScoreToolsShell = 800_000; + internal const int ScoreToolsConfig = 700_000; + + // "actions" submenu — saved actions are primary; all mutations live under Manage. + internal const int ScoreActionsSavedItem = 900_000; + internal const int ScoreActionsActionManage = 100_000; + internal const int ScoreActionsManageAdd = 9000; + internal const int ScoreActionsManageRename = 8000; + internal const int ScoreActionsManageRemove = 7000; + + // Final confirmation follows the global rule: Back is always first. + internal const int ScoreActionsConfirmBack = int.MaxValue; + internal const int ScoreActionsConfirmRun = int.MaxValue - 1; + internal const int ScoreActionsConfirmProfile = 8000; + internal const int ScoreActionsConfirmAction = 7000; + internal const int ScoreActionsConfirmCommand = 6000; + + // "keys" submenu — install and saved keys are primary; lower-level operations are grouped. + internal const int ScoreKeysSavedItem = 900_000; // decremented per additional key + internal const int ScoreKeysActionInstall = 200_000; + internal const int ScoreKeysActionManage = 100_000; + internal const int ScoreKeysManageAdd = 8000; + internal const int ScoreKeysManageGenerate = 7000; + internal const int ScoreKeysManageScan = 6000; + internal const int ScoreKeysManageRename = 5000; + internal const int ScoreKeysManageCopyPath = 4000; + internal const int ScoreKeysManageCopyPub = 3000; + internal const int ScoreKeysManageRemove = 1000; private string _databasePath; private string _dataDir; private bool _isSshInstalled = true; private bool _isDatabaseCreated = true; + /// public void Init(PluginInitContext context) { _pluginContext = context; @@ -149,6 +228,7 @@ public void Init(PluginInitContext context) _isSshInstalled = Utils.IsSshInstalled(); } + /// public List Query(Query query) { var results = new List(); @@ -196,6 +276,12 @@ public List Query(Query query) case CommandProfiles: results.AddRange(HandleProfiles(query, rest)); break; + case CommandActions: + results.AddRange(HandleActions(query, rest)); + break; + case CommandTools: + results.AddRange(HandleTools(query, rest)); + break; case CommandCustomShell: results.AddRange(HandleShell(query, rest)); break; @@ -258,6 +344,7 @@ private List HandleProfiles(Query query, string rest) case ProfilesSubCopy: return HandleProfilesCopy(query, subRest); case ProfilesSubExport: return HandleProfilesExport(query); case ProfilesSubImport: return HandleProfilesImport(query, subRest); + case ProfilesSubManage: return HandleProfilesManage(query); default: // Mirror the top-level matching pattern: when the partial input // is a prefix of one or more sub-commands, delegate to the @@ -279,59 +366,12 @@ private List HandleProfiles(Query query, string rest) private List HandleProfilesList(Query query, string search) { - var results = new List(); - var profiles = _profileManager.UserData.Profiles; - - // 1. Management/usage hint — always pinned at the top. - results.Add(new Result + var results = new List { - Title = GetTranslation("plugin_quickssh_title_commandprofiles"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandprofiles"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " profiles ", - Score = ScoreSubMenuManagement - }); - - // 2. Back-navigation row — returns to top-level command list. - results.Add(MakeBackNavResult(query, query.ActionKeyword + " ", query.ActionKeyword)); - - // 3. Action rows — always above saved profiles. - // Only shown when no search text is active (user is browsing, not filtering). - if (string.IsNullOrEmpty(search)) - { - var profileSubCmds = new[] - { - ("add", GetTranslation("plugin_quickssh_title_commandprofiles_add"), GetTranslation("plugin_quickssh_subtitle_commandprofiles_add"), ScoreProfilesActionAdd), - ("remove", GetTranslation("plugin_quickssh_title_commandprofiles_remove"), GetTranslation("plugin_quickssh_subtitle_commandprofiles_remove"), ScoreProfilesActionRemove), - ("rename", GetTranslation("plugin_quickssh_title_commandprofiles_rename"), GetTranslation("plugin_quickssh_subtitle_commandprofiles_rename"), ScoreProfilesActionRename), - ("copy", GetTranslation("plugin_quickssh_title_commandprofiles_copy"), GetTranslation("plugin_quickssh_subtitle_commandprofiles_copy_usage"), ScoreProfilesActionCopy), - ("export", GetTranslation("plugin_quickssh_title_commandprofiles_export"), GetTranslation("plugin_quickssh_subtitle_commandprofiles_export_usage"), ScoreProfilesActionExport), - ("import", GetTranslation("plugin_quickssh_title_commandprofiles_import"), GetTranslation("plugin_quickssh_subtitle_commandprofiles_import_usage"), ScoreProfilesActionImport), - }; - foreach (var (scName, scTitle, scSubTitle, scScore) in profileSubCmds) - { - var autoText = query.ActionKeyword + " profiles " + scName + " "; - results.Add(new Result - { - Title = scTitle, - SubTitle = scSubTitle, - IcoPath = AppIconPath, - AutoCompleteText = autoText, - Score = scScore, - Action = _ => - { - _pluginContext?.API?.ChangeQuery(autoText, true); - return false; - } - }); - } - } + MakeBackNavResult(query, query.ActionKeyword + " ", query.ActionKeyword) + }; + var profiles = _profileManager.UserData.Profiles; - // 4. Saved profiles — always below action rows. - // Use a decremented score (starting from ScoreProfilesSavedItem) so each - // profile has a distinct, explicit value — mirroring how the shell submenu - // uses ScoreShellOtherStart--. This prevents Flow Launcher's fuzzy-match - // bonus from boosting any profile above the action rows. if (profiles.Count == 0) { results.Add(new Result @@ -371,7 +411,7 @@ private List HandleProfilesList(Query query, string search) results.Add(new Result { Title = name, - SubTitle = displayCmd, + SubTitle = BuildProfileListSubtitle(profile), IcoPath = AppIconGreenPath, Score = profileScore--, Action = _ => @@ -384,6 +424,62 @@ private List HandleProfilesList(Query query, string search) } } + if (string.IsNullOrEmpty(search)) + { + var manageText = query.ActionKeyword + " profiles manage "; + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_title_commandprofiles_manage"), + SubTitle = GetTranslation("plugin_quickssh_subtitle_commandprofiles_manage"), + IcoPath = AppIconPath, + AutoCompleteText = manageText, + Score = ScoreProfilesActionManage, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(manageText, true); + return false; + } + }); + } + + return results; + } + + private List HandleProfilesManage(Query query) + { + var results = new List + { + MakeBackNavResult(query, query.ActionKeyword + " profiles ", query.ActionKeyword + " profiles") + }; + + var profileActions = new[] + { + ("add", GetTranslation("plugin_quickssh_title_commandprofiles_add"), GetTranslation("plugin_quickssh_subtitle_commandprofiles_add"), AppIconGreenPath, ScoreProfilesManageAdd), + ("rename", GetTranslation("plugin_quickssh_title_commandprofiles_rename"), GetTranslation("plugin_quickssh_subtitle_commandprofiles_rename"), AppIconOrangePath, ScoreProfilesManageRename), + ("copy", GetTranslation("plugin_quickssh_title_commandprofiles_copy"), GetTranslation("plugin_quickssh_subtitle_commandprofiles_copy_usage"), AppIconPath, ScoreProfilesManageCopy), + ("export", GetTranslation("plugin_quickssh_title_commandprofiles_export"), GetTranslation("plugin_quickssh_subtitle_commandprofiles_export_usage"), AppIconGreenPath, ScoreProfilesManageExport), + ("import", GetTranslation("plugin_quickssh_title_commandprofiles_import"), GetTranslation("plugin_quickssh_subtitle_commandprofiles_import_usage"), AppIconGreenPath, ScoreProfilesManageImport), + ("remove", GetTranslation("plugin_quickssh_title_commandprofiles_remove"), GetTranslation("plugin_quickssh_subtitle_commandprofiles_remove"), AppIconRedPath, ScoreProfilesManageRemove), + }; + + foreach (var (scName, scTitle, scSubTitle, iconPath, scScore) in profileActions) + { + var autoText = query.ActionKeyword + " profiles " + scName + " "; + results.Add(new Result + { + Title = scTitle, + SubTitle = scSubTitle, + IcoPath = iconPath, + AutoCompleteText = autoText, + Score = scScore, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(autoText, true); + return false; + } + }); + } + return results; } @@ -404,9 +500,9 @@ private List HandleLegacyAddRedirect(Query query, string rest) { Title = GetTranslation("plugin_quickssh_title_commandadd_legacy"), SubTitle = GetTranslation("plugin_quickssh_subtitle_commandadd_legacy"), - IcoPath = AppIconPath, + IcoPath = GetSemanticIconPath("add"), AutoCompleteText = redirectTarget, - Score = int.MaxValue, + Score = ScoreSubMenuManagement, Action = _ => { _pluginContext?.API?.ChangeQuery(redirectTarget, true); @@ -421,234 +517,573 @@ private List HandleLegacyAddRedirect(Query query, string rest) private List HandleProfilesAdd(Query query, string rest) { var results = new List(); + var profiles = _profileManager.UserData.Profiles; - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandprofiles_add"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandprofiles_add"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " profiles add ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " profiles ", query.ActionKeyword + " profiles")); + results.Add(MakeBackNavResult( + query, + query.ActionKeyword + " profiles manage ", + query.ActionKeyword + " profiles manage")); - if (string.IsNullOrEmpty(rest)) + rest = CommandInputGuard.NormalizeNestedCommandInput( + rest, query.ActionKeyword, "profiles add"); + if (string.IsNullOrWhiteSpace(rest)) + { + var exampleName = ProfileWizard.BuildAvailableName("server", profiles.Keys); + var exampleText = query.ActionKeyword + " profiles add " + exampleName; + results.Add(MakeWizardExampleResultFromKeys( + "plugin_quickssh_wizard_profiles_add_name_title", + "plugin_quickssh_wizard_profiles_add_name_subtitle", + exampleText, + exampleName)); return results; + } var addParts = rest.Split(new[] { ' ' }, 2); - var profileName = addParts[0]; - var rawCommand = addParts.Length > 1 ? addParts[1].Trim() : ""; + var profileName = addParts[0].Trim(); + var profileInput = addParts.Length > 1 ? addParts[1].Trim() : ""; - if (!string.IsNullOrEmpty(rawCommand)) + if (!CommandInputGuard.IsValidSavedName(profileName)) { - // Normalise: strip cmd-style /flags, ensure "ssh " prefix. - var sshCommand = NormalizeSshCommand(rawCommand) ?? ""; - if (!string.IsNullOrEmpty(sshCommand)) + results.Add(new Result { - var profile = SshProfile.ParseFromLegacyCommand(sshCommand); - var displayCmd = profile.ToDisplayString(); - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_save_label") + " " + profileName, - SubTitle = displayCmd, - IcoPath = AppIconGreenPath, - Action = _ => - { - _profileManager.UserData.Profiles[profileName] = profile; - _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " profiles ", true); - return false; - } - }); - } + Title = profileName, + SubTitle = GetTranslation("plugin_quickssh_name_invalid"), + IcoPath = AppIconRedPath + }); + return results; } - return results; - } - - // ── profiles remove ─────────────────────────────────────────────────────── - - private List HandleProfilesRemove(Query query, string rest) - { - var results = new List(); - var profiles = _profileManager.UserData.Profiles; - - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandprofiles_remove"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandprofiles_remove"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " profiles remove ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " profiles ", query.ActionKeyword + " profiles")); - - if (profiles.Count == 0) + if (CommandInputGuard.IsReservedSavedName(profileName)) { results.Add(new Result { - Title = GetTranslation("plugin_quickssh_title_commandprofiles_remove"), - SubTitle = GetTranslation("plugin_quickssh_noprofiles"), - IcoPath = AppIconPath + Title = profileName, + SubTitle = GetTranslation("plugin_quickssh_name_reserved"), + IcoPath = AppIconRedPath }); return results; } - foreach (var entry in profiles) + if (CommandInputGuard.FindExistingName(profiles, profileName) != null) { - if (!string.IsNullOrEmpty(rest) && - !entry.Key.ToLowerInvariant().Contains(rest.ToLowerInvariant())) - continue; - - var cmd = entry.Value?.ToDisplayString() ?? ""; results.Add(new Result { - Title = entry.Key, - SubTitle = cmd, - IcoPath = AppIconRedPath, - AutoCompleteText = query.ActionKeyword + " profiles remove " + entry.Key, - Action = _ => - { - _profileManager.UserData.Profiles.Remove(entry.Key); - _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " profiles ", true); - return false; - } + Title = profileName, + SubTitle = GetTranslation("plugin_quickssh_name_exists"), + IcoPath = AppIconRedPath }); + return results; } - return results; - } - - // ── profiles rename ─────────────────────────────────────────────────────── - - private List HandleProfilesRename(Query query, string rest) - { - var results = new List(); - var profiles = _profileManager.UserData.Profiles; - - var parts = rest.Split(new[] { ' ' }, 2); - var oldName = parts[0].Trim(); - var newName = parts.Length > 1 ? parts[1].Trim() : ""; + if (string.IsNullOrWhiteSpace(profileInput)) + { + var exampleText = query.ActionKeyword + " profiles add " + profileName + " user@host"; + results.Add(MakeWizardExampleResultFromKeys( + "plugin_quickssh_wizard_profiles_add_target_title", + "plugin_quickssh_wizard_profiles_add_target_subtitle", + exampleText)); + return results; + } - if (string.IsNullOrEmpty(oldName)) + SshProfile profile; + if (ProfileWizard.IsAdvancedCommand(profileInput)) { - results.Add(new Result + var advancedCommand = NormalizeSshCommand(profileInput) ?? ""; + if (string.IsNullOrEmpty(advancedCommand)) { - Title = GetTranslation("plugin_quickssh_title_commandprofiles_rename"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandprofiles_rename"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " profiles rename ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " profiles ", query.ActionKeyword + " profiles")); + results.Add(new Result + { + Title = profileInput, + SubTitle = GetTranslation("plugin_quickssh_command_invalid"), + IcoPath = AppIconRedPath + }); + return results; + } - if (profiles.Count == 0) + profile = SshProfile.ParseFromLegacyCommand(advancedCommand); + if (string.IsNullOrWhiteSpace(profile.HostName) && + !string.Equals(profile.Type, "scp", StringComparison.OrdinalIgnoreCase)) { results.Add(new Result { - Title = GetTranslation("plugin_quickssh_title_commandprofiles_rename"), - SubTitle = GetTranslation("plugin_quickssh_noprofiles"), - IcoPath = AppIconPath + Title = profileInput, + SubTitle = GetTranslation("plugin_quickssh_profiles_destination_invalid"), + IcoPath = AppIconRedPath }); return results; } + } + else + { + var inputTokens = SshProfile.TokenizeShellLine(profileInput); + var hasPortOption = inputTokens.Any(token => + token.Equals(ProfileWizard.PortOption, StringComparison.OrdinalIgnoreCase)); + var looksLikeBasicInput = inputTokens.Count == 1 || + inputTokens.Any(token => + token.Equals(ProfileWizard.SavedKeyOption, StringComparison.OrdinalIgnoreCase) || + token.Equals(ProfileWizard.DefaultAuthOption, StringComparison.OrdinalIgnoreCase) || + token.Equals(ProfileWizard.PortOption, StringComparison.OrdinalIgnoreCase)); + + if (!ProfileWizard.TryParseBasicInput( + profileInput, + out var destination, + out var keyAlias, + out var useDefaultAuthentication, + out var port)) + { + if (looksLikeBasicInput) + { + results.Add(new Result + { + Title = hasPortOption + ? GetTranslation("plugin_quickssh_profiles_port_invalid_title") + : profileInput, + SubTitle = hasPortOption + ? GetTranslation("plugin_quickssh_profiles_port_invalid_subtitle") + : GetTranslation("plugin_quickssh_profiles_destination_invalid"), + IcoPath = AppIconRedPath + }); + return results; + } - foreach (var entry in profiles) + // Backward-compatible advanced input without an explicit "ssh" prefix. + var legacyCommand = NormalizeSshCommand(profileInput) ?? ""; + profile = SshProfile.ParseFromLegacyCommand(legacyCommand); + if (string.IsNullOrWhiteSpace(profile.HostName)) + { + results.Add(new Result + { + Title = profileInput, + SubTitle = GetTranslation("plugin_quickssh_profiles_destination_invalid"), + IcoPath = AppIconRedPath + }); + return results; + } + } + else if (keyAlias == null && !useDefaultAuthentication && port == null) { - var name = entry.Key; - var autoText = query.ActionKeyword + " profiles rename " + name + " "; + var portPrefix = query.ActionKeyword + " profiles add " + profileName + " " + destination; + var defaultPortText = portPrefix + " " + ProfileWizard.PortOption + " 22"; results.Add(new Result { - Title = name, - SubTitle = entry.Value?.ToDisplayString() ?? "", + Title = GetTranslation("plugin_quickssh_profiles_port_default_title"), + SubTitle = GetTranslation("plugin_quickssh_profiles_port_default_subtitle"), IcoPath = AppIconPath, - AutoCompleteText = autoText, + Score = ScoreProfilesWizardDefaultPort, + AutoCompleteText = defaultPortText, Action = _ => { - _pluginContext?.API?.ChangeQuery(autoText, true); + _pluginContext?.API?.ChangeQuery(defaultPortText, true); return false; } }); - } - return results; - } - - if (!profiles.ContainsKey(oldName)) - { - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandprofiles_rename"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandprofiles_rename"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " profiles rename ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " profiles ", query.ActionKeyword + " profiles")); - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandprofiles_rename") + ": " + oldName, - SubTitle = GetTranslation("plugin_quickssh_rename_notfound"), - IcoPath = AppIconRedPath - }); - return results; - } - - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandprofiles_rename"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandprofiles_rename"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " profiles rename " + oldName + " ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " profiles ", query.ActionKeyword + " profiles")); - if (!string.IsNullOrEmpty(newName)) - { - var profileValue = profiles[oldName]; - results.Add(new Result - { - Title = oldName + " → " + newName, - SubTitle = profileValue?.ToDisplayString() ?? "", - IcoPath = AppIconGreenPath, - Action = _ => + var customPortText = portPrefix + " " + ProfileWizard.PortOption + " "; + results.Add(new Result { - var value = profiles[oldName]; - profiles.SetCallback(null); - try + Title = GetTranslation("plugin_quickssh_profiles_port_custom_title"), + SubTitle = GetTranslation("plugin_quickssh_profiles_port_custom_subtitle"), + IcoPath = AppIconPath, + Score = ScoreProfilesWizardCustomPort, + AutoCompleteText = customPortText, + Action = _ => { - profiles.Remove(oldName); - profiles[newName] = value; + _pluginContext?.API?.ChangeQuery(customPortText, true); + return false; } - finally + }); + return results; + } + else if (keyAlias == null && !useDefaultAuthentication) + { + var authPrefix = query.ActionKeyword + " profiles add " + profileName + " " + + destination + " " + ProfileWizard.PortOption + " " + port; + var usableKeyCount = 0; + var keyScore = ScoreProfilesWizardSavedKeyStart; + foreach (var entry in _profileManager.UserData.SshKeys) + { + var rowScore = keyScore--; + var selectedAlias = entry.Key; + var selectedEntry = entry.Value; + var selectedPath = selectedEntry?.Path ?? ""; + if (!ProfileWizard.IsUsablePrivateKey(selectedEntry)) { - profiles.SetCallback(_profileManager.SaveConfiguration); + results.Add(new Result + { + Title = selectedAlias, + SubTitle = GetProfileKeyUnavailableSubtitle(selectedEntry), + IcoPath = AppIconRedPath, + Score = rowScore + }); + continue; } - _profileManager.SaveConfiguration(); - _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " profiles ", true); - return false; - } - }); - } - return results; - } + usableKeyCount++; + var keyText = authPrefix + " " + + ProfileWizard.SavedKeyOption + " " + selectedAlias; + results.Add(new Result + { + Title = selectedAlias, + SubTitle = GetTranslation("plugin_quickssh_keys_private_path_label") + " " + + ProfileWizard.ExpandLocalPath(selectedPath), + IcoPath = AppIconGreenPath, + Score = rowScore, + AutoCompleteText = keyText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(keyText, true); + return false; + } + }); + } - // ── profiles copy ───────────────────────────────────────────────────────── + if (usableKeyCount == 0) + { + var manageKeysText = query.ActionKeyword + " keys manage "; + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_profiles_no_private_keys_title"), + SubTitle = GetTranslation("plugin_quickssh_profiles_no_private_keys_subtitle"), + IcoPath = AppIconPath, + Score = ScoreProfilesWizardManageKeys, + AutoCompleteText = manageKeysText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(manageKeysText, true); + return false; + } + }); + } - private List HandleProfilesCopy(Query query, string search) - { - var results = new List(); + var defaultText = authPrefix + " " + ProfileWizard.DefaultAuthOption; + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_profiles_auth_default_title"), + SubTitle = GetTranslation("plugin_quickssh_profiles_auth_default_subtitle"), + IcoPath = AppIconPath, + Score = ScoreProfilesWizardDefaultAuth, + AutoCompleteText = defaultText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(defaultText, true); + return false; + } + }); + + var advancedText = query.ActionKeyword + " profiles add " + profileName + " ssh "; + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_profiles_auth_advanced_title"), + SubTitle = GetTranslation("plugin_quickssh_profiles_auth_advanced_subtitle"), + IcoPath = AppIconPath, + Score = ScoreProfilesWizardAdvanced, + AutoCompleteText = advancedText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(advancedText, true); + return false; + } + }); + return results; + } + else + { + string? identityFile = null; + if (keyAlias != null) + { + var storedAlias = CommandInputGuard.FindExistingName( + _profileManager.UserData.SshKeys, keyAlias); + if (storedAlias == null) + { + results.Add(new Result + { + Title = keyAlias, + SubTitle = GetTranslation("plugin_quickssh_profiles_key_notfound"), + IcoPath = AppIconRedPath + }); + return results; + } + + var keyEntry = _profileManager.UserData.SshKeys[storedAlias]; + if (!ProfileWizard.IsUsablePrivateKey(keyEntry)) + { + results.Add(new Result + { + Title = storedAlias, + SubTitle = GetProfileKeyUnavailableSubtitle(keyEntry), + IcoPath = AppIconRedPath + }); + return results; + } + + identityFile = ProfileWizard.ExpandLocalPath(keyEntry.Path); + } + + if (!ProfileWizard.TryCreateBasicProfile( + destination, identityFile, port, out profile)) + { + results.Add(new Result + { + Title = destination, + SubTitle = GetTranslation("plugin_quickssh_profiles_destination_invalid"), + IcoPath = AppIconRedPath + }); + return results; + } + } + } + + results.Add(new Result + { + Title = string.Format( + GetTranslation("plugin_quickssh_profiles_save_title"), profileName), + SubTitle = BuildProfileListSubtitle(profile), + IcoPath = AppIconGreenPath, + Action = _ => + { + profiles[profileName] = profile; + _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " profiles ", true); + return false; + } + }); + + return results; + } + + + // ── profiles remove ─────────────────────────────────────────────────────── + + private List HandleProfilesRemove(Query query, string rest) + { + var profiles = _profileManager.UserData.Profiles; + var exactName = CommandInputGuard.FindExistingName(profiles, rest); + var results = new List + { + exactName == null + ? MakeBackNavResult( + query, + query.ActionKeyword + " profiles manage ", + query.ActionKeyword + " profiles manage") + : MakeBackNavResult( + query, + query.ActionKeyword + " profiles remove ", + "profiles remove selection") + }; + + if (profiles.Count == 0) + { + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_title_commandprofiles_remove"), + SubTitle = GetTranslation("plugin_quickssh_noprofiles"), + IcoPath = AppIconPath + }); + return results; + } + + if (exactName != null) + { + var profile = profiles[exactName]; + results.Add(new Result + { + Title = string.Format( + GetTranslation("plugin_quickssh_profiles_remove_confirm"), exactName), + SubTitle = BuildProfileListSubtitle(profile), + IcoPath = AppIconRedPath, + Score = ScoreActionsConfirmRun, + Action = _ => + { + profiles.Remove(exactName); + _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " profiles ", true); + return false; + } + }); + return results; + } + + foreach (var entry in profiles) + { + if (!string.IsNullOrEmpty(rest) && + !entry.Key.ToLowerInvariant().Contains(rest.ToLowerInvariant())) + continue; + + var autoText = query.ActionKeyword + " profiles remove " + entry.Key; + results.Add(new Result + { + Title = entry.Key, + SubTitle = BuildProfileListSubtitle(entry.Value), + IcoPath = AppIconRedPath, + AutoCompleteText = autoText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(autoText, true); + return false; + } + }); + } + + return results; + } + + // ── profiles rename ─────────────────────────────────────────────────────── + + private List HandleProfilesRename(Query query, string rest) + { + var results = new List(); var profiles = _profileManager.UserData.Profiles; + rest = CommandInputGuard.NormalizeNestedCommandInput( + rest, query.ActionKeyword, "profiles rename"); + var parts = rest.Split(new[] { ' ' }, 2); + var requestedOldName = parts[0].Trim(); + var newName = parts.Length > 1 ? parts[1].Trim() : ""; + + if (string.IsNullOrEmpty(requestedOldName)) + { + results.Add(MakeBackNavResult(query, query.ActionKeyword + " profiles manage ", query.ActionKeyword + " profiles manage")); + + if (profiles.Count == 0) + { + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_title_commandprofiles_rename"), + SubTitle = GetTranslation("plugin_quickssh_noprofiles"), + IcoPath = AppIconPath + }); + return results; + } + + foreach (var entry in profiles) + { + var name = entry.Key; + var autoText = ProfileWizard.BuildPrefilledRenameQuery( + query.ActionKeyword, "profiles rename", name); + results.Add(new Result + { + Title = name, + SubTitle = BuildProfileListSubtitle(entry.Value), + IcoPath = GetSemanticIconPath("rename"), + AutoCompleteText = autoText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(autoText, true); + return false; + } + }); + } + return results; + } + + var oldName = CommandInputGuard.FindExistingName(profiles, requestedOldName); + results.Add(MakeBackNavResult(query, query.ActionKeyword + " profiles manage ", query.ActionKeyword + " profiles manage")); + + if (oldName == null) + { + results.Add(new Result + { + Title = requestedOldName, + SubTitle = GetTranslation("plugin_quickssh_rename_notfound"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (string.IsNullOrWhiteSpace(newName)) + { + var suggestedName = ProfileWizard.BuildSuggestedName(oldName, profiles.Keys); + var exampleText = ProfileWizard.BuildRenameQuery( + query.ActionKeyword, "profiles rename", oldName, suggestedName); + results.Add(MakeWizardExampleResult( + string.Format(GetTranslation("plugin_quickssh_wizard_profiles_rename_title"), suggestedName), + string.Format(GetTranslation("plugin_quickssh_wizard_profiles_rename_subtitle"), oldName), + exampleText)); + return results; + } + + if (!CommandInputGuard.IsValidSavedName(newName)) + { + results.Add(new Result + { + Title = newName, + SubTitle = GetTranslation("plugin_quickssh_name_invalid"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (CommandInputGuard.IsReservedSavedName(newName)) + { + results.Add(new Result + { + Title = newName, + SubTitle = GetTranslation("plugin_quickssh_name_reserved"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (string.Equals(oldName, newName, StringComparison.Ordinal)) + { + var suggestedName = ProfileWizard.BuildSuggestedName(oldName, profiles.Keys); + var exampleText = ProfileWizard.BuildRenameQuery( + query.ActionKeyword, "profiles rename", oldName, suggestedName); + results.Add(MakeWizardExampleResult( + string.Format(GetTranslation("plugin_quickssh_wizard_profiles_rename_title"), suggestedName), + string.Format(GetTranslation("plugin_quickssh_wizard_profiles_rename_prefilled_subtitle"), oldName), + exampleText)); + return results; + } + + var conflictingName = CommandInputGuard.FindExistingName(profiles, newName); + if (conflictingName != null && + !string.Equals(conflictingName, oldName, StringComparison.Ordinal)) + { + results.Add(new Result + { + Title = newName, + SubTitle = GetTranslation("plugin_quickssh_name_exists"), + IcoPath = AppIconRedPath + }); + return results; + } + results.Add(new Result { - Title = GetTranslation("plugin_quickssh_title_commandprofiles_copy"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandprofiles_copy_usage"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " profiles copy ", - Score = int.MaxValue + Title = string.Format( + GetTranslation("plugin_quickssh_profiles_rename_confirm_title"), + oldName, newName), + SubTitle = GetTranslation("plugin_quickssh_profiles_rename_confirm_subtitle"), + IcoPath = GetSemanticIconPath("rename"), + Action = _ => + { + var value = profiles[oldName]; + profiles.SetCallback(null); + try + { + profiles.Remove(oldName); + profiles[newName] = value; + } + finally + { + profiles.SetCallback(_profileManager.SaveConfiguration); + } + _profileManager.SaveConfiguration(); + _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " profiles ", true); + return false; + } }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " profiles ", query.ActionKeyword + " profiles")); + + return results; + } + + // ── profiles copy ───────────────────────────────────────────────────────── + + private List HandleProfilesCopy(Query query, string search) + { + var results = new List(); + var profiles = _profileManager.UserData.Profiles; + + results.Add(MakeBackNavResult(query, query.ActionKeyword + " profiles manage ", query.ActionKeyword + " profiles manage")); if (profiles.Count == 0) { @@ -673,8 +1108,8 @@ private List HandleProfilesCopy(Query query, string search) results.Add(new Result { Title = name, - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandprofiles_copy") + " " + displayCmd, - IcoPath = AppIconGreenPath, + SubTitle = GetTranslation("plugin_quickssh_subtitle_commandprofiles_copy") + " " + BuildProfileListSubtitle(entry.Value), + IcoPath = GetSemanticIconPath("copy"), AutoCompleteText = query.ActionKeyword + " profiles copy " + name, Action = _ => { @@ -697,15 +1132,7 @@ private List HandleProfilesExport(Query query) var results = new List(); var exportPath = Path.Combine(_dataDir, "profiles_export.sshconfig"); - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandprofiles_export"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandprofiles_export_usage"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " profiles export ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " profiles ", query.ActionKeyword + " profiles")); + results.Add(MakeBackNavResult(query, query.ActionKeyword + " profiles manage ", query.ActionKeyword + " profiles manage")); results.Add(new Result { @@ -758,15 +1185,7 @@ private List HandleProfilesImport(Query query, string rest) catch (UnauthorizedAccessException) { } catch (IOException) { } - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandprofiles_import"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandprofiles_import_usage"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " profiles import ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " profiles ", query.ActionKeyword + " profiles")); + results.Add(MakeBackNavResult(query, query.ActionKeyword + " profiles manage ", query.ActionKeyword + " profiles manage")); if (importFiles.Length == 0) { @@ -862,29 +1281,13 @@ private void ImportProfilesFromFile(string filePath) } } - int count = 0; - _profileManager.UserData.Profiles.SetCallback(null); - try - { - foreach (var kvp in imported) - { - if (!_profileManager.UserData.Profiles.ContainsKey(kvp.Key)) - { - _profileManager.UserData.Profiles[kvp.Key] = kvp.Value; - count++; - } - } - } - finally - { - _profileManager.UserData.Profiles.SetCallback(_profileManager.SaveConfiguration); - } - - if (count > 0) - _profileManager.SaveConfiguration(); + var result = ProfileImportService.Import(_profileManager, imported); _pluginContext.API.ShowMsg("QuickSSH", - string.Format(GetTranslation("plugin_quickssh_import_success"), count)); + string.Format( + GetTranslation("plugin_quickssh_import_success"), + result.ImportedCount, + result.SkippedCount)); } private List HandleDirectConnect(Query query, string rest) @@ -897,7 +1300,7 @@ private List HandleDirectConnect(Query query, string rest) Title = GetTranslation("plugin_quickssh_title_commanddirect"), SubTitle = GetTranslation("plugin_quickssh_subtitle_commanddirectconnect_usage"), IcoPath = AppIconPath, - Score = int.MaxValue + Score = ScoreSubMenuManagement }); if (string.IsNullOrEmpty(rest)) @@ -979,62 +1382,942 @@ private List HandleDirectConnect(Query query, string rest) return results; } - private List HandleShell(Query query, string rest) + + // ── actions (production CRUD and confirmed remote execution) ───────────── + + private List HandleActions(Query query, string rest) { - var results = new List(); var parts = rest.Split(new[] { ' ' }, 2); var subCmd = parts[0].ToLowerInvariant(); var subRest = parts.Length > 1 ? parts[1].Trim() : ""; switch (subCmd) { - case "add": - // Always show usage hint at the top. - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandshell_add"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandshell_add_usage"), - IcoPath = AppIconPath, - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " shell ", query.ActionKeyword + " shell")); - if (!string.IsNullOrEmpty(subRest)) + case ActionsSubRun: return HandleActionsRun(query, subRest); + case ActionsSubUse: return HandleActionsUse(query, subRest); + case ActionsSubAdd: return HandleActionsAdd(query, subRest); + case ActionsSubManage: return HandleActionsManage(query); + case ActionsSubRemove: return HandleActionsRemove(query, subRest); + case ActionsSubRename: return HandleActionsRename(query, subRest); + default: + if (!string.IsNullOrEmpty(subCmd) && + ActionsSubCommands.Any(s => s.StartsWith(subCmd))) { - var (name, value) = ParseShellAddArgs(subRest); - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_addshell_label") + " " + name, - SubTitle = string.IsNullOrEmpty(value) ? name : value, - IcoPath = AppIconGreenPath, - Action = _ => - { - // Suppress auto-save during mutations so we can set all - // fields (including SelectedCustomShell) before persisting once. - _profileManager.UserData.CustomShell.SetCallback(null); - _profileManager.UserData.CustomShell[name] = value ?? ""; - if (_profileManager.UserData.CustomShell.Count == 1) - _profileManager.UserData.SelectedCustomShell = name; - _profileManager.UserData.CustomShell.SetCallback(_profileManager.SaveConfiguration); - _profileManager.SaveConfiguration(); - _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " shell ", true); + return new List(AutoCompleter.GetSuggestions( + query.ActionKeyword, "actions " + rest, + _profileManager?.UserData, AppIconPath, + _pluginContext?.API)); + } + return HandleActionsList(query, rest); + } + } + + private List HandleActionsList(Query query, string search) + { + var results = new List + { + MakeBackNavResult(query, query.ActionKeyword + " ", query.ActionKeyword) + }; + var actions = _profileManager.UserData.CommandProfiles; + + if (actions.Count == 0) + { + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_actions_empty_title"), + SubTitle = GetTranslation("plugin_quickssh_actions_empty_subtitle"), + IcoPath = AppIconPath, + Score = ScoreActionsSavedItem + }); + } + else + { + int itemScore = ScoreActionsSavedItem; + foreach (var entry in actions) + { + var display = entry.Value?.ToDisplayString() ?? ""; + if (!string.IsNullOrEmpty(search) && + !SearchMatcher.ContainsIgnoreAccents(entry.Key, search) && + !SearchMatcher.ContainsIgnoreAccents(display, search)) + continue; + + var autoText = query.ActionKeyword + " actions use " + entry.Key + " "; + results.Add(new Result + { + Title = entry.Key, + SubTitle = display, + IcoPath = AppIconGreenPath, + AutoCompleteText = autoText, + Score = itemScore--, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(autoText, true); + return false; + } + }); + } + } + + if (string.IsNullOrEmpty(search)) + { + var manageText = query.ActionKeyword + " actions manage "; + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_title_commandactions_manage"), + SubTitle = GetTranslation("plugin_quickssh_subtitle_commandactions_manage"), + IcoPath = AppIconPath, + AutoCompleteText = manageText, + Score = ScoreActionsActionManage, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(manageText, true); + return false; + } + }); + } + + return results; + } + + private List HandleActionsManage(Query query) + { + var results = new List + { + MakeBackNavResult(query, query.ActionKeyword + " actions ", query.ActionKeyword + " actions") + }; + + var actionRows = new[] + { + ("add", GetTranslation("plugin_quickssh_title_commandactions_add"), GetTranslation("plugin_quickssh_subtitle_commandactions_add"), AppIconGreenPath, ScoreActionsManageAdd), + ("rename", GetTranslation("plugin_quickssh_title_commandactions_rename"), GetTranslation("plugin_quickssh_subtitle_commandactions_rename"), AppIconOrangePath, ScoreActionsManageRename), + ("remove", GetTranslation("plugin_quickssh_title_commandactions_remove"), GetTranslation("plugin_quickssh_subtitle_commandactions_remove"), AppIconRedPath, ScoreActionsManageRemove), + }; + + foreach (var (name, title, subtitle, icon, score) in actionRows) + { + var autoText = query.ActionKeyword + " actions " + name + " "; + results.Add(new Result + { + Title = title, + SubTitle = subtitle, + IcoPath = icon, + AutoCompleteText = autoText, + Score = score, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(autoText, true); + return false; + } + }); + } + + return results; + } + + private List HandleActionsUse(Query query, string rest) + { + var actions = _profileManager.UserData.CommandProfiles; + var profiles = _profileManager.UserData.Profiles; + var parts = rest.Split(new[] { ' ' }, 2); + var requestedActionName = parts[0].Trim(); + var requestedProfileName = parts.Length > 1 ? parts[1].Trim() : ""; + var actionName = CommandInputGuard.FindExistingName(actions, requestedActionName); + var results = new List + { + MakeBackNavResult(query, query.ActionKeyword + " actions ", query.ActionKeyword + " actions") + }; + + if (actionName == null) + { + results.Add(new Result + { + Title = requestedActionName, + SubTitle = GetTranslation("plugin_quickssh_actions_notfound"), + IcoPath = AppIconRedPath + }); + return results; + } + + var selectedAction = actions[actionName]; + + if (profiles.Count == 0) + { + var addProfileText = query.ActionKeyword + " profiles add "; + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_actions_add_profile"), + SubTitle = GetTranslation("plugin_quickssh_actions_no_profiles"), + IcoPath = AppIconGreenPath, + AutoCompleteText = addProfileText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(addProfileText, true); + return false; + } + }); + return results; + } + + var profileName = CommandInputGuard.FindExistingName(profiles, requestedProfileName); + if (profileName == null) + { + foreach (var entry in profiles) + { + if (string.Equals(entry.Value?.Type, "scp", StringComparison.OrdinalIgnoreCase)) + continue; + if (!string.IsNullOrEmpty(requestedProfileName) && + !SearchMatcher.ContainsIgnoreAccents(entry.Key, requestedProfileName) && + !SearchMatcher.ContainsIgnoreAccents(entry.Value?.ToDisplayString() ?? "", requestedProfileName)) + continue; + + var autoText = query.ActionKeyword + " actions use " + actionName + " " + entry.Key; + results.Add(new Result + { + Title = entry.Key, + SubTitle = BuildProfileListSubtitle(entry.Value), + IcoPath = AppIconGreenPath, + AutoCompleteText = autoText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(autoText, true); + return false; + } + }); + } + return results; + } + + var selectedProfile = profiles[profileName]; + if (string.Equals(selectedProfile?.Type, "scp", StringComparison.OrdinalIgnoreCase)) + { + results.Clear(); + results.Add(MakeBackNavResult( + query, + query.ActionKeyword + " actions use " + actionName + " ", + "actions profile selection")); + results.Add(new Result + { + Title = profileName, + SubTitle = GetTranslation("plugin_quickssh_actions_profile_notfound"), + IcoPath = AppIconRedPath + }); + return results; + } + + return BuildActionConfirmationResults( + query, + query.ActionKeyword + " actions use " + actionName + " ", + "actions profile selection", + profileName, + selectedProfile, + actionName, + selectedAction); + } + + private List BuildActionConfirmationResults( + Query query, + string backQuery, + string backTarget, + string profileName, + SshProfile selectedProfile, + string actionName, + CommandProfile selectedAction) + { + var results = new List + { + MakeBackNavResult(query, backQuery, backTarget) + }; + + if (!ActionCommandBuilder.TryBuild(selectedProfile, selectedAction, out var command) || + !ActionCommandBuilder.TryBuildDisplay(selectedProfile, selectedAction, out var displayCommand)) + { + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_actions_cannot_run"), + SubTitle = GetTranslation("plugin_quickssh_actions_unsupported"), + IcoPath = AppIconRedPath, + Score = ScoreActionsConfirmRun + }); + return results; + } + + results.Add(new Result + { + Title = string.Format( + GetTranslation("plugin_quickssh_actions_execute_named_title"), actionName), + SubTitle = string.Format( + GetTranslation("plugin_quickssh_actions_execute_summary"), + profileName, + selectedAction?.ToDisplayString() ?? ""), + IcoPath = AppIconGreenPath, + Score = ScoreActionsConfirmRun, + Action = _ => + { + RunCommand(command); + return true; + } + }); + + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_actions_copy_command_title"), + SubTitle = displayCommand, + IcoPath = AppIconPath, + Score = ScoreActionsConfirmCommand, + Action = _ => + { + _pluginContext?.API?.CopyToClipboard(displayCommand, false, false); + _pluginContext?.API?.ShowMsg( + "QuickSSH", + GetTranslation("plugin_quickssh_copy_command_success")); + return false; + } + }); + + return results; + } + + private List HandleActionsAdd(Query query, string rest) + { + var results = new List + { + MakeBackNavResult(query, query.ActionKeyword + " actions manage ", query.ActionKeyword + " actions manage") + }; + + var actions = _profileManager.UserData.CommandProfiles; + rest = CommandInputGuard.NormalizeNestedCommandInput( + rest, query.ActionKeyword, "actions add"); + if (string.IsNullOrWhiteSpace(rest)) + { + var exampleName = ProfileWizard.BuildAvailableName("check", actions.Keys); + var exampleText = query.ActionKeyword + " actions add " + exampleName; + results.Add(MakeWizardExampleResultFromKeys( + "plugin_quickssh_wizard_actions_add_name_title", + "plugin_quickssh_wizard_actions_add_name_subtitle", + exampleText, + exampleName)); + return results; + } + + var parts = rest.Split(new[] { ' ' }, 2); + var name = parts[0].Trim(); + var command = parts.Length > 1 ? parts[1].Trim() : ""; + + if (!CommandInputGuard.IsValidSavedName(name)) + { + results.Add(new Result + { + Title = name, + SubTitle = GetTranslation("plugin_quickssh_name_invalid"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (CommandInputGuard.IsReservedSavedName(name)) + { + results.Add(new Result + { + Title = name, + SubTitle = GetTranslation("plugin_quickssh_name_reserved"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (CommandInputGuard.FindExistingName(actions, name) != null) + { + results.Add(new Result + { + Title = name, + SubTitle = GetTranslation("plugin_quickssh_name_exists"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (string.IsNullOrWhiteSpace(command)) + { + var exampleText = query.ActionKeyword + " actions add " + name + " hostname"; + results.Add(MakeWizardExampleResultFromKeys( + "plugin_quickssh_wizard_actions_add_command_title", + "plugin_quickssh_wizard_actions_add_command_subtitle", + exampleText)); + return results; + } + + if (!CommandProfile.IsSafeToStore(command)) + { + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_title_commandactions_add"), + SubTitle = GetTranslation("plugin_quickssh_actions_rejected"), + IcoPath = AppIconRedPath + }); + return results; + } + + results.Add(new Result + { + Title = string.Format( + GetTranslation("plugin_quickssh_actions_save_title"), name), + SubTitle = command, + IcoPath = AppIconGreenPath, + Action = _ => + { + actions[name] = new CommandProfile + { + Command = command + }; + _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " actions ", true); + return false; + } + }); + + return results; + } + + private List HandleActionsRemove(Query query, string search) + { + var actions = _profileManager.UserData.CommandProfiles; + var exactName = CommandInputGuard.FindExistingName(actions, search); + var results = new List + { + exactName == null + ? MakeBackNavResult( + query, + query.ActionKeyword + " actions manage ", + query.ActionKeyword + " actions manage") + : MakeBackNavResult( + query, + query.ActionKeyword + " actions remove ", + "actions remove selection") + }; + + if (actions.Count == 0) + { + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_actions_empty_title"), + SubTitle = GetTranslation("plugin_quickssh_actions_empty_subtitle"), + IcoPath = AppIconPath + }); + return results; + } + + if (exactName != null) + { + var action = actions[exactName]; + results.Add(new Result + { + Title = string.Format( + GetTranslation("plugin_quickssh_actions_remove_confirm"), exactName), + SubTitle = action?.ToDisplayString() ?? "", + IcoPath = AppIconRedPath, + Score = ScoreActionsConfirmRun, + Action = _ => + { + actions.Remove(exactName); + _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " actions ", true); + return false; + } + }); + return results; + } + + foreach (var entry in actions) + { + if (!string.IsNullOrEmpty(search) && !SearchMatcher.ContainsIgnoreAccents(entry.Key, search)) + continue; + + var autoText = query.ActionKeyword + " actions remove " + entry.Key; + results.Add(new Result + { + Title = entry.Key, + SubTitle = entry.Value?.ToDisplayString() ?? "", + IcoPath = AppIconRedPath, + AutoCompleteText = autoText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(autoText, true); + return false; + } + }); + } + + return results; + } + + private List HandleActionsRename(Query query, string rest) + { + var results = new List(); + var actions = _profileManager.UserData.CommandProfiles; + + rest = CommandInputGuard.NormalizeNestedCommandInput( + rest, query.ActionKeyword, "actions rename"); + var parts = rest.Split(new[] { ' ' }, 2); + var requestedOldName = parts[0].Trim(); + var newName = parts.Length > 1 ? parts[1].Trim() : ""; + + results.Add(MakeBackNavResult(query, query.ActionKeyword + " actions manage ", query.ActionKeyword + " actions manage")); + + if (actions.Count == 0) + { + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_actions_empty_title"), + SubTitle = GetTranslation("plugin_quickssh_actions_empty_subtitle"), + IcoPath = AppIconPath + }); + return results; + } + + if (string.IsNullOrEmpty(requestedOldName)) + { + foreach (var entry in actions) + { + var autoText = ProfileWizard.BuildPrefilledRenameQuery( + query.ActionKeyword, "actions rename", entry.Key); + results.Add(new Result + { + Title = entry.Key, + SubTitle = entry.Value?.ToDisplayString() ?? "", + IcoPath = AppIconOrangePath, + AutoCompleteText = autoText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(autoText, true); + return false; + } + }); + } + return results; + } + + var oldName = CommandInputGuard.FindExistingName(actions, requestedOldName); + if (oldName == null) + { + results.Add(new Result + { + Title = requestedOldName, + SubTitle = GetTranslation("plugin_quickssh_actions_notfound"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (string.IsNullOrWhiteSpace(newName)) + { + var suggestedName = ProfileWizard.BuildSuggestedName(oldName, actions.Keys); + var exampleText = ProfileWizard.BuildRenameQuery( + query.ActionKeyword, "actions rename", oldName, suggestedName); + results.Add(MakeWizardExampleResult( + string.Format(GetTranslation("plugin_quickssh_wizard_actions_rename_title"), suggestedName), + string.Format(GetTranslation("plugin_quickssh_wizard_actions_rename_subtitle"), oldName), + exampleText)); + return results; + } + + if (!CommandInputGuard.IsValidSavedName(newName)) + { + results.Add(new Result + { + Title = newName, + SubTitle = GetTranslation("plugin_quickssh_name_invalid"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (CommandInputGuard.IsReservedSavedName(newName)) + { + results.Add(new Result + { + Title = newName, + SubTitle = GetTranslation("plugin_quickssh_name_reserved"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (string.Equals(oldName, newName, StringComparison.Ordinal)) + { + var suggestedName = ProfileWizard.BuildSuggestedName(oldName, actions.Keys); + var exampleText = ProfileWizard.BuildRenameQuery( + query.ActionKeyword, "actions rename", oldName, suggestedName); + results.Add(MakeWizardExampleResult( + string.Format(GetTranslation("plugin_quickssh_wizard_actions_rename_title"), suggestedName), + string.Format(GetTranslation("plugin_quickssh_wizard_actions_rename_prefilled_subtitle"), oldName), + exampleText)); + return results; + } + + var conflictingName = CommandInputGuard.FindExistingName(actions, newName); + if (conflictingName != null && + !string.Equals(conflictingName, oldName, StringComparison.Ordinal)) + { + results.Add(new Result + { + Title = newName, + SubTitle = GetTranslation("plugin_quickssh_name_exists"), + IcoPath = AppIconRedPath + }); + return results; + } + + results.Add(new Result + { + Title = string.Format( + GetTranslation("plugin_quickssh_actions_rename_confirm_title"), + oldName, newName), + SubTitle = GetTranslation("plugin_quickssh_actions_rename_confirm_subtitle"), + IcoPath = AppIconOrangePath, + Action = _ => + { + var captured = actions[oldName]; + actions.SetCallback(null); + try + { + actions.Remove(oldName); + actions[newName] = captured; + } + finally + { + actions.SetCallback(_profileManager.SaveConfiguration); + } + _profileManager.SaveConfiguration(); + _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " actions ", true); + return false; + } + }); + + return results; + } + + private List HandleActionsRun(Query query, string rest) + { + var profiles = _profileManager.UserData.Profiles; + var actions = _profileManager.UserData.CommandProfiles; + var parts = rest.Split(new[] { ' ' }, 2); + var requestedProfileName = parts[0].Trim(); + var requestedActionName = parts.Length > 1 ? parts[1].Trim() : ""; + var profileName = profiles.Count == 0 + ? null + : CommandInputGuard.FindExistingName(profiles, requestedProfileName); + var actionName = profileName == null || actions.Count == 0 + ? null + : CommandInputGuard.FindExistingName(actions, requestedActionName); + + if (actions.Count == 0) + { + var results = new List + { + MakeBackNavResult(query, query.ActionKeyword + " actions ", query.ActionKeyword + " actions") + }; + var addText = query.ActionKeyword + " actions add "; + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_actions_add_first"), + SubTitle = GetTranslation("plugin_quickssh_actions_empty_subtitle"), + IcoPath = AppIconGreenPath, + AutoCompleteText = addText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(addText, true); + return false; + } + }); + return results; + } + + if (profiles.Count == 0) + { + var results = new List + { + MakeBackNavResult(query, query.ActionKeyword + " actions ", query.ActionKeyword + " actions") + }; + var addProfileText = query.ActionKeyword + " profiles add "; + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_actions_add_profile"), + SubTitle = GetTranslation("plugin_quickssh_actions_no_profiles"), + IcoPath = AppIconGreenPath, + AutoCompleteText = addProfileText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(addProfileText, true); + return false; + } + }); + return results; + } + + if (profileName == null) + { + var results = new List + { + MakeBackNavResult(query, query.ActionKeyword + " actions ", query.ActionKeyword + " actions") + }; + foreach (var entry in profiles) + { + if (string.Equals(entry.Value?.Type, "scp", StringComparison.OrdinalIgnoreCase)) + continue; + if (!string.IsNullOrEmpty(requestedProfileName) && + !SearchMatcher.ContainsIgnoreAccents(entry.Key, requestedProfileName) && + !SearchMatcher.ContainsIgnoreAccents(entry.Value?.ToDisplayString() ?? "", requestedProfileName)) + continue; + + var autoText = query.ActionKeyword + " actions run " + entry.Key + " "; + results.Add(new Result + { + Title = entry.Key, + SubTitle = BuildProfileListSubtitle(entry.Value), + IcoPath = AppIconGreenPath, + AutoCompleteText = autoText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(autoText, true); + return false; + } + }); + } + return results; + } + + var selectedProfile = profiles[profileName]; + if (string.Equals(selectedProfile?.Type, "scp", StringComparison.OrdinalIgnoreCase)) + { + return new List + { + MakeBackNavResult( + query, + query.ActionKeyword + " actions run ", + "actions profile selection"), + new Result + { + Title = profileName, + SubTitle = GetTranslation("plugin_quickssh_actions_profile_notfound"), + IcoPath = AppIconRedPath + } + }; + } + + if (actionName == null) + { + var results = new List + { + MakeBackNavResult( + query, + query.ActionKeyword + " actions run ", + "actions profile selection") + }; + foreach (var entry in actions) + { + if (!string.IsNullOrEmpty(requestedActionName) && + !SearchMatcher.ContainsIgnoreAccents(entry.Key, requestedActionName) && + !SearchMatcher.ContainsIgnoreAccents(entry.Value?.ToDisplayString() ?? "", requestedActionName)) + continue; + + var autoText = query.ActionKeyword + " actions run " + profileName + " " + entry.Key; + results.Add(new Result + { + Title = entry.Key, + SubTitle = entry.Value?.ToDisplayString() ?? "", + IcoPath = AppIconGreenPath, + AutoCompleteText = autoText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(autoText, true); + return false; + } + }); + } + return results; + } + + var selectedAction = actions[actionName]; + return BuildActionConfirmationResults( + query, + query.ActionKeyword + " actions run " + profileName + " ", + "actions action selection", + profileName, + selectedProfile, + actionName, + selectedAction); + } + + + private List HandleTools(Query query, string search) + { + var results = new List + { + MakeBackNavResult(query, query.ActionKeyword + " ", query.ActionKeyword) + }; + + var toolItems = new[] + { + (CommandKeys, GetTranslation("plugin_quickssh_title_commandkeys"), GetTranslation("plugin_quickssh_subtitle_tools_keys"), ScoreToolsKeys), + (CommandCustomShell, GetTranslation("plugin_quickssh_title_commandshell"), GetTranslation("plugin_quickssh_subtitle_tools_shell"), ScoreToolsShell), + (CommandConfig, GetTranslation("plugin_quickssh_title_commandconfig"), GetTranslation("plugin_quickssh_subtitle_tools_config"), ScoreToolsConfig), + }; + + foreach (var (command, title, subtitle, score) in toolItems) + { + if (!string.IsNullOrEmpty(search) && + !command.StartsWith(search, StringComparison.OrdinalIgnoreCase) && + !title.StartsWith(search, StringComparison.CurrentCultureIgnoreCase)) + continue; + + var target = query.ActionKeyword + " " + command + " "; + results.Add(new Result + { + Title = title, + SubTitle = subtitle, + IcoPath = AppIconPath, + Score = score, + AutoCompleteText = target, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(target, true); + return false; + } + }); + } + + return results; + } + + + private List HandleShell(Query query, string rest) + { + var parts = rest.Split(new[] { ' ' }, 2); + var subCmd = parts[0].ToLowerInvariant(); + var subRest = parts.Length > 1 ? parts[1].Trim() : ""; + + switch (subCmd) + { + case "add": + { + var results = new List + { + MakeBackNavResult( + query, + query.ActionKeyword + " shell manage ", + query.ActionKeyword + " shell manage") + }; + + subRest = CommandInputGuard.NormalizeNestedCommandInput( + subRest, query.ActionKeyword, "shell add"); + if (string.IsNullOrWhiteSpace(subRest)) + { + var exampleName = ProfileWizard.BuildAvailableName( + "PowerShell", _profileManager.UserData.CustomShell.Keys); + var exampleText = query.ActionKeyword + " shell add " + exampleName; + results.Add(MakeWizardExampleResultFromKeys( + "plugin_quickssh_wizard_shell_add_name_title", + "plugin_quickssh_wizard_shell_add_name_subtitle", + exampleText, + exampleName)); + return results; + } + + var (name, value) = ParseShellAddArgs(subRest); + name = name.Trim(); + + if (!CommandInputGuard.IsValidSavedName(name)) + { + results.Add(new Result + { + Title = name, + SubTitle = GetTranslation("plugin_quickssh_name_invalid"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (CommandInputGuard.IsReservedSavedName(name)) + { + results.Add(new Result + { + Title = name, + SubTitle = GetTranslation("plugin_quickssh_name_reserved"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (_profileManager.UserData.CustomShell.Keys.Any(shell => + string.Equals(shell, name, StringComparison.OrdinalIgnoreCase))) + { + results.Add(new Result + { + Title = name, + SubTitle = GetTranslation("plugin_quickssh_name_exists"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (string.IsNullOrWhiteSpace(value)) + { + var exampleText = query.ActionKeyword + " shell add " + name + " pwsh.exe -NoLogo"; + results.Add(MakeWizardExampleResultFromKeys( + "plugin_quickssh_wizard_shell_add_command_title", + "plugin_quickssh_wizard_shell_add_command_subtitle", + exampleText)); + results.Add(new Result + { + Title = string.Format( + GetTranslation("plugin_quickssh_shell_save_title"), name), + SubTitle = string.Format( + GetTranslation("plugin_quickssh_wizard_shell_use_name_subtitle"), name), + IcoPath = AppIconGreenPath, + Action = _ => + { + _profileManager.UserData.CustomShell.SetCallback(null); + _profileManager.UserData.CustomShell[name] = ""; + if (_profileManager.UserData.CustomShell.Count == 1) + _profileManager.UserData.SelectedCustomShell = name; + _profileManager.UserData.CustomShell.SetCallback(_profileManager.SaveConfiguration); + _profileManager.SaveConfiguration(); + _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " shell ", true); return false; } }); + return results; } - break; + + results.Add(new Result + { + Title = string.Format( + GetTranslation("plugin_quickssh_shell_save_title"), name), + SubTitle = value, + IcoPath = AppIconGreenPath, + Action = _ => + { + _profileManager.UserData.CustomShell.SetCallback(null); + _profileManager.UserData.CustomShell[name] = value; + if (_profileManager.UserData.CustomShell.Count == 1) + _profileManager.UserData.SelectedCustomShell = name; + _profileManager.UserData.CustomShell.SetCallback(_profileManager.SaveConfiguration); + _profileManager.SaveConfiguration(); + _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " shell ", true); + return false; + } + }); + return results; + } case "remove": - var shells = _profileManager.UserData.CustomShell; - // Always show usage hint at the top. - results.Add(new Result + { + var results = new List { - Title = GetTranslation("plugin_quickssh_title_commandshell_remove"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandshell_remove"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " shell remove ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " shell ", query.ActionKeyword + " shell")); + MakeBackNavResult( + query, + query.ActionKeyword + " shell manage ", + query.ActionKeyword + " shell manage") + }; + var shells = _profileManager.UserData.CustomShell; if (shells.Count == 0) { results.Add(new Result @@ -1056,13 +2339,10 @@ private List HandleShell(Query query, string rest) AutoCompleteText = query.ActionKeyword + " shell remove " + shell.Key, Action = _ => { - // Suppress auto-save so we can update SelectedCustomShell - // atomically before the single explicit save below. _profileManager.UserData.CustomShell.SetCallback(null); _profileManager.UserData.CustomShell.Remove(shell.Key); if (_profileManager.UserData.SelectedCustomShell == shell.Key) { - // Auto-select the first remaining shell (if any). _profileManager.UserData.SelectedCustomShell = _profileManager.UserData.CustomShell.Keys.FirstOrDefault(); } @@ -1074,13 +2354,13 @@ private List HandleShell(Query query, string rest) }); } } - break; + return results; + } + + case "manage": + return HandleShellManage(query); default: - // Mirror the top-level matching pattern: when the partial input - // is a prefix of one or more sub-commands, delegate to the - // autocompleter so that "shell a" suggests "add" the same way - // "ssh p" suggests "profiles" at the top level. if (!string.IsNullOrEmpty(subCmd) && ShellSubCommands.Any(s => s.StartsWith(subCmd))) { @@ -1090,106 +2370,110 @@ private List HandleShell(Query query, string rest) _pluginContext?.API)); } - // Always show "Shell management" hint at the top. - results.Add(new Result + var defaultResults = new List { - Title = GetTranslation("plugin_quickssh_title_commandshell"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandshell_help"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " shell ", - Score = ScoreSubMenuManagement - }); - - // Back-navigation row — returns to top-level command list. - results.Add(MakeBackNavResult(query, query.ActionKeyword + " ", query.ActionKeyword)); - - // List shells in deterministic order: - // 1. management row (ScoreSubMenuManagement = int.MaxValue) - // 2. back-nav row (ScoreBackNavigation = int.MaxValue - 1) - // 3. action rows (ScoreShellActionAdd = 1100, ScoreShellActionRemove = 1050) - // 4. selected shell (ScoreShellSelected = 1000) - // 5. other shells (decreasing from ScoreShellOtherStart = 500) + MakeBackNavResult(query, query.ActionKeyword + " ", query.ActionKeyword) + }; var allShells = _profileManager.UserData.CustomShell; var selected = _profileManager.UserData.SelectedCustomShell; - // Sub-command action rows (add / remove) — always above saved shell entries. - var shellSubCmds = new[] - { - ("add", GetTranslation("plugin_quickssh_title_commandshell_add"), GetTranslation("plugin_quickssh_subtitle_commandshell_add_usage"), ScoreShellActionAdd), - ("remove", GetTranslation("plugin_quickssh_title_commandshell_remove"), GetTranslation("plugin_quickssh_subtitle_commandshell_remove"), ScoreShellActionRemove), - }; - foreach (var (scName, scTitle, scSubTitle, scScore) in shellSubCmds) + if (allShells.Count > 0) { - if (string.IsNullOrEmpty(subCmd) || scName.StartsWith(subCmd)) + if (!string.IsNullOrEmpty(selected) && allShells.ContainsKey(selected)) { - var autoText = query.ActionKeyword + " shell " + scName + " "; - results.Add(new Result + var shellVal = allShells[selected]; + defaultResults.Add(new Result { - Title = scTitle, - SubTitle = scSubTitle, - IcoPath = AppIconPath, - AutoCompleteText = autoText, - Score = scScore, + Title = selected + " " + GetTranslation("plugin_quickssh_shell_selected"), + SubTitle = string.IsNullOrEmpty(shellVal) ? selected : shellVal, + IcoPath = AppIconGreenPath, + AutoCompleteText = query.ActionKeyword + " shell " + selected, + Score = ScoreShellSelected, Action = _ => { - _pluginContext?.API?.ChangeQuery(autoText, true); + _profileManager.UserData.SelectedCustomShell = selected; + _profileManager.SaveConfiguration(); + _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " shell ", true); return false; } }); } - } - // Selected shell (if any) — pinned just below the action rows. - if (!string.IsNullOrEmpty(selected) && allShells.ContainsKey(selected)) - { - var shellVal = allShells[selected]; - results.Add(new Result + int otherShellScore = ScoreShellOtherStart; + foreach (var shell in allShells) { - Title = selected + " " + GetTranslation("plugin_quickssh_shell_selected"), - SubTitle = string.IsNullOrEmpty(shellVal) ? selected : shellVal, - IcoPath = AppIconGreenPath, - AutoCompleteText = query.ActionKeyword + " shell " + selected, - Score = ScoreShellSelected, - Action = _ => + if (shell.Key == selected) + continue; + defaultResults.Add(new Result { - _profileManager.UserData.SelectedCustomShell = selected; - _profileManager.SaveConfiguration(); - _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " shell ", true); - return false; - } - }); + Title = shell.Key, + SubTitle = string.IsNullOrEmpty(shell.Value) ? shell.Key : shell.Value, + IcoPath = AppIconGreenPath, + AutoCompleteText = query.ActionKeyword + " shell " + shell.Key, + Score = otherShellScore--, + Action = _ => + { + _profileManager.UserData.SelectedCustomShell = shell.Key; + _profileManager.SaveConfiguration(); + _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " shell ", true); + return false; + } + }); + } } - // Remaining (non-selected) shell profiles. - int otherShellScore = ScoreShellOtherStart; - foreach (var shell in allShells) + var manageText = query.ActionKeyword + " shell manage "; + defaultResults.Add(new Result { - if (shell.Key == selected) - continue; - results.Add(new Result + Title = GetTranslation("plugin_quickssh_title_commandshell_manage"), + SubTitle = GetTranslation("plugin_quickssh_subtitle_commandshell_help"), + IcoPath = AppIconPath, + AutoCompleteText = manageText, + Score = ScoreShellActionManage, + Action = _ => { - Title = shell.Key, - SubTitle = string.IsNullOrEmpty(shell.Value) ? shell.Key : shell.Value, - IcoPath = AppIconGreenPath, - AutoCompleteText = query.ActionKeyword + " shell " + shell.Key, - Score = otherShellScore--, - Action = _ => - { - _profileManager.UserData.SelectedCustomShell = shell.Key; - _profileManager.SaveConfiguration(); - _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " shell ", true); - return false; - } - }); + _pluginContext?.API?.ChangeQuery(manageText, true); + return false; + } + }); + return defaultResults; + } + } + + private List HandleShellManage(Query query) + { + var results = new List + { + MakeBackNavResult(query, query.ActionKeyword + " shell ", query.ActionKeyword + " shell") + }; + + var shellActions = new[] + { + ("add", GetTranslation("plugin_quickssh_title_commandshell_add"), GetTranslation("plugin_quickssh_subtitle_commandshell_add_usage"), AppIconGreenPath, ScoreShellManageAdd), + ("remove", GetTranslation("plugin_quickssh_title_commandshell_remove"), GetTranslation("plugin_quickssh_subtitle_commandshell_remove"), AppIconRedPath, ScoreShellManageRemove), + }; + + foreach (var (name, title, subtitle, icon, score) in shellActions) + { + var autoText = query.ActionKeyword + " shell " + name + " "; + results.Add(new Result + { + Title = title, + SubTitle = subtitle, + IcoPath = icon, + AutoCompleteText = autoText, + Score = score, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(autoText, true); + return false; } - break; + }); } return results; } - // ── keys (SSH key management) ───────────────────────────────────────────── - private List HandleKeys(Query query, string rest) { var parts = rest.Split(new[] { ' ' }, 2); @@ -1206,6 +2490,7 @@ private List HandleKeys(Query query, string rest) case KeysSubCopyPath: return HandleKeysCopyPath(query, subRest); case KeysSubCopyPub: return HandleKeysCopyPub(query, subRest); case KeysSubScan: return HandleKeysScan(query); + case KeysSubManage: return HandleKeysManage(query); default: // Partial sub-command matching (mirrors profiles/shell pattern). if (!string.IsNullOrEmpty(subCmd) && @@ -1222,56 +2507,12 @@ private List HandleKeys(Query query, string rest) private List HandleKeysList(Query query, string search) { - var results = new List(); - var keys = _profileManager.UserData.SshKeys; - - // 1. Management/usage hint — always pinned at the top. - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandkeys"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandkeys"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " keys ", - Score = ScoreSubMenuManagement - }); - - // 2. Back-navigation row — returns to top-level command list. - results.Add(MakeBackNavResult(query, query.ActionKeyword + " ", query.ActionKeyword)); - - // 3. Action rows — only shown when no search text is active. - if (string.IsNullOrEmpty(search)) + var results = new List { - var keysSubCmds = new[] - { - ("install", GetTranslation("plugin_quickssh_title_commandkeys_install"), GetTranslation("plugin_quickssh_subtitle_commandkeys_install"), ScoreKeysActionInstall), - ("add", GetTranslation("plugin_quickssh_title_commandkeys_add"), GetTranslation("plugin_quickssh_subtitle_commandkeys_add"), ScoreKeysActionAdd), - ("generate", GetTranslation("plugin_quickssh_title_commandkeys_generate"), GetTranslation("plugin_quickssh_subtitle_commandkeys_generate"), ScoreKeysActionGenerate), - ("remove", GetTranslation("plugin_quickssh_title_commandkeys_remove"), GetTranslation("plugin_quickssh_subtitle_commandkeys_remove"), ScoreKeysActionRemove), - ("rename", GetTranslation("plugin_quickssh_title_commandkeys_rename"), GetTranslation("plugin_quickssh_subtitle_commandkeys_rename"), ScoreKeysActionRename), - ("copy-path", GetTranslation("plugin_quickssh_title_commandkeys_copypath"), GetTranslation("plugin_quickssh_subtitle_commandkeys_copypath"), ScoreKeysActionCopyPath), - ("copy-pub", GetTranslation("plugin_quickssh_title_commandkeys_copypub"), GetTranslation("plugin_quickssh_subtitle_commandkeys_copypub"), ScoreKeysActionCopyPub), - ("scan", GetTranslation("plugin_quickssh_title_commandkeys_scan"), GetTranslation("plugin_quickssh_subtitle_commandkeys_scan"), ScoreKeysActionScan), - }; - foreach (var (scName, scTitle, scSubTitle, scScore) in keysSubCmds) - { - var autoText = query.ActionKeyword + " keys " + scName + " "; - results.Add(new Result - { - Title = scTitle, - SubTitle = scSubTitle, - IcoPath = AppIconPath, - AutoCompleteText = autoText, - Score = scScore, - Action = _ => - { - _pluginContext?.API?.ChangeQuery(autoText, true); - return false; - } - }); - } - } + MakeBackNavResult(query, query.ActionKeyword + " ", query.ActionKeyword) + }; + var keys = _profileManager.UserData.SshKeys; - // 4. Saved keys. if (keys.Count == 0) { results.Add(new Result @@ -1295,70 +2536,209 @@ private List HandleKeysList(Query query, string search) var keyEntry = entry.Value; var displayPath = keyEntry?.ToDisplayString() ?? ""; bool fileExists = !string.IsNullOrEmpty(keyEntry?.Path) && File.Exists(keyEntry.Path); + bool savedPathIsPublic = !string.IsNullOrEmpty(keyEntry?.Path) && + keyEntry.Path.EndsWith(".pub", StringComparison.OrdinalIgnoreCase); + var keyTypeLabel = savedPathIsPublic + ? GetTranslation("plugin_quickssh_keys_public_path_label") + : GetTranslation("plugin_quickssh_keys_private_path_label"); + var subtitle = keyTypeLabel + " " + displayPath + + (fileExists ? "" : " " + GetTranslation("plugin_quickssh_keys_file_missing")); + var installText = query.ActionKeyword + " keys install " + alias + " "; results.Add(new Result { Title = alias, - SubTitle = displayPath + (fileExists ? "" : " " + GetTranslation("plugin_quickssh_keys_file_missing")), + SubTitle = subtitle, IcoPath = fileExists ? AppIconGreenPath : AppIconRedPath, - AutoCompleteText = query.ActionKeyword + " keys " + alias, - Score = keyScore-- + AutoCompleteText = installText, + Score = keyScore--, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(installText, true); + return false; + } }); } } + if (string.IsNullOrEmpty(search)) + { + var installText = query.ActionKeyword + " keys install "; + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_title_commandkeys_install"), + SubTitle = GetTranslation("plugin_quickssh_subtitle_commandkeys_install"), + IcoPath = AppIconGreenPath, + AutoCompleteText = installText, + Score = ScoreKeysActionInstall, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(installText, true); + return false; + } + }); + + var manageText = query.ActionKeyword + " keys manage "; + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_title_commandkeys_manage"), + SubTitle = GetTranslation("plugin_quickssh_subtitle_commandkeys_manage"), + IcoPath = AppIconPath, + AutoCompleteText = manageText, + Score = ScoreKeysActionManage, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(manageText, true); + return false; + } + }); + } + return results; } - private List HandleKeysAdd(Query query, string rest) + private List HandleKeysManage(Query query) { - var results = new List(); + var results = new List + { + MakeBackNavResult(query, query.ActionKeyword + " keys ", query.ActionKeyword + " keys") + }; - results.Add(new Result + var keyActions = new[] { - Title = GetTranslation("plugin_quickssh_title_commandkeys_add"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandkeys_add"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " keys add ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys ", query.ActionKeyword + " keys")); + ("add", GetTranslation("plugin_quickssh_title_commandkeys_add"), GetTranslation("plugin_quickssh_subtitle_commandkeys_add"), AppIconGreenPath, ScoreKeysManageAdd), + ("generate", GetTranslation("plugin_quickssh_title_commandkeys_generate"), GetTranslation("plugin_quickssh_subtitle_commandkeys_generate"), AppIconGreenPath, ScoreKeysManageGenerate), + ("scan", GetTranslation("plugin_quickssh_title_commandkeys_scan"), GetTranslation("plugin_quickssh_subtitle_commandkeys_scan"), AppIconGreenPath, ScoreKeysManageScan), + ("rename", GetTranslation("plugin_quickssh_title_commandkeys_rename"), GetTranslation("plugin_quickssh_subtitle_commandkeys_rename"), AppIconOrangePath, ScoreKeysManageRename), + ("copy-path", GetTranslation("plugin_quickssh_title_commandkeys_copypath"), GetTranslation("plugin_quickssh_subtitle_commandkeys_copypath"), AppIconPath, ScoreKeysManageCopyPath), + ("copy-pub", GetTranslation("plugin_quickssh_title_commandkeys_copypub"), GetTranslation("plugin_quickssh_subtitle_commandkeys_copypub"), AppIconPath, ScoreKeysManageCopyPub), + ("remove", GetTranslation("plugin_quickssh_title_commandkeys_remove"), GetTranslation("plugin_quickssh_subtitle_commandkeys_remove"), AppIconRedPath, ScoreKeysManageRemove), + }; - if (string.IsNullOrEmpty(rest)) + foreach (var (scName, scTitle, scSubTitle, iconPath, scScore) in keyActions) + { + var autoText = query.ActionKeyword + " keys " + scName + " "; + results.Add(new Result + { + Title = scTitle, + SubTitle = scSubTitle, + IcoPath = iconPath, + AutoCompleteText = autoText, + Score = scScore, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(autoText, true); + return false; + } + }); + } + + return results; + } + + private List HandleKeysAdd(Query query, string rest) + { + var results = new List + { + MakeBackNavResult(query, query.ActionKeyword + " keys manage ", query.ActionKeyword + " keys manage") + }; + var keys = _profileManager.UserData.SshKeys; + + rest = CommandInputGuard.NormalizeNestedCommandInput( + rest, query.ActionKeyword, "keys add"); + if (string.IsNullOrWhiteSpace(rest)) + { + var exampleName = ProfileWizard.BuildAvailableName("server-key", keys.Keys); + var exampleText = query.ActionKeyword + " keys add " + exampleName; + results.Add(MakeWizardExampleResultFromKeys( + "plugin_quickssh_wizard_keys_add_name_title", + "plugin_quickssh_wizard_keys_add_name_subtitle", + exampleText, + exampleName)); return results; + } var addParts = rest.Split(new[] { ' ' }, 2); - var keyAlias = addParts[0]; + var keyAlias = addParts[0].Trim(); var keyPath = addParts.Length > 1 ? addParts[1].Trim() : ""; - // Strip surrounding quotes from the path. + if (!CommandInputGuard.IsValidSavedName(keyAlias)) + { + results.Add(new Result + { + Title = keyAlias, + SubTitle = GetTranslation("plugin_quickssh_name_invalid"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (CommandInputGuard.IsReservedSavedName(keyAlias)) + { + results.Add(new Result + { + Title = keyAlias, + SubTitle = GetTranslation("plugin_quickssh_name_reserved"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (keys.Keys.Any(key => string.Equals( + key, keyAlias, StringComparison.OrdinalIgnoreCase))) + { + results.Add(new Result + { + Title = keyAlias, + SubTitle = GetTranslation("plugin_quickssh_name_exists"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (string.IsNullOrWhiteSpace(keyPath)) + { + var exampleText = query.ActionKeyword + " keys add " + keyAlias + " ~/.ssh/private_key"; + results.Add(MakeWizardExampleResultFromKeys( + "plugin_quickssh_wizard_keys_add_path_title", + "plugin_quickssh_wizard_keys_add_path_subtitle", + exampleText)); + return results; + } + if (keyPath.Length >= 2 && keyPath.StartsWith("\"") && keyPath.EndsWith("\"")) keyPath = keyPath.Substring(1, keyPath.Length - 2); - if (!string.IsNullOrEmpty(keyPath)) + var expandedPath = keyPath.Replace("~", + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + if (!File.Exists(expandedPath)) { - // Expand ~ to user profile directory. - var expandedPath = keyPath.Replace("~", - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); - bool fileExists = File.Exists(expandedPath); - results.Add(new Result { - Title = GetTranslation("plugin_quickssh_save_label") + " " + keyAlias, - SubTitle = expandedPath + (fileExists ? "" : " " + GetTranslation("plugin_quickssh_keys_file_missing")), - IcoPath = fileExists ? AppIconGreenPath : AppIconRedPath, - Action = _ => - { - _profileManager.UserData.SshKeys[keyAlias] = new SshKeyEntry - { - Path = expandedPath - }; - _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " keys ", true); - return false; - } + Title = GetTranslation("plugin_quickssh_keys_path_missing_title"), + SubTitle = expandedPath, + IcoPath = AppIconRedPath }); + return results; } + results.Add(new Result + { + Title = string.Format( + GetTranslation("plugin_quickssh_keys_save_title"), keyAlias), + SubTitle = expandedPath, + IcoPath = AppIconGreenPath, + Action = _ => + { + keys[keyAlias] = new SshKeyEntry + { + Path = expandedPath + }; + _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " keys ", true); + return false; + } + }); + return results; } @@ -1374,21 +2754,12 @@ private List HandleKeysAdd(Query query, string rest) /// private List HandleKeysInstall(Query query, string rest) { - var results = new List(); - var keys = _profileManager.UserData.SshKeys; - - // Hint row — always pinned at the top. - results.Add(new Result + var results = new List { - Title = GetTranslation("plugin_quickssh_title_commandkeys_install"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandkeys_install"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " keys install ", - Score = int.MaxValue - }); - - // Back row — always "← Back to ssh keys". - results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys ", query.ActionKeyword + " keys")); + MakeBackNavResult(query, query.ActionKeyword + " keys ", query.ActionKeyword + " keys") + }; + var keys = _profileManager.UserData.SshKeys; + var profiles = _profileManager.UserData.Profiles; if (string.IsNullOrEmpty(rest)) { @@ -1448,7 +2819,7 @@ private List HandleKeysInstall(Query query, string rest) // Split rest into and optional . var installParts = rest.Split(new[] { ' ' }, 2); var installAlias = installParts[0]; - var userAtHost = installParts.Length > 1 ? installParts[1].Trim() : ""; + var requestedDestination = installParts.Length > 1 ? installParts[1].Trim() : ""; // Validate alias exists. if (!keys.ContainsKey(installAlias)) @@ -1477,9 +2848,53 @@ private List HandleKeysInstall(Query query, string rest) return results; } - if (string.IsNullOrEmpty(userAtHost)) - { - // Step 2: Prompt for user@host. + if (string.IsNullOrEmpty(requestedDestination)) + { + // Step 2: Select a saved SSH profile when available; otherwise prompt for user@host. + var hasUsableProfiles = false; + foreach (var entry in profiles) + { + if (!TryGetInstallDestinationFromProfile(entry.Value, out _, out _)) + continue; + + hasUsableProfiles = true; + var profileName = entry.Key; + var profile = entry.Value; + var autoText = query.ActionKeyword + " keys install " + installAlias + " " + profileName; + results.Add(new Result + { + Title = profileName, + SubTitle = profile?.ToDisplayString() ?? "", + IcoPath = AppIconGreenPath, + AutoCompleteText = autoText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(autoText, true); + return false; + } + }); + } + + if (hasUsableProfiles) + { + + var manualText = query.ActionKeyword + " keys install " + installAlias + " "; + results.Add(new Result + { + Title = GetTranslation("plugin_quickssh_keys_install_manual_destination"), + SubTitle = string.Format(GetTranslation("plugin_quickssh_keys_install_type_userhost"), + query.ActionKeyword, installAlias), + IcoPath = AppIconPath, + AutoCompleteText = manualText, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(manualText, true); + return false; + } + }); + return results; + } + results.Add(new Result { Title = GetTranslation("plugin_quickssh_title_commandkeys_install"), @@ -1490,7 +2905,26 @@ private List HandleKeysInstall(Query query, string rest) return results; } - // Step 3: Validate destination and show action rows. + // Step 3: Resolve a selected saved profile, or treat the input as manual user@host. + SshProfile selectedInstallProfile = null; + var selectedProfileName = CommandInputGuard.FindExistingName(profiles, requestedDestination); + var userAtHost = requestedDestination; + if (selectedProfileName != null) + { + selectedInstallProfile = profiles[selectedProfileName]; + if (!TryGetInstallDestinationFromProfile(selectedInstallProfile, out userAtHost, out _)) + { + results.Add(new Result + { + Title = selectedProfileName, + SubTitle = GetTranslation("plugin_quickssh_keys_install_profile_unsupported"), + IcoPath = AppIconRedPath + }); + return results; + } + } + + // Validate destination and show action rows. if (!RemoteKeyInstallBuilder.IsValidUserAtHost(userAtHost)) { results.Add(new Result @@ -1531,19 +2965,10 @@ private List HandleKeysInstall(Query query, string rest) } var bootstrap = RemoteKeyInstallBuilder.BuildBootstrapCommand(pubContent); - var fullSshCmd = RemoteKeyInstallBuilder.BuildFullSshCommand(userAtHost, bootstrap); - var runSshCmd = RemoteKeyInstallBuilder.BuildRunCommand(userAtHost, bootstrap); - - // Hint row subtitle updates for step 3. - results[0] = new Result - { - Title = GetTranslation("plugin_quickssh_title_commandkeys_install"), - SubTitle = string.Format(GetTranslation("plugin_quickssh_keys_install_summary"), - installAlias, userAtHost), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " keys install ", - Score = int.MaxValue - }; + var fullSshCmd = selectedInstallProfile == null + ? RemoteKeyInstallBuilder.BuildFullSshCommand(userAtHost, bootstrap) + : BuildProfileKeyInstallCommand(selectedInstallProfile, userAtHost, bootstrap); + var runSshCmd = fullSshCmd + " || echo " + RemoteKeyInstallBuilder.FailureMessage; // Row 1: Run remote setup command (launches terminal) // Uses the wrapped run command that includes a local failure guard @@ -1571,7 +2996,7 @@ private List HandleKeysInstall(Query query, string rest) _pluginContext?.API?.CopyToClipboard(fullSshCmd, false, false); _pluginContext?.API?.ShowMsg("QuickSSH", GetTranslation("plugin_quickssh_keys_install_copy_cmd_success")); - _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " keys install " + installAlias + " " + userAtHost, true); + _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " keys install " + installAlias + " " + requestedDestination, true); return false; } }); @@ -1587,7 +3012,7 @@ private List HandleKeysInstall(Query query, string rest) _pluginContext?.API?.CopyToClipboard(pubContent, false, false); _pluginContext?.API?.ShowMsg("QuickSSH", GetTranslation("plugin_quickssh_copy_pubkey_success")); - _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " keys install " + installAlias + " " + userAtHost, true); + _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " keys install " + installAlias + " " + requestedDestination, true); return false; } }); @@ -1595,6 +3020,63 @@ private List HandleKeysInstall(Query query, string rest) return results; } + private static bool TryGetInstallDestinationFromProfile( + SshProfile profile, + out string userAtHost, + out string error) + { + userAtHost = ""; + error = ""; + + if (profile == null || + string.Equals(profile.Type, "scp", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(profile.User) || + string.IsNullOrWhiteSpace(profile.HostName)) + { + error = "unsupported"; + return false; + } + + userAtHost = profile.User.Trim() + "@" + profile.HostName.Trim(); + if (!RemoteKeyInstallBuilder.IsValidUserAtHost(userAtHost)) + { + error = "destination"; + return false; + } + + if (!string.IsNullOrWhiteSpace(profile.Port) && profile.Port.Trim() != "22") + { + if (!int.TryParse(profile.Port.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var port) || + port < 1 || port > 65535) + { + error = "port"; + return false; + } + } + + return true; + } + + private static string BuildProfileKeyInstallCommand( + SshProfile profile, + string userAtHost, + string bootstrapCommand) + { + var sb = new StringBuilder("ssh"); + + if (!string.IsNullOrWhiteSpace(profile?.IdentityFile)) + sb.Append(" -i ").Append(SshCommandBuilder.QuoteArgument(profile.IdentityFile.Trim())); + + if (profile?.IdentitiesOnly == true) + sb.Append(" -o IdentitiesOnly=yes"); + + if (!string.IsNullOrWhiteSpace(profile?.Port) && profile.Port.Trim() != "22") + sb.Append(" -p ").Append(profile.Port.Trim()); + + sb.Append(" ").Append(userAtHost).Append(" \"").Append(bootstrapCommand).Append("\""); + return sb.ToString(); + } + // ── keys generate ───────────────────────────────────────────────────────── /// @@ -1614,15 +3096,7 @@ private List HandleKeysGenerate(Query query, string rest) var results = new List(); var keys = _profileManager.UserData.SshKeys; - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandkeys_generate"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandkeys_generate"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " keys generate ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys ", query.ActionKeyword + " keys")); + results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys manage ", query.ActionKeyword + " keys manage")); if (string.IsNullOrEmpty(rest)) return results; @@ -1720,7 +3194,7 @@ private List HandleKeysGenerate(Query query, string rest) { Title = string.Format(GetTranslation("plugin_quickssh_keys_generate_confirm"), alias), SubTitle = string.Format(GetTranslation("plugin_quickssh_keys_generate_subtitle"), "RSA 4096", fullPath), - IcoPath = AppIconPath, + IcoPath = GetSemanticIconPath("generate"), Action = _ => ExecuteKeyGeneration(alias, "rsa", 4096, fullPath, query.ActionKeyword) }); } @@ -1757,7 +3231,7 @@ private List HandleKeysGenerate(Query query, string rest) { Title = string.Format(GetTranslation("plugin_quickssh_keys_generate_confirm"), alias), SubTitle = string.Format(GetTranslation("plugin_quickssh_keys_generate_subtitle"), "RSA 4096", defaultKeyPath), - IcoPath = AppIconPath, + IcoPath = GetSemanticIconPath("generate"), Action = _ => ExecuteKeyGeneration(alias, "rsa", 4096, defaultKeyPath, query.ActionKeyword) }); @@ -1883,15 +3357,7 @@ private List HandleKeysRemove(Query query, string rest) var results = new List(); var keys = _profileManager.UserData.SshKeys; - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandkeys_remove"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandkeys_remove"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " keys remove ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys ", query.ActionKeyword + " keys")); + results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys manage ", query.ActionKeyword + " keys manage")); if (keys.Count == 0) { @@ -1941,22 +3407,16 @@ private List HandleKeysRename(Query query, string rest) var results = new List(); var keys = _profileManager.UserData.SshKeys; + rest = CommandInputGuard.NormalizeNestedCommandInput( + rest, query.ActionKeyword, "keys rename"); var parts = rest.Split(new[] { ' ' }, 2); - var oldAlias = parts[0].Trim(); + var requestedOldAlias = parts[0].Trim(); var newAlias = parts.Length > 1 ? parts[1].Trim() : ""; - if (string.IsNullOrEmpty(oldAlias)) - { - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandkeys_rename"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandkeys_rename"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " keys rename ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys ", query.ActionKeyword + " keys")); + results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys manage ", query.ActionKeyword + " keys manage")); + if (string.IsNullOrEmpty(requestedOldAlias)) + { if (keys.Count == 0) { results.Add(new Result @@ -1971,12 +3431,13 @@ private List HandleKeysRename(Query query, string rest) foreach (var entry in keys) { var alias = entry.Key; - var autoText = query.ActionKeyword + " keys rename " + alias + " "; + var autoText = ProfileWizard.BuildPrefilledRenameQuery( + query.ActionKeyword, "keys rename", alias); results.Add(new Result { Title = alias, SubTitle = entry.Value?.ToDisplayString() ?? "", - IcoPath = AppIconPath, + IcoPath = GetSemanticIconPath("rename"), AutoCompleteText = autoText, Action = _ => { @@ -1988,76 +3449,104 @@ private List HandleKeysRename(Query query, string rest) return results; } - if (!keys.ContainsKey(oldAlias)) + var oldAlias = keys.Keys.FirstOrDefault(key => string.Equals( + key, requestedOldAlias, StringComparison.OrdinalIgnoreCase)); + if (oldAlias == null) { results.Add(new Result { - Title = GetTranslation("plugin_quickssh_title_commandkeys_rename"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandkeys_rename"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " keys rename ", - Score = int.MaxValue + Title = GetTranslation("plugin_quickssh_title_commandkeys_rename") + ": " + requestedOldAlias, + SubTitle = GetTranslation("plugin_quickssh_keys_rename_notfound"), + IcoPath = AppIconRedPath }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys ", query.ActionKeyword + " keys")); + return results; + } + + if (string.IsNullOrWhiteSpace(newAlias)) + { + var suggestedName = ProfileWizard.BuildSuggestedName(oldAlias, keys.Keys); + var exampleText = ProfileWizard.BuildRenameQuery( + query.ActionKeyword, "keys rename", oldAlias, suggestedName); + results.Add(MakeWizardExampleResult( + string.Format(GetTranslation("plugin_quickssh_wizard_keys_rename_title"), suggestedName), + string.Format(GetTranslation("plugin_quickssh_wizard_keys_rename_subtitle"), oldAlias), + exampleText)); + return results; + } + + if (!CommandInputGuard.IsValidSavedName(newAlias)) + { results.Add(new Result { - Title = GetTranslation("plugin_quickssh_title_commandkeys_rename") + ": " + oldAlias, - SubTitle = GetTranslation("plugin_quickssh_keys_rename_notfound"), + Title = newAlias, + SubTitle = GetTranslation("plugin_quickssh_name_invalid"), IcoPath = AppIconRedPath }); return results; } - results.Add(new Result + if (CommandInputGuard.IsReservedSavedName(newAlias)) { - Title = GetTranslation("plugin_quickssh_title_commandkeys_rename"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandkeys_rename"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " keys rename " + oldAlias + " ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys ", query.ActionKeyword + " keys")); + results.Add(new Result + { + Title = newAlias, + SubTitle = GetTranslation("plugin_quickssh_name_reserved"), + IcoPath = AppIconRedPath + }); + return results; + } + + if (string.Equals(oldAlias, newAlias, StringComparison.Ordinal)) + { + var suggestedName = ProfileWizard.BuildSuggestedName(oldAlias, keys.Keys); + var exampleText = ProfileWizard.BuildRenameQuery( + query.ActionKeyword, "keys rename", oldAlias, suggestedName); + results.Add(MakeWizardExampleResult( + string.Format(GetTranslation("plugin_quickssh_wizard_keys_rename_title"), suggestedName), + string.Format(GetTranslation("plugin_quickssh_wizard_keys_rename_prefilled_subtitle"), oldAlias), + exampleText)); + return results; + } - if (!string.IsNullOrEmpty(newAlias)) + var conflictingAlias = keys.Keys.FirstOrDefault(key => string.Equals( + key, newAlias, StringComparison.OrdinalIgnoreCase)); + if (conflictingAlias != null && + !string.Equals(conflictingAlias, oldAlias, StringComparison.Ordinal)) { - // Duplicate alias check - if (keys.ContainsKey(newAlias)) + results.Add(new Result { - results.Add(new Result - { - Title = oldAlias + " → " + newAlias, - SubTitle = GetTranslation("plugin_quickssh_keys_rename_duplicate"), - IcoPath = AppIconRedPath - }); - } - else + Title = newAlias, + SubTitle = GetTranslation("plugin_quickssh_keys_rename_duplicate"), + IcoPath = AppIconRedPath + }); + return results; + } + + results.Add(new Result + { + Title = string.Format( + GetTranslation("plugin_quickssh_keys_rename_confirm_title"), + oldAlias, newAlias), + SubTitle = GetTranslation("plugin_quickssh_keys_rename_confirm_subtitle"), + IcoPath = GetSemanticIconPath("rename"), + Action = _ => { - var keyEntry = keys[oldAlias]; - results.Add(new Result + var value = keys[oldAlias]; + keys.SetCallback(null); + try { - Title = oldAlias + " → " + newAlias, - SubTitle = keyEntry?.ToDisplayString() ?? "", - IcoPath = AppIconGreenPath, - Action = _ => - { - var value = keys[oldAlias]; - keys.SetCallback(null); - try - { - keys.Remove(oldAlias); - keys[newAlias] = value; - } - finally - { - keys.SetCallback(_profileManager.SaveConfiguration); - } - _profileManager.SaveConfiguration(); - _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " keys ", true); - return false; - } - }); + keys.Remove(oldAlias); + keys[newAlias] = value; + } + finally + { + keys.SetCallback(_profileManager.SaveConfiguration); + } + _profileManager.SaveConfiguration(); + _pluginContext?.API?.ChangeQuery(query.ActionKeyword + " keys ", true); + return false; } - } + }); return results; } @@ -2069,15 +3558,7 @@ private List HandleKeysCopyPath(Query query, string search) var results = new List(); var keys = _profileManager.UserData.SshKeys; - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandkeys_copypath"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandkeys_copypath"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " keys copy-path ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys ", query.ActionKeyword + " keys")); + results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys manage ", query.ActionKeyword + " keys manage")); if (keys.Count == 0) { @@ -2102,7 +3583,7 @@ private List HandleKeysCopyPath(Query query, string search) { Title = alias, SubTitle = GetTranslation("plugin_quickssh_keys_copypath_label") + " " + keyPath, - IcoPath = AppIconGreenPath, + IcoPath = GetSemanticIconPath("copy"), AutoCompleteText = query.ActionKeyword + " keys copy-path " + alias, Action = _ => { @@ -2125,15 +3606,7 @@ private List HandleKeysCopyPub(Query query, string search) var results = new List(); var keys = _profileManager.UserData.SshKeys; - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandkeys_copypub"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandkeys_copypub"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " keys copy-pub ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys ", query.ActionKeyword + " keys")); + results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys manage ", query.ActionKeyword + " keys manage")); if (keys.Count == 0) { @@ -2162,7 +3635,7 @@ private List HandleKeysCopyPub(Query query, string search) { Title = alias, SubTitle = GetTranslation("plugin_quickssh_keys_copypub_label") + " " + pubPath, - IcoPath = AppIconGreenPath, + IcoPath = GetSemanticIconPath("copy"), AutoCompleteText = query.ActionKeyword + " keys copy-pub " + alias, Action = _ => { @@ -2209,15 +3682,7 @@ private List HandleKeysScan(Query query) var results = new List(); var keys = _profileManager.UserData.SshKeys; - results.Add(new Result - { - Title = GetTranslation("plugin_quickssh_title_commandkeys_scan"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandkeys_scan"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " keys scan ", - Score = int.MaxValue - }); - results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys ", query.ActionKeyword + " keys")); + results.Add(MakeBackNavResult(query, query.ActionKeyword + " keys manage ", query.ActionKeyword + " keys manage")); var sshDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh"); @@ -2344,23 +3809,11 @@ internal static List ScanSshDirectory(string sshDir) private List HandleConfig(Query query, string rest) { - // Both "config" and "config import" trigger the same import action. - var results = new List(); - - // 1. Management/usage hint — always pinned at the top. - results.Add(new Result + var results = new List { - Title = GetTranslation("plugin_quickssh_title_commandconfig"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandconfig_usage"), - IcoPath = AppIconPath, - AutoCompleteText = query.ActionKeyword + " config ", - Score = ScoreSubMenuManagement - }); - - // 2. Back-navigation row — returns to top-level command list. - results.Add(MakeBackNavResult(query, query.ActionKeyword + " ", query.ActionKeyword)); + MakeBackNavResult(query, query.ActionKeyword + " ", query.ActionKeyword) + }; - // 3. Config action row. results.Add(new Result { Title = GetTranslation("plugin_quickssh_title_commandconfig"), @@ -2408,22 +3861,12 @@ private List HandleDocs(Query query) { return new List { - // 1. Management/usage hint — always pinned at the top. - new Result - { - Title = GetTranslation("plugin_quickssh_title_commandhelp"), - SubTitle = GetTranslation("plugin_quickssh_subtitle_commandhelp_usage"), - IcoPath = AppIconPath, - Score = ScoreSubMenuManagement - }, - // 2. Back-navigation row — returns to top-level command list. MakeBackNavResult(query, query.ActionKeyword + " ", query.ActionKeyword), - // 3. Help action row. new Result { Title = GetTranslation("plugin_quickssh_title_commandhelp"), SubTitle = GetTranslation("plugin_quickssh_subtitle_commandhelp"), - IcoPath = AppIconGreenPath, + IcoPath = AppIconPath, Action = _ => { using var process = Process.Start(new ProcessStartInfo @@ -2444,8 +3887,8 @@ private List HandleDocs(Query query) /// /// Creates a back-navigation result that navigates the query up one command level. - /// The result is scored at so it always appears - /// immediately below the pinned usage-hint row. + /// The result is scored at so it is always + /// the first row in every submenu, selection view, and confirmation view. /// /// The current Flow Launcher query (for the action keyword). /// @@ -2458,9 +3901,10 @@ private List HandleDocs(Query query) /// private Result MakeBackNavResult(Query query, string parentQueryText, string parentLabel) { + var displayLabel = GetBackNavigationLabel(query, parentLabel); return new Result { - Title = string.Format(GetTranslation("plugin_quickssh_back_nav_title"), parentLabel), + Title = string.Format(GetTranslation("plugin_quickssh_back_nav_title"), displayLabel), IcoPath = AppIconPath, Score = ScoreBackNavigation, AutoCompleteText = parentQueryText, @@ -2472,6 +3916,133 @@ private Result MakeBackNavResult(Query query, string parentQueryText, string par }; } + private Result MakeWizardExampleResultFromKeys( + string titleKey, + string subtitleKey, + string exampleQuery, + params object[] titleArguments) + { + var title = GetTranslation(titleKey); + if (titleArguments != null && titleArguments.Length > 0) + title = string.Format(title, titleArguments); + + return MakeWizardExampleResult( + title, + GetTranslation(subtitleKey), + exampleQuery); + } + + private Result MakeWizardExampleResult( + string title, + string subtitle, + string exampleQuery) + { + return new Result + { + Title = title, + SubTitle = subtitle, + IcoPath = AppIconPath, + Score = ScoreSubMenuManagement, + AutoCompleteText = exampleQuery, + Action = _ => + { + _pluginContext?.API?.ChangeQuery(exampleQuery, true); + return false; + } + }; + } + + private static string GetBackNavigationLabel(Query query, string parentLabel) + { + var label = (parentLabel ?? string.Empty).Trim(); + var keyword = (query?.ActionKeyword ?? string.Empty).Trim(); + + if (!string.IsNullOrEmpty(keyword) && + label.StartsWith(keyword, StringComparison.OrdinalIgnoreCase)) + label = label.Substring(keyword.Length).Trim(); + + var normalized = label.ToLowerInvariant(); + if (string.IsNullOrEmpty(normalized)) + return GetTranslation("plugin_quickssh_back_root_label"); + if (normalized == "profiles manage") + return GetTranslation("plugin_quickssh_back_profiles_manage_label"); + if (normalized == "profiles remove selection") + return GetTranslation("plugin_quickssh_back_profiles_selection_label"); + if (normalized == "actions manage") + return GetTranslation("plugin_quickssh_back_actions_manage_label"); + if (normalized == "actions profile selection") + return GetTranslation("plugin_quickssh_back_actions_profile_selection_label"); + if (normalized == "actions action selection" || + normalized == "actions remove selection") + return GetTranslation("plugin_quickssh_back_actions_action_selection_label"); + if (normalized == "keys manage") + return GetTranslation("plugin_quickssh_back_keys_manage_label"); + if (normalized == "shell manage") + return GetTranslation("plugin_quickssh_back_shell_manage_label"); + if (normalized.StartsWith("profiles", StringComparison.Ordinal)) + return GetTranslation("plugin_quickssh_back_profiles_label"); + if (normalized.StartsWith("actions", StringComparison.Ordinal)) + return GetTranslation("plugin_quickssh_back_actions_label"); + if (normalized.StartsWith("tools", StringComparison.Ordinal)) + return GetTranslation("plugin_quickssh_back_tools_label"); + if (normalized.StartsWith("shell", StringComparison.Ordinal)) + return GetTranslation("plugin_quickssh_back_shell_label"); + if (normalized.StartsWith("keys", StringComparison.Ordinal)) + return GetTranslation("plugin_quickssh_back_keys_label"); + if (normalized.StartsWith("config", StringComparison.Ordinal)) + return GetTranslation("plugin_quickssh_back_tools_label"); + if (normalized.StartsWith("help", StringComparison.Ordinal)) + return GetTranslation("plugin_quickssh_back_root_label"); + return label; + } + + private string GetProfileKeyUnavailableSubtitle(SshKeyEntry? entry) + { + var path = ProfileWizard.ExpandLocalPath(entry?.Path); + switch (ProfileWizard.GetKeyFileKind(entry)) + { + case ProfileWizard.SshKeyFileKind.Public: + return string.Format( + GetTranslation("plugin_quickssh_profiles_key_public_subtitle"), path); + case ProfileWizard.SshKeyFileKind.Unknown: + return string.Format( + GetTranslation("plugin_quickssh_profiles_key_invalid_subtitle"), path); + default: + return string.Format( + GetTranslation("plugin_quickssh_profiles_key_missing_subtitle"), path); + } + } + + internal static string BuildProfileListSubtitle(SshProfile profile) + { + if (profile == null) + return string.Empty; + + if (string.Equals(profile.Type, "scp", StringComparison.OrdinalIgnoreCase)) + return profile.ToDisplayString(); + + var host = profile.HostName ?? string.Empty; + if (string.IsNullOrWhiteSpace(host)) + return profile.ToDisplayString(); + + var destination = string.IsNullOrWhiteSpace(profile.User) + ? host + : profile.User + "@" + host; + + if (!string.IsNullOrWhiteSpace(profile.Port) && profile.Port != "22") + destination += ":" + profile.Port; + + if (string.IsNullOrWhiteSpace(profile.IdentityFile)) + return destination; + + var normalizedPath = profile.IdentityFile.Trim('"').Replace('\\', '/'); + var separator = normalizedPath.LastIndexOf('/'); + var keyName = separator >= 0 ? normalizedPath.Substring(separator + 1) : normalizedPath; + return string.IsNullOrWhiteSpace(keyName) + ? destination + : destination + " • " + keyName; + } + #endregion #region SSH / SCP Execution @@ -2601,43 +4172,24 @@ private void RunCommand(string command) var selectedShell = _profileManager.UserData.SelectedCustomShell; var customShells = _profileManager.UserData.CustomShell; - string fileName; - string arguments; - string customShellName = null; - - if (!string.IsNullOrEmpty(selectedShell) && customShells.ContainsKey(selectedShell)) - { - var shellValue = customShells[selectedShell]; - customShellName = selectedShell; - - if (string.IsNullOrEmpty(shellValue)) - { - // Shell name is the executable - fileName = Utils.ResolveExecutable(selectedShell); - arguments = command; - } - else - { - // Parse the shell value into exe + args - var spaceIdx = shellValue.IndexOf(' '); - if (spaceIdx < 0) - { - fileName = Utils.ResolveExecutable(shellValue); - arguments = command; - } - else - { - fileName = Utils.ResolveExecutable(shellValue.Substring(0, spaceIdx)); - arguments = shellValue.Substring(spaceIdx + 1) + " " + command; - } - } - } - else + string? ResolveSelectedExecutable(string executable) => + Utils.TryResolveExecutable(executable, out var resolvedPath) + ? resolvedPath + : null; + + if (!ShellLaunchPlan.TryCreate( + command, + selectedShell, + customShells, + ResolveSelectedExecutable, + GetCmdExePath(), + out var launchPlan, + out var planError)) { - // Default: use cmd.exe with /k so the window stays open after SSH/SCP exits, - // allowing the user to see any connection-error messages. - fileName = GetCmdExePath(); - arguments = "/k " + command; + _pluginContext?.API?.ShowMsg( + "QuickSSH", + GetShellLaunchPlanErrorMessage(planError, selectedShell)); + return; } // Use the user's home directory as the working directory so SSH can always @@ -2645,41 +4197,51 @@ private void RunCommand(string command) // a path that contains non-ASCII characters or spaces. var workingDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - try + if (!ShellCommandLauncher.TryStart( + launchPlan, + workingDir, + Process.Start, + out var launchError)) { - using var process = Process.Start(new ProcessStartInfo - { - FileName = fileName, - Arguments = arguments, - UseShellExecute = true, - WorkingDirectory = workingDir - }); + var shellName = launchPlan.UsesDefaultShell + ? "cmd.exe" + : launchPlan.ShellName; + _pluginContext?.API?.ShowMsg( + "QuickSSH", + string.Format( + GetTranslation("plugin_quickssh_shell_start_failed"), + shellName, + launchError?.Message ?? string.Empty)); } - catch (Exception ex) + } + + private static string GetShellLaunchPlanErrorMessage( + ShellLaunchPlanError error, + string? selectedShell) + { + var shellName = string.IsNullOrWhiteSpace(selectedShell) + ? "cmd.exe" + : selectedShell; + + switch (error) { - if (customShellName != null) - { - // The custom shell executable could not be started; fall back to - // cmd.exe so the connection still opens despite the broken shell. - try - { - using var fallback = Process.Start(new ProcessStartInfo - { - FileName = GetCmdExePath(), - Arguments = "/k " + command, - UseShellExecute = true, - WorkingDirectory = workingDir - }); - } - catch (Exception ex2) - { - _pluginContext?.API?.ShowMsg("QuickSSH", "Error: " + ex2.Message); - } - } - else - { - _pluginContext?.API?.ShowMsg("QuickSSH", "Error: " + ex.Message); - } + case ShellLaunchPlanError.SelectedShellMissing: + return string.Format( + GetTranslation("plugin_quickssh_shell_selected_missing"), + shellName); + case ShellLaunchPlanError.InvalidShellDefinition: + return string.Format( + GetTranslation("plugin_quickssh_shell_definition_invalid"), + shellName); + case ShellLaunchPlanError.ExecutableNotFound: + return string.Format( + GetTranslation("plugin_quickssh_shell_executable_missing"), + shellName); + default: + return string.Format( + GetTranslation("plugin_quickssh_shell_start_failed"), + shellName, + string.Empty); } } @@ -2700,87 +4262,7 @@ private static string GetCmdExePath() internal static int ScoreProfile(string search, string name, string command) { - var searchLower = RemoveDiacritics(search.ToLowerInvariant()); - var nameLower = RemoveDiacritics(name.ToLowerInvariant()); - var commandLower = RemoveDiacritics(command.ToLowerInvariant()); - - bool nameExact = ContainsIgnoreAccents(nameLower, searchLower); - bool cmdExact = ContainsIgnoreAccents(commandLower, searchLower); - - if (nameExact && cmdExact) return 0; - if (nameExact) return 1; - if (cmdExact) return 2; - - bool nameFuzzy = FuzzyContains(nameLower, searchLower); - bool cmdFuzzy = FuzzyContains(commandLower, searchLower); - - if (nameFuzzy && cmdFuzzy) return 3; - if (nameFuzzy) return 4; - if (cmdFuzzy) return 5; - - return int.MaxValue; - } - - private static string RemoveDiacritics(string text) - { - var normalized = text.Normalize(NormalizationForm.FormD); - var sb = new StringBuilder(normalized.Length); - foreach (var c in normalized) - { - if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark) - sb.Append(c); - } - return sb.ToString().Normalize(NormalizationForm.FormC); - } - - private static bool ContainsIgnoreAccents(string source, string search) - { - return RemoveDiacritics(source).Contains(RemoveDiacritics(search)); - } - - private static bool FuzzyContains(string source, string search) - { - if (search.Length < 5) - return source.Contains(search); - - int tolerance = search.Length / 5; - - for (int i = 0; i <= source.Length - search.Length + tolerance; i++) - { - int end = Math.Min(i + search.Length + tolerance, source.Length); - var window = source.Substring(i, end - i); - if (DamerauLevenshteinDistance(window, search) <= tolerance) - return true; - } - - return false; - } - - private static int DamerauLevenshteinDistance(string s, string t) - { - int n = s.Length; - int m = t.Length; - var d = new int[n + 1, m + 1]; - - for (int i = 0; i <= n; i++) d[i, 0] = i; - for (int j = 0; j <= m; j++) d[0, j] = j; - - for (int i = 1; i <= n; i++) - { - for (int j = 1; j <= m; j++) - { - int cost = s[i - 1] == t[j - 1] ? 0 : 1; - - d[i, j] = Math.Min( - Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), - d[i - 1, j - 1] + cost); - - if (i > 1 && j > 1 && s[i - 1] == t[j - 2] && s[i - 2] == t[j - 1]) - d[i, j] = Math.Min(d[i, j], d[i - 2, j - 2] + cost); - } - } - - return d[n, m]; + return SearchMatcher.ScoreProfile(search, name, command); } #endregion @@ -2817,16 +4299,21 @@ private static (string name, string value) ParseShellAddArgs(string input) #region i18n + /// public string GetTranslatedPluginTitle() { return GetTranslation("plugin_quickssh_plugin_name"); } + /// public string GetTranslatedPluginDescription() { return GetTranslation("plugin_quickssh_plugin_description"); } + /// Returns a localized resource value, or the key itself when translation lookup fails. + /// Resource key to resolve. + /// The localized text or the original key as a safe fallback. public static string GetTranslation(string key) { try diff --git a/Profile.cs b/Profile.cs index 123707a..73e90ef 100644 --- a/Profile.cs +++ b/Profile.cs @@ -7,10 +7,11 @@ namespace Flow.Launcher.Plugin.QuickSSH { /// - /// Persisted plugin data: SSH/SCP profiles, custom shells, and selected shell. + /// Persisted plugin data: SSH/SCP profiles, reusable actions, custom shells, SSH keys, and selected shell. /// public class UserData { + /// Gets or sets the persisted user-data schema version. public string PluginVersion { get; set; } = "2.0"; // ── Structured profiles (canonical format, v2+) ──────────────────────────── @@ -18,6 +19,7 @@ public class UserData [JsonProperty] private Dictionary ProfilesLists { get; set; } = new(); + /// Gets the auto-saving SSH and SCP profile registry. [JsonIgnore] public AutoSaveDictionary Profiles { get; private set; } @@ -30,16 +32,28 @@ public class UserData [JsonProperty] private Dictionary CustomShellLists { get; set; } = new(); + /// Gets the auto-saving custom shell registry. [JsonIgnore] public AutoSaveDictionary CustomShell { get; private set; } + /// Gets or sets the alias of the selected custom shell. public string? SelectedCustomShell { get; set; } + // ── Reusable SSH action profiles ─────────────────────────────────────── + + [JsonProperty] + private Dictionary CommandProfilesLists { get; set; } = new(); + + /// Gets the auto-saving reusable SSH action registry. + [JsonIgnore] + public AutoSaveDictionary CommandProfiles { get; private set; } + // ── SSH key registry (alias → local path, never stores key content) ─────── [JsonProperty] private Dictionary SshKeysLists { get; set; } = new(); + /// Gets the auto-saving SSH key metadata registry. [JsonIgnore] public AutoSaveDictionary SshKeys { get; private set; } @@ -47,7 +61,7 @@ public class UserData /// Binds auto-save callbacks after construction or deserialization. /// Migrates any legacy raw-string profiles to structured objects. /// - /// Callback invoked on every profile or shell mutation. + /// Callback invoked on every profile, action, shell, or key mutation. /// /// when v1 legacy data was found and migrated; /// the caller should persist immediately so the disk file reflects the new v2 format. @@ -56,6 +70,7 @@ public bool Attach(Action onChanged) { ProfilesLists ??= new Dictionary(); CustomShellLists ??= new Dictionary(); + CommandProfilesLists ??= new Dictionary(); SshKeysLists ??= new Dictionary(); bool migrated = false; @@ -80,6 +95,7 @@ public bool Attach(Action onChanged) Profiles = new AutoSaveDictionary(ProfilesLists, onChanged); CustomShell = new AutoSaveDictionary(CustomShellLists, onChanged); + CommandProfiles = new AutoSaveDictionary(CommandProfilesLists, onChanged); SshKeys = new AutoSaveDictionary(SshKeysLists, onChanged); return migrated; @@ -95,34 +111,54 @@ public sealed class AutoSaveDictionary : IDictionary private readonly IDictionary _inner; private Action _onChanged; - public AutoSaveDictionary(IDictionary inner, Action onChanged) + /// Initializes an auto-saving dictionary wrapper. + /// Dictionary that stores the values. + /// Callback invoked after every successful mutation. + public AutoSaveDictionary(IDictionary inner, Action? onChanged) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _onChanged = onChanged ?? Noop; } - public void SetCallback(Action onChanged) => _onChanged = onChanged ?? Noop; + /// Replaces the callback invoked after dictionary mutations. + /// New callback, or a no-op callback when null. + public void SetCallback(Action? onChanged) => _onChanged = onChanged ?? Noop; + /// public TValue this[TKey key] { get => _inner[key]; set { _inner[key] = value; _onChanged(); } } + /// public ICollection Keys => _inner.Keys; + /// public ICollection Values => _inner.Values; + /// public int Count => _inner.Count; + /// public bool IsReadOnly => _inner.IsReadOnly; + /// public void Add(TKey key, TValue value) { _inner.Add(key, value); _onChanged(); } + /// public void Add(KeyValuePair item) { _inner.Add(item); _onChanged(); } + /// public bool Remove(TKey key) { var r = _inner.Remove(key); if (r) _onChanged(); return r; } + /// public bool Remove(KeyValuePair item) { var r = _inner.Remove(item); if (r) _onChanged(); return r; } + /// public void Clear() { _inner.Clear(); _onChanged(); } + /// public bool ContainsKey(TKey key) => _inner.ContainsKey(key); + /// public bool Contains(KeyValuePair item) => _inner.Contains(item); + /// public bool TryGetValue(TKey key, out TValue value) => _inner.TryGetValue(key, out value); + /// public void CopyTo(KeyValuePair[] array, int arrayIndex) => _inner.CopyTo(array, arrayIndex); + /// public IEnumerator> GetEnumerator() => _inner.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_inner).GetEnumerator(); } @@ -133,8 +169,15 @@ public TValue this[TKey key] public class ProfileManager { private readonly string _path; + + /// Gets the currently loaded plugin data. public UserData UserData { get; private set; } + /// Gets the fixed pre-import backup path next to the portable database. + internal string ImportBackupPath => _path + ".import.bak"; + + /// Initializes a profile manager for the specified JSON storage path. + /// Path to the portable profiles database. public ProfileManager(string path) { _path = path; @@ -155,6 +198,7 @@ public ProfileManager(string path) } } + /// Atomically saves the current plugin data to disk. public void SaveConfiguration() { var json = JsonConvert.SerializeObject(UserData, Formatting.Indented); @@ -171,6 +215,46 @@ public void SaveConfiguration() } } + /// Creates or replaces the portable pre-import backup atomically. + internal string CreateImportBackup() + { + var backupPath = ImportBackupPath; + var tmp = backupPath + ".tmp"; + + try + { + File.Copy(_path, tmp, overwrite: true); + File.Move(tmp, backupPath, overwrite: true); + return backupPath; + } + finally + { + if (File.Exists(tmp)) + try { File.Delete(tmp); } catch { /* best effort cleanup */ } + } + } + + /// Restores the portable database from a pre-import backup and reloads memory state. + internal void RestoreImportBackup(string backupPath) + { + if (string.IsNullOrWhiteSpace(backupPath) || !File.Exists(backupPath)) + throw new FileNotFoundException("Profile import backup was not found.", backupPath); + + var tmp = _path + ".restore.tmp"; + try + { + File.Copy(backupPath, tmp, overwrite: true); + File.Move(tmp, _path, overwrite: true); + LoadConfiguration(); + } + finally + { + if (File.Exists(tmp)) + try { File.Delete(tmp); } catch { /* best effort cleanup */ } + } + } + + /// Loads plugin data from disk and performs any required legacy migration. public void LoadConfiguration() { var json = File.ReadAllText(_path); diff --git a/ProfileImportService.cs b/ProfileImportService.cs new file mode 100644 index 0000000..04141c6 --- /dev/null +++ b/ProfileImportService.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Flow.Launcher.Plugin.QuickSSH +{ + /// Result of a guarded profile import. + internal sealed class ProfileImportResult + { + internal ProfileImportResult(int importedCount, int skippedCount, string backupPath) + { + ImportedCount = importedCount; + SkippedCount = skippedCount; + BackupPath = backupPath; + } + + internal int ImportedCount { get; } + internal int SkippedCount { get; } + internal string BackupPath { get; } + } + + /// + /// Imports profiles with a portable pre-import backup and fail-closed rollback. + /// + internal static class ProfileImportService + { + internal static ProfileImportResult Import( + ProfileManager profileManager, + IReadOnlyDictionary importedProfiles, + Action? saveConfiguration = null) + { + if (profileManager == null) + throw new ArgumentNullException(nameof(profileManager)); + if (importedProfiles == null) + throw new ArgumentNullException(nameof(importedProfiles)); + + var backupPath = profileManager.CreateImportBackup(); + var profiles = profileManager.UserData.Profiles; + var importedCount = 0; + var skippedCount = 0; + var stateReloaded = false; + + profiles.SetCallback(null); + try + { + foreach (var entry in importedProfiles) + { + var nameExists = profiles.Keys.Any(existing => + string.Equals(existing, entry.Key, StringComparison.OrdinalIgnoreCase)); + + if (nameExists) + { + skippedCount++; + continue; + } + + profiles[entry.Key] = entry.Value; + importedCount++; + } + + if (importedCount > 0) + (saveConfiguration ?? profileManager.SaveConfiguration)(); + + return new ProfileImportResult(importedCount, skippedCount, backupPath); + } + catch (Exception importException) + { + try + { + profileManager.RestoreImportBackup(backupPath); + stateReloaded = true; + } + catch (Exception rollbackException) + { + throw new IOException( + "Profile import failed and the previous portable database could not be restored.", + new AggregateException(importException, rollbackException)); + } + + throw; + } + finally + { + if (!stateReloaded) + profiles.SetCallback(profileManager.SaveConfiguration); + } + } + } +} diff --git a/ProfileWizard.cs b/ProfileWizard.cs new file mode 100644 index 0000000..c769594 --- /dev/null +++ b/ProfileWizard.cs @@ -0,0 +1,417 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Globalization; + +namespace Flow.Launcher.Plugin.QuickSSH +{ + /// + /// Pure helpers for the guided profile-create and rename flows. + /// Keeps query parsing and profile construction outside the Flow Launcher UI handler. + /// + internal static class ProfileWizard + { + internal const string SavedKeyOption = "--key"; + internal const string DefaultAuthOption = "--default"; + internal const string PortOption = "--port"; + + internal enum SshKeyFileKind + { + Missing, + Private, + Public, + Unknown + } + + /// + /// Builds a rename query with the existing name duplicated as the editable value. + /// The cursor remains at the end, so the user changes only the final token. + /// + internal static string BuildPrefilledRenameQuery( + string actionKeyword, + string commandPath, + string currentName) + { + return string.Join(" ", new[] + { + (actionKeyword ?? string.Empty).Trim(), + (commandPath ?? string.Empty).Trim(), + currentName ?? string.Empty, + currentName ?? string.Empty + }).Trim(); + } + + /// + /// Builds an explicit rename query with a chosen editable value. + /// + internal static string BuildRenameQuery( + string actionKeyword, + string commandPath, + string currentName, + string newName) + { + return string.Join(" ", new[] + { + (actionKeyword ?? string.Empty).Trim(), + (commandPath ?? string.Empty).Trim(), + currentName ?? string.Empty, + newName ?? string.Empty + }).Trim(); + } + + /// + /// Returns the preferred example name when it is free, otherwise the next + /// case-insensitively available numeric suffix. + /// + internal static string BuildAvailableName( + string preferredName, + IEnumerable existingNames) + { + var preferred = string.IsNullOrWhiteSpace(preferredName) + ? "item" + : preferredName.Trim(); + var existing = new HashSet( + existingNames ?? Array.Empty(), + StringComparer.OrdinalIgnoreCase); + + return existing.Contains(preferred) + ? BuildSuggestedName(preferred, existing) + : preferred; + } + + /// + /// Returns a simple, case-insensitively unique rename suggestion. + /// + internal static string BuildSuggestedName( + string currentName, + IEnumerable existingNames) + { + var baseName = string.IsNullOrWhiteSpace(currentName) + ? "item" + : currentName.Trim(); + var startSuffix = 2; + var separator = baseName.LastIndexOf('-'); + if (separator > 0 && separator < baseName.Length - 1 && + int.TryParse(baseName.Substring(separator + 1), out var parsedSuffix) && + parsedSuffix >= 2 && parsedSuffix < int.MaxValue) + { + baseName = baseName.Substring(0, separator); + startSuffix = parsedSuffix + 1; + } + + var existing = new HashSet( + existingNames ?? Array.Empty(), + StringComparer.OrdinalIgnoreCase); + + for (var suffix = startSuffix; suffix < int.MaxValue; suffix++) + { + var suffixText = suffix.ToString(); + var maxBaseLength = 64 - suffixText.Length - 1; + var candidateBase = baseName.Length > maxBaseLength + ? baseName.Substring(0, maxBaseLength) + : baseName; + var candidate = candidateBase + "-" + suffixText; + if (!existing.Contains(candidate)) + return candidate; + } + + return baseName + "-new"; + } + + /// + /// Returns true when the input is an explicit advanced SSH/SCP command. + /// Advanced commands keep the legacy free-form profile workflow intact. + /// + internal static bool IsAdvancedCommand(string? input) + { + var value = (input ?? string.Empty).TrimStart(); + return value.Equals("ssh", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("ssh ", StringComparison.OrdinalIgnoreCase) || + value.Equals("scp", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("scp ", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Parses the guided syntax: + /// destination, optional --port, and optional --default or --key alias. + /// + internal static bool TryParseBasicInput( + string? input, + out string destination, + out string? keyAlias, + out bool useDefaultAuthentication, + out string? port) + { + destination = string.Empty; + keyAlias = null; + useDefaultAuthentication = false; + port = null; + + var tokens = SshProfile.TokenizeShellLine((input ?? string.Empty).Trim()); + if (tokens.Count == 0) + return false; + + destination = tokens[0]; + if (!TryParseDestination(destination, out _, out _)) + return false; + + var index = 1; + if (index < tokens.Count && + tokens[index].Equals(PortOption, StringComparison.OrdinalIgnoreCase)) + { + if (index + 1 >= tokens.Count || + !TryNormalizePort(tokens[index + 1], out port)) + return false; + index += 2; + } + + if (index == tokens.Count) + return true; + + if (index + 1 == tokens.Count && + tokens[index].Equals(DefaultAuthOption, StringComparison.OrdinalIgnoreCase)) + { + useDefaultAuthentication = true; + return true; + } + + if (index + 2 == tokens.Count && + tokens[index].Equals(SavedKeyOption, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(tokens[index + 1])) + { + keyAlias = tokens[index + 1]; + return true; + } + + return false; + } + + internal static bool TryParseBasicInput( + string? input, + out string destination, + out string? keyAlias, + out bool useDefaultAuthentication) + { + return TryParseBasicInput( + input, out destination, out keyAlias, + out useDefaultAuthentication, out _); + } + + internal static bool TryNormalizePort(string? value, out string? port) + { + port = null; + if (!int.TryParse( + (value ?? string.Empty).Trim(), + NumberStyles.None, + CultureInfo.InvariantCulture, + out var parsed) || + parsed < 1 || parsed > 65535) + return false; + + port = parsed.ToString(CultureInfo.InvariantCulture); + return true; + } + + /// + /// Creates a structured SSH profile from a beginner-friendly destination, port, and optional key. + /// + internal static bool TryCreateBasicProfile( + string destination, + string? identityFile, + string? port, + out SshProfile profile) + { + profile = new SshProfile { Type = "ssh" }; + if (!TryParseDestination(destination, out var user, out var host)) + return false; + + string? normalizedPort = null; + if (!string.IsNullOrWhiteSpace(port) && + !TryNormalizePort(port, out normalizedPort)) + return false; + + profile.User = user; + profile.HostName = host; + profile.Port = normalizedPort == "22" ? null : normalizedPort; + profile.IdentityFile = string.IsNullOrWhiteSpace(identityFile) + ? null + : identityFile; + profile.IdentitiesOnly = !string.IsNullOrWhiteSpace(identityFile); + return true; + } + + internal static bool TryCreateBasicProfile( + string destination, + string? identityFile, + out SshProfile profile) + { + return TryCreateBasicProfile(destination, identityFile, null, out profile); + } + + /// + /// Expands environment variables and a leading ~ for local file checks. + /// + internal static string ExpandLocalPath(string? path) + { + var value = (path ?? string.Empty).Trim().Trim('"'); + value = Environment.ExpandEnvironmentVariables(value); + + if (value.Equals("~", StringComparison.Ordinal)) + return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + if (value.StartsWith("~/", StringComparison.Ordinal) || + value.StartsWith("~\\", StringComparison.Ordinal)) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + value = Path.Combine(home, value.Substring(2)); + } + + return value; + } + + /// + /// Classifies a registered SSH key by its file content. + /// This catches public keys even when their file name does not end in .pub. + /// Unknown files fail closed and are never offered as connection identities. + /// + internal static SshKeyFileKind GetKeyFileKind(SshKeyEntry? entry) + { + return GetKeyFileKind(entry?.Path); + } + + internal static SshKeyFileKind GetKeyFileKind(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return SshKeyFileKind.Missing; + + var expandedPath = ExpandLocalPath(path); + if (!File.Exists(expandedPath)) + return SshKeyFileKind.Missing; + + if (expandedPath.EndsWith(".pub", StringComparison.OrdinalIgnoreCase)) + return SshKeyFileKind.Public; + + try + { + using var reader = new StreamReader(expandedPath, detectEncodingFromByteOrderMarks: true); + var buffer = new char[4096]; + var length = reader.ReadBlock(buffer, 0, buffer.Length); + var content = new string(buffer, 0, length).TrimStart('\uFEFF', ' ', '\t', '\r', '\n'); + + if (content.StartsWith("-----BEGIN OPENSSH PRIVATE KEY-----", StringComparison.Ordinal) || + content.StartsWith("-----BEGIN RSA PRIVATE KEY-----", StringComparison.Ordinal) || + content.StartsWith("-----BEGIN DSA PRIVATE KEY-----", StringComparison.Ordinal) || + content.StartsWith("-----BEGIN EC PRIVATE KEY-----", StringComparison.Ordinal) || + content.StartsWith("-----BEGIN PRIVATE KEY-----", StringComparison.Ordinal) || + content.StartsWith("-----BEGIN ENCRYPTED PRIVATE KEY-----", StringComparison.Ordinal) || + content.StartsWith("PuTTY-User-Key-File-", StringComparison.Ordinal) || + content.StartsWith("SSH PRIVATE KEY FILE FORMAT 1.1", StringComparison.Ordinal)) + return SshKeyFileKind.Private; + + if (content.StartsWith("ssh-", StringComparison.Ordinal) || + content.StartsWith("ecdsa-", StringComparison.Ordinal) || + content.StartsWith("sk-ssh-", StringComparison.Ordinal) || + content.StartsWith("sk-ecdsa-", StringComparison.Ordinal) || + content.StartsWith("-----BEGIN PUBLIC KEY-----", StringComparison.Ordinal) || + content.StartsWith("---- BEGIN SSH2 PUBLIC KEY ----", StringComparison.Ordinal)) + return SshKeyFileKind.Public; + } + catch (IOException) + { + return SshKeyFileKind.Unknown; + } + catch (UnauthorizedAccessException) + { + return SshKeyFileKind.Unknown; + } + + return SshKeyFileKind.Unknown; + } + + internal static bool IsUsablePrivateKey(SshKeyEntry? entry) + { + return GetKeyFileKind(entry) == SshKeyFileKind.Private; + } + + /// + /// Parses a conservative single-token SSH destination for the guided flow. + /// Complex options remain available through the advanced full-command path. + /// + internal static bool TryParseDestination( + string? destination, + out string? user, + out string host) + { + user = null; + host = string.Empty; + + var value = (destination ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(value) || value.Length > 255) + return false; + + foreach (var c in value) + { + if (char.IsWhiteSpace(c) || char.IsControl(c) || + c == '&' || c == '|' || c == ';' || c == '<' || c == '>' || + c == '(' || c == ')' || c == '$' || c == '`' || c == '"' || c == '\'') + return false; + } + + var at = value.IndexOf('@'); + if (at >= 0) + { + if (at == 0 || at != value.LastIndexOf('@') || at == value.Length - 1) + return false; + + var candidateUser = value.Substring(0, at); + if (!IsSafeUser(candidateUser)) + return false; + + user = candidateUser; + value = value.Substring(at + 1); + } + + if (!IsSafeHost(value)) + return false; + + host = value; + return true; + } + + private static bool IsSafeUser(string value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 64) + return false; + + foreach (var c in value) + if (!(char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '-')) + return false; + + return true; + } + + private static bool IsSafeHost(string value) + { + if (string.IsNullOrWhiteSpace(value) || value.StartsWith("-", StringComparison.Ordinal)) + return false; + + // Bracketed IPv6 is accepted as a single safe destination token. + if (value.Length >= 3 && value[0] == '[' && value[value.Length - 1] == ']') + { + for (var i = 1; i < value.Length - 1; i++) + { + var c = value[i]; + if (!(Uri.IsHexDigit(c) || c == ':' || c == '.')) + return false; + } + return true; + } + + foreach (var c in value) + if (!(char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '-')) + return false; + + return true; + } + } +} diff --git a/README.md b/README.md index 6f8a3cd..e4e3aea 100644 --- a/README.md +++ b/README.md @@ -1,730 +1,328 @@ # QuickSSH — Flow Launcher Plugin -Enhanced SSH/SCP connection plugin for [Flow Launcher](https://www.flowlauncher.com/) with query autocomplete, structured profile management, SSH key registry, SSH config import, human-readable profile export/import, custom shell support, and fuzzy search. - -Inspired by [Melv1no/Flow.Launcher.Plugin.easyssh](https://github.com/Melv1no/Flow.Launcher.Plugin.easyssh). - -## Command Structure - -| Command | Description | -|---------|-------------| -| `ssh profiles [filter]` | Browse saved profiles and connect | -| `ssh profiles add ` | Save a new SSH or SCP profile | -| `ssh profiles remove [filter]` | Delete a saved profile | -| `ssh profiles rename ` | Rename an existing profile | -| `ssh profiles copy [filter]` | Copy an SSH/SCP command to the clipboard | -| `ssh profiles export` | Export all profiles to a human-readable `.sshconfig` file | -| `ssh profiles import [filter]` | Import profiles from a `.sshconfig` or legacy `.json` file | -| `ssh keys` | Manage registered SSH key aliases (install / add / generate / remove / rename / copy-path / copy-pub / scan) | -| `ssh keys install ` | Install public key on remote Linux host | -| `ssh keys add ` | Register an SSH key alias pointing to a local key file | -| `ssh keys generate [path]` | Generate a new SSH keypair and auto-register it (default: `~/.ssh/`, or custom path) | -| `ssh keys remove [filter]` | Remove a registered SSH key alias | -| `ssh keys rename ` | Rename an existing key alias | -| `ssh keys copy-path [filter]` | Copy the private key file path to clipboard | -| `ssh keys copy-pub [filter]` | Copy the public key (.pub) content to clipboard | -| `ssh keys scan` | Scan `~/.ssh/` for key files and offer registration | -| `ssh shell` | Manage custom terminal shells (add / remove / select) | -| `ssh config` | Import hosts from `~/.ssh/config` | -| `ssh help` | Open plugin documentation | -| `ssh -i ` | Direct connect with key autocomplete from registered keys | -| `ssh ` | **Implicit direct connect** — type a destination or SSH options directly | - -> **Suggestion order:** Typing bare `ssh` (with no arguments) shows top-level suggestions in this order: **profiles**, **keys**, **shell**, **config**, **help**. - -> **Partial subcommand matching:** Under `ssh profiles`, subcommand matching reacts from the first matching character, consistently with top-level command matching. -> Examples: `ssh profiles a` → **add**; `ssh profiles r` → **remove**, **rename**; `ssh profiles rem` → **remove**; `ssh profiles ren` → **rename**. -> Single-letter prefixes that match only one subcommand show just that suggestion (e.g. `a` → **add**, `e` → **export**, `i` → **import**, `c` → **copy**). - -> **Shell subcommand matching:** Under `ssh shell`, partial subcommand matching works the same way. -> Examples: `ssh shell a` → **add**; `ssh shell r` → **remove**; `ssh shell rem` → **remove**. - -> **Keys subcommand matching:** Under `ssh keys`, partial subcommand matching works the same way. -> Examples: `ssh keys a` → **add**; `ssh keys g` → **generate**; `ssh keys i` → **install**; `ssh keys r` → **remove**, **rename**; `ssh keys c` → **copy-path**, **copy-pub**; `ssh keys s` → **scan**. - -> **Note for v1 users:** The top-level `add` command (v1: `ssh add `) has been moved to `ssh profiles add `. -> Typing `ssh add ...` shows an explicit redirect hint in the UI — it will not silently do something unexpected. - -### Capabilities - -- **Structured profile model** — profiles are stored as typed, structured objects (not raw strings); supports SSH, RemoteCommand, port-forwards, SCP, ProxyJump, and more -- **Human-readable export/import** — profiles are exported and imported in an SSH-config-like text format (`.sshconfig` files) -- **Legacy migration** — v1 raw-command profiles (JSON) are automatically migrated to the structured format on first load -- **Query autocomplete** — type partial commands or profile names to see matching suggestions; select a result to expand the query -- **SSH key registry** — register local SSH keys by alias; registered keys are offered in autocomplete when typing `ssh -i` -- **SSH key generation** — generate new SSH keypairs (ed25519 or RSA 4096) locally via row-driven wizard; supports custom output path or default `~/.ssh/` location; generated keys are auto-registered after verifying both private and public key files -- **SSH key installation** — deploy a registered public key to a remote Linux host's `~/.ssh/authorized_keys` via an idempotent bootstrap command; supports run, copy command, and copy public key actions -- **Implicit direct SSH input** — type a destination (`user@host`, bare IP/hostname) or SSH options (`-p 22 user@host`, `-i key user@host`) directly without any command prefix -- **SSH config import** — parse and import hosts from `~/.ssh/config` -- **SCP support** — save SCP upload/download profiles with all SCP options -- **Tunnel support** — save SSH tunnel profiles with LocalForward, RemoteForward, DynamicForward -- **RemoteCommand support** — run arbitrary remote commands (e.g. `reboot`, `systemctl restart nginx`) -- **Fuzzy search** — accent-insensitive search with Damerau-Levenshtein distance -- **Custom shells** — use cmd.exe, PowerShell, WSL, Git Bash, Windows Terminal, or any terminal -- **Multi-language support** — English, Slovak, French, German, Russian, Polish, and Spanish -- **Atomic saves** — profile data is written atomically to prevent corruption - ---- - -## Usage Examples - -### Browse and connect to saved profiles - -``` -ssh profiles → profile management view: action rows + saved profiles -ssh profiles prod → filter saved profiles containing "prod" -``` - -Press Enter on a profile row to launch the connection. - -> **Stay-open behaviour:** All non-launch actions (add, remove, rename, copy, export, import, generate, scan, config import, help) keep Flow Launcher open and navigate back to the parent menu. Only actions that actually launch an SSH/SCP connection close the plugin. - -> **Display order** — `ssh profiles` always shows results in a fixed, stable order regardless of fuzzy-match scoring: -> 1. **Profile management** (usage hint, always pinned at the top) -> 2. **← Back to ssh** (back-navigation row — press Enter to return to the top-level command list) -> 3. **Action rows** as one continuous block: Add profile → Remove profile → Rename profile → Copy SSH command → Export profiles → Import profiles -> 4. **Saved profiles** (filtered / sorted by relevance when a search term is given) - -Sub-command views (e.g. `ssh profiles add`, `ssh shell remove`) and single-action views (`ssh config`, `ssh help`) also show a back-navigation row immediately below their usage hint so you can press Enter to return to the parent level. - -### Add a profile - -Profiles are added using standard SSH or SCP command syntax. -The plugin parses the command into a structured profile automatically. - -**SSH — basic login:** -``` -ssh profiles add myserver ssh root@10.0.0.150 -ssh profiles add dev-box ssh -p 2222 dev@10.0.0.50 -``` - -**SSH — with identity file:** -``` -ssh profiles add production ssh -i "C:\Users\me\.ssh\id_rsa" -o IdentitiesOnly=yes admin@prod.example.com -``` - -**SSH — run a remote command:** -``` -ssh profiles add reboot-proxmox ssh -t -t root@10.0.0.150 reboot -``` - -**SSH — local port forward (tunnel):** -``` -ssh profiles add pangolin-tunnel ssh -L 8443:127.0.0.1:443 -L 8080:127.0.0.1:80 root@10.100.100.242 -``` - -**SSH — SOCKS proxy:** -``` -ssh profiles add socks-proxy ssh -D 1080 root@jump.example.com -``` - -**SSH — ProxyJump:** -``` -ssh profiles add internal-host ssh -J bastion.example.com root@10.0.0.10 -``` - -**SCP — upload a file:** -``` -ssh profiles add upload-index scp -i "~/.ssh/key" "C:\web\index.html" root@10.0.0.1:/var/www/html/index.html -``` - -You can also omit the `ssh ` prefix — the plugin adds it automatically: -``` -ssh profiles add bastion admin@bastion.example.com -p 22222 -``` - -### Remove a profile - -``` -ssh profiles remove → list all profiles for removal -ssh profiles remove prod → filter profiles by "prod", then click to delete -``` - -### Rename a profile - -``` -ssh profiles rename → list all profiles to select for renaming -ssh profiles rename myserver → pick "myserver" as the source -ssh profiles rename myserver new-name → rename "myserver" to "new-name" -``` - -### Copy an SSH command to the clipboard - -``` -ssh profiles copy → list all profiles for copying -ssh profiles copy myserver → filter by "myserver", then click to copy -``` - -> **Copied command format:** The clipboard receives a user-friendly, paste-ready command with single backslashes for Windows paths. Arguments are quoted only when needed (e.g. paths containing spaces). Examples: -> - `ssh -i C:\Users\info\.ssh\key root@10.0.0.150` — no quotes (path without spaces) -> - `ssh -i "C:\Users\info\My Keys\key" root@10.0.0.150` — quoted (path with spaces) - -### SSH key management - -Register SSH keys by alias so you can quickly reference them in direct connect or profile creation: - -``` -ssh keys → key management view: action rows + registered keys -ssh keys add prod ~/.ssh/id_ed25519 → register key alias "prod" -ssh keys add dev "C:\Users\me\.ssh\dev_key" → register key alias "dev" (quoted path) -ssh keys remove prod → remove key alias "prod" (registry only — files on disk are kept) -``` - -> **Display order** — `ssh keys` always shows action rows in a fixed, stable order: **install** → **add** → **generate** → **remove** → **rename** → **copy-path** → **copy-pub** → **scan**, followed by registered key entries. - -> **Security note:** QuickSSH stores only the alias and the file path — **never** the private key content. The key file is accessed by SSH at connection time, not by the plugin. - -> **Key file validation:** When browsing registered keys, QuickSSH checks whether the key file exists on disk and shows a warning icon if it is missing. - -> **Post-action feedback:** All management actions (add, remove, rename, copy, export, import, generate, install, scan, config import) keep Flow Launcher open and return to the parent menu so you can see the updated state and continue working. Clipboard actions (profiles copy, keys copy-path, keys copy-pub) stay inside their submenu. The `keys install` "Run remote setup command" action opens a terminal and closes Flow Launcher. The `keys remove` command only removes the alias from the registry — key files on disk are never deleted. - -### Generate an SSH keypair - -Generate a new SSH keypair locally and auto-register it in the key registry: +[![Latest release](https://img.shields.io/github/v/release/Vaso73/Flow.Launcher.Plugin.QuickSSH)](https://github.com/Vaso73/Flow.Launcher.Plugin.QuickSSH/releases/latest) +[![Flow Launcher](https://img.shields.io/badge/Flow%20Launcher-plugin-2ea44f)](https://www.flowlauncher.com/) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -``` -ssh keys generate → usage hint -ssh keys generate mykey → shows actionable rows: - ● Generate ed25519 (recommended default) - ● Generate RSA 4096 - ● Custom path… (hint row) -ssh keys generate mykey C:\keys\mykey → custom path flow: - ● Generate ed25519 → C:\keys\mykey - ● Generate RSA 4096 → C:\keys\mykey -``` - -**Row-driven UX:** After typing the alias, you choose the algorithm by clicking a row — no need to type `ed25519` or `rsa` as arguments. To use a custom output path, append it after the alias. - -**Default behaviour:** -- **Algorithm:** ed25519 (recommended). RSA 4096 is available as an alternative row. -- **Output path:** `%USERPROFILE%\.ssh\` — the file name is derived from the alias with unsafe characters removed. -- **Custom path:** Append a path after the alias to generate the keypair at a custom location (e.g. `ssh keys generate mykey D:\keys\mykey`). Quoted paths with spaces are supported (e.g. `ssh keys generate mykey "C:\My Keys\mykey"`). The alias and path are separated using Unicode-aware whitespace parsing, so the custom path is never mangled through alias sanitisation. -- **Passphrase:** Not supported in this version — keys are generated with an empty passphrase (`-N ""`). Interactive passphrase support will be added in a future release. - -**What happens on click:** -1. `ssh-keygen` runs non-interactively in the background (no terminal window). -2. QuickSSH verifies that **both** the private key and `.pub` file were created. -3. If both files exist → the key is auto-registered with metadata (alias, path, algorithm, source, timestamp). -4. If either file is missing (failed) → nothing is registered. -5. On success, a confirmation message shows the alias, private key path, and public key path. Flow Launcher stays open and returns to the `ssh keys` menu. - -**Validations:** -- Empty alias → usage hint shown -- Duplicate alias → error: alias already exists -- Target key file already exists → error: file already exists -- Custom path is an existing directory → error: path is a directory -- Custom path contains invalid characters → error: invalid path -- ssh-keygen not found → error: install OpenSSH -- Generation failed → no registration - -> **Storage:** Only harmless metadata is stored in the key registry: alias, path, public key path, algorithm, source (`"generated"`), and creation timestamp. Private key content and passphrases are **never** stored. - -> **Passphrase flow:** Intentionally deferred. Launching an interactive terminal from a Flow Launcher plugin and waiting for completion has not been runtime-verified. This will be addressed in a follow-up PR. - -### Install public key on a remote host - -Deploy a registered public key to a remote Linux host's `~/.ssh/authorized_keys`: - -``` -ssh keys install → list registered keys (select one) -ssh keys install mykey → prompt for user@host -ssh keys install mykey admin@10.0.0.1 → shows 3 action rows: - ● Run remote setup command (opens terminal) - ● Copy remote setup command (clipboard) - ● Copy public key (clipboard) -``` - -**Available actions:** -- **Run remote setup command** — opens a terminal and runs `ssh user@host ''`. The user enters their password in the terminal. -- **Copy remote setup command** — copies the full `ssh user@host '...'` command to the clipboard. -- **Copy public key** — copies the `.pub` file content to the clipboard. - -**Remote bootstrap command:** The plugin builds an idempotent one-liner that: -1. Sets `umask 077` for secure permissions -2. Creates `~/.ssh` and `authorized_keys` if missing -3. Fixes permissions (`chmod 700`/`600`) -4. Checks whether the key is already present (`grep -qxF`) -5. Appends the key only if not already present (`printf` — no `echo`) - -**Public key validation:** The `.pub` file content must start with a known key type (`ssh-ed25519`, `ssh-rsa`, `ecdsa-sha2-*`, etc.) followed by base64 data. Everything after the second token is treated as an optional comment. Single quotes, newlines, and null bytes are rejected to prevent shell injection. - -**Security note:** Only the public key is handled. No private keys are transmitted. No `sshd_config` changes are made on the remote host. The user enters their password directly in the SSH terminal. +QuickSSH is a Flow Launcher plugin for connecting to saved SSH and SCP profiles, running reusable remote actions, managing SSH keys, importing OpenSSH configuration, and choosing custom terminal shells. -### Rename a key alias +Type `ssh` to open a focused menu with **Profiles**, **Actions**, **Tools**, and **Help**. -``` -ssh keys rename → list all key aliases for renaming -ssh keys rename prod → pick "prod" as the source -ssh keys rename prod production → rename "prod" to "production" -``` - -Duplicate alias names are validated — renaming to an existing alias shows an error. +![QuickSSH main menu](.github/assets/quickssh/main-menu.png) -### Copy key path to clipboard +## Highlights -``` -ssh keys copy-path → list all keys for path copying -ssh keys copy-path prod → copy the private key file path for "prod" -``` +- Guided SSH profile setup with server, port, and authentication steps +- Full SSH/SCP command support for tunnels, ProxyJump, remote commands, and file transfers +- Reusable remote actions with mandatory review and confirmation before execution +- SSH key registration, generation, discovery, clipboard helpers, and public-key installation +- Human-readable profile export/import in an SSH-config-like format +- Portable-aware storage inside the Flow Launcher plugin settings directory +- Custom terminal shells with fail-closed launch behavior +- Accent-insensitive fuzzy search and localized navigation +- English, German, Spanish, French, Polish, Russian, and Slovak interfaces -### Copy public key content to clipboard +## Installation -``` -ssh keys copy-pub → list all keys for public key copying -ssh keys copy-pub prod → copy the content of prod's .pub file -``` +### Flow Launcher Plugin Store -If the `.pub` file does not exist (e.g. the key was generated without a public counterpart), an error row is shown instead of the copy action. +1. Open Flow Launcher. +2. Type `pm install QuickSSH`. +3. Install the plugin and restart Flow Launcher when prompted. +4. Type `ssh`. -The public key path is derived as `.pub` by default, or from the explicit `PublicKeyPath` field if set. +### Manual installation -### Scan for key files +1. Download `QuickSSH.zip` from the [latest release](https://github.com/Vaso73/Flow.Launcher.Plugin.QuickSSH/releases/latest). +2. Extract it as a single plugin folder inside Flow Launcher's user plugin directory. +3. Restart Flow Launcher. +4. Type `ssh`. -``` -ssh keys scan → scan ~/.ssh/ for key files -``` +## Requirements -Scans `%USERPROFILE%\.ssh\` for private key files. Filters out: -- `.pub` files (public keys) -- `known_hosts`, `known_hosts.old`, `config`, `authorized_keys`, `environment` -- Files with `.log`, `.bak`, `.tmp`, `.old` extensions +- Windows +- [Flow Launcher](https://www.flowlauncher.com/) +- OpenSSH Client (`ssh.exe`) available in `PATH` +- `ssh-keygen` only when generating a new keypair + +## Quick start + +| Command | Purpose | +|---|---| +| `ssh` | Open the main QuickSSH menu | +| `ssh profiles` | Browse, search, connect, or manage saved profiles | +| `ssh actions` | Run or manage reusable remote commands | +| `ssh tools` | Open SSH keys, shell, and configuration tools | +| `ssh keys` | Use or manage SSH key aliases | +| `ssh shell` | Select or manage the terminal shell | +| `ssh config` | Import hosts from `~/.ssh/config` | +| `ssh help` | Open this documentation | +| `ssh user@example.com` | Start a one-time SSH connection | +| `ssh -p 2222 user@example.com` | Start a one-time connection on another port | +| `ssh -i "~/.ssh/private_key" user@example.com` | Start a one-time connection with an identity file | -Each discovered key file is shown as a candidate — click to register it with the file name as the alias. Already-registered keys are marked as "(already registered)". +QuickSSH commands remain in English in every localized interface. Press **Enter** on menu rows to navigate or perform the displayed action. -#### Identity file autocomplete (`-i`) +## Profiles -When typing a direct SSH command with `-i`, registered keys are offered as autocomplete suggestions: +Profiles store structured SSH or SCP connection settings. The default guided flow asks for: -``` -ssh -i → shows all registered key aliases -ssh -i ~/.ssh/pr → shows matching key aliases (e.g. "prod") -``` +1. a profile name, +2. `user@host` or `host`, +3. port `22` or another value from `1` to `65535`, +4. a saved private key, or SSH agent/configuration. -Select a key alias to fill in the full path automatically, then continue typing the destination. The inserted path uses normal Windows backslashes (e.g. `ssh -i "C:\Users\me\.ssh\key"`) — paths containing spaces are quoted automatically. +Port `22` is treated as the default and is not stored explicitly. A selected identity file must exist and be recognized by content as a private SSH key. Public keys are rejected even when the file name does not end in `.pub`. -### Quick one-time connection (without saving) +![QuickSSH profiles](.github/assets/quickssh/profiles.png) -Type a destination or SSH options directly — the plugin detects these automatically: +Common examples: -``` -ssh root@10.0.0.1 -ssh -p 2222 deploy@staging.example.com -ssh -i "C:\Users\me\.ssh\private_key" -o IdentitiesOnly=yes root@10.100.100.110 -ssh 10.100.100.110 +```text +ssh profiles add demo-server demo@example.com +ssh profiles add staging admin@192.0.2.10 +ssh profiles add custom-port tester@203.0.113.25 ``` -**Implicit detection rules** — input is treated as a direct connect when it: -- contains `@` (e.g. `user@host`, `root@10.0.0.1`) -- starts with `-` (e.g. `-p 22 user@host`, `-i key user@host`) -- is a bare hostname or IP with at least one dot (e.g. `10.0.0.1`, `myserver.example.com`) +For tunnels, SCP, ProxyJump, remote commands, or other advanced options, use the full-command path: -### Import hosts from `~/.ssh/config` - -``` -ssh config → import all Host entries from ~/.ssh/config +```text +ssh profiles add staging ssh -p 2222 admin@192.0.2.10 +ssh profiles add production ssh -i "~/.ssh/private_key" -o IdentitiesOnly=yes deploy@example.com +ssh profiles add tunnel ssh -L 8443:127.0.0.1:443 user@example.com +ssh profiles add internal ssh -J bastion.example.com user@192.0.2.20 +ssh profiles add upload scp "C:\example\index.html" user@example.com:/var/www/html/index.html ``` -Only new hosts are imported — existing profiles are not overwritten. +**Manage profiles** groups the regular maintenance actions: -The parser captures: `HostName`, `User`, `Port`, `IdentityFile`, `IdentitiesOnly`, -`LocalForward`, `RemoteForward`, `DynamicForward`, `ProxyJump`, `ProxyCommand`. +- add, +- rename, +- copy SSH command, +- export, +- import, +- remove. -Example `~/.ssh/config` that is fully supported: +Removal requires confirmation. Rename and add operations never silently overwrite an existing name. -``` -Host proxmox - HostName 10.0.0.150 - User root - Port 22 - IdentityFile ~/.ssh/id_ed25519 - -Host production - HostName prod.example.com - User deploy - Port 2222 - IdentityFile ~/.ssh/id_ed25519 - IdentitiesOnly yes - -Host bastion - HostName bastion.corp.internal - User ec2-user - IdentityFile "C:\Users\me\.ssh\corp_key" - -Host internal - HostName 10.0.0.10 - ProxyJump bastion -``` +### Export and import -Wildcard entries (`Host *`) are skipped automatically. +`ssh profiles export` writes a human-readable `.sshconfig` file to the plugin data directory. -### Export and import profiles +`ssh profiles import` accepts: -**Export** — saves all current profiles to a human-readable `.sshconfig` file: +- `.sshconfig` exports, +- legacy `.json` profile dictionaries for migration. -``` -ssh profiles export -``` +Before importing, QuickSSH creates `profiles.json.import.bak` next to the active database. Existing profile names are skipped rather than overwritten. The result reports imported and skipped counts, and a failed save restores both the previous file and the in-memory state. -The file is written to: -``` -%APPDATA%\FlowLauncher\Plugins\QuickSSH\data\profiles_export.sshconfig -``` +## Remote actions -**Import** — loads profiles from any `.sshconfig` file (or legacy `.json` file) placed in the `data\` folder: +Actions are named, reusable single-line commands such as: -``` -ssh profiles import → list all importable files -ssh profiles import mybackup → filter files containing "mybackup" +```text +hostname +uptime +systemctl status nginx ``` -Only profiles that do not already exist are added (no overwriting). +The normal flow is: -### Saved profile format (`.sshconfig`) +1. choose a saved action, +2. choose an SSH profile, +3. review the action and generated SSH command, +4. run it or copy the command. -Profiles are exported in a human-readable SSH-config-like format. -This format is intentionally similar to OpenSSH `ssh_config(5)` but is **not** a strict clone — -it adds QuickSSH-specific fields like `Type`, `RemoteCommand`, `RequestTTY`, `Source`, `Target`, etc. +![QuickSSH action confirmation](.github/assets/quickssh/action-confirmation.png) -**SSH profile — normal login:** -``` -Host Proxmox-Host - Type ssh - HostName 10.0.0.150 - User root - Port 22 - IdentityFile ~/.ssh/private_key - IdentitiesOnly yes -``` - -**SSH profile — remote command with TTY:** -``` -Host RustDesk-REBOOT - Type ssh - HostName 10.100.100.110 - User root - Port 22 - IdentityFile "C:\Users\info\.ssh\private_key" - IdentitiesOnly yes - RemoteCommand reboot - RequestTTY force -``` - -**SSH profile — local port forwards (tunnel):** -``` -Host Pangolin-Tunnel - Type ssh - HostName 10.100.100.242 - User root - Port 22 - IdentityFile ~/.ssh/private_key - IdentitiesOnly yes - LocalForward 8443 127.0.0.1:443 - LocalForward 8080 127.0.0.1:80 -``` - -**SCP profile — file upload:** -``` -Host Homepage-Upload - Type scp - HostName 10.100.100.241 - User root - Port 22 - IdentityFile "C:\Users\info\.ssh\private_key" - IdentitiesOnly yes - Source "C:\web\index.html" - Target "/var/www/html/index.html" -``` +Every action execution requires explicit confirmation. QuickSSH does not support disabling confirmation per action. SCP profiles cannot run remote actions. -### Supported profile fields +Action names can be added, renamed, and removed under **Manage actions**. Action text must be a single line. Null bytes, line breaks, and recognizable private-key payload markers are rejected. -**Common fields (SSH and SCP):** +> QuickSSH executes user-provided remote commands as entered. Review every command and never store passwords, tokens, private keys, or other secrets in an action. -| Field | Description | -|-------|-------------| -| `Type` | `ssh` (default) or `scp` | -| `HostName` | Hostname or IP address | -| `User` | Remote user name | -| `Port` | Port number (omitted from command when 22) | -| `IdentityFile` | Path to private key file | -| `IdentitiesOnly` | `yes` adds `-o IdentitiesOnly=yes` | -| `ExtraArgs` | Raw extra arguments (fallback for unparsed flags) | +## SSH keys -**SSH-specific fields:** +QuickSSH stores key aliases and file metadata, never private-key content. -| Field | Description | -|-------|-------------| -| `RemoteCommand` | Command to execute on the remote host | -| `RequestTTY` | TTY allocation: `force` (-t -t), `yes` (-t), `no` (-T) | -| `LocalForward` | Local port forward spec, e.g. `8443 127.0.0.1:443` (repeatable) | -| `RemoteForward` | Remote port forward spec (repeatable) | -| `DynamicForward` | SOCKS5 proxy port, e.g. `1080` | -| `ProxyJump` | Jump host(s) for `-J` | -| `ProxyCommand` | Proxy command string | +Available operations include: -**SCP-specific fields:** +- register an existing private key, +- generate an Ed25519 or RSA 4096 keypair, +- scan `~/.ssh` for existing private keys, +- rename or remove a saved alias, +- copy a private-key path, +- copy public-key content, +- install a public key on a remote Linux host. -| Field | Description | -|-------|-------------| -| `Source` | **Bare source path** — local path for upload, remote path for download | -| `Target` | **Bare target path** — remote path for upload, local path for download | -| `Recursive` | `yes` adds `-r` | -| `PreserveTimes` | `yes` adds `-p` | -| `Compression` | `yes` adds `-C` | +Generated keypairs are created non-interactively without a passphrase. QuickSSH verifies that both private and public files exist before registering the new key. -**SCP normalization rule:** -`Source` and `Target` always store **bare paths** — no `user@host:` prefix. -`HostName` and `User` are always in the common structured fields. -The command builder determines transfer direction by inspecting the paths: +Removing a saved key removes only the QuickSSH entry. The key files remain unchanged. -- **Upload** — `Source` is a Windows local path (e.g. `C:\...`): builds `scp source user@host:target` -- **Download** — `Target` is a Windows local path: builds `scp user@host:source target` -- **Ambiguous** (both are Unix-style paths): upload is assumed, Source is treated as local +Public-key installation appends the key to `~/.ssh/authorized_keys` only when it is not already present. It does not transmit private-key content and does not modify `sshd_config`. -This means on-disk profiles are always portable and can be re-parsed without data loss. -Legacy SCP commands with `user@host:path` positionals are automatically normalised on import. +## Shells -### Custom shell management +QuickSSH uses `cmd.exe` when no custom shell is selected. You can register and select another executable, including optional arguments: -``` -ssh shell → shell management view: action rows + saved shells -ssh shell add PowerShell → add PowerShell (found via PATH) +```text +ssh shell add PowerShell pwsh.exe -NoLogo ssh shell add GitBash "C:\Program Files\Git\bin\bash.exe" --login -i -c ssh shell add WSL wsl.exe -- -ssh shell add WindowsTerminal wt.exe ssh -ssh shell remove PowerShell → remove a shell entry +ssh shell manage ``` -**Shell value format** — `ssh shell add [ [extra-args]]`: +Quoted executable paths are supported. -| Example | Effect | -|---------|--------| -| `ssh shell add PowerShell` | Name = value = `PowerShell`; resolved via PATH | -| `ssh shell add PS "C:\...\pwsh.exe"` | Name `PS`, explicit exe path, no extra args | -| `ssh shell add GitBash "C:\...\bash.exe" -c` | Name `GitBash`, exe + `-c` flag prepended to command | +A selected custom shell is exclusive. When its definition is invalid, the executable is missing, or startup fails, QuickSSH shows an error and does not retry the command through `cmd.exe` or another shell. Deselect the custom shell to return to the default. -Click any shell in the list to **select** it. All SSH connections will then launch through that shell. Click it again to deselect (returns to default `cmd.exe`). +## SSH configuration import ---- +`ssh config` imports supported hosts from `~/.ssh/config`. -## Data Storage +Supported fields include: -QuickSSH stores its main database in Flow Launcher's per-plugin settings directory. -In portable Flow Launcher builds this stays inside the portable `UserData` tree: +- `HostName` +- `User` +- `Port` +- `IdentityFile` +- `IdentitiesOnly` +- `LocalForward` +- `RemoteForward` +- `DynamicForward` +- `ProxyJump` +- `ProxyCommand` -``` -\UserData\Settings\Plugins\Flow.Launcher.Plugin.QuickSSH\profiles.json -``` +Wildcard entries such as `Host *` are skipped. Existing profile names are preserved. -On first startup after upgrading, an existing legacy `~/.ssh/profiles.json` file is copied -to the new location only when the new database does not already exist. The legacy file is -left untouched. - -| File | Purpose | -|------|---------| -| `\profiles.json` | Main profile, shell, and key database (v2 structured JSON) | -| `~/.ssh/profiles.json` | Legacy database path, copied once during upgrade when needed | -| `%APPDATA%\FlowLauncher\Plugins\QuickSSH\data\*.sshconfig` | Human-readable export/import files | -| `%APPDATA%\FlowLauncher\Plugins\QuickSSH\data\*.json` | Legacy import files (v1, still readable) | - -### profiles.json schema (v2) - -```json -{ - "PluginVersion": "2.0", - "ProfilesLists": { - "": { - "Type": "ssh", - "HostName": "10.0.0.150", - "User": "root", - "Port": "22", - "IdentityFile": "~/.ssh/private_key", - "IdentitiesOnly": true, - "RemoteCommand": "reboot", - "RequestTTY": "force" - } - }, - "CustomShellLists": { - "": "" - }, - "SelectedCustomShell": "", - "SshKeysLists": { - "": { - "Path": "C:\\Users\\me\\.ssh\\id_ed25519", - "PublicKeyPath": "C:\\Users\\me\\.ssh\\id_ed25519.pub", - "Fingerprint": "SHA256:...", - "Comment": "user@host", - "Description": "optional description", - "Algorithm": "ed25519", - "Source": "generated", - "CreatedAt": "2025-01-15T10:30:00.0000000Z" - } - } -} -``` +## Data and portability -### Migration from v1 - -**v1 profiles.json** stored profiles as raw SSH command strings: -```json -{ - "PluginVersion": "1.0", - "EntriesLists": { - "myserver": "ssh user@192.168.1.100", - "production": "ssh -i \"C:\\...\\id_rsa\" admin@prod -p 2222" - } -} -``` +QuickSSH stores `profiles.json` in the Flow Launcher plugin settings directory. The database contains: -**On first load**, QuickSSH automatically: -1. Parses each raw command string into a structured `SshProfile` -2. Stores them in the new `ProfilesLists` format -3. Clears the legacy `EntriesLists` field from memory -4. **Immediately persists the v2 format** so the disk file is canonical after the first run +- SSH/SCP profiles, +- reusable actions, +- custom shells and the selected shell, +- SSH key aliases and metadata. -Migration handles: -- Simple `ssh user@host` → structured with `User`, `HostName` -- `ssh -p 22 user@host` → structured with `Port`, `User`, `HostName` -- `ssh -i key -o IdentitiesOnly=yes user@host` → structured with all fields -- Remote commands after the destination → `RemoteCommand` field -- Unknown/unsupported flags (e.g. `-X`, `-A`) → stored verbatim in `ExtraArgs` -- SCP upload `scp C:\file.txt user@host:/path` → `Source` = local bare path, `Target` = remote bare path, `User`/`HostName` extracted safely (Windows drive paths never misidentified as remote specs) -- SCP download `scp user@host:/remote/file C:\local\file` → `Source` = remote bare path, `Target` = local bare path +Installed and portable Flow Launcher environments therefore keep QuickSSH data in their own settings tree. When the new database does not yet exist, QuickSSH can copy a legacy `~/.ssh/profiles.json` once into the active plugin settings directory. -**Unparseable flag fallback (ExtraArgs):** SSH has many options. Flags that this plugin does not map to a named structured field are preserved verbatim in the `ExtraArgs` field and appended to the generated command. This means: -- **No data is silently lost** during migration. -- The `ExtraArgs` field is round-trip stable: it is included when exporting to `.sshconfig` and is read back unchanged on import. -- The generated SSH command still includes the flag, so the connection behaviour is preserved. +Database writes are atomic. Export and import files are stored in the plugin's `data` directory. -> **Note:** "profiles import" still accepts legacy `.json` files for backward-compatible migration. -> JSON is **never written** by this plugin — `.sshconfig` is the canonical export format. -> Legacy `.json` files are clearly labelled "(legacy)" in the import UI. +## Search and navigation ---- +- Bare `ssh` shows **Profiles**, **Actions**, **Tools**, and **Help**. +- **Tools** groups SSH keys, shell selection, and SSH config import. +- Direct expert commands such as `ssh keys`, `ssh shell`, and `ssh config` remain available. +- Saved profiles and actions can be filtered by name or displayed text. +- Search is accent-insensitive and supports fuzzy matching. +- Every submenu starts with a **Back** row. +- Menu icons use consistent semantics: green for saved/run/add operations, orange for rename/edit, red for remove/errors, and blue for navigation or neutral operations. -## Installation +## Command reference -### From Flow Launcher (after plugin is published) +
+Profiles -1. Open Flow Launcher -2. Type `pm install QuickSSH` -3. Restart Flow Launcher +| Command | Purpose | +|---|---| +| `ssh profiles [filter]` | Browse or filter saved profiles | +| `ssh profiles add` | Open guided profile creation | +| `ssh profiles manage` | Open profile management | +| `ssh profiles rename` | Rename a profile | +| `ssh profiles copy` | Copy a generated SSH/SCP command | +| `ssh profiles export` | Export profiles to `.sshconfig` | +| `ssh profiles import` | Import `.sshconfig` or legacy `.json` profiles | +| `ssh profiles remove` | Select and confirm profile removal | -### Manual Installation +
-1. Download `QuickSSH.zip` from [Releases](https://github.com/Vaso73/Flow.Launcher.Plugin.QuickSSH/releases) -2. Extract the zip into a new folder: - ``` - %APPDATA%\FlowLauncher\Plugins\QuickSSH\ - ``` -3. Restart Flow Launcher -4. Type `ssh` to verify the plugin loaded +
+Actions -## Building from Source +| Command | Purpose | +|---|---| +| `ssh actions [filter]` | Browse or filter saved actions | +| `ssh actions add` | Save a new single-line remote command | +| `ssh actions manage` | Open action management | +| `ssh actions rename` | Rename an action | +| `ssh actions remove` | Select and confirm action removal | +| `ssh actions run` | Compatibility route: profile first, then action | -Requires Windows with .NET 9.0 SDK installed. +
-```powershell -# Clone the repository -git clone https://github.com/Vaso73/Flow.Launcher.Plugin.QuickSSH.git -cd Flow.Launcher.Plugin.QuickSSH +
+SSH keys -# Build -dotnet publish -c Release -r win-x64 --no-self-contained +| Command | Purpose | +|---|---| +| `ssh keys` | Browse saved keys and key operations | +| `ssh keys install` | Install a selected public key remotely | +| `ssh keys manage` | Open key management | +| `ssh keys add` | Register an existing private key | +| `ssh keys generate` | Generate and register a new keypair | +| `ssh keys scan` | Find private-key candidates in `~/.ssh` | +| `ssh keys rename` | Rename a key alias | +| `ssh keys copy-path` | Copy a private-key path | +| `ssh keys copy-pub` | Copy public-key content | +| `ssh keys remove` | Remove only the saved alias | -# Output: bin\Release\win-x64\publish\ -# Copy that folder's contents to: -# %APPDATA%\FlowLauncher\Plugins\QuickSSH\ -``` +
-## Requirements +
+Shell and tools -- Windows 10 version 1809+ or Windows Server 2019+ (built-in OpenSSH) - — or any Windows with `ssh.exe` available in PATH -- [Flow Launcher](https://www.flowlauncher.com/) v1.19+ -- .NET 9.0 Runtime (bundled with Flow Launcher v1.19+) +| Command | Purpose | +|---|---| +| `ssh tools` | Open keys, shell, and configuration tools | +| `ssh shell` | Select a saved shell | +| `ssh shell manage` | Open shell management | +| `ssh shell add` | Register a shell executable and optional arguments | +| `ssh shell remove` | Remove a saved shell entry | +| `ssh config` | Import `~/.ssh/config` | +| `ssh help` | Open documentation | + +
## Languages -| Code | Language | -|------|----------| -| `en` | English | -| `sk` | Slovak (Slovenčina) | -| `fr` | French (Français) | -| `de` | German (Deutsch) | -| `ru` | Russian (Русский) | -| `pl` | Polish (Polski) | -| `es` | Spanish (Español) | +- English +- German +- Spanish +- French +- Polish +- Russian +- Slovak -Flow Launcher automatically selects the language that matches your system locale. +Flow Launcher selects the matching interface language from the current locale. -## Publishing to Flow Launcher Plugin Store +## Troubleshooting -To make QuickSSH available via `pm install QuickSSH` in Flow Launcher: +### OpenSSH client not found -1. **Create a GitHub Release** — open a Pull Request in this repository, update `plugin.json` with the desired version, add one label (`release:patch`, `release:minor`, or `release:major`), and merge it into `main`. GitHub Actions will automatically build `QuickSSH.zip`, create a tag, and publish the release. +Install the Windows OpenSSH Client optional feature and confirm that `ssh.exe` is available in `PATH`. -2. **Fork the Plugin Manifest** — fork [Flow-Launcher/Flow.Launcher.PluginsManifest](https://github.com/Flow-Launcher/Flow.Launcher.PluginsManifest). +### A key cannot be selected for a profile -3. **Add a manifest entry** — in your fork create the file: +QuickSSH accepts only existing files recognized as private SSH keys. Select the corresponding private key rather than a `.pub` file. - ``` - plugins/QuickSSH-86AC23FE48BC45E5B7E0A94F5847FA83.json - ``` +### A custom shell does not start - with the following content (update `UrlDownload` to point to the latest release zip): +Verify the executable path and arguments. QuickSSH deliberately does not fall back to another shell after a selected-shell failure. - ```json - { - "ID": "86AC23FE48BC45E5B7E0A94F5847FA83", - "Name": "QuickSSH", - "Description": "Enhanced SSH/SCP connection plugin with query autocomplete, structured profiles, SSH config support, and custom shell handling", - "Author": "Vaso73", - "Version": "3.4.0", - "Language": "csharp", - "MinFlowLauncherVersion": "1.19.0", - "Website": "https://github.com/Vaso73/Flow.Launcher.Plugin.QuickSSH", - "UrlSourceCode": "https://github.com/Vaso73/Flow.Launcher.Plugin.QuickSSH", - "UrlDownload": "https://github.com/Vaso73/Flow.Launcher.Plugin.QuickSSH/releases/latest/download/QuickSSH.zip", - "IcoPath": "https://raw.githubusercontent.com/Vaso73/Flow.Launcher.Plugin.QuickSSH/main/Images/app.png" - } - ``` +### Import files are not listed -4. **Open a Pull Request** — submit the PR to `Flow-Launcher/Flow.Launcher.PluginsManifest`. Once merged the plugin becomes available in the Flow Launcher store. +Place `.sshconfig` or legacy `.json` files in the QuickSSH plugin `data` directory, then reopen `ssh profiles import`. -## Contributing - -Contributions are welcome! Here is the typical workflow: - -1. Fork the repository and create a feature branch. -2. Make your changes. -3. Run a local build to verify nothing is broken: - ```powershell - dotnet publish -c Release -r win-x64 --no-self-contained - ``` -4. If the Pull Request should create a release, add one label: `release:patch`, `release:minor`, or `release:major`. -5. Open a Pull Request describing your changes. - -### Versioning +### Icons are blank after a manual replacement -`plugin.json` is the **single source of truth** for the plugin version. There is no separate version in the project file or any other location. GitHub tags and release names always match the version committed in `plugin.json`. +Restart Flow Launcher so it reloads the plugin assets. -### Releasing a new version - -1. Open a Pull Request with your changes. -2. **Update `plugin.json`** — set `Version` to the desired new version (e.g. `"3.0.1"` for a patch, `"3.1.0"` for a minor, `"4.0.0"` for a major). This must be done in the PR itself before merge — the workflow reads whatever version is already in `plugin.json` on `main` after the merge. -3. Add **exactly one** release label: `release:patch`, `release:minor`, or `release:major` (or `skip-release` to skip the release entirely). -4. Merge the Pull Request into `main`. -5. GitHub Actions automatically: - - Validates that exactly one release label is present - - Reads the current version from `plugin.json` on `main` - - Builds `QuickSSH.zip` - - Creates a matching git tag and GitHub Release -6. Update the Plugin Manifest entry in `Flow-Launcher/Flow.Launcher.PluginsManifest` if needed. - -`plugin.json` is the **single source of truth** for the plugin version. The version must already be correct in the PR — the workflow does **not** modify `main` after merge. This is required because `main` is a protected branch and post-merge pushes from CI would be rejected. +## Contributing -> **Note:** `skip-release` prevents the entire release job from running. It is different from a missing label: a PR without any label at all that is merged to `main` will cause the release job to **fail** with a clear error. Always include either a release label or `skip-release`. +Issues and pull requests are welcome. User-facing behavior changes should include corresponding public README updates. ## License -[MIT](LICENSE) +QuickSSH is released under the [MIT License](LICENSE). + +QuickSSH was inspired by [Melv1no/Flow.Launcher.Plugin.easyssh](https://github.com/Melv1no/Flow.Launcher.Plugin.easyssh). diff --git a/RemoteKeyInstallBuilder.cs b/RemoteKeyInstallBuilder.cs index 4227b8c..b929c2d 100644 --- a/RemoteKeyInstallBuilder.cs +++ b/RemoteKeyInstallBuilder.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text.RegularExpressions; namespace Flow.Launcher.Plugin.QuickSSH { @@ -7,7 +8,7 @@ namespace Flow.Launcher.Plugin.QuickSSH /// Builds SSH commands for deploying a public key to a remote host's /// ~/.ssh/authorized_keys file. /// - public static class RemoteKeyInstallBuilder + public static partial class RemoteKeyInstallBuilder { /// /// Key type prefixes that are accepted in public key validation. @@ -23,6 +24,14 @@ public static class RemoteKeyInstallBuilder "sk-ecdsa-sha2-nistp256@openssh.com" }; + private const string UserAtHostPatternText = + @"\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}@" + + @"[A-Za-z0-9](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9])?" + + @"(?:\.[A-Za-z0-9](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9])?)*\z"; + + [GeneratedRegex(UserAtHostPatternText, RegexOptions.CultureInvariant)] + private static partial Regex UserAtHostRegex(); + /// /// Validates that is a safe, well-formed public /// key line suitable for embedding in a remote shell command. @@ -37,7 +46,7 @@ public static class RemoteKeyInstallBuilder /// (\0). /// /// - public static bool ValidatePublicKeyLine(string line) + public static bool ValidatePublicKeyLine(string? line) { if (string.IsNullOrEmpty(line)) return false; @@ -160,6 +169,9 @@ public static string BuildBootstrapCommand(string publicKeyLine) /// public static string BuildFullSshCommand(string userAtHost, string bootstrapCommand) { + if (!IsValidUserAtHost(userAtHost)) + throw new ArgumentException("Destination must be a safe user@host value.", nameof(userAtHost)); + return "ssh " + userAtHost + " \"" + bootstrapCommand + "\""; } @@ -190,23 +202,19 @@ public static string BuildRunCommand(string userAtHost, string bootstrapCommand) /// /// Returns when looks like - /// a valid user@host destination (contains exactly one @ with - /// non-empty parts on both sides). + /// a valid user@host destination. The destination is later passed + /// through cmd.exe /k, so this intentionally uses a strict allowlist + /// instead of trying to reject individual shell metacharacters. /// - public static bool IsValidUserAtHost(string input) + public static bool IsValidUserAtHost(string? input) { - if (string.IsNullOrEmpty(input)) + if (string.IsNullOrWhiteSpace(input)) return false; - var atIndex = input.IndexOf('@'); - if (atIndex <= 0 || atIndex >= input.Length - 1) + if (!string.Equals(input, input.Trim(), StringComparison.Ordinal)) return false; - // Must not contain spaces. - if (input.IndexOf(' ') >= 0) - return false; - - return true; + return UserAtHostRegex().IsMatch(input); } } } diff --git a/SearchMatcher.cs b/SearchMatcher.cs new file mode 100644 index 0000000..468555c --- /dev/null +++ b/SearchMatcher.cs @@ -0,0 +1,98 @@ +using System; +using System.Globalization; +using System.Text; + +namespace Flow.Launcher.Plugin.QuickSSH +{ + /// + /// Provides accent-insensitive and fuzzy matching for saved profile searches. + /// + internal static class SearchMatcher + { + internal static int ScoreProfile(string search, string name, string command) + { + var searchLower = RemoveDiacritics(search.ToLowerInvariant()); + var nameLower = RemoveDiacritics(name.ToLowerInvariant()); + var commandLower = RemoveDiacritics(command.ToLowerInvariant()); + + bool nameExact = ContainsIgnoreAccents(nameLower, searchLower); + bool cmdExact = ContainsIgnoreAccents(commandLower, searchLower); + + if (nameExact && cmdExact) return 0; + if (nameExact) return 1; + if (cmdExact) return 2; + + bool nameFuzzy = FuzzyContains(nameLower, searchLower); + bool cmdFuzzy = FuzzyContains(commandLower, searchLower); + + if (nameFuzzy && cmdFuzzy) return 3; + if (nameFuzzy) return 4; + if (cmdFuzzy) return 5; + + return int.MaxValue; + } + + internal static bool ContainsIgnoreAccents(string source, string search) + { + return RemoveDiacritics(source).Contains(RemoveDiacritics(search)); + } + + private static string RemoveDiacritics(string text) + { + var normalized = text.Normalize(NormalizationForm.FormD); + var sb = new StringBuilder(normalized.Length); + foreach (var c in normalized) + { + if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark) + sb.Append(c); + } + + return sb.ToString().Normalize(NormalizationForm.FormC); + } + + private static bool FuzzyContains(string source, string search) + { + if (search.Length < 5) + return source.Contains(search); + + int tolerance = search.Length / 5; + + for (int i = 0; i <= source.Length - search.Length + tolerance; i++) + { + int end = Math.Min(i + search.Length + tolerance, source.Length); + var window = source.Substring(i, end - i); + if (DamerauLevenshteinDistance(window, search) <= tolerance) + return true; + } + + return false; + } + + private static int DamerauLevenshteinDistance(string s, string t) + { + int n = s.Length; + int m = t.Length; + var d = new int[n + 1, m + 1]; + + for (int i = 0; i <= n; i++) d[i, 0] = i; + for (int j = 0; j <= m; j++) d[0, j] = j; + + for (int i = 1; i <= n; i++) + { + for (int j = 1; j <= m; j++) + { + int cost = s[i - 1] == t[j - 1] ? 0 : 1; + + d[i, j] = Math.Min( + Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), + d[i - 1, j - 1] + cost); + + if (i > 1 && j > 1 && s[i - 1] == t[j - 2] && s[i - 2] == t[j - 1]) + d[i, j] = Math.Min(d[i, j], d[i - 2, j - 2] + cost); + } + } + + return d[n, m]; + } + } +} diff --git a/ShellLaunchPlan.cs b/ShellLaunchPlan.cs new file mode 100644 index 0000000..7e2ae5b --- /dev/null +++ b/ShellLaunchPlan.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Flow.Launcher.Plugin.QuickSSH +{ + internal enum ShellLaunchPlanError + { + None, + SelectedShellMissing, + InvalidShellDefinition, + ExecutableNotFound + } + + /// + /// Immutable process-launch plan for one SSH/SCP command. + /// Custom-shell failures are represented as errors and never fall back to another shell. + /// + internal sealed class ShellLaunchPlan + { + internal ShellLaunchPlan( + string fileName, + string arguments, + string shellName, + bool usesDefaultShell) + { + FileName = fileName; + Arguments = arguments; + ShellName = shellName; + UsesDefaultShell = usesDefaultShell; + } + + internal string FileName { get; } + internal string Arguments { get; } + internal string ShellName { get; } + internal bool UsesDefaultShell { get; } + + internal static bool TryCreate( + string command, + string? selectedShell, + IDictionary? customShells, + Func resolveExecutable, + string defaultCmdPath, + out ShellLaunchPlan? plan, + out ShellLaunchPlanError error) + { + plan = null; + error = ShellLaunchPlanError.None; + + if (string.IsNullOrWhiteSpace(command)) + { + error = ShellLaunchPlanError.InvalidShellDefinition; + return false; + } + + var shellName = selectedShell?.Trim(); + if (string.IsNullOrEmpty(shellName)) + { + if (string.IsNullOrWhiteSpace(defaultCmdPath)) + { + error = ShellLaunchPlanError.ExecutableNotFound; + return false; + } + + plan = new ShellLaunchPlan( + defaultCmdPath, + "/k " + command, + "cmd.exe", + usesDefaultShell: true); + return true; + } + + if (customShells == null || !customShells.TryGetValue(shellName, out var storedDefinition)) + { + error = ShellLaunchPlanError.SelectedShellMissing; + return false; + } + + var definition = string.IsNullOrWhiteSpace(storedDefinition) + ? shellName + : storedDefinition.Trim(); + + if (!TrySplitDefinition(definition, out var executable, out var prefixArguments)) + { + error = ShellLaunchPlanError.InvalidShellDefinition; + return false; + } + + var resolvedExecutable = resolveExecutable(executable); + if (string.IsNullOrWhiteSpace(resolvedExecutable)) + { + error = ShellLaunchPlanError.ExecutableNotFound; + return false; + } + + var arguments = string.IsNullOrWhiteSpace(prefixArguments) + ? command + : prefixArguments + " " + command; + + plan = new ShellLaunchPlan( + resolvedExecutable, + arguments, + shellName, + usesDefaultShell: false); + return true; + } + + private static bool TrySplitDefinition( + string definition, + out string executable, + out string prefixArguments) + { + executable = string.Empty; + prefixArguments = string.Empty; + + if (string.IsNullOrWhiteSpace(definition) || ContainsForbiddenControl(definition)) + return false; + + var value = definition.Trim(); + if (value[0] == '"') + { + var closingQuote = value.IndexOf('"', 1); + if (closingQuote <= 1) + return false; + + executable = value.Substring(1, closingQuote - 1); + if (closingQuote + 1 < value.Length && !char.IsWhiteSpace(value[closingQuote + 1])) + return false; + + prefixArguments = closingQuote + 1 < value.Length + ? value.Substring(closingQuote + 1).TrimStart() + : string.Empty; + } + else + { + var separator = -1; + for (var i = 0; i < value.Length; i++) + { + if (!char.IsWhiteSpace(value[i])) + continue; + + separator = i; + break; + } + + if (separator < 0) + { + executable = value; + } + else + { + executable = value.Substring(0, separator); + prefixArguments = value.Substring(separator).TrimStart(); + } + } + + return !string.IsNullOrWhiteSpace(executable) && executable.IndexOf('"') < 0; + } + + private static bool ContainsForbiddenControl(string value) => + value.IndexOf('\r') >= 0 || value.IndexOf('\n') >= 0 || value.IndexOf('\0') >= 0; + } + + /// + /// Starts exactly one process from a validated launch plan. + /// + internal static class ShellCommandLauncher + { + internal static bool TryStart( + ShellLaunchPlan plan, + string workingDirectory, + Func processStarter, + out Exception? error) + { + error = null; + + if (plan == null) + { + error = new ArgumentNullException(nameof(plan)); + return false; + } + + if (processStarter == null) + { + error = new ArgumentNullException(nameof(processStarter)); + return false; + } + + try + { + var startInfo = new ProcessStartInfo + { + FileName = plan.FileName, + Arguments = plan.Arguments, + UseShellExecute = true, + WorkingDirectory = workingDirectory + }; + + var process = processStarter(startInfo); + if (process == null) + { + error = new InvalidOperationException("The selected shell did not start a process."); + return false; + } + + process.Dispose(); + return true; + } + catch (Exception ex) + { + error = ex; + return false; + } + } + } +} diff --git a/SshKeyEntry.cs b/SshKeyEntry.cs index ba7683b..89738e4 100644 --- a/SshKeyEntry.cs +++ b/SshKeyEntry.cs @@ -10,48 +10,48 @@ public class SshKeyEntry { /// Path to the private key file on disk. [JsonProperty] - public string Path { get; set; } + public string? Path { get; set; } /// /// Path to the corresponding public key file (e.g. id_ed25519.pub). /// When null, derived as + ".pub". /// [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public string PublicKeyPath { get; set; } + public string? PublicKeyPath { get; set; } /// /// SSH key fingerprint (e.g. SHA256:...). Populated by scan or user. /// [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public string Fingerprint { get; set; } + public string? Fingerprint { get; set; } /// /// Comment field from the key (e.g. user@host). Populated by scan or user. /// [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public string Comment { get; set; } + public string? Comment { get; set; } /// Optional human-readable description. [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public string Description { get; set; } + public string? Description { get; set; } /// /// Key algorithm (e.g. "ed25519", "rsa"). Populated by generate or scan. /// [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public string Algorithm { get; set; } + public string? Algorithm { get; set; } /// /// How this key was registered (e.g. "generated", "manual", "scanned"). /// [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public string Source { get; set; } + public string? Source { get; set; } /// /// ISO 8601 UTC timestamp of when this entry was created. /// [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public string CreatedAt { get; set; } + public string? CreatedAt { get; set; } /// Returns a display-friendly summary of this key entry. public string ToDisplayString() @@ -65,7 +65,7 @@ public string ToDisplayString() /// Returns the effective public key path: explicit /// if set, otherwise + ".pub". /// - public string GetEffectivePublicKeyPath() + public string? GetEffectivePublicKeyPath() { if (!string.IsNullOrEmpty(PublicKeyPath)) return PublicKeyPath; diff --git a/Tests/ActionCommandBuilderTests.cs b/Tests/ActionCommandBuilderTests.cs new file mode 100644 index 0000000..dfb6df0 --- /dev/null +++ b/Tests/ActionCommandBuilderTests.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class ActionCommandBuilderTests + { + [Fact] + public void TryBuild_PreservesConnectionOptionsAndAddsRemoteCommand() + { + var profile = new SshProfile + { + HostName = "server.example", + User = "admin", + Port = "2222", + IdentityFile = @"C:\Keys\admin key", + IdentitiesOnly = true, + ProxyJump = "jump.example", + LocalForward = new List { "8080:127.0.0.1:80" } + }; + var action = new CommandProfile { Command = "sudo systemctl restart nginx" }; + + var success = ActionCommandBuilder.TryBuild(profile, action, out var command); + + Assert.True(success); + Assert.Contains("ssh", command); + Assert.Contains("-p 2222", command); + Assert.Contains("admin@server.example", command); + Assert.Contains("-J jump.example", command); + Assert.Contains("sudo systemctl restart nginx", command); + } + + [Fact] + public void TryBuild_DoesNotMutateStoredProfile() + { + var profile = new SshProfile + { + HostName = "server.example", + RemoteCommand = "original-command", + RequestTTY = "yes" + }; + var action = new CommandProfile + { + Command = "uptime", + RequestTTY = "force" + }; + + Assert.True(ActionCommandBuilder.TryBuild(profile, action, out var command)); + Assert.Equal("original-command", profile.RemoteCommand); + Assert.Equal("yes", profile.RequestTTY); + Assert.Contains("uptime", command); + Assert.Contains("-t -t", command); + } + + [Fact] + public void TryBuild_RejectsScpUnsupportedAndUnsafeInputs() + { + Assert.False(ActionCommandBuilder.TryBuild( + new SshProfile { Type = "scp", HostName = "server" }, + new CommandProfile { Command = "uptime" }, + out _)); + + Assert.False(ActionCommandBuilder.TryBuild( + new SshProfile { HostName = "server" }, + new CommandProfile { Kind = "unknown", Command = "uptime" }, + out _)); + + Assert.False(ActionCommandBuilder.TryBuild( + new SshProfile { HostName = "server" }, + new CommandProfile { Command = "line1\nline2" }, + out _)); + } + + [Fact] + public void TryBuildDisplay_KeepsWindowsPathHumanReadable() + { + var profile = new SshProfile + { + HostName = "server.example", + User = "admin", + IdentityFile = @"C:\Users\info\.ssh\private_key" + }; + var action = new CommandProfile { Command = "hostname" }; + + Assert.True(ActionCommandBuilder.TryBuildDisplay(profile, action, out var display)); + Assert.Contains(@"C:\Users\info\.ssh\private_key", display); + Assert.DoesNotContain(@"C:\\Users", display); + Assert.Contains("hostname", display); + } + } +} diff --git a/Tests/ActionLocalizationTests.cs b/Tests/ActionLocalizationTests.cs new file mode 100644 index 0000000..ca1a2a7 --- /dev/null +++ b/Tests/ActionLocalizationTests.cs @@ -0,0 +1,134 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class ActionLocalizationTests + { + [Fact] + public void AllLanguages_ContainContextualActionRunKeys() + { + var languagesDir = Path.GetFullPath(Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "Languages")); + if (!Directory.Exists(languagesDir)) + return; + + var requiredKeys = new[] + { + "plugin_quickssh_actions_select_profile_subtitle", + "plugin_quickssh_actions_select_action_subtitle", + "plugin_quickssh_actions_confirm_subtitle", + "plugin_quickssh_actions_command_label", + "plugin_quickssh_actions_execute_subtitle", + "plugin_quickssh_title_commandactions_manage", + "plugin_quickssh_subtitle_commandactions_manage", + "plugin_quickssh_actions_select_profile_for_action", + "plugin_quickssh_actions_confirm_title", + "plugin_quickssh_actions_execute_named_title", + "plugin_quickssh_actions_execute_named_subtitle", + "plugin_quickssh_title_commandprofiles_manage", + "plugin_quickssh_subtitle_commandprofiles_manage" + }; + + var files = Directory.GetFiles(languagesDir, "*.xaml"); + Assert.Equal(7, files.Length); + + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + foreach (var file in files) + { + var document = XDocument.Load(file); + var keys = document.Descendants() + .Select(element => (string?)element.Attribute(x + "Key")) + .OfType() + .Where(key => !string.IsNullOrWhiteSpace(key)) + .ToHashSet(StringComparer.Ordinal); + + foreach (var requiredKey in requiredKeys) + Assert.Contains(requiredKey, keys); + } + } + + [Fact] + public void AllLanguages_ContainKeysInstallKeys() + { + var languagesDir = Path.GetFullPath(Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "Languages")); + if (!Directory.Exists(languagesDir)) + return; + + var requiredKeys = new[] + { + "plugin_quickssh_title_commandkeys_install", + "plugin_quickssh_subtitle_commandkeys_install", + "plugin_quickssh_keys_install_type_userhost", + "plugin_quickssh_keys_install_summary", + "plugin_quickssh_keys_install_run", + "plugin_quickssh_keys_install_copy_cmd", + "plugin_quickssh_keys_install_copy_pub", + "plugin_quickssh_keys_install_copy_cmd_subtitle", + "plugin_quickssh_keys_install_copy_pub_subtitle", + "plugin_quickssh_keys_install_alias_notfound", + "plugin_quickssh_keys_install_pub_notfound", + "plugin_quickssh_keys_install_pub_invalid", + "plugin_quickssh_keys_install_invalid_destination", + "plugin_quickssh_keys_install_copy_cmd_success", + "plugin_quickssh_keys_install_select_profile", + "plugin_quickssh_keys_install_manual_destination", + "plugin_quickssh_keys_install_profile_unsupported", + "plugin_quickssh_title_commandkeys_manage", + "plugin_quickssh_subtitle_commandkeys_manage", + "plugin_quickssh_keys_public_path_label" + }; + + var files = Directory.GetFiles(languagesDir, "*.xaml"); + Assert.Equal(7, files.Length); + + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + foreach (var file in files) + { + var document = XDocument.Load(file); + var keys = document.Descendants() + .Select(element => (string?)element.Attribute(x + "Key")) + .OfType() + .Where(key => !string.IsNullOrWhiteSpace(key)) + .ToHashSet(StringComparer.Ordinal); + + foreach (var requiredKey in requiredKeys) + Assert.Contains(requiredKey, keys); + } + } + + [Fact] + public void AllLanguages_HaveUniqueResourceKeys() + { + var languagesDir = Path.GetFullPath(Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "Languages")); + if (!Directory.Exists(languagesDir)) + return; + + var files = Directory.GetFiles(languagesDir, "*.xaml"); + Assert.Equal(7, files.Length); + + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + foreach (var file in files) + { + var document = XDocument.Load(file); + var duplicateKeys = document.Descendants() + .Select(element => (string?)element.Attribute(x + "Key")) + .OfType() + .Where(key => !string.IsNullOrWhiteSpace(key)) + .GroupBy(key => key, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .OrderBy(key => key, StringComparer.Ordinal) + .ToArray(); + + Assert.True(duplicateKeys.Length == 0, + $"{Path.GetFileName(file)} contains duplicate localization keys: {string.Join(", ", duplicateKeys)}"); + } + } + } +} diff --git a/Tests/ActionsMenuTests.cs b/Tests/ActionsMenuTests.cs new file mode 100644 index 0000000..f336837 --- /dev/null +++ b/Tests/ActionsMenuTests.cs @@ -0,0 +1,166 @@ +using System; +using System.IO; +using System.Linq; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class ActionsMenuTests + { + [Fact] + public void TopLevelMenu_UsesApprovedSimplifiedOrder() + { + var results = AutoCompleter.GetSuggestions("ssh", "", null, "icon.png") + .OrderByDescending(r => r.Score) + .Select(r => r.Title) + .ToList(); + + Assert.Equal(new[] { "profiles", "actions", "tools", "help" }, results); + } + + [Fact] + public void ActionsRows_PrioritizeSavedActionsAboveManagement() + { + Assert.True(QuickSsh.ScoreBackNavigation > QuickSsh.ScoreActionsSavedItem); + Assert.True(QuickSsh.ScoreActionsSavedItem > QuickSsh.ScoreActionsActionManage); + Assert.True(QuickSsh.ScoreActionsManageAdd > QuickSsh.ScoreActionsManageRename); + } + + [Fact] + public void Confirmation_PutsBackBeforeRunAndCopy() + { + Assert.Equal(int.MaxValue, QuickSsh.ScoreActionsConfirmBack); + Assert.True(QuickSsh.ScoreActionsConfirmBack > QuickSsh.ScoreActionsConfirmRun); + Assert.True(QuickSsh.ScoreActionsConfirmRun > QuickSsh.ScoreActionsConfirmCommand); + } + + [Fact] + public void SavedAction_OpensActionFirstProfileSelection() + { + var mainCsPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "Main.cs"); + mainCsPath = Path.GetFullPath(mainCsPath); + if (!File.Exists(mainCsPath)) + return; + + var source = File.ReadAllText(mainCsPath); + var start = source.IndexOf("private List HandleActionsList(", StringComparison.Ordinal); + var end = source.IndexOf("private List HandleActionsManage(", start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + + var region = source.Substring(start, end - start); + Assert.Contains("actions use ", region); + Assert.Contains("Action = _ =>", region); + Assert.Contains("plugin_quickssh_title_commandactions_manage", region); + Assert.DoesNotContain("plugin_quickssh_title_commandactions_add", region); + Assert.DoesNotContain("(\"run\",", region); + } + + [Fact] + public void ActionsHandler_ContainsConfirmedExecutionAndNoDevelopmentCopy() + { + var mainCsPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "Main.cs"); + mainCsPath = Path.GetFullPath(mainCsPath); + if (!File.Exists(mainCsPath)) + return; + + var source = File.ReadAllText(mainCsPath); + var start = source.IndexOf("private List HandleActions(", StringComparison.Ordinal); + var end = source.IndexOf("private List HandleShell(", start, StringComparison.Ordinal); + + Assert.True(start >= 0 && end > start, "Actions handler region must be present."); + var actionsRegion = source.Substring(start, end - start); + Assert.Contains("ActionCommandBuilder.TryBuild", actionsRegion); + Assert.Contains("ActionCommandBuilder.TryBuildDisplay", actionsRegion); + Assert.Contains("RunCommand(command)", actionsRegion); + Assert.Contains("HandleActionsUse", actionsRegion); + Assert.DoesNotContain("preview only", actionsRegion.ToLowerInvariant()); + } + + [Fact] + public void ActionsHandler_ProtectsCreateRenameAndRemoveFlows() + { + var mainCsPath = Path.GetFullPath(Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "Main.cs")); + if (!File.Exists(mainCsPath)) + return; + + var source = File.ReadAllText(mainCsPath); + Assert.Contains("CommandInputGuard.NormalizeNestedCommandInput", source); + Assert.Contains("CommandInputGuard.IsReservedSavedName", source); + Assert.Contains("plugin_quickssh_name_exists", source); + Assert.Contains("plugin_quickssh_actions_remove_confirm", source); + } + + [Fact] + public void ActionsManage_UsesOrangeForRenameAndRedForRemove() + { + var mainCsPath = Path.GetFullPath(Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "Main.cs")); + if (!File.Exists(mainCsPath)) + return; + + var source = File.ReadAllText(mainCsPath); + var start = source.IndexOf("private List HandleActionsManage(", StringComparison.Ordinal); + var end = source.IndexOf("private List HandleActionsUse(", start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + + var region = source.Substring(start, end - start); + Assert.Contains("AppIconGreenPath", region); + Assert.Contains("AppIconOrangePath", region); + Assert.Contains("AppIconRedPath", region); + } + + [Fact] + public void ActionsRename_UsesOrangeForSelectionAndConfirmation() + { + var mainCsPath = Path.GetFullPath(Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "Main.cs")); + if (!File.Exists(mainCsPath)) + return; + + var source = File.ReadAllText(mainCsPath); + var start = source.IndexOf("private List HandleActionsRename(", StringComparison.Ordinal); + var end = source.IndexOf("private List HandleActionsRun(", start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + + var region = source.Substring(start, end - start); + Assert.True(region.Split("IcoPath = AppIconOrangePath").Length - 1 >= 2); + } + + [Fact] + public void ActionConfirmation_StartsWithBackAndHasNoPassiveHeading() + { + var mainCsPath = Path.GetFullPath(Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "Main.cs")); + if (!File.Exists(mainCsPath)) + return; + + var source = File.ReadAllText(mainCsPath); + var start = source.IndexOf("private List BuildActionConfirmationResults(", StringComparison.Ordinal); + var end = source.IndexOf("private List HandleActionsAdd(", start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + + var region = source.Substring(start, end - start); + Assert.DoesNotContain("plugin_quickssh_actions_confirm_title", region); + Assert.Contains("MakeBackNavResult(query, backQuery, backTarget)", region); + Assert.Contains("Score = ScoreActionsConfirmRun", region); + Assert.Contains("plugin_quickssh_actions_copy_command_title", region); + Assert.DoesNotContain("plugin_quickssh_actions_profile_label", region); + Assert.DoesNotContain("plugin_quickssh_actions_action_label", region); + } + + [Fact] + public void OrangeIcon_IsDeclaredAndPresent() + { + var root = Path.GetFullPath(Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..")); + var mainCsPath = Path.Combine(root, "Main.cs"); + var iconPath = Path.Combine(root, "Images", "app-orange.png"); + if (!File.Exists(mainCsPath)) + return; + + Assert.Contains("AppIconOrangePath", File.ReadAllText(mainCsPath)); + Assert.True(File.Exists(iconPath), "Orange icon must be shipped with the plugin."); + } + } +} diff --git a/Tests/AutoCompleterTests.cs b/Tests/AutoCompleterTests.cs index 9ae5021..5a3a897 100644 --- a/Tests/AutoCompleterTests.cs +++ b/Tests/AutoCompleterTests.cs @@ -19,10 +19,12 @@ public void GetSuggestions_EmptyInput_ReturnsTopLevelCommands() // New top-level commands Assert.Contains("profiles", titles); - Assert.Contains("keys", titles); - Assert.Contains("config", titles); - Assert.Contains("shell", titles); + Assert.Contains("actions", titles); + Assert.Contains("tools", titles); Assert.Contains("help", titles); + Assert.DoesNotContain("keys", titles); + Assert.DoesNotContain("config", titles); + Assert.DoesNotContain("shell", titles); // Removed top-level commands must NOT appear Assert.DoesNotContain("add", titles); @@ -58,12 +60,10 @@ public void GetSuggestions_PartialPr_ReturnsProfiles() } [Fact] - public void GetSuggestions_PartialCo_ReturnsConfig() + public void GetSuggestions_PartialTo_ReturnsTools() { - var results = AutoCompleter.GetSuggestions("ssh", "co", null, "icon.png"); - var titles = new HashSet(); - foreach (var r in results) titles.Add(r.Title); - Assert.Contains("config", titles); + var results = AutoCompleter.GetSuggestions("ssh", "to", null, "icon.png"); + Assert.Contains(results, r => r.AutoCompleteText == "ssh tools "); } [Fact] @@ -82,6 +82,7 @@ public void GetSuggestions_ProfilesSpace_SuggestsSubCommands() var titles = new HashSet(); foreach (var r in results) titles.Add(r.Title); + Assert.Contains("manage", titles); Assert.Contains("add", titles); Assert.Contains("remove", titles); Assert.Contains("rename", titles); @@ -140,34 +141,27 @@ public void GetSuggestions_NullUserData_DoesNotThrow() [Fact] public void GetSuggestions_EmptyInput_CommandsHaveDescendingScoresInDefinedOrder() { - // Expected display order: profiles > keys > shell > config > help - // Flow Launcher sorts by Score descending, so each command must have a - // strictly higher score than the one that should follow it. var results = AutoCompleter.GetSuggestions("ssh", "", null, "icon.png"); - int profilesScore = results.First(r => r.Title == "profiles").Score; - int keysScore = results.First(r => r.Title == "keys").Score; - int shellScore = results.First(r => r.Title == "shell").Score; - int configScore = results.First(r => r.Title == "config").Score; - int helpScore = results.First(r => r.Title == "help").Score; + int profilesScore = results.First(r => r.AutoCompleteText == "ssh profiles ").Score; + int actionsScore = results.First(r => r.AutoCompleteText == "ssh actions ").Score; + int toolsScore = results.First(r => r.AutoCompleteText == "ssh tools ").Score; + int helpScore = results.First(r => r.AutoCompleteText == "ssh help ").Score; - Assert.True(profilesScore > keysScore, "profiles must outrank keys"); - Assert.True(keysScore > shellScore, "keys must outrank shell"); - Assert.True(shellScore > configScore, "shell must outrank config"); - Assert.True(configScore > helpScore, "config must outrank help"); + Assert.True(profilesScore > actionsScore); + Assert.True(actionsScore > toolsScore); + Assert.True(toolsScore > helpScore); } [Fact] public void GetSuggestions_EmptyInput_SortedByScoreDescending_YieldsExactOrder() { - // When sorted by Score descending (as Flow Launcher does at runtime), - // the top-level commands must appear in exactly this order: - // 1. profiles, 2. keys, 3. shell, 4. config, 5. help var results = AutoCompleter.GetSuggestions("ssh", "", null, "icon.png"); + var ordered = results.OrderByDescending(r => r.Score) + .Select(r => r.AutoCompleteText) + .ToList(); - var ordered = results.OrderByDescending(r => r.Score).Select(r => r.Title).ToList(); - - Assert.Equal(new[] { "profiles", "keys", "shell", "config", "help" }, ordered); + Assert.Equal(new[] { "ssh profiles ", "ssh actions ", "ssh tools ", "ssh help " }, ordered); } [Fact] @@ -190,38 +184,24 @@ public void GetSuggestions_EmptyInput_ScoreGapsAreLargeEnoughToResistFuzzyBoost( [Fact] public void TopLevelScoreConstants_AreInCorrectDescendingOrder() { - // Verify the centralized constants in QuickSsh follow the expected order: - // profiles > keys > shell > config > help - Assert.True(QuickSsh.ScoreTopLevelProfiles > QuickSsh.ScoreTopLevelKeys, - "profiles must outrank keys"); - Assert.True(QuickSsh.ScoreTopLevelKeys > QuickSsh.ScoreTopLevelShell, - "keys must outrank shell"); - Assert.True(QuickSsh.ScoreTopLevelShell > QuickSsh.ScoreTopLevelConfig, - "shell must outrank config"); - Assert.True(QuickSsh.ScoreTopLevelConfig > QuickSsh.ScoreTopLevelHelp, - "config must outrank help"); + Assert.True(QuickSsh.ScoreTopLevelProfiles > QuickSsh.ScoreTopLevelActions); + Assert.True(QuickSsh.ScoreTopLevelActions > QuickSsh.ScoreTopLevelTools); + Assert.True(QuickSsh.ScoreTopLevelTools > QuickSsh.ScoreTopLevelHelp); } [Fact] public void TopLevelScoreConstants_GapsAreAtLeast100k() { - // Ensure each gap is exactly 100 000 (or at least large enough to resist - // Flow Launcher's internal usage-history bonus). int[] scores = new[] { QuickSsh.ScoreTopLevelProfiles, - QuickSsh.ScoreTopLevelKeys, - QuickSsh.ScoreTopLevelShell, - QuickSsh.ScoreTopLevelConfig, + QuickSsh.ScoreTopLevelActions, + QuickSsh.ScoreTopLevelTools, QuickSsh.ScoreTopLevelHelp }; for (int i = 0; i < scores.Length - 1; i++) - { - int gap = scores[i] - scores[i + 1]; - Assert.True(gap >= 100_000, - $"Gap between constant position {i} and {i + 1} is only {gap}; must be >= 100 000."); - } + Assert.True(scores[i] - scores[i + 1] >= 100_000); } // ── Partial "profiles " sub-command suggestions ─────────────────── @@ -280,6 +260,62 @@ public void GetSuggestions_ProfilesNonSubCommandSearch_StillFiltersProfileNames( Assert.DoesNotContain(results, r => r.Title == "home"); } + // ── "actions " sub-command suggestions ─────────────────────────────────── + + [Fact] + public void GetSuggestions_ActionsSpace_SuggestsSubCommandsAndSavedActions() + { + var userData = new UserData(); + userData.Attach(() => { }); + userData.CommandProfiles["restart-nginx"] = new CommandProfile { Command = "systemctl restart nginx" }; + + var results = AutoCompleter.GetSuggestions("ssh", "actions ", userData, "icon.png"); + var titles = results.Select(r => r.Title).ToHashSet(); + + Assert.Contains("run", titles); + Assert.Contains("add", titles); + Assert.Contains("manage", titles); + Assert.Contains("restart-nginx", titles); + Assert.Contains(results, r => r.Title == "restart-nginx" && + r.AutoCompleteText == "ssh actions use restart-nginx "); + } + + [Fact] + public void GetSuggestions_ActionsSpaceWithoutSavedActions_OnlySuggestsAdd() + { + var userData = new UserData(); + userData.Attach(() => { }); + + var results = AutoCompleter.GetSuggestions("ssh", "actions ", userData, "icon.png"); + var titles = results.Select(r => r.Title).ToHashSet(); + + Assert.Contains("add", titles); + Assert.DoesNotContain("run", titles); + Assert.DoesNotContain("manage", titles); + } + + [Theory] + [InlineData("a", new[] { "add" })] + [InlineData("ru", new[] { "run" })] + [InlineData("m", new[] { "manage" })] + public void GetSuggestions_ActionsPartialPrefix_ShowsMatchingSubCommands( + string partial, string[] expected) + { + var userData = new UserData(); + userData.Attach(() => { }); + userData.CommandProfiles["restart-nginx"] = new CommandProfile { Command = "uptime" }; + + var results = AutoCompleter.GetSuggestions( + "ssh", "actions " + partial, userData, "icon.png"); + var titles = results.Select(r => r.Title) + .Where(t => t == "run" || t == "add" || t == "manage") + .ToHashSet(); + + foreach (var item in expected) + Assert.Contains(item, titles); + Assert.Equal(expected.Length, titles.Count); + } + // ── "shell " sub-command suggestions ───────────────────────────────────── [Fact] @@ -289,11 +325,14 @@ public void GetSuggestions_ShellSpace_SuggestsSubCommands() var titles = new HashSet(); foreach (var r in results) titles.Add(r.Title); + Assert.Contains("manage", titles); Assert.Contains("add", titles); Assert.Contains("remove", titles); } [Theory] + [InlineData("m", new[] { "manage" })] + [InlineData("ma", new[] { "manage" })] [InlineData("a", new[] { "add" })] [InlineData("ad", new[] { "add" })] [InlineData("add", new[] { "add" })] @@ -309,7 +348,7 @@ public void GetSuggestions_ShellPartialPrefix_ShowsMatchingSubCommands( var results = AutoCompleter.GetSuggestions("ssh", "shell " + partial, null, "icon.png"); var subCommandTitles = results .Select(r => r.Title) - .Where(t => t == "add" || t == "remove") + .Where(t => t == "manage" || t == "add" || t == "remove") .ToHashSet(); foreach (var e in expected) @@ -319,7 +358,10 @@ public void GetSuggestions_ShellPartialPrefix_ShowsMatchingSubCommands( [Theory] [InlineData("ad", "remove")] + [InlineData("ad", "manage")] [InlineData("rem", "add")] + [InlineData("rem", "manage")] + [InlineData("ma", "add")] public void GetSuggestions_ShellPartialSubCommand_DoesNotSuggestNonMatchingSubCommands( string partial, string notExpected) { @@ -331,6 +373,8 @@ public void GetSuggestions_ShellPartialSubCommand_DoesNotSuggestNonMatchingSubCo [Theory] [InlineData("profiles")] + [InlineData("actions")] + [InlineData("tools")] [InlineData("keys")] [InlineData("config")] [InlineData("shell")] diff --git a/Tests/BackNavigationTests.cs b/Tests/BackNavigationTests.cs index c303191..1036b71 100644 --- a/Tests/BackNavigationTests.cs +++ b/Tests/BackNavigationTests.cs @@ -2,93 +2,31 @@ namespace Flow.Launcher.Plugin.QuickSSH.Tests { - /// - /// Verifies the back-navigation score invariants. - /// - /// The back-navigation row appears in every submenu immediately below the pinned - /// usage-hint row, so that the user can press Enter on row 2 to return to the - /// parent command level without manually erasing text. - /// - /// Note: True Backspace-driven parent navigation is not possible via the - /// Flow Launcher plugin SDK. The Query() method receives only the post-processed - /// search string; no keyboard-event hook is exposed. Explicit back-navigation rows - /// are therefore the correct and reliable solution. - /// public class BackNavigationTests { [Fact] - public void BackNavScore_IsBelowManagementRow() + public void BackNav_IsAlwaysTopScore() { - Assert.True(QuickSsh.ScoreBackNavigation < QuickSsh.ScoreSubMenuManagement, - "Back-nav row must appear below the pinned usage/management hint."); + Assert.Equal(int.MaxValue, QuickSsh.ScoreBackNavigation); + Assert.Equal(int.MaxValue - 1, QuickSsh.ScoreSubMenuManagement); + Assert.True(QuickSsh.ScoreBackNavigation > QuickSsh.ScoreSubMenuManagement); } [Fact] - public void BackNavScore_IsAboveAllProfilesActionRows() + public void BackNav_OutranksEveryPrimarySubmenuRow() { - Assert.True(QuickSsh.ScoreBackNavigation > QuickSsh.ScoreProfilesActionAdd, - "Back-nav row must appear above every profiles action row (including 'add', the highest)."); + Assert.True(QuickSsh.ScoreBackNavigation > QuickSsh.ScoreProfilesSavedItem); + Assert.True(QuickSsh.ScoreBackNavigation > QuickSsh.ScoreActionsSavedItem); + Assert.True(QuickSsh.ScoreBackNavigation > QuickSsh.ScoreShellSelected); + Assert.True(QuickSsh.ScoreBackNavigation > QuickSsh.ScoreKeysSavedItem); + Assert.True(QuickSsh.ScoreBackNavigation > QuickSsh.ScoreToolsKeys); } [Fact] - public void BackNavScore_IsAboveAllShellActionRows() + public void ActionConfirmation_BackIsAboveRunAndCopy() { - Assert.True(QuickSsh.ScoreBackNavigation > QuickSsh.ScoreShellActionAdd, - "Back-nav row must appear above every shell action row (including 'add', the highest)."); - } - - [Fact] - public void BackNavScore_IsAboveProfilesSavedItems() - { - Assert.True(QuickSsh.ScoreBackNavigation > QuickSsh.ScoreProfilesSavedItem, - "Back-nav row must appear above saved profile entries."); - } - - [Fact] - public void BackNavScore_IsAboveShellOtherEntries() - { - Assert.True(QuickSsh.ScoreBackNavigation > QuickSsh.ScoreShellOtherStart, - "Back-nav row must appear above non-selected shell entries."); - } - - [Fact] - public void BackNavScore_IsExactlyOneBelow_ManagementRow() - { - // Ensures the back-nav row is pinned immediately adjacent to the management row - // with no other score values between them. - Assert.Equal(QuickSsh.ScoreSubMenuManagement - 1, QuickSsh.ScoreBackNavigation); - } - - // ── config submenu back-navigation ──────────────────────────────────────── - - [Fact] - public void ConfigSubmenu_BackNavScoreGuaranteesSecondRowPosition() - { - // The config submenu must display: - // 1. management row (ScoreSubMenuManagement) - // 2. back-nav row (ScoreBackNavigation) - // 3. config action (no explicit Score, defaults to 0) - // The back-nav score must be below management but above 0 (default). - Assert.True(QuickSsh.ScoreBackNavigation < QuickSsh.ScoreSubMenuManagement, - "Config back-nav must be below the management row."); - Assert.True(QuickSsh.ScoreBackNavigation > 0, - "Config back-nav must be above the config action row (Score = 0 default)."); - } - - // ── help submenu back-navigation ────────────────────────────────────────── - - [Fact] - public void HelpSubmenu_BackNavScoreGuaranteesSecondRowPosition() - { - // The help submenu must display: - // 1. management row (ScoreSubMenuManagement) - // 2. back-nav row (ScoreBackNavigation) - // 3. help action (no explicit Score, defaults to 0) - // The back-nav score must be below management but above 0 (default). - Assert.True(QuickSsh.ScoreBackNavigation < QuickSsh.ScoreSubMenuManagement, - "Help back-nav must be below the management row."); - Assert.True(QuickSsh.ScoreBackNavigation > 0, - "Help back-nav must be above the help action row (Score = 0 default)."); + Assert.True(QuickSsh.ScoreActionsConfirmBack > QuickSsh.ScoreActionsConfirmRun); + Assert.True(QuickSsh.ScoreActionsConfirmRun > QuickSsh.ScoreActionsConfirmCommand); } } } diff --git a/Tests/CommandInputGuardTests.cs b/Tests/CommandInputGuardTests.cs new file mode 100644 index 0000000..cf5cae7 --- /dev/null +++ b/Tests/CommandInputGuardTests.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class CommandInputGuardTests + { + [Theory] + [InlineData("restart-nginx sudo systemctl restart nginx", "restart-nginx sudo systemctl restart nginx")] + [InlineData("actions add restart-nginx uptime", "restart-nginx uptime")] + [InlineData("ssh actions add restart-nginx uptime", "restart-nginx uptime")] + [InlineData("ssh actions add ssh actions add restart-nginx uptime", "restart-nginx uptime")] + public void NormalizeNestedCommandInput_RemovesRepeatedMenuPrefix( + string input, + string expected) + { + Assert.Equal(expected, CommandInputGuard.NormalizeNestedCommandInput( + input, "ssh", "actions add")); + } + + [Theory] + [InlineData("server", true)] + [InlineData("server-prod_1.example", true)] + [InlineData("Žilina", true)] + [InlineData("", false)] + [InlineData("-server", false)] + [InlineData("server name", false)] + [InlineData("server/one", false)] + public void IsValidSavedName_UsesQuerySafeNames(string name, bool expected) + { + Assert.Equal(expected, CommandInputGuard.IsValidSavedName(name)); + } + + [Theory] + [InlineData("ssh")] + [InlineData("Actions")] + [InlineData("run")] + [InlineData("use")] + [InlineData("manage")] + [InlineData("copy-pub")] + public void ReservedNames_AreCaseInsensitive(string name) + { + Assert.True(CommandInputGuard.IsReservedSavedName(name)); + } + + [Fact] + public void FindExistingName_IsCaseInsensitiveAndPreservesStoredSpelling() + { + var values = new Dictionary { ["Prod-Server"] = 1 }; + Assert.Equal("Prod-Server", CommandInputGuard.FindExistingName(values, "prod-server")); + } + } +} diff --git a/Tests/CommandProfileStorageTests.cs b/Tests/CommandProfileStorageTests.cs new file mode 100644 index 0000000..021e111 --- /dev/null +++ b/Tests/CommandProfileStorageTests.cs @@ -0,0 +1,91 @@ +using System; +using System.IO; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class CommandProfileStorageTests : IDisposable + { + private readonly string _tmpDir = Path.Combine( + Path.GetTempPath(), $"quickssh_actions_storage_{Guid.NewGuid():N}"); + + public CommandProfileStorageTests() => Directory.CreateDirectory(_tmpDir); + + public void Dispose() + { + if (Directory.Exists(_tmpDir)) + Directory.Delete(_tmpDir, recursive: true); + } + + [Fact] + public void OldJsonWithoutActions_LoadsWithEmptyCollection() + { + var path = Path.Combine(_tmpDir, "profiles.json"); + File.WriteAllText(path, """{"PluginVersion":"2.0","ProfilesLists":{},"CustomShellLists":{},"SshKeysLists":{}}"""); + + var pm = new ProfileManager(path); + + Assert.NotNull(pm.UserData.CommandProfiles); + Assert.Empty(pm.UserData.CommandProfiles); + } + + [Fact] + public void AddAction_AutoSavesAndReloads() + { + var path = Path.Combine(_tmpDir, "profiles.json"); + var pm = new ProfileManager(path); + pm.UserData.CommandProfiles["uptime"] = new CommandProfile { Command = "uptime" }; + + var reloaded = new ProfileManager(path); + + Assert.True(reloaded.UserData.CommandProfiles.ContainsKey("uptime")); + Assert.Equal("uptime", reloaded.UserData.CommandProfiles["uptime"].Command); + Assert.DoesNotContain("RequireConfirmation", File.ReadAllText(path)); + } + + [Fact] + public void LegacyRequireConfirmation_LoadsAndIsRemovedOnNextSave() + { + var path = Path.Combine(_tmpDir, "profiles.json"); + File.WriteAllText(path, """ + { + "PluginVersion": "2.0", + "ProfilesLists": {}, + "CustomShellLists": {}, + "SshKeysLists": {}, + "CommandProfilesLists": { + "uptime": { + "Kind": "remote-command", + "Command": "uptime", + "RequireConfirmation": false + } + } + } + """); + + var pm = new ProfileManager(path); + + Assert.Equal("uptime", pm.UserData.CommandProfiles["uptime"].Command); + pm.SaveConfiguration(); + Assert.DoesNotContain("RequireConfirmation", File.ReadAllText(path)); + } + + [Fact] + public void ActionMutation_PreservesProfilesShellsAndKeys() + { + var path = Path.Combine(_tmpDir, "profiles.json"); + var pm = new ProfileManager(path); + pm.UserData.Profiles["server"] = new SshProfile { HostName = "server.example" }; + pm.UserData.CustomShell["pwsh"] = "pwsh.exe"; + pm.UserData.SshKeys["admin"] = new SshKeyEntry { Path = @"C:\keys\admin" }; + pm.UserData.CommandProfiles["uptime"] = new CommandProfile { Command = "uptime" }; + + var reloaded = new ProfileManager(path); + + Assert.True(reloaded.UserData.Profiles.ContainsKey("server")); + Assert.True(reloaded.UserData.CustomShell.ContainsKey("pwsh")); + Assert.True(reloaded.UserData.SshKeys.ContainsKey("admin")); + Assert.True(reloaded.UserData.CommandProfiles.ContainsKey("uptime")); + } + } +} diff --git a/Tests/CommandProfileTests.cs b/Tests/CommandProfileTests.cs new file mode 100644 index 0000000..c0c3c01 --- /dev/null +++ b/Tests/CommandProfileTests.cs @@ -0,0 +1,84 @@ +using Newtonsoft.Json; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class CommandProfileTests + { + [Fact] + public void Defaults_ToSupportedRemoteCommandKind() + { + var action = new CommandProfile { Command = "uptime" }; + Assert.Equal(CommandProfile.RemoteCommandKind, action.Kind); + Assert.True(action.IsSupportedKind); + } + + [Fact] + public void UnknownKind_IsNotSupported() + { + var action = new CommandProfile { Kind = "unknown", Command = "uptime" }; + Assert.False(action.IsSupportedKind); + } + + [Theory] + [InlineData("uptime", true)] + [InlineData("sudo systemctl restart nginx", true)] + [InlineData("", false)] + [InlineData("line1\nline2", false)] + [InlineData("abc\0def", false)] + [InlineData("-----BEGIN OPENSSH PRIVATE KEY-----", false)] + [InlineData("-----BEGIN RSA PRIVATE KEY-----", false)] + public void IsSafeToStore_RejectsUnsafePayloads(string command, bool expected) + { + Assert.Equal(expected, CommandProfile.IsSafeToStore(command)); + } + + [Fact] + public void RoundTrip_PreservesSupportedFieldsWithoutDeadConfirmationState() + { + var source = new CommandProfile + { + Command = "systemctl restart nginx", + Description = "Restart nginx", + RequestTTY = "force" + }; + + var json = JsonConvert.SerializeObject(source); + var loaded = JsonConvert.DeserializeObject(json); + + Assert.NotNull(loaded); + Assert.Equal(source.Command, loaded!.Command); + Assert.Equal(source.Description, loaded.Description); + Assert.Equal("force", loaded.RequestTTY); + Assert.DoesNotContain("RequireConfirmation", json); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void LegacyJson_WithRequireConfirmation_IsStillReadable(bool legacyValue) + { + var json = $$""" + { + "Kind": "remote-command", + "Command": "uptime", + "RequireConfirmation": {{legacyValue.ToString().ToLowerInvariant()}} + } + """; + + var loaded = JsonConvert.DeserializeObject(json); + + Assert.NotNull(loaded); + Assert.Equal("uptime", loaded!.Command); + Assert.True(loaded.IsSupportedKind); + Assert.Null(typeof(CommandProfile).GetProperty("RequireConfirmation")); + } + + [Fact] + public void Model_HasNoPrivateKeyContentProperty() + { + Assert.Null(typeof(CommandProfile).GetProperty("PrivateKey")); + Assert.Null(typeof(CommandProfile).GetProperty("PrivateKeyContent")); + } + } +} diff --git a/Tests/DisplayStringRegressionTests.cs b/Tests/DisplayStringRegressionTests.cs index 82a6259..97f23ac 100644 --- a/Tests/DisplayStringRegressionTests.cs +++ b/Tests/DisplayStringRegressionTests.cs @@ -11,7 +11,7 @@ namespace Flow.Launcher.Plugin.QuickSSH.Tests /// public class DisplayStringRegressionTests { - private static string GetMainCsSource() + private static string? GetMainCsSource() { var mainCsPath = Path.Combine( AppContext.BaseDirectory, diff --git a/Tests/FinalUxPolishTests.cs b/Tests/FinalUxPolishTests.cs new file mode 100644 index 0000000..9df9a90 --- /dev/null +++ b/Tests/FinalUxPolishTests.cs @@ -0,0 +1,187 @@ +using System; +using System.IO; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class FinalUxPolishTests + { + [Theory] + [InlineData("HandleActionsAdd", "HandleActionsRemove", "plugin_quickssh_wizard_actions_add_name_title", "plugin_quickssh_wizard_actions_add_command_title", "plugin_quickssh_actions_save_title")] + [InlineData("HandleKeysAdd", "HandleKeysInstall", "plugin_quickssh_wizard_keys_add_name_title", "plugin_quickssh_wizard_keys_add_path_title", "plugin_quickssh_keys_save_title")] + public void AddFlows_GuideNameThenValueAndEndWithExplicitSave( + string method, + string nextMethod, + string firstStepKey, + string secondStepKey, + string saveKey) + { + var region = ReadMethod(ReadMain(), method, nextMethod); + + Assert.True(Count(region, "MakeWizardExampleResultFromKeys(") >= 2, + $"{method} must provide two actionable example rows."); + Assert.Contains(firstStepKey, region); + Assert.Contains(secondStepKey, region); + Assert.Contains("AppIconRedPath", region); + Assert.Contains(saveKey, region); + Assert.Contains("AppIconGreenPath", region); + Assert.DoesNotContain("MakeQueryTemplateResult(", region); + } + + [Fact] + public void ProfileAdd_GuidesNameServerPortAndAuthenticationBeforeSave() + { + var region = ReadMethod(ReadMain(), "HandleProfilesAdd", "HandleProfilesRemove"); + + Assert.Contains("plugin_quickssh_wizard_profiles_add_name_title", region); + Assert.Contains("plugin_quickssh_wizard_profiles_add_target_title", region); + Assert.Contains("plugin_quickssh_profiles_port_default_title", region); + Assert.Contains("plugin_quickssh_profiles_port_custom_title", region); + Assert.Contains("ProfileWizard.PortOption", region); + Assert.Contains("ProfileWizard.SavedKeyOption", region); + Assert.Contains("ProfileWizard.DefaultAuthOption", region); + Assert.Contains("ProfileWizard.IsUsablePrivateKey", region); + Assert.Contains("plugin_quickssh_profiles_save_title", region); + Assert.Contains("AppIconGreenPath", region); + } + + [Fact] + public void WizardExamples_AreLocalizedActionRows() + { + var source = ReadMain(); + var helperStart = source.IndexOf( + "private Result MakeWizardExampleResultFromKeys(", StringComparison.Ordinal); + var helperEnd = source.IndexOf( + "private static string GetBackNavigationLabel(", helperStart, StringComparison.Ordinal); + + Assert.True(helperStart >= 0 && helperEnd > helperStart); + var helper = source.Substring(helperStart, helperEnd - helperStart); + Assert.Contains("GetTranslation(titleKey)", helper); + Assert.Contains("GetTranslation(subtitleKey)", helper); + Assert.Contains("AutoCompleteText = exampleQuery", helper); + Assert.Contains("ChangeQuery(exampleQuery, true)", helper); + Assert.DoesNotContain("private Result MakeWizardStepResult(", source); + } + + [Fact] + public void ProfileSelectionViews_UseCompactSharedSubtitle() + { + var source = ReadMain(); + var rename = ReadMethod(source, "HandleProfilesRename", "HandleProfilesCopy"); + var copy = ReadMethod(source, "HandleProfilesCopy", "HandleProfilesExport"); + + Assert.Contains("BuildProfileListSubtitle(entry.Value)", rename); + Assert.DoesNotContain("SubTitle = entry.Value?.ToDisplayString()", rename); + Assert.DoesNotContain("SubTitle = profileValue?.ToDisplayString()", rename); + Assert.Contains("BuildProfileListSubtitle(entry.Value)", copy); + } + + [Fact] + public void SavedKeyRows_UseExplicitPrivateOrPublicLabels() + { + var keys = ReadMethod(ReadMain(), "HandleKeysList", "HandleKeysManage"); + Assert.Contains("plugin_quickssh_keys_private_path_label", keys); + Assert.Contains("plugin_quickssh_keys_public_path_label", keys); + Assert.Contains("EndsWith(\".pub\", StringComparison.OrdinalIgnoreCase)", keys); + } + + [Fact] + public void SlovakLabels_AreShortAndExplainNonDestructiveKeyRemoval() + { + var text = File.ReadAllText(Path.Combine(ProjectRoot(), "Languages", "sk.xaml")); + + Assert.Contains("x:Key=\"plugin_quickssh_title_commandshell_add\">Pridať shell<", text); + Assert.Contains("x:Key=\"plugin_quickssh_title_commandshell_remove\">Odstrániť shell<", text); + Assert.Contains("x:Key=\"plugin_quickssh_title_commandkeys_install\">Nainštalovať verejný kľúč<", text); + Assert.Contains("x:Key=\"plugin_quickssh_title_commandkeys_rename\">Premenovať kľúč<", text); + Assert.Contains("x:Key=\"plugin_quickssh_title_commandkeys_remove\">Odstrániť uložený kľúč<", text); + Assert.Contains("súbor kľúča zostane zachovaný", text); + Assert.DoesNotContain("shell profil", text.ToLowerInvariant()); + var lower = text.ToLowerInvariant(); + Assert.DoesNotContain("uložené aliasy", lower); + Assert.DoesNotContain("neplatný alias", lower); + Assert.DoesNotContain("alias už existuje", lower); + Assert.DoesNotContain("alias kľúča", lower); + } + + [Fact] + public void EveryLanguage_DefinesFinalUxTranslationKeys() + { + var languages = new[] { "en", "de", "es", "fr", "pl", "ru", "sk" }; + var keys = new[] + { + "plugin_quickssh_wizard_profiles_add_name_title", + "plugin_quickssh_wizard_profiles_add_target_title", + "plugin_quickssh_wizard_profiles_add_auth_title", + "plugin_quickssh_profiles_port_default_title", + "plugin_quickssh_profiles_port_custom_title", + "plugin_quickssh_profiles_port_invalid_title", + "plugin_quickssh_profiles_key_public_subtitle", + "plugin_quickssh_profiles_key_invalid_subtitle", + "plugin_quickssh_profiles_auth_default_title", + "plugin_quickssh_profiles_auth_advanced_title", + "plugin_quickssh_wizard_profiles_rename_prefilled_subtitle", + "plugin_quickssh_wizard_actions_rename_prefilled_subtitle", + "plugin_quickssh_wizard_keys_rename_prefilled_subtitle", + "plugin_quickssh_wizard_actions_add_name_title", + "plugin_quickssh_wizard_actions_add_command_title", + "plugin_quickssh_wizard_keys_add_name_title", + "plugin_quickssh_wizard_keys_add_path_title", + "plugin_quickssh_wizard_shell_add_name_title", + "plugin_quickssh_wizard_shell_add_command_title", + "plugin_quickssh_wizard_profiles_rename_title", + "plugin_quickssh_wizard_actions_rename_title", + "plugin_quickssh_wizard_keys_rename_title", + "plugin_quickssh_name_unchanged", + "plugin_quickssh_keys_private_path_label", + "plugin_quickssh_keys_public_path_label", + }; + + foreach (var language in languages) + { + var text = File.ReadAllText(Path.Combine( + ProjectRoot(), "Languages", language + ".xaml")); + foreach (var key in keys) + Assert.Contains($"x:Key=\"{key}\"", text); + } + } + + private static int Count(string text, string value) + { + var count = 0; + var index = 0; + while ((index = text.IndexOf(value, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += value.Length; + } + return count; + } + + private static string ReadMain() + { + return File.ReadAllText(Path.Combine(ProjectRoot(), "Main.cs")); + } + + private static string ReadMethod( + string source, + string methodName, + string nextMethodName) + { + var start = source.IndexOf( + $"private List {methodName}(", StringComparison.Ordinal); + var end = source.IndexOf( + $"private List {nextMethodName}(", start, StringComparison.Ordinal); + + Assert.True(start >= 0, $"Method {methodName} was not found."); + Assert.True(end > start, $"Boundary {nextMethodName} was not found."); + return source.Substring(start, end - start); + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, "..", "..", "..", "..")); + } + } +} diff --git a/Tests/Flow.Launcher.Plugin.QuickSSH.Tests.csproj b/Tests/Flow.Launcher.Plugin.QuickSSH.Tests.csproj index ffbe332..2aa27e1 100644 --- a/Tests/Flow.Launcher.Plugin.QuickSSH.Tests.csproj +++ b/Tests/Flow.Launcher.Plugin.QuickSSH.Tests.csproj @@ -5,6 +5,7 @@ true false enable + true diff --git a/Tests/IconSemanticsTests.cs b/Tests/IconSemanticsTests.cs new file mode 100644 index 0000000..e7aa97f --- /dev/null +++ b/Tests/IconSemanticsTests.cs @@ -0,0 +1,192 @@ +using System; +using System.IO; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class IconSemanticsTests + { + [Theory] + [InlineData("add")] + [InlineData("generate")] + [InlineData("import")] + [InlineData("export")] + [InlineData("install")] + [InlineData("scan")] + [InlineData("run")] + [InlineData("saved")] + public void PositiveOperations_AreGreen(string operation) + { + Assert.Equal("Images\\app-green.png", QuickSsh.GetSemanticIconPath(operation)); + } + + [Theory] + [InlineData("rename")] + [InlineData("edit")] + [InlineData("update")] + public void EditOperations_AreOrange(string operation) + { + Assert.Equal("Images\\app-orange.png", QuickSsh.GetSemanticIconPath(operation)); + } + + [Theory] + [InlineData("remove")] + [InlineData("delete")] + public void DestructiveOperations_AreRed(string operation) + { + Assert.Equal("Images\\app-red.png", QuickSsh.GetSemanticIconPath(operation)); + } + + [Theory] + [InlineData("copy")] + [InlineData("manage")] + public void NeutralOperations_AreBlue(string operation) + { + Assert.Equal("Images\\app.png", QuickSsh.GetSemanticIconPath(operation)); + } + + [Fact] + public void SemanticIconMapping_IsCentralizedAndUsedByOperationFlows() + { + var root = Path.GetFullPath(Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..")); + var source = File.ReadAllText(Path.Combine(root, "Main.cs")); + + Assert.Contains("internal static string GetSemanticIconPath(string operation)", source); + Assert.Contains("IcoPath = GetSemanticIconPath(\"rename\")", source); + Assert.Contains("AppIconRedPath", source); + } + + [Fact] + public void OperationRows_UseApprovedSemanticColors() + { + var root = Path.GetFullPath(Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..")); + var source = File.ReadAllText(Path.Combine(root, "Main.cs")); + + Assert.Contains("IcoPath = AppIconGreenPath", ReadMethod( + source, "HandleProfilesAdd", "HandleProfilesRemove")); + Assert.Contains("IcoPath = AppIconRedPath", ReadMethod( + source, "HandleProfilesRemove", "HandleProfilesRename")); + Assert.Contains("IcoPath = GetSemanticIconPath(\"rename\")", ReadMethod( + source, "HandleProfilesRename", "HandleProfilesCopy")); + Assert.Contains("IcoPath = AppIconGreenPath", ReadMethod( + source, "HandleProfilesExport", "HandleProfilesImport")); + Assert.Contains("IcoPath = AppIconGreenPath", ReadMethod( + source, "HandleProfilesImport", "HandleDirectConnect")); + + Assert.Contains("IcoPath = AppIconGreenPath", ReadMethod( + source, "HandleActionsUse", "BuildActionConfirmationResults")); + Assert.Contains("IcoPath = AppIconGreenPath", ReadMethod( + source, "HandleActionsAdd", "HandleActionsRemove")); + Assert.Contains("IcoPath = AppIconRedPath", ReadMethod( + source, "HandleActionsRemove", "HandleActionsRename")); + Assert.Contains("IcoPath = AppIconOrangePath", ReadMethod( + source, "HandleActionsRename", "HandleActionsRun")); + + var keyAdd = ReadMethod(source, "HandleKeysAdd", "HandleKeysInstall"); + Assert.Contains("if (!File.Exists(expandedPath))", keyAdd); + Assert.Contains("IcoPath = AppIconRedPath", keyAdd); + Assert.Contains("IcoPath = AppIconGreenPath", keyAdd); + Assert.Contains("IcoPath = AppIconGreenPath", ReadMethod( + source, "HandleKeysInstall", "HandleKeysGenerate")); + Assert.Contains("IcoPath = AppIconGreenPath", ReadMethod( + source, "HandleKeysGenerate", "HandleKeysRemove")); + Assert.Contains("IcoPath = AppIconRedPath", ReadMethod( + source, "HandleKeysRemove", "HandleKeysRename")); + Assert.Contains("IcoPath = GetSemanticIconPath(\"rename\")", ReadMethod( + source, "HandleKeysRename", "HandleKeysCopyPath")); + Assert.Contains("IcoPath = AppIconGreenPath", ReadMethod( + source, "HandleKeysScan", "HandleConfig")); + + var shellRegion = ReadMethod(source, "HandleShell", "HandleKeys"); + Assert.Contains("IcoPath = AppIconGreenPath", shellRegion); + Assert.Contains("IcoPath = AppIconRedPath", shellRegion); + } + + [Fact] + public void SavedKeysAreGreenAndHelpIsNeutralBlue() + { + var root = Path.GetFullPath(Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..")); + var source = File.ReadAllText(Path.Combine(root, "Main.cs")); + + var keys = ReadMethod(source, "HandleKeysList", "HandleKeysManage"); + Assert.Contains("IcoPath = fileExists ? AppIconGreenPath : AppIconRedPath", keys); + + var docsStart = source.IndexOf( + "private List HandleDocs(", StringComparison.Ordinal); + var docsEnd = source.IndexOf("#endregion", docsStart, StringComparison.Ordinal); + Assert.True(docsStart >= 0 && docsEnd > docsStart); + var docs = source.Substring(docsStart, docsEnd - docsStart); + Assert.Contains("IcoPath = AppIconPath", docs); + Assert.DoesNotContain("IcoPath = AppIconGreenPath", docs); + } + + [Fact] + public void CopyExecutionRows_RemainNeutralBlue() + { + var root = Path.GetFullPath(Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..")); + var source = File.ReadAllText(Path.Combine(root, "Main.cs")); + + Assert.Contains("IcoPath = GetSemanticIconPath(\"copy\")", ReadMethod( + source, "HandleProfilesCopy", "HandleProfilesExport")); + Assert.Contains("IcoPath = GetSemanticIconPath(\"copy\")", ReadMethod( + source, "HandleKeysCopyPath", "HandleKeysCopyPub")); + Assert.Contains("IcoPath = GetSemanticIconPath(\"copy\")", ReadMethod( + source, "HandleKeysCopyPub", "HandleKeysScan")); + } + + private static void AssertMethodContainsIcon( + string source, + string methodName, + string nextMethodName, + string operation) + { + var region = ReadMethod(source, methodName, nextMethodName); + Assert.Contains($"IcoPath = GetSemanticIconPath(\"{operation}\")", region); + } + + private static void AssertMethodFirstIcon( + string source, + string methodName, + string nextMethodName, + string operation) + { + var region = ReadMethod(source, methodName, nextMethodName); + var iconStart = region.IndexOf("IcoPath = ", StringComparison.Ordinal); + Assert.True(iconStart >= 0, $"No icon found in {methodName}."); + + var iconEnd = region.IndexOf('\n', iconStart); + var iconLine = iconEnd >= 0 + ? region.Substring(iconStart, iconEnd - iconStart) + : region.Substring(iconStart); + + Assert.Equal($"IcoPath = GetSemanticIconPath(\"{operation}\"),", iconLine.Trim()); + } + + private static string ReadMethod(string source, string methodName, string nextMethodName) + { + var start = source.IndexOf( + $"private List {methodName}(", StringComparison.Ordinal); + var end = source.IndexOf( + $"private List {nextMethodName}(", start, StringComparison.Ordinal); + + Assert.True(start >= 0, $"Method {methodName} was not found."); + Assert.True(end > start, $"Method boundary {nextMethodName} was not found."); + return source.Substring(start, end - start); + } + + [Fact] + public void Autocomplete_UsesTheSameSemanticIconMapping() + { + var root = Path.GetFullPath(Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..")); + var source = File.ReadAllText(Path.Combine(root, "AutoCompleter.cs")); + + Assert.Contains("QuickSsh.GetSemanticIconPath(sub)", source); + Assert.Contains("QuickSsh.GetSemanticIconPath(\"saved\")", source); + } + } +} diff --git a/Tests/ProfileImportServiceTests.cs b/Tests/ProfileImportServiceTests.cs new file mode 100644 index 0000000..43c66ef --- /dev/null +++ b/Tests/ProfileImportServiceTests.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class ProfileImportServiceTests : IDisposable + { + private readonly string _tmpDir; + + public ProfileImportServiceTests() + { + _tmpDir = Path.Combine(Path.GetTempPath(), $"quickssh_import_tests_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tmpDir); + } + + public void Dispose() + { + if (Directory.Exists(_tmpDir)) + Directory.Delete(_tmpDir, recursive: true); + } + + private ProfileManager CreateManager(out string path) + { + path = Path.Combine(_tmpDir, "profiles.json"); + return new ProfileManager(path); + } + + [Fact] + public void Import_NewProfile_CreatesBackupAndPersists() + { + var manager = CreateManager(out var path); + manager.UserData.Profiles["existing"] = new SshProfile { HostName = "old.example" }; + var before = File.ReadAllText(path); + + var result = ProfileImportService.Import( + manager, + new Dictionary + { + ["new"] = new SshProfile { HostName = "new.example" } + }); + + Assert.Equal(1, result.ImportedCount); + Assert.Equal(0, result.SkippedCount); + Assert.Equal(path + ".import.bak", result.BackupPath); + Assert.Equal(before, File.ReadAllText(result.BackupPath)); + + manager.UserData.Profiles["after"] = new SshProfile { HostName = "after.example" }; + + var reloaded = new ProfileManager(path); + Assert.True(reloaded.UserData.Profiles.ContainsKey("existing")); + Assert.True(reloaded.UserData.Profiles.ContainsKey("new")); + Assert.True(reloaded.UserData.Profiles.ContainsKey("after")); + } + + [Fact] + public void Import_ExistingName_IsSkippedCaseInsensitively() + { + var manager = CreateManager(out var path); + manager.UserData.Profiles["Production"] = + new SshProfile { HostName = "original.example" }; + var before = File.ReadAllText(path); + + var result = ProfileImportService.Import( + manager, + new Dictionary + { + ["production"] = new SshProfile { HostName = "replacement.example" } + }); + + Assert.Equal(0, result.ImportedCount); + Assert.Equal(1, result.SkippedCount); + Assert.Equal(before, File.ReadAllText(path)); + Assert.Equal(before, File.ReadAllText(result.BackupPath)); + Assert.Equal("original.example", manager.UserData.Profiles["Production"].HostName); + } + + [Fact] + public void Import_SaveFailure_RestoresMemoryAndDisk() + { + var manager = CreateManager(out var path); + manager.UserData.Profiles["existing"] = + new SshProfile { HostName = "old.example" }; + var before = File.ReadAllText(path); + + Assert.Throws(() => + ProfileImportService.Import( + manager, + new Dictionary + { + ["new"] = new SshProfile { HostName = "new.example" } + }, + () => throw new IOException("Simulated write failure."))); + + Assert.Equal(before, File.ReadAllText(path)); + Assert.Equal(before, File.ReadAllText(path + ".import.bak")); + Assert.True(manager.UserData.Profiles.ContainsKey("existing")); + Assert.False(manager.UserData.Profiles.ContainsKey("new")); + + manager.UserData.Profiles["after"] = new SshProfile { HostName = "after.example" }; + + var reloaded = new ProfileManager(path); + Assert.True(reloaded.UserData.Profiles.ContainsKey("existing")); + Assert.False(reloaded.UserData.Profiles.ContainsKey("new")); + Assert.True(reloaded.UserData.Profiles.ContainsKey("after")); + } + } +} diff --git a/Tests/ProfileManagerTests.cs b/Tests/ProfileManagerTests.cs index 50a353e..6c93363 100644 --- a/Tests/ProfileManagerTests.cs +++ b/Tests/ProfileManagerTests.cs @@ -310,13 +310,13 @@ public void AutoSaveDictionary_Count_ReflectsCurrentState() { var path = GetTmpPath(); var pm = new ProfileManager(path); - Assert.Equal(0, pm.UserData.Profiles.Count); + Assert.Empty(pm.UserData.Profiles); pm.UserData.Profiles["x"] = new SshProfile { HostName = "x.host" }; - Assert.Equal(1, pm.UserData.Profiles.Count); + Assert.Single(pm.UserData.Profiles); pm.UserData.Profiles.Remove("x"); - Assert.Equal(0, pm.UserData.Profiles.Count); + Assert.Empty(pm.UserData.Profiles); } } } diff --git a/Tests/ProfileWizardTests.cs b/Tests/ProfileWizardTests.cs new file mode 100644 index 0000000..2cf4645 --- /dev/null +++ b/Tests/ProfileWizardTests.cs @@ -0,0 +1,211 @@ +using System; +using System.IO; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public sealed class ProfileWizardTests : IDisposable + { + private readonly string _tempDir; + + public ProfileWizardTests() + { + _tempDir = Path.Combine( + Path.GetTempPath(), + "quickssh-profile-wizard-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + [Fact] + public void BuildPrefilledRenameQuery_DuplicatesOnlyEditableName() + { + Assert.Equal( + "ssh profiles rename dev-runtime dev-runtime", + ProfileWizard.BuildPrefilledRenameQuery( + "ssh", "profiles rename", "dev-runtime")); + } + + + [Fact] + public void BuildSuggestedName_SkipsExistingSuffixes() + { + Assert.Equal( + "dev-runtime-4", + ProfileWizard.BuildSuggestedName( + "dev-runtime", + new[] { "dev-runtime", "dev-runtime-2", "DEV-RUNTIME-3" })); + + Assert.Equal( + "ssh profiles rename dev-runtime dev-runtime-4", + ProfileWizard.BuildRenameQuery( + "ssh", "profiles rename", "dev-runtime", "dev-runtime-4")); + + Assert.Equal( + "dev-runtime-4", + ProfileWizard.BuildSuggestedName( + "dev-runtime-3", + new[] { "dev-runtime-3" })); + + var longSuggestion = ProfileWizard.BuildSuggestedName( + new string('a', 64), + Array.Empty()); + Assert.Equal(64, longSuggestion.Length); + Assert.EndsWith("-2", longSuggestion); + } + + [Fact] + public void BuildAvailableName_UsesPreferredNameOrNextFreeSuffix() + { + Assert.Equal( + "server", + ProfileWizard.BuildAvailableName("server", Array.Empty())); + + Assert.Equal( + "server-3", + ProfileWizard.BuildAvailableName( + "server", + new[] { "server", "SERVER-2" })); + } + + [Theory] + [InlineData("server", null, "server")] + [InlineData("vaio@10.0.0.10", "vaio", "10.0.0.10")] + [InlineData("root@host_name.local", "root", "host_name.local")] + [InlineData("root@[2001:db8::1]", "root", "[2001:db8::1]")] + public void TryParseDestination_AcceptsSafeBeginnerTargets( + string input, + string? expectedUser, + string expectedHost) + { + Assert.True(ProfileWizard.TryParseDestination( + input, out var user, out var host)); + Assert.Equal(expectedUser, user); + Assert.Equal(expectedHost, host); + } + + [Theory] + [InlineData("root@host;reboot")] + [InlineData("root@host && whoami")] + [InlineData("root@@host")] + [InlineData("-oProxyCommand=bad")] + public void TryParseDestination_RejectsUnsafeOrComplexInput(string input) + { + Assert.False(ProfileWizard.TryParseDestination( + input, out _, out _)); + } + + [Fact] + public void TryParseBasicInput_RecognizesPortAndAuthenticationChoice() + { + Assert.True(ProfileWizard.TryParseBasicInput( + "vaio@dev --port 2222 --key private_key", + out var destination, + out var keyAlias, + out var useDefault, + out var port)); + + Assert.Equal("vaio@dev", destination); + Assert.Equal("private_key", keyAlias); + Assert.False(useDefault); + Assert.Equal("2222", port); + + Assert.True(ProfileWizard.TryParseBasicInput( + "vaio@dev --port 22 --default", + out destination, + out keyAlias, + out useDefault, + out port)); + + Assert.Equal("vaio@dev", destination); + Assert.Null(keyAlias); + Assert.True(useDefault); + Assert.Equal("22", port); + } + + [Theory] + [InlineData("vaio@dev --port 0")] + [InlineData("vaio@dev --port 65536")] + [InlineData("vaio@dev --port abc")] + [InlineData("vaio@dev --port")] + public void TryParseBasicInput_RejectsInvalidPort(string input) + { + Assert.False(ProfileWizard.TryParseBasicInput( + input, out _, out _, out _, out _)); + } + + [Fact] + public void TryCreateBasicProfile_AddsPortIdentityAndIdentitiesOnly() + { + Assert.True(ProfileWizard.TryCreateBasicProfile( + "vaio@dev", + @"C:\Users\info\.ssh\private_key", + "2222", + out var profile)); + + Assert.Equal("ssh", profile.Type); + Assert.Equal("vaio", profile.User); + Assert.Equal("dev", profile.HostName); + Assert.Equal("2222", profile.Port); + Assert.Equal(@"C:\Users\info\.ssh\private_key", profile.IdentityFile); + Assert.True(profile.IdentitiesOnly); + Assert.Contains("-p 2222", profile.ToCommandLine()); + Assert.Contains("-o IdentitiesOnly=yes", profile.ToCommandLine()); + + Assert.True(ProfileWizard.TryCreateBasicProfile( + "vaio@dev", null, "22", out var defaultPortProfile)); + Assert.Null(defaultPortProfile.Port); + } + + [Fact] + public void IsUsablePrivateKey_ValidatesContentNotOnlyFileName() + { + var privatePath = Path.Combine(_tempDir, "private_key"); + var publicPathWithoutPubSuffix = Path.Combine(_tempDir, "public_key"); + var unknownPath = Path.Combine(_tempDir, "not_a_key"); + File.WriteAllText(privatePath, "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA"); + File.WriteAllText(publicPathWithoutPubSuffix, "ssh-ed25519 AAAATEST user@host"); + File.WriteAllText(unknownPath, "plain text"); + + Assert.Equal( + ProfileWizard.SshKeyFileKind.Private, + ProfileWizard.GetKeyFileKind(privatePath)); + Assert.Equal( + ProfileWizard.SshKeyFileKind.Public, + ProfileWizard.GetKeyFileKind(publicPathWithoutPubSuffix)); + Assert.Equal( + ProfileWizard.SshKeyFileKind.Unknown, + ProfileWizard.GetKeyFileKind(unknownPath)); + Assert.Equal( + ProfileWizard.SshKeyFileKind.Missing, + ProfileWizard.GetKeyFileKind(Path.Combine(_tempDir, "missing"))); + + Assert.True(ProfileWizard.IsUsablePrivateKey( + new SshKeyEntry { Path = privatePath })); + Assert.False(ProfileWizard.IsUsablePrivateKey( + new SshKeyEntry { Path = publicPathWithoutPubSuffix })); + Assert.False(ProfileWizard.IsUsablePrivateKey( + new SshKeyEntry { Path = unknownPath })); + } + + [Theory] + [InlineData("ssh root@server")] + [InlineData("scp file root@server:/tmp/file")] + public void IsAdvancedCommand_PreservesFullCommandWorkflow(string input) + { + Assert.True(ProfileWizard.IsAdvancedCommand(input)); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + catch + { + // Best-effort test cleanup. + } + } + } +} diff --git a/Tests/RemoteKeyInstallBuilderTests.cs b/Tests/RemoteKeyInstallBuilderTests.cs index f0eefa8..66b791d 100644 --- a/Tests/RemoteKeyInstallBuilderTests.cs +++ b/Tests/RemoteKeyInstallBuilderTests.cs @@ -347,16 +347,57 @@ public void BuildRunCommand_RunAndCopyAreDifferent() [InlineData("admin@10.0.0.1", true)] [InlineData("root@server.example.com", true)] [InlineData("user@host", true)] + [InlineData("user.name_1@host-1.example_lab", true)] [InlineData("", false)] [InlineData("noatsign", false)] [InlineData("@host", false)] // empty user [InlineData("user@", false)] // empty host [InlineData("user @host", false)] // space in destination + [InlineData(" user@host", false)] + [InlineData("user@host ", false)] + [InlineData("user@@host", false)] + [InlineData("-oProxyCommand@host", false)] + [InlineData("user@-oProxyCommand", false)] + [InlineData("user@host:22", false)] public void IsValidUserAtHost_ValidatesCorrectly(string input, bool expected) { Assert.Equal(expected, RemoteKeyInstallBuilder.IsValidUserAtHost(input)); } + [Theory] + [InlineData("user@host&calc")] + [InlineData("user@host|whoami")] + [InlineData("user@host;whoami")] + [InlineData("user@host>file")] + [InlineData("user@host(() => + RemoteKeyInstallBuilder.BuildFullSshCommand("user@host&calc", bootstrap)); + } + + [Fact] + public void BuildRunCommand_RejectsUnsafeDestination() + { + var bootstrap = RemoteKeyInstallBuilder.BuildBootstrapCommand("ssh-ed25519 AAAA user@host"); + + Assert.Throws(() => + RemoteKeyInstallBuilder.BuildRunCommand("user@host|whoami", bootstrap)); + } + [Fact] public void IsValidUserAtHost_RejectsNull() { diff --git a/Tests/SearchMatcherTests.cs b/Tests/SearchMatcherTests.cs new file mode 100644 index 0000000..06fe6ec --- /dev/null +++ b/Tests/SearchMatcherTests.cs @@ -0,0 +1,34 @@ +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class SearchMatcherTests + { + [Fact] + public void ContainsIgnoreAccents_MatchesEquivalentText() + { + Assert.True(SearchMatcher.ContainsIgnoreAccents("sérver", "server")); + } + + [Fact] + public void ScoreProfile_TransposedLongSearch_UsesFuzzyMatch() + { + Assert.Equal(4, SearchMatcher.ScoreProfile("sevrer", "server", "ssh host")); + } + + [Fact] + public void ScoreProfile_ShortTransposition_DoesNotEnableFuzzyMatch() + { + Assert.Equal(int.MaxValue, SearchMatcher.ScoreProfile("wbe", "web", "ssh host")); + } + + [Fact] + public void QuickSshWrapper_PreservesSearchMatcherResult() + { + int expected = SearchMatcher.ScoreProfile("prod", "myserver", "ssh user@prod.example.com"); + int actual = QuickSsh.ScoreProfile("prod", "myserver", "ssh user@prod.example.com"); + + Assert.Equal(expected, actual); + } + } +} diff --git a/Tests/ShellLaunchPlanTests.cs b/Tests/ShellLaunchPlanTests.cs new file mode 100644 index 0000000..abd565d --- /dev/null +++ b/Tests/ShellLaunchPlanTests.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class ShellLaunchPlanTests + { + [Fact] + public void TryCreate_QuotedExecutablePathAndPrefixArguments_ArePreserved() + { + var shells = new Dictionary + { + ["PowerShell"] = "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -NoLogo" + }; + string? observedExecutable = null; + + var success = ShellLaunchPlan.TryCreate( + "ssh admin@server", + "PowerShell", + shells, + executable => + { + observedExecutable = executable; + return executable; + }, + @"C:\Windows\System32\cmd.exe", + out var plan, + out var error); + + Assert.True(success); + Assert.Equal(ShellLaunchPlanError.None, error); + var actualPlan = Assert.IsType(plan); + Assert.Equal(@"C:\Program Files\PowerShell\7\pwsh.exe", observedExecutable); + Assert.Equal(@"C:\Program Files\PowerShell\7\pwsh.exe", actualPlan.FileName); + Assert.Equal("-NoLogo ssh admin@server", actualPlan.Arguments); + Assert.False(actualPlan.UsesDefaultShell); + } + + [Fact] + public void TryCreate_SelectedShellMissing_FailsClosed() + { + var resolverCalls = 0; + + var success = ShellLaunchPlan.TryCreate( + "ssh admin@server", + "PowerShell", + new Dictionary(), + executable => + { + resolverCalls++; + return executable; + }, + @"C:\Windows\System32\cmd.exe", + out var plan, + out var error); + + Assert.False(success); + Assert.Null(plan); + Assert.Equal(ShellLaunchPlanError.SelectedShellMissing, error); + Assert.Equal(0, resolverCalls); + } + + [Fact] + public void TryCreate_SelectedExecutableMissing_DoesNotCreateCmdFallback() + { + var shells = new Dictionary + { + ["PowerShell"] = "missing-pwsh.exe -NoLogo" + }; + + var success = ShellLaunchPlan.TryCreate( + "ssh admin@server", + "PowerShell", + shells, + _ => null, + @"C:\Windows\System32\cmd.exe", + out var plan, + out var error); + + Assert.False(success); + Assert.Null(plan); + Assert.Equal(ShellLaunchPlanError.ExecutableNotFound, error); + } + + [Fact] + public void TryCreate_NoSelectedShell_UsesExplicitDefaultCmdPlan() + { + var success = ShellLaunchPlan.TryCreate( + "ssh admin@server", + null, + new Dictionary(), + _ => throw new InvalidOperationException("Resolver must not be called for the default shell."), + @"C:\Windows\System32\cmd.exe", + out var plan, + out var error); + + Assert.True(success); + Assert.Equal(ShellLaunchPlanError.None, error); + var actualPlan = Assert.IsType(plan); + Assert.True(actualPlan.UsesDefaultShell); + Assert.Equal(@"C:\Windows\System32\cmd.exe", actualPlan.FileName); + Assert.Equal("/k ssh admin@server", actualPlan.Arguments); + } + + [Fact] + public void TryStart_WhenProcessStartThrows_AttemptsExactlyOnce() + { + var plan = new ShellLaunchPlan( + @"C:\Program Files\PowerShell\7\pwsh.exe", + "-NoLogo ssh admin@server", + "PowerShell", + usesDefaultShell: false); + var attempts = 0; + + var success = ShellCommandLauncher.TryStart( + plan, + @"C:\Users\test", + _ => + { + attempts++; + throw new InvalidOperationException("launch failed"); + }, + out var error); + + Assert.False(success); + Assert.Equal(1, attempts); + var actualError = Assert.IsType(error); + Assert.Equal("launch failed", actualError.Message); + } + + [Fact] + public void TryStart_Success_UsesPlanWithoutChangingItsShell() + { + var plan = new ShellLaunchPlan( + @"C:\Program Files\PowerShell\7\pwsh.exe", + "-NoLogo ssh admin@server", + "PowerShell", + usesDefaultShell: false); + ProcessStartInfo? captured = null; + + var success = ShellCommandLauncher.TryStart( + plan, + @"C:\Users\test", + startInfo => + { + captured = startInfo; + return new Process(); + }, + out var error); + + Assert.True(success); + Assert.Null(error); + var actualStartInfo = Assert.IsType(captured); + Assert.Equal(plan.FileName, actualStartInfo.FileName); + Assert.Equal(plan.Arguments, actualStartInfo.Arguments); + Assert.True(actualStartInfo.UseShellExecute); + Assert.Equal(@"C:\Users\test", actualStartInfo.WorkingDirectory); + } + } +} diff --git a/Tests/SshKeysTests.cs b/Tests/SshKeysTests.cs index 8e8c180..5b19a4f 100644 --- a/Tests/SshKeysTests.cs +++ b/Tests/SshKeysTests.cs @@ -90,6 +90,7 @@ public void SshKeyEntry_Deserialize_NewFormat_AllFields() }"; var entry = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + Assert.NotNull(entry); Assert.Equal(@"C:\Users\me\.ssh\id_ed25519", entry.Path); Assert.Equal(@"C:\Users\me\.ssh\id_ed25519.pub", entry.PublicKeyPath); Assert.Equal("SHA256:abc123", entry.Fingerprint); @@ -179,10 +180,10 @@ public void UserData_SshKeys_RenamePreservesValue() // ── AutoCompleter keys suggestions ──────────────────────────────────────── [Fact] - public void GetSuggestions_PartialKe_ReturnsKeys() + public void GetSuggestions_PartialKe_DoesNotExposeKeysInSimplifiedRootMenu() { var results = AutoCompleter.GetSuggestions("ssh", "ke", null, "icon.png"); - Assert.Contains(results, r => r.Title == "keys"); + Assert.DoesNotContain(results, r => r.Title == "keys"); } [Fact] @@ -194,6 +195,8 @@ public void GetSuggestions_KeysSpace_SuggestsAllSubCommands() Assert.Contains("add", titles); Assert.Contains("generate", titles); + Assert.Contains("install", titles); + Assert.Contains("manage", titles); Assert.Contains("remove", titles); Assert.Contains("rename", titles); Assert.Contains("copy-path", titles); @@ -208,6 +211,8 @@ public void GetSuggestions_KeysSpace_SuggestsAllSubCommands() [InlineData("g", new[] { "generate" })] [InlineData("ge", new[] { "generate" })] [InlineData("gen", new[] { "generate" })] + [InlineData("m", new[] { "manage" })] + [InlineData("ma", new[] { "manage" })] [InlineData("r", new[] { "remove", "rename" })] [InlineData("re", new[] { "remove", "rename" })] [InlineData("rem", new[] { "remove" })] @@ -222,7 +227,7 @@ public void GetSuggestions_KeysPartialPrefix_ShowsMatchingSubCommands( string partial, string[] expected) { var results = AutoCompleter.GetSuggestions("ssh", "keys " + partial, null, "icon.png"); - var allSubCmds = new HashSet { "add", "generate", "remove", "rename", "copy-path", "copy-pub", "scan" }; + var allSubCmds = new HashSet { "install", "manage", "add", "generate", "remove", "rename", "copy-path", "copy-pub", "scan" }; var subCommandTitles = results .Select(r => r.Title) .Where(t => allSubCmds.Contains(t)) @@ -270,37 +275,40 @@ public void GetSuggestions_KeysPrefixWithSearch_FiltersAliases() // ── Keys submenu score invariants ───────────────────────────────────────── [Fact] - public void KeysSubmenu_ManagementRowIsAboveAllActionRows() + public void KeysSubmenu_ManagementRowIsAbovePrimaryRows() { - Assert.True(QuickSsh.ScoreSubMenuManagement > QuickSsh.ScoreKeysActionAdd, - "Management row must outrank every keys action row."); + Assert.True(QuickSsh.ScoreSubMenuManagement > QuickSsh.ScoreKeysActionInstall, + "Management row must outrank the install action row."); } [Fact] - public void KeysSubmenu_AllActionRowsAreAboveSavedItems() + public void KeysSubmenu_SavedItemsAndInstallAreAboveManage() { - Assert.True(QuickSsh.ScoreKeysActionScan > QuickSsh.ScoreKeysSavedItem, - "The scan action row (lowest action score) must appear above saved keys."); + Assert.True(QuickSsh.ScoreKeysSavedItem > QuickSsh.ScoreKeysActionInstall, + "Saved keys must appear above the install row."); + Assert.True(QuickSsh.ScoreKeysActionInstall > QuickSsh.ScoreKeysActionManage, + "Install row must appear above the manage row."); } [Fact] - public void KeysSubmenu_ActionRowScoresAreInDescendingOrder() + public void KeysManage_ActionRowScoresAreInDescendingOrder() { - // add > generate > remove > rename > copy-path > copy-pub > scan - Assert.True(QuickSsh.ScoreKeysActionAdd > QuickSsh.ScoreKeysActionGenerate); - Assert.True(QuickSsh.ScoreKeysActionGenerate > QuickSsh.ScoreKeysActionRemove); - Assert.True(QuickSsh.ScoreKeysActionRemove > QuickSsh.ScoreKeysActionRename); - Assert.True(QuickSsh.ScoreKeysActionRename > QuickSsh.ScoreKeysActionCopyPath); - Assert.True(QuickSsh.ScoreKeysActionCopyPath > QuickSsh.ScoreKeysActionCopyPub); - Assert.True(QuickSsh.ScoreKeysActionCopyPub > QuickSsh.ScoreKeysActionScan); + // add > generate > scan > rename > copy-path > copy-pub > remove + Assert.True(QuickSsh.ScoreKeysManageAdd > QuickSsh.ScoreKeysManageGenerate); + Assert.True(QuickSsh.ScoreKeysManageGenerate > QuickSsh.ScoreKeysManageScan); + Assert.True(QuickSsh.ScoreKeysManageScan > QuickSsh.ScoreKeysManageRename); + Assert.True(QuickSsh.ScoreKeysManageRename > QuickSsh.ScoreKeysManageCopyPath); + Assert.True(QuickSsh.ScoreKeysManageCopyPath > QuickSsh.ScoreKeysManageCopyPub); + Assert.True(QuickSsh.ScoreKeysManageCopyPub > QuickSsh.ScoreKeysManageRemove); } [Fact] - public void KeysSubmenu_ActionRowScoresAreSafeAboveSavedItemBase() + public void KeysSubmenu_ManageRowsAreSafeAboveDefaultScore() { - int gap = QuickSsh.ScoreKeysActionScan - QuickSsh.ScoreKeysSavedItem; - Assert.True(gap > 500, - $"Scan action score must exceed saved item base by > 500 (actual gap: {gap})."); + Assert.True(QuickSsh.ScoreKeysActionManage >= 1000, + "Keys manage action score must be >= 1000 to be safe from fuzzy boosting."); + Assert.True(QuickSsh.ScoreKeysManageCopyPub >= 1000, + "Keys manage copy-pub score must be >= 1000 to be safe from fuzzy boosting."); } // ── ScanSshDirectory filtering ──────────────────────────────────────────── @@ -451,6 +459,7 @@ public void SshKeyEntry_Deserialize_WithNewFields() }"; var entry = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + Assert.NotNull(entry); Assert.Equal(@"C:\Users\me\.ssh\id_ed25519", entry.Path); Assert.Equal("ed25519", entry.Algorithm); Assert.Equal("generated", entry.Source); @@ -512,20 +521,19 @@ public void GetSuggestions_KeysPartialG_SuggestsGenerate() // ── Generate score placement ────────────────────────────────────────────── [Fact] - public void KeysSubmenu_GenerateScoreIsBetweenAddAndRemove() + public void KeysManage_GenerateScoreIsBetweenAddAndScan() { - Assert.True(QuickSsh.ScoreKeysActionAdd > QuickSsh.ScoreKeysActionGenerate, - "Add must outrank generate."); - Assert.True(QuickSsh.ScoreKeysActionGenerate > QuickSsh.ScoreKeysActionRemove, - "Generate must outrank remove."); + Assert.True(QuickSsh.ScoreKeysManageAdd > QuickSsh.ScoreKeysManageGenerate, + "Add must outrank generate in the manage menu."); + Assert.True(QuickSsh.ScoreKeysManageGenerate > QuickSsh.ScoreKeysManageScan, + "Generate must outrank scan in the manage menu."); } [Fact] - public void KeysSubmenu_GenerateScoreIsSafeAboveSavedItems() + public void KeysManage_GenerateScoreIsSafeAboveDefaultScore() { - int gap = QuickSsh.ScoreKeysActionGenerate - QuickSsh.ScoreKeysSavedItem; - Assert.True(gap > 500, - $"Generate action score must exceed saved item base by > 500 (actual gap: {gap})."); + Assert.True(QuickSsh.ScoreKeysManageGenerate >= 1000, + "Generate score must be >= 1000 to be safe from fuzzy boosting."); } // ── Generated key auto-register availability ────────────────────────────── @@ -837,8 +845,8 @@ public void CustomPath_ParentCreation_Succeeds() finally { // Clean up the top-level temp dir - var root = Path.Combine(Path.GetTempPath(), - Path.GetFileName(Path.GetDirectoryName(Path.GetDirectoryName(dir)))); + var root = Directory.GetParent(dir)?.Parent?.FullName + ?? throw new InvalidOperationException("Unable to resolve the temporary test root."); if (Directory.Exists(root)) Directory.Delete(root, true); } diff --git a/Tests/SubmenuOrderingTests.cs b/Tests/SubmenuOrderingTests.cs index d48bdb7..79ff071 100644 --- a/Tests/SubmenuOrderingTests.cs +++ b/Tests/SubmenuOrderingTests.cs @@ -1,121 +1,204 @@ +using System; +using System.IO; using Xunit; namespace Flow.Launcher.Plugin.QuickSSH.Tests { - /// - /// Verifies the submenu score invariants that drive consistent display ordering. - /// Flow Launcher sorts Result objects by Score descending, so the required layout: - /// 1. management row - /// 2. action rows - /// 3. saved items - /// must be enforced through the Score constants alone. - /// - /// Root cause of the original profiles ordering bug: - /// Action row scores were 10-60 and saved profiles used Score=0. Flow Launcher's - /// built-in fuzzy-match bonus can boost a Score=0 result by hundreds of points, - /// pushing saved profiles above the "Import profiles" action row (Score=10). - /// The fix mirrors the shell submenu: action rows use the 1010-1060 range and - /// saved profiles decrement from 500, matching the scale used by ScoreShellOtherStart. - /// public class SubmenuOrderingTests { - // ── profiles submenu ────────────────────────────────────────────────────── - [Fact] - public void ProfilesSubmenu_ManagementRowIsAboveAllActionRows() + public void Profiles_SavedItemsAreAboveManage() { - Assert.True(QuickSsh.ScoreSubMenuManagement > QuickSsh.ScoreProfilesActionAdd, - "Management row must outrank every profiles action row."); + Assert.True(QuickSsh.ScoreProfilesSavedItem > QuickSsh.ScoreProfilesActionManage); } [Fact] - public void ProfilesSubmenu_AllActionRowsAreAboveSavedItems() + public void ProfilesManage_DestructiveActionIsLast() { - // The lowest-priority action row (import) must still beat a saved profile entry. - Assert.True(QuickSsh.ScoreProfilesActionImport > QuickSsh.ScoreProfilesSavedItem, - "The import action row (lowest action score) must appear above saved profiles."); + Assert.True(QuickSsh.ScoreProfilesManageAdd > QuickSsh.ScoreProfilesManageRename); + Assert.True(QuickSsh.ScoreProfilesManageRename > QuickSsh.ScoreProfilesManageCopy); + Assert.True(QuickSsh.ScoreProfilesManageCopy > QuickSsh.ScoreProfilesManageExport); + Assert.True(QuickSsh.ScoreProfilesManageExport > QuickSsh.ScoreProfilesManageImport); + Assert.True(QuickSsh.ScoreProfilesManageImport > QuickSsh.ScoreProfilesManageRemove); } [Fact] - public void ProfilesSubmenu_ActionRowScoresAreInDescendingOrder() + public void Actions_SavedItemsAreAboveManage() { - // add > remove > rename > copy > export > import - Assert.True(QuickSsh.ScoreProfilesActionAdd > QuickSsh.ScoreProfilesActionRemove); - Assert.True(QuickSsh.ScoreProfilesActionRemove > QuickSsh.ScoreProfilesActionRename); - Assert.True(QuickSsh.ScoreProfilesActionRename > QuickSsh.ScoreProfilesActionCopy); - Assert.True(QuickSsh.ScoreProfilesActionCopy > QuickSsh.ScoreProfilesActionExport); - Assert.True(QuickSsh.ScoreProfilesActionExport > QuickSsh.ScoreProfilesActionImport); + Assert.True(QuickSsh.ScoreActionsSavedItem > QuickSsh.ScoreActionsActionManage); + Assert.True(QuickSsh.ScoreActionsManageAdd > QuickSsh.ScoreActionsManageRename); + Assert.True(QuickSsh.ScoreActionsManageRename > QuickSsh.ScoreActionsManageRemove); } [Fact] - public void ProfilesSubmenu_ActionRowScoresAreSafeAboveSavedItemBase() + public void Shell_SavedItemsAreAboveManage() { - // The gap between the lowest action row (import) and the highest possible saved - // profile score (ScoreProfilesSavedItem, used as the decrement start) must be - // large enough that Flow Launcher's fuzzy-match bonus cannot bridge it. - // A gap > 500 is considered safe based on observed Flow Launcher scoring. - int gap = QuickSsh.ScoreProfilesActionImport - QuickSsh.ScoreProfilesSavedItem; - Assert.True(gap > 500, - $"Import action score must exceed saved item base by > 500 (actual gap: {gap})."); + Assert.True(QuickSsh.ScoreShellSelected > QuickSsh.ScoreShellOtherStart); + Assert.True(QuickSsh.ScoreShellOtherStart > QuickSsh.ScoreShellActionManage); + Assert.True(QuickSsh.ScoreShellManageAdd > QuickSsh.ScoreShellManageRemove); } - // ── shell submenu ───────────────────────────────────────────────────────── + [Fact] + public void Keys_PrimaryRowsAreAboveManage() + { + Assert.True(QuickSsh.ScoreKeysSavedItem > QuickSsh.ScoreKeysActionInstall); + Assert.True(QuickSsh.ScoreKeysActionInstall > QuickSsh.ScoreKeysActionManage); + } [Fact] - public void ShellSubmenu_ManagementRowIsAboveAllActionRows() + public void KeysManage_DestructiveActionIsLast() { - Assert.True(QuickSsh.ScoreSubMenuManagement > QuickSsh.ScoreShellActionAdd, - "Management row must outrank every shell action row."); + Assert.True(QuickSsh.ScoreKeysManageRename > QuickSsh.ScoreKeysManageCopyPath); + Assert.True(QuickSsh.ScoreKeysManageCopyPath > QuickSsh.ScoreKeysManageCopyPub); + Assert.True(QuickSsh.ScoreKeysManageCopyPub > QuickSsh.ScoreKeysManageRemove); } [Fact] - public void ShellSubmenu_AllActionRowsAreAboveSelectedShell() + public void Tools_PrimaryNavigationRowsAreOrdered() { - // The lower-priority action row (remove) must still beat the selected shell entry. - Assert.True(QuickSsh.ScoreShellActionRemove > QuickSsh.ScoreShellSelected, - "The remove action row must appear above the selected shell entry."); + Assert.True(QuickSsh.ScoreToolsKeys > QuickSsh.ScoreToolsShell); + Assert.True(QuickSsh.ScoreToolsShell > QuickSsh.ScoreToolsConfig); } [Fact] - public void ShellSubmenu_AllActionRowsAreAboveOtherShells() + public void MainSubmenus_HaveNoPassiveHeadingOrAddRow() { - // "other shells" start at ScoreShellOtherStart and decrement; the action rows - // must exceed even the maximum (first) other-shell score. - Assert.True(QuickSsh.ScoreShellActionRemove > QuickSsh.ScoreShellOtherStart, - "The remove action row must appear above the highest-scored non-selected shell."); + var source = ReadMain(); + + var profiles = ReadMethod(source, "HandleProfilesList", "HandleProfilesManage"); + Assert.DoesNotContain("ScoreSubMenuManagement", profiles); + Assert.DoesNotContain("plugin_quickssh_title_commandprofiles_add", profiles); + + var actions = ReadMethod(source, "HandleActionsList", "HandleActionsManage"); + Assert.DoesNotContain("ScoreSubMenuManagement", actions); + Assert.DoesNotContain("plugin_quickssh_title_commandactions_add", actions); + + var shell = ReadMethod(source, "HandleShell", "HandleShellManage"); + var shellDefault = shell.IndexOf("default:", StringComparison.Ordinal); + var shellManage = shell.IndexOf("var manageText", shellDefault, StringComparison.Ordinal); + Assert.True(shellDefault >= 0 && shellManage > shellDefault); + Assert.DoesNotContain("plugin_quickssh_noshells", + shell.Substring(shellDefault, shellManage - shellDefault)); + + var keys = ReadMethod(source, "HandleKeysList", "HandleKeysManage"); + Assert.DoesNotContain("ScoreSubMenuManagement", keys); + Assert.DoesNotContain("plugin_quickssh_title_commandkeys_add", keys); } [Fact] - public void ShellSubmenu_SelectedShellIsAboveOtherShells() + public void ManageSubmenus_ContainAddAndStartWithBack() { - Assert.True(QuickSsh.ScoreShellSelected > QuickSsh.ScoreShellOtherStart, - "The selected shell must appear above other (non-selected) shells."); + var source = ReadMain(); + + AssertManage(source, "HandleProfilesManage", "HandleLegacyAddRedirect", + "plugin_quickssh_title_commandprofiles_add"); + AssertManage(source, "HandleActionsManage", "HandleActionsUse", + "plugin_quickssh_title_commandactions_add"); + AssertManage(source, "HandleShellManage", "HandleKeys", + "plugin_quickssh_title_commandshell_add"); + AssertManage(source, "HandleKeysManage", "HandleKeysAdd", + "plugin_quickssh_title_commandkeys_add"); } [Fact] - public void ShellSubmenu_ActionRowAddIsAboveActionRowRemove() + public void ConfigAndHelp_HaveNoPassiveHeading() { - Assert.True(QuickSsh.ScoreShellActionAdd > QuickSsh.ScoreShellActionRemove); + var source = ReadMain(); + Assert.DoesNotContain("ScoreSubMenuManagement", + ReadMethod(source, "HandleConfig", "HandleDocs")); + Assert.DoesNotContain("ScoreSubMenuManagement", + ReadMethod(source, "HandleDocs", "#endregion")); } - // ── cross-submenu consistency ───────────────────────────────────────────── + [Fact] - public void BothSubmenus_ShareTheSameManagementRowScore() + public void DeepOperationViews_StartWithBackAndHaveNoPassiveHeading() { - // The management row constant is used identically in both submenus. - Assert.Equal(int.MaxValue, QuickSsh.ScoreSubMenuManagement); + var source = ReadMain(); + var methods = new[] + { + ("HandleProfilesAdd", "HandleProfilesRemove"), + ("HandleProfilesRemove", "HandleProfilesRename"), + ("HandleProfilesRename", "HandleProfilesCopy"), + ("HandleProfilesCopy", "HandleProfilesExport"), + ("HandleProfilesExport", "HandleProfilesImport"), + ("HandleProfilesImport", "HandleDirectConnect"), + ("HandleActionsUse", "BuildActionConfirmationResults"), + ("BuildActionConfirmationResults", "HandleActionsAdd"), + ("HandleActionsAdd", "HandleActionsRemove"), + ("HandleActionsRemove", "HandleActionsRename"), + ("HandleActionsRename", "HandleActionsRun"), + ("HandleActionsRun", "HandleTools"), + ("HandleShell", "HandleShellManage"), + ("HandleKeysAdd", "HandleKeysInstall"), + ("HandleKeysInstall", "HandleKeysGenerate"), + ("HandleKeysGenerate", "HandleKeysRemove"), + ("HandleKeysRemove", "HandleKeysRename"), + ("HandleKeysRename", "HandleKeysCopyPath"), + ("HandleKeysCopyPath", "HandleKeysCopyPub"), + ("HandleKeysCopyPub", "HandleKeysScan"), + ("HandleKeysScan", "HandleConfig"), + }; + + foreach (var (method, nextMethod) in methods) + { + var region = ReadMethod(source, method, nextMethod); + Assert.DoesNotContain("Score = ScoreSubMenuManagement", region); + + var back = region.IndexOf("MakeBackNavResult", StringComparison.Ordinal); + var firstRow = region.IndexOf("new Result", StringComparison.Ordinal); + Assert.True(back >= 0, $"{method} must contain back navigation."); + Assert.True(firstRow < 0 || back < firstRow, + $"{method} must place back navigation before every visible row."); + } } [Fact] - public void BothSubmenus_ActionRowScoresAreOnTheSameScale() + public void ActionSelectionAndConfirmation_UseCompactProfileSummaries() { - // Both submenus must use the 1000+ range for action rows so the ordering - // invariant holds regardless of Flow Launcher's internal fuzzy-match bonus. - Assert.True(QuickSsh.ScoreProfilesActionImport >= 1000, - "Profiles import action score must be >= 1000 to be safe from fuzzy boosting."); - Assert.True(QuickSsh.ScoreShellActionRemove >= 1000, - "Shell remove action score must be >= 1000 to be safe from fuzzy boosting."); + var source = ReadMain(); + var use = ReadMethod(source, "HandleActionsUse", "BuildActionConfirmationResults"); + Assert.Contains("BuildProfileListSubtitle(entry.Value)", use); + Assert.DoesNotContain("plugin_quickssh_title_commandactions_run", use); + + var confirmation = ReadMethod( + source, "BuildActionConfirmationResults", "HandleActionsAdd"); + Assert.Contains("plugin_quickssh_actions_execute_summary", confirmation); + Assert.Contains("plugin_quickssh_actions_copy_command_title", confirmation); + Assert.DoesNotContain("plugin_quickssh_actions_profile_label", confirmation); + Assert.DoesNotContain("plugin_quickssh_actions_action_label", confirmation); + } + + private static void AssertManage( + string source, string method, string nextMethod, string addKey) + { + var region = ReadMethod(source, method, nextMethod); + Assert.Contains("MakeBackNavResult", region); + Assert.Contains(addKey, region); + Assert.DoesNotContain("ScoreSubMenuManagement", region); + } + + private static string ReadMain() + { + var root = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, "..", "..", "..", "..")); + return File.ReadAllText(Path.Combine(root, "Main.cs")); + } + + private static string ReadMethod( + string source, string methodName, string nextMethodName) + { + var start = source.IndexOf( + $"private List {methodName}(", StringComparison.Ordinal); + var end = nextMethodName == "#endregion" + ? source.IndexOf("#endregion", start, StringComparison.Ordinal) + : source.IndexOf( + $"private List {nextMethodName}(", start, StringComparison.Ordinal); + + Assert.True(start >= 0, $"Method {methodName} was not found."); + Assert.True(end > start, $"Boundary {nextMethodName} was not found."); + return source.Substring(start, end - start); } } } diff --git a/Tests/ToolsMenuTests.cs b/Tests/ToolsMenuTests.cs new file mode 100644 index 0000000..c6e74c3 --- /dev/null +++ b/Tests/ToolsMenuTests.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class ToolsMenuTests + { + [Fact] + public void RootMenu_ContainsOnlyProfilesActionsToolsAndHelp() + { + var source = ReadSource("AutoCompleter.cs"); + Assert.Contains("\"profiles\", \"actions\", \"tools\", \"help\"", source); + Assert.DoesNotContain("\"profiles\", \"actions\", \"shell\"", source); + } + + [Fact] + public void ToolsMenu_GroupsKeysShellAndConfig() + { + var source = ReadSource("Main.cs"); + var start = source.IndexOf("private List HandleTools(", StringComparison.Ordinal); + var end = source.IndexOf("private List HandleShell(", start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + var region = source.Substring(start, end - start); + Assert.Contains("CommandKeys", region); + Assert.Contains("CommandCustomShell", region); + Assert.Contains("CommandConfig", region); + Assert.Contains("MakeBackNavResult", region); + } + + [Fact] + public void ProfileList_UsesConciseSubtitle() + { + var profile = new SshProfile + { + Type = "ssh", User = "vaio", HostName = "dev", Port = "22", + IdentityFile = @"C:\Users\info\.ssh\private_key" + }; + Assert.Equal("vaio@dev • private_key", QuickSsh.BuildProfileListSubtitle(profile)); + } + + private static string ReadSource(string name) + { + var root = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, "..", "..", "..", "..")); + return File.ReadAllText(Path.Combine(root, name)); + } + } +} diff --git a/Tests/UxConsolidationTests.cs b/Tests/UxConsolidationTests.cs new file mode 100644 index 0000000..9472136 --- /dev/null +++ b/Tests/UxConsolidationTests.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class UxConsolidationTests + { + [Fact] + public void EveryLanguage_DefinesConsolidatedNavigationKeys() + { + var root = ProjectRoot(); + var languages = new[] { "en", "de", "es", "fr", "pl", "ru", "sk" }; + var keys = new[] + { + "plugin_quickssh_title_commandshell_manage", + "plugin_quickssh_actions_execute_summary", + "plugin_quickssh_actions_copy_command_title", + "plugin_quickssh_back_profiles_label", + "plugin_quickssh_back_profiles_manage_label", + "plugin_quickssh_back_profiles_selection_label", + "plugin_quickssh_profiles_remove_confirm", + "plugin_quickssh_back_actions_label", + "plugin_quickssh_back_actions_manage_label", + "plugin_quickssh_back_actions_profile_selection_label", + "plugin_quickssh_back_actions_action_selection_label", + "plugin_quickssh_back_keys_label", + "plugin_quickssh_back_keys_manage_label", + "plugin_quickssh_back_shell_label", + "plugin_quickssh_back_shell_manage_label", + "plugin_quickssh_back_tools_label", + }; + + foreach (var language in languages) + { + var text = File.ReadAllText(Path.Combine( + root, "Languages", language + ".xaml")); + foreach (var key in keys) + Assert.Contains($"x:Key=\"{key}\"", text); + } + } + + [Fact] + public void SlovakMenu_UsesShortNaturalTitles() + { + var text = File.ReadAllText(Path.Combine( + ProjectRoot(), "Languages", "sk.xaml")); + + Assert.Contains( + "x:Key=\"plugin_quickssh_title_commandprofiles\">Profily<", text); + Assert.Contains( + "x:Key=\"plugin_quickssh_title_commandactions\">Akcie<", text); + Assert.Contains( + "x:Key=\"plugin_quickssh_title_commandkeys\">SSH kľúče<", text); + Assert.Contains( + "x:Key=\"plugin_quickssh_title_commandshell\">Shell<", text); + Assert.Contains( + "x:Key=\"plugin_quickssh_title_commandconfig\">Importovať SSH konfiguráciu<", text); + } + + [Fact] + public void BackNavigation_UsesDedicatedHumanLabels() + { + var source = File.ReadAllText(Path.Combine(ProjectRoot(), "Main.cs")); + + Assert.Contains("plugin_quickssh_back_profiles_manage_label", source); + Assert.Contains("plugin_quickssh_back_actions_profile_selection_label", source); + Assert.Contains("plugin_quickssh_back_actions_action_selection_label", source); + Assert.Contains("plugin_quickssh_back_keys_manage_label", source); + Assert.Contains("plugin_quickssh_back_shell_manage_label", source); + Assert.DoesNotContain("return GetTranslation(\"plugin_quickssh_title_commandprofiles_manage\")", source); + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, "..", "..", "..", "..")); + } + } +} diff --git a/Tests/WizardUxTests.cs b/Tests/WizardUxTests.cs new file mode 100644 index 0000000..604f5fb --- /dev/null +++ b/Tests/WizardUxTests.cs @@ -0,0 +1,126 @@ +using System; +using System.IO; +using Xunit; + +namespace Flow.Launcher.Plugin.QuickSSH.Tests +{ + public class WizardUxTests + { + [Theory] + [InlineData("HandleProfilesRename", "HandleProfilesCopy", "plugin_quickssh_wizard_profiles_rename_title", "plugin_quickssh_profiles_rename_confirm_title")] + [InlineData("HandleActionsRename", "HandleActionsRun", "plugin_quickssh_wizard_actions_rename_title", "plugin_quickssh_actions_rename_confirm_title")] + [InlineData("HandleKeysRename", "HandleKeysCopyPath", "plugin_quickssh_wizard_keys_rename_title", "plugin_quickssh_keys_rename_confirm_title")] + public void RenameFlows_GuideTheNewNameAndRequireExplicitConfirmation( + string method, + string nextMethod, + string wizardKey, + string confirmKey) + { + var region = ReadMethod(ReadMain(), method, nextMethod); + Assert.Contains(wizardKey, region); + Assert.Contains(confirmKey, region); + Assert.Contains("ProfileWizard.BuildPrefilledRenameQuery", region); + Assert.Contains("ProfileWizard.BuildSuggestedName", region); + Assert.Contains("ProfileWizard.BuildRenameQuery", region); + Assert.Contains("MakeWizardExampleResult", region); + Assert.Contains("rename_prefilled_subtitle", region); + Assert.True(region.Contains("AppIconOrangePath", StringComparison.Ordinal) || + region.Contains("GetSemanticIconPath(\"rename\")", StringComparison.Ordinal)); + } + + + [Fact] + public void AddWizardRows_AreClickableAndUseAvailableExampleNames() + { + var source = ReadMain(); + var profiles = ReadMethod(source, "HandleProfilesAdd", "HandleProfilesRename"); + var actions = ReadMethod(source, "HandleActionsAdd", "HandleActionsRemove"); + var keys = ReadMethod(source, "HandleKeysAdd", "HandleKeysInstall"); + + Assert.Contains("ProfileWizard.BuildAvailableName(\"server\", profiles.Keys)", profiles); + Assert.Contains("ProfileWizard.BuildAvailableName(\"check\", actions.Keys)", actions); + Assert.Contains("ProfileWizard.BuildAvailableName(\"server-key\", keys.Keys)", keys); + Assert.Contains("ProfileWizard.BuildAvailableName(", source); + Assert.Contains("\"PowerShell\", _profileManager.UserData.CustomShell.Keys", source); + Assert.Contains("AutoCompleteText = exampleQuery", source); + Assert.Contains("ChangeQuery(exampleQuery, true)", source); + } + + [Fact] + public void ProfileWizard_OffersPortBeforeAuthenticationAndRejectsPublicKeys() + { + var region = ReadMethod(ReadMain(), "HandleProfilesAdd", "HandleProfilesRename"); + Assert.Contains("ProfileWizard.PortOption + \" 22\"", region); + Assert.Contains("Score = ScoreProfilesWizardDefaultPort", region); + Assert.Contains("Score = ScoreProfilesWizardCustomPort", region); + Assert.Contains("ScoreProfilesWizardSavedKeyStart", region); + Assert.Contains("Score = rowScore", region); + Assert.Contains("GetProfileKeyUnavailableSubtitle", region); + Assert.Contains("Score = ScoreProfilesWizardManageKeys", region); + Assert.Contains("Score = ScoreProfilesWizardDefaultAuth", region); + Assert.Contains("Score = ScoreProfilesWizardAdvanced", region); + } + + [Fact] + public void KeyAdd_RejectsMissingFilesInsteadOfSavingThem() + { + var region = ReadMethod(ReadMain(), "HandleKeysAdd", "HandleKeysInstall"); + Assert.Contains("if (!File.Exists(expandedPath))", region); + Assert.Contains("plugin_quickssh_keys_path_missing_title", region); + Assert.Contains("return results;", region); + Assert.Contains("plugin_quickssh_keys_save_title", region); + } + + [Fact] + public void ShellAdd_GuidesBothStepsAndKeepsOneTokenCompatibility() + { + var source = ReadMain(); + var start = source.IndexOf("case \"add\":", source.IndexOf("private List HandleShell(", StringComparison.Ordinal), StringComparison.Ordinal); + var end = source.IndexOf("case \"remove\":", start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + var region = source.Substring(start, end - start); + + Assert.Contains("plugin_quickssh_wizard_shell_add_name_title", region); + Assert.Contains("plugin_quickssh_wizard_shell_add_command_title", region); + Assert.Contains("plugin_quickssh_wizard_shell_use_name_subtitle", region); + Assert.Contains("plugin_quickssh_shell_save_title", region); + Assert.Contains("CustomShell[name] = \"\"", region); + } + + [Fact] + public void SlovakWizardText_ExplainsEveryRequiredInput() + { + var text = File.ReadAllText(Path.Combine(ProjectRoot(), "Languages", "sk.xaml")); + Assert.Contains("Doplniť príklad názvu: „{0}“", text); + Assert.Contains("Doplniť príklad servera: „user@host“", text); + Assert.Contains("Krok 3 zo 4 • Napíšte číslo od 1 do 65535.", text); + Assert.Contains("Krok 4 zo 4: Vyberte prihlásenie", text); + Assert.Contains("Verejný kľúč nemožno použiť na prihlásenie", text); + Assert.Contains("Doplniť príklad názvu: „{0}“", text); + Assert.Contains("Doplniť príklad príkazu: „hostname“", text); + Assert.Contains("Doplniť príklad názvu: „{0}“", text); + Assert.Contains("Doplniť príklad cesty: „~/.ssh/private_key“", text); + Assert.Contains("Doplniť návrh nového názvu: „{0}“", text); + } + + private static string ReadMain() + { + return File.ReadAllText(Path.Combine(ProjectRoot(), "Main.cs")); + } + + private static string ReadMethod(string source, string methodName, string nextMethodName) + { + var start = source.IndexOf($"private List {methodName}(", StringComparison.Ordinal); + var end = source.IndexOf($"private List {nextMethodName}(", start, StringComparison.Ordinal); + Assert.True(start >= 0, $"Method {methodName} was not found."); + Assert.True(end > start, $"Boundary {nextMethodName} was not found."); + return source.Substring(start, end - start); + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, "..", "..", "..", "..")); + } + } +} diff --git a/Utils.cs b/Utils.cs index 18d7299..cd4e876 100644 --- a/Utils.cs +++ b/Utils.cs @@ -13,36 +13,64 @@ public abstract class Utils /// Resolves the full path of an executable using the 'where' command. /// Returns the original name if resolution fails. /// - public static string ResolveExecutable(string exeName) + public static string ResolveExecutable(string exeName) => + TryResolveExecutable(exeName, out var resolvedPath) + ? resolvedPath + : exeName; + + /// + /// Resolves an executable to an existing file. Unlike , + /// this method fails when neither an explicit path nor PATH lookup can find the program. + /// + internal static bool TryResolveExecutable(string exeName, out string resolvedPath) { - if (Path.IsPathRooted(exeName) && File.Exists(exeName)) - return exeName; + resolvedPath = string.Empty; + if (string.IsNullOrWhiteSpace(exeName)) + return false; + + var candidate = exeName.Trim(); + if (Path.IsPathRooted(candidate)) + { + if (!File.Exists(candidate)) + return false; + + resolvedPath = Path.GetFullPath(candidate); + return true; + } try { - using var p = new Process + using var process = new Process { StartInfo = new ProcessStartInfo { - FileName = "where", - Arguments = exeName, + FileName = "where.exe", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true } }; - p.Start(); - string output = p.StandardOutput.ReadLine(); - // Drain stderr to prevent deadlock; we only need the first stdout line. - p.StandardError.ReadToEnd(); - p.WaitForExit(); - if (!string.IsNullOrWhiteSpace(output) && File.Exists(output.Trim())) - return output.Trim(); - } - catch { /* fall through */ } + process.StartInfo.ArgumentList.Add(candidate); + process.Start(); + var output = process.StandardOutput.ReadLine(); + process.StandardError.ReadToEnd(); + process.WaitForExit(); - return exeName; + if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(output)) + return false; + + var path = output.Trim(); + if (!File.Exists(path)) + return false; + + resolvedPath = Path.GetFullPath(path); + return true; + } + catch + { + return false; + } } /// @@ -88,7 +116,7 @@ public static bool IsSshKeygenInstalled() /// illegal in Windows file names. /// Returns if the result is empty. /// - public static string SanitizeKeyFileName(string alias) + public static string? SanitizeKeyFileName(string? alias) { if (string.IsNullOrWhiteSpace(alias)) return null; @@ -109,7 +137,7 @@ public static string SanitizeKeyFileName(string alias) /// characters are handled correctly. /// Surrounding quotes on the custom path are stripped. /// - internal static (string alias, string customPath) ParseGenerateArgs(string rest) + internal static (string alias, string customPath) ParseGenerateArgs(string? rest) { if (string.IsNullOrEmpty(rest)) return ("", ""); diff --git a/plugin.json b/plugin.json index 006cf44..aefb881 100644 --- a/plugin.json +++ b/plugin.json @@ -2,9 +2,9 @@ "ID": "86AC23FE48BC45E5B7E0A94F5847FA83", "ActionKeyword": "ssh", "Name": "QuickSSH", - "Description": "SSH/SCP plugin with structured profiles, SSH-config-like import/export, direct SSH, custom shell management, and query autocomplete via suggestions", + "Description": "Manage SSH profiles, reusable remote actions, SSH keys, config imports, and custom shells from Flow Launcher", "Author": "Vaso73", - "Version": "3.5.2", + "Version": "3.6.0", "Language": "csharp", "Website": "https://github.com/Vaso73/Flow.Launcher.Plugin.QuickSSH", "IcoPath": "Images\\app.png",