Files
GitLite/src-tauri/src/external_tools.rs
T
Christoph Brandau 15d1f2bfd6 feat(external-tools): add cross-platform external tool discovery
Adds a new external tools subsystem to detect and launch
diff and editor tools across Windows, macOS, and Linux.
It exposes data models for tools, commands, and results to the UI
and serializes them for consumption by the app.

- Implement cross-platform discovery of editors and diff tools
- Expose serialized results to the UI for user selection
- Centralize per-OS known tool lists and overrides
2026-08-13 14:08:02 +02:00

1653 lines
45 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",
&["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 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 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}"))?;
Ok(())
}
fn run_tool(
command: ExternalToolCommand,
repo: &Path,
values: BTreeMap<&str, String>,
kind: ExternalToolRunKind,
) -> Result<(), String> {
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 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)
));
}
}