From 15d1f2bfd61750be83025c946cd422cea180541a Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 13 Aug 2026 14:08:02 +0200 Subject: [PATCH] feat(external-tools): add cross-platform external tool discovery Adds a new external tools subsystem to detect and launch diff and editor tools across Windows, macOS, and Linux. It exposes data models for tools, commands, and results to the UI and serializes them for consumption by the app. - Implement cross-platform discovery of editors and diff tools - Expose serialized results to the UI for user selection - Centralize per-OS known tool lists and overrides --- src-tauri/src/external_tools.rs | 1652 +++++++++++++++++++ src-tauri/src/git.rs | 309 ++++ src-tauri/src/main.rs | 38 +- src/App.svelte | 358 +++- src/app.css | 30 + src/lib/RepoToolbar.svelte | 21 +- src/lib/components/AppSettingsDialog.svelte | 626 +++++-- src/lib/components/CommitNoteDialog.svelte | 303 ++++ src/lib/components/ExplorerPanel.svelte | 70 +- src/lib/components/HistoryPanel.svelte | 32 +- src/lib/components/LinePatchDialog.svelte | 19 +- src/lib/components/ResolveDialog.svelte | 28 +- src/lib/externalTools.ts | 281 ++++ src/lib/git.ts | 49 + src/lib/types.ts | 30 + 15 files changed, 3721 insertions(+), 125 deletions(-) create mode 100644 src-tauri/src/external_tools.rs create mode 100644 src/lib/components/CommitNoteDialog.svelte create mode 100644 src/lib/externalTools.ts 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 @@
+ + + + +