Compare commits

..
96 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
Christoph 8d0a7b4c71 Update version to 0.200.2 2026-07-08 21:19:39 +02:00
Christoph Brandau a26e164d95 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 19m59s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 26m52s
2026-07-08 21:03:39 +02:00
Christoph Brandau 784f133abd feat(ci): enhance publishing targets and update configuration
This update introduces a new mechanism for handling multiple publish
targets when uploading artifacts. It refines the configuration by
parsing legacy targets from environment variables and allows for
dynamic bucket and directory management during the publishing process.

- Added support for multiple publish targets from environment variables
- Refactored artifact upload logic to improve maintainability
- Updated tauri configuration to include new endpoint for latest.json
2026-07-08 21:02:55 +02:00
Christoph 3021c7c345 Update version to 0.200.1 2026-07-08 20:51:16 +02:00
Christoph Brandau e0a77607f5 feat(tauri): migrate from GitLite to Gitty and clean up legacy data
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 19m47s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 27m57s
This commit updates the installer and configuration to transition from
GitLite to Gitty. It includes macros for migrating legacy data and
uninstalling the previous GitLite installation, ensuring a smoother
upgrade experience for users.

- Added migration and uninstallation macros for legacy GitLite data
- Updated application identifier from com.git-lite to com.gitty
- Enhanced post-installation cleanup for legacy shortcuts
2026-07-08 20:48:22 +02:00
Christoph Brandau 2383abb496 feat(tauri): add NSIS hooks for post-install and post-uninstall
This update introduces NSIS hooks for managing shortcuts and directories
during the installation and uninstallation of the application. The hooks
ensure that the desktop and start menu shortcuts are removed properly,
along with the associated program directory.

- Implement post-install and post-uninstall hooks in NSIS
- Update configuration to reference the new hooks file
2026-07-08 19:52:59 +02:00
Christoph Brandau a7faaceb83 refactor(app): rename GitLite to Gitty throughout the application
The application has been rebranded from GitLite to Gitty, which involved
updating various references across the codebase, including titles,
descriptions, and identifiers. This change reflects the new branding
strategy and ensures consistency in the user interface and backend.

- Updated project name and identifiers in configuration files
- Changed all instances of "GitLite" to "Gitty" in HTML and Svelte files
- Adjusted descriptions and help texts to match the new branding
2026-07-08 19:51:28 +02:00
Christoph Brandau ea49380138 Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-07-08 18:26:02 +02:00
Christoph Brandau a072bba2e6 feat(splashscreen): enhance startup sequence and splashscreen handling
This update improves the splashscreen management during application startup. The splashscreen will now remain visible for a minimum duration and will only close after the main application is ready, ensuring a smoother user experience.

- Introduced a new startup sequence to manage background tasks.
- Added error handling for splashscreen closure to maintain functionality.
- Optimized the timing for displaying and hiding the splashscreen.
2026-07-08 18:25:43 +02:00
Christoph c45a43b866 Update version to 2026.7.16 2026-07-08 16:37:13 +02:00
Christoph Brandau 7e7c1b0b69 feat(assets): Add comprehensive platform icon assets
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Has been cancelled
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Has been cancelled
This update introduces a complete set of high-resolution icons for all target platforms. This ensures consistent branding and visual identity across desktop, Android, and iOS environments. The tauri configuration file has been updated to utilize these new assets.

- Added full sets of Android mipmap density icons
- Included comprehensive iOS AppIcon assets for multiple sizes
- Updated tauri.conf.json with modern icon paths
2026-07-08 16:34:11 +02:00
Christoph Brandau 218907a946 build(tauri): add custom icon to Windows installer
Adds configuration to specify a dedicated icon for the NSIS installer when building on Windows. This improves the visual branding and user experience during the installation process.

- Configures the tauri build system to use a specific icon file.
2026-07-08 15:46:42 +02:00
Christoph Brandau 0d4f9ca630 fix(RepoLoadingOverlay): adjust loading overlay position
The loading overlay's position has been updated to account for a new
layout change. This ensures that the overlay is displayed correctly
below the app title bar, improving the user experience during loading.

- Adjusted overlay position to maintain consistency with layout changes
2026-07-08 15:39:41 +02:00
Christoph Brandau 44be7afff2 fix(App): close context menu on right-click event
This change ensures that the repository tab context menu is closed
when the user right-clicks anywhere in the window. This improves
the user experience by preventing the context menu from remaining
open unintentionally.

- Added functionality to close the context menu on right-click.
- Updated event handling for context menu to use capture phase.
2026-07-08 15:39:32 +02:00
Christoph Brandau 8793d3de40 feat(repo-tabs): add context menu for managing repository tabs
This update introduces a context menu for repository tabs, allowing users
to easily close individual tabs, close all other tabs, or close tabs to the
right. Additionally, several visual elements related to loading animations
have been removed to streamline the user interface.

- Implement context menu actions for repository tab management
- Remove unnecessary loading animations from the splash screen and overlays
- Enhance user experience with improved tab management functionality
2026-07-08 15:34:30 +02:00
Christoph Brandau cc179e9eef feat(splashscreen): implement a splash screen for application startup
This commit introduces a splash screen that enhances the user experience during application startup. It includes a new HTML file for the splash screen layout and a corresponding icon. The main application logic has been updated to close the splash screen once the main application is ready.

- Added a splash screen HTML and icon for improved visual feedback
- Updated main application logic to manage splash screen visibility
- Enhanced title bar branding with an image instead of SVG
2026-07-08 15:16:51 +02:00
Christoph Brandau 61a22987ba style(FileHistoryPanel): enhance loading animation and styles
This update improves the loading animation for the File History Panel,
making it more visually appealing and responsive. The CSS has been
refactored to introduce new styles and animations, while the Svelte
component has been updated to utilize these changes effectively.

- Added new loading animations and styles for better user experience
- Refactored CSS to improve maintainability and visual design
- Updated Svelte component to integrate new loading elements
2026-07-08 14:36:09 +02:00
Christoph Brandau ba2065d3c4 feat(RepoLoadingOverlay): redesign loading overlay with new animations
The loading overlay component has been significantly redesigned to enhance the user experience. The previous SVG-based loading animation has been replaced with a more visually appealing layout that includes new animations and an icon. This update aims to improve the overall aesthetics and interactivity of the loading state.

- Introduced new loading animations and visual elements
- Added a background grid effect for a dynamic appearance
- Updated styles for better responsiveness and visual hierarchy
2026-07-08 14:33:25 +02:00
Christoph Brandau c9a93f1d0a feat(blame): add search functionality to blame dialog
This update introduces a search bar to the blame dialog, allowing users to filter blame lines based on their input. The search highlights matching text segments, improving the usability of the blame feature for navigating large files.

- Implemented a search bar for filtering blame lines
- Added visual feedback for matched search terms
- Enhanced overall user experience in the blame dialog
2026-07-08 14:20:51 +02:00
Christoph Brandau de6f497e35 feat(tracking): enhance event tracking for repository actions
This update significantly improves the event tracking system within the
application. Various user interactions, such as toggling favorite
repositories, managing branches, and handling file operations, are now
logged with detailed event tracking, providing better insights into user
behavior and application usage.

- Added tracking for repository management actions and file operations
- Enhanced tracking for branch and tag management events
- Implemented tracking for explorer interactions and conflict resolutions
2026-07-08 14:17:51 +02:00
Christoph Brandau 7589a38564 Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-07-08 14:08:07 +02:00
Christoph Brandau 5e46995c89 feat(repositories): add favorite repositories feature
This update introduces a favorites system for repositories, allowing users
to mark certain repositories as favorites for easier access. The UI has been
modified to include a star icon for toggling favorites, and the layout has
been adjusted to display favorite repositories separately.

- Implemented favorite repository management with local storage
- Updated UI to show favorite repositories with a star icon
- Enhanced repository listing to filter and display favorites
2026-07-08 14:07:50 +02:00
Christoph 82f738c15e Update version to 0.200.0 2026-07-08 13:13:00 +02:00
Christoph 6f1771f8b6 Merge pull request 'feat(icons): generate application icons in multiple sizes' (#22) from branding into main
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 19m47s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 30m37s
Reviewed-on: #22
2026-07-08 11:10:17 +00:00
Christoph Brandau a57e52a38b feat(icons): generate application icons in multiple sizes
This update introduces a new script to generate application icons in various sizes, including 32x32, 64x64, 128x128, and 512x512 pixels. The generated icons are now included in the Tauri configuration, ensuring that the application has the appropriate icons for different contexts.

- Added a script for generating icons from a source image
- Updated Tauri configuration to reference new icon sizes
- Included multiple icon formats for better compatibility
2026-07-08 13:09:14 +02:00
Christoph 4befe8b189 Update version to 2026.7.15 2026-07-07 17:40:11 +02:00
Christoph 9d0b9de142 Merge pull request 'feat(analytics): add optional Aptabase event tracking UI' (#21) from features/aptabase into main
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 17m51s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 27m1s
Reviewed-on: #21
2026-07-07 15:37:02 +00:00
Christoph Brandau e503786211 feat(analytics): add optional Aptabase event tracking UI
Integrate Aptabase analytics via a Tauri plugin and add a privacy
notice flow plus a settings dialog to control whether anonymous
usage events are sent. The app now tracks key user actions while
ensuring analytics never blocks core Git operations.

- Add analytics tracking helper and Aptabase plugin wiring
- Persist analytics consent in localStorage and gate tracking
- Introduce notice and settings dialogs, plus new event calls
2026-07-07 17:35:20 +02:00
Christoph Brandau d4ac449074 feat(blame): add file blame dialog with git porcelain parsing
This change introduces a new backend command to fetch file blame using
git's line-porcelain output and returns structured per-line metadata.
The UI adds a BlameDialog that groups lines by commit, highlights
uncommitted changes, and styles the dialog to match the updated theme.

- Add get_file_blame command and parsing with uncommitted detection
- Wire BlameDialog into the explorer file node actions
- Add blame UI component and supporting types and styles
2026-07-07 15:09:25 +02:00
Christoph Brandau a67663f3c5 Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-07-07 10:30:35 +02:00
Christoph Brandau 9e48727fce chore(release): bump app version to 2026.7.14
Update the project and Tauri configuration version to 2026.7.14.
This keeps the package metadata and desktop app build in sync for
consistent release tracking.

- Bump version in package.json
- Bump version in tauri.conf.json
2026-07-07 10:28:18 +02:00
Christoph a09335b729 Update version to 2026.7.13 2026-07-07 10:27:33 +02:00
Christoph da05a8461f Update version to 2026.7.0 2026-07-07 10:27:33 +02:00
Christoph 21366cb26b Update version to 2026.7.14 2026-07-06 21:10:29 +02:00
Christoph a6312af6d7 Merge pull request 'Features/extend git features' (#20) from features/extend_git_features into main
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 16m33s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 28m3s
Reviewed-on: #20
2026-07-06 19:07:49 +00:00
Christoph Brandau 899f024a4f feat(ui): default credential dialog to save session
The credential dialog now starts with session saving enabled by
default, reducing extra user interaction for the common case. This
improves usability while keeping the option available to change.

- Set saveSession default to true in CredentialDialog
2026-07-06 21:06:58 +02:00
Christoph Brandau aa61199b43 feat(history): highlight ahead/behind vs active upstream
Repo status updates for the management view are now refreshed in the
background after switching, without blocking the initial open flow.
The history graph and hover labels now distinguish local vs upstream
branches, visually marking commits that are ahead or behind.

- Add background fetch helper and shared status update logic
- Pass active upstream into HistoryPanel and render sync styling
2026-07-06 20:58:01 +02:00
Christoph Brandau 98f3902839 style(ui): improve ahead/behind colors and amend toggle styling
Adjust the repo ahead/behind meta colors and add subtle background
highlights for better visual distinction. Also introduce dedicated
CSS for the commit amend toggle input, including checked, focus, and
disabled states to improve usability and accessibility.

- Refine ahead/behind meta color and background emphasis
- Add complete styling for commit amend toggle states
2026-07-06 20:24:07 +02:00
Christoph Brandau d1d3604a6d feat(repo-status): cache and background-refresh repo ahead/behind
Repo branch status is now cached locally and refreshed in the
background so management and tab lists show up-to-date data without
manual fetches. The background worker updates repos in a round-robin
batch to avoid running multiple git subprocesses at once, and the UI
renders ahead/behind/changed counts alongside branch names.

- Add localStorage-backed repo status cache for known repos
- Background polling updates open tabs and recent/known repos
- Show ahead/behind/changed metadata in repository management UI
2026-07-06 20:04:13 +02:00
Christoph Brandau ce0cbc0786 feat(commit): add amend and undo last commit support
This change introduces new Tauri commands to amend the last commit with
an optional message, fetch the last commit message, and undo the most
recent commit safely. The UI now offers an amend toggle and an undo
button only when the last commit hasn’t been pushed upstream, reducing
the risk of rewriting shared history.

- Add amend/undo/last-message git commands in Rust
- Wire new operations into the Svelte commit panel UI
- Add styling and state handling for amend mode
2026-07-06 17:34:05 +02:00
Christoph Brandau 0bad722c7a feat(git): add tag management and cherry-pick workflow
This update extends the Git backend and UI to support listing,
creating, deleting, and pushing tags. It also adds cherry-pick
commands with proper in-progress detection and conflict handling, and
prevents other operations while a cherry-pick is active.

- Add GitTag model, tag listing, and tag CRUD/push commands
- Implement cherry-pick start/continue/abort with status tracking
- Update UI to display tags and gate actions during cherry-pick
2026-07-06 15:05:12 +02:00
Christoph 247c118bdf Merge pull request 'fix(styles): show graph hover branches only on gutter hover' (#19) from opt/app_layout into main
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 16m35s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 26m22s
Reviewed-on: #19
2026-07-06 09:45:35 +00:00
Christoph c59cb412dd Update version to 2026.7.13 2026-07-06 11:39:13 +02:00
Christoph 1b83429c06 Merge pull request 'Opt/app layout' (#18) from opt/app_layout into main
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Has been cancelled
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Has been cancelled
Reviewed-on: #18
2026-07-06 09:36:07 +00:00
Christoph Brandau 3e2885f64d fix(styles): show graph hover branches only on gutter hover
Hover behavior for graph branch highlights was adjusted to limit the
opacity/transform changes to the gutter hover state. This prevents the
highlight from triggering when hovering over the graph rows, reducing
unintended visual effects.
2026-07-06 11:35:46 +02:00
Christoph Brandau eac0f059fa feat(history): improve commit hover branch labels and titles
The history panel now generates more informative hover text for commits.
It prefers direct local branch refs when available, otherwise it derives
a concise list from containing branch labels and summarizes overflow.

- Add helpers to compute hover branch labels and title text
- Update the graph row hover rendering to use the new helpers
2026-07-06 11:31:41 +02:00
Christoph Brandau aaa4228b82 feat(history): add commit action context menu in HistoryPanel
The commit history UI now provides a compact actions menu per commit,
with positioning constrained to the history panel. The menu supports
keyboard dismissal via Escape and closes on outside clicks to keep the
interaction predictable.

- Replace per-commit action buttons with an ellipsis menu
- Add context menu state and panel-relative positioning logic
- Style the new history context menu and menu button
2026-07-06 11:16:52 +02:00
Christoph e5c26ea502 Update version to 2026.7.0 2026-07-06 08:41:32 +02:00
Christoph Brandau 38b4f98233 feat(ci): improve Tauri build pipeline and artifact collection
The CI workflow now installs the required Rust toolchain and uv on
Ubuntu, and passes a no-strip flag into the Tauri build to control
binary stripping behavior per platform. The CI helper script was
updated to collect only expected bundle and signature files, reducing
noise and improving upload correctness. Versioning was aligned across
the Node package and Tauri config for the new release.

- Add platform-specific no_strip handling in the build workflow
- Install Rust toolchain/uv for Ubuntu builds
- Filter collected artifacts by expected extensions and signatures
2026-07-06 08:23:49 +02:00
Christoph Brandau d592894f28 fix(git): split long path args to avoid Windows CreateProcess limit
Git operations that stage or restore many files could exceed the Windows
command line length limit, causing os error 206 failures. This change
splits file paths into size-bounded chunks and concatenates the output
from multiple git invocations to keep commands within safe limits.

- Chunk file path arguments for Windows compatibility
- Add a local dev command to list pkg-config packages
2026-07-06 08:22:41 +02:00
Christoph 9ee2f71dc3 Update .gitea/workflows/app_builder.yaml
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 17m5s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 29m36s
2026-07-06 06:17:03 +00:00
Christoph 283779610a Merge pull request 'Build/arch image' (#17) from build/arch_image into main
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Failing after 16m49s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Has been cancelled
Reviewed-on: #17
2026-07-06 05:40:19 +00:00
Christoph Brandau d33f576212 ci(app): install Rust toolchain on Ubuntu 22.04 builds
The workflow now sets up a Rust toolchain only for the Ubuntu
22.04 runner. This ensures Rust-based components can compile
reliably without affecting other platforms.

- Add conditional Rust toolchain install for ubuntu-22.04
2026-07-06 07:33:16 +02:00
Christoph a3fd8f1ce8 Update version to 0.100.0
publish / publish-tauri (appimage, ubuntu-22.04, linux-x86_64) (release) Failing after 1m4s
publish / publish-tauri (nsis, windows-latest, windows-x86_64) (release) Failing after 13m46s
2026-07-06 07:15:04 +02:00
Christoph de06e7a15d feat(ci): control symbol stripping via NO_STRIP matrix var
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Failing after 1m7s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Has been cancelled
The workflow now passes a per-platform NO_STRIP setting into the
Tauri build environment. This allows builds to retain symbols when
needed, improving debugging and crash analysis without changing the
default behavior for other targets.

- Add matrix no_strip values for Windows and Ubuntu
- Export NO_STRIP into the build environment for Tauri
2026-07-05 23:28:23 +02:00
Christoph 0105e0d46e feat(cicd): collect only expected artifact bundle files
Artifact collection previously grabbed every file under each bundle
directory, which could include unrelated or intermediate outputs. This
updates the logic to only include files with the expected extensions
for each artifact type, improving accuracy and reducing noise.

- Use a per-bundle suffix map to filter collected artifacts
- Switch to non-recursive directory iteration for bundle roots
2026-07-05 23:20:28 +02:00
Christoph 0732f9ca6d Merge pull request 'feat(ci): build Linux AppImage and update updater metadata' (#16) from build/arch_image into main
publish / publish-tauri (nsis, windows-latest, windows-x86_64) (release) Has been cancelled
publish / publish-tauri (appimage, ubuntu-22.04, linux-x86_64) (release) Failing after 2m18s
Reviewed-on: #16
2026-07-05 21:12:29 +00:00
Christoph fcaaa770d6 feat(ci): build Linux AppImage and update updater metadata
The CI workflow now builds a single bundle per run, adds Linux
AppImage output, and updates the Tauri version using a Node script.
The publishing tool is extended to recognize Linux artifacts,
select a primary artifact based on the target platform, and merge
platform entries into an existing latest.json when present.

- Limit matrix parallelism and pass bundle targets to Tauri
- Update updater platform handling and latest.json merging
- Add Linux Tauri config for AppImage bundling
2026-07-05 23:10:52 +02:00
Christoph 379350b9ee Update version to 2026.7.12 2026-07-05 11:05:49 +02:00
Christoph 5f7899bcee Merge pull request 'Feature/clone repo' (#15) from feature/clone_repo into main
publish / publish-tauri (, windows-latest) (release) Successful in 26m10s
Reviewed-on: #15
2026-07-04 23:24:27 +00:00
Christoph b646a2c647 feat(clone): support credentialed git clone with askpass
Cloning now accepts optional username/password and passes them to the
Rust git layer via GIT_ASKPASS, avoiding interactive prompts. The UI
detects authentication failures, opens the credential dialog for clone,
and auto-hides error messages to keep the flow smooth.

- Add username/password support to clone_repository and git.ts
- Detect auth failures and route users to the clone credential dialog
- Improve clone UX with a dedicated loading overlay and timed errors
2026-07-05 01:18:26 +02:00
Christoph 32497d53df feat(clone): add repository cloning flow to UI and backend
This introduces a new Tauri command to clone a remote repository into a
validated destination folder, then return the full repository bundle for
immediate rendering. The Svelte app gains a clone dialog and a new action
in repository management, wiring the cloned bundle into existing refresh
helpers. Dialog backdrops were also adjusted to rely on explicit close
controls.

- Add clone_repository command and core cloning logic in Rust
- Create CloneRepositoryDialog and integrate it into App.svelte
- Improve clone-related styling and tighten dialog backdrop behavior
2026-07-05 01:05:23 +02:00
Christoph 6365e164aa update for linux 2026-07-04 18:14:25 +02:00
Christoph f6af156fbd install on linux 2026-07-04 17:49:08 +02:00
Christoph 33cef059e8 Update version to 2026.7.11 2026-07-04 13:56:50 +02:00
105 changed files with 7261 additions and 786 deletions
+23 -1
View File
@@ -78,7 +78,29 @@
"Bash(rustfmt --edition 2024 --check src/badge.rs src/main.rs)",
"Bash(rustfmt --edition 2024 --check src/git.rs)",
"Bash(rustfmt --edition 2024 --check src/badge.rs)",
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)"
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)",
"Bash(pkg-config --list-all)",
"Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata src/git.rs)",
"Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata /mnt/d/Development/GitLite/src-tauri/src/git.rs)",
"Bash(rustc --edition 2021 --crate-type bin -o /dev/null --emit=metadata src/main.rs)",
"Bash(grep -B1 \"^error\\\\[E0432\\\\]\\\\|^error$\")",
"Bash(grep -v \"^--$\")",
"Bash(grep \"^error$\" -A2)",
"Bash(cargo run *)",
"Bash(node_modules/.bin/svelte-check --version)",
"Bash(rustfmt --check --edition 2021 src/git.rs src/main.rs)",
"Bash(rustfmt --check --edition 2021 src/git.rs)",
"Bash(awk *)",
"Bash(rustfmt --edition 2021 /tmp/blame_chunk.rs)",
"Read(//tmp/**)",
"Bash(identify src-tauri/icons/GitCat.ico)",
"Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")",
"Bash(python3 *)",
"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 *)"
]
}
}
+25 -14
View File
@@ -32,16 +32,21 @@ jobs:
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
# - platform: 'macos-latest'
# args: '--target aarch64-apple-darwin'
# - platform: 'macos-latest'
# args: '--target x86_64-apple-darwin'
# - platform: 'ubuntu-22.04'
# args: ''
- platform: "windows-latest"
args: ""
bundles: "nsis"
updater_platform: "windows-x86_64"
no_strip: ""
- platform: "ubuntu-22.04"
bundles: "appimage"
updater_platform: "linux-x86_64"
no_strip: "1"
runs-on: ${{ matrix.platform }}
@@ -55,13 +60,21 @@ jobs:
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install uv
if: matrix.platform == 'ubuntu-22.04'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
- name: install rust toolchain
if: matrix.platform == 'ubuntu-22.04'
uses: dtolnay/rust-toolchain@stable
- name: install frontend dependencies
run: npm ci
@@ -69,18 +82,13 @@ jobs:
run: npm version ${{ github.ref_name }} --no-git-tag-version --allow-same-version
- name: Update tauri.conf.json Version
shell: powershell
env:
RELEASE_VERSION: ${{ github.ref_name }}
run: |
$path = ".\src-tauri\tauri.conf.json"
$version = '${{ github.ref_name }}'
$content = Get-Content -Raw -Encoding UTF8 $path
$regex = [regex]'"version"\s*:\s*"[^"]*"'
$replacement = '"version": "' + $version + '"'
$updated = $regex.Replace($content, $replacement, 1)
$encoding = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($path, $updated, $encoding)
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
- name: Commit and Push Changes
if: matrix.platform == 'windows-latest'
shell: powershell
run: |
git config user.name '${{ vars.USERNAME_GIT }}'
@@ -131,12 +139,15 @@ jobs:
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
run: npm run tauri build
NO_STRIP: ${{ matrix.no_strip }}
run: npm run tauri -- build --bundles ${{ matrix.bundles }}
# ----------------------
# Upload to MinIO (via mc) and create latest.json
# ----------------------
- name: Upload artifacts to MinIO with cicd_tool
working-directory: GitLite/cicd_tool
env:
PUBLISH_PLATFORM: ${{ matrix.updater_platform }}
run: |
uv sync
uv run main.py
+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
+206 -54
View File
@@ -4,14 +4,14 @@ import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, List, Optional
from typing import Any, Iterable, List, NamedTuple, Optional
from urllib.parse import urlparse
from minio import Minio
from minio.error import S3Error
# cicd_tool lives inside the GitLite repo, so its parent dir is the app root.
# cicd_tool lives inside the Gitty repo, so its parent dir is the app root.
PROJECT_DIR = Path(__file__).resolve().parent.parent
ARTIFACT_ROOT = PROJECT_DIR / "src-tauri" / "target" / "release" / "bundle"
@@ -20,6 +20,11 @@ class ConfigurationError(RuntimeError):
"""Raised when required configuration values are missing or invalid."""
class PublishTarget(NamedTuple):
bucket_name: str
app_dir: str
def _resolve_version(version_arg: Optional[str]) -> str:
"""Resolve the version to publish, falling back to tauri.conf.json."""
if version_arg and version_arg.lower() not in {"auto"}:
@@ -115,23 +120,57 @@ def _ensure_bucket(client: Minio, bucket_name: str) -> None:
def _collect_artifacts() -> List[Path]:
artifacts: List[Path] = []
for directory in (
ARTIFACT_ROOT / "msi",
ARTIFACT_ROOT / "nsis",
ARTIFACT_ROOT / "app",
):
bundle_dirs = {
ARTIFACT_ROOT / "msi": (".msi", ".sig"),
ARTIFACT_ROOT / "nsis": (".exe", ".sig"),
ARTIFACT_ROOT / "appimage": (".appimage", ".sig"),
ARTIFACT_ROOT / "deb": (".deb", ".sig"),
ARTIFACT_ROOT / "rpm": (".rpm", ".sig"),
ARTIFACT_ROOT / "app": (".dmg", ".zip", ".sig"),
}
for directory, suffixes in bundle_dirs.items():
if directory.exists():
artifacts.extend(p for p in directory.rglob("*") if p.is_file())
artifacts.extend(
p
for p in directory.iterdir()
if p.is_file() and p.suffix.lower() in suffixes
)
return artifacts
def _select_primary_artifact(artifacts: Iterable[Path]) -> Optional[Path]:
priority = (".exe", ".msi", ".zip", ".dmg")
def _select_primary_artifact(
artifacts: Iterable[Path], platform: Optional[str] = None
) -> Optional[Path]:
artifact_list = list(artifacts)
priorities = {
"windows-x86_64": (".exe", ".msi"),
"linux-x86_64": (".appimage", ".deb", ".rpm"),
"darwin-x86_64": (".dmg", ".zip"),
"darwin-aarch64": (".dmg", ".zip"),
}
priority = priorities.get(platform or "", ())
priority += (".exe", ".msi", ".appimage", ".deb", ".rpm", ".zip", ".dmg")
for ext in priority:
for artifact in artifacts:
for artifact in artifact_list:
if artifact.suffix.lower() == ext:
return artifact
return next(iter(artifacts), None)
return next(
(artifact for artifact in artifact_list if artifact.suffix != ".sig"), None
)
def _infer_platform(artifact: Path) -> str:
suffix = artifact.suffix.lower()
if suffix in {".exe", ".msi"}:
return "windows-x86_64"
if suffix in {".appimage", ".deb", ".rpm"}:
return "linux-x86_64"
if suffix in {".dmg", ".zip"}:
return "darwin-x86_64"
raise ConfigurationError(
f"Cannot infer updater platform from artifact: {artifact.name}. "
"Set PUBLISH_PLATFORM explicitly."
)
def _read_signature(artifact: Path) -> str:
@@ -145,16 +184,55 @@ def _read_signature(artifact: Path) -> str:
raise FileNotFoundError(f"Signature not found for {artifact.name}")
def _build_latest_json(
def _build_artifact_url(
version: str,
primary_artifact: Path,
signature: str,
artifact_base_url: str,
app_dir: str,
) -> str:
base_url = artifact_base_url.rstrip("/")
path_parts = [app_dir.strip("/"), version, primary_artifact.name]
artifact_url = "/".join([base_url, *filter(None, path_parts)])
return "/".join([base_url, *filter(None, path_parts)])
def _read_existing_latest_json(
client: Minio, bucket_name: str
) -> Optional[dict[str, Any]]:
try:
response = client.get_object(bucket_name, "latest.json")
except S3Error as exc:
if exc.code in {"NoSuchKey", "NoSuchObject"}:
return None
raise
try:
payload = json.loads(response.read().decode("utf-8"))
except json.JSONDecodeError as exc:
raise ConfigurationError("Existing latest.json is invalid JSON") from exc
finally:
response.close()
response.release_conn()
if not isinstance(payload, dict):
raise ConfigurationError("Existing latest.json must be a JSON object")
return payload
def _build_latest_payload(
version: str,
platform: str,
primary_artifact: Path,
signature: str,
artifact_base_url: str,
app_dir: str,
existing_payload: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
artifact_url = _build_artifact_url(
version=version,
primary_artifact=primary_artifact,
artifact_base_url=artifact_base_url,
app_dir=app_dir,
)
pub_date = (
datetime.now(timezone.utc)
@@ -163,18 +241,92 @@ def _build_latest_json(
.replace("+00:00", "Z")
)
latest_payload = {
platforms: dict[str, Any] = {}
existing_notes = None
if existing_payload and existing_payload.get("version") == version:
existing_platforms = existing_payload.get("platforms")
if isinstance(existing_platforms, dict):
platforms.update(existing_platforms)
existing_notes = existing_payload.get("notes")
platforms[platform] = {
"signature": signature,
"url": artifact_url,
}
return {
"version": version,
"pub_date": pub_date,
"notes": os.environ.get("RELEASE_NOTES", "Automated release"),
"platforms": {
"windows-x86_64": {
"signature": signature,
"url": artifact_url,
}
},
"notes": os.environ.get("RELEASE_NOTES")
or existing_notes
or "Automated release",
"platforms": platforms,
}
return json.dumps(latest_payload, separators=(",", ":"))
def _parse_publish_targets(primary_bucket: str, primary_app_dir: str) -> list[PublishTarget]:
targets = [PublishTarget(primary_bucket, primary_app_dir)]
raw_legacy_targets = os.environ.get("LEGACY_UPDATE_TARGETS", "gitlite:gitlite")
for raw_target in raw_legacy_targets.split(","):
value = raw_target.strip()
if not value:
continue
if ":" in value:
bucket_name, app_dir = value.split(":", 1)
else:
bucket_name = value
app_dir = value
target = PublishTarget(bucket_name.strip().lower(), app_dir.strip("/"))
if target.bucket_name and target not in targets:
targets.append(target)
return targets
def _publish_target(
client: Minio,
target: PublishTarget,
version: str,
platform: str,
artifacts: Iterable[Path],
primary_artifact: Path,
signature: str,
artifact_base_url: str,
) -> None:
_ensure_bucket(client, target.bucket_name)
existing_latest_json = _read_existing_latest_json(client, target.bucket_name)
latest_payload = _build_latest_payload(
version=version,
platform=platform,
primary_artifact=primary_artifact,
signature=signature,
artifact_base_url=artifact_base_url,
app_dir=target.app_dir,
existing_payload=existing_latest_json,
)
latest_json = json.dumps(latest_payload, separators=(",", ":"))
for artifact in artifacts:
object_segments = [part for part in (version, artifact.name) if part]
object_name = "/".join(object_segments)
client.fput_object(target.bucket_name, object_name, str(artifact))
print(f"Uploaded {artifact.name} -> s3://{target.bucket_name}/{object_name}")
latest_bytes = latest_json.encode("utf-8")
client.put_object(
bucket_name=target.bucket_name,
object_name="latest.json",
data=io.BytesIO(latest_bytes),
length=len(latest_bytes),
content_type="application/json",
)
print(
f"Uploaded latest.json for {platform} -> "
f"s3://{target.bucket_name}/latest.json"
)
def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
@@ -184,9 +336,10 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
raise ConfigurationError("ARTIFACT_BASE_URL is required")
# S3/MinIO bucket names must be lowercase, so normalise regardless of how the
# S3_BUCKET variable is cased (e.g. "GitLite" -> "gitlite").
bucket_name = (os.environ.get("S3_BUCKET") or "gitlite").lower()
bucket_prefix = (app_dir or os.environ.get("APP_DIR") or "gitlite").strip("/")
# S3_BUCKET variable is cased (e.g. "Gitty" -> "gitty").
bucket_name = (os.environ.get("S3_BUCKET") or "gitty").lower()
bucket_prefix = (app_dir or os.environ.get("APP_DIR") or "gitty").strip("/")
publish_targets = _parse_publish_targets(bucket_name, bucket_prefix)
artifacts = _collect_artifacts()
if not artifacts:
@@ -194,49 +347,48 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
"No release artifacts found in src-tauri/target/release/bundle."
)
primary = _select_primary_artifact(artifacts)
publish_platform = os.environ.get("PUBLISH_PLATFORM") or os.environ.get(
"UPDATER_PLATFORM"
)
primary = _select_primary_artifact(artifacts, publish_platform)
if not primary:
raise FileNotFoundError("Unable to determine primary artifact to publish.")
platform = publish_platform or _infer_platform(primary)
signature = _read_signature(primary)
latest_json = _build_latest_json(
client = _create_minio_client()
local_dist_dir = PROJECT_DIR / "dist"
local_dist_dir.mkdir(parents=True, exist_ok=True)
latest_json_path = local_dist_dir / "latest.json"
latest_payload = _build_latest_payload(
version=resolved_version,
platform=platform,
primary_artifact=primary,
signature=signature,
artifact_base_url=artifact_base_url,
app_dir=bucket_prefix,
)
local_dist_dir = PROJECT_DIR / "dist"
local_dist_dir.mkdir(parents=True, exist_ok=True)
latest_json_path = local_dist_dir / "latest.json"
latest_json = json.dumps(latest_payload, separators=(",", ":"))
latest_json_path.write_text(latest_json + "\n", encoding="utf-8")
client = _create_minio_client()
_ensure_bucket(client, bucket_name)
for artifact in artifacts:
object_segments = [part for part in (resolved_version, artifact.name) if part]
object_name = "/".join(object_segments)
client.fput_object(bucket_name, object_name, str(artifact))
print(f"Uploaded {artifact.name} -> s3://{bucket_name}/{object_name}")
latest_object_name = "latest.json"
latest_bytes = latest_json.encode("utf-8")
client.put_object(
bucket_name=bucket_name,
object_name=latest_object_name,
data=io.BytesIO(latest_bytes),
length=len(latest_bytes),
content_type="application/json",
)
print(f"Uploaded latest.json -> s3://{bucket_name}/{latest_object_name}")
for target in publish_targets:
_publish_target(
client=client,
target=target,
version=resolved_version,
platform=platform,
artifacts=artifacts,
primary_artifact=primary,
signature=signature,
artifact_base_url=artifact_base_url,
)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Upload GitLite release artifacts to MinIO."
description="Upload Gitty release artifacts to MinIO."
)
parser.add_argument(
"-v",
@@ -247,7 +399,7 @@ if __name__ == "__main__":
parser.add_argument(
"--app-dir",
default=None,
help="App directory prefix inside the bucket (defaults to gitlite).",
help="App directory prefix inside the bucket (defaults to gitty).",
)
args = parser.parse_args()
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri Git Lite</title>
<title>Gitty</title>
</head>
<body>
<div id="app"></div>
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "tauri-git-lite",
"version": "2026.7.10",
"name": "gitty",
"version": "2026.7.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tauri-git-lite",
"version": "2026.7.10",
"name": "gitty",
"version": "2026.7.17",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+3 -2
View File
@@ -1,11 +1,12 @@
{
"name": "git-lite",
"version": "2026.7.10",
"name": "gitty",
"version": "2026.7.17",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"icons": "node scripts/generate-app-icons.mjs",
"preview": "vite preview --host 127.0.0.1",
"tauri": "tauri",
"tauri:dev": "tauri dev",
Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

+241
View File
@@ -0,0 +1,241 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gitty</title>
<style>
:root {
color-scheme: dark;
--ink: #f3f5ff;
--muted: #9aa3c5;
--purple: #9b70ff;
--orange: #ff6d26;
--panel: rgba(15, 18, 31, 0.9);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #050712;
}
body {
display: grid;
place-items: center;
background:
radial-gradient(circle at 50% 38%, rgba(137, 92, 255, 0.22), transparent 36%),
linear-gradient(135deg, #050712 0%, #080b16 48%, #140b18 100%);
}
.grid {
position: fixed;
inset: 0;
opacity: 0.28;
background-image:
linear-gradient(rgba(151, 118, 255, 0.12) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 109, 38, 0.08) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: radial-gradient(circle at center, black 0%, transparent 70%);
animation: grid-drift 10s linear infinite;
}
.splash {
position: relative;
display: grid;
justify-items: center;
gap: 16px;
width: 100vw;
height: 100vh;
align-content: center;
padding: 32px;
border: 0;
border-radius: 0;
background:
linear-gradient(180deg, rgba(23, 26, 43, 0.92), rgba(12, 15, 27, 0.94)),
var(--panel);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
overflow: hidden;
}
.mark {
position: relative;
display: grid;
place-items: center;
width: 174px;
height: 174px;
isolation: isolate;
}
.halo {
position: absolute;
inset: 10px;
border: 1px solid rgba(151, 118, 255, 0.25);
border-radius: 36px;
transform: rotate(45deg);
animation: halo-breathe 2.4s ease-in-out infinite;
}
.halo.secondary {
inset: 25px;
border-color: rgba(255, 109, 38, 0.28);
animation-direction: reverse;
}
.traces {
position: absolute;
inset: -18px;
width: 210px;
height: 210px;
overflow: visible;
}
.trace {
fill: none;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 165;
stroke-dashoffset: 165;
filter: drop-shadow(0 0 8px rgba(151, 118, 255, 0.55));
animation: trace-draw 2.6s ease-in-out infinite;
}
.trace.main {
stroke: var(--purple);
}
.trace.branch {
stroke: var(--orange);
animation-delay: 0.28s;
}
.trace.base {
stroke: rgba(255, 255, 255, 0.42);
stroke-dasharray: 128;
stroke-dashoffset: 128;
animation-delay: 0.55s;
}
.icon {
position: relative;
z-index: 1;
width: 136px;
height: 136px;
object-fit: contain;
filter:
drop-shadow(0 18px 24px rgba(0, 0, 0, 0.55))
drop-shadow(0 0 18px rgba(151, 118, 255, 0.3));
animation: icon-float 2.4s ease-in-out infinite;
}
.copy {
position: relative;
display: grid;
gap: 4px;
justify-items: center;
text-align: center;
}
.copy strong {
color: var(--ink);
font-size: 22px;
font-weight: 900;
line-height: 1;
}
.copy span {
color: var(--muted);
font-size: 12.5px;
font-weight: 700;
}
.bar {
position: relative;
width: 230px;
height: 4px;
margin-top: 2px;
overflow: hidden;
border-radius: 999px;
background: rgba(151, 118, 255, 0.14);
}
.bar span {
position: absolute;
inset: 0;
width: 46%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, var(--purple) 40%, var(--orange) 74%, transparent);
box-shadow: 0 0 16px rgba(255, 109, 38, 0.32);
animation: bar-slide 1.35s ease-in-out infinite;
}
@keyframes grid-drift {
to { transform: translate3d(42px, 42px, 0); }
}
@keyframes icon-float {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-5px) scale(1.015); }
}
@keyframes halo-breathe {
0%, 100% { opacity: 0.35; transform: rotate(45deg) scale(0.95); }
50% { opacity: 0.8; transform: rotate(45deg) scale(1.04); }
}
@keyframes trace-draw {
0% { stroke-dashoffset: 165; opacity: 0; }
36% { opacity: 1; }
64%, 100% { stroke-dashoffset: 0; opacity: 0.72; }
}
@keyframes bar-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
@media (prefers-reduced-motion: reduce) {
.grid,
.halo,
.trace,
.icon,
.bar span {
animation: none;
}
.trace {
stroke-dashoffset: 0;
opacity: 0.72;
}
}
</style>
</head>
<body>
<div class="grid" aria-hidden="true"></div>
<main class="splash" role="status" aria-live="polite">
<div class="mark" aria-hidden="true">
<span class="halo"></span>
<span class="halo secondary"></span>
<svg class="traces" viewBox="0 0 220 220">
<path class="trace main" d="M28 154 C72 114, 88 108, 110 110 S156 116, 192 68" />
<path class="trace branch" d="M62 74 C100 88, 126 124, 158 166" />
<path class="trace base" d="M46 180 L174 180" />
</svg>
<img class="icon" src="/splash-icon.png" alt="" />
</div>
<div class="copy">
<strong>Gitty</strong>
<span>Starting workspace</span>
</div>
<div class="bar" aria-hidden="true"><span></span></div>
</main>
</body>
</html>
+266
View File
@@ -0,0 +1,266 @@
import { Buffer } from "node:buffer";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { deflateSync, inflateSync } from "node:zlib";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const iconsDir = join(root, "src-tauri", "icons");
const sourcePath = join(iconsDir, "GitCat_org.png");
const pngSizes = [32, 64, 128, 512];
const icoSizes = [16, 24, 32, 48, 64, 128, 256];
function decodePng(buffer) {
if (buffer.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") {
throw new Error("Source icon must be a PNG file.");
}
let width = 0;
let height = 0;
const idatChunks = [];
for (let offset = 8; offset < buffer.length; ) {
const length = buffer.readUInt32BE(offset);
const type = buffer.subarray(offset + 4, offset + 8).toString("ascii");
const dataStart = offset + 8;
const dataEnd = dataStart + length;
const data = buffer.subarray(dataStart, dataEnd);
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
if (data[8] !== 8 || data[9] !== 6) {
throw new Error("Source icon must be an 8-bit RGBA PNG.");
}
} else if (type === "IDAT") {
idatChunks.push(data);
} else if (type === "IEND") {
break;
}
offset = dataEnd + 4;
}
const stride = width * 4;
const raw = inflateSync(Buffer.concat(idatChunks));
const rgba = Buffer.alloc(width * height * 4);
for (let y = 0; y < height; y += 1) {
const rowStart = y * (stride + 1);
const filter = raw[rowStart];
const input = raw.subarray(rowStart + 1, rowStart + 1 + stride);
const priorOffset = (y - 1) * stride;
const outputOffset = y * stride;
for (let x = 0; x < stride; x += 1) {
const left = x >= 4 ? rgba[outputOffset + x - 4] : 0;
const up = y > 0 ? rgba[priorOffset + x] : 0;
const upLeft = y > 0 && x >= 4 ? rgba[priorOffset + x - 4] : 0;
let value = input[x];
if (filter === 1) value += left;
else if (filter === 2) value += up;
else if (filter === 3) value += Math.floor((left + up) / 2);
else if (filter === 4) value += paeth(left, up, upLeft);
else if (filter !== 0) throw new Error(`Unsupported PNG filter: ${filter}`);
rgba[outputOffset + x] = value & 0xff;
}
}
return { width, height, rgba };
}
function paeth(left, up, upLeft) {
const estimate = left + up - upLeft;
const distanceLeft = Math.abs(estimate - left);
const distanceUp = Math.abs(estimate - up);
const distanceUpLeft = Math.abs(estimate - upLeft);
if (distanceLeft <= distanceUp && distanceLeft <= distanceUpLeft) return left;
if (distanceUp <= distanceUpLeft) return up;
return upLeft;
}
function visibleSquare(image, alphaThreshold = 8) {
let minX = image.width;
let minY = image.height;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < image.height; y += 1) {
for (let x = 0; x < image.width; x += 1) {
const alpha = image.rgba[(y * image.width + x) * 4 + 3];
if (alpha > alphaThreshold) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
}
if (maxX < 0 || maxY < 0) {
throw new Error("Source icon has no visible pixels.");
}
const visibleWidth = maxX - minX + 1;
const visibleHeight = maxY - minY + 1;
const side = Math.max(visibleWidth, visibleHeight);
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const left = Math.max(0, Math.min(image.width - side, Math.round(centerX - side / 2)));
const top = Math.max(0, Math.min(image.height - side, Math.round(centerY - side / 2)));
return { left, top, side };
}
function resizePng(image, crop, size) {
const rgba = Buffer.alloc(size * size * 4);
const scale = crop.side / size;
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const sourceX = crop.left + (x + 0.5) * scale - 0.5;
const sourceY = crop.top + (y + 0.5) * scale - 0.5;
const pixel = sampleBilinear(image, sourceX, sourceY);
const offset = (y * size + x) * 4;
rgba[offset] = pixel.r;
rgba[offset + 1] = pixel.g;
rgba[offset + 2] = pixel.b;
rgba[offset + 3] = pixel.a;
}
}
return encodePng(size, size, rgba);
}
function sampleBilinear(image, x, y) {
const x0 = Math.max(0, Math.min(image.width - 1, Math.floor(x)));
const y0 = Math.max(0, Math.min(image.height - 1, Math.floor(y)));
const x1 = Math.max(0, Math.min(image.width - 1, x0 + 1));
const y1 = Math.max(0, Math.min(image.height - 1, y0 + 1));
const tx = x - x0;
const ty = y - y0;
const top = mixPixels(getPixel(image, x0, y0), getPixel(image, x1, y0), tx);
const bottom = mixPixels(getPixel(image, x0, y1), getPixel(image, x1, y1), tx);
return mixPixels(top, bottom, ty);
}
function getPixel(image, x, y) {
const offset = (y * image.width + x) * 4;
return {
r: image.rgba[offset],
g: image.rgba[offset + 1],
b: image.rgba[offset + 2],
a: image.rgba[offset + 3],
};
}
function mixPixels(a, b, t) {
return {
r: Math.round(a.r + (b.r - a.r) * t),
g: Math.round(a.g + (b.g - a.g) * t),
b: Math.round(a.b + (b.b - a.b) * t),
a: Math.round(a.a + (b.a - a.a) * t),
};
}
const crcTable = new Uint32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
crcTable[n] = c >>> 0;
}
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function chunk(type, payload) {
const typeBytes = Buffer.from(type);
const out = Buffer.alloc(12 + payload.length);
out.writeUInt32BE(payload.length, 0);
typeBytes.copy(out, 4);
payload.copy(out, 8);
out.writeUInt32BE(crc32(Buffer.concat([typeBytes, payload])), out.length - 4);
return out;
}
function encodePng(width, height, rgba) {
const header = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8;
ihdr[9] = 6;
const raw = Buffer.alloc((width * 4 + 1) * height);
for (let y = 0; y < height; y += 1) {
const rowStart = y * (width * 4 + 1);
raw[rowStart] = 0;
rgba.copy(raw, rowStart + 1, y * width * 4, (y + 1) * width * 4);
}
return Buffer.concat([
header,
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
function makeIco(images) {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(images.length, 4);
const entries = Buffer.alloc(images.length * 16);
let offset = 6 + images.length * 16;
images.forEach((image, index) => {
const entry = index * 16;
entries[entry] = image.size >= 256 ? 0 : image.size;
entries[entry + 1] = image.size >= 256 ? 0 : image.size;
entries[entry + 2] = 0;
entries[entry + 3] = 0;
entries.writeUInt16LE(1, entry + 4);
entries.writeUInt16LE(32, entry + 6);
entries.writeUInt32LE(image.png.length, entry + 8);
entries.writeUInt32LE(offset, entry + 12);
offset += image.png.length;
});
return Buffer.concat([header, entries, ...images.map((image) => image.png)]);
}
mkdirSync(iconsDir, { recursive: true });
const source = decodePng(readFileSync(sourcePath));
const crop = visibleSquare(source);
const rendered = new Map(
[...new Set([...pngSizes, ...icoSizes])].map((size) => [size, resizePng(source, crop, size)]),
);
writeFileSync(join(iconsDir, "32x32.png"), rendered.get(32));
writeFileSync(join(iconsDir, "64x64.png"), rendered.get(64));
writeFileSync(join(iconsDir, "128x128.png"), rendered.get(128));
writeFileSync(join(iconsDir, "icon.png"), rendered.get(512));
writeFileSync(join(iconsDir, "icon.ico"), makeIco(icoSizes.map((size) => ({ size, png: rendered.get(size) }))));
console.log(`Read ${join("src-tauri", "icons", "GitCat_org.png")}`);
console.log(`Cropped visible icon area to ${crop.side}x${crop.side} at ${crop.left},${crop.top}`);
console.log(`Wrote ${join("src-tauri", "icons", "32x32.png")}`);
console.log(`Wrote ${join("src-tauri", "icons", "64x64.png")}`);
console.log(`Wrote ${join("src-tauri", "icons", "128x128.png")}`);
console.log(`Wrote ${join("src-tauri", "icons", "icon.png")}`);
console.log(`Wrote ${join("src-tauri", "icons", "icon.ico")} (${icoSizes.length} sizes)`);
+4
View File
@@ -1,3 +1,7 @@
import "./generate-app-icons.mjs";
process.exit(0);
import { Buffer } from "node:buffer";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
+293 -13
View File
@@ -1128,7 +1128,7 @@ dependencies = [
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -2027,6 +2027,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -2034,7 +2043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -2048,6 +2057,12 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -2517,7 +2532,7 @@ dependencies = [
]
[[package]]
name = "git_lite"
name = "gitty"
version = "0.1.0"
dependencies = [
"commit_ai",
@@ -2526,8 +2541,11 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-aptabase",
"tauri-plugin-dialog",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tokio",
]
[[package]]
@@ -2895,6 +2913,22 @@ dependencies = [
"webpki-roots 1.0.8",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -3387,7 +3421,7 @@ dependencies = [
"security-framework 2.11.1",
"security-framework 3.7.0",
"windows-sys 0.60.2",
"zbus",
"zbus 4.4.0",
"zeroize",
]
@@ -4007,6 +4041,23 @@ dependencies = [
"typenum",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework 3.7.0",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -4059,6 +4110,18 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "no_std_io2"
version = "0.9.4"
@@ -4523,12 +4586,49 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -4554,6 +4654,22 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_info"
version = "3.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
dependencies = [
"android_system_properties",
"log",
"nix 0.31.3",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
"serde",
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
@@ -5342,11 +5458,13 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -5357,6 +5475,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -5777,7 +5896,7 @@ dependencies = [
"rand 0.8.6",
"serde",
"sha2",
"zbus",
"zbus 4.4.0",
]
[[package]]
@@ -6618,6 +6737,15 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "sysctl"
version = "0.6.0"
@@ -6877,6 +7005,26 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-aptabase"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "075ebe083b5f311bdfbf436f8383d316dfb12267be5fd977b57d7701bdb99987"
dependencies = [
"futures",
"log",
"os_info",
"rand 0.9.4",
"reqwest 0.12.28",
"serde",
"serde_json",
"sys-locale",
"tauri",
"tauri-plugin",
"time",
"tokio",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.0"
@@ -6919,6 +7067,21 @@ dependencies = [
"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]]
name = "tauri-plugin-updater"
version = "2.10.1"
@@ -7308,6 +7471,16 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rayon"
version = "2.1.0"
@@ -7942,6 +8115,12 @@ dependencies = [
"uuid 0.8.2",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -8952,7 +9131,7 @@ dependencies = [
"futures-sink",
"futures-util",
"hex",
"nix",
"nix 0.29.0",
"ordered-stream",
"rand 0.8.6",
"serde",
@@ -8963,9 +9142,44 @@ dependencies = [
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros",
"zbus_names",
"zvariant",
"zbus_macros 4.4.0",
"zbus_names 3.0.0",
"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]]
@@ -8978,7 +9192,22 @@ dependencies = [
"proc-macro2",
"quote",
"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]]
@@ -8989,7 +9218,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"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]]
@@ -9150,7 +9390,21 @@ dependencies = [
"enumflags2",
"serde",
"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]]
@@ -9163,7 +9417,20 @@ dependencies = [
"proc-macro2",
"quote",
"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]]
@@ -9176,3 +9443,16 @@ dependencies = [
"quote",
"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",
]
+4 -1
View File
@@ -1,5 +1,5 @@
[package]
name = "git_lite"
name = "gitty"
version = "0.1.0"
description = "Rust backend for a lightweight Git desktop client"
edition = "2024"
@@ -17,13 +17,16 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "=2.7.0"
tauri-plugin-aptabase = "1.0"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
commit_ai = { path = "crates/commit_ai" }
tokio = "1.52.3"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
# ── Build profiles ───────────────────────────────────────────────────────────
+1
View File
@@ -1,3 +1,4 @@
fn main() {
println!("cargo:rerun-if-env-changed=APTABASE_KEY");
tauri_build::build();
}
+2 -1
View File
@@ -11,6 +11,7 @@
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"dialog:allow-open"
"dialog:allow-open",
"aptabase:allow-track-event"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+72
View File
@@ -0,0 +1,72 @@
!macro GITTY_MIGRATE_LEGACY_DATA_DIR oldDir newDir
${IfNot} ${FileExists} "${newDir}\*.*"
${AndIf} ${FileExists} "${oldDir}\*.*"
Rename "${oldDir}" "${newDir}"
${EndIf}
!macroend
!macro GITTY_UNINSTALL_LEGACY_GITLITE rootKey
ClearErrors
ReadRegStr $R0 ${rootKey} "Software\Microsoft\Windows\CurrentVersion\Uninstall\GitLite" "UninstallString"
${If} "$R0" != ""
ReadRegStr $R1 ${rootKey} "Software\git-lite\GitLite" ""
${If} "$R1" == ""
ReadRegStr $R1 ${rootKey} "Software\Microsoft\Windows\CurrentVersion\Uninstall\GitLite" "InstallLocation"
${EndIf}
DetailPrint "Removing legacy GitLite installation"
${If} "$R1" != ""
ExecWait '$R0 /S _?=$R1' $R2
${Else}
ExecWait '$R0 /S' $R2
${EndIf}
${EndIf}
DeleteRegKey ${rootKey} "Software\Microsoft\Windows\CurrentVersion\Uninstall\GitLite"
DeleteRegKey ${rootKey} "Software\git-lite\GitLite"
DeleteRegKey /ifempty ${rootKey} "Software\git-lite"
!macroend
!macro GITTY_CLEANUP_LEGACY_SHORTCUTS
Delete "$DESKTOP\GitLite.lnk"
Delete "$SMPROGRAMS\GitLite.lnk"
RMDir /r "$SMPROGRAMS\GitLite"
!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
!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_UNINSTALL_LEGACY_GITLITE HKCU
!insertmacro GITTY_UNINSTALL_LEGACY_GITLITE HKLM
!macroend
!macro NSIS_HOOK_POSTINSTALL
!insertmacro GITTY_CLEANUP_LEGACY_SHORTCUTS
!insertmacro GITTY_REGISTER_INSTALL
!insertmacro GITTY_CREATE_UPDATE_SHORTCUTS
!macroend
!macro NSIS_HOOK_POSTUNINSTALL
!insertmacro GITTY_CLEANUP_LEGACY_SHORTCUTS
!macroend
+935 -141
View File
File diff suppressed because it is too large Load Diff
+63 -12
View File
@@ -5,26 +5,65 @@ mod git;
use badge::set_sync_badge;
use git::{
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models,
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch,
cred_delete, cred_load, cred_save, delete_branch, diff_file_against_working_tree, fetch,
get_file_patch, get_remote_url, get_status, list_branches, list_commits, list_file_history,
list_repository_files, list_stashes, merge_branch, open_repo_in_explorer, open_repository,
open_repository_bundle, open_repository_file, pull, push, read_conflict, rebase_abort,
rebase_branch, rebase_continue, rename_branch, resolve_conflict, resolve_conflict_side,
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions,
stage_files, stash_apply, stash_drop, stash_pop, stash_push, unstage_files,
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame,
get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits,
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
stash_push, undo_last_commit, unstage_files,
};
use tauri::{Manager, AppHandle};
fn main() {
#[tauri::command]
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("splashscreen") {
let _ = window.hide();
window
.destroy()
.map_err(|error| format!("failed to destroy splashscreen: {error}"))?;
}
if let Some(window) = app.get_webview_window("main") {
window
.show()
.map_err(|error| format!("failed to show main window: {error}"))?;
let _ = window.set_focus();
}
Ok(())
}
#[tokio::main]
async fn main() {
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_aptabase::Builder::new("A-SH-1344793789")
.with_options(tauri_plugin_aptabase::InitOptions {
host: Some("https://aptabase.cbsk-tech.de".to_string()),
flush_interval: None,
})
.build(),
)
.manage(SearchCancellationState::default())
.manage(commit_ai::CommitAiEngine::new())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
open_repository,
clone_repository,
open_repo_in_explorer,
open_repository_file,
get_status,
@@ -34,6 +73,13 @@ fn main() {
create_branch,
rename_branch,
delete_branch,
list_tags,
create_tag,
delete_tag,
push_tag,
cherry_pick_commit,
cherry_pick_continue,
cherry_pick_abort,
stage_files,
unstage_files,
stash_push,
@@ -44,6 +90,9 @@ fn main() {
get_file_patch,
apply_file_patch,
commit,
amend_commit,
undo_last_commit,
last_commit_message,
commit_ai_status,
commit_ai_load,
commit_ai_local_models,
@@ -62,6 +111,7 @@ fn main() {
open_repository_bundle,
list_file_history,
cancel_file_history,
get_file_blame,
compare_commits,
compare_file_to_head,
compare_file_to_parent,
@@ -75,7 +125,8 @@ fn main() {
cred_load,
cred_save,
cred_delete,
set_sync_badge
set_sync_badge,
close_splashscreen
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+32 -8
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GitLite",
"version": "2026.7.10",
"identifier": "com.git-lite",
"productName": "Gitty",
"version": "2026.7.17",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://127.0.0.1:1420",
@@ -13,13 +13,26 @@
"windows": [
{
"label": "main",
"title": "GitLite",
"title": "Gitty",
"width": 1200,
"height": 800,
"minWidth": 900,
"minHeight": 600,
"visible": false,
"decorations": false,
"shadow": true
},
{
"label": "splashscreen",
"url": "splashscreen.html",
"title": "Gitty",
"width": 460,
"height": 420,
"resizable": false,
"decorations": false,
"center": true,
"skipTaskbar": true,
"shadow": true
}
],
"security": {
@@ -28,19 +41,30 @@
},
"bundle": {
"active": true,
"targets": ["nsis"],
"icon": ["icons/icon.ico"],
"targets": [
"nsis"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"createUpdaterArtifacts": true,
"windows": {
"nsis": {
"installMode": "currentUser"
"installMode": "currentUser",
"installerIcon": "icons/icon.ico",
"installerHooks": "nsis-hooks.nsh"
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdDNEJGRTZERTREOTc5QjkKUldTNWVkbmtiZjVMZk02YzdpenVLOVltREJpRkFrc2F4dUwzaTV5M3pacFRVR0tmd25xSGtjK1MK",
"endpoints" : [
"endpoints": [
"https://cdn.cbsk-tech.de/gitty/latest.json",
"https://cdn.cbsk-tech.de/gitlite/latest.json"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"targets": ["appimage"]
}
}
+1767 -104
View File
File diff suppressed because it is too large Load Diff
+1425 -198
View File
File diff suppressed because it is too large Load Diff
+47 -28
View File
@@ -2,7 +2,8 @@
import { onDestroy, onMount } from "svelte";
import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte";
import iconUrl from "../../src-tauri/icons/icon.png";
export let branch: string = "";
export let ahead: number = 0;
@@ -21,47 +22,55 @@
export let onCompare: () => void = () => {};
export let onOpenInExplorer: () => void = () => {};
export let onToggleAutoRefresh: () => void = () => {};
export let onOpenSettings: () => void = () => {};
const win = getCurrentWindow();
let win: ReturnType<typeof getCurrentWindow> | null = null;
let isMaximized = false;
let unlisten: (() => void) | undefined;
let appVersion = "";
onMount(async () => {
isMaximized = await win.isMaximized();
unlisten = await win.onResized(async () => {
try {
win = getCurrentWindow();
isMaximized = await win.isMaximized();
});
appVersion = await getVersion();
unlisten = await win.onResized(async () => {
isMaximized = await win?.isMaximized() ?? false;
});
} catch {
win = null;
}
try {
appVersion = await getVersion();
} catch {
appVersion = "";
}
});
onDestroy(() => {
unlisten?.();
});
function minimizeWindow() {
void win?.minimize();
}
function toggleMaximizeWindow() {
void win?.toggleMaximize();
}
function closeWindow() {
void win?.close();
}
</script>
<header class="titlebar" data-tauri-drag-region>
<!-- Brand -->
<div class="titlebar-brand" data-tauri-drag-region>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
data-tauri-drag-region
>
<circle cx="12" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />
<circle cx="18" cy="6" r="3" />
<path d="M18 9v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9" />
<line x1="12" y1="12" x2="12" y2="15" />
</svg>
<span data-tauri-drag-region>GitLite</span>
<span class="titlebar-brand-icon" data-tauri-drag-region>
<img src={iconUrl} alt="" data-tauri-drag-region />
</span>
<span data-tauri-drag-region>Gitty</span>
{#if appVersion}
<span class="tb-version" data-tauri-drag-region title="Version {appVersion}">v{appVersion}</span>
{/if}
@@ -198,17 +207,27 @@
/>
<span class="tb-action-label">Auto</span>
</button>
<button
class="tb-action"
onclick={onOpenSettings}
title="Settings"
aria-label="Settings"
>
<Settings size={14} aria-hidden="true" />
<span class="tb-action-label">Settings</span>
</button>
</div>
<div class="titlebar-divider" aria-hidden="true"></div>
<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" />
</button>
<button
class="tb-btn"
onclick={() => win.toggleMaximize()}
onclick={toggleMaximizeWindow}
title={isMaximized ? "Restore" : "Maximize"}
aria-label={isMaximized ? "Restore" : "Maximize"}
>
@@ -223,7 +242,7 @@
</svg>
{/if}
</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" />
</button>
</div>
+12
View File
@@ -0,0 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
export type AnalyticsEventProperties = Record<string, string | number>;
export function trackAnalyticsEvent(name: string, props?: AnalyticsEventProperties) {
void invoke("plugin:aptabase|track_event", {
name,
props: props && Object.keys(props).length > 0 ? props : undefined,
}).catch(() => {
// Analytics must never interrupt the Git workflow.
});
}
+16 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import { onDestroy, onMount } from "svelte";
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
@@ -36,6 +36,7 @@
let loadingKeys = $state(true);
let saving = $state(false);
let error = $state("");
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
provider = settings.provider;
@@ -47,6 +48,16 @@
customModel = settings.customModel;
});
$effect(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
const currentError = error;
if (currentError) {
errorHideTimer = setTimeout(() => {
if (error === currentError) error = "";
}, 6000);
}
});
onMount(() => {
(async () => {
try {
@@ -66,6 +77,10 @@
})();
});
onDestroy(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
});
async function persistKey(target: CloudProvider, value: string) {
const key = CRED_KEYS[target];
const trimmed = value.trim();
@@ -126,7 +141,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
<header class="dialog-header">
@@ -0,0 +1,57 @@
<script lang="ts">
import { BarChart3, Check, ShieldCheck } from "@lucide/svelte";
interface Props {
enabled: boolean;
onContinue: (enabled: boolean) => void;
}
let { enabled = true, onContinue = () => {} }: Props = $props();
let allowAnalytics = $state(true);
$effect(() => {
allowAnalytics = enabled;
});
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog analytics-notice-dialog" role="dialog" aria-modal="true" aria-label="Analytics notice" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Privacy</span>
<h2 class="dialog-title">Anonymous usage analytics</h2>
</div>
<ShieldCheck size={20} aria-hidden="true" />
</header>
<div class="analytics-notice-body">
<div class="analytics-notice-icon">
<BarChart3 size={24} aria-hidden="true" />
</div>
<div class="analytics-notice-copy">
<p>
Gitty can send anonymous usage events to Aptabase so crashes, rough edges, and commonly used workflows are easier to improve.
</p>
<p>
Events do not include repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, or source code.
</p>
</div>
<label class="settings-toggle-row analytics-notice-toggle">
<input type="checkbox" bind:checked={allowAnalytics} />
<span>
<strong>Allow anonymous analytics</strong>
<small>You can change this later in Settings.</small>
</span>
</label>
</div>
<footer class="dialog-footer analytics-notice-footer">
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage events.</span>
<button class="btn-primary" type="button" onclick={() => onContinue(allowAnalytics)}>
<Check size={16} aria-hidden="true" />
Continue
</button>
</footer>
</div>
</div>
@@ -0,0 +1,96 @@
<script lang="ts">
import { Check, Settings, X } from "@lucide/svelte";
import type { AnalyticsSettings, AppTheme } from "../types";
interface Props {
analytics: AnalyticsSettings;
theme: AppTheme;
onSave: (settings: AnalyticsSettings, theme: AppTheme) => void;
onClose: () => void;
}
let { analytics, theme = "system", onSave = () => {}, onClose = () => {} }: Props = $props();
let analyticsEnabled = $state(true);
let selectedTheme = $state<AppTheme>("system");
$effect(() => {
analyticsEnabled = analytics.enabled;
selectedTheme = theme;
});
function save() {
onSave({
...analytics,
enabled: analyticsEnabled,
noticeSeen: true,
}, selectedTheme);
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Gitty</span>
<h2 class="dialog-title">Settings</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<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">
<header>
<Settings size={16} aria-hidden="true" />
<div>
<span class="eyebrow">Analytics</span>
<h3>Anonymous usage analytics</h3>
</div>
</header>
<label class="settings-toggle-row">
<input type="checkbox" bind:checked={analyticsEnabled} />
<span>
<strong>Allow anonymous Aptabase events</strong>
<small>No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent.</small>
</span>
</label>
</section>
<div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose}>Cancel</button>
<button class="btn-primary" type="submit">
<Check size={16} aria-hidden="true" />
Save
</button>
</div>
</form>
</div>
</div>
+230
View File
@@ -0,0 +1,230 @@
<script lang="ts">
import { FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
import type { GitBlameLine } from "../types";
interface BlameGroup {
id: string;
hash: string;
shortHash: string;
authorName: string;
authorEmail: string;
summary: string;
authorTime: number;
isUncommitted: boolean;
lines: GitBlameLine[];
}
interface TextSegment {
text: string;
matched: boolean;
}
interface Props {
filePath: string;
lines: GitBlameLine[];
isBusy: boolean;
isLoading: boolean;
error: string;
onClose: () => void;
}
let {
filePath = "",
lines = [],
isBusy = false,
isLoading = false,
error = "",
onClose = () => {},
}: Props = $props();
let blameSearch = $state("");
function groupBlameLines(source: GitBlameLine[]): BlameGroup[] {
const groups: BlameGroup[] = [];
for (const line of source) {
const last = groups[groups.length - 1];
if (last && last.hash === line.commit_hash) {
last.lines.push(line);
continue;
}
groups.push({
id: `${line.commit_hash}-${line.line_number}`,
hash: line.commit_hash,
shortHash: line.short_hash,
authorName: line.author_name,
authorEmail: line.author_email,
summary: line.summary,
authorTime: line.author_time,
isUncommitted: line.is_uncommitted,
lines: [line],
});
}
return groups;
}
function formatBlameDate(seconds: number): string {
if (!seconds) return "";
const date = new Date(seconds * 1000);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function groupTooltip(group: BlameGroup): string {
if (group.isUncommitted) return "Not committed yet";
return `${group.authorName} <${group.authorEmail}>\n${group.summary}\n${group.hash}`;
}
function blameLineSearchText(line: GitBlameLine): string {
return [
line.line_number,
line.content,
line.commit_hash,
line.short_hash,
line.author_name,
line.author_email,
formatBlameDate(line.author_time),
line.summary,
line.is_uncommitted ? "uncommitted not committed yet" : "",
].join("\n").toLowerCase();
}
function lineMatchesSearch(line: GitBlameLine, query: string): boolean {
return !query || blameLineSearchText(line).includes(query);
}
function textSegments(value: string | number): TextSegment[] {
const text = String(value ?? "");
const query = blameSearch.trim();
if (!query || !text) return [{ text, matched: false }];
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const segments: TextSegment[] = [];
let cursor = 0;
let matchIndex = lowerText.indexOf(lowerQuery);
while (matchIndex !== -1) {
if (matchIndex > cursor) {
segments.push({ text: text.slice(cursor, matchIndex), matched: false });
}
const end = matchIndex + query.length;
segments.push({ text: text.slice(matchIndex, end), matched: true });
cursor = end;
matchIndex = lowerText.indexOf(lowerQuery, cursor);
}
if (cursor < text.length) {
segments.push({ text: text.slice(cursor), matched: false });
}
return segments.length > 0 ? segments : [{ text, matched: false }];
}
let searchQuery = $derived(blameSearch.trim().toLowerCase());
let searchActive = $derived(searchQuery.length > 0);
let visibleLines = $derived(searchActive ? lines.filter((line) => lineMatchesSearch(line, searchQuery)) : lines);
let groups = $derived(groupBlameLines(visibleLines));
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog blame-dialog" role="dialog" aria-modal="true" aria-label="File blame">
<header class="dialog-header">
<div>
<span class="eyebrow">Blame</span>
<p class="dialog-title" title={filePath}>{filePath}</p>
</div>
<div class="dialog-header-actions">
<span class="pill pill-count">{lines.length}</span>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</div>
</header>
<div class="dialog-diff blame-body">
{#if isLoading}
<div class="blank-state">
<LoaderCircle class="spin" size={18} aria-hidden="true" />
Loading blame...
</div>
{:else if error}
<div class="blank-state">{error}</div>
{:else if lines.length === 0}
<div class="blank-state">No blame information available for this file.</div>
{:else}
<div class="diff-header blame-code-header">
<FileCode size={13} aria-hidden="true" />
<span title={filePath}>{filePath}</span>
<strong>{searchActive ? `${visibleLines.length} of ${lines.length} lines` : `${lines.length} lines`}</strong>
</div>
<div class="blame-search-bar">
<Search size={14} aria-hidden="true" />
<input
bind:value={blameSearch}
autocomplete="off"
spellcheck="false"
placeholder="Search blame"
aria-label="Search blame"
/>
{#if searchActive}
<button class="btn-sm blame-search-clear" type="button" onclick={() => { blameSearch = ""; }} aria-label="Clear blame search">
<X size={14} aria-hidden="true" />
</button>
{/if}
</div>
<div class="split-col-headers blame-column-headers">
<div class="split-col-label blame-commit-col-label">Commit</div>
<div class="split-col-label blame-code-col-label">Code</div>
</div>
<div class="split-diff blame-diff" role="table" aria-label="File blame">
<div class="split-pane blame-scroll">
{#if groups.length === 0}
<div class="blank-state">No matches found.</div>
{:else}
<div class="blame-code-table">
{#each groups as group (group.id)}
<div class="blame-group" class:uncommitted={group.isUncommitted} title={groupTooltip(group)}>
<div class="blame-meta">
<span class="blame-hash">
{#each textSegments(group.isUncommitted ? "Uncommitted" : group.shortHash) as segment, index (`hash-${group.id}-${index}`)}
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
{/each}
</span>
<span class="blame-author">
{#each textSegments(group.isUncommitted ? "Not committed yet" : group.authorName) as segment, index (`author-${group.id}-${index}`)}
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
{/each}
</span>
<span class="blame-summary">
{#each textSegments(group.summary || "No commit message") as segment, index (`summary-${group.id}-${index}`)}
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
{/each}
</span>
{#if !group.isUncommitted}
<span class="blame-date">
{#each textSegments(formatBlameDate(group.authorTime)) as segment, index (`date-${group.id}-${index}`)}
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
{/each}
</span>
{/if}
</div>
<div class="split-pane-grid blame-lines">
{#each group.lines as line (line.line_number)}
<span class="split-num blame-line-number">{line.line_number}</span>
<code class="split-cell blame-line-code">
{#each textSegments(line.content) as segment, index (`line-${line.line_number}-${index}`)}
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
{/each}
</code>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
@@ -20,13 +20,9 @@
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
function closeFromBackdrop(event: MouseEvent) {
if (isBusy || event.target !== event.currentTarget) return;
onClose();
}
</script>
<div class="dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
<div class="dialog-backdrop" role="presentation">
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header">
<div>
+205 -7
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Trash2, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -45,6 +45,7 @@
branches: GitBranchInfo[];
localBranches: GitBranchInfo[];
remoteBranches: GitBranchInfo[];
tags: GitTag[];
hasRepository: boolean;
isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void;
@@ -53,12 +54,18 @@
onCreateBranch: (branchName: string) => void | Promise<void>;
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
let {
branches = [],
localBranches = [],
remoteBranches = [],
tags = [],
hasRepository = false,
isBusy = false,
onCheckout = () => {},
@@ -67,17 +74,30 @@
onCreateBranch = () => {},
onRenameBranch = () => {},
onDeleteBranch = () => {},
onCreateTag = () => {},
onDeleteTag = () => {},
onPushTag = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
let localOpen = $state(true);
let remoteOpen = $state(false);
let tagsOpen = $state(false);
let createOpen = $state(false);
let newBranchName = $state("");
let createInput = $state<HTMLInputElement | null>(null);
let tagCreateOpen = $state(false);
let newTagName = $state("");
let newTagMessage = $state("");
let tagCreateInput = $state<HTMLInputElement | null>(null);
let panelElement = $state<HTMLElement | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextTag = $state<GitTag | null>(null);
let tagContextMenuX = $state(0);
let tagContextMenuY = $state(0);
let collapsedBranchFolders = $state<Set<string>>(new Set());
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
@@ -270,14 +290,76 @@
await onRebase(branch);
}
function openTagCreateForm() {
if (!hasRepository || isBusy) return;
tagCreateOpen = true;
queueMicrotask(() => tagCreateInput?.focus());
}
function closeTagCreateForm() {
tagCreateOpen = false;
newTagName = "";
newTagMessage = "";
}
async function submitCreateTag(event: SubmitEvent) {
event.preventDefault();
const name = newTagName.trim();
if (!name || !hasRepository || isBusy) return;
await onCreateTag(name, newTagMessage.trim());
newTagName = "";
newTagMessage = "";
tagCreateOpen = false;
tagsOpen = true;
}
function openTagContextMenu(event: MouseEvent, tag: GitTag) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 130);
contextTag = tag;
tagContextMenuX = Math.max(8, Math.min(rawX, maxX));
tagContextMenuY = Math.max(8, Math.min(rawY, maxY));
}
function closeTagContextMenu() {
contextTag = null;
}
async function pushContextTag() {
const tag = contextTag;
if (!tag || isBusy) return;
closeTagContextMenu();
await onPushTag(tag);
}
async function deleteContextTag() {
const tag = contextTag;
if (!tag || isBusy) return;
closeTagContextMenu();
await onDeleteTag(tag);
}
function closeAllContextMenus() {
closeBranchContextMenu();
closeTagContextMenu();
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeBranchContextMenu();
if (event.key === "Escape") closeAllContextMenus();
}
</script>
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeBranchContextMenu} />
<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>
<span class="eyebrow">Branches</span>
@@ -295,12 +377,28 @@
<Plus size={14} aria-hidden="true" />
</button>
<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>
{#if !hasRepository}
{#if collapsed}
<!-- collapsed -->
{:else if !hasRepository}
<p class="blank-state">Open a repository to list branches.</p>
{:else if branches.length === 0}
{:else if branches.length === 0 && tags.length === 0}
<p class="blank-state">No branches returned.</p>
{:else}
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
@@ -458,6 +556,86 @@
{/if}
{/if}
</div>
<div class="branch-group">
<div class="tag-group-head">
<button
class="branch-group-toggle"
type="button"
onclick={() => { tagsOpen = !tagsOpen; }}
aria-expanded={tagsOpen}
>
{#if tagsOpen}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Tags</span>
<span class="branch-group-count">{tags.length}</span>
</button>
<button
class="branch-create-toggle"
type="button"
onclick={openTagCreateForm}
disabled={!hasRepository || isBusy}
title="Create new tag"
aria-label="Create new tag"
>
<Plus size={13} aria-hidden="true" />
</button>
</div>
{#if tagCreateOpen}
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
<TagIcon size={15} aria-hidden="true" />
<input
bind:this={tagCreateInput}
bind:value={newTagName}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="v1.0.0"
aria-label="New tag name"
/>
<input
bind:value={newTagMessage}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="Message (optional)"
aria-label="Tag message"
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title="Create tag">
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title="Cancel">
<X size={14} aria-hidden="true" />
</button>
</form>
{/if}
{#if tagsOpen}
{#if tags.length === 0}
<div class="branch-empty">No tags.</div>
{:else}
{#each tags as tag (tag.name)}
<article
class="branch-row"
oncontextmenu={(event) => openTagContextMenu(event, tag)}
title={tag.message ?? tag.name}
>
<div class="branch-info">
<TagIcon size={16} aria-hidden="true" />
<div>
<strong>{tag.name}</strong>
<span>{tag.short_hash}</span>
</div>
</div>
</article>
{/each}
{/if}
{/if}
</div>
</div>
{/if}
@@ -499,4 +677,24 @@
</button>
</div>
{/if}
{#if contextTag}
<div
class="branch-context-menu"
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextTag.name}`}
>
<button type="button" role="menuitem" onclick={pushContextTag} disabled={isBusy}>
<Upload size={14} aria-hidden="true" />
Push to remote
</button>
<div class="menu-separator" role="separator"></div>
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title="Delete local tag">
<Trash2 size={14} aria-hidden="true" />
Delete
</button>
</div>
{/if}
</section>
@@ -0,0 +1,175 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { Download, FolderOpen, LoaderCircle, X } from "@lucide/svelte";
interface Props {
isBusy: boolean;
error: string;
onClone: (remoteUrl: string, parentPath: string, directoryName: string) => void;
onClose: () => void;
}
let {
isBusy = false,
error = "",
onClone = () => {},
onClose = () => {},
}: Props = $props();
let remoteUrl = $state("");
let parentPath = $state("");
let directoryName = $state("");
let directoryNameEdited = $state(false);
let directoryAutoName = $state("");
let browseError = $state("");
let visibleError = $state("");
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
let directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
let canSubmit = $derived(
!isBusy &&
remoteUrl.trim().length > 0 &&
parentPath.trim().length > 0,
);
$effect(() => {
const nextError = error || browseError;
if (errorHideTimer) clearTimeout(errorHideTimer);
visibleError = nextError;
if (nextError) {
errorHideTimer = setTimeout(() => {
visibleError = "";
}, 6000);
}
});
onDestroy(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
});
function directoryNameFromRemoteUrl(url: string): string {
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
return lastSegment.replace(/\.git$/i, "").trim();
}
function errorToMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
try { return JSON.stringify(error) ?? "Unknown error"; } catch { return "Unknown error"; }
}
async function chooseParentFolder() {
if (isBusy) return;
browseError = "";
try {
const selected = await openDialog({
title: "Select clone destination",
directory: true,
multiple: false,
defaultPath: parentPath.trim() || undefined,
});
if (typeof selected !== "string") return;
parentPath = selected;
} catch (error) {
browseError = errorToMessage(error);
}
}
function handleRemoteInput(event: Event) {
const nextRemoteUrl = (event.currentTarget as HTMLInputElement).value;
if (directoryNameEdited) return;
directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl);
directoryName = directoryAutoName;
}
function handleDirectoryInput(event: Event) {
const nextDirectoryName = (event.currentTarget as HTMLInputElement).value;
directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName;
}
function submit(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim());
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label="Clone repository" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Repository Management</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Clone repository</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="clone-dialog-form" onsubmit={submit}>
<label class="clone-dialog-field">
<span>Remote URL</span>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={remoteUrl}
oninput={handleRemoteInput}
autocomplete="off"
spellcheck="false"
placeholder="https://github.com/org/project.git"
disabled={isBusy}
autofocus
/>
</label>
<label class="clone-dialog-field">
<span>Destination</span>
<div class="clone-dialog-path-field">
<input
bind:value={parentPath}
autocomplete="off"
spellcheck="false"
placeholder="Choose parent folder"
disabled={isBusy}
/>
<button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}>
<FolderOpen size={14} aria-hidden="true" />
Browse
</button>
</div>
</label>
<label class="clone-dialog-field">
<span>Folder name</span>
<input
bind:value={directoryName}
oninput={handleDirectoryInput}
autocomplete="off"
spellcheck="false"
placeholder={directorySuggestion || "Optional"}
disabled={isBusy}
/>
</label>
{#if visibleError}
<div class="clone-dialog-error" role="alert">{visibleError}</div>
{/if}
<div class="clone-dialog-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
Cancel
</button>
<button class="btn-primary" type="submit" disabled={!canSubmit}>
{#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Download size={16} aria-hidden="true" />
{/if}
Clone
</button>
</div>
</form>
</div>
</div>
+35 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, LoaderCircle, Settings, Sparkles } from "@lucide/svelte";
import { Check, LoaderCircle, RotateCcw, Settings, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props {
@@ -13,10 +13,14 @@
commitAiProvider: CommitAiProvider;
commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean;
canAmend: boolean;
amendMode: boolean;
onCommit: () => void;
onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void;
onOpenAiSettings: () => void;
onToggleAmend: (checked: boolean) => void;
onUndoLastCommit: () => void;
}
let {
@@ -30,10 +34,14 @@
commitAiProvider = "local",
commitAiPhase = "idle",
commitAiGenerating = false,
canAmend = false,
amendMode = false,
onCommit = () => {},
onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {},
onOpenAiSettings = () => {},
onToggleAmend = () => {},
onUndoLastCommit = () => {},
}: Props = $props();
function handleSubmit(event: SubmitEvent) {
@@ -77,14 +85,37 @@
{#if commitBlockReason}
<p class="commit-block-reason">{commitBlockReason}</p>
{/if}
{#if canAmend}
<div class="commit-amend-row">
<label class="commit-amend-toggle">
<input
type="checkbox"
checked={amendMode}
disabled={isBusy}
onchange={(e) => onToggleAmend((e.target as HTMLInputElement).checked)}
/>
Amend last commit
</label>
<button
class="btn-secondary commit-undo-button"
type="button"
onclick={onUndoLastCommit}
disabled={isBusy}
title="Undo the last commit — its changes come back as uncommitted changes (not pushed yet, so safe)"
>
<RotateCcw size={13} aria-hidden="true" />
Undo last commit
</button>
</div>
{/if}
<div class="commit-actions-row flex-shrink-0">
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
{#if operation === "Committing"}
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || (amendMode ? "Amend the last commit" : "Commit staged changes")}>
{#if operation === "Committing" || operation === "Amending"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Commit
{amendMode ? "Amend" : "Commit"}
</button>
<button
class="btn-secondary commit-ai-button"
-1
View File
@@ -182,7 +182,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
@@ -41,7 +41,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
<header class="dialog-header">
+13 -6
View File
@@ -14,7 +14,7 @@
} from "@lucide/svelte";
interface Props {
action: "push" | "pull" | "fetch";
action: "push" | "pull" | "fetch" | "clone";
error: string;
isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
@@ -35,7 +35,7 @@
let username = $state("");
let password = $state("");
let showPassword = $state(false);
let saveSession = $state(false);
let saveSession = $state(true);
let expiresAt = $state("");
let canSubmit = $derived(
@@ -43,13 +43,21 @@
password.trim().length > 0 &&
(mode === "token" || username.trim().length > 0),
);
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : "Pull");
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
let actionTitle = $derived(
action === "push" ? "Authenticate push" : action === "fetch" ? "Authenticate fetch" : "Authenticate pull",
action === "push"
? "Authenticate push"
: action === "fetch"
? "Authenticate fetch"
: action === "clone"
? "Authenticate clone"
: "Authenticate pull",
);
let actionHint = $derived(action === "push"
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: "The remote needs access to the repository. Use your Git credentials or a personal access token.");
: action === "clone"
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
: "The remote needs access to the repository. Use your Git credentials or a personal access token.");
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
@@ -66,7 +74,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git credentials" tabindex="-1">
<div class="cred-hero">
+28 -15
View File
@@ -3,8 +3,8 @@
import type { GitFileStatus } from "../types";
interface Props {
file: GitFileStatus;
staged: boolean;
files: GitFileStatus[];
staged: boolean | null;
scope: "file" | "hunk";
isBusy: boolean;
onConfirm: () => void | Promise<void>;
@@ -12,7 +12,7 @@
}
let {
file,
files,
staged = false,
scope = "file",
isBusy = false,
@@ -20,18 +20,20 @@
onClose = () => {},
}: Props = $props();
let targetPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
let title = $derived(scope === "hunk" ? "Discard hunk?" : "Discard file changes?");
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
function closeFromBackdrop(event: MouseEvent) {
if (isBusy || event.target !== event.currentTarget) return;
onClose();
function targetPath(file: GitFileStatus): string {
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
}
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>
<div class="dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
<div class="dialog-backdrop" role="presentation">
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header">
<div>
@@ -50,11 +52,22 @@
<div class="discard-confirm-copy">
<p>
This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below.
This will reset the {sourceLabel} for the {scopeLabel} below.
</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">
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>
</div>
</div>
+42 -2
View File
@@ -18,6 +18,7 @@
Folder,
FolderOpen,
ExternalLink,
History,
Terminal,
} from "@lucide/svelte";
import { languageIconForPath } from "../languageIcons";
@@ -36,6 +37,9 @@
onCollapseAllFolders: () => void;
onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
let {
@@ -50,6 +54,9 @@
onCollapseAllFolders = () => {},
onSelectNode = () => {},
onOpenFile = () => {},
onBlame = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
let contextNode = $state<ExplorerNode | null>(null);
@@ -182,6 +189,13 @@
onOpenFile(node);
}
function openContextBlame() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onBlame(node);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeFileContextMenu();
}
@@ -193,7 +207,7 @@
<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>
<span class="eyebrow">Explorer</span>
@@ -221,10 +235,26 @@
<Folder size={14} aria-hidden="true" />
</button>
<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>
{#if !hasRepository}
{#if collapsed}
<!-- collapsed -->
{:else if !hasRepository}
<p class="blank-state">Open a repository to browse files.</p>
{:else if repoFiles.length === 0}
<p class="blank-state">No files returned.</p>
@@ -335,5 +365,15 @@
<ExternalLink size={14} aria-hidden="true" />
Open in Explorer
</button>
<button
type="button"
role="menuitem"
onclick={openContextBlame}
disabled={!contextNode.tracked || contextNode.status === "deleted"}
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
>
<History size={14} aria-hidden="true" />
Blame
</button>
</div>
{/if}
+46 -18
View File
@@ -1,5 +1,6 @@
<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 type { GitCommit } from "../types";
interface Props {
@@ -11,6 +12,8 @@
isLoading?: boolean;
onDiff: (commit: GitCommit) => void;
onRestore: (commit: GitCommit) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
let {
@@ -22,6 +25,8 @@
isLoading = false,
onDiff = () => {},
onRestore = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
function formatCommitDate(value: string): string {
@@ -75,7 +80,23 @@
}
</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="file-history-heading">
<span class="eyebrow">{selectedExplorerLabel}</span>
@@ -84,7 +105,19 @@
use:pathTooltip={selectedExplorerPath}
>{selectedExplorerPath ? fileName(selectedExplorerPath) : "No file"}</h2>
</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>
{#if !hasRepository}
@@ -93,22 +126,16 @@
<div class="blank-state">Select a file in Explorer.</div>
{:else if isLoading}
<div class="file-history-loading" role="status" aria-live="polite">
<svg class="file-history-loading-graph" viewBox="0 0 120 120" aria-hidden="true">
<defs>
<linearGradient id="fhl-line" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#646cff" />
<stop offset="100%" stop-color="#41d1ff" />
</linearGradient>
</defs>
<circle class="fhl-ring" cx="60" cy="60" r="52" />
<path class="fhl-trunk" d="M42 22 L42 98" />
<path class="fhl-branch" d="M42 44 C42 62, 82 58, 82 76 L82 88" />
<circle class="fhl-node fhl-n1" cx="42" cy="30" r="6" />
<circle class="fhl-node fhl-n2" cx="42" cy="60" r="6" />
<circle class="fhl-node fhl-n3" cx="82" cy="88" r="6" />
<circle class="fhl-node fhl-n4" cx="42" cy="90" r="6" />
</svg>
<div class="file-history-loading-mark" aria-hidden="true">
<span class="file-history-loading-halo"></span>
<svg class="file-history-loading-traces" viewBox="0 0 120 120">
<path class="fhl-trace fhl-trace-main" d="M14 82 C38 60, 48 58, 60 60 S84 66, 106 36" />
<path class="fhl-trace fhl-trace-branch" d="M28 36 C52 48, 70 70, 91 94" />
</svg>
<img src={iconUrl} alt="" class="file-history-loading-icon" />
</div>
<span class="file-history-loading-label">Loading history…</span>
<span class="file-history-loading-bar" aria-hidden="true"><span></span></span>
</div>
{:else if fileHistory.length === 0}
<div class="blank-state">No history returned for this selection.</div>
@@ -141,4 +168,5 @@
{/each}
</div>
{/if}
{/if}
</section>
@@ -129,7 +129,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1">
<header class="dialog-header">
+176 -22
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { ChevronDown, ChevronRight, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment {
@@ -32,6 +32,7 @@
commits: GitCommit[];
localBranchNames: string[];
activeBranch: string;
activeUpstream: string;
repositoryKey: string;
hasRepository: boolean;
isBusy: boolean;
@@ -40,12 +41,14 @@
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
onToggleCommitFiles: (hash: string) => void;
onCreateBranchFromCommit: (commit: GitCommit) => void;
onCherryPickCommit: (commit: GitCommit) => void;
}
let {
commits = [],
localBranchNames = [],
activeBranch = "",
activeUpstream = "",
repositoryKey = "",
hasRepository = false,
isBusy = false,
@@ -54,12 +57,17 @@
onPreviewCommitFile = () => {},
onToggleCommitFiles = () => {},
onCreateBranchFromCommit = () => {},
onCherryPickCommit = () => {},
}: Props = $props();
let hiddenGraphBranches = $state<Set<string>>(new Set());
let branchDialogOpen = $state(false);
let userAdjustedBranchFilter = $state(false);
let lastDefaultFilterKey = $state("");
let panelElement = $state<HTMLElement | null>(null);
let contextCommit = $state<GitCommit | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
function laneColor(col: number): string {
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
@@ -195,7 +203,13 @@
return [...new Set(values.filter(Boolean))];
}
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, activeUpstream].filter(Boolean)));
let graphBranchNameSet = $derived(new Set(graphBranchNames));
function branchIsVisible(branch: string): boolean {
if (branch === activeUpstream && activeUpstream) {
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
}
return !hiddenGraphBranches.has(branch);
}
@@ -203,12 +217,39 @@
return labels.filter(branchIsVisible);
}
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
const directBranches = graphBranchRefs(commit);
if (directBranches.length > 0) return directBranches;
const containingBranches = row?.branchLabels ?? [];
if (containingBranches.length <= 3) return containingBranches;
return [...containingBranches.slice(0, 3), `+${containingBranches.length - 3} more`];
}
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
const directBranches = graphBranchRefs(commit);
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
const containingBranches = row?.branchLabels ?? [];
if (containingBranches.length === 0) return commit.short_hash;
return `Branches containing this commit: ${containingBranches.join(", ")}`;
}
function segmentIsVisible(segment: GraphSegment): boolean {
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
}
function syncClassForBranches(labels: string[]): "" | "ahead" | "behind" {
if (!activeBranch || !activeUpstream) return "";
const hasLocal = labels.includes(activeBranch);
const hasRemote = labels.includes(activeUpstream);
if (hasLocal && !hasRemote) return "ahead";
if (hasRemote && !hasLocal) return "behind";
return "";
}
function branchesAreVisible(branches: string[]): boolean {
if (localBranchNames.length === 0) return true;
if (graphBranchNames.length === 0) return true;
return branches.some(branchIsVisible);
}
@@ -240,7 +281,7 @@
const membership = new Map<string, Set<string>>();
for (const commit of items) {
for (const branch of localBranchRefs(commit)) {
for (const branch of graphBranchRefs(commit)) {
const stack = [commit.hash];
const seen = new Set<string>();
@@ -265,7 +306,7 @@
return new Map(
items.map((commit) => [
commit.hash,
localBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
graphBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
]),
);
}
@@ -316,10 +357,59 @@
}
}
function handleBranchDialogBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
closeBranchDialog();
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
if (contextCommit?.hash === commit.hash) {
closeCommitContextMenu();
return;
}
const panelRect = panelElement?.getBoundingClientRect();
const buttonRect = event.currentTarget instanceof HTMLElement
? event.currentTarget.getBoundingClientRect()
: null;
const rawX = panelRect && buttonRect ? buttonRect.right - panelRect.left - 184 : event.offsetX;
const rawY = panelRect && buttonRect ? buttonRect.bottom - panelRect.top + 4 : event.offsetY;
const maxX = Math.max(8, (panelRect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (panelRect?.height ?? window.innerHeight) - 96);
contextCommit = commit;
contextMenuX = Math.max(8, Math.min(rawX, maxX));
contextMenuY = Math.max(8, Math.min(rawY, maxY));
}
function closeCommitContextMenu() {
contextCommit = null;
}
async function createBranchFromContextCommit() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
await onCreateBranchFromCommit(commit);
}
async function restoreContextCommit() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
await onRestoreCommit(commit);
}
async function cherryPickContextCommit() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
await onCherryPickCommit(commit);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
closeCommitContextMenu();
handleBranchDialogKeydown(event);
}
function localBranchRefs(commit: GitCommit): string[] {
@@ -334,6 +424,18 @@
return labels;
}
function graphBranchRefs(commit: GitCommit): string[] {
const seen = new Set<string>();
const labels: string[] = [];
for (const ref of commit.refs) {
const label = refLabel(ref);
if (!graphBranchNameSet.has(label) || seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels;
}
function visibleRefs(commit: GitCommit): string[] {
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
}
@@ -367,7 +469,7 @@
$effect(() => {
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
? activeBranch
: (localBranchNames[0] ?? "");
: (graphBranchNames[0] ?? "");
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
if (!defaultBranch) {
@@ -399,9 +501,9 @@
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
</script>
<svelte:window onkeydown={handleBranchDialogKeydown} />
<svelte:window onclick={closeCommitContextMenu} onkeydown={handleWindowKeydown} on:contextmenu|capture={closeCommitContextMenu} />
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
<section bind:this={panelElement} class="panel history-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
<div class="section-head">
<div>
<span class="eyebrow">History</span>
@@ -437,15 +539,25 @@
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
{@const item = entry.commit}
{@const row = graphRows[rowIndex]}
{@const hoverBranchRefs = visibleBranchLabels(row?.branchLabels ?? [])}
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
{@const otherRefs = visibleRefs(item)}
<article class="commit-row graph-row" class:merge-row={item.parents.length > 1} class:root-row={item.parents.length === 0} class:tip-row={item.refs.length > 0}>
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
<article
class="commit-row graph-row"
class:graph-ahead-row={rowSyncClass === "ahead"}
class:graph-behind-row={rowSyncClass === "behind"}
class:merge-row={item.parents.length > 1}
class:root-row={item.parents.length === 0}
class:tip-row={item.refs.length > 0}
>
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
{#if row}
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
{#each row.top as seg}
<path
class:hidden-branch={!segmentIsVisible(seg)}
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
d={graphPath(seg, 0, 50)}
stroke={seg.color}
stroke-width="2.2"
@@ -455,6 +567,8 @@
{#each row.bottom as seg}
<path
class:hidden-branch={!segmentIsVisible(seg)}
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
d={graphPath(seg, 50, 100)}
stroke={seg.color}
stroke-width="2.2"
@@ -467,13 +581,20 @@
class:merge={item.parents.length > 1}
class:tip={item.refs.length > 0}
class:hidden-branch={!rowGraphIsVisible(row)}
title={hoverBranchRefs.length > 0 ? `Contained in: ${hoverBranchRefs.join(", ")}` : item.short_hash}
class:ahead={rowSyncClass === "ahead"}
class:behind={rowSyncClass === "behind"}
title={commitHoverTitle(item, row)}
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
></span>
{#if hoverBranchRefs.length > 0}
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
{#each hoverBranchRefs as branch}
<span title={branch}>
<span
class:remote={branch === activeUpstream}
class:ahead={activeBranch === branch && rowSyncClass === "ahead"}
class:behind={activeUpstream === branch && rowSyncClass === "behind"}
title={branch}
>
<GitBranch size={10} aria-hidden="true" />
{branch}
</span>
@@ -551,13 +672,17 @@
<div class="commit-actions">
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons">
<button class="btn-sm" type="button" onclick={() => onCreateBranchFromCommit(item)} disabled={isBusy} title="Create a new branch from this commit">
<GitBranch size={15} aria-hidden="true" />
Branch
</button>
<button class="btn-sm" type="button" onclick={() => onRestoreCommit(item)} disabled={isBusy} title="Bring this commit's files into your working tree as unstaged changes (no history is changed)">
<RotateCcw size={15} aria-hidden="true" />
Restore
<button
class="commit-menu-button"
type="button"
onclick={(event) => openCommitActionMenu(event, item)}
disabled={isBusy}
title="Commit actions"
aria-label={`Actions for ${item.short_hash}`}
aria-haspopup="menu"
aria-expanded={contextCommit?.hash === item.hash}
>
<EllipsisVertical size={15} aria-hidden="true" />
</button>
</div>
</div>
@@ -566,10 +691,39 @@
{/each}
</div>
{/if}
{#if contextCommit}
<div
class="history-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextCommit.short_hash}`}
>
<button type="button" role="menuitem" onclick={createBranchFromContextCommit} disabled={isBusy}>
<GitBranch size={14} aria-hidden="true" />
Branch
</button>
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
<RotateCcw size={14} aria-hidden="true" />
Restore
</button>
<button
type="button"
role="menuitem"
onclick={cherryPickContextCommit}
disabled={isBusy}
title="Apply this commit's changes on top of the current branch"
>
<Cherry size={14} aria-hidden="true" />
Cherry-pick
</button>
</div>
{/if}
</section>
{#if branchDialogOpen}
<div class="branch-filter-backdrop" role="presentation" onclick={handleBranchDialogBackdropClick}>
<div class="branch-filter-backdrop" role="presentation">
<div
class="branch-filter-dialog"
role="dialog"
@@ -29,7 +29,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
<header class="dialog-header">
@@ -33,7 +33,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
<header class="dialog-header">
+172 -90
View File
@@ -1,33 +1,23 @@
<script lang="ts">
import iconUrl from "../../../src-tauri/icons/icon.png";
export let label = "Opening repository";
export let repoName = "";
</script>
<div class="repo-loading" role="status" aria-live="polite">
<div class="repo-loading-grid" aria-hidden="true"></div>
<div class="repo-loading-card">
<!-- Animated git graph: nodes light up in sequence while a branch draws itself -->
<svg class="git-graph" viewBox="0 0 120 120" aria-hidden="true">
<defs>
<linearGradient id="gl-line" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#646cff" />
<stop offset="100%" stop-color="#41d1ff" />
</linearGradient>
</defs>
<!-- rotating dashed ring -->
<circle class="ring" cx="60" cy="60" r="52" />
<!-- main trunk -->
<path class="trunk" d="M42 22 L42 98" />
<!-- branch forking off and merging back -->
<path class="branch" d="M42 44 C42 62, 82 58, 82 76 L82 88" />
<!-- commit nodes -->
<circle class="node n1" cx="42" cy="30" r="6" />
<circle class="node n2" cx="42" cy="60" r="6" />
<circle class="node n3" cx="82" cy="88" r="6" />
<circle class="node n4" cx="42" cy="90" r="6" />
</svg>
<div class="repo-loading-mark">
<span class="repo-loading-halo halo-one"></span>
<span class="repo-loading-halo halo-two"></span>
<svg class="repo-loading-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="repo-loading-icon" />
</div>
<div class="repo-loading-text">
<span class="repo-loading-label">{label}</span>
@@ -43,67 +33,116 @@
<style>
.repo-loading {
position: fixed;
inset: 0;
top: calc(var(--app-titlebar-height, 42px) + 56px);
right: 0;
bottom: 0;
left: 0;
z-index: 400;
display: grid;
place-items: center;
background: rgba(6, 6, 14, 0.72);
backdrop-filter: blur(8px);
overflow: hidden;
background: linear-gradient(135deg, #070a10 0%, #0c111a 48%, #090e16 100%);
animation: overlay-in 180ms ease;
}
.repo-loading-grid {
position: absolute;
inset: 0;
opacity: 0.28;
background-image:
linear-gradient(rgba(111, 140, 255, 0.09) 1px, transparent 1px),
linear-gradient(90deg, rgba(77, 182, 214, 0.07) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: radial-gradient(circle at center, black 0%, transparent 68%);
animation: grid-drift 10s linear infinite;
}
.repo-loading-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 34px 46px;
border: 1px solid var(--color-border);
border-radius: 18px;
background: var(--color-surface-raised);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
gap: 18px;
width: min(420px, calc(100vw - 42px));
padding: 30px 34px 28px;
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);
box-shadow:
0 26px 78px rgba(0, 0, 0, 0.54),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.git-graph {
width: 118px;
height: 118px;
.repo-loading-mark {
position: relative;
width: 168px;
height: 168px;
display: grid;
place-items: center;
isolation: isolate;
}
.repo-loading-halo {
position: absolute;
inset: 10px;
border: 1px solid rgba(111, 140, 255, 0.22);
border-radius: 34px;
transform: rotate(45deg);
}
.repo-loading-halo.halo-one {
animation: halo-breathe 2.4s ease-in-out infinite;
}
.repo-loading-halo.halo-two {
inset: 24px;
border-color: rgba(77, 182, 214, 0.24);
animation: halo-breathe 2.4s ease-in-out infinite reverse;
}
.repo-loading-traces {
position: absolute;
inset: -18px;
width: 204px;
height: 204px;
overflow: visible;
z-index: 0;
}
.git-graph .ring {
.repo-loading-traces .trace {
fill: none;
stroke: rgba(100, 108, 255, 0.22);
stroke-width: 2;
stroke-dasharray: 26 18;
transform-origin: 60px 60px;
animation: ring-spin 5s linear infinite;
}
.git-graph .trunk,
.git-graph .branch {
fill: none;
stroke: url(#gl-line);
stroke-width: 4;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 165;
stroke-dashoffset: 165;
filter: drop-shadow(0 0 8px rgba(77, 182, 214, 0.32));
animation: trace-draw 2.6s ease-in-out infinite;
}
.repo-loading-traces .trace-main {
stroke: #6f8cff;
}
.repo-loading-traces .trace-branch {
stroke: #4db6d6;
animation-delay: 0.28s;
}
.repo-loading-traces .trace-cut {
stroke: rgba(177, 186, 208, 0.42);
stroke-dasharray: 128;
stroke-dashoffset: 128;
animation-delay: 0.55s;
}
.git-graph .branch {
stroke-dasharray: 90;
stroke-dashoffset: 90;
animation: branch-draw 2.4s ease-in-out infinite;
.repo-loading-icon {
position: relative;
z-index: 1;
width: 132px;
height: 132px;
object-fit: contain;
filter:
drop-shadow(0 18px 24px rgba(0, 0, 0, 0.55))
drop-shadow(0 0 18px rgba(77, 182, 214, 0.2));
animation: icon-float 2.4s ease-in-out infinite;
}
.git-graph .node {
fill: var(--color-surface-alt);
stroke: url(#gl-line);
stroke-width: 4;
animation: node-pulse 2.4s ease-in-out infinite;
}
.git-graph .n1 { animation-delay: 0s; }
.git-graph .n2 { animation-delay: 0.5s; }
.git-graph .n3 { animation-delay: 1s; }
.git-graph .n4 { animation-delay: 1.5s; }
.repo-loading-text {
display: flex;
flex-direction: column;
@@ -115,8 +154,7 @@
.repo-loading-label {
color: var(--color-ink);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.01em;
font-weight: 800;
}
.repo-loading-name {
@@ -131,45 +169,89 @@
.repo-loading-bar {
position: relative;
width: 180px;
height: 3px;
width: min(230px, 100%);
height: 4px;
overflow: hidden;
border-radius: 3px;
background: rgba(100, 108, 255, 0.16);
border-radius: 999px;
background: rgba(111, 140, 255, 0.14);
}
.repo-loading-bar span {
position: absolute;
inset: 0;
width: 40%;
border-radius: 3px;
background: linear-gradient(90deg, transparent, var(--color-accent), transparent);
animation: bar-slide 1.3s ease-in-out infinite;
width: 46%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
box-shadow: 0 0 16px rgba(77, 182, 214, 0.26);
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 ring-spin { to { transform: rotate(360deg); } }
@keyframes grid-drift { to { transform: translate3d(42px, 42px, 0); } }
@keyframes icon-float {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-5px) scale(1.015); }
}
@keyframes halo-breathe {
0%, 100% { opacity: 0.35; transform: rotate(45deg) scale(0.95); }
50% { opacity: 0.8; transform: rotate(45deg) scale(1.04); }
}
@keyframes bar-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
@keyframes branch-draw {
0% { stroke-dashoffset: 90; opacity: 0.35; }
45% { stroke-dashoffset: 0; opacity: 1; }
100% { stroke-dashoffset: 0; opacity: 1; }
}
@keyframes node-pulse {
0%, 100% { fill: var(--color-surface-alt); filter: none; }
50% {
fill: var(--color-primary);
filter: drop-shadow(0 0 6px rgba(100, 108, 255, 0.8));
}
@keyframes trace-draw {
0% { stroke-dashoffset: 165; opacity: 0; }
36% { opacity: 1; }
64%, 100% { stroke-dashoffset: 0; opacity: 0.72; }
}
@media (prefers-reduced-motion: reduce) {
.git-graph .ring,
.git-graph .branch,
.git-graph .node,
.repo-loading-grid,
.repo-loading-halo,
.repo-loading-traces .trace,
.repo-loading-icon,
.repo-loading-bar span { animation: none; }
.git-graph .branch { stroke-dashoffset: 0; }
.repo-loading-traces .trace { stroke-dashoffset: 0; opacity: 0.72; }
}
</style>
-1
View File
@@ -254,7 +254,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
class="dialog"
+15 -11
View File
@@ -11,6 +11,8 @@
onApply: (stash: GitStash) => void;
onPop: (stash: GitStash) => void;
onDrop: (stash: GitStash) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
let {
@@ -22,11 +24,12 @@
onApply = () => {},
onPop = () => {},
onDrop = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
let message = $state("");
let includeUntracked = $state(true);
let open = $state(false);
function submitPush() {
onPush(message, includeUntracked);
@@ -38,31 +41,32 @@
}
</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>
<span class="eyebrow">Stash</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Shelved changes</h2>
</div>
<div class="stash-head-actions">
<span class="pill pill-count">{stashes.length}</span>
<button
class="stash-toggle"
class="stash-toggle panel-collapse-toggle"
type="button"
onclick={() => { open = !open; }}
aria-expanded={open}
title={open ? "Collapse stash panel" : "Expand stash panel"}
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title={collapsed ? "Expand stash panel" : "Collapse stash panel"}
aria-label={collapsed ? "Expand stash panel" : "Collapse stash panel"}
>
{#if open}
<ChevronDown size={14} aria-hidden="true" />
{:else}
{#if collapsed}
<ChevronRight size={14} aria-hidden="true" />
{:else}
<ChevronDown size={14} aria-hidden="true" />
{/if}
</button>
<span class="pill pill-count">{stashes.length}</span>
</div>
</div>
{#if !open}
{#if collapsed}
<!-- collapsed -->
{:else if !hasRepository}
<div class="blank-state">No repository loaded.</div>

Some files were not shown because too many files have changed in this diff Show More