Files
GitLite/src-tauri/src/external_tools.rs
T
Christoph Brandau a823aabbb9 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
2026-08-13 14:33:05 +02:00

1806 lines
51 KiB
Rust

use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
env, fs,
path::{Component, Path, PathBuf},
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalToolCommand {
program: String,
args: Vec<String>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ExternalDiffScope {
Head,
Staged,
Unstaged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExternalToolRunKind {
Diff,
Merge,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DetectedExternalTool {
id: String,
label: String,
program: String,
kinds: Vec<String>,
}
#[derive(Clone, Copy)]
struct ToolSpec {
id: &'static str,
label: &'static str,
commands: &'static [&'static str],
common_paths: &'static [&'static str],
scan_roots: &'static [&'static str],
scan_names: &'static [&'static str],
program_override: Option<&'static str>,
kinds: &'static [&'static str],
}
fn tool(
id: &'static str,
label: &'static str,
commands: &'static [&'static str],
common_paths: &'static [&'static str],
scan_roots: &'static [&'static str],
scan_names: &'static [&'static str],
kinds: &'static [&'static str],
) -> ToolSpec {
ToolSpec {
id,
label,
commands,
common_paths,
scan_roots,
scan_names,
program_override: None,
kinds,
}
}
#[cfg(target_os = "macos")]
fn overridden_tool(
id: &'static str,
label: &'static str,
common_paths: &'static [&'static str],
program: &'static str,
kinds: &'static [&'static str],
) -> ToolSpec {
ToolSpec {
id,
label,
commands: &[],
common_paths,
scan_roots: &[],
scan_names: &[],
program_override: Some(program),
kinds,
}
}
#[cfg(windows)]
fn known_tools() -> Vec<ToolSpec> {
const CODE_FAMILY: &[&str] = &["editor", "diff", "merge"];
const EDITOR: &[&str] = &["editor"];
const DIFF_MERGE: &[&str] = &["diff", "merge"];
const TERMINAL: &[&str] = &["terminal"];
const FILE_MANAGER: &[&str] = &["fileManager"];
const JETBRAINS_ROOTS: &[&str] = &[
r"%PROGRAMFILES%\JetBrains",
r"%LOCALAPPDATA%\JetBrains\Toolbox\apps",
];
vec![
tool(
"vscode",
"Visual Studio Code",
&["Code.exe", "code.cmd"],
&[
r"%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe",
r"%PROGRAMFILES%\Microsoft VS Code\Code.exe",
],
&[],
&[],
CODE_FAMILY,
),
tool(
"vscode-insiders",
"Visual Studio Code Insiders",
&["Code - Insiders.exe", "code-insiders.cmd"],
&[
r"%LOCALAPPDATA%\Programs\Microsoft VS Code Insiders\Code - Insiders.exe",
r"%PROGRAMFILES%\Microsoft VS Code Insiders\Code - Insiders.exe",
],
&[],
&[],
CODE_FAMILY,
),
tool(
"cursor",
"Cursor",
&["Cursor.exe", "cursor.cmd"],
&[r"%LOCALAPPDATA%\Programs\cursor\Cursor.exe"],
&[],
&[],
CODE_FAMILY,
),
tool(
"windsurf",
"Windsurf",
&["Windsurf.exe", "windsurf.cmd"],
&[r"%LOCALAPPDATA%\Programs\Windsurf\Windsurf.exe"],
&[],
&[],
CODE_FAMILY,
),
tool(
"vscodium",
"VSCodium",
&["VSCodium.exe", "codium.cmd"],
&[
r"%LOCALAPPDATA%\Programs\VSCodium\VSCodium.exe",
r"%PROGRAMFILES%\VSCodium\VSCodium.exe",
],
&[],
&[],
CODE_FAMILY,
),
tool(
"zed",
"Zed",
&["zed.exe"],
&[r"%LOCALAPPDATA%\Programs\Zed\Zed.exe"],
&[],
&[],
&["editor", "diff"],
),
tool(
"sublime-text",
"Sublime Text",
&["subl.exe", "sublime_text.exe"],
&[
r"%PROGRAMFILES%\Sublime Text\subl.exe",
r"%PROGRAMFILES%\Sublime Text 3\subl.exe",
],
&[],
&[],
EDITOR,
),
tool(
"notepad-plus-plus",
"Notepad++",
&["notepad++.exe"],
&[
r"%PROGRAMFILES%\Notepad++\notepad++.exe",
r"%PROGRAMFILES(X86)%\Notepad++\notepad++.exe",
],
&[],
&[],
EDITOR,
),
tool("neovim", "Neovim", &["nvim.exe"], &[], &[], &[], EDITOR),
tool(
"vim",
"Vim",
&["gvim.exe", "vim.exe"],
&[
r"%PROGRAMFILES%\Vim\vim91\gvim.exe",
r"%PROGRAMFILES%\Vim\vim90\gvim.exe",
],
&[r"%PROGRAMFILES%\Vim"],
&["gvim.exe"],
EDITOR,
),
tool(
"emacs",
"Emacs",
&["runemacs.exe", "emacs.exe"],
&[],
&[r"%PROGRAMFILES%\Emacs"],
&["runemacs.exe"],
EDITOR,
),
tool(
"notepad",
"Windows Notepad",
&["notepad.exe"],
&[r"%WINDIR%\System32\notepad.exe"],
&[],
&[],
EDITOR,
),
tool(
"intellij-idea",
"IntelliJ IDEA",
&["idea64.exe", "idea.exe"],
&[],
JETBRAINS_ROOTS,
&["idea64.exe", "idea.exe"],
CODE_FAMILY,
),
tool(
"webstorm",
"WebStorm",
&["webstorm64.exe", "webstorm.exe"],
&[],
JETBRAINS_ROOTS,
&["webstorm64.exe", "webstorm.exe"],
CODE_FAMILY,
),
tool(
"pycharm",
"PyCharm",
&["pycharm64.exe", "pycharm.exe"],
&[],
JETBRAINS_ROOTS,
&["pycharm64.exe", "pycharm.exe"],
CODE_FAMILY,
),
tool(
"phpstorm",
"PhpStorm",
&["phpstorm64.exe", "phpstorm.exe"],
&[],
JETBRAINS_ROOTS,
&["phpstorm64.exe", "phpstorm.exe"],
CODE_FAMILY,
),
tool(
"rider",
"JetBrains Rider",
&["rider64.exe", "rider.exe"],
&[],
JETBRAINS_ROOTS,
&["rider64.exe", "rider.exe"],
CODE_FAMILY,
),
tool(
"clion",
"CLion",
&["clion64.exe", "clion.exe"],
&[],
JETBRAINS_ROOTS,
&["clion64.exe", "clion.exe"],
CODE_FAMILY,
),
tool(
"rustrover",
"RustRover",
&["rustrover64.exe", "rustrover.exe"],
&[],
JETBRAINS_ROOTS,
&["rustrover64.exe", "rustrover.exe"],
CODE_FAMILY,
),
tool(
"goland",
"GoLand",
&["goland64.exe", "goland.exe"],
&[],
JETBRAINS_ROOTS,
&["goland64.exe", "goland.exe"],
CODE_FAMILY,
),
tool(
"beyond-compare",
"Beyond Compare",
&["BCompare.exe", "BComp.exe"],
&[
r"%PROGRAMFILES%\Beyond Compare 5\BCompare.exe",
r"%PROGRAMFILES%\Beyond Compare 4\BCompare.exe",
r"%PROGRAMFILES(X86)%\Beyond Compare 4\BCompare.exe",
],
&[],
&[],
DIFF_MERGE,
),
tool(
"winmerge",
"WinMerge",
&["WinMergeU.exe"],
&[
r"%PROGRAMFILES%\WinMerge\WinMergeU.exe",
r"%LOCALAPPDATA%\Programs\WinMerge\WinMergeU.exe",
],
&[],
&[],
DIFF_MERGE,
),
tool(
"meld",
"Meld",
&["meld.exe", "meld"],
&[
r"%LOCALAPPDATA%\Programs\Meld\Meld.exe",
r"%PROGRAMFILES%\Meld\Meld.exe",
],
&[],
&[],
DIFF_MERGE,
),
tool(
"kdiff3",
"KDiff3",
&["kdiff3.exe"],
&[
r"%PROGRAMFILES%\KDiff3\bin\kdiff3.exe",
r"%PROGRAMFILES%\KDiff3\kdiff3.exe",
],
&[],
&[],
DIFF_MERGE,
),
tool(
"p4merge",
"P4Merge",
&["p4merge.exe"],
&[r"%PROGRAMFILES%\Perforce\p4merge.exe"],
&[],
&[],
DIFF_MERGE,
),
tool(
"araxis-merge",
"Araxis Merge",
&["Compare.exe"],
&[r"%PROGRAMFILES%\Araxis\Araxis Merge\Compare.exe"],
&[],
&[],
DIFF_MERGE,
),
tool(
"tortoisegitmerge",
"TortoiseGitMerge",
&["TortoiseGitMerge.exe"],
&[r"%PROGRAMFILES%\TortoiseGit\bin\TortoiseGitMerge.exe"],
&[],
&[],
DIFF_MERGE,
),
tool(
"windows-terminal",
"Windows Terminal",
&["wt.exe"],
&[r"%LOCALAPPDATA%\Microsoft\WindowsApps\wt.exe"],
&[],
&[],
TERMINAL,
),
tool(
"powershell",
"PowerShell 7",
&["pwsh.exe"],
&[r"%PROGRAMFILES%\PowerShell\7\pwsh.exe"],
&[],
&[],
TERMINAL,
),
tool(
"windows-powershell",
"Windows PowerShell",
&["powershell.exe"],
&[r"%WINDIR%\System32\WindowsPowerShell\v1.0\powershell.exe"],
&[],
&[],
TERMINAL,
),
tool(
"git-bash",
"Git Bash",
&["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",
],
&[],
&[],
TERMINAL,
),
tool(
"cmd",
"Command Prompt",
&["cmd.exe"],
&[r"%WINDIR%\System32\cmd.exe"],
&[],
&[],
TERMINAL,
),
tool(
"wezterm",
"WezTerm",
&["wezterm-gui.exe", "wezterm.exe"],
&[r"%PROGRAMFILES%\WezTerm\wezterm-gui.exe"],
&[],
&[],
TERMINAL,
),
tool(
"alacritty",
"Alacritty",
&["alacritty.exe"],
&[],
&[],
&[],
TERMINAL,
),
tool(
"kitty-terminal",
"kitty",
&["kitty.exe"],
&[],
&[],
&[],
TERMINAL,
),
tool(
"explorer",
"Windows Explorer",
&["explorer.exe"],
&[r"%WINDIR%\explorer.exe"],
&[],
&[],
FILE_MANAGER,
),
tool(
"total-commander",
"Total Commander",
&["TOTALCMD64.EXE", "TOTALCMD.EXE"],
&[
r"%PROGRAMFILES%\totalcmd\TOTALCMD64.EXE",
r"%PROGRAMFILES(X86)%\totalcmd\TOTALCMD.EXE",
],
&[],
&[],
FILE_MANAGER,
),
tool(
"directory-opus",
"Directory Opus",
&["dopus.exe"],
&[r"%PROGRAMFILES%\GPSoftware\Directory Opus\dopus.exe"],
&[],
&[],
FILE_MANAGER,
),
tool(
"double-commander",
"Double Commander",
&["doublecmd.exe"],
&[r"%PROGRAMFILES%\Double Commander\doublecmd.exe"],
&[],
&[],
FILE_MANAGER,
),
tool(
"freecommander",
"FreeCommander XE",
&["FreeCommander.exe"],
&[
r"%LOCALAPPDATA%\FreeCommander XE\FreeCommander.exe",
r"%PROGRAMFILES%\FreeCommander XE\FreeCommander.exe",
],
&[],
&[],
FILE_MANAGER,
),
tool(
"xyplorer",
"XYplorer",
&["XYplorer.exe"],
&[r"%PROGRAMFILES(X86)%\XYplorer\XYplorer.exe"],
&[],
&[],
FILE_MANAGER,
),
]
}
#[cfg(target_os = "macos")]
fn known_tools() -> Vec<ToolSpec> {
const CODE_FAMILY: &[&str] = &["editor", "diff", "merge"];
const EDITOR: &[&str] = &["editor"];
const DIFF_MERGE: &[&str] = &["diff", "merge"];
const TERMINAL: &[&str] = &["terminal"];
const FILE_MANAGER: &[&str] = &["fileManager"];
vec![
tool(
"vscode",
"Visual Studio Code",
&["code"],
&["/Applications/Visual Studio Code.app/Contents/MacOS/Electron"],
&[],
&[],
CODE_FAMILY,
),
tool(
"vscode-insiders",
"Visual Studio Code Insiders",
&["code-insiders"],
&["/Applications/Visual Studio Code - Insiders.app/Contents/MacOS/Electron"],
&[],
&[],
CODE_FAMILY,
),
tool(
"cursor",
"Cursor",
&["cursor"],
&["/Applications/Cursor.app/Contents/MacOS/Cursor"],
&[],
&[],
CODE_FAMILY,
),
tool(
"windsurf",
"Windsurf",
&["windsurf"],
&["/Applications/Windsurf.app/Contents/MacOS/Windsurf"],
&[],
&[],
CODE_FAMILY,
),
tool(
"vscodium",
"VSCodium",
&["codium"],
&["/Applications/VSCodium.app/Contents/MacOS/Electron"],
&[],
&[],
CODE_FAMILY,
),
tool(
"zed",
"Zed",
&["zed"],
&["/Applications/Zed.app/Contents/MacOS/zed"],
&[],
&[],
&["editor", "diff"],
),
tool(
"sublime-text",
"Sublime Text",
&["subl"],
&["/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl"],
&[],
&[],
EDITOR,
),
tool(
"nova",
"Nova",
&["nova"],
&["/Applications/Nova.app/Contents/MacOS/Nova"],
&[],
&[],
EDITOR,
),
tool(
"textmate",
"TextMate",
&["mate"],
&["/Applications/TextMate.app/Contents/Resources/mate"],
&[],
&[],
EDITOR,
),
tool(
"bbedit",
"BBEdit",
&["bbedit"],
&["/Applications/BBEdit.app/Contents/Helpers/bbedit"],
&[],
&[],
EDITOR,
),
tool(
"xcode",
"Xcode",
&["xed"],
&["/usr/bin/xed"],
&[],
&[],
EDITOR,
),
tool("neovim", "Neovim", &["nvim"], &[], &[], &[], EDITOR),
tool("vim", "Vim", &["mvim", "vim"], &[], &[], &[], EDITOR),
tool(
"emacs",
"Emacs",
&["emacs"],
&["/Applications/Emacs.app/Contents/MacOS/Emacs"],
&[],
&[],
EDITOR,
),
tool(
"intellij-idea",
"IntelliJ IDEA",
&["idea"],
&["/Applications/IntelliJ IDEA.app/Contents/MacOS/idea"],
&[],
&[],
CODE_FAMILY,
),
tool(
"webstorm",
"WebStorm",
&["webstorm"],
&["/Applications/WebStorm.app/Contents/MacOS/webstorm"],
&[],
&[],
CODE_FAMILY,
),
tool(
"pycharm",
"PyCharm",
&["pycharm"],
&["/Applications/PyCharm.app/Contents/MacOS/pycharm"],
&[],
&[],
CODE_FAMILY,
),
tool(
"phpstorm",
"PhpStorm",
&["phpstorm"],
&["/Applications/PhpStorm.app/Contents/MacOS/phpstorm"],
&[],
&[],
CODE_FAMILY,
),
tool(
"rider",
"JetBrains Rider",
&["rider"],
&["/Applications/Rider.app/Contents/MacOS/rider"],
&[],
&[],
CODE_FAMILY,
),
tool(
"clion",
"CLion",
&["clion"],
&["/Applications/CLion.app/Contents/MacOS/clion"],
&[],
&[],
CODE_FAMILY,
),
tool(
"rustrover",
"RustRover",
&["rustrover"],
&["/Applications/RustRover.app/Contents/MacOS/rustrover"],
&[],
&[],
CODE_FAMILY,
),
tool(
"goland",
"GoLand",
&["goland"],
&["/Applications/GoLand.app/Contents/MacOS/goland"],
&[],
&[],
CODE_FAMILY,
),
tool(
"beyond-compare",
"Beyond Compare",
&["bcompare"],
&["/Applications/Beyond Compare.app/Contents/MacOS/bcomp"],
&[],
&[],
DIFF_MERGE,
),
tool(
"kaleidoscope",
"Kaleidoscope",
&["ksdiff"],
&["/Applications/Kaleidoscope.app/Contents/Helpers/ksdiff"],
&[],
&[],
DIFF_MERGE,
),
tool(
"araxis-merge",
"Araxis Merge",
&["compare"],
&["/Applications/Araxis Merge.app/Contents/Utilities/compare"],
&[],
&[],
DIFF_MERGE,
),
tool(
"p4merge",
"P4Merge",
&["p4merge"],
&["/Applications/p4merge.app/Contents/MacOS/p4merge"],
&[],
&[],
DIFF_MERGE,
),
tool(
"opendiff",
"FileMerge",
&["opendiff"],
&["/usr/bin/opendiff"],
&[],
&[],
DIFF_MERGE,
),
overridden_tool(
"terminal",
"Terminal",
&[
"/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal",
"/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal",
],
"/usr/bin/open",
TERMINAL,
),
overridden_tool(
"iterm2",
"iTerm2",
&["/Applications/iTerm.app/Contents/MacOS/iTerm2"],
"/usr/bin/open",
TERMINAL,
),
overridden_tool(
"warp",
"Warp",
&["/Applications/Warp.app/Contents/MacOS/stable"],
"/usr/bin/open",
TERMINAL,
),
tool(
"wezterm",
"WezTerm",
&["wezterm"],
&["/Applications/WezTerm.app/Contents/MacOS/wezterm"],
&[],
&[],
TERMINAL,
),
tool(
"alacritty",
"Alacritty",
&["alacritty"],
&["/Applications/Alacritty.app/Contents/MacOS/alacritty"],
&[],
&[],
TERMINAL,
),
overridden_tool(
"finder",
"Finder",
&["/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder"],
"/usr/bin/open",
FILE_MANAGER,
),
tool(
"forklift",
"ForkLift",
&["forklift"],
&["/Applications/ForkLift.app/Contents/MacOS/ForkLift"],
&[],
&[],
FILE_MANAGER,
),
tool(
"path-finder",
"Path Finder",
&[],
&["/Applications/Path Finder.app/Contents/MacOS/Path Finder"],
&[],
&[],
FILE_MANAGER,
),
]
}
#[cfg(all(unix, not(target_os = "macos")))]
fn known_tools() -> Vec<ToolSpec> {
const CODE_FAMILY: &[&str] = &["editor", "diff", "merge"];
const EDITOR: &[&str] = &["editor"];
const DIFF_MERGE: &[&str] = &["diff", "merge"];
const TERMINAL: &[&str] = &["terminal"];
const FILE_MANAGER: &[&str] = &["fileManager"];
vec![
tool(
"vscode",
"Visual Studio Code",
&["code"],
&[],
&[],
&[],
CODE_FAMILY,
),
tool(
"vscode-insiders",
"Visual Studio Code Insiders",
&["code-insiders"],
&[],
&[],
&[],
CODE_FAMILY,
),
tool("cursor", "Cursor", &["cursor"], &[], &[], &[], CODE_FAMILY),
tool(
"windsurf",
"Windsurf",
&["windsurf"],
&[],
&[],
&[],
CODE_FAMILY,
),
tool(
"vscodium",
"VSCodium",
&["codium"],
&[],
&[],
&[],
CODE_FAMILY,
),
tool("zed", "Zed", &["zed"], &[], &[], &[], &["editor", "diff"]),
tool(
"sublime-text",
"Sublime Text",
&["subl"],
&[],
&[],
&[],
EDITOR,
),
tool("lapce", "Lapce", &["lapce"], &[], &[], &[], EDITOR),
tool("kate", "Kate", &["kate"], &[], &[], &[], EDITOR),
tool(
"gedit",
"GNOME Text Editor",
&["gnome-text-editor", "gedit"],
&[],
&[],
&[],
EDITOR,
),
tool("geany", "Geany", &["geany"], &[], &[], &[], EDITOR),
tool("neovim", "Neovim", &["nvim"], &[], &[], &[], EDITOR),
tool("vim", "Vim", &["gvim", "vim"], &[], &[], &[], EDITOR),
tool(
"emacs",
"Emacs",
&["emacsclient", "emacs"],
&[],
&[],
&[],
EDITOR,
),
tool("helix", "Helix", &["hx"], &[], &[], &[], EDITOR),
tool(
"intellij-idea",
"IntelliJ IDEA",
&["idea", "idea.sh"],
&[],
&[],
&[],
CODE_FAMILY,
),
tool(
"webstorm",
"WebStorm",
&["webstorm", "webstorm.sh"],
&[],
&[],
&[],
CODE_FAMILY,
),
tool(
"pycharm",
"PyCharm",
&["pycharm", "pycharm.sh"],
&[],
&[],
&[],
CODE_FAMILY,
),
tool(
"phpstorm",
"PhpStorm",
&["phpstorm", "phpstorm.sh"],
&[],
&[],
&[],
CODE_FAMILY,
),
tool(
"rider",
"JetBrains Rider",
&["rider", "rider.sh"],
&[],
&[],
&[],
CODE_FAMILY,
),
tool(
"clion",
"CLion",
&["clion", "clion.sh"],
&[],
&[],
&[],
CODE_FAMILY,
),
tool(
"rustrover",
"RustRover",
&["rustrover", "rustrover.sh"],
&[],
&[],
&[],
CODE_FAMILY,
),
tool(
"goland",
"GoLand",
&["goland", "goland.sh"],
&[],
&[],
&[],
CODE_FAMILY,
),
tool(
"beyond-compare",
"Beyond Compare",
&["bcompare"],
&[],
&[],
&[],
DIFF_MERGE,
),
tool("meld", "Meld", &["meld"], &[], &[], &[], DIFF_MERGE),
tool("kdiff3", "KDiff3", &["kdiff3"], &[], &[], &[], DIFF_MERGE),
tool(
"p4merge",
"P4Merge",
&["p4merge"],
&[],
&[],
&[],
DIFF_MERGE,
),
tool("kompare", "Kompare", &["kompare"], &[], &[], &[], &["diff"]),
tool(
"x-terminal",
"System terminal",
&["x-terminal-emulator"],
&[],
&[],
&[],
TERMINAL,
),
tool(
"gnome-terminal",
"GNOME Terminal",
&["gnome-terminal"],
&[],
&[],
&[],
TERMINAL,
),
tool("konsole", "Konsole", &["konsole"], &[], &[], &[], TERMINAL),
tool(
"kitty-terminal",
"kitty",
&["kitty"],
&[],
&[],
&[],
TERMINAL,
),
tool("wezterm", "WezTerm", &["wezterm"], &[], &[], &[], TERMINAL),
tool(
"alacritty",
"Alacritty",
&["alacritty"],
&[],
&[],
&[],
TERMINAL,
),
tool(
"xfce-terminal",
"Xfce Terminal",
&["xfce4-terminal"],
&[],
&[],
&[],
TERMINAL,
),
tool("tilix", "Tilix", &["tilix"], &[], &[], &[], TERMINAL),
tool(
"system-file-manager",
"System file manager",
&["xdg-open"],
&[],
&[],
&[],
FILE_MANAGER,
),
tool(
"nautilus",
"GNOME Files",
&["nautilus"],
&[],
&[],
&[],
FILE_MANAGER,
),
tool(
"dolphin",
"Dolphin",
&["dolphin"],
&[],
&[],
&[],
FILE_MANAGER,
),
tool("thunar", "Thunar", &["thunar"], &[], &[], &[], FILE_MANAGER),
tool("nemo", "Nemo", &["nemo"], &[], &[], &[], FILE_MANAGER),
tool(
"pcmanfm",
"PCManFM",
&["pcmanfm", "pcmanfm-qt"],
&[],
&[],
&[],
FILE_MANAGER,
),
tool(
"double-commander",
"Double Commander",
&["doublecmd"],
&[],
&[],
&[],
FILE_MANAGER,
),
]
}
fn expand_path_template(template: &str) -> Option<PathBuf> {
let mut expanded = template.to_string();
for key in [
"LOCALAPPDATA",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"WINDIR",
"HOME",
] {
let token = format!("%{key}%");
if expanded.to_ascii_uppercase().contains(&token) {
let value = env::var(key).ok()?;
expanded = expanded.replace(&token, &value);
expanded = expanded.replace(&token.to_ascii_lowercase(), &value);
}
}
if let Some(rest) = expanded.strip_prefix("~/") {
expanded = env::var("HOME").ok().map(|home| format!("{home}/{rest}"))?;
}
if expanded.contains('%') {
None
} else {
Some(PathBuf::from(expanded))
}
}
fn executable_on_path(name: &str) -> Option<PathBuf> {
let path = Path::new(name);
if path.components().count() > 1 && path.is_file() {
return Some(path.to_path_buf());
}
let path_value = env::var_os("PATH")?;
#[cfg(windows)]
let extensions: Vec<String> = if path.extension().is_some() {
vec![String::new()]
} else {
env::var("PATHEXT")
.unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".to_string())
.split(';')
.map(|value| value.to_ascii_lowercase())
.collect()
};
#[cfg(not(windows))]
let extensions = vec![String::new()];
for directory in env::split_paths(&path_value) {
for extension in &extensions {
let candidate = if extension.is_empty() {
directory.join(name)
} else {
directory.join(format!("{name}{extension}"))
};
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
fn index_scan_root(root: &Path, depth: usize, index: &mut HashMap<String, PathBuf>) {
if depth == 0 || !root.is_dir() {
return;
}
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
index_scan_root(&path, depth - 1, index);
} else if let Some(name) = path.file_name().and_then(|value| value.to_str()) {
let key = name.to_ascii_lowercase();
let should_replace = index.get(&key).is_none_or(|current| path > *current);
if should_replace {
index.insert(key, path);
}
}
}
}
fn build_scan_indexes(specs: &[ToolSpec]) -> HashMap<String, HashMap<String, PathBuf>> {
let roots: BTreeSet<&str> = specs
.iter()
.flat_map(|spec| spec.scan_roots.iter().copied())
.collect();
let mut indexes = HashMap::new();
for template in roots {
let Some(root) = expand_path_template(template) else {
continue;
};
if !root.is_dir() {
continue;
}
let mut index = HashMap::new();
index_scan_root(&root, 8, &mut index);
indexes.insert(template.to_string(), index);
}
indexes
}
fn detected_program(
spec: &ToolSpec,
indexes: &HashMap<String, HashMap<String, PathBuf>>,
) -> Option<PathBuf> {
let found = spec
.common_paths
.iter()
.find_map(|template| {
let path = expand_path_template(template)?;
path.is_file().then_some(path)
})
.or_else(|| {
spec.commands
.iter()
.find_map(|name| executable_on_path(name))
})
.or_else(|| {
spec.scan_roots.iter().find_map(|root| {
let index = indexes.get(*root)?;
spec.scan_names
.iter()
.find_map(|name| index.get(&name.to_ascii_lowercase()).cloned())
})
})?;
if let Some(program) = spec.program_override {
executable_on_path(program).or_else(|| Some(PathBuf::from(program)))
} else {
Some(found)
}
}
#[tauri::command]
pub async fn detect_external_tools() -> Result<Vec<DetectedExternalTool>, String> {
tauri::async_runtime::spawn_blocking(|| {
let specs = known_tools();
let indexes = build_scan_indexes(&specs);
let mut seen = BTreeSet::new();
let mut detected = Vec::new();
for spec in specs {
let Some(program) = detected_program(&spec, &indexes) else {
continue;
};
if !seen.insert(spec.id) {
continue;
}
detected.push(DetectedExternalTool {
id: spec.id.to_string(),
label: spec.label.to_string(),
program: program.to_string_lossy().into_owned(),
kinds: spec.kinds.iter().map(|kind| (*kind).to_string()).collect(),
});
}
Ok(detected)
})
.await
.map_err(|error| format!("External tool detection failed: {error}"))?
}
fn git_command() -> Command {
let mut command = Command::new("git");
command.env("LC_ALL", "C");
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW);
command
}
fn resolve_repo(path: &str) -> Result<PathBuf, String> {
if path.trim().is_empty() {
return Err("Repository path must not be empty.".to_string());
}
let output = git_command()
.arg("-C")
.arg(path)
.args(["rev-parse", "--show-toplevel"])
.output()
.map_err(|error| format!("Could not start Git: {error}"))?;
if !output.status.success() {
return Err("Not a Git repository or repository is unreachable.".to_string());
}
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
PathBuf::from(root)
.canonicalize()
.map_err(|error| format!("Could not resolve repository path: {error}"))
}
fn repo_child(repo: &Path, file: &str, must_exist: bool) -> Result<PathBuf, String> {
let child = Path::new(file);
if file.trim().is_empty()
|| child.is_absolute()
|| child.components().any(|part| {
matches!(
part,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err("File path must stay within the repository.".to_string());
}
let candidate = repo.join(child);
if must_exist {
let canonical = candidate
.canonicalize()
.map_err(|error| format!("Could not resolve file path: {error}"))?;
if !canonical.starts_with(repo) {
return Err("File path lies outside the repository.".to_string());
}
return Ok(canonical);
}
if candidate.exists() {
let canonical = candidate
.canonicalize()
.map_err(|error| format!("Could not resolve file path: {error}"))?;
if !canonical.starts_with(repo) {
return Err("File path lies outside the repository.".to_string());
}
return Ok(canonical);
}
Ok(candidate)
}
fn validate_command(command: &ExternalToolCommand) -> Result<(), String> {
if command.program.trim().is_empty() {
return Err("External tool program must not be empty.".to_string());
}
if command.program.contains('\0') || command.args.iter().any(|arg| arg.contains('\0')) {
return Err("External tool command contains an invalid null character.".to_string());
}
if command.args.len() > 64 || command.args.iter().any(|arg| arg.len() > 8192) {
return Err("External tool command is too long.".to_string());
}
Ok(())
}
fn expand_args(
command: &ExternalToolCommand,
values: &BTreeMap<&str, String>,
) -> Result<Vec<String>, String> {
validate_command(command)?;
command
.args
.iter()
.map(|template| {
let mut result = template.clone();
for (key, value) in values {
result = result.replace(&format!("{{{key}}}"), value);
}
if let Some(start) = result.find('{') {
if result[start..].contains('}') {
return Err(format!(
"Unknown or unavailable placeholder in argument '{template}'."
));
}
}
Ok(result)
})
.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());
if let Some(file) = file {
values.insert("file", file.to_string_lossy().into_owned());
values.insert(
"parent",
file.parent().unwrap_or(repo).to_string_lossy().into_owned(),
);
} else {
values.insert("file", repo.to_string_lossy().into_owned());
values.insert("parent", repo.to_string_lossy().into_owned());
}
values
}
fn spawn_tool(
command: ExternalToolCommand,
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);
process
.spawn()
.map_err(|error| format!("Could not launch external tool: {error}"))?;
Ok(())
}
fn run_tool(
command: ExternalToolCommand,
repo: &Path,
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());
process.args(args).current_dir(repo);
#[cfg(windows)]
process.creation_flags(CREATE_NO_WINDOW);
let status = process
.status()
.map_err(|error| format!("Could not launch external tool: {error}"))?;
if !status.success() && !is_expected_tool_exit(&program, kind, status.code()) {
return Err(format!("External tool exited with status {status}."));
}
Ok(())
}
fn is_expected_tool_exit(program: &str, kind: ExternalToolRunKind, code: Option<i32>) -> bool {
let Some(code) = code else { return false };
// Many diff drivers use 1 to report "files differ" rather than a launch failure.
if kind == ExternalToolRunKind::Diff && code == 1 {
return true;
}
let executable = Path::new(program)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(program)
.to_ascii_lowercase();
let beyond_compare = executable.starts_with("bcomp");
// Beyond Compare documents these as comparison results. In particular, 13 means
// "rules-based differences" and is commonly returned when a normal GUI compare closes.
// Codes 14 (conflicts) and 100+ (execution/output errors) intentionally remain failures.
beyond_compare && matches!(code, 1 | 2 | 11 | 12 | 13)
}
fn temp_dir(label: &str) -> Result<PathBuf, String> {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let path = std::env::temp_dir().join(format!("gitty-{label}-{}-{stamp}", std::process::id()));
fs::create_dir_all(&path)
.map_err(|error| format!("Could not create temporary tool directory: {error}"))?;
Ok(path)
}
fn git_blob(repo: &Path, spec: &str) -> Result<Vec<u8>, String> {
let output = git_command()
.arg("-C")
.arg(repo)
.args(["show", spec])
.output()
.map_err(|error| format!("Could not start Git: {error}"))?;
if output.status.success() {
Ok(output.stdout)
} else {
Ok(Vec::new())
}
}
#[tauri::command(async)]
pub fn launch_external_tool(
path: String,
file: Option<String>,
command: ExternalToolCommand,
) -> Result<(), String> {
let repo = resolve_repo(&path)?;
let file_path = file
.as_deref()
.map(|file| repo_child(&repo, file, true))
.transpose()?;
spawn_tool(command, &repo, tool_values(&repo, file_path.as_deref()))
}
#[tauri::command]
pub async fn launch_external_diff(
path: String,
file: String,
command: ExternalToolCommand,
scope: ExternalDiffScope,
) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let repo = resolve_repo(&path)?;
let working_file = repo_child(&repo, &file, false)?;
let temporary = temp_dir("diff")?;
let name = Path::new(&file)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("file");
let (left_label, left_content, right_label, right_content) = match scope {
ExternalDiffScope::Head => (
"HEAD",
git_blob(&repo, &format!("HEAD:{file}"))?,
"WORKTREE",
if working_file.is_file() {
fs::read(&working_file)
.map_err(|error| format!("Could not read working file: {error}"))?
} else {
Vec::new()
},
),
ExternalDiffScope::Staged => (
"HEAD",
git_blob(&repo, &format!("HEAD:{file}"))?,
"INDEX",
git_blob(&repo, &format!(":0:{file}"))?,
),
ExternalDiffScope::Unstaged => (
"INDEX",
git_blob(&repo, &format!(":0:{file}"))?,
"WORKTREE",
if working_file.is_file() {
fs::read(&working_file)
.map_err(|error| format!("Could not read working file: {error}"))?
} else {
Vec::new()
},
),
};
let left = temporary.join(format!("{left_label}-{name}"));
let right = temporary.join(format!("{right_label}-{name}"));
fs::write(&left, left_content)
.map_err(|error| format!("Could not write comparison file: {error}"))?;
fs::write(&right, right_content)
.map_err(|error| format!("Could not write comparison file: {error}"))?;
let mut values = tool_values(&repo, Some(&working_file));
values.insert("left", left.to_string_lossy().into_owned());
values.insert("right", right.to_string_lossy().into_owned());
let result = run_tool(command, &repo, values, ExternalToolRunKind::Diff);
let _ = fs::remove_dir_all(temporary);
result
})
.await
.map_err(|error| format!("External diff task failed: {error}"))?
}
#[tauri::command]
pub async fn launch_external_merge(
path: String,
file: String,
command: ExternalToolCommand,
) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let repo = resolve_repo(&path)?;
let result_file = repo_child(&repo, &file, true)?;
let temporary = temp_dir("merge")?;
let name = Path::new(&file)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("file");
let base = temporary.join(format!("BASE-{name}"));
let ours = temporary.join(format!("OURS-{name}"));
let theirs = temporary.join(format!("THEIRS-{name}"));
fs::write(&base, git_blob(&repo, &format!(":1:{file}"))?)
.map_err(|error| format!("Could not write merge base: {error}"))?;
fs::write(&ours, git_blob(&repo, &format!(":2:{file}"))?)
.map_err(|error| format!("Could not write ours file: {error}"))?;
fs::write(&theirs, git_blob(&repo, &format!(":3:{file}"))?)
.map_err(|error| format!("Could not write theirs file: {error}"))?;
let mut values = tool_values(&repo, Some(&result_file));
values.insert("base", base.to_string_lossy().into_owned());
values.insert("ours", ours.to_string_lossy().into_owned());
values.insert("theirs", theirs.to_string_lossy().into_owned());
values.insert("result", result_file.to_string_lossy().into_owned());
let result = run_tool(command, &repo, values, ExternalToolRunKind::Merge);
let _ = fs::remove_dir_all(temporary);
result
})
.await
.map_err(|error| format!("External merge task failed: {error}"))?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expands_placeholders_without_shell_parsing() {
let command = ExternalToolCommand {
program: "tool".into(),
args: vec!["--diff".into(), "{left}".into(), "title={file}".into()],
};
let values = BTreeMap::from([
("left", "C:\\a & b.txt".to_string()),
("file", "quoted file.txt".to_string()),
]);
assert_eq!(
expand_args(&command, &values).unwrap(),
vec!["--diff", "C:\\a & b.txt", "title=quoted file.txt"]
);
}
#[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 {
program: "tool".into(),
args: vec!["{missing}".into()],
};
assert!(
expand_args(&command, &BTreeMap::new())
.unwrap_err()
.contains("placeholder")
);
}
#[test]
fn rejects_empty_program() {
let command = ExternalToolCommand {
program: " ".into(),
args: vec![],
};
assert!(expand_args(&command, &BTreeMap::new()).is_err());
}
#[test]
fn repository_is_a_valid_file_placeholder_for_repository_actions() {
let repo = Path::new("C:/repo");
let values = tool_values(repo, None);
assert_eq!(values.get("repo"), values.get("file"));
assert_eq!(values.get("repo"), values.get("parent"));
}
#[test]
fn accepts_documented_beyond_compare_result_codes() {
for code in [1, 2, 11, 12, 13] {
assert!(is_expected_tool_exit(
"C:/Program Files/Beyond Compare 5/BCompare.exe",
ExternalToolRunKind::Diff,
Some(code)
));
assert!(is_expected_tool_exit(
"BComp.exe",
ExternalToolRunKind::Merge,
Some(code)
));
}
}
#[test]
fn keeps_real_external_tool_failures_visible() {
assert!(!is_expected_tool_exit(
"BCompare.exe",
ExternalToolRunKind::Merge,
Some(14)
));
assert!(!is_expected_tool_exit(
"BCompare.exe",
ExternalToolRunKind::Merge,
Some(101)
));
assert!(!is_expected_tool_exit(
"Code.exe",
ExternalToolRunKind::Diff,
Some(13)
));
assert!(is_expected_tool_exit(
"meld",
ExternalToolRunKind::Diff,
Some(1)
));
}
}