diff --git a/src-tauri/src/external_tools.rs b/src-tauri/src/external_tools.rs new file mode 100644 index 0000000..d191cb6 --- /dev/null +++ b/src-tauri/src/external_tools.rs @@ -0,0 +1,1652 @@ +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) + )); + } +} diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index dabfad4..96ac1a9 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -3129,6 +3129,230 @@ pub async fn list_commits( .await } +const COMMIT_NOTES_REF: &str = "refs/notes/commits"; +const COMMIT_NOTES_SYNC_REF: &str = "refs/gitlite/notes-sync"; +const MAX_COMMIT_NOTE_BYTES: usize = 256 * 1024; + +#[tauri::command] +pub async fn get_commit_note(path: String, commit: String) -> Result, String> { + run_git_task("Could not load commit note", move || { + let repo = resolve_repo(&path)?; + commit_note_for_repo(&repo, &commit) + }) + .await +} + +#[tauri::command] +pub async fn set_commit_note(path: String, commit: String, note: String) -> Result<(), String> { + run_git_task("Could not save commit note", move || { + let repo = resolve_repo(&path)?; + set_commit_note_for_repo(&repo, &commit, ¬e) + }) + .await +} + +#[tauri::command] +pub async fn delete_commit_note(path: String, commit: String) -> Result<(), String> { + run_git_task("Could not delete commit note", move || { + let repo = resolve_repo(&path)?; + delete_commit_note_for_repo(&repo, &commit) + }) + .await +} + +#[tauri::command] +pub async fn fetch_commit_notes( + path: String, + remote: String, + username: Option, + password: Option, +) -> Result<(), String> { + run_git_task("Could not fetch commit notes", move || { + let repo = resolve_repo(&path)?; + fetch_commit_notes_for_repo(&repo, &remote, username.as_deref(), password.as_deref()) + }) + .await +} + +#[tauri::command] +pub async fn push_commit_notes( + path: String, + remote: String, + username: Option, + password: Option, +) -> Result<(), String> { + run_git_task("Could not push commit notes", move || { + let repo = resolve_repo(&path)?; + push_commit_notes_for_repo(&repo, &remote, username.as_deref(), password.as_deref()) + }) + .await +} + +fn commit_note_for_repo(repo: &Path, commit: &str) -> Result, String> { + let commit = verify_commit(repo, commit)?; + let output = git_command() + .arg("-C") + .arg(repo) + .args(["notes", "--ref", COMMIT_NOTES_REF, "list", commit.as_str()]) + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; + + if output.status.code() == Some(1) { + return Ok(None); + } + if !output.status.success() { + return Err(format!( + "Could not inspect commit note: {}", + command_output_details(&output) + )); + } + + let note_object = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if note_object.is_empty() { + return Ok(None); + } + + let note = run_git(repo, ["cat-file", "blob", note_object.as_str()])?; + let mut note = + String::from_utf8(note).map_err(|_| "Commit note is not valid UTF-8 text.".to_string())?; + if note.ends_with('\n') { + note.pop(); + if note.ends_with('\r') { + note.pop(); + } + } + Ok(Some(note)) +} + +fn set_commit_note_for_repo(repo: &Path, commit: &str, note: &str) -> Result<(), String> { + let commit = verify_commit(repo, commit)?; + if note.trim().is_empty() { + return Err("Commit note must not be empty. Use Delete to remove it.".to_string()); + } + if note.len() > MAX_COMMIT_NOTE_BYTES { + return Err(format!( + "Commit note is too large (maximum {} KiB).", + MAX_COMMIT_NOTE_BYTES / 1024 + )); + } + + run_git_with_stdin( + repo, + [ + "notes", + "--ref", + COMMIT_NOTES_REF, + "add", + "-f", + "-F", + "-", + "--", + commit.as_str(), + ], + note.as_bytes(), + )?; + Ok(()) +} + +fn delete_commit_note_for_repo(repo: &Path, commit: &str) -> Result<(), String> { + let commit = verify_commit(repo, commit)?; + run_git( + repo, + [ + "notes", + "--ref", + COMMIT_NOTES_REF, + "remove", + "--ignore-missing", + "--", + commit.as_str(), + ], + )?; + Ok(()) +} + +fn fetch_commit_notes_for_repo( + repo: &Path, + remote: &str, + username: Option<&str>, + password: Option<&str>, +) -> Result<(), String> { + let remote = validate_remote_name(repo, remote, true)?; + let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]); + let refspec = format!("+{COMMIT_NOTES_REF}:{COMMIT_NOTES_SYNC_REF}"); + let fetch_args = ["fetch", remote.as_str(), refspec.as_str()]; + let fetched = match (username, password) { + (Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => { + run_git_authenticated(repo, fetch_args, user, pass) + } + _ => run_git(repo, fetch_args), + }; + + if let Err(error) = fetched { + let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]); + return Err( + if error.to_lowercase().contains("couldn't find remote ref") { + format!("Remote '{remote}' does not contain commit notes yet.") + } else { + error + }, + ); + } + + let merge_result = (|| -> Result<(), String> { + if ref_exists(repo, COMMIT_NOTES_REF)? { + run_git( + repo, + [ + "notes", + "--ref", + COMMIT_NOTES_REF, + "merge", + "-s", + "cat_sort_uniq", + COMMIT_NOTES_SYNC_REF, + ], + )?; + } else { + let remote_notes_hash = run_git(repo, ["rev-parse", COMMIT_NOTES_SYNC_REF])?; + let remote_notes_hash = String::from_utf8_lossy(&remote_notes_hash) + .trim() + .to_string(); + run_git( + repo, + ["update-ref", COMMIT_NOTES_REF, remote_notes_hash.as_str()], + )?; + } + Ok(()) + })(); + + let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]); + merge_result +} + +fn push_commit_notes_for_repo( + repo: &Path, + remote: &str, + username: Option<&str>, + password: Option<&str>, +) -> Result<(), String> { + let remote = validate_remote_name(repo, remote, true)?; + if !ref_exists(repo, COMMIT_NOTES_REF)? { + return Err("There are no local commit notes to push.".to_string()); + } + let refspec = format!("{COMMIT_NOTES_REF}:{COMMIT_NOTES_REF}"); + let push_args = ["push", remote.as_str(), refspec.as_str()]; + match (username, password) { + (Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => { + run_git_authenticated(repo, push_args, user, pass)?; + } + _ => { + run_git(repo, push_args)?; + } + } + Ok(()) +} + fn commits_for_repo(repo: &Path, limit: Option) -> Result, String> { let bounded_limit = limit.unwrap_or(100).clamp(1, 500); commit_page_for_repo(repo, Some(bounded_limit), None) @@ -5923,6 +6147,91 @@ mod tests { run_git_test(repo, ["commit", "-q", "-m", "init"]); } + #[test] + fn commit_notes_can_be_created_updated_and_deleted_without_changing_commit() { + let repo = init_temp_repo("commit_notes_crud"); + commit_initial_file(&repo.path); + let commit_before = git_output_test(&repo.path, ["rev-parse", "HEAD"]); + + assert_eq!( + commit_note_for_repo(&repo.path, &commit_before).expect("note lookup should work"), + None + ); + + set_commit_note_for_repo( + &repo.path, + &commit_before, + "Review: sieht gut aus\nBuild: 42", + ) + .expect("note should be created"); + assert_eq!( + commit_note_for_repo(&repo.path, &commit_before).expect("note should load"), + Some("Review: sieht gut aus\nBuild: 42".to_string()) + ); + + set_commit_note_for_repo(&repo.path, &commit_before, "Freigabe erteilt") + .expect("note should be replaced"); + assert_eq!( + commit_note_for_repo(&repo.path, &commit_before).expect("updated note should load"), + Some("Freigabe erteilt".to_string()) + ); + + delete_commit_note_for_repo(&repo.path, &commit_before).expect("note should be deleted"); + assert_eq!( + commit_note_for_repo(&repo.path, &commit_before) + .expect("deleted note lookup should work"), + None + ); + assert_eq!( + git_output_test(&repo.path, ["rev-parse", "HEAD"]), + commit_before + ); + } + + #[test] + #[cfg_attr( + windows, + ignore = "Git for Windows can fail local push tests with a sh signal pipe error" + )] + fn commit_notes_can_be_pushed_and_fetched_through_the_notes_ref() { + let source = init_temp_repo("commit_notes_source"); + let target = init_temp_repo("commit_notes_target"); + let remote = init_bare_temp_repo("commit_notes_remote"); + commit_initial_file(&source.path); + let commit = git_output_test(&source.path, ["rev-parse", "HEAD"]); + let remote_url = format!( + "file:///{}", + remote.path.to_string_lossy().replace('\\', "/") + ); + + run_git_test( + &source.path, + ["remote", "add", "origin", remote_url.as_str()], + ); + run_git_test( + &source.path, + ["push", "-q", "origin", "HEAD:refs/heads/main"], + ); + set_commit_note_for_repo(&source.path, &commit, "Shared review note") + .expect("source note should be created"); + push_commit_notes_for_repo(&source.path, "origin", None, None) + .expect("notes should be pushed"); + + run_git_test( + &target.path, + ["remote", "add", "origin", remote_url.as_str()], + ); + run_git_test(&target.path, ["fetch", "-q", "origin", "main"]); + run_git_test(&target.path, ["checkout", "-q", "FETCH_HEAD"]); + fetch_commit_notes_for_repo(&target.path, "origin", None, None) + .expect("notes should be fetched"); + + assert_eq!( + commit_note_for_repo(&target.path, &commit).expect("fetched note should load"), + Some("Shared review note".to_string()) + ); + } + #[test] fn clone_directory_name_is_inferred_from_common_remote_urls() { assert_eq!( diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c52ce6b..3c1098f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,28 +1,33 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod badge; +mod external_tools; mod git; mod telemetry; use badge::set_sync_badge; +use external_tools::{ + detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool, +}; use git::{ SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag, - cred_delete, cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag, - diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url, - get_status, init_repository, last_commit_message, list_branches, list_commits, - list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes, - list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, - merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository, - open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag, - read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, - rename_branch, repair_worktree, resolve_conflict, resolve_conflict_side, - restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit, - revert_commit, run_sequence_editor_if_requested, search_code_introductions, - set_branch_upstream, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop, + cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch, + delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes, get_commit_note, + get_file_blame, get_file_patch, get_remote_url, get_status, init_repository, + last_commit_message, list_branches, list_commits, list_file_history, + list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files, + list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch, + merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle, + open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict, + rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch, + repair_worktree, resolve_conflict, resolve_conflict_side, restore_file_from_commit, + restore_files, restore_reflog_entry, restore_to_commit, revert_commit, + run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream, + set_commit_note, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote, }; use tauri::Manager; @@ -123,6 +128,10 @@ async fn main() { clone_repository, open_repo_in_explorer, open_repository_file, + detect_external_tools, + launch_external_tool, + launch_external_diff, + launch_external_merge, get_status, list_branches, list_remotes, @@ -174,6 +183,11 @@ async fn main() { push, fetch, list_commits, + get_commit_note, + set_commit_note, + delete_commit_note, + fetch_commit_notes, + push_commit_notes, restore_to_commit, restore_file_from_commit, merge_branch, diff --git a/src/App.svelte b/src/App.svelte index 46a9b0e..290a10c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -18,6 +18,7 @@ import BranchPanel from "./lib/components/BranchPanel.svelte"; import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte"; import CommandPalette from "./lib/components/CommandPalette.svelte"; + import CommitNoteDialog from "./lib/components/CommitNoteDialog.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte"; import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte"; import CredentialDialog from "./lib/components/CredentialDialog.svelte"; @@ -56,12 +57,15 @@ createBranch, createTag, deleteBranch, + deleteCommitNote, deleteTag, deleteRemoteBranch, initRepository, diffFileAgainstWorkingTree, compareFileToParent, + fetchCommitNotes, fetchRemote, + getCommitNote, getFileBlame, getStatus, lastCommitMessage, @@ -86,18 +90,24 @@ pruneWorktrees, pull, push, + pushCommitNotes, pushTag, removeRemote, removeWorktree, repairWorktree, revertCommit, setBranchUpstream, + setCommitNote, updateRemote, renameBranch, rebaseAbort, rebaseBranch, rebaseContinue, getRemoteUrl, + detectExternalTools, + launchExternalDiff, + launchExternalMerge, + launchExternalTool, credLoad, credSave, credDelete, @@ -131,8 +141,11 @@ AnalyticsSettings, CommitAiPhase, ConflictFile, + DetectedExternalTool, ExplorerNode, ExplorerNodeKind, + ExternalDiffScope, + ExternalToolsSettings, GitBlameLine, GitBranch as GitBranchInfo, GitCommit, @@ -157,6 +170,12 @@ RepositoryBundle, StoredCredential, } from "./lib/types"; + import { + defaultExternalToolsSettings, + externalToolDisplayName, + normaliseExternalToolsSettings, + resolveDetectedExternalToolPrograms, + } from "./lib/externalTools"; import { orgKeyFromUrl, @@ -223,6 +242,7 @@ const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1"; const APP_THEME_KEY = "gitlite.theme.v1"; const APP_LANGUAGE_KEY = "gitlite.language.v1"; + const EXTERNAL_TOOLS_SETTINGS_KEY = "gitlite.externalTools.v1"; const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1"; const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1"; const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1"; @@ -289,6 +309,15 @@ let expandedExplorerPaths = new Set(); let expandedCommitHashes = new Set(); let selectedCommitHash = ""; + let commitNoteTarget: GitCommit | null = null; + let commitNoteRepoPath = ""; + let commitNoteText = ""; + let commitNoteRemotes: GitRemote[] = []; + let commitNotePreferredRemote = ""; + let commitNoteLoading = false; + let commitNoteBusy = false; + let commitNoteError = ""; + let commitNoteStatus = ""; let fileHistory: GitCommit[] = []; let fileHistoryLoading = false; let fileHistoryError = ""; @@ -319,6 +348,11 @@ let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings(); let appTheme: AppTheme = loadThemePreference(); let appLanguage: AppLanguage = loadLanguagePreference(); + let externalToolsSettings: ExternalToolsSettings = loadExternalToolsSettings(); + let externalToolsConfigured = hasStoredExternalToolsSettings(); + let detectedExternalTools: DetectedExternalTool[] = []; + let externalToolsDetectionPending = true; + let externalToolsDetectionUnavailable = false; let localModelOptions: LocalModelOption[] = []; let errorMessage = ""; let operation = ""; @@ -481,6 +515,11 @@ .filter((repo) => repoMatchesSearch(repo, repoSearchTerm)); $: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed); $: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed; + $: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools); + $: diffToolName = externalToolDisplayName("diff", externalToolsSettings.diff, detectedExternalTools); + $: mergeToolName = externalToolDisplayName("merge", externalToolsSettings.merge, detectedExternalTools); + $: terminalToolName = externalToolDisplayName("terminal", externalToolsSettings.terminal, detectedExternalTools); + $: fileManagerToolName = externalToolDisplayName("fileManager", externalToolsSettings.fileManager, detectedExternalTools); $: applyThemePreference(appTheme); $: applyLanguagePreference(appLanguage); @@ -491,6 +530,7 @@ themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)"); themeMediaQuery.addEventListener("change", handleSystemThemeChange); void runStartupSequence(); + void refreshDetectedExternalTools(); void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; }); window.addEventListener("beforeunload", handleAppShutdown); window.addEventListener("pagehide", handleAppShutdown); @@ -769,7 +809,7 @@ } async function autoRefreshTick() { - if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || globalSearchOpen || helpOpen) return; + if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return; const path = activeRepoPath; autoRefreshInFlight = true; try { @@ -941,16 +981,19 @@ if (appTheme === "system") applyThemePreference(appTheme); } - function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextLanguage: AppLanguage, nextAutoRefresh: boolean) { + function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextLanguage: AppLanguage, nextAutoRefresh: boolean, nextExternalTools: ExternalToolsSettings) { const autoRefreshWasEnabled = autoRefreshEnabled; analyticsSettings = next; appTheme = nextTheme; appLanguage = nextLanguage; autoRefreshEnabled = nextAutoRefresh; + externalToolsSettings = nextExternalTools; persistAnalyticsSettings(next); persistThemePreference(nextTheme); persistLanguagePreference(nextLanguage); persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh); + persistExternalToolsSettings(nextExternalTools); + externalToolsConfigured = true; setTelemetryEnabled(next.enabled); appSettingsOpen = false; if (nextAutoRefresh && !autoRefreshWasEnabled) void autoRefreshTick(); @@ -1794,6 +1837,14 @@ selectedExplorerKind = "file"; expandedExplorerPaths = new Set(); expandedCommitHashes = new Set(); + commitNoteTarget = null; + commitNoteRepoPath = ""; + commitNoteText = ""; + commitNoteRemotes = []; + commitNoteLoading = false; + commitNoteBusy = false; + commitNoteError = ""; + commitNoteStatus = ""; fileHistory = []; fileHistoryLoading = false; fileHistoryError = ""; @@ -2854,6 +2905,143 @@ }); } + function closeCommitNoteDialog() { + if (commitNoteBusy) return; + commitNoteTarget = null; + commitNoteRepoPath = ""; + commitNoteText = ""; + commitNoteRemotes = []; + commitNotePreferredRemote = ""; + commitNoteLoading = false; + commitNoteError = ""; + commitNoteStatus = ""; + } + + async function openCommitNoteDialog(commit: GitCommit) { + if (!activeRepoPath || commitNoteBusy) return; + const repo = activeRepoPath; + selectedCommitHash = commit.hash; + commitNoteTarget = commit; + commitNoteRepoPath = repo; + commitNoteText = ""; + commitNoteRemotes = []; + commitNotePreferredRemote = selectedRemote; + commitNoteLoading = true; + commitNoteError = ""; + commitNoteStatus = ""; + + const [noteResult, remotesResult] = await Promise.allSettled([ + getCommitNote(repo, commit.hash), + listRemotes(repo), + ]); + if (commitNoteRepoPath !== repo || commitNoteTarget?.hash !== commit.hash) return; + + if (noteResult.status === "fulfilled") { + commitNoteText = noteResult.value ?? ""; + } else { + commitNoteError = errorToMessage(noteResult.reason); + } + if (remotesResult.status === "fulfilled") { + commitNoteRemotes = remotesResult.value; + commitNotePreferredRemote = remotesResult.value.some((remote) => remote.name === selectedRemote) + ? selectedRemote + : (remotesResult.value[0]?.name ?? ""); + } else if (!commitNoteError) { + commitNoteError = errorToMessage(remotesResult.reason); + } + commitNoteLoading = false; + } + + async function saveActiveCommitNote(note: string) { + const commit = commitNoteTarget; + const repo = commitNoteRepoPath; + if (!commit || !repo || commitNoteBusy || !note.trim()) return; + commitNoteBusy = true; + commitNoteError = ""; + commitNoteStatus = ""; + try { + await setCommitNote(repo, commit.hash, note); + commitNoteText = note; + commitNoteStatus = appLanguage === "de" + ? "Notiz gespeichert. Der Commit-Hash ist unverändert." + : "Note saved. The commit hash is unchanged."; + trackEvent("commit_note_saved"); + } catch (error) { + commitNoteError = errorToMessage(error); + } finally { + commitNoteBusy = false; + } + } + + async function deleteActiveCommitNote() { + const commit = commitNoteTarget; + const repo = commitNoteRepoPath; + if (!commit || !repo || commitNoteBusy) return; + commitNoteBusy = true; + commitNoteError = ""; + commitNoteStatus = ""; + try { + await deleteCommitNote(repo, commit.hash); + commitNoteText = ""; + commitNoteStatus = appLanguage === "de" ? "Notiz gelöscht." : "Note deleted."; + trackEvent("commit_note_deleted"); + } catch (error) { + commitNoteError = errorToMessage(error); + } finally { + commitNoteBusy = false; + } + } + + async function storedCredentialForNoteRemote(remote: string, direction: "fetch" | "push") { + const config = commitNoteRemotes.find((item) => item.name === remote); + const key = orgKeyFromUrl(direction === "push" ? (config?.push_url ?? "") : (config?.fetch_url ?? "")); + const stored = await loadStoredCredential(key); + if (stored && isCredentialExpired(stored)) { + if (key) await credDelete(key).catch(() => {}); + return null; + } + return stored; + } + + function commitNoteRemoteError(error: unknown): string { + const raw = errorToMessage(error); + if (!isAuthError(raw)) return stripAuthPrefix(raw); + const detail = summarizeGitError(stripAuthPrefix(raw)); + return appLanguage === "de" + ? `${detail || "Anmeldung fehlgeschlagen."} Bitte zuerst über Pull oder Push bei diesem Remote anmelden.` + : `${detail || "Sign-in failed."} Sign in to this remote using Pull or Push first.`; + } + + async function syncActiveCommitNotes(remote: string, direction: "fetch" | "push") { + const commit = commitNoteTarget; + const repo = commitNoteRepoPath; + if (!commit || !repo || !remote || commitNoteBusy) return; + commitNoteBusy = true; + commitNoteError = ""; + commitNoteStatus = ""; + try { + const credential = await storedCredentialForNoteRemote(remote, direction); + if (direction === "fetch") { + await fetchCommitNotes(repo, remote, credential?.username, credential?.password); + commitNoteText = (await getCommitNote(repo, commit.hash)) ?? ""; + commitNoteStatus = appLanguage === "de" + ? `Notizen von ${remote} geladen und zusammengeführt.` + : `Notes fetched from ${remote} and merged.`; + trackEvent("commit_notes_fetched"); + } else { + await pushCommitNotes(repo, remote, credential?.username, credential?.password); + commitNoteStatus = appLanguage === "de" + ? `Notizen zu ${remote} gesendet.` + : `Notes pushed to ${remote}.`; + trackEvent("commit_notes_pushed"); + } + } catch (error) { + commitNoteError = commitNoteRemoteError(error); + } finally { + commitNoteBusy = false; + } + } + async function cherryPickFromCommit(commit: GitCommit) { if (!activeRepoPath || rebaseInProgress || cherryPickInProgress) return; await runOperation(`Cherry-picking ${commit.short_hash}`, async () => { @@ -3752,6 +3940,104 @@ } } + async function openExternalFileDiff(filePath: string, scope: ExternalDiffScope, source: "status" | "explorer" | "diff-dialog") { + if (!activeRepoPath || !filePath || isBusy) return; + await runOperation(`Opening ${filePath} in ${diffToolName}`, async () => { + await launchExternalDiff(activeRepoPath, filePath, externalToolsSettings.diff, scope); + trackEvent("external_tool_opened", { kind: "diff", scope: source }); + }); + } + + async function openPreferredFileDiff(file: GitFileStatus, staged: boolean) { + if (externalToolsSettings.diffOpenMode === "external") { + await openExternalFileDiff(file.path, staged ? "staged" : "unstaged", "status"); + return; + } + await openLinePatch(file, staged); + } + + async function openCurrentLinePatchExternally() { + if (!linePatchFile) return; + await openExternalFileDiff(linePatchFile.path, linePatchStaged ? "staged" : "unstaged", "diff-dialog"); + } + + function loadExternalToolsSettings(): ExternalToolsSettings { + try { + return normaliseExternalToolsSettings(JSON.parse(localStorage.getItem(EXTERNAL_TOOLS_SETTINGS_KEY) ?? "null")); + } catch { + return defaultExternalToolsSettings(); + } + } + + function hasStoredExternalToolsSettings(): boolean { + try { + return localStorage.getItem(EXTERNAL_TOOLS_SETTINGS_KEY) != null; + } catch { + return false; + } + } + + function persistExternalToolsSettings(next: ExternalToolsSettings) { + try { + localStorage.setItem(EXTERNAL_TOOLS_SETTINGS_KEY, JSON.stringify(next)); + } catch { + // Local storage is best-effort only; built-in defaults remain usable. + } + } + + async function refreshDetectedExternalTools(applyDetectedDefaults = true) { + externalToolsDetectionPending = true; + externalToolsDetectionUnavailable = false; + try { + detectedExternalTools = await detectExternalTools(); + if (applyDetectedDefaults) { + externalToolsSettings = externalToolsConfigured + ? resolveDetectedExternalToolPrograms(externalToolsSettings, detectedExternalTools) + : defaultExternalToolsSettings(detectedExternalTools); + } + } catch { + detectedExternalTools = []; + externalToolsDetectionUnavailable = true; + } finally { + externalToolsDetectionPending = false; + } + } + + async function openActiveRepoInEditor() { + if (!activeRepoPath || isBusy) return; + try { + await launchExternalTool(activeRepoPath, externalToolsSettings.editor); + trackEvent("external_tool_opened", { kind: "editor", scope: "repository" }); + } catch (error) { errorMessage = errorToMessage(error); } + } + + async function openActiveRepoTerminal() { + if (!activeRepoPath || isBusy) return; + try { await launchExternalTool(activeRepoPath, externalToolsSettings.terminal); trackEvent("external_tool_opened", { kind: "terminal" }); } + catch (error) { errorMessage = errorToMessage(error); } + } + + async function openActiveRepoFileManager() { + if (!activeRepoPath || isBusy) return; + try { await launchExternalTool(activeRepoPath, externalToolsSettings.fileManager); trackEvent("external_tool_opened", { kind: "file_manager" }); } + catch { await openActiveRepoInExplorer(); } + } + + async function openExplorerFileInEditor(node: ExplorerNode) { + if (!activeRepoPath || node.kind !== "file" || isBusy) return; + try { + await launchExternalTool(activeRepoPath, externalToolsSettings.editor, node.path); + trackEvent("external_tool_opened", { kind: "editor", scope: "file" }); + } catch (error) { + errorMessage = errorToMessage(error); + } + } + + async function compareExplorerFileExternally(node: ExplorerNode) { + if (!activeRepoPath || node.kind !== "file" || !node.tracked || isBusy) return; + await openExternalFileDiff(node.path, "head", "explorer"); + } + async function openFileFromCommandPalette(file: GitRepositoryFile) { if (!activeRepoPath) return; selectedExplorerPath = file.path; @@ -3932,21 +4218,40 @@ async function openResolveDialog() { if (!hasConflicts || isBusy) return; const first = conflictedFiles[0].path; - await runOperation("Loading conflicts", async () => { + const openExternally = externalToolsSettings.mergeOpenMode === "external"; + await runOperation(openExternally ? `Opening ${first} in ${mergeToolName}` : "Loading conflicts", async () => { preparedResolutions = {}; resolveDialogOpen = true; await loadConflict(first); trackEvent("resolve_dialog_opened", { conflicts: conflictedFiles.length, }); + if (openExternally) await launchMergeToolForConflict(first); }); } async function selectConflictFile(path: string) { if (path === conflictTarget || isBusy) return; - await runOperation(`Loading ${path}`, async () => { + const openExternally = externalToolsSettings.mergeOpenMode === "external"; + await runOperation(openExternally ? `Opening ${path} in ${mergeToolName}` : `Loading ${path}`, async () => { await loadConflict(path); trackEvent("conflict_file_selected"); + if (openExternally) await launchMergeToolForConflict(path); + }); + } + + async function launchMergeToolForConflict(path: string) { + if (!activeRepoPath || !path) return; + await launchExternalMerge(activeRepoPath, path, externalToolsSettings.merge); + await loadConflict(path); + await refreshExplorerFiles(activeRepoPath); + trackEvent("external_tool_opened", { kind: "merge", scope: "conflict" }); + } + + async function openConflictInExternalMerge(path: string) { + if (!activeRepoPath || !path || isBusy) return; + await runOperation(`Opening ${path} in ${mergeToolName}`, async () => { + await launchMergeToolForConflict(path); }); } @@ -4080,6 +4385,9 @@ ahead={status?.ahead ?? 0} behind={status?.behind ?? 0} language={appLanguage} + editorName={editorToolName} + terminalName={terminalToolName} + fileManagerName={fileManagerToolName} onFetch={fetchRepo} onPull={pullRepo} onPush={pushRepo} @@ -4088,7 +4396,9 @@ onCompare={openCompareSelect} onInteractiveRebase={openInteractiveRebase} onReflog={openReflog} - onOpenInExplorer={openActiveRepoInExplorer} + onOpenInEditor={openActiveRepoInEditor} + onOpenTerminal={openActiveRepoTerminal} + onOpenInExplorer={openActiveRepoFileManager} onFetchPrune={fetchPruneRepo} onForcePush={forcePushRepo} onSyncOptions={openSyncOptions} @@ -4478,11 +4788,16 @@ {selectedExplorerKind} {hasRepository} {isBusy} + language={appLanguage} + editorName={editorToolName} + diffName={diffToolName} onToggleFolder={toggleExplorerFolder} onExpandAllFolders={expandAllExplorerFolders} onCollapseAllFolders={collapseAllExplorerFolders} onSelectNode={selectExplorerNode} onOpenFile={openFileFromExplorer} + onOpenInEditor={openExplorerFileInEditor} + onExternalDiff={compareExplorerFileExternally} onFileHistory={openFileHistoryDialog} onBlame={openBlame} collapsed={explorerPanelCollapsed} @@ -4540,7 +4855,7 @@ onUnstage={unstageFile} onDiscard={discardFiles} onDiscardMany={discardChanges} - onPatch={openLinePatch} + onPatch={openPreferredFileDiff} onStageAll={stageAllFiles} onUnstageAll={unstageAllFiles} /> @@ -4629,6 +4944,7 @@ onCreateBranchFromCommit={openNewBranchDialog} onCherryPickCommit={cherryPickFromCommit} onRevertCommit={revertHistoryCommit} + onOpenCommitNote={openCommitNoteDialog} onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }} onToggleCommitFiles={(hash) => { const next = new Set(expandedCommitHashes); @@ -4709,6 +5025,11 @@ theme={appTheme} language={appLanguage} autoRefresh={autoRefreshEnabled} + externalTools={externalToolsSettings} + detectedTools={detectedExternalTools} + detectionPending={externalToolsDetectionPending} + detectionUnavailable={externalToolsDetectionUnavailable} + onRefreshDetectedTools={() => refreshDetectedExternalTools(false)} onSave={saveAppSettings} onClose={() => { appSettingsOpen = false; }} /> @@ -4751,6 +5072,9 @@ onClose={closeLinePatch} onRefresh={refreshLinePatch} onApply={applyLinePatch} + language={appLanguage} + diffName={diffToolName} + onExternalDiff={openCurrentLinePatchExternally} /> {/await} {/if} @@ -4849,6 +5173,25 @@ /> {/if} +{#if commitNoteTarget} + syncActiveCommitNotes(remote, "fetch")} + onPush={(remote) => syncActiveCommitNotes(remote, "push")} + onClose={closeCommitNoteDialog} + /> +{/if} + {#if renameBranchTarget} { resolveDialogOpen = false; }} onSelectFile={selectConflictFile} onMarkResolved={handleMarkResolved} onApply={applyPreparedResolutions} + onExternalMerge={openConflictInExternalMerge} /> {/await} {/if} diff --git a/src/app.css b/src/app.css index 163df55..61f9e3a 100644 --- a/src/app.css +++ b/src/app.css @@ -2620,6 +2620,7 @@ background: rgba(65,209,255,0.08); color: var(--color-ink); } + .commit-note-button:not(:disabled) { color: color-mix(in srgb, var(--color-accent) 72%, var(--color-ink-dim)); } /* --- Git graph --- */ .graph-list { @@ -3188,6 +3189,9 @@ max-height: calc(100vh - 32px); overflow: auto; } + .app-settings-dialog { + width: min(880px, calc(100vw - 32px)); + } .clone-repository-dialog { display: block; width: min(620px, calc(100vw - 32px)); @@ -3503,6 +3507,12 @@ .dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); } .dialog-header > div:first-child { min-width: 0; } .dialog-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex: 0 0 auto; min-width: 0; } + .tool-surface-choice { display: inline-flex; align-items: center; gap: 3px; min-width: 0; padding: 3px; border: 1px solid var(--color-border-subtle); border-radius: 7px; background: var(--color-surface-dim); } + .tool-surface-choice > span { padding: 0 6px 0 4px; color: var(--color-ink-faint); font-size: 9px; font-weight: 800; letter-spacing: .035em; text-transform: uppercase; white-space: nowrap; } + .tool-surface-choice > button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-width: 0; min-height: 26px; max-width: 190px; padding: 0 8px; border: 1px solid transparent; border-radius: 5px; color: var(--color-ink-dim); background: transparent; font-size: 10px; font-weight: 750; } + .tool-surface-choice > button > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .tool-surface-choice > button:hover:not(:disabled) { color: var(--color-ink); background: var(--color-surface-hover); } + .tool-surface-choice > button.active { border-color: color-mix(in srgb, var(--color-accent) 32%, var(--color-border)); color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); } .compare-restore { max-width: 170px; min-width: 0; } .compare-restore span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -3511,6 +3521,11 @@ .dialog-close { min-height: 32px; min-width: 32px; padding: 0; justify-content: center; } + @media (max-width: 760px) { + .tool-surface-choice > span { display: none; } + .tool-surface-choice > button { max-width: 120px; padding-inline: 7px; } + } + .dialog-body { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 0; } .compare-dialog .dialog-body { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); } @@ -6589,6 +6604,21 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s padding: 0; border-radius: 5px; } +.explorer-head-actions .explorer-tool-action:not(:disabled) { + color: var(--color-accent); + border-color: color-mix(in srgb, var(--color-accent) 24%, var(--color-border)); + background: color-mix(in srgb, var(--color-accent) 7%, var(--color-surface-raised)); +} +.explorer-head-actions .explorer-tool-action:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--color-accent) 42%, var(--color-border)); + background: color-mix(in srgb, var(--color-accent) 13%, var(--color-surface-raised)); +} +.explorer-action-divider { + inline-size: 1px; + block-size: 14px; + margin-inline: 1px; + background: var(--color-border); +} .ai-review-suggestion { display: grid; gap: 3px; margin-top: 9px; padding: 8px 9px; border-radius: 5px; background: var(--color-surface-dim); } .ai-review-suggestion strong { color: var(--color-ink-dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; } .ai-review-suggestion span { color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.45; } diff --git a/src/lib/RepoToolbar.svelte b/src/lib/RepoToolbar.svelte index 4809b7b..ce69e8c 100644 --- a/src/lib/RepoToolbar.svelte +++ b/src/lib/RepoToolbar.svelte @@ -1,6 +1,7 @@
+ + + + +