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
This commit is contained in:
Christoph Brandau
2026-08-13 14:33:05 +02:00
parent 15d1f2bfd6
commit a823aabbb9
2 changed files with 195 additions and 35 deletions
+156 -3
View File
@@ -405,8 +405,10 @@ fn known_tools() -> Vec<ToolSpec> {
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<String>, flags: &[&str]| {
args.retain(|arg| !flags.iter().any(|flag| arg.eq_ignore_ascii_case(flag)));
};
let prepend_flag = |args: &mut Vec<String>, 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 {