From a823aabbb9882ab62ea2264734fdb02baa96ee14 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 13 Aug 2026 14:33:05 +0200 Subject: [PATCH] feat(external-tools): force tools to open in new windows where needed Adds a helper that forces selected tools to launch in a new window instead of reusing the current one. This is applied to code editors, diff/merge, and terminal launches, aligning behavior across platforms. Presets and defaults are updated to pass new-window or equivalent flags, and tests verify the new behavior for common tools. - Update code editors to always use a new window when opened - Normalize launch flags for Windows terminals and diff tools - Add tests covering new-window behavior for common tools --- src-tauri/src/external_tools.rs | 159 +++++++++++++++++++++++++++++++- src/lib/externalTools.ts | 71 +++++++------- 2 files changed, 195 insertions(+), 35 deletions(-) diff --git a/src-tauri/src/external_tools.rs b/src-tauri/src/external_tools.rs index d191cb6..2826c91 100644 --- a/src-tauri/src/external_tools.rs +++ b/src-tauri/src/external_tools.rs @@ -405,8 +405,10 @@ fn known_tools() -> Vec { tool( "git-bash", "Git Bash", - &["bash.exe"], + &["git-bash.exe", "bash.exe"], &[ + r"%PROGRAMFILES%\Git\git-bash.exe", + r"%LOCALAPPDATA%\Programs\Git\git-bash.exe", r"%PROGRAMFILES%\Git\bin\bash.exe", r"%LOCALAPPDATA%\Programs\Git\bin\bash.exe", ], @@ -1350,6 +1352,101 @@ fn expand_args( .collect() } +fn command_in_new_window(mut command: ExternalToolCommand) -> ExternalToolCommand { + let executable = Path::new(command.program.trim()) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(command.program.trim()) + .to_ascii_lowercase(); + + let remove_flags = |args: &mut Vec, flags: &[&str]| { + args.retain(|arg| !flags.iter().any(|flag| arg.eq_ignore_ascii_case(flag))); + }; + let prepend_flag = |args: &mut Vec, flag: &str| { + if !args.iter().any(|arg| arg.eq_ignore_ascii_case(flag)) { + args.insert(0, flag.to_string()); + } + }; + + if matches!( + executable.as_str(), + "code" + | "code.exe" + | "code-insiders" + | "code-insiders.exe" + | "code - insiders.exe" + | "cursor" + | "cursor.exe" + | "windsurf" + | "windsurf.exe" + | "vscodium" + | "vscodium.exe" + | "codium" + | "codium.exe" + ) { + remove_flags(&mut command.args, &["--reuse-window", "-r"]); + prepend_flag(&mut command.args, "--new-window"); + } else if matches!(executable.as_str(), "zed" | "zed.exe") { + remove_flags(&mut command.args, &["--reuse", "-r"]); + prepend_flag(&mut command.args, "--new"); + } else if matches!( + executable.as_str(), + "subl" | "subl.exe" | "sublime_text" | "sublime_text.exe" + ) { + prepend_flag(&mut command.args, "--new-window"); + } else if executable == "notepad++.exe" { + prepend_flag(&mut command.args, "-multiInst"); + } else if executable == "kate" || executable == "kate.exe" { + prepend_flag(&mut command.args, "--new"); + } else if executable == "geany" || executable == "geany.exe" { + prepend_flag(&mut command.args, "--new-instance"); + } else if executable == "wt" || executable == "wt.exe" { + let mut index = 0; + while index < command.args.len() { + let is_window = command.args[index].eq_ignore_ascii_case("-w") + || command.args[index].eq_ignore_ascii_case("--window") + || command.args[index] + .to_ascii_lowercase() + .starts_with("--window="); + if is_window { + let has_separate_value = !command.args[index].contains('='); + command.args.remove(index); + if has_separate_value && index < command.args.len() { + command.args.remove(index); + } + } else { + index += 1; + } + } + command + .args + .splice(0..0, ["-w".to_string(), "new".to_string()]); + } else if executable == "explorer" || executable == "explorer.exe" { + prepend_flag(&mut command.args, "/n,"); + } else if matches!( + executable.as_str(), + "totalcmd" | "totalcmd.exe" | "totalcmd64" | "totalcmd64.exe" + ) { + command + .args + .retain(|arg| !arg.to_ascii_lowercase().starts_with("/o")); + prepend_flag(&mut command.args, "/N"); + } else if executable == "open" { + prepend_flag(&mut command.args, "-n"); + } else if executable.starts_with("bcomp") { + remove_flags(&mut command.args, &["/solo", "-solo"]); + #[cfg(windows)] + prepend_flag(&mut command.args, "/solo"); + #[cfg(not(windows))] + prepend_flag(&mut command.args, "-solo"); + } else if executable == "winmergeu" || executable == "winmergeu.exe" { + remove_flags(&mut command.args, &["/s", "/sw", "-s", "-sw"]); + prepend_flag(&mut command.args, "/s-"); + } + + command +} + fn tool_values(repo: &Path, file: Option<&Path>) -> BTreeMap<&'static str, String> { let mut values = BTreeMap::new(); values.insert("repo", repo.to_string_lossy().into_owned()); @@ -1371,11 +1468,10 @@ fn spawn_tool( repo: &Path, values: BTreeMap<&str, String>, ) -> Result<(), String> { + let command = command_in_new_window(command); let args = expand_args(&command, &values)?; let mut process = Command::new(command.program.trim()); process.args(args).current_dir(repo); - #[cfg(windows)] - process.creation_flags(CREATE_NO_WINDOW); process .spawn() .map_err(|error| format!("Could not launch external tool: {error}"))?; @@ -1388,6 +1484,7 @@ fn run_tool( values: BTreeMap<&str, String>, kind: ExternalToolRunKind, ) -> Result<(), String> { + let command = command_in_new_window(command); let args = expand_args(&command, &values)?; let program = command.program.trim().to_owned(); let mut process = Command::new(command.program.trim()); @@ -1580,6 +1677,62 @@ mod tests { ); } + #[test] + fn forces_code_family_tools_into_a_new_window() { + let command = command_in_new_window(ExternalToolCommand { + program: "C:/Program Files/Microsoft VS Code/Code.exe".into(), + args: vec![ + "--reuse-window".into(), + "--diff".into(), + "{left}".into(), + "{right}".into(), + ], + }); + + assert_eq!( + command.args, + vec!["--new-window", "--diff", "{left}", "{right}"] + ); + } + + #[test] + fn forces_windows_terminal_to_use_a_fresh_window() { + let command = command_in_new_window(ExternalToolCommand { + program: "wt.exe".into(), + args: vec![ + "--window".into(), + "last".into(), + "-d".into(), + "{repo}".into(), + ], + }); + + assert_eq!(command.args, vec!["-w", "new", "-d", "{repo}"]); + } + + #[test] + fn forces_single_instance_compare_and_file_tools_into_new_windows() { + let beyond = command_in_new_window(ExternalToolCommand { + program: "BCompare.exe".into(), + args: vec!["/readonly".into(), "{left}".into(), "{right}".into()], + }); + let winmerge = command_in_new_window(ExternalToolCommand { + program: "WinMergeU.exe".into(), + args: vec!["/s".into(), "{left}".into(), "{right}".into()], + }); + let total_commander = command_in_new_window(ExternalToolCommand { + program: "TOTALCMD64.EXE".into(), + args: vec!["/O".into(), "/T".into(), "{repo}".into()], + }); + + #[cfg(windows)] + assert_eq!(beyond.args[0], "/solo"); + #[cfg(not(windows))] + assert_eq!(beyond.args[0], "-solo"); + assert_eq!(winmerge.args, vec!["/s-", "{left}", "{right}"]); + assert_eq!(total_commander.args, vec!["/N", "/T", "{repo}"]); + } + #[test] fn rejects_unknown_placeholder() { let command = ExternalToolCommand { diff --git a/src/lib/externalTools.ts b/src/lib/externalTools.ts index a712e8c..a7ab833 100644 --- a/src/lib/externalTools.ts +++ b/src/lib/externalTools.ts @@ -53,15 +53,15 @@ const jetBrains: ToolIdentity[] = [ ]; const editor = (tool: ToolIdentity, args = ["{file}"]): ExternalToolPreset => ({ ...tool, args }); -const codeDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--wait", "--diff", "{left}", "{right}"] }); -const codeMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--wait", "--merge", "{ours}", "{theirs}", "{base}", "{result}"] }); +const codeDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--new-window", "--wait", "--diff", "{left}", "{right}"] }); +const codeMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--new-window", "--wait", "--merge", "{ours}", "{theirs}", "{base}", "{result}"] }); const jetBrainsDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["diff", "{left}", "{right}"] }); const jetBrainsMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["merge", "{ours}", "{theirs}", "{base}", "{result}"] }); const commonEditors: ExternalToolPreset[] = [ - ...codeFamily.map((item) => editor(item, ["--reuse-window", "{file}"])), - editor({ id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed" }), - editor({ id: "sublime-text", label: "Sublime Text", program: externalToolsPlatform === "windows" ? "subl.exe" : "subl" }), + ...codeFamily.map((item) => editor(item, ["--new-window", "{file}"])), + editor({ id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed" }, ["--new", "{file}"]), + editor({ id: "sublime-text", label: "Sublime Text", program: externalToolsPlatform === "windows" ? "subl.exe" : "subl" }, ["--new-window", "{file}"]), ...jetBrains.map((item) => editor(item)), editor({ id: "neovim", label: "Neovim", program: externalToolsPlatform === "windows" ? "nvim.exe" : "nvim" }), editor({ id: "vim", label: "Vim", program: externalToolsPlatform === "windows" ? "gvim.exe" : "gvim" }), @@ -70,7 +70,7 @@ const commonEditors: ExternalToolPreset[] = [ const platformEditors: ExternalToolPreset[] = externalToolsPlatform === "windows" ? [ - editor({ id: "notepad-plus-plus", label: "Notepad++", program: "notepad++.exe" }), + editor({ id: "notepad-plus-plus", label: "Notepad++", program: "notepad++.exe" }, ["-multiInst", "{file}"]), { ...editor({ id: "notepad", label: "Windows Notepad", program: "notepad.exe" }), builtIn: true }, ] : externalToolsPlatform === "macos" @@ -82,16 +82,16 @@ const platformEditors: ExternalToolPreset[] = externalToolsPlatform === "windows ] : [ editor({ id: "lapce", label: "Lapce", program: "lapce" }), - editor({ id: "kate", label: "Kate", program: "kate" }), + editor({ id: "kate", label: "Kate", program: "kate" }, ["--new", "{file}"]), editor({ id: "gedit", label: "GNOME Text Editor", program: "gnome-text-editor" }), - editor({ id: "geany", label: "Geany", program: "geany" }), + editor({ id: "geany", label: "Geany", program: "geany" }, ["--new-instance", "{file}"]), editor({ id: "helix", label: "Helix", program: "hx" }), ]; const dedicatedDiff: ExternalToolPreset[] = externalToolsPlatform === "windows" ? [ - { id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/readonly", "{left}", "{right}"] }, - { id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/u", "/e", "/wl", "/wr", "{left}", "{right}"] }, + { id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/solo", "/readonly", "{left}", "{right}"] }, + { id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/s-", "/u", "/e", "/wl", "/wr", "{left}", "{right}"] }, { id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "{left}", "{right}"] }, { id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{left}", "{right}"] }, { id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{left}", "{right}"] }, @@ -100,14 +100,14 @@ const dedicatedDiff: ExternalToolPreset[] = externalToolsPlatform === "windows" ] : externalToolsPlatform === "macos" ? [ - { id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["{left}", "{right}"] }, + { id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{left}", "{right}"] }, { id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--wait", "{left}", "{right}"] }, { id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{left}", "{right}"] }, { id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] }, { id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{left}", "{right}"] }, ] : [ - { id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["{left}", "{right}"] }, + { id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{left}", "{right}"] }, { id: "meld", label: "Meld", program: "meld", args: ["--wait", "{left}", "{right}"] }, { id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{left}", "{right}"] }, { id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] }, @@ -116,8 +116,8 @@ const dedicatedDiff: ExternalToolPreset[] = externalToolsPlatform === "windows" const dedicatedMerge: ExternalToolPreset[] = externalToolsPlatform === "windows" ? [ - { id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] }, - { id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/u", "/e", "{ours}", "{theirs}", "{base}", "/o", "{result}"] }, + { id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] }, + { id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/s-", "/u", "/e", "{ours}", "{theirs}", "{base}", "/o", "{result}"] }, { id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] }, { id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] }, { id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{base}", "{theirs}", "{ours}", "{result}"] }, @@ -126,14 +126,14 @@ const dedicatedMerge: ExternalToolPreset[] = externalToolsPlatform === "windows" ] : externalToolsPlatform === "macos" ? [ - { id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] }, + { id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] }, { id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--merge", "--output", "{result}", "{base}", "{ours}", "{theirs}"] }, { id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{ours}", "{base}", "{theirs}", "{result}"] }, { id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] }, { id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{ours}", "{theirs}", "-ancestor", "{base}", "-merge", "{result}"] }, ] : [ - { id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] }, + { id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] }, { id: "meld", label: "Meld", program: "meld", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] }, { id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] }, { id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] }, @@ -141,10 +141,10 @@ const dedicatedMerge: ExternalToolPreset[] = externalToolsPlatform === "windows" const terminalPresets: ExternalToolPreset[] = externalToolsPlatform === "windows" ? [ - { id: "windows-terminal", label: "Windows Terminal", program: "wt.exe", args: ["-d", "{repo}"] }, + { id: "windows-terminal", label: "Windows Terminal", program: "wt.exe", args: ["-w", "new", "-d", "{repo}"] }, { id: "powershell", label: "PowerShell 7", program: "pwsh.exe", args: ["-NoExit", "-WorkingDirectory", "{repo}"] }, { id: "windows-powershell", label: "Windows PowerShell", program: "powershell.exe", args: ["-NoExit"], builtIn: true }, - { id: "git-bash", label: "Git Bash", program: "bash.exe", args: ["--login", "-i"] }, + { id: "git-bash", label: "Git Bash", program: "git-bash.exe", args: ["--login", "-i"] }, { id: "cmd", label: "Command Prompt", program: "cmd.exe", args: ["/K"], builtIn: true }, { id: "wezterm", label: "WezTerm", program: "wezterm-gui.exe", args: ["start", "--cwd", "{repo}"] }, { id: "alacritty", label: "Alacritty", program: "alacritty.exe", args: ["--working-directory", "{repo}"] }, @@ -152,9 +152,9 @@ const terminalPresets: ExternalToolPreset[] = externalToolsPlatform === "windows ] : externalToolsPlatform === "macos" ? [ - { id: "terminal", label: "Terminal", program: "open", args: ["-a", "Terminal", "{repo}"], builtIn: true }, - { id: "iterm2", label: "iTerm2", program: "open", args: ["-a", "iTerm", "{repo}"] }, - { id: "warp", label: "Warp", program: "open", args: ["-a", "Warp", "{repo}"] }, + { id: "terminal", label: "Terminal", program: "open", args: ["-n", "-a", "Terminal", "{repo}"], builtIn: true }, + { id: "iterm2", label: "iTerm2", program: "open", args: ["-n", "-a", "iTerm", "{repo}"] }, + { id: "warp", label: "Warp", program: "open", args: ["-n", "-a", "Warp", "{repo}"] }, { id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] }, { id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] }, ] @@ -171,8 +171,8 @@ const terminalPresets: ExternalToolPreset[] = externalToolsPlatform === "windows const fileManagerPresets: ExternalToolPreset[] = externalToolsPlatform === "windows" ? [ - { id: "explorer", label: "Windows Explorer", program: "explorer.exe", args: ["{repo}"], builtIn: true }, - { id: "total-commander", label: "Total Commander", program: "TOTALCMD64.EXE", args: ["/O", "/T", "{repo}"] }, + { id: "explorer", label: "Windows Explorer", program: "explorer.exe", args: ["/n,", "{repo}"], builtIn: true }, + { id: "total-commander", label: "Total Commander", program: "TOTALCMD64.EXE", args: ["/N", "/T", "{repo}"] }, { id: "directory-opus", label: "Directory Opus", program: "dopus.exe", args: ["{repo}"] }, { id: "double-commander", label: "Double Commander", program: "doublecmd.exe", args: ["{repo}"] }, { id: "freecommander", label: "FreeCommander XE", program: "FreeCommander.exe", args: ["/L={repo}"] }, @@ -180,7 +180,7 @@ const fileManagerPresets: ExternalToolPreset[] = externalToolsPlatform === "wind ] : externalToolsPlatform === "macos" ? [ - { id: "finder", label: "Finder", program: "open", args: ["{repo}"], builtIn: true }, + { id: "finder", label: "Finder", program: "open", args: ["-n", "{repo}"], builtIn: true }, { id: "forklift", label: "ForkLift", program: "forklift", args: ["{repo}"] }, { id: "path-finder", label: "Path Finder", program: "Path Finder", args: ["{repo}"] }, ] @@ -196,7 +196,7 @@ const fileManagerPresets: ExternalToolPreset[] = externalToolsPlatform === "wind export const externalToolPresets: Record = { editor: [...commonEditors, ...platformEditors], - diff: [...dedicatedDiff, ...codeFamily.map(codeDiff), { id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed", args: ["--diff", "{left}", "{right}"] }, ...jetBrains.map(jetBrainsDiff)], + diff: [...dedicatedDiff, ...codeFamily.map(codeDiff), { id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed", args: ["--new", "--diff", "{left}", "{right}"] }, ...jetBrains.map(jetBrainsDiff)], merge: [...dedicatedMerge, ...codeFamily.map(codeMerge), ...jetBrains.map(jetBrainsMerge)], terminal: terminalPresets, fileManager: fileManagerPresets, @@ -233,14 +233,21 @@ export function defaultExternalToolsSettings(detectedTools: DetectedExternalTool }; } -function normaliseSetting(value: unknown, fallback: ExternalToolSetting): ExternalToolSetting { +function normaliseSetting(kind: ExternalToolKind, value: unknown, fallback: ExternalToolSetting): ExternalToolSetting { if (!value || typeof value !== "object" || Array.isArray(value)) return fallback; const candidate = value as Partial; const program = typeof candidate.program === "string" && candidate.program.trim() ? candidate.program : fallback.program; - const args = Array.isArray(candidate.args) + const storedArgs = Array.isArray(candidate.args) ? candidate.args.filter((item): item is string => typeof item === "string").slice(0, 64) : fallback.args; const preset = typeof candidate.preset === "string" && candidate.preset ? candidate.preset : fallback.preset; + // Preset arguments are application-owned and can be upgraded safely. Editing either + // the executable or arguments in Settings changes the preset to "custom", which keeps + // genuine user commands untouched. + const currentPreset = preset === "custom" + ? undefined + : externalToolPresets[kind].find((item) => item.id === preset); + const args = currentPreset ? [...currentPreset.args] : storedArgs; return { preset, program, args }; } @@ -249,11 +256,11 @@ export function normaliseExternalToolsSettings(value: unknown): ExternalToolsSet if (!value || typeof value !== "object" || Array.isArray(value)) return defaults; const candidate = value as Partial; return { - editor: normaliseSetting(candidate.editor, defaults.editor), - diff: normaliseSetting(candidate.diff, defaults.diff), - merge: normaliseSetting(candidate.merge, defaults.merge), - terminal: normaliseSetting(candidate.terminal, defaults.terminal), - fileManager: normaliseSetting(candidate.fileManager, defaults.fileManager), + editor: normaliseSetting("editor", candidate.editor, defaults.editor), + diff: normaliseSetting("diff", candidate.diff, defaults.diff), + merge: normaliseSetting("merge", candidate.merge, defaults.merge), + terminal: normaliseSetting("terminal", candidate.terminal, defaults.terminal), + fileManager: normaliseSetting("fileManager", candidate.fileManager, defaults.fileManager), diffOpenMode: normaliseOpenMode(candidate.diffOpenMode, defaults.diffOpenMode), mergeOpenMode: normaliseOpenMode(candidate.mergeOpenMode, defaults.mergeOpenMode), };