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, } #[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, } #[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 { 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 { 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 { 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 { 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 { 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 = 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) { 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> { 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>, ) -> Option { 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, 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 { 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 { 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, 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) -> 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 { 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, 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, 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) )); } }