Compare commits

..
31 Commits
Author SHA1 Message Date
Christoph 0af57f1818 Merge pull request 'Opt/light mode' (#23) from opt/light_mode into main
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 18m49s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 27m6s
Reviewed-on: #23
2026-07-10 16:22:06 +00:00
Christoph Brandau 8e6811b786 style(ui): refresh status panel color palette
The StatusPanel component has received a visual update to modernize its appearance. This change adjusts the primary color scheme across multiple elements, shifting from purples and magentas towards cooler blue and cyan tones. The goal is to improve aesthetic consistency and give the component a more cohesive look within the application's overall design system.

- Updated accent colors and gradients throughout the panel.
- Refined borders and shadows for a cleaner visual depth.
- Adjusted trace and overlay element colors for better harmony.
2026-07-10 17:33:52 +02:00
Christoph Brandau de8832a919 style(ui): update global theme colors and palette
This commit implements a comprehensive visual refresh by updating the core color scheme across the application. A new, cohesive dark mode aesthetic has been applied, shifting primary accents from purple/orange tones to cooler blue/cyan gradients. This ensures better consistency and modernizes the look of various components, including buttons and loading indicators.

- Updated global CSS variables for consistent theming
- Refreshed component styles with the new color palette
- Adjusted repository loading overlay visuals
2026-07-10 13:47:50 +02:00
Christoph Brandau 25b98d7c4d feat(ui): Implement theme persistence and system preference support
Adds comprehensive theme management across the application, allowing users to save their preferred color scheme (light/dark) or automatically follow the operating system's settings. This required updating state management in App.svelte to handle theme loading and applying changes dynamically via CSS variables.

The styling layer was significantly updated with new CSS variables and rules for both dark and light modes, ensuring visual consistency across all components like dialogs, buttons, and sidebars.

- Added logic to detect system color scheme preference
- Implemented full support for persistent light/dark mode switching
- Overhauled global CSS variables for improved theme adaptability
2026-07-10 12:24:14 +02:00
Christoph Brandau 7ef1a2dac8 refactor(status): Support batch and all changes discard functionality
The file discarding mechanism has been significantly refactored to improve handling of multi-file operations. Instead of processing discards on a per-file basis, the system now supports batch actions for selected files or reverting all tracked modifications simultaneously. This update centralizes complex discard logic into dedicated functions across the component suite.

- Updated state management and types to handle arrays of file changes.
- Added UI elements and handlers for discarding all staged/unstaged changes.
- Enhanced the confirmation dialog to display multiple discarded targets in a list view.
2026-07-10 11:36:15 +02:00
Christoph Brandau 44a5b776d7 fix(refresh): improve auto-refresh robustness against repo switches
The automatic data refreshing mechanism has been updated to handle asynchronous state changes more reliably. It now incorporates checks to determine if the active repository path has changed during background operations. This prevents stale or incorrect data from being displayed if a user navigates away from or switches repositories while a refresh cycle is in progress.

- Added path validation checks after status fetching and bundle opening
- Ensured all subsequent refresh calls use the validated current repository path
2026-07-10 11:15:47 +02:00
Christoph 55b4c9fc50 Update version to 2026.7.17 2026-07-09 20:58:50 +02:00
Christoph Brandau 35d9fc969c feat(status-panel): enhance UI and add new functionality
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 18m10s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 30m34s
This update introduces several improvements to the StatusPanel component, enhancing the user interface and experience. A new function has been added to simplify file path handling, and the overlay now features a more dynamic design with animated elements for better visual feedback during operations.

- Added baseName function for improved path handling
- Enhanced status overlay with animations and new design elements
- Updated file staging and unstaging messages for clarity
2026-07-09 19:51:51 +02:00
Christoph Brandau 710c6d4a53 feat(git): refactor Git commands to use async/await pattern
This update modifies several Git command functions to utilize the async/await
pattern, enhancing performance and responsiveness during operations. The changes
allow for non-blocking execution of commands, improving the overall user experience.

- Converted multiple Git command functions to async
- Added error handling for async operations
- Updated related tests to support async execution
2026-07-09 19:17:02 +02:00
Christoph Brandau 0877d5a568 feat(status-panel): add loading overlay for ongoing operations
This update introduces a loading overlay in the StatusPanel component
to indicate ongoing operations. The overlay displays a spinner and
the current operation status, enhancing user experience during
long-running tasks.

- Added a loading overlay with a spinner for busy states
- Included operation status text to inform users of current actions
- Improved visual feedback for ongoing processes in the UI
2026-07-09 15:20:52 +02:00
Christoph Brandau f79f3dc1ec feat(recent-repos): add functionality to remove repositories from recent
This update introduces a new function to remove repositories from the
recent list. The user can now manage their recent repositories more
effectively, enhancing the overall user experience.

- Implemented removal of repositories from the recent list
- Updated UI to reflect changes in the recent repositories management
2026-07-09 15:14:29 +02:00
Christoph cf2773b6f4 Update version to 2026.7.16 2026-07-09 09:54:57 +02:00
Christoph Brandau 0ba7169efd feat(tauri): add single instance plugin and improve code formatting
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 19m14s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 26m5s
This update introduces the tauri-plugin-single-instance to ensure that
only one instance of the application can run at a time. Additionally,
the code has been reformatted for better readability, with consistent
line breaks and spacing.

- Added single instance plugin to prevent multiple app instances
- Improved code formatting for better readability and maintenance
- Updated dependencies in Cargo.toml for new plugin integration
2026-07-09 09:49:27 +02:00
Christoph Brandau c087171735 Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-07-09 09:21:53 +02:00
Christoph Brandau 2d3c30b553 feat(App): enhance repository fetching with reference and commit updates
This update improves the repository fetching process by ensuring that
the references and commit graph are refreshed after fetching remote
repositories. This change enhances the accuracy of the displayed
repository state and ensures that users have the most up-to-date
information.

- Added a new function to refresh refs and commit history
- Integrated the refresh function into the fetching workflow
- Improved status application logic for active repositories
2026-07-09 09:21:33 +02:00
Christoph 8409556158 Update README.md 2026-07-09 07:20:42 +00:00
Christoph db60f57544 Update README.md 2026-07-09 07:19:59 +00:00
Christoph cb5865094f Update README.md 2026-07-09 07:19:15 +00:00
Christoph c74db60daf Update README.md 2026-07-09 07:18:58 +00:00
Christoph 812027abcf Update README.md 2026-07-09 07:07:43 +00:00
Christoph Brandau c8d5553214 refactor(App): streamline repo search and row mapping functions
This update enhances the repo search functionality by passing the search term directly to the filtering functions. It also refactors the row mapping functions to accept parameters for better flexibility and clarity, improving the overall readability and maintainability of the code.

- Simplified search and mapping logic for repositories
- Improved parameter handling in helper functions
2026-07-09 09:01:42 +02:00
Christoph 8d9dedf93c Update version to 0.200.5 2026-07-09 08:41:49 +02:00
Christoph Brandau 2c9b8adb9e Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 18m57s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 28m30s
2026-07-09 08:38:44 +02:00
Christoph Brandau e6d9e60b8f feat(status-panel): enhance file staging and un-staging functionality
This update improves the file staging and un-staging process by allowing
multiple files to be selected and acted upon simultaneously. The user
interface has been enhanced to provide clear feedback on the number of
selected files and their respective statuses.

- Added support for staging and un-staging multiple files at once
- Introduced visual indicators for selected files in the UI
- Improved event handling for file selection and actions
2026-07-09 08:38:33 +02:00
Christoph Brandau 8627012d93 feat(ui): enhance panel resizing and state persistence
This update introduces functionality for resizing various panels in the UI, including the left sidebar, branch panel, stash panel, and file history. It also adds state persistence for panel sizes and collapsed states using local storage, improving user experience by maintaining preferences across sessions.

- Implemented resizing functionality for multiple UI panels
- Added local storage support for panel dimensions and collapsed states
- Enhanced accessibility features for panel controls
2026-07-09 08:33:09 +02:00
Christoph a21fa9e54c Update version to 0.200.4 2026-07-08 22:25:09 +02:00
Christoph Brandau adfb5f4158 Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 19m49s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 26m26s
2026-07-08 22:07:02 +02:00
Christoph Brandau 6f3f438f15 feat(nsis): add quiet uninstall string to registry
This update introduces a quiet uninstall string to the registry for
the application, allowing users to uninstall the software silently
without user interaction. This enhancement improves the user experience
for automated uninstall scenarios.

- Added QuietUninstallString for silent uninstallation
- Enhances automation capabilities for users
2026-07-08 22:06:56 +02:00
Christoph b495313a51 Update version to 0.200.3 2026-07-08 22:05:11 +02:00
Christoph Brandau eed7325719 Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Has been cancelled
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Has been cancelled
2026-07-08 21:54:51 +02:00
Christoph Brandau 51482c497f feat(installer): enhance installation process with registry and shortcuts
This update improves the installation process by adding macros for
registering the application in the Windows registry and creating
shortcuts on the desktop and start menu. These changes ensure a
better user experience by providing easy access to the application
and proper installation records.

- Register application details in the Windows registry
- Create shortcuts for quick access on the desktop and start menu
- Clean up legacy shortcuts during installation
2026-07-08 21:54:24 +02:00
23 changed files with 2495 additions and 475 deletions
+5 -1
View File
@@ -96,7 +96,11 @@
"Bash(identify src-tauri/icons/GitCat.ico)", "Bash(identify src-tauri/icons/GitCat.ico)",
"Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")", "Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")",
"Bash(python3 *)", "Bash(python3 *)",
"Bash(xxd -l 16 src-tauri/icons/GitCat.ico)" "Bash(xxd -l 16 src-tauri/icons/GitCat.ico)",
"Bash(pkg-config --exists openssl)",
"Bash(sudo apt-get install -y libssl-dev pkg-config)",
"Bash(dpkg -L libssl3t64)",
"Bash(grep *)"
] ]
} }
} }
+50
View File
@@ -1 +1,51 @@
<div align="center">
<img src="public/splash-icon.png" alt="Gitty Logo" width="360">
# Gitty
**A lightweight, modern Git client built with Tauri.**
Fast, simple, and designed for developers who want a clean Git experience without unnecessary complexity.
</div>
---
## ✨ Features
- 🚀 Fast native performance
- 🌿 Branch management
- 📝 Commit history
- 🔄 Pull, Push & Fetch
- 🔀 Merge & Rebase
- 📦 Repository management
- 🎨 Modern and intuitive UI
---
## 📸 Preview
> Screenshots coming soon.
---
## 🛠️ Built With
- **Tauri**
- **Rust**
- **Svelte**
---
## 🚧 Status
Gitty is currently under active development.
Contributions, feedback and feature requests are always welcome!
---
## 📄 License
MIT License
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "gitty", "name": "gitty",
"version": "0.200.2", "version": "2026.7.17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gitty", "name": "gitty",
"version": "0.200.2", "version": "2026.7.17",
"dependencies": { "dependencies": {
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitty", "name": "gitty",
"version": "0.200.2", "version": "2026.7.17",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+126 -9
View File
@@ -2543,6 +2543,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-aptabase", "tauri-plugin-aptabase",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-single-instance",
"tauri-plugin-updater", "tauri-plugin-updater",
"tokio", "tokio",
] ]
@@ -3420,7 +3421,7 @@ dependencies = [
"security-framework 2.11.1", "security-framework 2.11.1",
"security-framework 3.7.0", "security-framework 3.7.0",
"windows-sys 0.60.2", "windows-sys 0.60.2",
"zbus", "zbus 4.4.0",
"zeroize", "zeroize",
] ]
@@ -5895,7 +5896,7 @@ dependencies = [
"rand 0.8.6", "rand 0.8.6",
"serde", "serde",
"sha2", "sha2",
"zbus", "zbus 4.4.0",
] ]
[[package]] [[package]]
@@ -7066,6 +7067,21 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus 5.17.0",
]
[[package]] [[package]]
name = "tauri-plugin-updater" name = "tauri-plugin-updater"
version = "2.10.1" version = "2.10.1"
@@ -9126,9 +9142,44 @@ dependencies = [
"uds_windows", "uds_windows",
"windows-sys 0.52.0", "windows-sys 0.52.0",
"xdg-home", "xdg-home",
"zbus_macros", "zbus_macros 4.4.0",
"zbus_names", "zbus_names 3.0.0",
"zvariant", "zvariant 4.2.0",
]
[[package]]
name = "zbus"
version = "5.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid 1.23.4",
"windows-sys 0.61.2",
"winnow 1.0.3",
"zbus_macros 5.17.0",
"zbus_names 4.3.3",
"zvariant 5.13.0",
] ]
[[package]] [[package]]
@@ -9141,7 +9192,22 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.118", "syn 2.0.118",
"zvariant_utils", "zvariant_utils 2.1.0",
]
[[package]]
name = "zbus_macros"
version = "5.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"zbus_names 4.3.3",
"zvariant 5.13.0",
"zvariant_utils 3.5.0",
] ]
[[package]] [[package]]
@@ -9152,7 +9218,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [ dependencies = [
"serde", "serde",
"static_assertions", "static_assertions",
"zvariant", "zvariant 4.2.0",
]
[[package]]
name = "zbus_names"
version = "4.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2"
dependencies = [
"serde",
"winnow 1.0.3",
"zvariant 5.13.0",
] ]
[[package]] [[package]]
@@ -9313,7 +9390,21 @@ dependencies = [
"enumflags2", "enumflags2",
"serde", "serde",
"static_assertions", "static_assertions",
"zvariant_derive", "zvariant_derive 4.2.0",
]
[[package]]
name = "zvariant"
version = "5.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.3",
"zvariant_derive 5.13.0",
"zvariant_utils 3.5.0",
] ]
[[package]] [[package]]
@@ -9326,7 +9417,20 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.118", "syn 2.0.118",
"zvariant_utils", "zvariant_utils 2.1.0",
]
[[package]]
name = "zvariant_derive"
version = "5.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
"zvariant_utils 3.5.0",
] ]
[[package]] [[package]]
@@ -9339,3 +9443,16 @@ dependencies = [
"quote", "quote",
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "zvariant_utils"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.118",
"winnow 1.0.3",
]
+1
View File
@@ -26,6 +26,7 @@ tokio = "1.52.3"
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
# ── Build profiles ─────────────────────────────────────────────────────────── # ── Build profiles ───────────────────────────────────────────────────────────
+23 -1
View File
@@ -1,7 +1,6 @@
!macro GITTY_MIGRATE_LEGACY_DATA_DIR oldDir newDir !macro GITTY_MIGRATE_LEGACY_DATA_DIR oldDir newDir
${IfNot} ${FileExists} "${newDir}\*.*" ${IfNot} ${FileExists} "${newDir}\*.*"
${AndIf} ${FileExists} "${oldDir}\*.*" ${AndIf} ${FileExists} "${oldDir}\*.*"
CreateDirectory "${newDir}"
Rename "${oldDir}" "${newDir}" Rename "${oldDir}" "${newDir}"
${EndIf} ${EndIf}
!macroend !macroend
@@ -34,6 +33,27 @@
RMDir /r "$SMPROGRAMS\GitLite" RMDir /r "$SMPROGRAMS\GitLite"
!macroend !macroend
!macro GITTY_REGISTER_INSTALL
WriteRegStr HKCU "${MANUPRODUCTKEY}" "" "$INSTDIR"
WriteRegStr HKCU "${UNINSTKEY}" "MainBinaryName" "${MAINBINARYNAME}.exe"
WriteRegStr HKCU "${UNINSTKEY}" "DisplayName" "${PRODUCTNAME}"
WriteRegStr HKCU "${UNINSTKEY}" "DisplayIcon" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\""
WriteRegStr HKCU "${UNINSTKEY}" "DisplayVersion" "${VERSION}"
WriteRegStr HKCU "${UNINSTKEY}" "Publisher" "${MANUFACTURER}"
WriteRegStr HKCU "${UNINSTKEY}" "InstallLocation" "$\"$INSTDIR$\""
WriteRegStr HKCU "${UNINSTKEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKCU "${UNINSTKEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
WriteRegDWORD HKCU "${UNINSTKEY}" "NoModify" "1"
WriteRegDWORD HKCU "${UNINSTKEY}" "NoRepair" "1"
!macroend
!macro GITTY_CREATE_UPDATE_SHORTCUTS
CreateShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
!insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk"
CreateShortcut "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
!insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk"
!macroend
!macro NSIS_HOOK_PREINSTALL !macro NSIS_HOOK_PREINSTALL
!insertmacro GITTY_MIGRATE_LEGACY_DATA_DIR "$APPDATA\com.git-lite" "$APPDATA\com.gitty" !insertmacro GITTY_MIGRATE_LEGACY_DATA_DIR "$APPDATA\com.git-lite" "$APPDATA\com.gitty"
!insertmacro GITTY_MIGRATE_LEGACY_DATA_DIR "$LOCALAPPDATA\com.git-lite" "$LOCALAPPDATA\com.gitty" !insertmacro GITTY_MIGRATE_LEGACY_DATA_DIR "$LOCALAPPDATA\com.git-lite" "$LOCALAPPDATA\com.gitty"
@@ -43,6 +63,8 @@
!macro NSIS_HOOK_POSTINSTALL !macro NSIS_HOOK_POSTINSTALL
!insertmacro GITTY_CLEANUP_LEGACY_SHORTCUTS !insertmacro GITTY_CLEANUP_LEGACY_SHORTCUTS
!insertmacro GITTY_REGISTER_INSTALL
!insertmacro GITTY_CREATE_UPDATE_SHORTCUTS
!macroend !macroend
!macro NSIS_HOOK_POSTUNINSTALL !macro NSIS_HOOK_POSTUNINSTALL
+245 -184
View File
@@ -681,7 +681,14 @@ pub fn create_tag(
Some(message) => { Some(message) => {
run_git( run_git(
&repo, &repo,
["tag", "-a", name.as_str(), "-m", message.as_str(), target.as_str()], [
"tag",
"-a",
name.as_str(),
"-m",
message.as_str(),
target.as_str(),
],
)?; )?;
} }
None => { None => {
@@ -755,7 +762,11 @@ fn validate_existing_tag_name(repo: &Path, name: &str) -> Result<String, String>
fn validate_tag_ref_name(name: &str) -> Result<String, String> { fn validate_tag_ref_name(name: &str) -> Result<String, String> {
let output = git_command() let output = git_command()
.args(["check-ref-format", "--allow-onelevel", &format!("refs/tags/{name}")]) .args([
"check-ref-format",
"--allow-onelevel",
&format!("refs/tags/{name}"),
])
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
@@ -778,63 +789,75 @@ fn validate_tag_ref_name(name: &str) -> Result<String, String> {
} }
#[tauri::command] #[tauri::command]
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> { pub async fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
validate_files(&files)?; let repo = resolve_repo(&path)?;
validate_files(&files)?;
if !files.is_empty() { if !files.is_empty() {
// A file we've detected as an unstaged rename (see `detect_worktree_renames`) has // A file we've detected as an unstaged rename (see `detect_worktree_renames`) has
// to be staged with both its old and new path so `git add` records it as a rename // to be staged with both its old and new path so `git add` records it as a rename
// instead of leaving the old path's deletion unstaged. // instead of leaving the old path's deletion unstaged.
let current_status = status_for_repo(&repo)?; let current_status = status_for_repo(&repo)?;
let mut add_paths: Vec<String> = Vec::new(); let mut add_paths: Vec<String> = Vec::new();
for file in &files { for file in &files {
match find_status(&current_status.files, file) { match find_status(&current_status.files, file) {
Some(entry) => { Some(entry) => {
if let Some(old_path) = &entry.old_path { if let Some(old_path) = &entry.old_path {
add_paths.push(old_path.clone()); add_paths.push(old_path.clone());
}
add_paths.push(entry.path.clone());
} }
add_paths.push(entry.path.clone()); None => add_paths.push(file.clone()),
} }
None => add_paths.push(file.clone()),
} }
run_git_with_paths(&repo, &["add"], &add_paths)?;
} }
run_git_with_paths(&repo, &["add"], &add_paths)?;
}
status_for_repo(&repo) status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not stage files: {err}"))?
} }
#[tauri::command] #[tauri::command]
pub fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> { pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
validate_files(&files)?; let repo = resolve_repo(&path)?;
validate_files(&files)?;
if !files.is_empty() {
let current_status = status_for_repo(&repo)?;
unstage_selected_files(&repo, &current_status.files, &files)?;
}
status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not unstage files: {err}"))?
}
#[tauri::command]
pub async fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
validate_files(&files)?;
if files.is_empty() {
return status_for_repo(&repo);
}
if !files.is_empty() {
let current_status = status_for_repo(&repo)?; let current_status = status_for_repo(&repo)?;
unstage_selected_files(&repo, &current_status.files, &files)?; if staged {
} restore_staged_files(&repo, &current_status.files, &files)?;
} else {
restore_worktree_files(&repo, &current_status.files, &files)?;
}
status_for_repo(&repo) status_for_repo(&repo)
} })
.await
#[tauri::command] .map_err(|err| format!("Could not restore files: {err}"))?
pub fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
validate_files(&files)?;
if files.is_empty() {
return status_for_repo(&repo);
}
let current_status = status_for_repo(&repo)?;
if staged {
restore_staged_files(&repo, &current_status.files, &files)?;
} else {
restore_worktree_files(&repo, &current_status.files, &files)?;
}
status_for_repo(&repo)
} }
#[tauri::command] #[tauri::command]
@@ -1044,47 +1067,55 @@ pub fn apply_file_patch(
} }
#[tauri::command] #[tauri::command]
pub fn commit(path: String, message: String) -> Result<GitStatus, String> { pub async fn commit(path: String, message: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
if message.trim().is_empty() { let repo = resolve_repo(&path)?;
return Err("Commit message must not be empty.".to_string()); if message.trim().is_empty() {
} return Err("Commit message must not be empty.".to_string());
}
let current_status = status_for_repo(&repo)?; let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) { if has_unresolved_conflicts(&current_status) {
return Err("Merge conflicts must be resolved before you can commit.".to_string()); return Err("Merge conflicts must be resolved before you can commit.".to_string());
} }
run_git(&repo, ["commit", "-m", message.as_str()])?; run_git(&repo, ["commit", "-m", message.as_str()])?;
status_for_repo(&repo) status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not commit: {err}"))?
} }
#[tauri::command] #[tauri::command]
pub fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> { pub async fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
if verify_commit(&repo, "HEAD").is_err() { let repo = resolve_repo(&path)?;
return Err("There is no commit to amend.".to_string()); if verify_commit(&repo, "HEAD").is_err() {
} return Err("There is no commit to amend.".to_string());
let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) {
return Err("Merge conflicts must be resolved before you can commit.".to_string());
}
let message = message
.map(|message| message.trim().to_string())
.filter(|message| !message.is_empty());
match message {
Some(message) => {
run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?;
} }
None => {
run_git(&repo, ["commit", "--amend", "--no-edit"])?;
}
}
status_for_repo(&repo) let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) {
return Err("Merge conflicts must be resolved before you can commit.".to_string());
}
let message = message
.map(|message| message.trim().to_string())
.filter(|message| !message.is_empty());
match message {
Some(message) => {
run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?;
}
None => {
run_git(&repo, ["commit", "--amend", "--no-edit"])?;
}
}
status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not amend commit: {err}"))?
} }
#[tauri::command] #[tauri::command]
@@ -1096,7 +1127,11 @@ pub fn last_commit_message(path: String) -> Result<Option<String>, String> {
let output = run_git(&repo, ["log", "-1", "--format=%B", "HEAD"])?; let output = run_git(&repo, ["log", "-1", "--format=%B", "HEAD"])?;
let message = String::from_utf8_lossy(&output).trim_end().to_string(); let message = String::from_utf8_lossy(&output).trim_end().to_string();
Ok(if message.is_empty() { None } else { Some(message) }) Ok(if message.is_empty() {
None
} else {
Some(message)
})
} }
#[tauri::command] #[tauri::command]
@@ -1117,39 +1152,43 @@ pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
} }
#[tauri::command] #[tauri::command]
pub fn pull( pub async fn pull(
path: String, path: String,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"]; let repo = resolve_repo(&path)?;
let output = match (username.as_deref(), password.as_deref()) { let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"];
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { let output = match (username.as_deref(), password.as_deref()) {
run_git_authenticated_output(&repo, pull_args, u, p)? (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated_output(&repo, pull_args, u, p)?
}
_ => git_command()
.arg("-C")
.arg(&repo)
.args(pull_args)
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
};
if output.status.success() {
return status_for_repo(&repo);
} }
_ => git_command()
.arg("-C")
.arg(&repo)
.args(pull_args)
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
};
if output.status.success() { let status = status_for_repo(&repo)?;
return status_for_repo(&repo); if has_unresolved_conflicts(&status) {
} return Ok(status);
}
let status = status_for_repo(&repo)?; let details = command_output_details(&output);
if has_unresolved_conflicts(&status) { if is_auth_error(&details) {
return Ok(status); return Err(format!("AUTH_FAILED:{details}"));
} }
Err(format!("Git command failed: {details}"))
let details = command_output_details(&output); })
if is_auth_error(&details) { .await
return Err(format!("AUTH_FAILED:{details}")); .map_err(|err| format!("Could not pull: {err}"))?
}
Err(format!("Git command failed: {details}"))
} }
#[tauri::command] #[tauri::command]
@@ -1188,22 +1227,26 @@ pub async fn fetch(
} }
#[tauri::command] #[tauri::command]
pub fn push( pub async fn push(
path: String, path: String,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let push_args = push_args_for_repo(&repo)?; let repo = resolve_repo(&path)?;
match (username.as_deref(), password.as_deref()) { let push_args = push_args_for_repo(&repo)?;
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { match (username.as_deref(), password.as_deref()) {
run_git_authenticated(&repo, push_args, u, p)?; (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated(&repo, push_args, u, p)?;
}
_ => {
run_git(&repo, push_args)?;
}
} }
_ => { status_for_repo(&repo)
run_git(&repo, push_args)?; })
} .await
} .map_err(|err| format!("Could not push: {err}"))?
status_for_repo(&repo)
} }
// ── Credential storage (OS keychain) ──────────────────────────────────────── // ── Credential storage (OS keychain) ────────────────────────────────────────
@@ -1373,61 +1416,69 @@ pub fn cred_delete(key: String) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> { pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let branch = branch.trim(); let repo = resolve_repo(&path)?;
if branch.is_empty() { let branch = branch.trim();
return Err("Branch name must not be empty.".to_string()); if branch.is_empty() {
} return Err("Branch name must not be empty.".to_string());
}
let output = git_command() let output = git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(["merge", "--no-edit", branch]) .args(["merge", "--no-edit", branch])
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() { if output.status.success() {
return status_for_repo(&repo); return status_for_repo(&repo);
} }
// A merge that stops on conflicts leaves unmerged paths in the work tree. // A merge that stops on conflicts leaves unmerged paths in the work tree.
// Surface those through the status so the UI can offer conflict resolution // Surface those through the status so the UI can offer conflict resolution
// instead of treating the conflict as a hard error. // instead of treating the conflict as a hard error.
let status = status_for_repo(&repo)?; let status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&status) { if has_unresolved_conflicts(&status) {
return Ok(status); return Ok(status);
} }
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let details = if !stderr.trim().is_empty() { let details = if !stderr.trim().is_empty() {
stderr.trim() stderr.trim()
} else if !stdout.trim().is_empty() { } else if !stdout.trim().is_empty() {
stdout.trim() stdout.trim()
} else { } else {
"unknown error" "unknown error"
}; };
Err(format!("Merge failed: {details}")) Err(format!("Merge failed: {details}"))
})
.await
.map_err(|err| format!("Could not merge: {err}"))?
} }
#[tauri::command] #[tauri::command]
pub fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> { pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let branch = branch.trim(); let repo = resolve_repo(&path)?;
if branch.is_empty() { let branch = branch.trim();
return Err("Branch name must not be empty.".to_string()); if branch.is_empty() {
} return Err("Branch name must not be empty.".to_string());
}
let output = git_command() let output = git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(["rebase", branch]) .args(["rebase", branch])
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
rebase_status_or_error(&repo, output, "Rebase failed", true) rebase_status_or_error(&repo, output, "Rebase failed", true)
})
.await
.map_err(|err| format!("Could not rebase: {err}"))?
} }
#[tauri::command] #[tauri::command]
@@ -1479,19 +1530,23 @@ fn rebase_status_or_error(
} }
#[tauri::command] #[tauri::command]
pub fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> { pub async fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let commit_hash = verify_commit(&repo, &commit)?; let repo = resolve_repo(&path)?;
let commit_hash = verify_commit(&repo, &commit)?;
let output = git_command() let output = git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(["cherry-pick", commit_hash.as_str()]) .args(["cherry-pick", commit_hash.as_str()])
.env("GIT_EDITOR", "true") .env("GIT_EDITOR", "true")
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
cherry_pick_status_or_error(&repo, output, "Cherry-pick failed") cherry_pick_status_or_error(&repo, output, "Cherry-pick failed")
})
.await
.map_err(|err| format!("Could not cherry-pick: {err}"))?
} }
#[tauri::command] #[tauri::command]
@@ -4826,12 +4881,12 @@ mod tests {
})); }));
} }
#[test] #[tokio::test]
#[cfg_attr( #[cfg_attr(
windows, windows,
ignore = "Git for Windows can fail local pull tests with a sh signal pipe error" ignore = "Git for Windows can fail local pull tests with a sh signal pipe error"
)] )]
fn pull_merges_diverging_branch_instead_of_requiring_fast_forward() { async fn pull_merges_diverging_branch_instead_of_requiring_fast_forward() {
let repo = init_temp_repo("pull_diverged"); let repo = init_temp_repo("pull_diverged");
commit_initial_file(&repo.path); commit_initial_file(&repo.path);
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
@@ -4861,7 +4916,9 @@ mod tests {
], ],
); );
let status = pull(repo.path.to_string_lossy().to_string(), None, None).unwrap(); let status = pull(repo.path.to_string_lossy().to_string(), None, None)
.await
.unwrap();
assert!(status.clean, "{:?}", status.files); assert!(status.clean, "{:?}", status.files);
assert!(repo.path.join("remote.txt").exists()); assert!(repo.path.join("remote.txt").exists());
@@ -4910,12 +4967,12 @@ mod tests {
); );
} }
#[test] #[tokio::test]
#[cfg_attr( #[cfg_attr(
windows, windows,
ignore = "Git for Windows can fail local push tests with a sh signal pipe error" ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
)] )]
fn push_sets_upstream_for_branch_without_tracking_remote() { async fn push_sets_upstream_for_branch_without_tracking_remote() {
let repo = init_temp_repo("push_sets_upstream_integration"); let repo = init_temp_repo("push_sets_upstream_integration");
let remote = init_bare_temp_repo("push_sets_upstream_integration_remote"); let remote = init_bare_temp_repo("push_sets_upstream_integration_remote");
commit_initial_file(&repo.path); commit_initial_file(&repo.path);
@@ -4932,7 +4989,9 @@ mod tests {
["remote", "add", "origin", remote.path.to_str().unwrap()], ["remote", "add", "origin", remote.path.to_str().unwrap()],
); );
let status = push(repo.path.to_string_lossy().to_string(), None, None).unwrap(); let status = push(repo.path.to_string_lossy().to_string(), None, None)
.await
.unwrap();
assert_eq!( assert_eq!(
status.upstream.as_deref(), status.upstream.as_deref(),
@@ -5000,8 +5059,8 @@ mod tests {
assert_eq!(contents.replace("\r\n", "\n"), "resolved\n"); assert_eq!(contents.replace("\r\n", "\n"), "resolved\n");
} }
#[test] #[tokio::test]
fn commit_rejects_unresolved_merge_conflicts() { async fn commit_rejects_unresolved_merge_conflicts() {
let repo = init_temp_repo("commit_rejects_conflicts"); let repo = init_temp_repo("commit_rejects_conflicts");
fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written"); fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written");
run_git_test(&repo.path, ["add", "file.txt"]); run_git_test(&repo.path, ["add", "file.txt"]);
@@ -5027,6 +5086,7 @@ mod tests {
repo.path.to_string_lossy().to_string(), repo.path.to_string_lossy().to_string(),
"should not commit".to_string(), "should not commit".to_string(),
) )
.await
.unwrap_err(); .unwrap_err();
assert!(err.contains("Merge conflicts")); assert!(err.contains("Merge conflicts"));
@@ -5544,7 +5604,8 @@ mod tests {
fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() { fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() {
let repo = init_temp_repo("file_blame"); let repo = init_temp_repo("file_blame");
commit_initial_file(&repo.path); commit_initial_file(&repo.path);
fs::write(repo.path.join("old.txt"), "original\ntwo\n").expect("tracked file should change"); fs::write(repo.path.join("old.txt"), "original\ntwo\n")
.expect("tracked file should change");
run_git_test(&repo.path, ["add", "old.txt"]); run_git_test(&repo.path, ["add", "old.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "add second line"]); run_git_test(&repo.path, ["commit", "-q", "-m", "add second line"]);
+15 -7
View File
@@ -19,7 +19,7 @@ use git::{
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop, restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
stash_push, undo_last_commit, unstage_files, stash_push, undo_last_commit, unstage_files,
}; };
use tauri::Manager; use tauri::{Manager, AppHandle};
#[tauri::command] #[tauri::command]
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> { fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
@@ -43,13 +43,21 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
#[cfg(desktop)]
let _ = app.get_webview_window("main")
.expect("no main window")
.set_focus();
}))
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_aptabase::Builder::new("A-SH-1344793789").with_options( .plugin(
tauri_plugin_aptabase::InitOptions { tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
host: Some("https://aptabase.cbsk-tech.de".to_string()), .with_options(tauri_plugin_aptabase::InitOptions {
flush_interval:None host: Some("https://aptabase.cbsk-tech.de".to_string()),
} flush_interval: None,
).build()) })
.build(),
)
.manage(SearchCancellationState::default()) .manage(SearchCancellationState::default())
.manage(commit_ai::CommitAiEngine::new()) .manage(commit_ai::CommitAiEngine::new())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty", "productName": "Gitty",
"version": "0.200.2", "version": "2026.7.17",
"identifier": "com.gitty", "identifier": "com.gitty",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+614 -68
View File
@@ -98,6 +98,7 @@
import type { import type {
AiSettings, AiSettings,
AppTheme,
AnalyticsSettings, AnalyticsSettings,
CommitAiPhase, CommitAiPhase,
ConflictFile, ConflictFile,
@@ -133,7 +134,8 @@
type AppView = "management" | "repository"; type AppView = "management" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone"; type CredentialAction = "push" | "pull" | "fetch" | "clone";
type PendingDiscard = type PendingDiscard =
| { kind: "file"; file: GitFileStatus; staged: boolean } | { kind: "file"; files: GitFileStatus[]; staged: boolean }
| { kind: "all-changes"; files: GitFileStatus[] }
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string }; | { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
interface RepoTab { interface RepoTab {
@@ -164,14 +166,35 @@
const REPO_STATUS_CACHE_KEY = "gitlite.repoStatusCache.v1"; const REPO_STATUS_CACHE_KEY = "gitlite.repoStatusCache.v1";
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1"; const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1"; const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
const APP_THEME_KEY = "gitlite.theme.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1"; const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
const LEFT_BRANCH_PANEL_HEIGHT_KEY = "gitlite.leftBranchPanelHeight.v1";
const LEFT_STASH_PANEL_HEIGHT_KEY = "gitlite.leftStashPanelHeight.v1";
const BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1";
const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2";
const EXPLORER_PANEL_COLLAPSED_KEY = "gitlite.explorerPanelCollapsed.v1";
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1"; const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
const FILE_HISTORY_WIDTH_KEY = "gitlite.fileHistoryWidth.v1";
const COMMIT_PANEL_DEFAULT_HEIGHT = 220; const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT; const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
const COMMIT_PANEL_MAX_HEIGHT = 640; const COMMIT_PANEL_MAX_HEIGHT = 640;
const LEFT_SIDEBAR_DEFAULT_WIDTH = 280;
const LEFT_SIDEBAR_MIN_WIDTH = 220;
const LEFT_SIDEBAR_MAX_WIDTH = 420;
const LEFT_BRANCH_PANEL_DEFAULT_HEIGHT = 260;
const LEFT_BRANCH_PANEL_MIN_HEIGHT = 180;
const LEFT_BRANCH_PANEL_MAX_HEIGHT = 560;
const LEFT_STASH_PANEL_DEFAULT_HEIGHT = 190;
const LEFT_STASH_PANEL_MIN_HEIGHT = 150;
const LEFT_STASH_PANEL_MAX_HEIGHT = 420;
const LEFT_EXPLORER_PANEL_MIN_HEIGHT = 220;
const HISTORY_ASIDE_DEFAULT_WIDTH = 620; const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
const HISTORY_ASIDE_MIN_WIDTH = 560; const HISTORY_ASIDE_MIN_WIDTH = 560;
const HISTORY_ASIDE_MAX_WIDTH = 920; const HISTORY_ASIDE_MAX_WIDTH = 920;
const FILE_HISTORY_DEFAULT_WIDTH = 300;
const FILE_HISTORY_MIN_WIDTH = 240;
const FILE_HISTORY_MAX_WIDTH = 520;
const ERROR_AUTO_HIDE_MS = 6000; const ERROR_AUTO_HIDE_MS = 6000;
// ── State ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
@@ -218,6 +241,7 @@
let appSettingsOpen = false; let appSettingsOpen = false;
let analyticsNoticeOpen = false; let analyticsNoticeOpen = false;
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings(); let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
let appTheme: AppTheme = loadThemePreference();
let localModelOptions: LocalModelOption[] = []; let localModelOptions: LocalModelOption[] = [];
let errorMessage = ""; let errorMessage = "";
let operation = ""; let operation = "";
@@ -292,10 +316,31 @@
let resizingCommitPanel = false; let resizingCommitPanel = false;
let resizeStartY = 0; let resizeStartY = 0;
let resizeStartHeight = 0; let resizeStartHeight = 0;
let leftSidebarWidth = loadLeftSidebarWidth();
let resizingLeftSidebar = false;
let leftSidebarResizeStartX = 0;
let leftSidebarResizeStartWidth = 0;
let leftBranchPanelHeight = loadLeftBranchPanelHeight();
let resizingLeftBranchPanel = false;
let leftBranchResizeStartY = 0;
let leftBranchResizeStartHeight = 0;
let leftStashPanelHeight = loadLeftStashPanelHeight();
let resizingLeftStashPanel = false;
let leftStashResizeStartY = 0;
let leftStashResizeStartHeight = 0;
let branchPanelCollapsed = loadStoredBoolean(BRANCH_PANEL_COLLAPSED_KEY, false);
let stashPanelCollapsed = loadStoredBoolean(STASH_PANEL_COLLAPSED_KEY, false);
let explorerPanelCollapsed = loadStoredBoolean(EXPLORER_PANEL_COLLAPSED_KEY, false);
let historyAsideWidth = loadHistoryAsideWidth(); let historyAsideWidth = loadHistoryAsideWidth();
let resizingHistoryAside = false; let resizingHistoryAside = false;
let historyResizeStartX = 0; let historyResizeStartX = 0;
let historyResizeStartWidth = 0; let historyResizeStartWidth = 0;
let fileHistoryWidth = loadFileHistoryWidth();
let resizingFileHistory = false;
let fileHistoryResizeStartX = 0;
let fileHistoryResizeStartWidth = 0;
let fileHistoryCollapsed = true;
let themeMediaQuery: MediaQueryList | undefined;
// ── Derived ──────────────────────────────────────────────────────────────── // ── Derived ────────────────────────────────────────────────────────────────
@@ -337,22 +382,29 @@
$: localBranchNames = localBranches.map((b) => b.name); $: localBranchNames = localBranches.map((b) => b.name);
$: remoteBranches = branches.filter((b) => b.remote); $: remoteBranches = branches.filter((b) => b.remote);
$: repoSearchTerm = repoSearch.trim().toLowerCase(); $: repoSearchTerm = repoSearch.trim().toLowerCase();
$: openRepoRows = repoTabs.filter(repoMatchesSearch); $: openRepoRows = repoTabs.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
$: recentRepoRows = recentRepoPaths $: recentRepoRows = recentRepoPaths
.filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path))) .filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path)))
.map(repoRowFromPath) .map((path) => repoRowFromPath(path, repoTabs, repoStatusCache))
.filter(repoMatchesSearch); .filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
$: favoriteRepoRows = favoriteRepoPaths $: favoriteRepoRows = favoriteRepoPaths
.map(repoRowFromPath) .map((path) => repoRowFromPath(path, repoTabs, repoStatusCache))
.filter(repoMatchesSearch); .filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
$: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed);
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
$: applyThemePreference(appTheme);
// ── Lifecycle ────────────────────────────────────────────────────────────── // ── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => { onMount(() => {
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
void runStartupSequence(); void runStartupSequence();
}); });
onDestroy(() => { onDestroy(() => {
themeMediaQuery?.removeEventListener("change", handleSystemThemeChange);
if (autoRefreshTimer) clearInterval(autoRefreshTimer); if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (backgroundRepoStatusTimer) clearInterval(backgroundRepoStatusTimer); if (backgroundRepoStatusTimer) clearInterval(backgroundRepoStatusTimer);
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer); if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
@@ -497,6 +549,7 @@
try { try {
await fetchRemote(activeRepoPath); await fetchRemote(activeRepoPath);
applyStatus(await getStatus(activeRepoPath)); applyStatus(await getStatus(activeRepoPath));
await refreshRefsAndCommitGraph(activeRepoPath);
} catch { } catch {
// ignore — see comment above // ignore — see comment above
} finally { } finally {
@@ -514,8 +567,10 @@
try { try {
await fetchRemote(path); await fetchRemote(path);
const nextStatus = await getStatus(path); const nextStatus = await getStatus(path);
if (sameRepoPath(path, activeRepoPath)) applyStatus(nextStatus); if (sameRepoPath(path, activeRepoPath)) {
else updateRepoManagementStatus(path, nextStatus); applyStatus(nextStatus);
await refreshRefsAndCommitGraph(path);
} else updateRepoManagementStatus(path, nextStatus);
} catch { } catch {
// ignore; manual Fetch/Pull surfaces auth or network problems // ignore; manual Fetch/Pull surfaces auth or network problems
} finally { } finally {
@@ -586,26 +641,32 @@
async function autoRefreshTick() { async function autoRefreshTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return; if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
const path = activeRepoPath;
autoRefreshInFlight = true; autoRefreshInFlight = true;
try { try {
// Cheap fast path: only fetch status; skip the heavy reload if nothing changed. // Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
const nextStatus = await getStatus(activeRepoPath); const nextStatus = await getStatus(path);
// The user may have switched repos (or closed this one) while the status
// call was in flight — applying a stale result would flash/overwrite the
// now-active repo's name and data with this one's.
if (!sameRepoPath(path, activeRepoPath)) return;
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return; if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
applyStatus(nextStatus); applyStatus(nextStatus);
// Something changed — reload branches, commits and files in one bundled call. // Something changed — reload branches, commits and files in one bundled call.
const bundle = await openRepositoryBundle(activeRepoPath, 100); const bundle = await openRepositoryBundle(path, 100);
if (!sameRepoPath(path, activeRepoPath)) return;
const previousHeadHash = lastFileHistoryHeadHash; const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(activeRepoPath, bundle.branches); await refreshBranchList(path, bundle.branches);
await refreshTags(activeRepoPath, bundle.tags); await refreshTags(path, bundle.tags);
await refreshStashes(activeRepoPath, bundle.stashes); await refreshStashes(path, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshCommitHistory(path, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files); await refreshExplorerFiles(path, bundle.files);
// File history reflects `git log`, which only changes when HEAD actually moves // File history reflects `git log`, which only changes when HEAD actually moves
// (new commit, checkout, merge, ...) — skip the reload otherwise so a plain // (new commit, checkout, merge, ...) — skip the reload otherwise so a plain
// working-tree/status change (staging, edits) doesn't keep re-fetching and // working-tree/status change (staging, edits) doesn't keep re-fetching and
// flickering the currently viewed file's history. // flickering the currently viewed file's history.
if (lastFileHistoryHeadHash !== previousHeadHash) { if (lastFileHistoryHeadHash !== previousHeadHash && sameRepoPath(path, activeRepoPath)) {
await refreshFileHistory(activeRepoPath); await refreshFileHistory(path);
} }
} catch { /* ignore transient errors */ } finally { } catch { /* ignore transient errors */ } finally {
autoRefreshInFlight = false; autoRefreshInFlight = false;
@@ -702,11 +763,46 @@
trackEvent("app_started", { first_run: 1 }); trackEvent("app_started", { first_run: 1 });
} }
function saveAppSettings(next: AnalyticsSettings) { function loadThemePreference(): AppTheme {
try {
const stored = localStorage.getItem(APP_THEME_KEY);
if (stored === "system" || stored === "light" || stored === "dark") return stored;
} catch {
// Local storage is optional; system theme is a sane default.
}
return "system";
}
function persistThemePreference(next: AppTheme) {
try {
localStorage.setItem(APP_THEME_KEY, next);
} catch {
// Ignore storage quota/private-mode errors.
}
}
function applyThemePreference(next: AppTheme) {
const prefersLight = themeMediaQuery?.matches ?? window.matchMedia("(prefers-color-scheme: light)").matches;
const resolved = next === "system"
? prefersLight
? "light"
: "dark"
: next;
document.documentElement.dataset.themePreference = next;
document.documentElement.dataset.theme = resolved;
}
function handleSystemThemeChange() {
if (appTheme === "system") applyThemePreference(appTheme);
}
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme) {
analyticsSettings = next; analyticsSettings = next;
appTheme = nextTheme;
persistAnalyticsSettings(next); persistAnalyticsSettings(next);
persistThemePreference(nextTheme);
appSettingsOpen = false; appSettingsOpen = false;
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1 }); if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme });
} }
function updateCommitMessage(message: string) { function updateCommitMessage(message: string) {
@@ -893,6 +989,10 @@
return repoKey(left) === repoKey(right); return repoKey(left) === repoKey(right);
} }
function baseName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function uniqueRepoPaths(paths: string[]): string[] { function uniqueRepoPaths(paths: string[]): string[] {
const seen = new Set<string>(); const seen = new Set<string>();
const result: string[] = []; const result: string[] = [];
@@ -907,11 +1007,11 @@
return result; return result;
} }
function repoRowFromPath(path: string): RepoTab { function repoRowFromPath(path: string, tabs = repoTabs, statusCache = repoStatusCache): RepoTab {
const openTab = repoTabs.find((tab) => sameRepoPath(tab.path, path)); const openTab = tabs.find((tab) => sameRepoPath(tab.path, path));
if (openTab) return openTab; if (openTab) return openTab;
const cached = repoStatusCache[repoKey(path)]; const cached = statusCache[repoKey(path)];
if (cached) return { ...cached, path, name: repoNameFromPath(path) }; if (cached) return { ...cached, path, name: repoNameFromPath(path) };
return { return {
@@ -925,11 +1025,11 @@
}; };
} }
function repoMatchesSearch(repo: RepoTab): boolean { function repoMatchesSearch(repo: RepoTab, searchTerm = repoSearchTerm): boolean {
if (!repoSearchTerm) return true; if (!searchTerm) return true;
return repo.name.toLowerCase().includes(repoSearchTerm) return repo.name.toLowerCase().includes(searchTerm)
|| repo.path.toLowerCase().includes(repoSearchTerm) || repo.path.toLowerCase().includes(searchTerm)
|| (repo.branch ?? "").toLowerCase().includes(repoSearchTerm); || (repo.branch ?? "").toLowerCase().includes(searchTerm);
} }
function loadRepoLists() { function loadRepoLists() {
@@ -1024,6 +1124,25 @@
} }
} }
function loadStoredBoolean(key: string, fallback: boolean): boolean {
try {
const stored = localStorage.getItem(key);
if (stored === "true") return true;
if (stored === "false") return false;
} catch {
// Local storage is best-effort only; panel defaults are enough.
}
return fallback;
}
function persistStoredBoolean(key: string, value: boolean) {
try {
localStorage.setItem(key, String(value));
} catch {
// Local storage is best-effort only; toggles must keep working without it.
}
}
function clampCommitPanelHeight(value: number): number { function clampCommitPanelHeight(value: number): number {
return Math.min(COMMIT_PANEL_MAX_HEIGHT, Math.max(COMMIT_PANEL_MIN_HEIGHT, Math.round(value))); return Math.min(COMMIT_PANEL_MAX_HEIGHT, Math.max(COMMIT_PANEL_MIN_HEIGHT, Math.round(value)));
} }
@@ -1046,6 +1165,72 @@
} }
} }
function clampLeftSidebarWidth(value: number): number {
return Math.min(LEFT_SIDEBAR_MAX_WIDTH, Math.max(LEFT_SIDEBAR_MIN_WIDTH, Math.round(value)));
}
function loadLeftSidebarWidth(): number {
try {
const stored = Number(localStorage.getItem(LEFT_SIDEBAR_WIDTH_KEY));
if (Number.isFinite(stored) && stored > 0) return clampLeftSidebarWidth(stored);
} catch {
// Fall through to the default below.
}
return LEFT_SIDEBAR_DEFAULT_WIDTH;
}
function persistLeftSidebarWidth(value: number) {
try {
localStorage.setItem(LEFT_SIDEBAR_WIDTH_KEY, String(value));
} catch {
// Local storage is best-effort only; resizing must keep working without it.
}
}
function clampLeftBranchPanelHeight(value: number): number {
return Math.min(LEFT_BRANCH_PANEL_MAX_HEIGHT, Math.max(LEFT_BRANCH_PANEL_MIN_HEIGHT, Math.round(value)));
}
function loadLeftBranchPanelHeight(): number {
try {
const stored = Number(localStorage.getItem(LEFT_BRANCH_PANEL_HEIGHT_KEY));
if (Number.isFinite(stored) && stored > 0) return clampLeftBranchPanelHeight(stored);
} catch {
// Fall through to the default below.
}
return LEFT_BRANCH_PANEL_DEFAULT_HEIGHT;
}
function persistLeftBranchPanelHeight(value: number) {
try {
localStorage.setItem(LEFT_BRANCH_PANEL_HEIGHT_KEY, String(value));
} catch {
// Local storage is best-effort only; resizing must keep working without it.
}
}
function clampLeftStashPanelHeight(value: number): number {
return Math.min(LEFT_STASH_PANEL_MAX_HEIGHT, Math.max(LEFT_STASH_PANEL_MIN_HEIGHT, Math.round(value)));
}
function loadLeftStashPanelHeight(): number {
try {
const stored = Number(localStorage.getItem(LEFT_STASH_PANEL_HEIGHT_KEY));
if (Number.isFinite(stored) && stored > 0) return clampLeftStashPanelHeight(stored);
} catch {
// Fall through to the default below.
}
return LEFT_STASH_PANEL_DEFAULT_HEIGHT;
}
function persistLeftStashPanelHeight(value: number) {
try {
localStorage.setItem(LEFT_STASH_PANEL_HEIGHT_KEY, String(value));
} catch {
// Local storage is best-effort only; resizing must keep working without it.
}
}
function clampHistoryAsideWidth(value: number): number { function clampHistoryAsideWidth(value: number): number {
return Math.min(HISTORY_ASIDE_MAX_WIDTH, Math.max(HISTORY_ASIDE_MIN_WIDTH, Math.round(value))); return Math.min(HISTORY_ASIDE_MAX_WIDTH, Math.max(HISTORY_ASIDE_MIN_WIDTH, Math.round(value)));
} }
@@ -1068,6 +1253,28 @@
} }
} }
function clampFileHistoryWidth(value: number): number {
return Math.min(FILE_HISTORY_MAX_WIDTH, Math.max(FILE_HISTORY_MIN_WIDTH, Math.round(value)));
}
function loadFileHistoryWidth(): number {
try {
const stored = Number(localStorage.getItem(FILE_HISTORY_WIDTH_KEY));
if (Number.isFinite(stored) && stored > 0) return clampFileHistoryWidth(stored);
} catch {
// Fall through to the default below.
}
return FILE_HISTORY_DEFAULT_WIDTH;
}
function persistFileHistoryWidth(value: number) {
try {
localStorage.setItem(FILE_HISTORY_WIDTH_KEY, String(value));
} catch {
// Local storage is best-effort only; resizing must keep working without it.
}
}
function startCommitPanelResize(event: PointerEvent) { function startCommitPanelResize(event: PointerEvent) {
event.preventDefault(); event.preventDefault();
resizingCommitPanel = true; resizingCommitPanel = true;
@@ -1096,6 +1303,131 @@
persistCommitPanelHeight(commitPanelHeight); persistCommitPanelHeight(commitPanelHeight);
} }
function startLeftSidebarResize(event: PointerEvent) {
event.preventDefault();
resizingLeftSidebar = true;
leftSidebarResizeStartX = event.clientX;
leftSidebarResizeStartWidth = leftSidebarWidth;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onLeftSidebarResizeMove(event: PointerEvent) {
if (!resizingLeftSidebar) return;
leftSidebarWidth = clampLeftSidebarWidth(leftSidebarResizeStartWidth + (event.clientX - leftSidebarResizeStartX));
}
function endLeftSidebarResize(event: PointerEvent) {
if (!resizingLeftSidebar) return;
resizingLeftSidebar = false;
persistLeftSidebarWidth(leftSidebarWidth);
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onLeftSidebarResizeKeydown(event: KeyboardEvent) {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
leftSidebarWidth = clampLeftSidebarWidth(leftSidebarWidth + (event.key === "ArrowRight" ? 20 : -20));
persistLeftSidebarWidth(leftSidebarWidth);
}
function buildLeftSidebarRows(branchCollapsed: boolean, stashCollapsed: boolean, explorerCollapsed: boolean): string {
const branchRow = branchCollapsed
? "auto"
: `minmax(${LEFT_BRANCH_PANEL_MIN_HEIGHT}px, var(--branch-panel-height, ${LEFT_BRANCH_PANEL_DEFAULT_HEIGHT}px))`;
const branchHandleRow = branchCollapsed ? "0" : "14px";
const stashRow = stashCollapsed
? "auto"
: `minmax(${LEFT_STASH_PANEL_MIN_HEIGHT}px, var(--stash-panel-height, ${LEFT_STASH_PANEL_DEFAULT_HEIGHT}px))`;
const stashHandleRow = explorerCollapsed || (stashCollapsed && branchCollapsed) ? "0" : "14px";
const explorerRow = explorerCollapsed ? "auto" : `minmax(${LEFT_EXPLORER_PANEL_MIN_HEIGHT}px, 1fr)`;
return `${branchRow} ${branchHandleRow} ${stashRow} ${stashHandleRow} ${explorerRow}`;
}
function startLeftBranchPanelResize(event: PointerEvent) {
if (branchPanelCollapsed) return;
event.preventDefault();
resizingLeftBranchPanel = true;
leftBranchResizeStartY = event.clientY;
leftBranchResizeStartHeight = leftBranchPanelHeight;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onLeftBranchPanelResizeMove(event: PointerEvent) {
if (!resizingLeftBranchPanel) return;
leftBranchPanelHeight = clampLeftBranchPanelHeight(leftBranchResizeStartHeight + (event.clientY - leftBranchResizeStartY));
}
function endLeftBranchPanelResize(event: PointerEvent) {
if (!resizingLeftBranchPanel) return;
resizingLeftBranchPanel = false;
persistLeftBranchPanelHeight(leftBranchPanelHeight);
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onLeftBranchPanelResizeKeydown(event: KeyboardEvent) {
if (branchPanelCollapsed || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
event.preventDefault();
leftBranchPanelHeight = clampLeftBranchPanelHeight(leftBranchPanelHeight + (event.key === "ArrowDown" ? 20 : -20));
persistLeftBranchPanelHeight(leftBranchPanelHeight);
}
function startLeftStashPanelResize(event: PointerEvent) {
if (stashPanelCollapsed && branchPanelCollapsed) return;
event.preventDefault();
resizingLeftStashPanel = true;
leftStashResizeStartY = event.clientY;
leftStashResizeStartHeight = stashPanelCollapsed ? leftBranchPanelHeight : leftStashPanelHeight;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onLeftStashPanelResizeMove(event: PointerEvent) {
if (!resizingLeftStashPanel) return;
if (stashPanelCollapsed) {
leftBranchPanelHeight = clampLeftBranchPanelHeight(leftStashResizeStartHeight + (event.clientY - leftStashResizeStartY));
} else {
leftStashPanelHeight = clampLeftStashPanelHeight(leftStashResizeStartHeight + (event.clientY - leftStashResizeStartY));
}
}
function endLeftStashPanelResize(event: PointerEvent) {
if (!resizingLeftStashPanel) return;
resizingLeftStashPanel = false;
if (stashPanelCollapsed) persistLeftBranchPanelHeight(leftBranchPanelHeight);
else persistLeftStashPanelHeight(leftStashPanelHeight);
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onLeftStashPanelResizeKeydown(event: KeyboardEvent) {
if ((stashPanelCollapsed && branchPanelCollapsed) || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
event.preventDefault();
if (stashPanelCollapsed) {
leftBranchPanelHeight = clampLeftBranchPanelHeight(leftBranchPanelHeight + (event.key === "ArrowDown" ? 20 : -20));
persistLeftBranchPanelHeight(leftBranchPanelHeight);
} else {
leftStashPanelHeight = clampLeftStashPanelHeight(leftStashPanelHeight + (event.key === "ArrowDown" ? 20 : -20));
persistLeftStashPanelHeight(leftStashPanelHeight);
}
}
function toggleBranchPanelCollapsed() {
branchPanelCollapsed = !branchPanelCollapsed;
persistStoredBoolean(BRANCH_PANEL_COLLAPSED_KEY, branchPanelCollapsed);
}
function toggleStashPanelCollapsed() {
stashPanelCollapsed = !stashPanelCollapsed;
persistStoredBoolean(STASH_PANEL_COLLAPSED_KEY, stashPanelCollapsed);
}
function toggleExplorerPanelCollapsed() {
explorerPanelCollapsed = !explorerPanelCollapsed;
persistStoredBoolean(EXPLORER_PANEL_COLLAPSED_KEY, explorerPanelCollapsed);
}
function startHistoryAsideResize(event: PointerEvent) { function startHistoryAsideResize(event: PointerEvent) {
event.preventDefault(); event.preventDefault();
resizingHistoryAside = true; resizingHistoryAside = true;
@@ -1124,6 +1456,43 @@
persistHistoryAsideWidth(historyAsideWidth); persistHistoryAsideWidth(historyAsideWidth);
} }
function startFileHistoryResize(event: PointerEvent) {
if (fileHistoryCollapsed) return;
event.preventDefault();
resizingFileHistory = true;
fileHistoryResizeStartX = event.clientX;
fileHistoryResizeStartWidth = fileHistoryWidth;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onFileHistoryResizeMove(event: PointerEvent) {
if (!resizingFileHistory) return;
fileHistoryWidth = clampFileHistoryWidth(fileHistoryResizeStartWidth + (fileHistoryResizeStartX - event.clientX));
}
function endFileHistoryResize(event: PointerEvent) {
if (!resizingFileHistory) return;
resizingFileHistory = false;
persistFileHistoryWidth(fileHistoryWidth);
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onFileHistoryResizeKeydown(event: KeyboardEvent) {
if (fileHistoryCollapsed || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) return;
event.preventDefault();
fileHistoryWidth = clampFileHistoryWidth(fileHistoryWidth + (event.key === "ArrowLeft" ? 20 : -20));
persistFileHistoryWidth(fileHistoryWidth);
}
function toggleFileHistoryCollapsed() {
fileHistoryCollapsed = !fileHistoryCollapsed;
}
function revealFileHistory() {
fileHistoryCollapsed = false;
}
function rememberRecentRepo(path: string) { function rememberRecentRepo(path: string) {
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40); recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
persistRepoLists(); persistRepoLists();
@@ -1318,6 +1687,16 @@
} }
} }
async function refreshRefsAndCommitGraph(path = activeRepoPath) {
const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(path);
await refreshTags(path);
await refreshCommitHistory(path);
if (lastFileHistoryHeadHash !== previousHeadHash) {
await refreshFileHistory(path);
}
}
function isCancellationMessage(message: string): boolean { function isCancellationMessage(message: string): boolean {
return message.toLowerCase().includes("cancelled"); return message.toLowerCase().includes("cancelled");
} }
@@ -1615,6 +1994,21 @@
if (!activeStillOpen) await openRepo(path); if (!activeStillOpen) await openRepo(path);
} }
async function removeRepoFromRecent(path: string, event?: MouseEvent) {
event?.stopPropagation();
if (isBusy) return;
recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
persistRepoLists();
trackEvent("repository_removed_from_recent", {
recent_repositories: recentRepoPaths.length,
});
if (!repoTabs.some((tab) => sameRepoPath(tab.path, path)) && !isFavoriteRepo(path) && repoStatusCache[repoKey(path)]) {
const { [repoKey(path)]: _removed, ...rest } = repoStatusCache;
repoStatusCache = rest;
persistRepoStatusCache();
}
}
async function removeRepoFromManagement(path: string, event?: MouseEvent) { async function removeRepoFromManagement(path: string, event?: MouseEvent) {
event?.stopPropagation(); event?.stopPropagation();
if (isBusy) return; if (isBusy) return;
@@ -2018,6 +2412,7 @@
errorMessage = ""; errorMessage = "";
await runOperation("Fetching", async () => { await runOperation("Fetching", async () => {
applyStatus(await fetchRemote(activeRepoPath, username, password)); applyStatus(await fetchRemote(activeRepoPath, username, password));
await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("repository_fetched", { trackEvent("repository_fetched", {
from_stored_credential: fromStore ? 1 : 0, from_stored_credential: fromStore ? 1 : 0,
ahead: status?.ahead ?? 0, ahead: status?.ahead ?? 0,
@@ -2210,46 +2605,87 @@
// ── File staging / restore ───────────────────────────────────────────────── // ── File staging / restore ─────────────────────────────────────────────────
async function stageFile(file: GitFileStatus) { async function stageFile(files: GitFileStatus[]) {
await runOperation(`Staging ${file.path}`, async () => { const targets = files.filter((file) => file.unstaged !== null);
applyStatus(await stageFiles(activeRepoPath, [file.path])); if (targets.length === 0) return;
const paths = targets.map((file) => file.path);
await runOperation(targets.length === 1 ? `Staging ${baseName(targets[0].path)}` : `Staging ${targets.length} files`, async () => {
applyStatus(await stageFiles(activeRepoPath, paths));
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
trackEvent("file_staged", { trackEvent("file_staged", {
status: file.unstaged ?? file.staged ?? "unknown", files: targets.length,
status: targets.length === 1 ? (targets[0].unstaged ?? targets[0].staged ?? "unknown") : "multiple",
}); });
}); });
} }
async function unstageFile(file: GitFileStatus) { async function unstageFile(files: GitFileStatus[]) {
await runOperation(`Unstaging ${file.path}`, async () => { const targets = files.filter((file) => file.staged !== null);
applyStatus(await unstageFiles(activeRepoPath, [file.path])); if (targets.length === 0) return;
const paths = targets.map((file) => file.path);
await runOperation(targets.length === 1 ? `Unstaging ${baseName(targets[0].path)}` : `Unstaging ${targets.length} files`, async () => {
applyStatus(await unstageFiles(activeRepoPath, paths));
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
trackEvent("file_unstaged", { trackEvent("file_unstaged", {
status: file.staged ?? file.unstaged ?? "unknown", files: targets.length,
status: targets.length === 1 ? (targets[0].staged ?? targets[0].unstaged ?? "unknown") : "multiple",
}); });
}); });
} }
function discardFile(file: GitFileStatus, staged: boolean) { function discardFiles(files: GitFileStatus[], staged: boolean) {
if (!activeRepoPath || isBusy) return; if (!activeRepoPath || isBusy || files.length === 0) return;
pendingDiscard = { kind: "file", file, staged }; pendingDiscard = { kind: "file", files, staged };
trackEvent("discard_confirm_opened", { trackEvent("discard_confirm_opened", {
kind: "file", kind: "file",
staged: staged ? 1 : 0, staged: staged ? 1 : 0,
files: files.length,
}); });
} }
async function runDiscardFile(file: GitFileStatus, staged: boolean) { function discardChanges(files: GitFileStatus[]) {
await runOperation(`Discarding ${file.path}`, async () => { if (!activeRepoPath || isBusy || files.length === 0) return;
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged)); pendingDiscard = { kind: "all-changes", files };
trackEvent("discard_confirm_opened", {
kind: "all",
files: files.length,
});
}
async function runDiscardFiles(files: GitFileStatus[], staged: boolean) {
if (files.length === 0) return;
const paths = files.map((file) => file.path);
await runOperation(files.length === 1 ? `Discarding ${baseName(files[0].path)}` : `Discarding ${files.length} files`, async () => {
applyStatus(await restoreFiles(activeRepoPath, paths, staged));
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath); await refreshFileHistory(activeRepoPath);
trackEvent("file_discarded", { trackEvent("file_discarded", {
files: files.length,
staged: staged ? 1 : 0, staged: staged ? 1 : 0,
}); });
}); });
} }
// Discards both the staged and unstaged changes for each given file (used
// by "Discard all" and "Discard selected", which don't distinguish lanes).
async function runDiscardAllChanges(files: GitFileStatus[]) {
const stagedPaths = files.filter((file) => file.staged !== null).map((file) => file.path);
const unstagedPaths = files.filter((file) => file.unstaged !== null).map((file) => file.path);
if (stagedPaths.length === 0 && unstagedPaths.length === 0) return;
await runOperation(files.length === 1 ? `Discarding ${baseName(files[0].path)}` : `Discarding ${files.length} files`, async () => {
let nextStatus: GitStatus | null = null;
if (stagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, stagedPaths, true);
if (unstagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, unstagedPaths, false);
if (nextStatus) applyStatus(nextStatus);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("file_discarded", {
files: files.length,
staged: 2,
});
});
}
async function openLinePatch(file: GitFileStatus, staged: boolean) { async function openLinePatch(file: GitFileStatus, staged: boolean) {
if (!activeRepoPath) return; if (!activeRepoPath) return;
linePatchOpen = true; linePatchOpen = true;
@@ -2385,7 +2821,9 @@
if (!discard || !activeRepoPath || isBusy) return; if (!discard || !activeRepoPath || isBusy) return;
if (discard.kind === "file") { if (discard.kind === "file") {
await runDiscardFile(discard.file, discard.staged); await runDiscardFiles(discard.files, discard.staged);
} else if (discard.kind === "all-changes") {
await runDiscardAllChanges(discard.files);
} else { } else {
await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged); await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged);
} }
@@ -2608,6 +3046,7 @@
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return; if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
selectedExplorerPath = node.path; selectedExplorerPath = node.path;
selectedExplorerKind = node.kind; selectedExplorerKind = node.kind;
revealFileHistory();
void loadSelectedFileHistory(node.path); void loadSelectedFileHistory(node.path);
trackEvent("explorer_node_selected", { trackEvent("explorer_node_selected", {
kind: node.kind, kind: node.kind,
@@ -2620,6 +3059,7 @@
selectedExplorerPath = file.path; selectedExplorerPath = file.path;
selectedExplorerKind = "file"; selectedExplorerKind = "file";
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]); expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
revealFileHistory();
void loadSelectedFileHistory(file.path); void loadSelectedFileHistory(file.path);
trackEvent("explorer_file_selected", { trackEvent("explorer_file_selected", {
@@ -2633,6 +3073,7 @@
selectedExplorerPath = file.path; selectedExplorerPath = file.path;
selectedExplorerKind = "file"; selectedExplorerKind = "file";
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]); expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
revealFileHistory();
void loadSelectedFileHistory(file.path); void loadSelectedFileHistory(file.path);
trackEvent("explorer_file_selected", { trackEvent("explorer_file_selected", {
@@ -2645,6 +3086,7 @@
if (!activeRepoPath || node.kind !== "file") return; if (!activeRepoPath || node.kind !== "file") return;
selectedExplorerPath = node.path; selectedExplorerPath = node.path;
selectedExplorerKind = "file"; selectedExplorerKind = "file";
revealFileHistory();
void loadSelectedFileHistory(node.path); void loadSelectedFileHistory(node.path);
try { try {
@@ -3020,7 +3462,7 @@
</section> </section>
{/if} {/if}
{#if operation && operation !== "Opening repository"} {#if operation && operation !== "Opening repository" && !hasRepository}
<section class="notice busy" aria-live="polite"> <section class="notice busy" aria-live="polite">
<LoaderCircle class="spin" size={17} aria-hidden="true" /> <LoaderCircle class="spin" size={17} aria-hidden="true" />
<span>{operation}</span> <span>{operation}</span>
@@ -3179,7 +3621,7 @@
> >
<Star size={14} aria-hidden="true" /> <Star size={14} aria-hidden="true" />
</button> </button>
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}> <button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromRecent(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
<X size={14} aria-hidden="true" /> <X size={14} aria-hidden="true" />
</button> </button>
</div> </div>
@@ -3231,10 +3673,22 @@
</section> </section>
{:else} {:else}
<!-- Workspace --> <!-- Workspace -->
<section class="workspace" aria-label="Git workspace" style="--history-aside-width: {historyAsideWidth}px;"> <section
class="workspace"
aria-label="Git workspace"
style="--left-sidebar-width: {leftSidebarWidth}px; --history-aside-width: {historyAsideWidth}px; --file-history-width: {fileHistoryWidth}px;"
>
<!-- Left sidebar: branches + explorer --> <!-- Left sidebar: branches + explorer -->
<aside class="left-sidebar" aria-label="Repository navigation"> <aside
class="left-sidebar"
class:branch-collapsed={branchPanelCollapsed}
class:stash-collapsed={stashPanelCollapsed}
class:explorer-collapsed={explorerPanelCollapsed}
class:all-collapsed={allLeftPanelsCollapsed}
aria-label="Repository navigation"
style="--branch-panel-height: {leftBranchPanelHeight}px; --stash-panel-height: {leftStashPanelHeight}px; grid-template-rows: {leftSidebarRows};"
>
<BranchPanel <BranchPanel
{branches} {branches}
{localBranches} {localBranches}
@@ -3251,7 +3705,31 @@
onCreateTag={createNewTag} onCreateTag={createNewTag}
onDeleteTag={deleteLocalTag} onDeleteTag={deleteLocalTag}
onPushTag={pushLocalTag} onPushTag={pushLocalTag}
collapsed={branchPanelCollapsed}
onToggleCollapsed={toggleBranchPanelCollapsed}
/> />
{#if !branchPanelCollapsed}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="panel-resize-handle left-panel-resize-handle branch-panel-resize-handle"
class:resizing={resizingLeftBranchPanel}
role="separator"
aria-orientation="horizontal"
aria-label="Resize branches panel height"
aria-valuenow={leftBranchPanelHeight}
aria-valuemin={LEFT_BRANCH_PANEL_MIN_HEIGHT}
aria-valuemax={LEFT_BRANCH_PANEL_MAX_HEIGHT}
tabindex="0"
onpointerdown={startLeftBranchPanelResize}
onpointermove={onLeftBranchPanelResizeMove}
onpointerup={endLeftBranchPanelResize}
onpointercancel={endLeftBranchPanelResize}
onkeydown={onLeftBranchPanelResizeKeydown}
></div>
{:else}
<span class="left-panel-resize-placeholder" aria-hidden="true"></span>
{/if}
<StashPanel <StashPanel
{stashes} {stashes}
changedCount={changedFiles.length} changedCount={changedFiles.length}
@@ -3261,7 +3739,31 @@
onApply={applyStashEntry} onApply={applyStashEntry}
onPop={popStashEntry} onPop={popStashEntry}
onDrop={dropStashEntry} onDrop={dropStashEntry}
collapsed={stashPanelCollapsed}
onToggleCollapsed={toggleStashPanelCollapsed}
/> />
{#if !explorerPanelCollapsed && (!stashPanelCollapsed || !branchPanelCollapsed)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="panel-resize-handle left-panel-resize-handle stash-panel-resize-handle"
class:resizing={resizingLeftStashPanel}
role="separator"
aria-orientation="horizontal"
aria-label={stashPanelCollapsed ? "Resize files panel space" : "Resize stash and explorer panels"}
aria-valuenow={stashPanelCollapsed ? leftBranchPanelHeight : leftStashPanelHeight}
aria-valuemin={stashPanelCollapsed ? LEFT_BRANCH_PANEL_MIN_HEIGHT : LEFT_STASH_PANEL_MIN_HEIGHT}
aria-valuemax={stashPanelCollapsed ? LEFT_BRANCH_PANEL_MAX_HEIGHT : LEFT_STASH_PANEL_MAX_HEIGHT}
tabindex="0"
onpointerdown={startLeftStashPanelResize}
onpointermove={onLeftStashPanelResizeMove}
onpointerup={endLeftStashPanelResize}
onpointercancel={endLeftStashPanelResize}
onkeydown={onLeftStashPanelResizeKeydown}
></div>
{:else}
<span class="left-panel-resize-placeholder" aria-hidden="true"></span>
{/if}
<ExplorerPanel <ExplorerPanel
{repoFiles} {repoFiles}
{expandedExplorerPaths} {expandedExplorerPaths}
@@ -3275,8 +3777,28 @@
onSelectNode={selectExplorerNode} onSelectNode={selectExplorerNode}
onOpenFile={openFileFromExplorer} onOpenFile={openFileFromExplorer}
onBlame={openBlame} onBlame={openBlame}
collapsed={explorerPanelCollapsed}
onToggleCollapsed={toggleExplorerPanelCollapsed}
/> />
</aside> </aside>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="left-sidebar-resize-handle"
class:resizing={resizingLeftSidebar}
role="separator"
aria-orientation="vertical"
aria-label="Resize navigation sidebar width"
aria-valuenow={leftSidebarWidth}
aria-valuemin={LEFT_SIDEBAR_MIN_WIDTH}
aria-valuemax={LEFT_SIDEBAR_MAX_WIDTH}
tabindex="0"
onpointerdown={startLeftSidebarResize}
onpointermove={onLeftSidebarResizeMove}
onpointerup={endLeftSidebarResize}
onpointercancel={endLeftSidebarResize}
onkeydown={onLeftSidebarResizeKeydown}
></div>
<!-- Center: summary + status + commit --> <!-- Center: summary + status + commit -->
<section class="main-panel" aria-label="Repository status"> <section class="main-panel" aria-label="Repository status">
@@ -3302,12 +3824,14 @@
{unstagedCount} {unstagedCount}
{hasRepository} {hasRepository}
{isBusy} {isBusy}
{operation}
{status} {status}
selectedFilePath={selectedExplorerPath} selectedFilePath={selectedExplorerPath}
onSelectFile={selectFileFromStatus} onSelectFile={selectFileFromStatus}
onStage={stageFile} onStage={stageFile}
onUnstage={unstageFile} onUnstage={unstageFile}
onDiscard={discardFile} onDiscard={discardFiles}
onDiscardMany={discardChanges}
onPatch={openLinePatch} onPatch={openLinePatch}
onStageAll={stageAllFiles} onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles} onUnstageAll={unstageAllFiles}
@@ -3353,26 +3877,27 @@
</div> </div>
</section> </section>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="history-resize-handle"
class:resizing={resizingHistoryAside}
role="separator"
aria-orientation="vertical"
aria-label="Resize commit history width"
aria-valuenow={historyAsideWidth}
aria-valuemin={HISTORY_ASIDE_MIN_WIDTH}
aria-valuemax={HISTORY_ASIDE_MAX_WIDTH}
tabindex="0"
onpointerdown={startHistoryAsideResize}
onpointermove={onHistoryAsideResizeMove}
onpointerup={endHistoryAsideResize}
onpointercancel={endHistoryAsideResize}
onkeydown={onHistoryAsideResizeKeydown}
></div>
<!-- Right sidebar: commit graph + file history --> <!-- Right sidebar: commit graph + file history -->
<aside class="history-aside" aria-label="Commit history"> <aside class="history-aside" class:file-history-collapsed={fileHistoryCollapsed} aria-label="Commit history">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="history-resize-handle"
class:resizing={resizingHistoryAside}
role="separator"
aria-orientation="vertical"
aria-label="Resize history panel width"
aria-valuenow={historyAsideWidth}
aria-valuemin={HISTORY_ASIDE_MIN_WIDTH}
aria-valuemax={HISTORY_ASIDE_MAX_WIDTH}
tabindex="0"
onpointerdown={startHistoryAsideResize}
onpointermove={onHistoryAsideResizeMove}
onpointerup={endHistoryAsideResize}
onpointercancel={endHistoryAsideResize}
onkeydown={onHistoryAsideResizeKeydown}
></div>
<HistoryPanel <HistoryPanel
{commits} {commits}
{localBranchNames} {localBranchNames}
@@ -3392,6 +3917,24 @@
expandedCommitHashes = next; expandedCommitHashes = next;
}} }}
/> />
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="file-history-resize-handle"
class:resizing={resizingFileHistory}
role="separator"
aria-orientation="vertical"
aria-label="Resize file history width"
aria-valuenow={fileHistoryWidth}
aria-valuemin={FILE_HISTORY_MIN_WIDTH}
aria-valuemax={FILE_HISTORY_MAX_WIDTH}
tabindex={fileHistoryCollapsed ? -1 : 0}
onpointerdown={startFileHistoryResize}
onpointermove={onFileHistoryResizeMove}
onpointerup={endFileHistoryResize}
onpointercancel={endFileHistoryResize}
onkeydown={onFileHistoryResizeKeydown}
></div>
<FileHistoryPanel <FileHistoryPanel
{fileHistory} {fileHistory}
{selectedExplorerPath} {selectedExplorerPath}
@@ -3401,6 +3944,8 @@
isLoading={fileHistoryLoading} isLoading={fileHistoryLoading}
onDiff={diffSelectedFileFromCommit} onDiff={diffSelectedFileFromCommit}
onRestore={restoreSelectedFileFromCommit} onRestore={restoreSelectedFileFromCommit}
collapsed={fileHistoryCollapsed}
onToggleCollapsed={toggleFileHistoryCollapsed}
/> />
</aside> </aside>
</section> </section>
@@ -3431,6 +3976,7 @@
{#if appSettingsOpen} {#if appSettingsOpen}
<AppSettingsDialog <AppSettingsDialog
analytics={analyticsSettings} analytics={analyticsSettings}
theme={appTheme}
onSave={saveAppSettings} onSave={saveAppSettings}
onClose={() => { appSettingsOpen = false; }} onClose={() => { appSettingsOpen = false; }}
/> />
@@ -3463,8 +4009,8 @@
{#if pendingDiscard} {#if pendingDiscard}
<DiscardConfirmDialog <DiscardConfirmDialog
file={pendingDiscard.file} files={pendingDiscard.kind === "hunk" ? [pendingDiscard.file] : pendingDiscard.files}
staged={pendingDiscard.staged} staged={pendingDiscard.kind === "all-changes" ? null : pendingDiscard.staged}
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"} scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
{isBusy} {isBusy}
onConfirm={confirmDiscard} onConfirm={confirmDiscard}
+823 -128
View File
File diff suppressed because it is too large Load Diff
+30 -8
View File
@@ -24,22 +24,44 @@
export let onToggleAutoRefresh: () => void = () => {}; export let onToggleAutoRefresh: () => void = () => {};
export let onOpenSettings: () => void = () => {}; export let onOpenSettings: () => void = () => {};
const win = getCurrentWindow(); let win: ReturnType<typeof getCurrentWindow> | null = null;
let isMaximized = false; let isMaximized = false;
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
let appVersion = ""; let appVersion = "";
onMount(async () => { onMount(async () => {
isMaximized = await win.isMaximized(); try {
unlisten = await win.onResized(async () => { win = getCurrentWindow();
isMaximized = await win.isMaximized(); isMaximized = await win.isMaximized();
}); unlisten = await win.onResized(async () => {
appVersion = await getVersion(); isMaximized = await win?.isMaximized() ?? false;
});
} catch {
win = null;
}
try {
appVersion = await getVersion();
} catch {
appVersion = "";
}
}); });
onDestroy(() => { onDestroy(() => {
unlisten?.(); unlisten?.();
}); });
function minimizeWindow() {
void win?.minimize();
}
function toggleMaximizeWindow() {
void win?.toggleMaximize();
}
function closeWindow() {
void win?.close();
}
</script> </script>
<header class="titlebar" data-tauri-drag-region> <header class="titlebar" data-tauri-drag-region>
@@ -200,12 +222,12 @@
<div class="titlebar-divider" aria-hidden="true"></div> <div class="titlebar-divider" aria-hidden="true"></div>
<div class="titlebar-controls"> <div class="titlebar-controls">
<button class="tb-btn" onclick={() => win.minimize()} title="Minimize" aria-label="Minimize"> <button class="tb-btn" onclick={minimizeWindow} title="Minimize" aria-label="Minimize">
<Minus size={12} aria-hidden="true" /> <Minus size={12} aria-hidden="true" />
</button> </button>
<button <button
class="tb-btn" class="tb-btn"
onclick={() => win.toggleMaximize()} onclick={toggleMaximizeWindow}
title={isMaximized ? "Restore" : "Maximize"} title={isMaximized ? "Restore" : "Maximize"}
aria-label={isMaximized ? "Restore" : "Maximize"} aria-label={isMaximized ? "Restore" : "Maximize"}
> >
@@ -220,7 +242,7 @@
</svg> </svg>
{/if} {/if}
</button> </button>
<button class="tb-btn close" onclick={() => win.close()} title="Close" aria-label="Close"> <button class="tb-btn close" onclick={closeWindow} title="Close" aria-label="Close">
<X size={12} aria-hidden="true" /> <X size={12} aria-hidden="true" />
</button> </button>
</div> </div>
+32 -4
View File
@@ -1,19 +1,22 @@
<script lang="ts"> <script lang="ts">
import { Check, Settings, X } from "@lucide/svelte"; import { Check, Settings, X } from "@lucide/svelte";
import type { AnalyticsSettings } from "../types"; import type { AnalyticsSettings, AppTheme } from "../types";
interface Props { interface Props {
analytics: AnalyticsSettings; analytics: AnalyticsSettings;
onSave: (settings: AnalyticsSettings) => void; theme: AppTheme;
onSave: (settings: AnalyticsSettings, theme: AppTheme) => void;
onClose: () => void; onClose: () => void;
} }
let { analytics, onSave = () => {}, onClose = () => {} }: Props = $props(); let { analytics, theme = "system", onSave = () => {}, onClose = () => {} }: Props = $props();
let analyticsEnabled = $state(true); let analyticsEnabled = $state(true);
let selectedTheme = $state<AppTheme>("system");
$effect(() => { $effect(() => {
analyticsEnabled = analytics.enabled; analyticsEnabled = analytics.enabled;
selectedTheme = theme;
}); });
function save() { function save() {
@@ -21,7 +24,7 @@
...analytics, ...analytics,
enabled: analyticsEnabled, enabled: analyticsEnabled,
noticeSeen: true, noticeSeen: true,
}); }, selectedTheme);
} }
</script> </script>
@@ -38,6 +41,31 @@
</header> </header>
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}> <form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}>
<section class="settings-section">
<header>
<Settings size={16} aria-hidden="true" />
<div>
<span class="eyebrow">Appearance</span>
<h3>Theme</h3>
</div>
</header>
<div class="settings-segmented" role="radiogroup" aria-label="Theme">
<label class:active={selectedTheme === "system"}>
<input type="radio" bind:group={selectedTheme} value="system" />
<span>System</span>
</label>
<label class:active={selectedTheme === "light"}>
<input type="radio" bind:group={selectedTheme} value="light" />
<span>Light</span>
</label>
<label class:active={selectedTheme === "dark"}>
<input type="radio" bind:group={selectedTheme} value="dark" />
<span>Dark</span>
</label>
</div>
</section>
<section class="settings-section"> <section class="settings-section">
<header> <header>
<Settings size={16} aria-hidden="true" /> <Settings size={16} aria-hidden="true" />
+22 -2
View File
@@ -57,6 +57,8 @@
onCreateTag: (name: string, message: string) => void | Promise<void>; onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>; onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>; onPushTag: (tag: GitTag) => void | Promise<void>;
collapsed?: boolean;
onToggleCollapsed?: () => void;
} }
let { let {
@@ -75,6 +77,8 @@
onCreateTag = () => {}, onCreateTag = () => {},
onDeleteTag = () => {}, onDeleteTag = () => {},
onPushTag = () => {}, onPushTag = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props(); }: Props = $props();
let localOpen = $state(true); let localOpen = $state(true);
@@ -355,7 +359,7 @@
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} /> <svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches"> <section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
<div class="section-head"> <div class="section-head">
<div> <div>
<span class="eyebrow">Branches</span> <span class="eyebrow">Branches</span>
@@ -373,10 +377,26 @@
<Plus size={14} aria-hidden="true" /> <Plus size={14} aria-hidden="true" />
</button> </button>
<span class="pill pill-count">{branches.length}</span> <span class="pill pill-count">{branches.length}</span>
<button
class="branch-create-toggle panel-collapse-toggle"
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title={collapsed ? "Expand branches" : "Collapse branches"}
aria-label={collapsed ? "Expand branches panel" : "Collapse branches panel"}
>
{#if collapsed}
<ChevronRight size={14} aria-hidden="true" />
{:else}
<ChevronDown size={14} aria-hidden="true" />
{/if}
</button>
</div> </div>
</div> </div>
{#if !hasRepository} {#if collapsed}
<!-- collapsed -->
{:else if !hasRepository}
<p class="blank-state">Open a repository to list branches.</p> <p class="blank-state">Open a repository to list branches.</p>
{:else if branches.length === 0 && tags.length === 0} {:else if branches.length === 0 && tags.length === 0}
<p class="blank-state">No branches returned.</p> <p class="blank-state">No branches returned.</p>
+27 -10
View File
@@ -3,8 +3,8 @@
import type { GitFileStatus } from "../types"; import type { GitFileStatus } from "../types";
interface Props { interface Props {
file: GitFileStatus; files: GitFileStatus[];
staged: boolean; staged: boolean | null;
scope: "file" | "hunk"; scope: "file" | "hunk";
isBusy: boolean; isBusy: boolean;
onConfirm: () => void | Promise<void>; onConfirm: () => void | Promise<void>;
@@ -12,7 +12,7 @@
} }
let { let {
file, files,
staged = false, staged = false,
scope = "file", scope = "file",
isBusy = false, isBusy = false,
@@ -20,10 +20,16 @@
onClose = () => {}, onClose = () => {},
}: Props = $props(); }: Props = $props();
let targetPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path); function targetPath(file: GitFileStatus): string {
let title = $derived(scope === "hunk" ? "Discard hunk?" : "Discard file changes?"); return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes"); }
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
let count = $derived(files.length);
let title = $derived(
scope === "hunk" ? "Discard hunk?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
);
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : count > 1 ? `${count} files` : "file");
let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
</script> </script>
@@ -46,11 +52,22 @@
<div class="discard-confirm-copy"> <div class="discard-confirm-copy">
<p> <p>
This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below. This will reset the {sourceLabel} for the {scopeLabel} below.
</p> </p>
<code class="discard-target" title={targetPath}>{targetPath}</code> {#if count > 1}
<ul class="discard-target-list">
{#each files.slice(0, 8) as file (`${file.old_path ?? ""}:${file.path}`)}
<li><code class="discard-target" title={targetPath(file)}>{targetPath(file)}</code></li>
{/each}
{#if files.length > 8}
<li class="discard-target-more">+{files.length - 8} more</li>
{/if}
</ul>
{:else if count === 1}
<code class="discard-target" title={targetPath(files[0])}>{targetPath(files[0])}</code>
{/if}
<p class="discard-warning-text"> <p class="discard-warning-text">
This cannot be undone. If the file only exists in your working tree, it can be deleted entirely. This cannot be undone. If a file only exists in your working tree, it can be deleted entirely.
</p> </p>
</div> </div>
</div> </div>
+22 -2
View File
@@ -38,6 +38,8 @@
onSelectNode: (node: ExplorerNode) => void; onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void; onOpenFile: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void; onBlame: (node: ExplorerNode) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
} }
let { let {
@@ -53,6 +55,8 @@
onSelectNode = () => {}, onSelectNode = () => {},
onOpenFile = () => {}, onOpenFile = () => {},
onBlame = () => {}, onBlame = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props(); }: Props = $props();
let contextNode = $state<ExplorerNode | null>(null); let contextNode = $state<ExplorerNode | null>(null);
@@ -203,7 +207,7 @@
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} /> <svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
<section class="panel explorer-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="File explorer"> <section class="panel explorer-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="File explorer">
<div class="section-head"> <div class="section-head">
<div> <div>
<span class="eyebrow">Explorer</span> <span class="eyebrow">Explorer</span>
@@ -231,10 +235,26 @@
<Folder size={14} aria-hidden="true" /> <Folder size={14} aria-hidden="true" />
</button> </button>
<span class="pill pill-count">{repoFiles.length}</span> <span class="pill pill-count">{repoFiles.length}</span>
<button
class="explorer-bulk-button panel-collapse-toggle"
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title={collapsed ? "Expand file explorer" : "Collapse file explorer"}
aria-label={collapsed ? "Expand file explorer panel" : "Collapse file explorer panel"}
>
{#if collapsed}
<ChevronRight size={14} aria-hidden="true" />
{:else}
<ChevronDown size={14} aria-hidden="true" />
{/if}
</button>
</div> </div>
</div> </div>
{#if !hasRepository} {#if collapsed}
<!-- collapsed -->
{:else if !hasRepository}
<p class="blank-state">Open a repository to browse files.</p> <p class="blank-state">Open a repository to browse files.</p>
{:else if repoFiles.length === 0} {:else if repoFiles.length === 0}
<p class="blank-state">No files returned.</p> <p class="blank-state">No files returned.</p>
+36 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { GitCompare, History, RotateCcw } from "@lucide/svelte"; import { ChevronLeft, ChevronRight, GitCompare, History, RotateCcw } from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png"; import iconUrl from "../../../src-tauri/icons/icon.png";
import type { GitCommit } from "../types"; import type { GitCommit } from "../types";
@@ -12,6 +12,8 @@
isLoading?: boolean; isLoading?: boolean;
onDiff: (commit: GitCommit) => void; onDiff: (commit: GitCommit) => void;
onRestore: (commit: GitCommit) => void; onRestore: (commit: GitCommit) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
} }
let { let {
@@ -23,6 +25,8 @@
isLoading = false, isLoading = false,
onDiff = () => {}, onDiff = () => {},
onRestore = () => {}, onRestore = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props(); }: Props = $props();
function formatCommitDate(value: string): string { function formatCommitDate(value: string): string {
@@ -76,7 +80,23 @@
} }
</script> </script>
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Selected file history"> <section class="panel file-history-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Selected file history">
{#if collapsed}
<button
class="file-history-rail-toggle"
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title="Expand file history"
aria-label="Expand file history panel"
>
<ChevronLeft size={16} aria-hidden="true" />
<span>File history</span>
{#if selectedExplorerPath}
<small>{fileHistory.length}</small>
{/if}
</button>
{:else}
<div class="section-head file-history-head"> <div class="section-head file-history-head">
<div class="file-history-heading"> <div class="file-history-heading">
<span class="eyebrow">{selectedExplorerLabel}</span> <span class="eyebrow">{selectedExplorerLabel}</span>
@@ -85,7 +105,19 @@
use:pathTooltip={selectedExplorerPath} use:pathTooltip={selectedExplorerPath}
>{selectedExplorerPath ? fileName(selectedExplorerPath) : "No file"}</h2> >{selectedExplorerPath ? fileName(selectedExplorerPath) : "No file"}</h2>
</div> </div>
<span class="pill pill-count file-history-count">{fileHistory.length}</span> <div class="file-history-head-actions">
<span class="pill pill-count file-history-count">{fileHistory.length}</span>
<button
class="file-history-toggle panel-collapse-toggle"
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title="Collapse file history"
aria-label="Collapse file history panel"
>
<ChevronRight size={14} aria-hidden="true" />
</button>
</div>
</div> </div>
{#if !hasRepository} {#if !hasRepository}
@@ -136,4 +168,5 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{/if}
</section> </section>
+58 -19
View File
@@ -41,9 +41,7 @@
display: grid; display: grid;
place-items: center; place-items: center;
overflow: hidden; overflow: hidden;
background: background: linear-gradient(135deg, #070a10 0%, #0c111a 48%, #090e16 100%);
radial-gradient(circle at 50% 42%, rgba(137, 92, 255, 0.18), transparent 34%),
linear-gradient(135deg, #050712 0%, #080b16 46%, #120b18 100%);
animation: overlay-in 180ms ease; animation: overlay-in 180ms ease;
} }
@@ -52,8 +50,8 @@
inset: 0; inset: 0;
opacity: 0.28; opacity: 0.28;
background-image: background-image:
linear-gradient(rgba(151, 118, 255, 0.12) 1px, transparent 1px), linear-gradient(rgba(111, 140, 255, 0.09) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 109, 38, 0.08) 1px, transparent 1px); linear-gradient(90deg, rgba(77, 182, 214, 0.07) 1px, transparent 1px);
background-size: 42px 42px; background-size: 42px 42px;
mask-image: radial-gradient(circle at center, black 0%, transparent 68%); mask-image: radial-gradient(circle at center, black 0%, transparent 68%);
animation: grid-drift 10s linear infinite; animation: grid-drift 10s linear infinite;
@@ -67,14 +65,14 @@
gap: 18px; gap: 18px;
width: min(420px, calc(100vw - 42px)); width: min(420px, calc(100vw - 42px));
padding: 30px 34px 28px; padding: 30px 34px 28px;
border: 1px solid rgba(160, 124, 255, 0.24); border: 1px solid rgba(90, 111, 154, 0.28);
border-radius: 16px; border-radius: 16px;
background: background:
linear-gradient(180deg, rgba(23, 26, 43, 0.9), rgba(12, 15, 27, 0.92)), linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)),
var(--color-surface-raised); var(--color-surface-raised);
box-shadow: box-shadow:
0 26px 90px rgba(0, 0, 0, 0.62), 0 26px 78px rgba(0, 0, 0, 0.54),
inset 0 1px 0 rgba(255, 255, 255, 0.06); inset 0 1px 0 rgba(255, 255, 255, 0.05);
} }
.repo-loading-mark { .repo-loading-mark {
@@ -89,7 +87,7 @@
.repo-loading-halo { .repo-loading-halo {
position: absolute; position: absolute;
inset: 10px; inset: 10px;
border: 1px solid rgba(151, 118, 255, 0.24); border: 1px solid rgba(111, 140, 255, 0.22);
border-radius: 34px; border-radius: 34px;
transform: rotate(45deg); transform: rotate(45deg);
} }
@@ -98,7 +96,7 @@
} }
.repo-loading-halo.halo-two { .repo-loading-halo.halo-two {
inset: 24px; inset: 24px;
border-color: rgba(255, 109, 38, 0.26); border-color: rgba(77, 182, 214, 0.24);
animation: halo-breathe 2.4s ease-in-out infinite reverse; animation: halo-breathe 2.4s ease-in-out infinite reverse;
} }
@@ -116,18 +114,18 @@
stroke-linecap: round; stroke-linecap: round;
stroke-dasharray: 165; stroke-dasharray: 165;
stroke-dashoffset: 165; stroke-dashoffset: 165;
filter: drop-shadow(0 0 8px rgba(151, 118, 255, 0.55)); filter: drop-shadow(0 0 8px rgba(77, 182, 214, 0.32));
animation: trace-draw 2.6s ease-in-out infinite; animation: trace-draw 2.6s ease-in-out infinite;
} }
.repo-loading-traces .trace-main { .repo-loading-traces .trace-main {
stroke: #9b70ff; stroke: #6f8cff;
} }
.repo-loading-traces .trace-branch { .repo-loading-traces .trace-branch {
stroke: #ff6d26; stroke: #4db6d6;
animation-delay: 0.28s; animation-delay: 0.28s;
} }
.repo-loading-traces .trace-cut { .repo-loading-traces .trace-cut {
stroke: rgba(255, 255, 255, 0.42); stroke: rgba(177, 186, 208, 0.42);
stroke-dasharray: 128; stroke-dasharray: 128;
stroke-dashoffset: 128; stroke-dashoffset: 128;
animation-delay: 0.55s; animation-delay: 0.55s;
@@ -141,7 +139,7 @@
object-fit: contain; object-fit: contain;
filter: filter:
drop-shadow(0 18px 24px rgba(0, 0, 0, 0.55)) drop-shadow(0 18px 24px rgba(0, 0, 0, 0.55))
drop-shadow(0 0 18px rgba(151, 118, 255, 0.28)); drop-shadow(0 0 18px rgba(77, 182, 214, 0.2));
animation: icon-float 2.4s ease-in-out infinite; animation: icon-float 2.4s ease-in-out infinite;
} }
@@ -175,18 +173,59 @@
height: 4px; height: 4px;
overflow: hidden; overflow: hidden;
border-radius: 999px; border-radius: 999px;
background: rgba(151, 118, 255, 0.14); background: rgba(111, 140, 255, 0.14);
} }
.repo-loading-bar span { .repo-loading-bar span {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 46%; width: 46%;
border-radius: inherit; border-radius: inherit;
background: linear-gradient(90deg, transparent, #9b70ff 40%, #ff6d26 74%, transparent); background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
box-shadow: 0 0 16px rgba(255, 109, 38, 0.32); box-shadow: 0 0 16px rgba(77, 182, 214, 0.26);
animation: bar-slide 1.35s ease-in-out infinite; animation: bar-slide 1.35s ease-in-out infinite;
} }
:global(:root[data-theme="light"]) .repo-loading {
background:
radial-gradient(circle at 50% 42%, rgba(49, 95, 214, 0.12), transparent 34%),
linear-gradient(135deg, #f7f9fd 0%, #eef3f9 48%, #f8fafc 100%);
}
:global(:root[data-theme="light"]) .repo-loading-grid {
opacity: 0.38;
background-image:
linear-gradient(rgba(49, 95, 214, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(15, 143, 181, 0.08) 1px, transparent 1px);
}
:global(:root[data-theme="light"]) .repo-loading-card {
border-color: rgba(61, 89, 142, 0.2);
background:
linear-gradient(180deg, rgba(255,255,255,0.96), rgba(244,247,252,0.94)),
var(--color-surface-raised);
box-shadow:
0 26px 72px rgba(28,44,74,0.18),
inset 0 1px 0 rgba(255,255,255,0.9);
}
:global(:root[data-theme="light"]) .repo-loading-traces .trace {
filter: drop-shadow(0 0 8px rgba(49,95,214,0.22));
}
:global(:root[data-theme="light"]) .repo-loading-traces .trace-cut {
stroke: rgba(49,95,214,0.34);
}
:global(:root[data-theme="light"]) .repo-loading-icon {
filter:
drop-shadow(0 18px 24px rgba(28,44,74,0.2))
drop-shadow(0 0 18px rgba(49,95,214,0.18));
}
:global(:root[data-theme="light"]) .repo-loading-bar {
background: rgba(49,95,214,0.12);
}
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } } @keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes grid-drift { to { transform: translate3d(42px, 42px, 0); } } @keyframes grid-drift { to { transform: translate3d(42px, 42px, 0); } }
@keyframes icon-float { @keyframes icon-float {
+15 -11
View File
@@ -11,6 +11,8 @@
onApply: (stash: GitStash) => void; onApply: (stash: GitStash) => void;
onPop: (stash: GitStash) => void; onPop: (stash: GitStash) => void;
onDrop: (stash: GitStash) => void; onDrop: (stash: GitStash) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
} }
let { let {
@@ -22,11 +24,12 @@
onApply = () => {}, onApply = () => {},
onPop = () => {}, onPop = () => {},
onDrop = () => {}, onDrop = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props(); }: Props = $props();
let message = $state(""); let message = $state("");
let includeUntracked = $state(true); let includeUntracked = $state(true);
let open = $state(false);
function submitPush() { function submitPush() {
onPush(message, includeUntracked); onPush(message, includeUntracked);
@@ -38,31 +41,32 @@
} }
</script> </script>
<section class="panel stash-panel overflow-hidden" class:collapsed={!open} aria-label="Git stash"> <section class="panel stash-panel overflow-hidden" class:collapsed aria-label="Git stash">
<div class="section-head"> <div class="section-head">
<div> <div>
<span class="eyebrow">Stash</span> <span class="eyebrow">Stash</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Shelved changes</h2> <h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Shelved changes</h2>
</div> </div>
<div class="stash-head-actions"> <div class="stash-head-actions">
<span class="pill pill-count">{stashes.length}</span>
<button <button
class="stash-toggle" class="stash-toggle panel-collapse-toggle"
type="button" type="button"
onclick={() => { open = !open; }} onclick={onToggleCollapsed}
aria-expanded={open} aria-expanded={!collapsed}
title={open ? "Collapse stash panel" : "Expand stash panel"} title={collapsed ? "Expand stash panel" : "Collapse stash panel"}
aria-label={collapsed ? "Expand stash panel" : "Collapse stash panel"}
> >
{#if open} {#if collapsed}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" /> <ChevronRight size={14} aria-hidden="true" />
{:else}
<ChevronDown size={14} aria-hidden="true" />
{/if} {/if}
</button> </button>
<span class="pill pill-count">{stashes.length}</span>
</div> </div>
</div> </div>
{#if !open} {#if collapsed}
<!-- collapsed --> <!-- collapsed -->
{:else if !hasRepository} {:else if !hasRepository}
<div class="blank-state">No repository loaded.</div> <div class="blank-state">No repository loaded.</div>
+329 -14
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte"; import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png";
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types"; import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
interface Props { interface Props {
@@ -8,12 +9,14 @@
unstagedCount: number; unstagedCount: number;
hasRepository: boolean; hasRepository: boolean;
isBusy: boolean; isBusy: boolean;
operation: string;
status: GitStatus | null; status: GitStatus | null;
selectedFilePath: string; selectedFilePath: string;
onSelectFile: (file: GitFileStatus) => void; onSelectFile: (file: GitFileStatus) => void;
onStage: (file: GitFileStatus) => void; onStage: (files: GitFileStatus[]) => void;
onUnstage: (file: GitFileStatus) => void; onUnstage: (files: GitFileStatus[]) => void;
onDiscard: (file: GitFileStatus, staged: boolean) => void; onDiscard: (files: GitFileStatus[], staged: boolean) => void;
onDiscardMany: (files: GitFileStatus[]) => void;
onPatch: (file: GitFileStatus, staged: boolean) => void; onPatch: (file: GitFileStatus, staged: boolean) => void;
onStageAll: () => void; onStageAll: () => void;
onUnstageAll: () => void; onUnstageAll: () => void;
@@ -25,12 +28,14 @@
unstagedCount = 0, unstagedCount = 0,
hasRepository = false, hasRepository = false,
isBusy = false, isBusy = false,
operation = "",
status = null, status = null,
selectedFilePath = "", selectedFilePath = "",
onSelectFile = () => {}, onSelectFile = () => {},
onStage = () => {}, onStage = () => {},
onUnstage = () => {}, onUnstage = () => {},
onDiscard = () => {}, onDiscard = () => {},
onDiscardMany = () => {},
onPatch = () => {}, onPatch = () => {},
onStageAll = () => {}, onStageAll = () => {},
onUnstageAll = () => {}, onUnstageAll = () => {},
@@ -56,11 +61,102 @@
return kind === "modified"; return kind === "modified";
} }
function fileKey(file: GitFileStatus): string {
return `${file.old_path ?? ""}:${file.path}`;
}
let selectedStatusPaths = $state<Set<string>>(new Set());
let selectionAnchorKey = $state("");
function isStatusSelected(file: GitFileStatus): boolean {
return selectedStatusPaths.has(fileKey(file));
}
function selectedFiles(): GitFileStatus[] {
return changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)));
}
function selectedStageTargets(file: GitFileStatus): GitFileStatus[] {
const files = isStatusSelected(file) ? selectedFiles() : [file];
return files.filter((item) => item.unstaged !== null);
}
function selectedUnstageTargets(file: GitFileStatus): GitFileStatus[] {
const files = isStatusSelected(file) ? selectedFiles() : [file];
return files.filter((item) => item.staged !== null);
}
function handleFileSelect(event: MouseEvent, file: GitFileStatus) {
const key = fileKey(file);
const allKeys = changedFiles.map(fileKey);
const next = new Set(selectedStatusPaths);
if (event.shiftKey && selectionAnchorKey) {
const anchorIndex = allKeys.indexOf(selectionAnchorKey);
const currentIndex = allKeys.indexOf(key);
if (anchorIndex >= 0 && currentIndex >= 0) {
const start = Math.min(anchorIndex, currentIndex);
const end = Math.max(anchorIndex, currentIndex);
for (const itemKey of allKeys.slice(start, end + 1)) next.add(itemKey);
} else {
next.add(key);
}
} else if (event.ctrlKey || event.metaKey) {
if (next.has(key)) next.delete(key);
else next.add(key);
selectionAnchorKey = key;
} else {
next.clear();
next.add(key);
selectionAnchorKey = key;
}
selectedStatusPaths = next;
onSelectFile(file);
}
function stageFromFile(file: GitFileStatus) {
const targets = selectedStageTargets(file);
if (targets.length === 0) return;
onStage(targets);
}
function unstageFromFile(file: GitFileStatus) {
const targets = selectedUnstageTargets(file);
if (targets.length === 0) return;
onUnstage(targets);
}
// Discard mirrors the stage/unstage target selection: if the clicked row is
// part of the current multi-selection, the whole selection (filtered to the
// relevant lane) is discarded; otherwise just that one file.
function discardStagedFromFile(file: GitFileStatus) {
const targets = selectedUnstageTargets(file);
if (targets.length === 0) return;
onDiscard(targets, true);
}
function discardUnstagedFromFile(file: GitFileStatus) {
const targets = selectedStageTargets(file);
if (targets.length === 0) return;
onDiscard(targets, false);
}
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null)); let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null)); let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
let selectedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f))).length);
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
$effect(() => {
const validKeys = new Set(changedFiles.map(fileKey));
const next = new Set([...selectedStatusPaths].filter((key) => validKeys.has(key)));
if (next.size !== selectedStatusPaths.size) selectedStatusPaths = next;
if (selectionAnchorKey && !validKeys.has(selectionAnchorKey)) selectionAnchorKey = "";
});
</script> </script>
<section class="panel grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status"> <section class="panel relative grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status">
<div class="section-head"> <div class="section-head">
<div> <div>
<span class="eyebrow">Working tree</span> <span class="eyebrow">Working tree</span>
@@ -94,6 +190,49 @@
<Undo2 size={14} aria-hidden="true" /> <Undo2 size={14} aria-hidden="true" />
Unstage all Unstage all
</button> </button>
<button
class="btn-sm danger"
type="button"
onclick={() => onDiscardMany(changedFiles)}
disabled={isBusy || changedFiles.length === 0}
title="Discard all changes"
>
<RotateCcw size={14} aria-hidden="true" />
Discard all
</button>
{#if selectedCount > 1}
<span class="status-selection-count">{selectedCount} selected</span>
<button
class="btn-sm"
type="button"
onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))}
disabled={isBusy || selectedUnstagedCount === 0}
title="Stage selected unstaged files"
>
<Check size={14} aria-hidden="true" />
Stage selected
</button>
<button
class="btn-sm"
type="button"
onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))}
disabled={isBusy || selectedStagedCount === 0}
title="Unstage selected staged files"
>
<Undo2 size={14} aria-hidden="true" />
Unstage selected
</button>
<button
class="btn-sm danger"
type="button"
onclick={() => onDiscardMany(selectedFiles())}
disabled={isBusy || selectedCount === 0}
title="Discard changes in selected files"
>
<RotateCcw size={14} aria-hidden="true" />
Discard selected
</button>
{/if}
</div> </div>
{/if} {/if}
@@ -108,13 +247,14 @@
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)} {#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
<article <article
class="file-row" class="file-row"
class:selected={selectedFilePath === file.path} class:selected={isStatusSelected(file)}
class:active={selectedFilePath === file.path}
> >
<div class="file-title"> <div class="file-title">
<button <button
class="file-title-button" class="file-title-button"
type="button" type="button"
onclick={() => onSelectFile(file)} onclick={(event) => handleFileSelect(event, file)}
title={`Select ${displayPath(file)} in Explorer`} title={`Select ${displayPath(file)} in Explorer`}
> >
<strong>{fileName(file)}</strong> <strong>{fileName(file)}</strong>
@@ -129,17 +269,18 @@
</div> </div>
<div class="lane-actions"> <div class="lane-actions">
{#if file.staged} {#if file.staged}
<button class="btn-sm" type="button" onclick={() => onUnstage(file)} disabled={isBusy} title="Unstage file"> {@const unstageTargets = selectedUnstageTargets(file)}
<button class="btn-sm" type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={unstageTargets.length > 1 ? `Unstage ${unstageTargets.length} selected files` : "Unstage file"}>
<Undo2 size={14} aria-hidden="true" /> <Undo2 size={14} aria-hidden="true" />
Unstage {unstageTargets.length > 1 ? `Unstage ${unstageTargets.length}` : "Unstage"}
</button> </button>
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines"> <button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
<FileDiff size={14} aria-hidden="true" /> <FileDiff size={14} aria-hidden="true" />
Details Details
</button> </button>
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes"> <button class="btn-sm" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={unstageTargets.length > 1 ? `Discard staged changes in ${unstageTargets.length} selected files` : "Discard staged changes"}>
<RotateCcw size={14} aria-hidden="true" /> <RotateCcw size={14} aria-hidden="true" />
Discard {unstageTargets.length > 1 ? `Discard ${unstageTargets.length}` : "Discard"}
</button> </button>
{:else} {:else}
<span class="quiet">No staged change</span> <span class="quiet">No staged change</span>
@@ -154,17 +295,18 @@
</div> </div>
<div class="lane-actions"> <div class="lane-actions">
{#if file.unstaged} {#if file.unstaged}
<button class="btn-sm" type="button" onclick={() => onStage(file)} disabled={isBusy} title="Stage file"> {@const stageTargets = selectedStageTargets(file)}
<button class="btn-sm" type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Stage ${stageTargets.length} selected files` : "Stage file"}>
<Check size={14} aria-hidden="true" /> <Check size={14} aria-hidden="true" />
Stage {stageTargets.length > 1 ? `Stage ${stageTargets.length}` : "Stage"}
</button> </button>
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines"> <button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
<FileDiff size={14} aria-hidden="true" /> <FileDiff size={14} aria-hidden="true" />
Details Details
</button> </button>
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes"> <button class="btn-sm" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Discard unstaged changes in ${stageTargets.length} selected files` : "Discard unstaged changes"}>
<RotateCcw size={14} aria-hidden="true" /> <RotateCcw size={14} aria-hidden="true" />
Discard {stageTargets.length > 1 ? `Discard ${stageTargets.length}` : "Discard"}
</button> </button>
{:else} {:else}
<span class="quiet">No unstaged change</span> <span class="quiet">No unstaged change</span>
@@ -176,4 +318,177 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if isBusy && hasRepository}
<div class="status-panel-overlay" role="status" aria-live="polite">
<div class="status-panel-overlay-card">
<div class="status-panel-overlay-mark">
<span class="status-panel-overlay-halo halo-one"></span>
<span class="status-panel-overlay-halo halo-two"></span>
<svg class="status-panel-overlay-traces" viewBox="0 0 220 220" aria-hidden="true">
<path class="trace trace-main" d="M28 154 C72 114, 88 108, 110 110 S156 116, 192 68" />
<path class="trace trace-branch" d="M62 74 C100 88, 126 124, 158 166" />
<path class="trace trace-cut" d="M46 180 L174 180" />
</svg>
<img src={iconUrl} alt="" class="status-panel-overlay-icon" />
</div>
<span class="status-panel-overlay-label">{operation || "Working"}</span>
<div class="status-panel-overlay-bar"><span></span></div>
</div>
</div>
{/if}
</section> </section>
<style>
.status-panel-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
background:
radial-gradient(circle at 50% 38%, rgba(111, 140, 255, 0.1), transparent 55%),
rgba(7, 10, 16, 0.62);
backdrop-filter: blur(4px);
animation: status-panel-overlay-in 120ms ease;
}
.status-panel-overlay-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(300px, calc(100% - 32px));
padding: 26px 28px 26px;
border: 1px solid rgba(90, 111, 154, 0.28);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)),
var(--color-surface-raised);
color: var(--color-ink);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.status-panel-overlay-mark {
position: relative;
width: 104px;
height: 104px;
display: grid;
place-items: center;
isolation: isolate;
}
.status-panel-overlay-halo {
position: absolute;
inset: 6px;
border: 1px solid rgba(111, 140, 255, 0.22);
border-radius: 22px;
transform: rotate(45deg);
}
.status-panel-overlay-halo.halo-one {
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite;
}
.status-panel-overlay-halo.halo-two {
inset: 16px;
border-color: rgba(77, 182, 214, 0.24);
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse;
}
.status-panel-overlay-traces {
position: absolute;
inset: -12px;
width: 128px;
height: 128px;
overflow: visible;
z-index: 0;
}
.status-panel-overlay-traces .trace {
fill: none;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 165;
stroke-dashoffset: 165;
filter: drop-shadow(0 0 6px rgba(77, 182, 214, 0.32));
animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite;
}
.status-panel-overlay-traces .trace-main { stroke: #6f8cff; }
.status-panel-overlay-traces .trace-branch {
stroke: #4db6d6;
animation-delay: 0.28s;
}
.status-panel-overlay-traces .trace-cut {
stroke: rgba(177, 186, 208, 0.42);
stroke-dasharray: 128;
stroke-dashoffset: 128;
animation-delay: 0.55s;
}
.status-panel-overlay-icon {
position: relative;
z-index: 1;
width: 64px;
height: 64px;
object-fit: contain;
filter:
drop-shadow(0 8px 12px rgba(0, 0, 0, 0.5))
drop-shadow(0 0 10px rgba(77, 182, 214, 0.2));
animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite;
}
.status-panel-overlay-label {
max-width: 100%;
overflow: hidden;
color: var(--color-ink);
font-size: 14px;
font-weight: 700;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-panel-overlay-bar {
position: relative;
width: min(190px, 100%);
height: 4px;
overflow: hidden;
border-radius: 999px;
background: rgba(111, 140, 255, 0.14);
}
.status-panel-overlay-bar span {
position: absolute;
inset: 0;
width: 46%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
box-shadow: 0 0 12px rgba(77, 182, 214, 0.26);
animation: status-panel-overlay-bar-slide 1.35s ease-in-out infinite;
}
@keyframes status-panel-overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes status-panel-overlay-icon-float {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-3px) scale(1.015); }
}
@keyframes status-panel-overlay-halo-breathe {
0%, 100% { opacity: 0.35; transform: rotate(45deg) scale(0.95); }
50% { opacity: 0.8; transform: rotate(45deg) scale(1.04); }
}
@keyframes status-panel-overlay-bar-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
@keyframes status-panel-overlay-trace-draw {
0% { stroke-dashoffset: 165; opacity: 0; }
36% { opacity: 1; }
64%, 100% { stroke-dashoffset: 0; opacity: 0.72; }
}
@media (prefers-reduced-motion: reduce) {
.status-panel-overlay,
.status-panel-overlay-halo,
.status-panel-overlay-traces .trace,
.status-panel-overlay-icon,
.status-panel-overlay-bar span { animation: none; }
.status-panel-overlay-traces .trace { stroke-dashoffset: 0; opacity: 0.72; }
}
</style>
+1
View File
@@ -10,6 +10,7 @@ export type FileStatusKind =
export type CommitAiPhase = "idle" | "loading" | "ready" | "error"; export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom"; export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed"; export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
export type AppTheme = "system" | "light" | "dark";
export interface CommitAiStatus { export interface CommitAiStatus {
phase: CommitAiPhase; phase: CommitAiPhase;
+17
View File
@@ -3,12 +3,29 @@ import { mount } from "svelte";
import App from "./App.svelte"; import App from "./App.svelte";
import "./app.css"; import "./app.css";
const THEME_KEY = "gitlite.theme.v1";
const target = document.getElementById("app"); const target = document.getElementById("app");
if (!target) { if (!target) {
throw new Error("App target element was not found."); throw new Error("App target element was not found.");
} }
try {
const storedTheme = localStorage.getItem(THEME_KEY);
const preference = storedTheme === "light" || storedTheme === "dark" || storedTheme === "system"
? storedTheme
: "system";
const resolved = preference === "system" && window.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: preference === "system"
? "dark"
: preference;
document.documentElement.dataset.themePreference = preference;
document.documentElement.dataset.theme = resolved;
} catch {
// Storage is best-effort; the CSS system theme fallback still applies.
}
const app = mount(App, { target }); const app = mount(App, { target });
export default app; export default app;