diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ba650..ad10d25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,82 @@ All notable user-facing changes to Gitty are documented in this file. -The project uses calendar versions. Displayed release names use `YYYY.MM.DD`; -package metadata uses the equivalent numeric form without leading zeroes where -required by the package manager. +The project uses calendar-style versions in the form `YYYY.M.PATCH`. + +## [2026.8.3] - 2026-08-13 + +### Added + +- Configurable external tools for editors, diff viewers, merge tools, + terminals, and file managers, including automatic cross-platform discovery + and presets for VS Code, JetBrains IDEs, Beyond Compare, and other common + applications. +- Repository and file actions for opening content in the configured external + application. Supported tools open in a separate window. +- A choice between Gitty's internal diff/merge views and the configured + external applications. +- Git Notes support for attaching editable notes to commits without rewriting + commit history, including fetch and push synchronization. +- A command palette for quickly opening repository actions, files, and commits. +- Complete branch-to-branch comparisons for local and remote branches. The + comparison dialog shows every changed file and its side-by-side diff. +- Safe remote branch renaming from the branch context menu. + +### Changed + +- Redesigned the settings window with tool categories, detected applications, + preset dropdowns, and clearer explanations of where each tool is used. +- Redesigned the history graph's branch presentation with compact labels, + hover details, cleaner flag connectors, and branch visibility controls. +- Reduced the minimum width of the commit history panel so the workspace can + be resized more freely. +- Local-only branches are now identified consistently in the toolbar, + repository summary, status bar, and commit graph. Their first push is labeled + Publish and configures the remote tracking branch automatically. +- Git operations now run asynchronously to keep the application responsive + during slower repository commands. + +### Fixed + +- Closing supported external tools no longer reports their documented + comparison result codes as application errors. +- External tools that otherwise reuse an existing process are explicitly + opened in a new window where supported. +- Remote branch renaming uses an atomic push with lease checks, preventing an + existing destination branch or a newly changed remote branch from being + overwritten. + +## [2026.8.2] - 2026-08-10 + +### Changed + +- History graph colors remain stable across parent lanes, making longer and + branching histories easier to follow. +- Release artifacts are published to the matching Gitea release automatically + without creating duplicate assets. +- Application shutdown now completes telemetry cleanup more reliably. + +## [2026.8.1] - 2026-08-04 + +### Added + +- Paginated commit history that loads older commits on demand instead of + limiting the visible repository history to the initial page. +- A dedicated file-history dialog opened from the explorer context menu. +- Windows and Ubuntu release publishing plus improved AUR packaging workflows. + +### Changed + +- File history moved out of the permanent workspace panel into a focused, + larger dialog. +- Dialogs close more consistently with the Escape key. +- Arch Linux installation documentation now uses the `gitty-desktop` AUR + package. + +### Fixed + +- AUR SSH setup, package installation timeouts, and clone/push retries are more + robust in the release workflow. ## [2026.07.22] - 2026-07-22 @@ -88,3 +161,6 @@ required by the package manager. [2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22 [2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21 [2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20 +[2026.8.3]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.3 +[2026.8.2]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.2 +[2026.8.1]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.1 diff --git a/package-lock.json b/package-lock.json index 2fb8c11..4f445a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitty", - "version": "2026.8.2", + "version": "2026.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitty", - "version": "2026.8.2", + "version": "2026.8.3", "dependencies": { "@lucide/svelte": "^1.21.0", "@tailwindcss/vite": "^4.3.1", diff --git a/package.json b/package.json index f6e13f7..59b4370 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gitty", - "version": "2026.8.2", + "version": "2026.8.3", "private": true, "type": "module", "scripts": { diff --git a/src-tauri/src/external_tools.rs b/src-tauri/src/external_tools.rs new file mode 100644 index 0000000..2826c91 --- /dev/null +++ b/src-tauri/src/external_tools.rs @@ -0,0 +1,1805 @@ +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", + &["git-bash.exe", "bash.exe"], + &[ + r"%PROGRAMFILES%\Git\git-bash.exe", + r"%LOCALAPPDATA%\Programs\Git\git-bash.exe", + r"%PROGRAMFILES%\Git\bin\bash.exe", + r"%LOCALAPPDATA%\Programs\Git\bin\bash.exe", + ], + &[], + &[], + TERMINAL, + ), + tool( + "cmd", + "Command Prompt", + &["cmd.exe"], + &[r"%WINDIR%\System32\cmd.exe"], + &[], + &[], + TERMINAL, + ), + tool( + "wezterm", + "WezTerm", + &["wezterm-gui.exe", "wezterm.exe"], + &[r"%PROGRAMFILES%\WezTerm\wezterm-gui.exe"], + &[], + &[], + TERMINAL, + ), + tool( + "alacritty", + "Alacritty", + &["alacritty.exe"], + &[], + &[], + &[], + TERMINAL, + ), + tool( + "kitty-terminal", + "kitty", + &["kitty.exe"], + &[], + &[], + &[], + TERMINAL, + ), + tool( + "explorer", + "Windows Explorer", + &["explorer.exe"], + &[r"%WINDIR%\explorer.exe"], + &[], + &[], + FILE_MANAGER, + ), + tool( + "total-commander", + "Total Commander", + &["TOTALCMD64.EXE", "TOTALCMD.EXE"], + &[ + r"%PROGRAMFILES%\totalcmd\TOTALCMD64.EXE", + r"%PROGRAMFILES(X86)%\totalcmd\TOTALCMD.EXE", + ], + &[], + &[], + FILE_MANAGER, + ), + tool( + "directory-opus", + "Directory Opus", + &["dopus.exe"], + &[r"%PROGRAMFILES%\GPSoftware\Directory Opus\dopus.exe"], + &[], + &[], + FILE_MANAGER, + ), + tool( + "double-commander", + "Double Commander", + &["doublecmd.exe"], + &[r"%PROGRAMFILES%\Double Commander\doublecmd.exe"], + &[], + &[], + FILE_MANAGER, + ), + tool( + "freecommander", + "FreeCommander XE", + &["FreeCommander.exe"], + &[ + r"%LOCALAPPDATA%\FreeCommander XE\FreeCommander.exe", + r"%PROGRAMFILES%\FreeCommander XE\FreeCommander.exe", + ], + &[], + &[], + FILE_MANAGER, + ), + tool( + "xyplorer", + "XYplorer", + &["XYplorer.exe"], + &[r"%PROGRAMFILES(X86)%\XYplorer\XYplorer.exe"], + &[], + &[], + FILE_MANAGER, + ), + ] +} + +#[cfg(target_os = "macos")] +fn known_tools() -> Vec { + 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 command_in_new_window(mut command: ExternalToolCommand) -> ExternalToolCommand { + let executable = Path::new(command.program.trim()) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(command.program.trim()) + .to_ascii_lowercase(); + + let remove_flags = |args: &mut Vec, flags: &[&str]| { + args.retain(|arg| !flags.iter().any(|flag| arg.eq_ignore_ascii_case(flag))); + }; + let prepend_flag = |args: &mut Vec, flag: &str| { + if !args.iter().any(|arg| arg.eq_ignore_ascii_case(flag)) { + args.insert(0, flag.to_string()); + } + }; + + if matches!( + executable.as_str(), + "code" + | "code.exe" + | "code-insiders" + | "code-insiders.exe" + | "code - insiders.exe" + | "cursor" + | "cursor.exe" + | "windsurf" + | "windsurf.exe" + | "vscodium" + | "vscodium.exe" + | "codium" + | "codium.exe" + ) { + remove_flags(&mut command.args, &["--reuse-window", "-r"]); + prepend_flag(&mut command.args, "--new-window"); + } else if matches!(executable.as_str(), "zed" | "zed.exe") { + remove_flags(&mut command.args, &["--reuse", "-r"]); + prepend_flag(&mut command.args, "--new"); + } else if matches!( + executable.as_str(), + "subl" | "subl.exe" | "sublime_text" | "sublime_text.exe" + ) { + prepend_flag(&mut command.args, "--new-window"); + } else if executable == "notepad++.exe" { + prepend_flag(&mut command.args, "-multiInst"); + } else if executable == "kate" || executable == "kate.exe" { + prepend_flag(&mut command.args, "--new"); + } else if executable == "geany" || executable == "geany.exe" { + prepend_flag(&mut command.args, "--new-instance"); + } else if executable == "wt" || executable == "wt.exe" { + let mut index = 0; + while index < command.args.len() { + let is_window = command.args[index].eq_ignore_ascii_case("-w") + || command.args[index].eq_ignore_ascii_case("--window") + || command.args[index] + .to_ascii_lowercase() + .starts_with("--window="); + if is_window { + let has_separate_value = !command.args[index].contains('='); + command.args.remove(index); + if has_separate_value && index < command.args.len() { + command.args.remove(index); + } + } else { + index += 1; + } + } + command + .args + .splice(0..0, ["-w".to_string(), "new".to_string()]); + } else if executable == "explorer" || executable == "explorer.exe" { + prepend_flag(&mut command.args, "/n,"); + } else if matches!( + executable.as_str(), + "totalcmd" | "totalcmd.exe" | "totalcmd64" | "totalcmd64.exe" + ) { + command + .args + .retain(|arg| !arg.to_ascii_lowercase().starts_with("/o")); + prepend_flag(&mut command.args, "/N"); + } else if executable == "open" { + prepend_flag(&mut command.args, "-n"); + } else if executable.starts_with("bcomp") { + remove_flags(&mut command.args, &["/solo", "-solo"]); + #[cfg(windows)] + prepend_flag(&mut command.args, "/solo"); + #[cfg(not(windows))] + prepend_flag(&mut command.args, "-solo"); + } else if executable == "winmergeu" || executable == "winmergeu.exe" { + remove_flags(&mut command.args, &["/s", "/sw", "-s", "-sw"]); + prepend_flag(&mut command.args, "/s-"); + } + + command +} + +fn tool_values(repo: &Path, file: Option<&Path>) -> BTreeMap<&'static str, String> { + let mut values = BTreeMap::new(); + values.insert("repo", repo.to_string_lossy().into_owned()); + if let Some(file) = file { + values.insert("file", file.to_string_lossy().into_owned()); + values.insert( + "parent", + file.parent().unwrap_or(repo).to_string_lossy().into_owned(), + ); + } else { + values.insert("file", repo.to_string_lossy().into_owned()); + values.insert("parent", repo.to_string_lossy().into_owned()); + } + values +} + +fn spawn_tool( + command: ExternalToolCommand, + repo: &Path, + values: BTreeMap<&str, String>, +) -> Result<(), String> { + let command = command_in_new_window(command); + let args = expand_args(&command, &values)?; + let mut process = Command::new(command.program.trim()); + process.args(args).current_dir(repo); + process + .spawn() + .map_err(|error| format!("Could not launch external tool: {error}"))?; + Ok(()) +} + +fn run_tool( + command: ExternalToolCommand, + repo: &Path, + values: BTreeMap<&str, String>, + kind: ExternalToolRunKind, +) -> Result<(), String> { + let command = command_in_new_window(command); + let args = expand_args(&command, &values)?; + let program = command.program.trim().to_owned(); + let mut process = Command::new(command.program.trim()); + process.args(args).current_dir(repo); + #[cfg(windows)] + process.creation_flags(CREATE_NO_WINDOW); + let status = process + .status() + .map_err(|error| format!("Could not launch external tool: {error}"))?; + if !status.success() && !is_expected_tool_exit(&program, kind, status.code()) { + return Err(format!("External tool exited with status {status}.")); + } + Ok(()) +} + +fn is_expected_tool_exit(program: &str, kind: ExternalToolRunKind, code: Option) -> 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 forces_code_family_tools_into_a_new_window() { + let command = command_in_new_window(ExternalToolCommand { + program: "C:/Program Files/Microsoft VS Code/Code.exe".into(), + args: vec![ + "--reuse-window".into(), + "--diff".into(), + "{left}".into(), + "{right}".into(), + ], + }); + + assert_eq!( + command.args, + vec!["--new-window", "--diff", "{left}", "{right}"] + ); + } + + #[test] + fn forces_windows_terminal_to_use_a_fresh_window() { + let command = command_in_new_window(ExternalToolCommand { + program: "wt.exe".into(), + args: vec![ + "--window".into(), + "last".into(), + "-d".into(), + "{repo}".into(), + ], + }); + + assert_eq!(command.args, vec!["-w", "new", "-d", "{repo}"]); + } + + #[test] + fn forces_single_instance_compare_and_file_tools_into_new_windows() { + let beyond = command_in_new_window(ExternalToolCommand { + program: "BCompare.exe".into(), + args: vec!["/readonly".into(), "{left}".into(), "{right}".into()], + }); + let winmerge = command_in_new_window(ExternalToolCommand { + program: "WinMergeU.exe".into(), + args: vec!["/s".into(), "{left}".into(), "{right}".into()], + }); + let total_commander = command_in_new_window(ExternalToolCommand { + program: "TOTALCMD64.EXE".into(), + args: vec!["/O".into(), "/T".into(), "{repo}".into()], + }); + + #[cfg(windows)] + assert_eq!(beyond.args[0], "/solo"); + #[cfg(not(windows))] + assert_eq!(beyond.args[0], "-solo"); + assert_eq!(winmerge.args, vec!["/s-", "{left}", "{right}"]); + assert_eq!(total_commander.args, vec!["/N", "/T", "{repo}"]); + } + + #[test] + fn rejects_unknown_placeholder() { + let command = ExternalToolCommand { + program: "tool".into(), + args: vec!["{missing}".into()], + }; + assert!( + expand_args(&command, &BTreeMap::new()) + .unwrap_err() + .contains("placeholder") + ); + } + + #[test] + fn rejects_empty_program() { + let command = ExternalToolCommand { + program: " ".into(), + args: vec![], + }; + assert!(expand_args(&command, &BTreeMap::new()).is_err()); + } + + #[test] + fn repository_is_a_valid_file_placeholder_for_repository_actions() { + let repo = Path::new("C:/repo"); + let values = tool_values(repo, None); + assert_eq!(values.get("repo"), values.get("file")); + assert_eq!(values.get("repo"), values.get("parent")); + } + + #[test] + fn accepts_documented_beyond_compare_result_codes() { + for code in [1, 2, 11, 12, 13] { + assert!(is_expected_tool_exit( + "C:/Program Files/Beyond Compare 5/BCompare.exe", + ExternalToolRunKind::Diff, + Some(code) + )); + assert!(is_expected_tool_exit( + "BComp.exe", + ExternalToolRunKind::Merge, + Some(code) + )); + } + } + + #[test] + fn keeps_real_external_tool_failures_visible() { + assert!(!is_expected_tool_exit( + "BCompare.exe", + ExternalToolRunKind::Merge, + Some(14) + )); + assert!(!is_expected_tool_exit( + "BCompare.exe", + ExternalToolRunKind::Merge, + Some(101) + )); + assert!(!is_expected_tool_exit( + "Code.exe", + ExternalToolRunKind::Diff, + Some(13) + )); + assert!(is_expected_tool_exit( + "meld", + ExternalToolRunKind::Diff, + Some(1) + )); + } +} diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index dabfad4..3b76c3f 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -66,6 +66,7 @@ pub struct GitBranch { pub name: String, pub current: bool, pub remote: bool, + pub upstream: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -675,6 +676,99 @@ pub fn delete_remote_branch( result } +#[tauri::command] +pub async fn rename_remote_branch( + path: String, + remote: String, + old_branch: String, + new_branch: String, +) -> Result { + run_git_task("Could not rename remote branch", move || { + rename_remote_branch_core(path, remote, old_branch, new_branch) + }) + .await +} + +fn rename_remote_branch_core( + path: String, + remote: String, + old_branch: String, + new_branch: String, +) -> Result { + let repo = resolve_repo(&path)?; + let remote = validate_remote_name(&repo, &remote, true)?; + let old_branch = validate_branch_ref_name(old_branch.trim())?; + let new_branch = validate_branch_ref_name(new_branch.trim())?; + + if old_branch == new_branch { + return Err("The new remote branch name is unchanged.".to_string()); + } + + let old_tracking_ref = format!("refs/remotes/{remote}/{old_branch}"); + let new_tracking_ref = format!("refs/remotes/{remote}/{new_branch}"); + if !ref_exists(&repo, &old_tracking_ref)? { + return Err(format!( + "Remote branch '{remote}/{old_branch}' was not found." + )); + } + if ref_exists(&repo, &new_tracking_ref)? { + return Err(format!( + "Remote branch '{remote}/{new_branch}' already exists." + )); + } + + let old_hash = run_git(&repo, ["rev-parse", "--verify", old_tracking_ref.as_str()])?; + let old_hash = String::from_utf8_lossy(&old_hash).trim().to_string(); + let old_remote_ref = format!("refs/heads/{old_branch}"); + let new_remote_ref = format!("refs/heads/{new_branch}"); + let source_lease = format!("--force-with-lease={old_remote_ref}:{old_hash}"); + // An empty expected value means the destination must not exist on the remote. + let destination_lease = format!("--force-with-lease={new_remote_ref}:"); + let create_refspec = format!("{old_tracking_ref}:{new_remote_ref}"); + let delete_refspec = format!(":{old_remote_ref}"); + + // Git has no standalone remote-rename command. Create the new ref and delete + // the old one in a single atomic push so a rejected update leaves both untouched. + run_git( + &repo, + [ + "push", + "--atomic", + source_lease.as_str(), + destination_lease.as_str(), + remote.as_str(), + create_refspec.as_str(), + delete_refspec.as_str(), + ], + )?; + + // Git normally updates remote-tracking refs after a successful push. Keep the + // local view consistent as a fallback for unusual remote/refspec setups. + if !ref_exists(&repo, &new_tracking_ref)? { + if let Err(error) = run_git( + &repo, + ["update-ref", new_tracking_ref.as_str(), old_hash.as_str()], + ) { + log::warn!(target: "gitty::remote", "remote rename succeeded, but the new tracking ref could not be updated: {error}"); + } + } + if ref_exists(&repo, &old_tracking_ref)? { + if let Err(error) = run_git( + &repo, + [ + "update-ref", + "-d", + old_tracking_ref.as_str(), + old_hash.as_str(), + ], + ) { + log::warn!(target: "gitty::remote", "remote rename succeeded, but the old tracking ref could not be removed: {error}"); + } + } + + status_for_repo(&repo) +} + #[tauri::command] pub async fn list_stashes(path: String) -> Result, String> { run_git_task("Could not load stashes", move || { @@ -749,7 +843,7 @@ fn branches_for_repo(repo: &Path) -> Result, String> { repo, [ "for-each-ref", - "--format=%(refname)\t%(HEAD)", + "--format=%(refname)\t%(HEAD)\t%(upstream:short)", "refs/heads", "refs/remotes", ], @@ -758,9 +852,10 @@ fn branches_for_repo(repo: &Path) -> Result, String> { let mut branches = Vec::new(); for line in text.lines() { - let Some((ref_name, head_marker)) = line.split_once('\t') else { - continue; - }; + let mut parts = line.splitn(3, '\t'); + let ref_name = parts.next().unwrap_or_default(); + let head_marker = parts.next().unwrap_or_default(); + let configured_upstream = parts.next().unwrap_or_default().trim(); let (name, remote) = if let Some(name) = ref_name.strip_prefix("refs/heads/") { (name, false) @@ -777,6 +872,11 @@ fn branches_for_repo(repo: &Path) -> Result, String> { name: name.to_string(), current: head_marker.trim() == "*", remote, + upstream: if remote || configured_upstream.is_empty() { + None + } else { + Some(configured_upstream.to_string()) + }, }); } @@ -2332,8 +2432,6 @@ const CRED_SERVICE: &str = "tauri_git_lite"; pub struct StoredCredential { pub username: String, pub password: String, - #[serde(default, rename = "expiresAt", skip_serializing_if = "Option::is_none")] - pub expires_at: Option, } fn cred_entry(key: &str) -> Result { @@ -2516,19 +2614,9 @@ pub fn cred_load(key: String) -> Result, String> { } #[tauri::command(async)] -pub fn cred_save( - key: String, - username: String, - password: String, - expires_at: Option, -) -> Result<(), String> { +pub fn cred_save(key: String, username: String, password: String) -> Result<(), String> { let entry = cred_entry(&key)?; - let expires_at = expires_at.filter(|value| !value.trim().is_empty()); - let cred = StoredCredential { - username, - password, - expires_at, - }; + let cred = StoredCredential { username, password }; let json = serde_json::to_string(&cred) .map_err(|err| format!("Could not serialize credentials: {err}"))?; entry @@ -3129,6 +3217,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 +6235,141 @@ mod tests { run_git_test(repo, ["commit", "-q", "-m", "init"]); } + #[test] + fn branches_report_configured_upstream_and_local_only_state() { + let repo = init_temp_repo("branch_upstream_state"); + commit_initial_file(&repo.path); + run_git_test(&repo.path, ["branch", "feature/local-only"]); + run_git_test(&repo.path, ["branch", "feature/tracked"]); + run_git_test(&repo.path, ["remote", "add", "origin", "."]); + run_git_test( + &repo.path, + [ + "update-ref", + "refs/remotes/origin/feature/published", + "HEAD", + ], + ); + run_git_test( + &repo.path, + ["config", "branch.feature/tracked.remote", "origin"], + ); + run_git_test( + &repo.path, + [ + "config", + "branch.feature/tracked.merge", + "refs/heads/feature/published", + ], + ); + + let branches = branches_for_repo(&repo.path).expect("branches should load"); + let local_only = branches + .iter() + .find(|branch| branch.name == "feature/local-only") + .expect("local-only branch should exist"); + let tracked = branches + .iter() + .find(|branch| branch.name == "feature/tracked") + .expect("tracked branch should exist"); + let remote = branches + .iter() + .find(|branch| branch.name == "origin/feature/published") + .expect("remote branch should exist"); + + assert_eq!(local_only.upstream, None); + assert_eq!( + tracked.upstream.as_deref(), + Some("origin/feature/published") + ); + assert_eq!(remote.upstream, None); + } + + #[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!( @@ -6377,6 +6824,31 @@ mod tests { assert!(comparison.patch.contains("second line")); } + #[test] + fn compare_commits_accepts_branch_refs_for_a_full_repository_diff() { + let repo = init_temp_repo("compare_branches"); + commit_initial_file(&repo.path); + run_git_test(&repo.path, ["branch", "base"]); + + fs::write(repo.path.join("branch-only.txt"), "only on feature\n") + .expect("branch file should be written"); + run_git_test(&repo.path, ["add", "branch-only.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "feature change"]); + run_git_test(&repo.path, ["branch", "feature/complete-compare"]); + + let comparison = compare_commits( + repo.path.to_string_lossy().to_string(), + "refs/heads/base".to_string(), + "refs/heads/feature/complete-compare".to_string(), + ) + .unwrap(); + + assert!(comparison.files.iter().any(|file| { + file.path == "branch-only.txt" && file.status == FileStatusKind::Added + })); + assert!(comparison.patch.contains("only on feature")); + } + #[test] fn compare_commits_includes_full_file_context() { let repo = init_temp_repo("compare_full_context"); @@ -6986,6 +7458,54 @@ mod tests { ); } + #[test] + #[cfg_attr( + windows, + ignore = "Git for Windows can fail local push tests with a sh signal pipe error" + )] + fn rename_remote_branch_moves_the_remote_ref_atomically() { + let repo = init_temp_repo("rename_remote_branch"); + let remote = init_bare_temp_repo("rename_remote_branch_remote"); + commit_initial_file(&repo.path); + let commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]); + let remote_url = format!( + "file:///{}", + remote.path.to_string_lossy().replace('\\', "/") + ); + + run_git_test(&repo.path, ["remote", "add", "origin", remote_url.as_str()]); + run_git_test( + &repo.path, + ["push", "-q", "origin", "HEAD:refs/heads/feature/old-name"], + ); + run_git_test(&repo.path, ["fetch", "-q", "origin"]); + + rename_remote_branch_core( + repo.path.to_string_lossy().to_string(), + "origin".to_string(), + "feature/old-name".to_string(), + "feature/new-name".to_string(), + ) + .unwrap(); + + assert!( + !ref_exists(&remote.path, "refs/heads/feature/old-name").unwrap(), + "old remote branch should be gone" + ); + assert_eq!( + git_output_test(&remote.path, ["rev-parse", "refs/heads/feature/new-name"]), + commit + ); + assert!( + !ref_exists(&repo.path, "refs/remotes/origin/feature/old-name").unwrap(), + "old remote-tracking branch should be gone" + ); + assert!( + ref_exists(&repo.path, "refs/remotes/origin/feature/new-name").unwrap(), + "new remote-tracking branch should exist" + ); + } + #[test] fn delete_branch_removes_local_branch_but_rejects_current_branch() { let repo = init_temp_repo("delete_branch"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c52ce6b..a5df731 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,29 +1,35 @@ #![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, + 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, + rename_remote_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, - stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote, + 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; use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled}; @@ -123,6 +129,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, @@ -135,6 +145,7 @@ async fn main() { checkout_branch, create_branch, rename_branch, + rename_remote_branch, delete_branch, list_worktrees, add_worktree, @@ -174,6 +185,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-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 745251c..41a9a07 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Gitty", - "version": "2026.8.2", + "version": "2026.8.3", "identifier": "com.gitty", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.svelte b/src/App.svelte index e8f4dc5..8e25b87 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -4,7 +4,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; - import { AlertCircle, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte"; + import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte"; import { beginFrontendShutdown } from "./lib/telemetry"; import TitleBar from "./lib/TitleBar.svelte"; @@ -17,6 +17,8 @@ import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte"; 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"; @@ -55,12 +57,15 @@ createBranch, createTag, deleteBranch, + deleteCommitNote, deleteTag, deleteRemoteBranch, initRepository, diffFileAgainstWorkingTree, compareFileToParent, + fetchCommitNotes, fetchRemote, + getCommitNote, getFileBlame, getStatus, lastCommitMessage, @@ -85,18 +90,25 @@ pruneWorktrees, pull, push, + pushCommitNotes, pushTag, removeRemote, removeWorktree, repairWorktree, revertCommit, setBranchUpstream, + setCommitNote, updateRemote, renameBranch, + renameRemoteBranch, rebaseAbort, rebaseBranch, rebaseContinue, getRemoteUrl, + detectExternalTools, + launchExternalDiff, + launchExternalMerge, + launchExternalTool, credLoad, credSave, credDelete, @@ -130,8 +142,11 @@ AnalyticsSettings, CommitAiPhase, ConflictFile, + DetectedExternalTool, ExplorerNode, ExplorerNodeKind, + ExternalDiffScope, + ExternalToolsSettings, GitBlameLine, GitBranch as GitBranchInfo, GitCommit, @@ -156,10 +171,15 @@ RepositoryBundle, StoredCredential, } from "./lib/types"; + import { + defaultExternalToolsSettings, + externalToolDisplayName, + normaliseExternalToolsSettings, + resolveDetectedExternalToolPrograms, + } from "./lib/externalTools"; import { orgKeyFromUrl, - isCredentialExpired, isAuthError, stripAuthPrefix, summarizeGitError, @@ -222,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"; @@ -245,7 +266,7 @@ const LEFT_STASH_PANEL_MAX_HEIGHT = 420; const LEFT_EXPLORER_PANEL_MIN_HEIGHT = 220; const HISTORY_ASIDE_DEFAULT_WIDTH = 620; - const HISTORY_ASIDE_MIN_WIDTH = 560; + const HISTORY_ASIDE_MIN_WIDTH = 420; const HISTORY_ASIDE_MAX_WIDTH = 920; const ERROR_AUTO_HIDE_MS = 6000; const COMMIT_HISTORY_PAGE_SIZE = 50; @@ -287,6 +308,16 @@ let selectedExplorerKind: ExplorerNodeKind = "file"; 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 = ""; @@ -312,16 +343,24 @@ let aiSettingsOpen = false; let appSettingsOpen = false; let helpOpen = false; + let commandPaletteOpen = false; let analyticsNoticeOpen = false; 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 = ""; let compareFrom = ""; let compareTo = ""; let comparison: GitCommitComparison | null = null; + let comparisonFromLabel = ""; + let comparisonToLabel = ""; let newBranchCommit: GitCommit | null = null; let renameBranchTarget: GitBranchInfo | null = null; let deleteBranchTarget: GitBranchInfo | null = null; @@ -466,7 +505,11 @@ $: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy; $: localBranches = branches.filter((b) => !b.remote); $: localBranchNames = localBranches.map((b) => b.name); + $: localBranchUpstreams = Object.fromEntries( + localBranches.flatMap((branch) => branch.upstream ? [[branch.name, branch.upstream]] : []), + ) as Record; $: remoteBranches = branches.filter((b) => b.remote); + $: currentBranchIsLocalOnly = Boolean(status?.current_branch) && !status?.upstream; $: repoSearchTerm = repoSearch.trim().toLowerCase(); $: openRepoRows = repoTabs.filter((repo) => repoMatchesSearch(repo, repoSearchTerm)); $: recentRepoRows = recentRepoPaths @@ -478,6 +521,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); @@ -488,6 +536,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); @@ -766,7 +815,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 { @@ -938,16 +987,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(); @@ -1791,6 +1843,14 @@ selectedExplorerKind = "file"; expandedExplorerPaths = new Set(); expandedCommitHashes = new Set(); + commitNoteTarget = null; + commitNoteRepoPath = ""; + commitNoteText = ""; + commitNoteRemotes = []; + commitNoteLoading = false; + commitNoteBusy = false; + commitNoteError = ""; + commitNoteStatus = ""; fileHistory = []; fileHistoryLoading = false; fileHistoryError = ""; @@ -1798,6 +1858,8 @@ compareFrom = ""; compareTo = ""; comparison = null; + comparisonFromLabel = ""; + comparisonToLabel = ""; compareSelectOpen = false; compareDialogOpen = false; interactiveRebaseOpen = false; @@ -1927,15 +1989,12 @@ commitHistoryLoadingMore = false; commitHistoryLoadError = ""; lastFileHistoryHeadHash = commits[0]?.hash ?? ""; - const hashes = new Set(commits.map((c) => c.hash)); - if (compareFrom && !hashes.has(compareFrom)) compareFrom = ""; - if (compareTo && !hashes.has(compareTo)) compareTo = ""; - if (comparison && comparison.to_hash.length > 0 && (!hashes.has(comparison.from_hash) || !hashes.has(comparison.to_hash))) { - comparison = null; - compareDialogOpen = false; - selectedDiffPath = ""; - pendingRestoreFile = null; - } + const targets = new Set([ + ...commits.map((commit) => commit.hash), + ...branches.map(compareRefForBranch), + ]); + if (compareFrom && !targets.has(compareFrom)) compareFrom = ""; + if (compareTo && !targets.has(compareTo)) compareTo = ""; } async function loadMoreCommitHistory() { @@ -2159,11 +2218,10 @@ if (!username && !password) { const stored = await loadStoredCredential(credentialKey); - if (stored && !isCredentialExpired(stored)) { + if (stored) { await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true); return; } - if (stored && credentialKey) await credDelete(credentialKey).catch(() => {}); } operation = "Cloning repository"; @@ -2428,22 +2486,47 @@ }); } - function renameLocalBranch(branch: GitBranchInfo) { - if (!activeRepoPath || branch.remote) return; + function openRenameBranchDialog(branch: GitBranchInfo) { + if (!activeRepoPath) return; + if (branch.remote && branch.name.indexOf("/") < 1) { + errorMessage = "Could not determine remote name."; + return; + } renameBranchTarget = branch; - trackEvent("branch_rename_dialog_opened"); + trackEvent("branch_rename_dialog_opened", { remote: branch.remote ? 1 : 0 }); } async function submitRenameBranch(branchName: string) { const branch = renameBranchTarget; const name = branchName.trim(); - if (!activeRepoPath || !branch || branch.remote || !name || name === branch.name) return; + if (!activeRepoPath || !branch || !name) return; + + if (branch.remote) { + const slash = branch.name.indexOf("/"); + if (slash < 1) { + errorMessage = "Could not determine remote name."; + return; + } + const remote = branch.name.slice(0, slash); + const oldRemoteBranch = branch.name.slice(slash + 1); + if (name === oldRemoteBranch) return; + + await runOperation(`Renaming ${branch.name} on remote`, async () => { + applyStatus(await renameRemoteBranch(activeRepoPath, remote, oldRemoteBranch, name)); + renameBranchTarget = null; + await refreshRefsAndCommitGraph(activeRepoPath); + trackEvent("branch_renamed", { remote: 1 }); + }); + return; + } + + if (name === branch.name) return; await runOperation(`Renaming ${branch.name}`, async () => { applyStatus(await renameBranch(activeRepoPath, branch.name, name)); renameBranchTarget = null; await refreshRepositoryViews(activeRepoPath); - trackEvent("branch_renamed"); + trackEvent("branch_renamed", { remote: 0 }); }); } @@ -2787,6 +2870,8 @@ await runOperation("Previewing reflog entry", async () => { const result = await compareCommits(activeRepoPath, entry.hash, "HEAD"); comparison = result; + comparisonFromLabel = entry.selector; + comparisonToLabel = "HEAD"; selectedDiffPath = result.files[0]?.path ?? ""; diffHighlightQuery = ""; pendingRestoreFile = null; @@ -2834,7 +2919,7 @@ if (!activeRepoPath || isBusy) return; const key = await currentCredKey(); const stored = await loadStoredCredential(key); - const credential = stored && !isCredentialExpired(stored) ? stored : null; + const credential = stored ?? null; await runOperation(`Pushing tag ${tag.name}`, async () => { try { @@ -2851,6 +2936,138 @@ }); } + 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 ?? "")); + return loadStoredCredential(key); + } + + 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 () => { @@ -3047,12 +3264,7 @@ handleRemoteResult("push", key, fromStore); } - async function handleCredentialSubmit( - username: string, - password: string, - save: boolean, - expiresAt: string | null, - ) { + async function handleCredentialSubmit(username: string, password: string, save: boolean) { const key = credDialogKey; if (credDialogAction === "pull") await doActualPull(username, password, key, false); else if (credDialogAction === "push") await doActualPush(username, password, key, false); @@ -3072,7 +3284,7 @@ // Only persist once the operation actually succeeded (dialog has closed). if (!credDialogOpen && save && key) { try { - await credSave(key, username, password, expiresAt); + await credSave(key, username, password); } catch (error) { errorMessage = errorToMessage(error); } @@ -3087,15 +3299,13 @@ const key = await currentCredKey(); const stored = await loadStoredCredential(key); - if (stored && !isCredentialExpired(stored)) { + if (stored) { if (action === "pull") await doActualPull(stored.username, stored.password, key, true); else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true); else await doActualPush(stored.username, stored.password, key, true); return; } - // Expired entry → clean it up before prompting again. - if (stored && key) await credDelete(key).catch(() => {}); await openCredentialDialog(action, key); } @@ -3625,6 +3835,8 @@ diffFile.path === file.path || diffFile.old_path === file.old_path || diffFile.old_path === file.path, ); comparison = result; + comparisonFromLabel = ""; + comparisonToLabel = ""; selectedDiffPath = matchingFile?.path ?? result.files[0]?.path ?? file.path; pendingRestoreFile = { commit: target, file }; compareDialogOpen = true; @@ -3749,6 +3961,122 @@ } } + 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; + selectedExplorerKind = "file"; + expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]); + try { + await openRepositoryFile(activeRepoPath, file.path); + trackEvent("explorer_file_opened", { source: "command_palette", tracked: file.tracked ? 1 : 0 }); + } catch (error) { + errorMessage = errorToMessage(error); + } + } + + function selectCommitFromCommandPalette(target: GitCommit) { + selectedCommitHash = target.hash; + trackEvent("commit_selected", { source: "command_palette" }); + } + async function restoreSelectedFileFromCommit(target: GitCommit) { if (!activeRepoPath || !selectedExplorerPath) return; const kind = selectedExplorerKind === "folder" ? "folder" : "file"; @@ -3765,12 +4093,36 @@ // ── Compare ──────────────────────────────────────────────────────────────── + function compareRefForBranch(branch: GitBranchInfo): string { + return branch.remote ? `refs/remotes/${branch.name}` : `refs/heads/${branch.name}`; + } + + function compareLabelForTarget(target: string): string { + const branch = branches.find((candidate) => compareRefForBranch(candidate) === target); + if (branch) return branch.name; + return commits.find((commit) => commit.hash === target)?.short_hash ?? ""; + } + function openCompareSelect() { if (!hasRepository) return; + if (!compareFrom) { + const current = branches.find((branch) => branch.current); + if (current) compareFrom = compareRefForBranch(current); + } compareSelectOpen = true; trackEvent("compare_opened"); } + function compareBranchWithCurrent(branch: GitBranchInfo) { + if (!hasRepository) return; + const selected = compareRefForBranch(branch); + const current = branches.find((candidate) => candidate.current); + compareFrom = current ? compareRefForBranch(current) : ""; + compareTo = selected === compareFrom ? "" : selected; + compareSelectOpen = true; + trackEvent("compare_opened", { source: "branch_context", remote: branch.remote ? 1 : 0 }); + } + function openGlobalSearchDialog() { globalSearchOpen = true; trackEvent("global_search_opened"); @@ -3781,11 +4133,21 @@ trackEvent("help_opened"); } - async function compareSelectedCommits() { + function openAppSettings() { + appSettingsOpen = true; + } + + function openAiSettings() { + aiSettingsOpen = true; + } + + async function compareSelectedTargets() { if (!canCompare) return; - await runOperation("Comparing commits", async () => { + await runOperation("Comparing revisions", async () => { const result = await compareCommits(activeRepoPath, compareFrom, compareTo); comparison = result; + comparisonFromLabel = compareLabelForTarget(compareFrom); + comparisonToLabel = compareLabelForTarget(compareTo); selectedDiffPath = result.files[0]?.path ?? ""; diffHighlightQuery = ""; pendingRestoreFile = null; @@ -3802,6 +4164,8 @@ await runOperation(`Diffing ${selectedExplorerPath}`, async () => { const result = await diffFileAgainstWorkingTree(activeRepoPath, historyCommit.hash, selectedExplorerPath); comparison = result; + comparisonFromLabel = ""; + comparisonToLabel = ""; selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath; diffHighlightQuery = ""; pendingRestoreFile = null; @@ -3818,6 +4182,8 @@ await runOperation(`Diffing ${hit.file}`, async () => { const result = await diffFileAgainstWorkingTree(activeRepoPath, hit.commit_hash, hit.file); comparison = result; + comparisonFromLabel = ""; + comparisonToLabel = ""; selectedDiffPath = result.files[0]?.path ?? hit.file; diffHighlightQuery = lastSearchQuery; pendingRestoreFile = null; @@ -3903,21 +4269,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); }); } @@ -3970,11 +4355,20 @@ // ── Event handlers ───────────────────────────────────────────────────────── function handleWindowKeydown(event: KeyboardEvent) { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + if (!event.repeat) commandPaletteOpen = !commandPaletteOpen; + return; + } if ((event.ctrlKey || event.metaKey) && event.key === "/") { event.preventDefault(); openHelp(); return; } + if (event.key === "Escape" && commandPaletteOpen) { + commandPaletteOpen = false; + return; + } if (event.key === "Escape" && helpOpen) { helpOpen = false; return; @@ -4012,7 +4406,7 @@
{ appSettingsOpen = true; }} + onOpenSettings={openAppSettings} onOpenHelp={openHelp} language={appLanguage} /> @@ -4041,7 +4435,11 @@ {operation} ahead={status?.ahead ?? 0} behind={status?.behind ?? 0} + localOnly={currentBranchIsLocalOnly} language={appLanguage} + editorName={editorToolName} + terminalName={terminalToolName} + fileManagerName={fileManagerToolName} onFetch={fetchRepo} onPull={pullRepo} onPush={pushRepo} @@ -4050,7 +4448,9 @@ onCompare={openCompareSelect} onInteractiveRebase={openInteractiveRebase} onReflog={openReflog} - onOpenInExplorer={openActiveRepoInExplorer} + onOpenInEditor={openActiveRepoInEditor} + onOpenTerminal={openActiveRepoTerminal} + onOpenInExplorer={openActiveRepoFileManager} onFetchPrune={fetchPruneRepo} onForcePush={forcePushRepo} onSyncOptions={openSyncOptions} @@ -4342,7 +4742,7 @@
@@ -4363,10 +4763,11 @@ {hasRepository} {isBusy} onCheckout={checkout} + onCompareBranch={compareBranchWithCurrent} onMerge={merge} onRebase={rebaseOnto} onCreateBranch={createNewBranch} - onRenameBranch={renameLocalBranch} + onRenameBranch={openRenameBranchDialog} onDeleteBranch={deleteLocalBranch} onDeleteRemoteBranch={deleteTrackedRemoteBranch} onCreateTag={createNewTag} @@ -4440,11 +4841,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} @@ -4481,9 +4887,22 @@ {/if}
- {#if status?.upstream}{status.upstream}{/if} - {status?.ahead ?? 0} ahead - {status?.behind ?? 0} behind + {#if currentBranchIsLocalOnly} + + + {:else} + {#if status?.upstream}{status.upstream}{/if} + {status?.ahead ?? 0} ahead + {status?.behind ?? 0} behind + {/if}
@@ -4502,7 +4921,7 @@ onUnstage={unstageFile} onDiscard={discardFiles} onDiscardMany={discardChanges} - onPatch={openLinePatch} + onPatch={openPreferredFileDiff} onStageAll={stageAllFiles} onUnstageAll={unstageAllFiles} /> @@ -4544,7 +4963,7 @@ onGenerateCommitMessage={generateCommitMessageWithAi} onReviewStaged={reviewStagedWithAi} onSplitStaged={splitStagedWithAi} - onOpenAiSettings={() => { aiSettingsOpen = true; }} + onOpenAiSettings={openAiSettings} onToggleAmend={toggleAmendMode} onUndoLastCommit={undoLastCommitChange} /> @@ -4574,9 +4993,14 @@
+{#if commandPaletteOpen} + { commandPaletteOpen = false; }} + onCheckoutBranch={checkout} + onOpenFile={openFileFromCommandPalette} + onSelectCommit={selectCommitFromCommandPalette} + onFetch={fetchRepo} + onPull={pullRepo} + onPush={pushRepo} + onRefresh={refreshRepo} + onOpenSearch={openGlobalSearchDialog} + onOpenCompare={openCompareSelect} + onOpenReflog={openReflog} + onOpenInteractiveRebase={openInteractiveRebase} + onOpenWorktrees={openWorktreeDialog} + onOpenSyncSettings={openSyncOptions} + onOpenSettings={openAppSettings} + onOpenAiSettings={openAiSettings} + onOpenHelp={openHelp} + /> +{/if} + {#if updateToastOpen} refreshDetectedExternalTools(false)} onSave={saveAppSettings} onClose={() => { appSettingsOpen = false; }} /> @@ -4683,6 +5148,9 @@ onClose={closeLinePatch} onRefresh={refreshLinePatch} onApply={applyLinePatch} + language={appLanguage} + diffName={diffToolName} + onExternalDiff={openCurrentLinePatchExternally} /> {/await} {/if} @@ -4781,7 +5249,26 @@ /> {/if} - +{#if commitNoteTarget} + syncActiveCommitNotes(remote, "fetch")} + onPush={(remote) => syncActiveCommitNotes(remote, "push")} + onClose={closeCommitNoteDialog} + /> +{/if} + + {#if renameBranchTarget} + {#if interactiveRebaseOpen} {#await import("./lib/components/InteractiveRebaseDialog.svelte") then module} {/if} - + {#if compareSelectOpen} { compareFrom = val; }} onCompareToChange={(val) => { compareTo = val; }} - onCompare={compareSelectedCommits} + onCompare={compareSelectedTargets} onClose={() => { compareSelectOpen = false; }} /> {/if} @@ -4870,6 +5358,8 @@ {comparison} {selectedDiffPath} {isBusy} + fromLabel={comparisonFromLabel} + toLabel={comparisonToLabel} highlightQuery={diffHighlightQuery} restoreLabel={pendingRestoreFile ? "Restore file" : ""} onClose={closeCompareDialog} @@ -4939,10 +5429,13 @@ {preparedResolutions} {isBusy} {operation} + language={appLanguage} + mergeName={mergeToolName} onClose={() => { resolveDialogOpen = false; }} onSelectFile={selectConflictFile} onMarkResolved={handleMarkResolved} onApply={applyPreparedResolutions} + onExternalMerge={openConflictInExternalMerge} /> {/await} {/if} diff --git a/src/app.css b/src/app.css index dfbaca4..697edef 100644 --- a/src/app.css +++ b/src/app.css @@ -736,6 +736,29 @@ } .repo-action-count.behind { color: #7aacff; } .repo-action-count.ahead { color: #e0a040; } + .repo-action.sync-primary.publish-local { + color: #f0bd6b; + background: linear-gradient(180deg, rgba(224,160,64,.1), rgba(224,160,64,.045)); + } + .repo-action.sync-primary.publish-local:hover:not(:disabled) { + color: #ffd48c; + background: rgba(224,160,64,.14); + } + .repo-action-local-marker { + display: inline-flex; + align-items: center; + gap: 3px; + height: 16px; + padding: 0 4px; + border: 1px dashed rgba(240,189,107,.5); + border-radius: 4px; + color: #f0bd6b; + background: rgba(224,160,64,.08); + font-family: var(--font-mono); + font-size: 7.5px; + font-weight: 900; + letter-spacing: .04em; + } .repo-toolbar-divider { width: 1px; height: 30px; @@ -1506,7 +1529,7 @@ .workspace { display: grid; - grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 8px minmax(0, 1fr) 8px minmax(560px, var(--history-aside-width, 620px)); + grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 8px minmax(0, 1fr) 8px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 620px)); flex: 1 1 0; min-width: 0; min-height: 0; @@ -1670,6 +1693,27 @@ .sync-stats strong:first-of-type { color: #e0a040; background: rgba(224,160,64,0.13); } .sync-stats strong:last-of-type { color: #7aacff; background: rgba(122,172,255,0.13); } .sync-stats span { color: var(--color-ink-dim); background: rgba(94,110,156,0.13); } + .sync-stats .sync-local-only { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 7px; + border: 1px dashed rgba(224,160,64,.38); + color: #f0bd6b; + background: rgba(224,160,64,.09); + } + .sync-stats .sync-local-only strong { + padding: 0; + color: #f0bd6b; + background: transparent; + font-size: 10.5px; + font-weight: 850; + } + .sync-stats .sync-local-only small { + color: var(--color-ink-faint); + font-size: 9px; + font-weight: 650; + } .top-section { display: grid; @@ -2408,6 +2452,7 @@ .commit-row { display: grid; min-width: 0; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); transition: border-color 120ms; } .commit-row + .commit-row { margin-top: 5px; } .commit-row:hover { border-color: var(--color-border); } + .commit-row.selected { border-color: color-mix(in srgb, var(--color-primary) 58%, var(--color-border)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent) inset; } .commit-row.compact { padding: 8px; } .commit-line { display: flex; align-items: flex-start; min-width: 0; gap: 8px; } @@ -2491,38 +2536,6 @@ font-size: 10.5px; font-weight: 700; } - .commit-local-branches { - display: inline-flex; - flex: 0 1 auto; - flex-wrap: wrap; - gap: 3px; - min-width: 0; - max-width: min(100%, 260px); - } - .commit-branch-chip { - display: inline-flex; - align-items: center; - gap: 3px; - min-width: 0; - max-width: 100%; - height: 17px; - padding: 0 6px 0 5px; - overflow: hidden; - border: 1px solid rgba(91,209,138,0.22); - border-radius: 999px; - color: #a8eeba; - background: rgba(34,68,48,0.42); - font-family: var(--font-mono); - font-size: 9.5px; - font-weight: 750; - line-height: 1; - text-overflow: ellipsis; - white-space: nowrap; - } - .commit-branch-chip svg { - flex: 0 0 auto; - color: #76d995; - } .commit-author { flex: 1 1 80px; overflow: hidden; @@ -2530,30 +2543,270 @@ white-space: nowrap; } - .ref-list { display: flex; flex-wrap: wrap; gap: 3px; } - .ref-list .ref-chip { + .commit-ref-area { + position: relative; + z-index: 3; + display: grid; + gap: 5px; + min-width: 0; + } + .commit-ref-strip { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + min-height: 20px; + } + .branch-ref-cluster { + display: inline-flex; + flex: 0 1 auto; + align-items: center; + min-width: 0; max-width: 100%; + margin-left: -10px; + } + .compact-ref-chip { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; + max-width: min(58%, 230px); + height: 19px; + padding: 0 6px 0 5px; overflow: hidden; - padding: 1px 6px; - border-radius: 999px; - color: var(--color-accent); - background: rgba(106,154,255,0.09); - border: 1px solid rgba(106,154,255,0.16); + border: 1px solid rgba(91,209,138,0.2); + border-radius: 5px; + color: #a8eeba; + background: rgba(34,68,48,0.3); + font-family: var(--font-mono); font-size: 9.5px; + font-weight: 750; + line-height: 1; + white-space: nowrap; + } + .compact-ref-chip.branch { + flex: 0 1 auto; + max-width: 22px; + height: 20px; + margin-left: 0; + padding: 0 5px; + border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 34%, transparent); + border-left-width: 2px; + border-radius: 0 5px 5px 0 !important; + color: #dce9ff; + background: + linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #69a7ff) 14%, rgba(18,24,36,.96)), rgba(18,24,36,.82)); + box-shadow: inset 2px 0 0 color-mix(in srgb, var(--ref-lane-color, #69a7ff) 72%, transparent); + transition: + max-width 190ms cubic-bezier(.2,.75,.25,1), + padding-right 190ms cubic-bezier(.2,.75,.25,1), + border-color 140ms ease, + background 140ms ease, + box-shadow 140ms ease; + } + .branch-ref-cluster.local-only .compact-ref-chip.branch { + border-radius: 0 !important; + } + .compact-ref-local-marker { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 3px; + height: 20px; + margin-left: -1px; + padding: 0 5px 0 4px; + border: 1px dashed rgba(240,189,107,.58); + border-left-style: solid; + border-radius: 0 5px 5px 0; + color: #f0bd6b; + background: linear-gradient(90deg, rgba(224,160,64,.13), rgba(224,160,64,.06)); + font-family: var(--font-mono); + font-size: 7.5px; + font-weight: 900; + letter-spacing: .05em; + line-height: 1; + box-shadow: inset 1px 0 0 rgba(240,189,107,.2); + } + .compact-ref-branch-icon { + flex: 0 0 auto; + color: var(--ref-lane-color, #69a7ff); + filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 28%, transparent)); + transition: transform 190ms cubic-bezier(.2,.75,.25,1); + } + .compact-ref-chip > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + .commit-ref-detail-item > i { + flex: 0 0 auto; + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--ref-lane-color, #69a7ff); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 18%, transparent); + } + .compact-ref-chip.current { + border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 62%, transparent); + color: #eaf2ff; + background: + linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #69a7ff) 25%, rgba(18,24,36,.96)), rgba(18,24,36,.9)); + } + .compact-ref-chip.remote { + border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 38%, transparent); + color: #bcd2ff; + background: + linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #69a7ff) 14%, rgba(20,27,42,.94)), rgba(20,27,42,.84)); + } + .compact-ref-chip.branch > span, + .compact-ref-chip.branch > small { + opacity: 0; + transition: opacity 80ms ease; + } + .graph-row:hover .compact-ref-chip.branch, + .graph-row.selected .compact-ref-chip.branch, + .graph-row:focus-within .compact-ref-chip.branch { + flex: 1 1 auto; + max-width: 100%; + padding-right: 8px; + border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 64%, transparent); + background: + linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #69a7ff) 24%, rgba(18,24,36,.98)), rgba(18,24,36,.9)); + box-shadow: + inset 2px 0 0 var(--ref-lane-color, #69a7ff), + 0 3px 12px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 12%, rgba(0,0,0,.24)); + } + .graph-row:hover .commit-ref-strip, + .graph-row.selected .commit-ref-strip, + .graph-row:focus-within .commit-ref-strip { + flex-wrap: wrap; + row-gap: 4px; + } + .graph-row:hover .branch-ref-cluster, + .graph-row.selected .branch-ref-cluster, + .graph-row:focus-within .branch-ref-cluster { + flex: 0 0 auto; + max-width: min(100%, 320px); + } + .graph-row:hover .compact-ref-branch-icon, + .graph-row.selected .compact-ref-branch-icon, + .graph-row:focus-within .compact-ref-branch-icon { + transform: translateX(1px); + } + .graph-row:hover .compact-ref-chip.branch > span, + .graph-row:hover .compact-ref-chip.branch > small, + .graph-row.selected .compact-ref-chip.branch > span, + .graph-row.selected .compact-ref-chip.branch > small, + .graph-row:focus-within .compact-ref-chip.branch > span, + .graph-row:focus-within .compact-ref-chip.branch > small { + opacity: 1; + transition-delay: 55ms; + transition-duration: 120ms; + } + .compact-ref-chip.tag { + flex: 0 1 auto; + max-width: min(32%, 150px); + padding-inline: 4px; + border-color: transparent; + color: #dbc078; + background: transparent; + } + .compact-ref-chip.tag svg { flex: 0 0 auto; } + .compact-ref-chip small { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 2px; + padding-left: 4px; + border-left: 1px solid rgba(255,255,255,.12); + color: #f0bd6b; + font-family: var(--font-sans); + font-size: 8.5px; + font-weight: 850; + } + .compact-ref-chip small.up-to-date { color: #7de39b; } + .compact-ref-overflow { + flex: 0 0 auto; + min-width: 29px; + min-height: 19px; + height: 19px; + padding: 0 5px; + border-color: transparent; + border-radius: 4px; + color: var(--color-ink-dim); + background: rgba(255,255,255,0.025); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 800; + } + .compact-ref-overflow:hover:not(:disabled), + .compact-ref-overflow[aria-expanded="true"] { + border-color: rgba(101,162,255,.34); + color: var(--color-ink); + background: rgba(101,162,255,.1); + } + .commit-ref-details { + display: grid; + gap: 7px; + padding: 8px; + border: 1px solid rgba(94,110,156,.2); + border-radius: 7px; + background: rgba(10,14,21,.68); + box-shadow: inset 2px 0 0 color-mix(in srgb, var(--ref-lane-color, #69a7ff) 65%, transparent); + } + .commit-ref-details > strong { + color: var(--color-ink-muted); + font-size: 10px; + font-weight: 800; + } + .commit-ref-details section { + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + align-items: start; + gap: 7px; + } + .commit-ref-details section > span { + padding-top: 3px; + color: var(--color-ink-faint); + font-size: 8px; + font-weight: 850; + letter-spacing: .08em; + text-transform: uppercase; + } + .commit-ref-details section > div { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; } + .commit-ref-detail-item { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + min-height: 20px; + padding: 2px 6px; + overflow: hidden; + border: 1px solid rgba(94,110,156,.18); + border-radius: 5px; + color: var(--color-ink-dim); + background: rgba(255,255,255,.025); + font-family: var(--font-mono); + font-size: 9px; font-weight: 700; - line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; } - .ref-list .ref-chip.head { - color: #061021; - border-color: rgba(65,209,255,0.48); - background: linear-gradient(135deg, #41d1ff, #7c6cff); - box-shadow: 0 0 14px rgba(65,209,255,0.2); + .commit-ref-detail-item.local { color: #a8eeba; border-color: rgba(91,209,138,.18); } + .commit-ref-detail-item.remote { color: #bcd2ff; border-color: rgba(122,172,255,.2); } + .commit-ref-detail-item.tag { color: #dbc078; border-color: rgba(224,180,92,.2); } + .commit-ref-detail-item small { + color: var(--color-ink-faint); + font-family: var(--font-sans); + font-size: 8px; + font-weight: 800; + } + .commit-ref-detail-item small.local-only { + display: inline-flex; + align-items: center; + gap: 3px; + color: #d99532; } - .ref-list .ref-chip.branch { color: #7ddf9c; background: rgba(78,202,118,0.08); border-color: rgba(78,202,118,0.18); } - .ref-list .ref-chip.remote { color: #aeb6ff; background: rgba(124,108,255,0.08); border-color: rgba(124,108,255,0.18); } - .ref-list .ref-chip.tag { color: #dbc078; background: rgba(224,180,92,0.08); border-color: rgba(224,180,92,0.2); } .commit-files { display: grid; @@ -2619,6 +2872,16 @@ background: rgba(65,209,255,0.08); color: var(--color-ink); } + + .branch-filter-group-label { + padding: 9px 9px 3px; + color: var(--color-ink-faint); + font-size: 9px; + font-weight: 850; + letter-spacing: .1em; + text-transform: uppercase; + } + .commit-note-button:not(:disabled) { color: color-mix(in srgb, var(--color-accent) 72%, var(--color-ink-dim)); } /* --- Git graph --- */ .graph-list { @@ -2692,6 +2955,10 @@ stroke-dasharray: 4 4; filter: drop-shadow(0 0 3px rgba(122,172,255,0.2)); } + .graph-svg path.graph-ref-connector { + opacity: 0.35; + stroke-linecap: round; + } .graph-dot { position: absolute; z-index: 2; @@ -2725,64 +2992,13 @@ border-color: var(--dot-color, #5a8cf8); box-shadow: 0 0 0 1px rgba(255,255,255,0.1); } - .graph-hover-branches { - position: absolute; - z-index: 5; - top: 50%; - display: flex; - flex-wrap: wrap; - gap: 3px; - max-width: 190px; - opacity: 0; - pointer-events: none; - transform: translateY(-50%) translateX(-4px); - transition: opacity 120ms ease, transform 120ms ease; - } - .graph-gutter:hover .graph-hover-branches { - opacity: 1; - transform: translateY(-50%) translateX(0); - } - .graph-hover-branches span { - display: inline-flex; - align-items: center; - gap: 3px; - max-width: 180px; - height: 18px; - padding: 0 6px 0 5px; - overflow: hidden; - border: 1px solid rgba(91,209,138,0.28); - border-radius: 999px; - color: #b2f0c2; - background: rgba(14,36,27,0.94); - box-shadow: 0 8px 22px rgba(0,0,0,0.24); - font-family: var(--font-mono); - font-size: 9.5px; - font-weight: 750; - line-height: 1; - text-overflow: ellipsis; - white-space: nowrap; - } - .graph-hover-branches span.remote, .branch-filter-option.remote { border-color: rgba(122,172,255,0.32); color: #bcd2ff; background: rgba(31,43,72,0.88); } - .graph-hover-branches span.ahead { - border-color: rgba(224,160,64,0.42); - color: #ffd99a; - background: rgba(58,42,20,0.94); - } - .graph-hover-branches span.behind { - border-color: rgba(122,172,255,0.46); - color: #c7dbff; - background: rgba(25,39,70,0.94); - } - .graph-hover-branches svg { - flex: 0 0 auto; - color: #76d995; - } .commit-body { + position: relative; display: grid; gap: 5px; min-width: 0; @@ -2790,6 +3006,39 @@ background: rgba(18,24,36,0.5); transition: background 120ms ease; } + .commit-body.has-branch-ref::before { + content: ""; + position: absolute; + z-index: 2; + top: 24px; + bottom: calc(50% + 16px); + left: -16px; + width: 16px; + min-height: 14px; + border-top: 1.5px solid var(--ref-lane-color, #69a7ff); + border-left: 1.5px solid var(--ref-lane-color, #69a7ff); + border-top-left-radius: 14px; + opacity: .35; + pointer-events: none; + filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 12%, transparent)); + transition: opacity 140ms ease, filter 140ms ease; + } + .commit-body.has-branch-ref::after { + content: ""; + position: absolute; + z-index: 2; + top: calc(50% - 16px); + left: -32px; + width: 16px; + height: 16px; + border-right: 1.5px solid var(--ref-lane-color, #69a7ff); + border-bottom: 1.5px solid var(--ref-lane-color, #69a7ff); + border-bottom-right-radius: 14px; + opacity: .35; + pointer-events: none; + filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 12%, transparent)); + transition: opacity 140ms ease, filter 140ms ease; + } .graph-row + .graph-row .commit-body { border-top: 1px solid rgba(226,232,240,0.075); } .graph-row.graph-ahead-row .commit-body { box-shadow: inset 3px 0 0 rgba(224,160,64,0.72); @@ -2799,6 +3048,18 @@ } .graph-row:hover .commit-body { background: rgba(30,39,57,0.72); } .graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; } + .graph-row:hover .graph-svg path.graph-ref-connector, + .graph-row.selected .graph-svg path.graph-ref-connector, + .graph-row:focus-within .graph-svg path.graph-ref-connector { opacity: .95; stroke-width: 1.8; } + .graph-row:hover .commit-body.has-branch-ref::before, + .graph-row:hover .commit-body.has-branch-ref::after, + .graph-row.selected .commit-body.has-branch-ref::before, + .graph-row.selected .commit-body.has-branch-ref::after, + .graph-row:focus-within .commit-body.has-branch-ref::before, + .graph-row:focus-within .commit-body.has-branch-ref::after { + opacity: .95; + filter: drop-shadow(0 0 4px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 24%, transparent)); + } .graph-row:hover .graph-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; } .graph-row:hover .graph-dot { transform: translate(-50%, -50%) scale(1.12); } .graph-row:hover .graph-dot.hidden-branch { transform: translate(-50%, -50%) scale(1); } @@ -2811,6 +3072,37 @@ rgba(20,27,40,0.62); } + @media (prefers-reduced-motion: reduce) { + .compact-ref-chip.branch, + .compact-ref-chip.branch > span, + .compact-ref-chip.branch > small, + .compact-ref-branch-icon, + .commit-body.has-branch-ref::before, + .commit-body.has-branch-ref::after { + transition: none; + } + } + + @media (hover: none) { + .commit-ref-strip { + flex-wrap: wrap; + row-gap: 4px; + } + .branch-ref-cluster { + flex: 0 0 auto; + max-width: min(100%, 320px); + } + .compact-ref-chip.branch { + flex: 1 1 auto; + max-width: 100%; + padding-right: 8px; + } + .compact-ref-chip.branch > span, + .compact-ref-chip.branch > small { + opacity: 1; + } + } + /* --- Compare panel --- */ .compare-panel { @@ -2834,6 +3126,16 @@ .compare-field { display: grid; gap: 4px; min-width: 0; } .compare-field span { color: var(--color-ink-faint); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; } .compare-arrow { margin-bottom: 6px; color: var(--color-ink-faint); } + .compare-target-help { + margin: 12px; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle)); + border-radius: 8px; + color: var(--color-ink-dim); + background: color-mix(in srgb, var(--color-accent) 5%, var(--color-surface-raised)); + font-size: 11.5px; + line-height: 1.5; + } .compare-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; padding: 10px 12px; } .compare-range { display: flex; align-items: center; gap: 7px; color: var(--color-ink-muted); } @@ -3187,6 +3489,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)); @@ -3493,6 +3798,34 @@ text-transform: uppercase; letter-spacing: 0.05em; } + .new-branch-field > div { display: flex; min-width: 0; } + .new-branch-field > div > input { width: 100%; min-width: 0; } + .remote-branch-name-field > strong { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + height: 34px; + padding: 0 0 0 11px; + border: 1px solid var(--color-border-input); + border-right: 0; + border-radius: var(--ui-radius-sm) 0 0 var(--ui-radius-sm); + color: var(--color-ink-faint); + background: var(--color-surface-dim); + font-family: var(--font-mono); + font-size: 12px; + } + .remote-branch-name-field > input { border-radius: 0 var(--ui-radius-sm) var(--ui-radius-sm) 0; } + .rename-remote-note { + margin: -2px 0 0; + padding: 10px 12px; + border: 1px solid var(--color-border-subtle); + border-radius: 8px; + color: var(--color-ink-dim); + background: var(--color-surface-dim); + font-size: 11.5px; + line-height: 1.5; + } + .rename-remote-note strong { color: var(--color-ink); font-family: var(--font-mono); font-weight: 700; } .new-branch-actions { display: flex; justify-content: flex-end; @@ -3502,14 +3835,26 @@ .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; } - .dialog-range { display: flex; align-items: center; gap: 8px; margin: 2px 0 0; color: var(--color-accent); font-size: 15px; } + .dialog-range { display: flex; align-items: center; gap: 8px; max-width: min(68vw, 780px); margin: 2px 0 0; color: var(--color-accent); font-size: 15px; } + .dialog-range .hash { min-width: 0; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dialog-title { margin: 2px 0 0; color: var(--color-ink); font-size: 15px; font-weight: 600; } .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); } @@ -3649,6 +3994,11 @@ } .split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); } .split-col-hash { + min-width: 0; + max-width: min(42%, 260px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; padding: 1px 6px; border-radius: 5px; background: rgba(90,140,248,0.12); @@ -5519,7 +5869,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s .repo-toolbar-divider { height: 34px; margin-inline: 6px; } .workspace { - grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 7px minmax(360px, 1fr) 7px minmax(560px, var(--history-aside-width, 620px)); + grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 7px minmax(360px, 1fr) 7px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 620px)); flex: 1 1 0; padding: 0; background: var(--color-border-subtle); @@ -5640,7 +5990,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s contain-intrinsic-block-size: 108px; } .commit-avatar { border-radius: 50%; } -.commit-kind, .commit-branch-chip, .ref-chip { border-radius: 4px !important; } +.commit-kind, .compact-ref-chip { border-radius: 4px !important; } .workspace-statusbar { display: flex; align-items: center; @@ -5658,6 +6008,17 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s .workspace-health.clean > span { background: #2da44e; } .workspace-statusbar .ahead { color: #d9891b; } .workspace-statusbar .behind { color: var(--color-primary); } +.workspace-statusbar .workspace-local-only { + padding: 2px 6px; + border: 1px dashed rgba(224,160,64,.42); + border-radius: 4px; + color: #f0bd6b; + background: rgba(224,160,64,.08); + font-family: var(--font-mono); + font-size: 9px; + font-weight: 850; + letter-spacing: .02em; +} .workspace-auto i { width: 7px; height: 7px; border-radius: 50%; background: var(--color-ink-faint); } .workspace-auto.active i { background: #2da44e; } .app-version { @@ -5886,46 +6247,80 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s color: #315fd6; } -:root[data-theme="light"] .commit-branch-chip, -:root[data-theme="light"] .graph-hover-branches span { - border-color: rgba(31,128,76,0.22); - color: #146b3b; - background: rgba(224,246,233,0.92); - box-shadow: 0 8px 22px rgba(28,44,74,0.12); +:root[data-theme="light"] .compact-ref-chip.branch { + border-color: color-mix(in srgb, var(--ref-lane-color, #315fd6) 48%, rgba(49,95,214,.16)); + color: #18345f; + background: + linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #315fd6) 14%, #f7faff), #f7faff); +} + +:root[data-theme="light"] .compact-ref-local-marker { + border-color: rgba(154,82,0,.46); + color: #8b5207; + background: linear-gradient(90deg, rgba(217,137,27,.14), rgba(217,137,27,.06)); + box-shadow: inset 1px 0 0 rgba(154,82,0,.14); +} + +:root[data-theme="light"] .compact-ref-chip.current { + border-color: color-mix(in srgb, var(--ref-lane-color, #315fd6) 58%, rgba(49,95,214,.2)); + color: #18345f; + background: + linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #315fd6) 20%, #f7faff), #f7faff); } -:root[data-theme="light"] .graph-hover-branches span.remote, :root[data-theme="light"] .branch-filter-option.remote { border-color: rgba(49,95,214,0.22); color: #315fd6; background: rgba(231,237,255,0.92); } -:root[data-theme="light"] .graph-hover-branches span.ahead { - border-color: rgba(150,98,15,0.28); - color: #96620f; - background: rgba(255,244,224,0.95); -} - -:root[data-theme="light"] .graph-hover-branches span.behind { - border-color: rgba(49,95,214,0.26); +:root[data-theme="light"] .compact-ref-chip.remote { + border-color: color-mix(in srgb, var(--ref-lane-color, #315fd6) 42%, rgba(49,95,214,.16)); color: #315fd6; - background: rgba(231,237,255,0.95); + background: + linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #315fd6) 12%, #f7faff), #f7faff); } -:root[data-theme="light"] .ref-list .ref-chip.head { - color: #ffffff; - background: linear-gradient(135deg, #0f8fb5, #315fd6); +:root[data-theme="light"] .graph-row:hover .compact-ref-chip.branch, +:root[data-theme="light"] .graph-row.selected .compact-ref-chip.branch, +:root[data-theme="light"] .graph-row:focus-within .compact-ref-chip.branch { + border-color: color-mix(in srgb, var(--ref-lane-color, #315fd6) 62%, rgba(49,95,214,.2)); + background: + linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #315fd6) 22%, #f7faff), #ffffff); + box-shadow: + inset 2px 0 0 var(--ref-lane-color, #315fd6), + 0 3px 12px color-mix(in srgb, var(--ref-lane-color, #315fd6) 10%, rgba(34,49,78,.16)); } -:root[data-theme="light"] .ref-list .ref-chip.branch { - color: #16723b; - background: rgba(78,202,118,0.12); +:root[data-theme="light"] .compact-ref-chip.tag, +:root[data-theme="light"] .commit-ref-detail-item.tag { + color: #8b5d0e; } -:root[data-theme="light"] .ref-list .ref-chip.remote { - color: #315fd6; - background: rgba(49,95,214,0.09); +:root[data-theme="light"] .compact-ref-chip.tag { + background: transparent; +} + +:root[data-theme="light"] .compact-ref-overflow { + color: #60708a; + border-color: transparent; + background: rgba(49,95,214,.035); +} + +:root[data-theme="light"] .graph-branch-dialog-button { + color: #475873; + border-color: rgba(49,95,214,.16); + background: rgba(244,247,252,.9); +} + +:root[data-theme="light"] .commit-ref-details { + border-color: rgba(49,95,214,.16); + background: rgba(247,249,253,.94); +} + +:root[data-theme="light"] .commit-ref-detail-item { + color: #475873; + background: #ffffff; } :root[data-theme="light"] .commit-files, @@ -6133,16 +6528,16 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s /* --- Responsive breakpoints --- */ @media (min-width: 1800px) { - .workspace { grid-template-columns: minmax(220px, var(--left-sidebar-width, 320px)) 8px minmax(0, 1fr) 8px minmax(560px, var(--history-aside-width, 680px)); } + .workspace { grid-template-columns: minmax(220px, var(--left-sidebar-width, 320px)) 8px minmax(0, 1fr) 8px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 680px)); } } @media (max-width: 1400px) { - .workspace { grid-template-columns: minmax(200px, var(--left-sidebar-width, 265px)) 8px minmax(0, 1fr) 8px minmax(540px, var(--history-aside-width, 580px)); } + .workspace { grid-template-columns: minmax(200px, var(--left-sidebar-width, 265px)) 8px minmax(0, 1fr) 8px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 580px)); } } /* Stack CommitPanel below StatusPanel; history panels stay side by side */ @media (max-width: 1100px) { - .workspace { grid-template-columns: minmax(185px, var(--left-sidebar-width, 220px)) 8px minmax(0, 1fr) 8px minmax(500px, var(--history-aside-width, 560px)); } + .workspace { grid-template-columns: minmax(185px, var(--left-sidebar-width, 220px)) 8px minmax(0, 1fr) 8px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 560px)); } .top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); } } @@ -6435,6 +6830,19 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s color: #0755c8; font-weight: 800; } +:root[data-theme="light"] .repo-action.sync-primary.publish-local, +:root[data-theme="light"] .repo-action-local-marker, +:root[data-theme="light"] .workspace-statusbar .workspace-local-only { + color: #8b5207; + border-color: rgba(154,82,0,.4); + background: rgba(217,137,27,.09); +} +:root[data-theme="light"] .sync-stats .sync-local-only { + border-color: rgba(154,82,0,.36); + color: #8b5207; + background: rgba(217,137,27,.09); +} +:root[data-theme="light"] .sync-stats .sync-local-only strong { color: #8b5207; } /* AI pre-commit review --------------------------------------------------- */ .commit-review-button { @@ -6588,6 +6996,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..2971b3d 100644 --- a/src/lib/RepoToolbar.svelte +++ b/src/lib/RepoToolbar.svelte @@ -1,7 +1,9 @@ diff --git a/src/lib/components/LinePatchDialog.svelte b/src/lib/components/LinePatchDialog.svelte index c533f8c..d6633b7 100644 --- a/src/lib/components/LinePatchDialog.svelte +++ b/src/lib/components/LinePatchDialog.svelte @@ -1,5 +1,5 @@ @@ -34,10 +49,10 @@ class="dialog-backdrop" role="presentation" > -