From 7e1092e91312928b57fd2427f2cf2ef90bff0e45 Mon Sep 17 00:00:00 2001 From: David Brett Date: Sat, 18 Jul 2026 15:49:08 +0200 Subject: [PATCH 1/3] Add Custom Template shell option to Shell plugin Adds a "Custom Template" entry to the shell selection dropdown. Users can provide any executable path and an arguments template using {command} as a placeholder. Falls back to RunCommand behavior when no executable path is configured. Leave Shell Open, Close After Press, and Use Windows Terminal checkboxes are disabled for Custom Template - the user has to manually implement those Use Windows Terminal is also now correctly disabled for RunCommand, which never supported it. --- Flow.Launcher.Test/Plugins/ShellPluginTest.cs | 206 +++++++++++++++++- ...nOrCloseShellAfterPressEnabledConverter.cs | 2 +- .../Languages/en.xaml | 4 + Plugins/Flow.Launcher.Plugin.Shell/Main.cs | 34 ++- .../Flow.Launcher.Plugin.Shell/Settings.cs | 48 ++++ .../ViewModels/ShellSettingViewModel.cs | 6 + .../Views/ShellSetting.xaml | 60 ++++- 7 files changed, 350 insertions(+), 10 deletions(-) diff --git a/Flow.Launcher.Test/Plugins/ShellPluginTest.cs b/Flow.Launcher.Test/Plugins/ShellPluginTest.cs index 5270b7e9f20..49fd466a04e 100644 --- a/Flow.Launcher.Test/Plugins/ShellPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/ShellPluginTest.cs @@ -18,7 +18,8 @@ private static ProcessStartInfo Create( bool leaveShellOpen = false, bool closeShellAfterPress = false, bool useWindowsTerminal = false, - bool runAsAdmin = false) + bool runAsAdmin = false, + CustomTemplateShellConfig customConfig = null) => Main.CreateProcessStartInfo( command, shell, @@ -26,7 +27,8 @@ private static ProcessStartInfo Create( closeShellAfterPress, useWindowsTerminal, runAsAdmin, - ClosePrompt); + ClosePrompt, + customConfig); #region CMD @@ -290,6 +292,181 @@ public void RunCommand_QuotedExecutablePath_ExtractsFileName() Assert.That(info.Arguments, Is.EqualTo("/c echo hello")); } + #endregion + + #region CustomTemplate + + [Test] + public void CustomTemplate_BasicExePath_WithDefaultTemplate() + { + var config = new CustomTemplateShellConfig + { + ExecutablePath = "pwsh.exe" + }; + var info = Create( + command: "Get-Process", + shell: Shell.CustomTemplate, + customConfig: config); + + Assert.That(info.FileName, Is.EqualTo("pwsh.exe")); + Assert.That(info.Arguments, Is.EqualTo("Get-Process")); + } + + [Test] + public void CustomTemplate_NullConfig_FallsBackToRunCommand() + { + var info = Create( + command: "notepad", + shell: Shell.CustomTemplate); + + Assert.That(info.FileName, Is.EqualTo("notepad")); + } + + [Test] + public void CustomTemplate_EmptyConfig_FallsBackToRunCommand() + { + var config = new CustomTemplateShellConfig(); + var info = Create( + command: "notepad test.txt", + shell: Shell.CustomTemplate, + customConfig: config); + + Assert.That(info.FileName, Is.EqualTo("notepad")); + Assert.That(info.Arguments, Is.EqualTo("test.txt")); + } + + [Test] + public void CustomTemplate_ReplacesCommandPlaceholder() + { + var config = new CustomTemplateShellConfig + { + ExecutablePath = "cmd.exe", + ArgumentsTemplate = "/c \"{command}\"" + }; + var info = Create( + command: "dir", + shell: Shell.CustomTemplate, + customConfig: config); + + Assert.That(info.FileName, Is.EqualTo("cmd.exe")); + Assert.That(info.Arguments, Is.EqualTo("/c \"dir\"")); + } + + [Test] + public void CustomTemplate_WhitespaceExecutablePath_FallsBackToRunCommand() + { + var config = new CustomTemplateShellConfig + { + ExecutablePath = " " + }; + var info = Create( + command: "notepad", + shell: Shell.CustomTemplate, + customConfig: config); + + Assert.That(info.FileName, Is.EqualTo("notepad")); + } + + [Test] + public void CustomTemplate_EmptyTemplate_FallsBackToDefaultTemplate() + { + var config = new CustomTemplateShellConfig + { + ExecutablePath = "pwsh.exe", + ArgumentsTemplate = "" + }; + var info = Create( + command: "ls", + shell: Shell.CustomTemplate, + customConfig: config); + + Assert.That(info.FileName, Is.EqualTo("pwsh.exe")); + Assert.That(info.Arguments, Is.EqualTo("ls")); + } + + [Test] + public void CustomTemplate_WhitespaceTemplate_FallsBackToDefaultTemplate() + { + var config = new CustomTemplateShellConfig + { + ExecutablePath = "pwsh.exe", + ArgumentsTemplate = " " + }; + var info = Create( + command: "ls", + shell: Shell.CustomTemplate, + customConfig: config); + + Assert.That(info.FileName, Is.EqualTo("pwsh.exe")); + Assert.That(info.Arguments, Is.EqualTo("ls")); + } + + [Test] + public void CustomTemplate_TrimsExecutablePath() + { + var config = new CustomTemplateShellConfig + { + ExecutablePath = " pwsh.exe ", + ArgumentsTemplate = "/c \"{command}\"" + }; + var info = Create( + command: "dir", + shell: Shell.CustomTemplate, + customConfig: config); + + Assert.That(info.FileName, Is.EqualTo("pwsh.exe")); + Assert.That(info.Arguments, Is.EqualTo("/c \"dir\"")); + } + + [Test] + public void CustomTemplate_ExpandsEnvironmentVariablesInExecutablePath() + { + var config = new CustomTemplateShellConfig + { + ExecutablePath = "%USERPROFILE%\\pwsh.exe" + }; + var info = Create( + command: "ls", + shell: Shell.CustomTemplate, + customConfig: config); + + var expectedPath = Environment.ExpandEnvironmentVariables("%USERPROFILE%\\pwsh.exe"); + Assert.That(info.FileName, Is.EqualTo(expectedPath)); + } + + [Test] + public void CustomTemplate_StripsQuotesFromExecutablePath() + { + var config = new CustomTemplateShellConfig + { + ExecutablePath = "\"C:\\Program Files\\pwsh.exe\"" + }; + var info = Create( + command: "ls", + shell: Shell.CustomTemplate, + customConfig: config); + + Assert.That(info.FileName, Is.EqualTo("C:\\Program Files\\pwsh.exe")); + Assert.That(info.Arguments, Is.EqualTo("ls")); + } + + [Test] + public void CustomTemplate_ExpandsEnvironmentVariablesInTemplate() + { + var config = new CustomTemplateShellConfig + { + ExecutablePath = "cmd.exe", + ArgumentsTemplate = "/c \"%USERPROFILE%\\{command}\"" + }; + var info = Create( + command: "test.cmd", + shell: Shell.CustomTemplate, + customConfig: config); + + var expectedArg = $"/c \"{Environment.ExpandEnvironmentVariables("%USERPROFILE%")}\\test.cmd\""; + Assert.That(info.Arguments, Is.EqualTo(expectedArg)); + } + #endregion #region Common @@ -307,6 +484,7 @@ public void SetsRunAsAdminVerb(bool runAsAdmin, string expectedVerb) [TestCase(Shell.Powershell)] [TestCase(Shell.Pwsh)] [TestCase(Shell.RunCommand)] + [TestCase(Shell.CustomTemplate)] public void SetsWorkingDirectory(Shell shell) { var info = Create(shell: shell); @@ -319,6 +497,7 @@ public void SetsWorkingDirectory(Shell shell) [TestCase(Shell.Powershell)] [TestCase(Shell.Pwsh)] [TestCase(Shell.RunCommand)] + [TestCase(Shell.CustomTemplate)] public void SetsUseShellExecute(Shell shell) { var info = Create(shell: shell); @@ -330,13 +509,25 @@ public void SetsUseShellExecute(Shell shell) [TestCase(Shell.Powershell)] [TestCase(Shell.Pwsh)] [TestCase(Shell.RunCommand)] + [TestCase(Shell.CustomTemplate)] public void ExpandsEnvironmentVariables(Shell shell) { + var expandedPath = Environment.ExpandEnvironmentVariables("%USERPROFILE%\\test"); + + CustomTemplateShellConfig config = null; + if (shell == Shell.CustomTemplate) + { + config = new CustomTemplateShellConfig + { + ExecutablePath = "cmd.exe", + ArgumentsTemplate = "/c \"{command}\"" + }; + } + var info = Create( command: "%USERPROFILE%\\test", - shell: shell); - - var expandedPath = Environment.ExpandEnvironmentVariables("%USERPROFILE%\\test"); + shell: shell, + customConfig: config); switch (shell) { @@ -350,9 +541,14 @@ public void ExpandsEnvironmentVariables(Shell shell) case Shell.RunCommand: Assert.That(info.FileName, Is.EqualTo(expandedPath)); break; + case Shell.CustomTemplate: + Assert.That(info.Arguments, Is.EqualTo($"/c \"{expandedPath}\"")); + break; } } #endregion + + } } diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Converters/LeaveShellOpenOrCloseShellAfterPressEnabledConverter.cs b/Plugins/Flow.Launcher.Plugin.Shell/Converters/LeaveShellOpenOrCloseShellAfterPressEnabledConverter.cs index 5649353e511..bda1216d240 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Converters/LeaveShellOpenOrCloseShellAfterPressEnabledConverter.cs +++ b/Plugins/Flow.Launcher.Plugin.Shell/Converters/LeaveShellOpenOrCloseShellAfterPressEnabledConverter.cs @@ -15,7 +15,7 @@ values[1] is not Shell shell ) return Binding.DoNothing; - return (!closeShellAfterPressOrLeaveShellOpen) && shell != Shell.RunCommand; + return (!closeShellAfterPressOrLeaveShellOpen) && shell != Shell.RunCommand && shell != Shell.CustomTemplate; } public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Languages/en.xaml b/Plugins/Flow.Launcher.Plugin.Shell/Languages/en.xaml index 2fb9c6b67be..d5a9cf40449 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Languages/en.xaml +++ b/Plugins/Flow.Launcher.Plugin.Shell/Languages/en.xaml @@ -19,4 +19,8 @@ Only show number of most used commands: Command not found: {0} Error running the command: {0} + Executable path + Arguments template + Full path to the shell executable. Example: C:\Windows\System32\cmd.exe + Use {command} as a placeholder. Example: /k "{command}" diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs index c4456403469..e1166cd7d46 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs @@ -199,7 +199,8 @@ private ProcessStartInfo PrepareProcessStartInfo(string command, bool runAsAdmin _settings.CloseShellAfterPress, _settings.UseWindowsTerminal, runAsAdmin, - closePrompt); + closePrompt, + _settings.CustomTemplateShellConfig); _settings.AddCmdHistory(command); return info; @@ -212,7 +213,8 @@ internal static ProcessStartInfo CreateProcessStartInfo( bool closeShellAfterPress, bool useWindowsTerminal, bool runAsAdmin, - string closePrompt) + string closePrompt, + CustomTemplateShellConfig customTemplateShellConfig = null) { command = command.Trim(); command = Environment.ExpandEnvironmentVariables(command); @@ -265,6 +267,13 @@ internal static ProcessStartInfo CreateProcessStartInfo( command); break; + case Shell.CustomTemplate: + ConfigureCustomTemplateShellStartInfo( + info, + command, + customTemplateShellConfig); + break; + default: throw new NotImplementedException(); } @@ -426,6 +435,27 @@ private static void ConfigureRunCommandStartInfo( } } + private static void ConfigureCustomTemplateShellStartInfo( + ProcessStartInfo info, + string command, + CustomTemplateShellConfig config) + { + if (config == null || string.IsNullOrWhiteSpace(config.ExecutablePath)) + { + // No valid custom shell configured, fall back to RunCommand behavior + ConfigureRunCommandStartInfo(info, command); + return; + } + + info.FileName = Environment.ExpandEnvironmentVariables(config.ExecutablePath).Trim().Trim('"');; + + var template = string.IsNullOrWhiteSpace(config.ArgumentsTemplate) + ? "{command}" + : Environment.ExpandEnvironmentVariables(config.ArgumentsTemplate).Trim(); + + info.Arguments = template.Replace("{command}", command); + } + private void Execute(Func startProcess, ProcessStartInfo info) { try diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Settings.cs b/Plugins/Flow.Launcher.Plugin.Shell/Settings.cs index 79a9065347d..f881a868271 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Settings.cs +++ b/Plugins/Flow.Launcher.Plugin.Shell/Settings.cs @@ -117,6 +117,20 @@ public int ShowOnlyMostUsedCMDsNumber } } + private CustomTemplateShellConfig _customTemplateShellConfig = new(); + public CustomTemplateShellConfig CustomTemplateShellConfig + { + get => _customTemplateShellConfig; + set + { + if (_customTemplateShellConfig != value) + { + _customTemplateShellConfig = value; + OnPropertyChanged(); + } + } + } + public Dictionary CommandHistory { get; set; } = []; public void AddCmdHistory(string cmdName) @@ -128,6 +142,37 @@ public void AddCmdHistory(string cmdName) } } + public class CustomTemplateShellConfig : BaseModel + { + private string _executablePath = ""; + public string ExecutablePath + { + get => _executablePath; + set + { + if (_executablePath != value) + { + _executablePath = value; + OnPropertyChanged(); + } + } + } + + private string _argumentsTemplate = "{command}"; + public string ArgumentsTemplate + { + get => _argumentsTemplate; + set + { + if (_argumentsTemplate != value) + { + _argumentsTemplate = value; + OnPropertyChanged(); + } + } + } + } + [EnumLocalize] public enum Shell { @@ -142,5 +187,8 @@ public enum Shell [EnumLocalizeValue("Pwsh")] Pwsh = 3, + + [EnumLocalizeValue("Custom Template")] + CustomTemplate = 4, } } diff --git a/Plugins/Flow.Launcher.Plugin.Shell/ViewModels/ShellSettingViewModel.cs b/Plugins/Flow.Launcher.Plugin.Shell/ViewModels/ShellSettingViewModel.cs index 341fc386803..9bcc9b45c5f 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/ViewModels/ShellSettingViewModel.cs +++ b/Plugins/Flow.Launcher.Plugin.Shell/ViewModels/ShellSettingViewModel.cs @@ -17,10 +17,16 @@ public Shell SelectedShell { Settings.Shell = value; OnPropertyChanged(); + OnPropertyChanged(nameof(IsCustomTemplateShell)); + OnPropertyChanged(nameof(IsWindowsTerminalEnabled)); } } } + public bool IsCustomTemplateShell => SelectedShell == Shell.CustomTemplate; + + public bool IsWindowsTerminalEnabled => SelectedShell != Shell.CustomTemplate && SelectedShell != Shell.RunCommand; + public List OnlyMostUsedCMDsNumbers { get; } = [5, 10, 20]; public int SelectedOnlyMostUsedCMDsNumber { diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Views/ShellSetting.xaml b/Plugins/Flow.Launcher.Plugin.Shell/Views/ShellSetting.xaml index 5f66884ea1b..176d7a21194 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Views/ShellSetting.xaml +++ b/Plugins/Flow.Launcher.Plugin.Shell/Views/ShellSetting.xaml @@ -23,6 +23,8 @@ + + + IsChecked="{Binding Settings.UseWindowsTerminal, Mode=TwoWay}" + IsEnabled="{Binding IsWindowsTerminalEnabled}" /> - + + + + + + + + + + + + + + + + + + Date: Sat, 18 Jul 2026 21:45:06 +0200 Subject: [PATCH 2/3] Add browse dialog button for executable file path in custom template shell configuration --- .../Languages/en.xaml | 3 +++ .../Views/ShellSetting.xaml | 5 ++++ .../Views/ShellSetting.xaml.cs | 27 ++++++++++++++++--- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Languages/en.xaml b/Plugins/Flow.Launcher.Plugin.Shell/Languages/en.xaml index d5a9cf40449..7e726845682 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Languages/en.xaml +++ b/Plugins/Flow.Launcher.Plugin.Shell/Languages/en.xaml @@ -20,6 +20,9 @@ Command not found: {0} Error running the command: {0} Executable path + Browse... + Executable files + All files Arguments template Full path to the shell executable. Example: C:\Windows\System32\cmd.exe Use {command} as a placeholder. Example: /k "{command}" diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Views/ShellSetting.xaml b/Plugins/Flow.Launcher.Plugin.Shell/Views/ShellSetting.xaml index 176d7a21194..72a85c96c8f 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Views/ShellSetting.xaml +++ b/Plugins/Flow.Launcher.Plugin.Shell/Views/ShellSetting.xaml @@ -111,6 +111,11 @@ VerticalAlignment="Center" Text="{Binding Settings.CustomTemplateShellConfig.ExecutablePath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" ToolTip="{DynamicResource flowlauncher_plugin_cmd_custom_template_exe_path_tooltip}" /> +