Compare commits

...
86 Commits
Author SHA1 Message Date
Christoph Brandau 7f85eb7d98 refactor(explorer): improve file history state management
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 19m26s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 32m24s
Adjusts the logic governing when file history views are refreshed or displayed. This ensures that selecting a file via the explorer correctly hides any active file history view, and prevents unnecessary refreshes of history data if the view is already collapsed or if the repository path is not yet set.

- Added explicit function to reset file history state
- Prevents automatic display of file history upon file selection
- Refines conditions for refreshing file history during repo updates
2026-07-13 00:23:27 +02:00
Christoph Brandau c2f55d96db feat(settings): implement persistent auto-refresh functionality
Adds a configurable and persistable auto-refresh setting to the application's settings dialog. This feature allows users to control whether the repository view automatically updates its status, branch information, and remote tracking in the background. The logic is integrated across core components to manage state changes and provide visual feedback to the user.

- Auto-refresh state is now persistent and managed via local storage.
- Updated settings dialog UI to include a dedicated toggle for auto-refresh.
- Removed manual auto-refresh toggling from the repository toolbar component.
2026-07-13 00:15:58 +02:00
Christoph Brandau 0dd5441754 feat(ui): enhance status panel actions and styling
This update significantly refactors the visual layout and functionality of the working tree status panel. It introduces dedicated action groups for discarding changes, staging, and unstaging files, improving user interaction and clarity.

- Added contextual action buttons (discard all/selected) to both staged and unstaged sections.
- Updated CSS structure to accommodate new status action containers.
- Improved handling of selected file counts within the panel headers.
2026-07-13 00:09:28 +02:00
Christoph Brandau 82c47c8d03 feat(ai): Add comprehensive pre-commit AI code review
Introduces a robust system for running automated, staged diff reviews against various large language models. This feature allows users to submit their changes to external AI services and receive structured feedback on potential bugs, security issues, and maintainability risks before committing.

The implementation covers the entire stack:
*   Backend logic was added to handle API communication with OpenAI, Anthropic, and custom endpoints.
*   A dedicated parser ensures that complex JSON outputs from LLMs are reliably converted into structured findings (severity, title, description).
*   New components and UI elements provide a clear visualization of the AI's assessment and actionable suggestions.

- Supports multiple major LLM providers (OpenAI, Anthropic)
- Parses structured JSON output for consistent review results
- Adds dedicated UI dialog to display AI findings and risk level
2026-07-13 00:03:11 +02:00
Christoph Brandau bad1263dcf feat(ui): enhance workspace status and diff styling
This commit updates several UI components to improve visual consistency and readability across various themes. It adds necessary class names to the Git branch display component and significantly refines the CSS for diff markers and general workspace status elements. These changes ensure that semantic colors are maintained on hover states and that key indicators remain highly visible in light mode.

- Updated styling for diff marker focus/hover states
- Improved visual feedback for line patch buttons (stage, unstage, discard)
- Added specific light theme styles for branch indicator and status counters
2026-07-12 23:46:44 +02:00
Christoph Brandau cc805e04bf style(ui): improve spacing and alignment of repository toolbar
Adjusts the CSS styling for the primary repository toolbar to enhance its visual organization. These changes introduce specific padding and margin rules, ensuring that various utility groups are properly centered and spaced vertically. This improves the overall aesthetic consistency of the component.
2026-07-12 23:38:44 +02:00
Christoph Brandau 735acd2551 refactor(ui): modernize status bar and component structure
This commit introduces a comprehensive overhaul of the application's UI, focusing on modernizing the global workspace status bar and improving overall theming consistency. Several components were refactored to simplify prop handling and improve separation of concerns, particularly within the TitleBar and RepoToolbar. The CSS includes extensive new variables and styles for better visual fidelity across light and dark themes.

- Overhauled the main application footer to display version, branch status, and sync metrics.
- Added global CSS variables and component styling for a modern look.
- Simplified repository state management by removing redundant props from TitleBar.
2026-07-12 23:32:55 +02:00
Christoph Brandau f9c0c00618 refactor(ui): extract repository tab bar into dedicated component
The complex logic and markup for rendering the repository tabs have been extracted from App.svelte into a new, reusable RepoTabs component. This refactoring significantly cleans up the main application view, improves separation of concerns, and makes the UI structure easier to maintain and extend. Corresponding CSS updates were applied to ensure the visual fidelity and responsiveness of the tab bar remain consistent across different states.

- Encapsulates all tab rendering logic in src/lib/RepoTabs.svelte
- Simplifies App.svelte by replacing large block of HTML with component usage
- Updates styling for better alignment and modern aesthetics
2026-07-12 22:56:39 +02:00
Christoph ff00925b13 chore(build): automate PKGBUILD version updates
This update enhances the build process by ensuring that the package definition file (PKGBUILD) is automatically updated with the current application version and release number. Changes are applied across both local build scripts and CI/CD workflows to maintain consistency. This prevents manual synchronization errors when building releases.

- Updates PKGBUILD version fields using package.json
- Ensures PKGBUILD is staged during final commit in CI/CD
- Improves robustness of the automated build pipeline
2026-07-11 23:27:59 +02:00
Christoph Brandau 982dbf136d feat(toolbar): overhaul repository action bar UI and status display
This refactors the entire appearance and structure of the repository toolbar, modernizing its layout and improving visual consistency across different states. The component now dynamically displays remote tracking information (ahead/behind) directly on Pull and Push buttons for immediate user feedback.

- Implemented a comprehensive CSS overhaul using grid and flexbox for better responsiveness.
- Added support for German localization within the toolbar actions.
- Enhanced visibility of sync status by displaying commit counts next to pull/push buttons.
2026-07-11 23:11:34 +02:00
Christoph Brandau 646dcc341e feat(help): Add comprehensive German documentation for Git concepts
The help overlay component has been significantly expanded with detailed sections covering advanced version control topics in German. These additions provide users with deep dives into core Git mechanics, complex workflows, and troubleshooting guides. This greatly enhances the user's ability to understand and utilize professional Git practices within the application.

- Detailed explanations of the Staging Area and Index concepts.
- Guides for advanced operations like interactive rebase and cherry-picking.
- Comprehensive sections on common errors (e.g., detached HEAD, non-fast-forward).
2026-07-11 22:52:31 +02:00
Christoph 5f3e55dcd7 chore: update package version and enhance build scripts
This commit updates the package version to 2026.7.19 and modifies
the build scripts to include additional commands for better
development workflow. The main window of the application is also set
to maximize upon launch.

- Updated package version in PKGBUILD
- Added new build commands in settings.local.json
- Maximized the main application window on startup
2026-07-11 20:02:23 +02:00
Christoph fdbff8175e style(ui): Move Buttons under the Repo selection 2026-07-11 18:04:49 +02:00
Christoph a6e7e991dd refactor(tauri): Improve builder initialization readability
The main application setup in src-tauri/src/main.rs has been refactored to enhance local variable scoping. By assigning the result of tauri::Builder::default() to a named variable, the code becomes clearer and more readable without changing runtime behavior.
2026-07-11 17:42:22 +02:00
Christoph 3491b8efb3 Merge remote-tracking branch 'origin/main' 2026-07-11 17:40:31 +02:00
Christoph 59a8f34e0e chore(config): update gitignore to exclude build artifacts
Updates the .gitignore file to ensure that generated package files and local build directories are ignored by Git. This prevents unnecessary binary data from being tracked in the repository history, keeping the project clean.

- Ignore pkg directory
- Ensure exclusion of tarball packages
2026-07-11 17:35:48 +02:00
Christoph 286b106baa feat(build): add PKGBUILD for package distribution
This introduces the necessary PKGBUILD file to define how Gitty should be built and packaged for system installation. It sets up the build process using npm and Tauri CLI, ensuring all dependencies are met before compiling the application binary. The script also handles installing required assets like icons, desktop entries, and documentation into the package structure.

- Defines build steps using tauri/npm
- Installs binaries, icons, and desktop entry files
- Sets up standard package metadata (pkgname, depends)
2026-07-11 17:34:48 +02:00
Christoph 670e7e24fe for linux build 2026-07-11 17:31:16 +02:00
Christoph Brandau 6729d61dca feat(language): implement internationalization and help overlay
This update introduces comprehensive language support (English/German) across the application, enabling localization for UI elements and settings dialogs. It also adds a dedicated Help Overlay component with detailed guides on using Gitty's core Git features.

- Adds language selection to App Settings
- Implements German translations in key areas
- Introduces global help documentation accessible via Ctrl+/
2026-07-11 15:03:01 +02:00
Christoph b181b384e7 Update version to 2026.7.19 2026-07-10 23:09:04 +02:00
Christoph Brandau c174fb3a80 Merge branch 'opt/light_mode'
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 18m59s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 27m53s
2026-07-10 22:59:20 +02:00
Christoph Brandau c747e02f29 feat(git): improve interactive rebase base handling and reflog display
The git module now correctly handles scenarios where the selected base branch is diverged from HEAD. This removes unnecessary ancestor checks during rebase planning, improving overall robustness of the feature. Additionally, the ReflogDialog component was updated to reliably retrieve the current HEAD hash for accurate display in the UI.

- Removed strict ancestor checking when starting interactive rebase operations.
- Updated App.svelte to accurately find and use the current HEAD entry from reflog data.
2026-07-10 22:56:48 +02:00
Christoph Brandau 791275f341 refactor(git): improve interactive rebase workflow robustness
This commit removes the dedicated feature QA page and significantly refactors the internal Git library logic for handling complex workflows, particularly interactive rebase. The changes introduce helper functions to manage temporary files and ensure proper cleanup of rebase artifacts regardless of success or failure. This improves the reliability of advanced git operations within the application.

- Added comprehensive cleanup routines for rebase helpers
- Centralized constants for rebase file names
- Removed obsolete QA feature code
2026-07-10 22:53:24 +02:00
Christoph Brandau f7b8beaad4 feat(git): add interactive rebase and reflog recovery features
This update significantly expands Git functionality by implementing support for advanced workflows, including interactive rebasing and recovering lost commits via the reflog. New logic handles preparing the necessary environment files (todo lists and reword queues) required by Git's internal editors. The frontend components are also updated to expose these new capabilities to the user interface.

- Implements full planning and execution flow for interactive rebase
- Adds functionality to list and restore commits using the reflog history
- Updates Rust backend commands to support advanced git operations
2026-07-10 22:49:08 +02:00
Christoph Brandau d0bca62362 refactor(ui): Improve title bar layout using flex and grid
The structure of the application title bar has been updated to utilize modern CSS layout techniques like Grid and Flexbox. This refactoring improves the responsiveness and organization of key elements such as repository name, branch indicator, and synchronization status. The changes ensure better alignment and handling of varying content lengths across different viewports.

- Restructures repo/branch info into a dedicated context container
- Uses flex properties for dynamic sizing of title bar sections
- Groups sync indicators into a cohesive unit
2026-07-10 22:21:53 +02:00
Christoph Brandau d3cde83518 style(css): standardize code surface and highlight colors
Introduces a comprehensive set of CSS variables defining surfaces, syntax elements, and diff coloring across both light and dark themes. This refactoring replaces numerous hardcoded color values throughout the stylesheet with these new variables, ensuring that all components consistently adhere to the defined theme palette.

- Added detailed variable definitions for code surfaces (e.g., `--code-surface`, `--code-input-bg`).
- Updated diff, hunk, and blame sections to use variable colors for better thematic consistency.
- Replaced fixed hex codes with variables across various component backgrounds and text colors.
2026-07-10 22:16:06 +02:00
Christoph 3fdd6d3467 Update version to 0.200.10 2026-07-10 20:27:29 +02:00
Christoph 900a6fa230 feat(core): improve platform compatibility and startup robustness
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 18m21s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Failing after 1h43m55s
This update enhances cross-platform reliability by addressing potential hangs during application startup on Linux environments using WebKitGTK. Additionally, native taskbar badge functionality is improved for macOS and Linux, ensuring accurate display of sync status. Configuration settings are also updated to include more specific internal crate reads and web search capabilities.

- Added timeout fallback to waitForAnimationFrame in Svelte
- Improved set_sync_badge logic for macOS/Linux platforms
- Updated local configuration with detailed dependency reads
2026-07-10 20:24:48 +02:00
Christoph 2d7946f6fd Update version to 2026.7.18 2026-07-10 18:38:11 +02:00
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
104 changed files with 9046 additions and 1239 deletions
+25 -1
View File
@@ -92,7 +92,31 @@
"Bash(rustfmt --check --edition 2021 src/git.rs)",
"Bash(awk *)",
"Bash(rustfmt --edition 2021 /tmp/blame_chunk.rs)",
"Read(//tmp/**)"
"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 *)",
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/**)",
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-runtime-wry-2.11.4/src/**)",
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/window/**)",
"WebSearch",
"Bash(node -p \"require\\('./package.json'\\).version\")",
"Bash(bash -n PKGBUILD)",
"Bash(makepkg --printsrcinfo)",
"Bash(npx tauri *)",
"Bash(echo \"---exit code:$?---\")",
"Bash(git -C /mnt/data/Development/GitLite status --short)",
"Bash(makepkg -f --noconfirm)",
"Bash(tar -tf gitty-2026.7.19-1-x86_64.pkg.tar.zst)",
"Bash(cp /mnt/data/Development/GitLite/PKGBUILD .)",
"Bash(sed -i 's/^pkgrel=1/pkgrel=3/' PKGBUILD)",
"Bash(node -e \"const fs=require\\('fs'\\); const version='2026.8.1'; const pkgbuild=fs.readFileSync\\('PKGBUILD','utf8'\\).replace\\(/^pkgver=.*\\\\$/m, 'pkgver='+version\\).replace\\(/^pkgrel=.*\\\\$/m, 'pkgrel=1'\\); fs.writeFileSync\\('PKGBUILD', pkgbuild\\);\")",
"Bash(rm PKGBUILD)"
]
}
}
+5 -1
View File
@@ -87,6 +87,10 @@ jobs:
run: |
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: Update PKGBUILD Version
run: |
node -e "const fs=require('fs'); const version=require('./package.json').version; const pkgbuild=fs.readFileSync('PKGBUILD','utf8').replace(/^pkgver=.*$/m, 'pkgver='+version).replace(/^pkgrel=.*$/m, 'pkgrel=1'); fs.writeFileSync('PKGBUILD', pkgbuild);"
- name: Commit and Push Changes
if: matrix.platform == 'windows-latest'
shell: powershell
@@ -95,7 +99,7 @@ jobs:
git config user.email '${{ vars.EMAIL_GIT }}'
# npm version also bumps the version inside package-lock.json, so stage it
# too -- otherwise it stays as an unstaged change and blocks the rebase.
git add package.json package-lock.json src-tauri/tauri.conf.json
git add package.json package-lock.json src-tauri/tauri.conf.json PKGBUILD
git commit -m 'Update version to ${{ github.ref_name }}'
if ($LASTEXITCODE -ne 0) {
+3
View File
@@ -7,3 +7,6 @@
~
.codex*
target
*.pkg.tar.zst
pkg
+66
View File
@@ -0,0 +1,66 @@
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
#
# Local/in-tree PKGBUILD: builds straight from this working directory (no
# source download, no tauri-bundler/AppImage step) and installs the raw
# binary + desktop entry. Run `makepkg -si` from the repo root.
pkgname=gitty
pkgver=2026.7.19
pkgrel=1
pkgdesc="A lightweight, modern Git client built with Tauri"
arch=('x86_64')
url="https://git.cbsk-tech.de/Christoph/GitLite"
license=('MIT')
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'hicolor-icon-theme')
makedepends=('rust' 'nodejs' 'npm')
options=('!lto')
source=()
sha256sums=()
# Keeps pkgver in sync with package.json (the release CI bumps that file).
pkgver() {
cd "$startdir"
node -p "require('./package.json').version"
}
build() {
cd "$startdir"
npm ci
# Build through the Tauri CLI (not a raw `cargo build --release`): it enables
# the `custom-protocol` cargo feature that makes the binary load the embedded
# `dist/` assets instead of the Vite dev server URL (127.0.0.1:1420), and runs
# `beforeBuildCommand` (`npm run build`) for us. `--no-bundle` skips deb/rpm/
# appimage packaging (and with it linuxdeploy/FUSE) since pacman owns that here.
npm run tauri -- build --no-bundle
}
package() {
cd "$startdir"
install -Dm755 "src-tauri/target/release/gitty" "$pkgdir/usr/bin/gitty"
install -Dm644 "src-tauri/icons/32x32.png" \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/gitty.png"
install -Dm644 "src-tauri/icons/128x128.png" \
"$pkgdir/usr/share/icons/hicolor/128x128/apps/gitty.png"
install -Dm644 "src-tauri/icons/128x128@2x.png" \
"$pkgdir/usr/share/icons/hicolor/256x256@2/apps/gitty.png"
install -Dm644 "src-tauri/icons/icon.png" \
"$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty.png"
install -d "$pkgdir/usr/share/applications"
cat > "$pkgdir/usr/share/applications/gitty.desktop" <<-EOF
[Desktop Entry]
Type=Application
Name=Gitty
Comment=$pkgdesc
Exec=gitty
Icon=gitty
Terminal=false
Categories=Development;RevisionControl;
StartupWMClass=gitty
EOF
install -Dm644 "README.md" "$pkgdir/usr/share/doc/$pkgname/README.md"
}
+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
+91 -32
View File
@@ -4,14 +4,14 @@ import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, 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"}:
@@ -259,6 +264,71 @@ def _build_latest_payload(
}
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:
resolved_version = _resolve_version(version)
artifact_base_url = os.environ.get("ARTIFACT_BASE_URL", "")
@@ -266,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:
@@ -287,9 +358,10 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
signature = _read_signature(primary)
client = _create_minio_client()
_ensure_bucket(client, bucket_name)
existing_latest_json = _read_existing_latest_json(client, bucket_name)
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,
@@ -297,39 +369,26 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
signature=signature,
artifact_base_url=artifact_base_url,
app_dir=bucket_prefix,
existing_payload=existing_latest_json,
)
latest_json = json.dumps(latest_payload, separators=(",", ":"))
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_path.write_text(latest_json + "\n", encoding="utf-8")
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 for {platform} -> "
f"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",
@@ -340,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": "git-lite",
"version": "2026.7.14",
"name": "gitty",
"version": "2026.7.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "git-lite",
"version": "2026.7.14",
"name": "gitty",
"version": "2026.7.19",
"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.14",
"name": "gitty",
"version": "2026.7.19",
"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";
+127 -10
View File
@@ -2532,7 +2532,7 @@ dependencies = [
]
[[package]]
name = "git_lite"
name = "gitty"
version = "0.1.0"
dependencies = [
"commit_ai",
@@ -2543,6 +2543,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-aptabase",
"tauri-plugin-dialog",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tokio",
]
@@ -3420,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",
]
@@ -5895,7 +5896,7 @@ dependencies = [
"rand 0.8.6",
"serde",
"sha2",
"zbus",
"zbus 4.4.0",
]
[[package]]
@@ -7066,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"
@@ -9126,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]]
@@ -9141,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]]
@@ -9152,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]]
@@ -9313,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]]
@@ -9326,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]]
@@ -9339,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",
]
+8 -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"
@@ -26,6 +26,13 @@ tokio = "1.52.3"
tauri-build = { version = "2", features = [] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2"
# Self-updating only makes sense for install methods this app ships itself
# (NSIS on Windows, the .app bundle on macOS). On Linux the app is meant to be
# installed via the system package manager (e.g. the PKGBUILD), which owns
# updates instead — so the updater plugin isn't even compiled in there.
[target.'cfg(not(any(target_os = "android", target_os = "ios", target_os = "linux")))'.dependencies]
tauri-plugin-updater = "2"
# ── Build profiles ───────────────────────────────────────────────────────────
+1 -2
View File
@@ -2,8 +2,7 @@
"identifier": "desktop-capability",
"platforms": [
"macOS",
"windows",
"linux"
"windows"
],
"windows": [
"main"
+119 -1
View File
@@ -2,7 +2,7 @@ use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::{build_messages, looks_like_diff_echo, sanitize_message};
use crate::{build_messages, build_review_messages, looks_like_diff_echo, sanitize_message};
// Generous sizing so a detailed body with bullet points isn't cut off.
const DEFAULT_MAX_TOKENS: u32 = 1500;
@@ -139,6 +139,82 @@ pub async fn generate_custom(
openai_compatible_request(url, api_key, model, diff, notes).await
}
async fn openai_compatible_review_request(
url: String,
bearer: Option<&str>,
model: &str,
diff: &str,
) -> Result<String, String> {
let (system, user) = build_review_messages(diff)?;
let body = OpenAiRequest {
model: model.to_string(),
messages: vec![
OpenAiMessage {
role: "system",
content: system,
},
OpenAiMessage {
role: "user",
content: user,
},
],
temperature: 0.1,
};
let client = http_client()?;
let mut request = client.post(url).json(&body);
if let Some(key) = bearer.filter(|key| !key.trim().is_empty()) {
request = request.bearer_auth(key);
}
let response = request
.send()
.await
.map_err(|err| format!("Request to the AI model failed: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Could not read response: {err}"))?;
if !status.is_success() {
return Err(format!("API error ({status}): {text}"));
}
let parsed: OpenAiResponse =
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
parsed
.choices
.into_iter()
.next()
.and_then(|choice| choice.message.content)
.map(|content| sanitize_message(&content))
.filter(|content| !content.is_empty())
.ok_or_else(|| "The model did not return a review.".to_string())
}
pub async fn review_openai(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
if api_key.trim().is_empty() {
return Err("OpenAI API key is missing.".to_string());
}
openai_compatible_review_request(
"https://api.openai.com/v1/chat/completions".to_string(),
Some(api_key),
model,
diff,
)
.await
}
pub async fn review_custom(
base_url: &str,
api_key: Option<&str>,
model: &str,
diff: &str,
) -> Result<String, String> {
if base_url.trim().is_empty() {
return Err("Endpoint URL is missing.".to_string());
}
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
openai_compatible_review_request(url, api_key, model, diff).await
}
#[derive(Serialize)]
struct AnthropicMessage {
role: &'static str,
@@ -220,3 +296,45 @@ pub async fn generate_anthropic(
}
Ok(message)
}
pub async fn review_anthropic(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
if api_key.trim().is_empty() {
return Err("Anthropic API key is missing.".to_string());
}
let (system, user) = build_review_messages(diff)?;
let body = AnthropicRequest {
model: model.to_string(),
max_tokens: 2400,
system,
messages: vec![AnthropicMessage {
role: "user",
content: user,
}],
};
let client = http_client()?;
let response = client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.json(&body)
.send()
.await
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Could not read response: {err}"))?;
if !status.is_success() {
return Err(format!("API error ({status}): {text}"));
}
let parsed: AnthropicResponse =
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
parsed
.content
.into_iter()
.find_map(|block| block.text)
.map(|text| sanitize_message(&text))
.filter(|text| !text.is_empty())
.ok_or_else(|| "The model did not return a review.".to_string())
}
+28 -1
View File
@@ -1,6 +1,9 @@
mod cloud;
pub use cloud::{generate_anthropic, generate_custom, generate_openai};
pub use cloud::{
generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom,
review_openai,
};
use std::{
collections::hash_map::DefaultHasher,
@@ -443,3 +446,27 @@ instead of a direct string comparison.\n\n\
user.push_str(&format!("Staged diff:\n{diff}"));
Ok((system, user))
}
pub(crate) fn build_review_messages(diff: &str) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for review.".to_string());
}
const MAX_CHARS: usize = 36_000;
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
let system = r#"You are a senior software engineer performing a focused pre-commit review.
Review only the supplied staged Git diff. Look for concrete correctness bugs, security issues,
data loss, regressions, broken edge cases, unsafe error handling, and meaningful performance or
maintainability risks. Do not report formatting preferences or speculative nitpicks.
Return ONLY valid JSON with this exact shape:
{"summary":"one concise overall assessment","risk":"low|medium|high","findings":[{"severity":"critical|warning|info","title":"short title","description":"clear evidence and impact","file":"path or null","line":123,"suggestion":"specific safe next step"}]}
Use the new-file line number from the diff when it is known; otherwise use null. Use null for file
when the issue is repository-wide. Maximum 12 findings, ordered critical then warning then info.
If no actionable issue exists, return an empty findings array and risk low. Never use markdown,
code fences, commentary outside the JSON, or claim that tests were executed."#
.to_string();
let user = format!("Staged diff to review:\n{diff}");
Ok((system, user))
}
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
+15 -1
View File
@@ -177,7 +177,21 @@ pub fn set_sync_badge(
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
}
#[cfg(not(target_os = "windows"))]
// Native badge count: works on Linux (via the desktop's launcher API, e.g. Unity's
// LauncherEntry, also honored by GNOME/KDE) and macOS (dock badge). Matched against the
// app's `<product-name>.desktop` file on Linux, so it's a silent no-op on launchers that
// don't implement the protocol.
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let Some(window) = app.get_webview_window("main") else {
return Ok(());
};
window
.set_badge_count(if count > 0 { Some(count as i64) } else { None })
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
let _ = (app, count);
}
+814 -28
View File
File diff suppressed because it is too large Load Diff
+72 -19
View File
@@ -8,31 +8,78 @@ use git::{
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,
commit_ai_local_models, commit_ai_review, 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_interactive_rebase_commits, list_reflog,
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_reflog_entry,
restore_to_commit, run_sequence_editor_if_requested, search_code_introductions, stage_files,
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit,
unstage_files,
};
use tauri::Manager;
#[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") {
let _ = window.maximize();
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_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
if let Some(result) = run_sequence_editor_if_requested() {
if let Err(error) = result {
eprintln!("{error}");
std::process::exit(1);
}
).build())
return;
}
let builder = 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_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())
.plugin(tauri_plugin_dialog::init());
// Linux installs are expected to come from the system package manager (see the
// PKGBUILD), which owns updates itself — the self-updater is only wired up for
// the platforms whose install method this app ships (NSIS/Windows, .app/macOS).
#[cfg(not(target_os = "linux"))]
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
builder
.invoke_handler(tauri::generate_handler![
open_repository,
clone_repository,
@@ -69,6 +116,7 @@ async fn main() {
commit_ai_load,
commit_ai_local_models,
commit_ai_generate,
commit_ai_review,
pull,
push,
fetch,
@@ -79,6 +127,10 @@ async fn main() {
rebase_branch,
rebase_continue,
rebase_abort,
list_interactive_rebase_commits,
start_interactive_rebase,
list_reflog,
restore_reflog_entry,
list_repository_files,
open_repository_bundle,
list_file_history,
@@ -97,7 +149,8 @@ async 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");
+25 -6
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GitLite",
"version": "2026.7.14",
"identifier": "com.git-lite",
"productName": "Gitty",
"version": "2026.7.19",
"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": {
@@ -32,13 +45,18 @@
"nsis"
],
"icon": [
"icons/icon.png",
"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"
}
}
},
@@ -46,6 +64,7 @@
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdDNEJGRTZERTREOTc5QjkKUldTNWVkbmtiZjVMZk02YzdpenVLOVltREJpRkFrc2F4dUwzaTV5M3pacFRVR0tmd25xSGtjK1MK",
"endpoints": [
"https://cdn.cbsk-tech.de/gitty/latest.json",
"https://cdn.cbsk-tech.de/gitlite/latest.json"
]
}
+1394 -147
View File
File diff suppressed because it is too large Load Diff
+2129 -278
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import { BookOpen, Database, Plus, X } from "@lucide/svelte";
interface RepositoryTabItem {
path: string;
name: string;
branch: string | null;
}
export let activeView: "management" | "repository" = "management";
export let repoTabs: RepositoryTabItem[] = [];
export let isBusy: boolean = false;
export let language: "en" | "de" = "en";
export let onOpenManagement: () => void = () => {};
export let isActive: (path: string) => boolean = () => false;
export let onSelect: (path: string) => void | Promise<void> = () => {};
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
export let onContextMenu: (path: string, event: MouseEvent) => void = () => {};
export let onAdd: () => void | Promise<void> = () => {};
</script>
<header class="repo-tabbar" aria-label={language === "de" ? "Repository-Reiter" : "Repository tabs"}>
<button
class="repo-tab management"
class:active={activeView === "management"}
type="button"
onclick={onOpenManagement}
disabled={isBusy}
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
>
<BookOpen size={14} aria-hidden="true" />
<span>{language === "de" ? "Repository-Verwaltung" : "Repository Management"}</span>
</button>
<div class="repo-tabs-scroll">
{#each repoTabs as repo (repo.path)}
<div
class="repo-tab-wrap"
class:active={isActive(repo.path)}
role="presentation"
oncontextmenu={(event) => onContextMenu(repo.path, event)}
>
<button
class="repo-tab"
type="button"
onclick={() => onSelect(repo.path)}
disabled={isBusy}
title={repo.path}
>
<Database size={15} aria-hidden="true" />
<span>{repo.name}</span>
</button>
<button
class="repo-tab-close"
type="button"
onclick={(event) => onClose(repo.path, event)}
disabled={isBusy}
aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
>
<X size={13} aria-hidden="true" />
</button>
</div>
{/each}
</div>
<button
class="repo-tab-add"
type="button"
onclick={onAdd}
disabled={isBusy}
title={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
aria-label={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
>
<Plus size={15} aria-hidden="true" />
</button>
</header>
+202
View File
@@ -0,0 +1,202 @@
<script lang="ts">
import {
ChevronDown,
CloudDownload,
Download,
FolderOpen,
GitCompare,
History,
ListRestart,
LoaderCircle,
RefreshCw,
Search,
Upload,
} from "@lucide/svelte";
export let hasRepository: boolean = false;
export let isBusy: boolean = false;
export let operation: string = "";
export let ahead: number = 0;
export let behind: number = 0;
export let language: "en" | "de" = "en";
export let onFetch: () => void = () => {};
export let onPull: () => void = () => {};
export let onPush: () => void = () => {};
export let onRefresh: () => void = () => {};
export let onSearch: () => void = () => {};
export let onCompare: () => void = () => {};
export let onInteractiveRebase: () => void = () => {};
export let onReflog: () => void = () => {};
export let onOpenInExplorer: () => void = () => {};
let historyOpen = false;
let toolbarElement: HTMLDivElement;
$: isGerman = language === "de";
function runHistoryAction(action: () => void) {
historyOpen = false;
action();
}
function handleWindowClick(event: MouseEvent) {
if (historyOpen && toolbarElement && !toolbarElement.contains(event.target as Node)) {
historyOpen = false;
}
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && historyOpen) {
event.stopPropagation();
historyOpen = false;
}
}
</script>
<svelte:window onclick={handleWindowClick} onkeydown={handleWindowKeydown} />
<div bind:this={toolbarElement} class="repo-toolbar" role="toolbar" aria-label={isGerman ? "Repository-Aktionen" : "Repository actions"}>
<div class="repo-toolbar-sync">
<div class="repo-action-group repo-sync-actions">
<button
class="repo-action fetch"
onclick={onFetch}
disabled={!hasRepository || isBusy}
title="Fetch"
aria-label="Fetch"
>
{#if operation === "Fetching"}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<CloudDownload size={15} aria-hidden="true" />
{/if}
<span class="repo-action-label">Fetch</span>
</button>
<button
class="repo-action sync-primary"
onclick={onPull}
disabled={!hasRepository || isBusy}
title="Pull"
aria-label={behind > 0
? `Pull, ${behind} ${isGerman ? (behind === 1 ? "Remote-Commit voraus" : "Remote-Commits voraus") : (behind === 1 ? "commit behind" : "commits behind")}`
: "Pull"}
>
{#if operation === "Pulling"}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<Download size={15} aria-hidden="true" />
{/if}
<span class="repo-action-label">Pull</span>
{#if behind > 0}<span class="repo-action-count behind">{behind}</span>{/if}
</button>
<button
class="repo-action sync-primary"
onclick={onPush}
disabled={!hasRepository || isBusy}
title="Push"
aria-label={ahead > 0
? `Push, ${ahead} ${isGerman ? (ahead === 1 ? "lokaler Commit voraus" : "lokale Commits voraus") : (ahead === 1 ? "commit ahead" : "commits ahead")}`
: "Push"}
>
{#if operation === "Pushing"}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<Upload size={15} aria-hidden="true" />
{/if}
<span class="repo-action-label">Push</span>
{#if ahead > 0}<span class="repo-action-count ahead">{ahead}</span>{/if}
</button>
</div>
</div>
<div class="repo-toolbar-divider" aria-hidden="true"></div>
<div class="repo-action-group repo-inspect-actions">
<button
class="repo-action"
onclick={onSearch}
disabled={!hasRepository || isBusy}
title={isGerman ? "Globale Suche" : "Global search"}
aria-label={isGerman ? "Globale Suche" : "Global search"}
>
<Search size={15} aria-hidden="true" />
<span class="repo-action-label">{isGerman ? "Suchen" : "Search"}</span>
</button>
<button
class="repo-action"
onclick={onCompare}
disabled={!hasRepository || isBusy}
title={isGerman ? "Commits vergleichen" : "Compare commits"}
aria-label={isGerman ? "Commits vergleichen" : "Compare commits"}
>
<GitCompare size={15} aria-hidden="true" />
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
</button>
<div class="repo-history-wrap">
<button
class="repo-action history-trigger"
class:active={historyOpen}
type="button"
onclick={() => { historyOpen = !historyOpen; }}
disabled={!hasRepository || isBusy}
aria-haspopup="menu"
aria-expanded={historyOpen}
title={isGerman ? "Verlauf und Rebase" : "History and rebase"}
>
<History size={15} aria-hidden="true" />
<span class="repo-action-label">{isGerman ? "Verlauf" : "History"}</span>
<ChevronDown class="history-chevron" size={13} aria-hidden="true" />
</button>
{#if historyOpen}
<div class="repo-history-menu" role="menu">
<button type="button" role="menuitem" onclick={() => runHistoryAction(onReflog)}>
<History size={15} aria-hidden="true" />
<span>
<strong>Reflog</strong>
<small>{isGerman ? "Lokale Referenzbewegungen" : "Local reference movements"}</small>
</span>
</button>
<button type="button" role="menuitem" onclick={() => runHistoryAction(onInteractiveRebase)}>
<ListRestart size={15} aria-hidden="true" />
<span>
<strong>{isGerman ? "Interaktiver Rebase" : "Interactive rebase"}</strong>
<small>{isGerman ? "Commits ordnen und zusammenfassen" : "Reorder and combine commits"}</small>
</span>
</button>
</div>
{/if}
</div>
</div>
<div class="repo-toolbar-spacer"></div>
<div class="repo-toolbar-divider utility" aria-hidden="true"></div>
<div class="repo-action-group repo-utility-actions">
<button
class="repo-action"
onclick={onOpenInExplorer}
disabled={!hasRepository || isBusy}
title={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
aria-label={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
>
<FolderOpen size={15} aria-hidden="true" />
<span class="repo-action-label utility-label">Explorer</span>
</button>
<button
class="repo-action"
onclick={onRefresh}
disabled={isBusy || !hasRepository}
title={isGerman ? "Manuell aktualisieren" : "Refresh now"}
aria-label={isGerman ? "Manuell aktualisieren" : "Refresh now"}
>
<RefreshCw class={operation === "Refreshing" ? "spin" : ""} size={16} aria-hidden="true" />
<span class="repo-action-label utility-label">{isGerman ? "Aktualisieren" : "Refresh"}</span>
</button>
</div>
</div>
+39 -181
View File
@@ -1,225 +1,84 @@
<script lang="ts">
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, Settings, Upload, X } from "@lucide/svelte";
import { CircleHelp, Minus, Settings, X } from "@lucide/svelte";
import iconUrl from "../../src-tauri/icons/icon.png";
export let branch: string = "";
export let ahead: number = 0;
export let behind: number = 0;
export let repoName: string = "";
export let hasRepository: boolean = false;
export let isBusy: boolean = false;
export let operation: string = "";
export let autoRefreshEnabled: boolean = true;
export let autoRefreshInFlight: boolean = false;
export let onFetch: () => void = () => {};
export let onPull: () => void = () => {};
export let onPush: () => void = () => {};
export let onRefresh: () => void = () => {};
export let onSearch: () => void = () => {};
export let onCompare: () => void = () => {};
export let onOpenInExplorer: () => void = () => {};
export let onToggleAutoRefresh: () => void = () => {};
export let onOpenHelp: () => void = () => {};
export let onOpenSettings: () => void = () => {};
export let language: "en" | "de" = "en";
const win = getCurrentWindow();
let win: ReturnType<typeof getCurrentWindow> | null = null;
let isMaximized = false;
let unlisten: (() => void) | undefined;
let appVersion = "";
onMount(async () => {
try {
win = getCurrentWindow();
isMaximized = await win.isMaximized();
unlisten = await win.onResized(async () => {
isMaximized = await win.isMaximized();
isMaximized = await win?.isMaximized() ?? false;
});
appVersion = await getVersion();
} catch {
win = null;
}
});
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>
{#if appVersion}
<span class="tb-version" data-tauri-drag-region title="Version {appVersion}">v{appVersion}</span>
{/if}
<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>
</div>
<!-- Center: repo + branch info -->
<div class="titlebar-info" data-tauri-drag-region>
{#if hasRepository}
{#if repoName}
<span class="tb-repo" data-tauri-drag-region>{repoName}</span>
<span class="tb-sep" data-tauri-drag-region aria-hidden="true">/</span>
{/if}
<GitBranch size={12} aria-hidden="true" />
<span class="tb-branch" data-tauri-drag-region>{branch}</span>
{#if ahead > 0}
<span class="tb-sync ahead" title="{ahead} commits ahead">{ahead}</span>
{/if}
{#if behind > 0}
<span class="tb-sync behind" title="{behind} commits behind">{behind}</span>
{/if}
{:else}
<span class="tb-no-repo" data-tauri-drag-region>No repository open</span>
{/if}
</div>
<div class="titlebar-drag" data-tauri-drag-region aria-hidden="true"></div>
<!-- Right: actions + window controls -->
<div class="titlebar-right">
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
<!-- Right: app-global actions + window controls -->
<div class="titlebar-globals">
<button
class="tb-action"
onclick={onOpenInExplorer}
disabled={!hasRepository || isBusy}
title="Open repository in Explorer"
aria-label="Open repository in Explorer"
onclick={onOpenHelp}
title={language === "de" ? "Hilfe (Ctrl+/)" : "Help (Ctrl+/)"}
aria-label={language === "de" ? "Hilfe öffnen" : "Open help"}
>
<FolderOpen size={14} aria-hidden="true" />
<span class="tb-action-label">Explorer</span>
<CircleHelp size={14} aria-hidden="true" />
</button>
<button
class="tb-action"
onclick={onSearch}
disabled={!hasRepository || isBusy}
title="Global search"
aria-label="Global search"
>
<Search size={14} aria-hidden="true" />
<span class="tb-action-label">Search</span>
</button>
<button
class="tb-action"
onclick={onCompare}
disabled={!hasRepository || isBusy}
title="Compare commits"
aria-label="Compare commits"
>
<GitCompare size={14} aria-hidden="true" />
<span class="tb-action-label">Compare</span>
</button>
<button
class="tb-action"
onclick={onFetch}
disabled={!hasRepository || isBusy}
title="Fetch"
aria-label="Fetch"
>
{#if operation === "Fetching"}
<LoaderCircle class="spin" size={14} aria-hidden="true" />
{:else}
<CloudDownload size={14} aria-hidden="true" />
{/if}
<span class="tb-action-label">Fetch</span>
</button>
<button
class="tb-action"
onclick={onPull}
disabled={!hasRepository || isBusy}
title="Pull"
aria-label="Pull"
>
{#if operation === "Pulling"}
<LoaderCircle class="spin" size={14} aria-hidden="true" />
{:else}
<Download size={14} aria-hidden="true" />
{/if}
<span class="tb-action-label">Pull</span>
</button>
<button
class="tb-action"
onclick={onPush}
disabled={!hasRepository || isBusy}
title="Push"
aria-label="Push"
>
{#if operation === "Pushing"}
<LoaderCircle class="spin" size={14} aria-hidden="true" />
{:else}
<Upload size={14} aria-hidden="true" />
{/if}
<span class="tb-action-label">Push</span>
</button>
<button
class="tb-action"
onclick={onRefresh}
disabled={isBusy || !hasRepository}
title="Refresh"
aria-label="Refresh"
>
<RefreshCw
class={operation === "Refreshing" ? "spin" : ""}
size={14}
aria-hidden="true"
/>
<span class="tb-action-label">Refresh</span>
</button>
<button
class="tb-action auto-toggle"
class:active={autoRefreshEnabled}
onclick={onToggleAutoRefresh}
aria-pressed={autoRefreshEnabled}
title={autoRefreshEnabled ? "Auto-refresh on — click to disable" : "Auto-refresh off — click to enable"}
aria-label="Toggle auto-refresh"
>
<RefreshCw
class={autoRefreshInFlight ? "spin" : ""}
size={14}
aria-hidden="true"
/>
<span class="tb-action-label">Auto</span>
</button>
<button
class="tb-action"
onclick={onOpenSettings}
title="Settings"
aria-label="Settings"
title={language === "de" ? "Einstellungen" : "Settings"}
aria-label={language === "de" ? "Einstellungen" : "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"}
>
@@ -234,9 +93,8 @@
</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>
</div>
</header>
+94
View File
@@ -0,0 +1,94 @@
<script lang="ts">
import { AlertTriangle, CircleAlert, FileCode, Info, LoaderCircle, RotateCw, ShieldCheck, X } from "@lucide/svelte";
import type { AiReviewFinding, AiReviewResult, CommitAiProvider } from "../types";
interface Props {
result: AiReviewResult;
provider: CommitAiProvider;
isReviewing: boolean;
onRerun: () => void;
onClose: () => void;
}
let { result, provider, isReviewing = false, onRerun, onClose }: Props = $props();
function providerLabel(value: CommitAiProvider): string {
if (value === "openai") return "OpenAI";
if (value === "anthropic") return "Anthropic";
if (value === "custom") return "Custom endpoint";
return "Local AI";
}
function locationLabel(finding: AiReviewFinding): string {
if (!finding.file) return "Repository-wide";
return finding.line ? `${finding.file}:${finding.line}` : finding.file;
}
</script>
<svelte:window onkeydown={(event) => { if (event.key === "Escape" && !isReviewing) onClose(); }} />
<div class="dialog-backdrop" role="presentation">
<div class="dialog ai-review-dialog" role="dialog" aria-modal="true" aria-label="AI pre-commit review" tabindex="-1">
<header class="dialog-header ai-review-header">
<div>
<span class="eyebrow">Staged changes</span>
<h2>AI pre-commit review</h2>
</div>
<div class="dialog-header-actions">
<span class="ai-review-provider">{providerLabel(provider)}</span>
<button class="dialog-close" type="button" onclick={onClose} disabled={isReviewing} aria-label="Close review"><X size={18} aria-hidden="true" /></button>
</div>
</header>
<div class="ai-review-summary">
<div class="ai-review-summary-icon" class:clean={result.findings.length === 0}>
{#if result.findings.length === 0}<ShieldCheck size={22} aria-hidden="true" />{:else}<AlertTriangle size={22} aria-hidden="true" />{/if}
</div>
<div>
<div class="ai-review-summary-line">
<strong>{result.findings.length === 0 ? "No actionable issues found" : `${result.findings.length} review ${result.findings.length === 1 ? "finding" : "findings"}`}</strong>
<span class="ai-review-risk {result.risk}">{result.risk} risk</span>
</div>
<p>{result.summary}</p>
</div>
</div>
<div class="ai-review-findings">
{#if result.findings.length === 0}
<div class="ai-review-clean-state">
<ShieldCheck size={30} aria-hidden="true" />
<strong>The staged diff looks ready for human verification.</strong>
<span>AI reviews can miss issues. Run the relevant tests before committing.</span>
</div>
{:else}
{#each result.findings as finding, index (`${finding.file ?? "repo"}:${finding.line ?? 0}:${finding.title}:${index}`)}
<article class="ai-review-finding {finding.severity}">
<div class="ai-review-finding-icon">
{#if finding.severity === "critical"}<CircleAlert size={17} aria-hidden="true" />{:else if finding.severity === "warning"}<AlertTriangle size={17} aria-hidden="true" />{:else}<Info size={17} aria-hidden="true" />{/if}
</div>
<div class="ai-review-finding-body">
<div class="ai-review-finding-title">
<span>{finding.severity}</span>
<strong>{finding.title}</strong>
</div>
<p>{finding.description}</p>
<div class="ai-review-location"><FileCode size={13} aria-hidden="true" /><code>{locationLabel(finding)}</code></div>
{#if finding.suggestion}<div class="ai-review-suggestion"><strong>Suggested next step</strong><span>{finding.suggestion}</span></div>{/if}
</div>
</article>
{/each}
{/if}
</div>
<footer class="dialog-footer ai-review-footer">
<p>Review suggestions are advisory and never modify files automatically.</p>
<div>
<button class="btn-secondary" type="button" onclick={onRerun} disabled={isReviewing}>
{#if isReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<RotateCw size={14} aria-hidden="true" />{/if}
Review again
</button>
<button class="btn-primary" type="button" onclick={onClose} disabled={isReviewing}>Done</button>
</div>
</footer>
</div>
</div>
@@ -30,7 +30,7 @@
</div>
<div class="analytics-notice-copy">
<p>
GitLite can send anonymous usage events to Aptabase so crashes, rough edges, and commonly used workflows are easier to improve.
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.
+88 -14
View File
@@ -1,19 +1,29 @@
<script lang="ts">
import { Check, Settings, X } from "@lucide/svelte";
import type { AnalyticsSettings } from "../types";
import { Check, Languages, RefreshCw, Settings, X } from "@lucide/svelte";
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types";
interface Props {
analytics: AnalyticsSettings;
onSave: (settings: AnalyticsSettings) => void;
theme: AppTheme;
language: AppLanguage;
autoRefresh: boolean;
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean) => void;
onClose: () => void;
}
let { analytics, onSave = () => {}, onClose = () => {} }: Props = $props();
let { analytics, theme = "system", language = "en", autoRefresh = true, onSave = () => {}, onClose = () => {} }: Props = $props();
let analyticsEnabled = $state(true);
let selectedTheme = $state<AppTheme>("system");
let selectedLanguage = $state<AppLanguage>("en");
let autoRefreshEnabled = $state(true);
const isGerman = $derived(selectedLanguage === "de");
$effect(() => {
analyticsEnabled = analytics.enabled;
selectedTheme = theme;
selectedLanguage = language;
autoRefreshEnabled = autoRefresh;
});
function save() {
@@ -21,46 +31,110 @@
...analytics,
enabled: analyticsEnabled,
noticeSeen: true,
});
}, selectedTheme, selectedLanguage, autoRefreshEnabled);
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1">
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">GitLite</span>
<h2 class="dialog-title">Settings</h2>
<span class="eyebrow">Gitty</span>
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "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">{isGerman ? "Darstellung" : "Appearance"}</span>
<h3>{isGerman ? "Farbschema" : "Theme"}</h3>
</div>
</header>
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "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>{isGerman ? "Hell" : "Light"}</span>
</label>
<label class:active={selectedTheme === "dark"}>
<input type="radio" bind:group={selectedTheme} value="dark" />
<span>{isGerman ? "Dunkel" : "Dark"}</span>
</label>
</div>
</section>
<section class="settings-section">
<header>
<RefreshCw size={16} aria-hidden="true" />
<div>
<span class="eyebrow">Repository</span>
<h3>{isGerman ? "Automatische Aktualisierung" : "Auto refresh"}</h3>
</div>
</header>
<label class="settings-toggle-row">
<input type="checkbox" bind:checked={autoRefreshEnabled} />
<span>
<strong>{isGerman ? "Repositories automatisch aktualisieren" : "Refresh repositories automatically"}</strong>
<small>{isGerman ? "Aktualisiert Arbeitsbereich, Branch-Status und Remotes regelmäßig im Hintergrund." : "Periodically refreshes the working tree, branch status, and remotes in the background."}</small>
</span>
</label>
</section>
<section class="settings-section">
<header>
<Languages size={16} aria-hidden="true" />
<div>
<span class="eyebrow">{isGerman ? "Sprache" : "Language"}</span>
<h3>{isGerman ? "App-Sprache" : "App language"}</h3>
</div>
</header>
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
<label class:active={selectedLanguage === "en"}>
<input type="radio" bind:group={selectedLanguage} value="en" />
<span>EN · English</span>
</label>
<label class:active={selectedLanguage === "de"}>
<input type="radio" bind:group={selectedLanguage} value="de" />
<span>DE · Deutsch</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>
<h3>{isGerman ? "Anonyme Nutzungsanalyse" : "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>
<strong>{isGerman ? "Anonyme Aptabase-Ereignisse erlauben" : "Allow anonymous Aptabase events"}</strong>
<small>{isGerman ? "Es werden keine Repository-Pfade, Remotes, Branches, Commit-Nachrichten, Dateinamen, Diffs, Zugangsdaten oder Code übertragen." : "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-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit">
<Check size={16} aria-hidden="true" />
Save
{isGerman ? "Speichern" : "Save"}
</button>
</div>
</form>
+104 -9
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { FileCode, LoaderCircle, X } from "@lucide/svelte";
import { FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
import type { GitBlameLine } from "../types";
interface BlameGroup {
@@ -14,6 +14,11 @@
lines: GitBlameLine[];
}
interface TextSegment {
text: string;
matched: boolean;
}
interface Props {
filePath: string;
lines: GitBlameLine[];
@@ -32,6 +37,8 @@
onClose = () => {},
}: Props = $props();
let blameSearch = $state("");
function groupBlameLines(source: GitBlameLine[]): BlameGroup[] {
const groups: BlameGroup[] = [];
for (const line of source) {
@@ -67,7 +74,56 @@
return `${group.authorName} <${group.authorEmail}>\n${group.summary}\n${group.hash}`;
}
let groups = $derived(groupBlameLines(lines));
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">
@@ -93,13 +149,28 @@
</div>
{:else if error}
<div class="blank-state">{error}</div>
{:else if groups.length === 0}
{: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>{lines.length} lines</strong>
<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>
@@ -107,26 +178,50 @@
</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">{group.isUncommitted ? "Uncommitted" : group.shortHash}</span>
<span class="blame-author">{group.isUncommitted ? "Not committed yet" : group.authorName}</span>
<span class="blame-summary">{group.summary || "No commit message"}</span>
<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">{formatBlameDate(group.authorTime)}</span>
<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">{line.content}</code>
<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}
+22 -2
View File
@@ -57,6 +57,8 @@
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 {
@@ -75,6 +77,8 @@
onCreateTag = () => {},
onDeleteTag = () => {},
onPushTag = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
let localOpen = $state(true);
@@ -355,7 +359,7 @@
<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>
@@ -373,10 +377,26 @@
<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 && tags.length === 0}
<p class="blank-state">No branches returned.</p>
+35 -28
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, LoaderCircle, RotateCcw, Settings, Sparkles } from "@lucide/svelte";
import { Check, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props {
@@ -13,11 +13,13 @@
commitAiProvider: CommitAiProvider;
commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean;
commitAiReviewing: boolean;
canAmend: boolean;
amendMode: boolean;
onCommit: () => void;
onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void;
onReviewStaged: () => void;
onOpenAiSettings: () => void;
onToggleAmend: (checked: boolean) => void;
onUndoLastCommit: () => void;
@@ -34,11 +36,13 @@
commitAiProvider = "local",
commitAiPhase = "idle",
commitAiGenerating = false,
commitAiReviewing = false,
canAmend = false,
amendMode = false,
onCommit = () => {},
onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {},
onReviewStaged = () => {},
onOpenAiSettings = () => {},
onToggleAmend = () => {},
onUndoLastCommit = () => {},
@@ -61,25 +65,52 @@
hasRepository &&
!isBusy &&
!commitAiGenerating &&
!commitAiReviewing &&
stagedCount > 0 &&
(commitAiProvider !== "local" || commitAiPhase === "ready"),
);
let canReview = $derived(canGenerate && commitAiProvider !== "local");
</script>
<section class="panel commit-panel" aria-label="Commit">
<div class="section-head">
<div>
<span class="eyebrow">Commit</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Message</h2>
<span class="eyebrow">Create revision</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commit</h2>
</div>
<div class="commit-head-actions">
<span class="pill pill-count">{stagedCount} staged</span>
<button
class="commit-review-button"
type="button"
onclick={onReviewStaged}
disabled={!canReview}
title={commitAiProvider === "local" ? "Pre-commit review currently requires an API provider" : stagedCount === 0 ? "Stage changes first" : "Review staged changes for bugs and risks"}
>
{#if commitAiReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<ShieldCheck size={14} aria-hidden="true" />{/if}
Review
</button>
<button
class="commit-generate-button"
type="button"
onclick={onGenerateCommitMessage}
disabled={!canGenerate}
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
>
{#if commitAiGenerating || localModelLoading}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<Sparkles size={14} aria-hidden="true" />{/if}
Generate
</button>
<button class="commit-settings-button" type="button" onclick={onOpenAiSettings} disabled={isBusy} title="AI settings" aria-label="AI settings">
<Settings size={14} aria-hidden="true" />
</button>
</div>
</div>
<form class="commit-form" onsubmit={handleSubmit}>
<textarea
value={commitMessage}
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
placeholder="Commit message..."
placeholder="Commit message"
disabled={!hasRepository || isBusy}
></textarea>
{#if commitBlockReason}
@@ -117,30 +148,6 @@
{/if}
{amendMode ? "Amend" : "Commit"}
</button>
<button
class="btn-secondary commit-ai-button"
type="button"
onclick={onGenerateCommitMessage}
disabled={!canGenerate}
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
>
{#if commitAiGenerating || localModelLoading}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Sparkles size={16} aria-hidden="true" />
{/if}
AI
</button>
<button
class="btn-secondary commit-ai-settings-button"
type="button"
onclick={onOpenAiSettings}
disabled={isBusy}
title="AI settings"
aria-label="AI settings"
>
<Settings size={16} aria-hidden="true" />
</button>
</div>
</form>
</section>
+50
View File
@@ -10,6 +10,12 @@
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
};
interface DiffMarker {
start: number;
end: number;
kind: "add" | "delete" | "mixed";
}
interface Props {
comparison: GitCommitComparison;
selectedDiffPath: string;
@@ -173,10 +179,40 @@
return rows;
}
function buildDiffMarkers(rows: SplitRow[]): DiffMarker[] {
const markers: DiffMarker[] = [];
let current: DiffMarker | null = null;
for (let index = 0; index < rows.length; index++) {
const row = rows[index];
if (row.type !== "pair" || (row.leftKind === "context" && row.rightKind === "context")) {
current = null;
continue;
}
const kind = row.leftKind === "del" && row.rightKind === "add"
? "mixed"
: row.rightKind === "add" ? "add" : "delete";
if (current && current.end === index - 1 && current.kind === kind) {
current.end = index;
} else {
current = { start: index, end: index, kind };
markers.push(current);
}
}
return markers;
}
function scrollToDiffMarker(rowIndex: number) {
const ratio = rowIndex / Math.max(splitRows.length - 1, 1);
for (const pane of [beforePane, afterPane]) {
if (pane) pane.scrollTop = ratio * Math.max(pane.scrollHeight - pane.clientHeight, 0);
}
}
let diffByPath = $derived(buildDiffByPath(comparison.patch));
let selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
let splitRows = $derived(buildSplitRows(selectedPatch));
let diffMarkers = $derived(buildDiffMarkers(splitRows));
</script>
<div
@@ -262,6 +298,7 @@
</div>
<!-- Split diff grid -->
<div class="split-diff-shell">
<div class="split-diff" role="table" aria-label="Side-by-side diff">
<div
class="split-pane"
@@ -298,6 +335,19 @@
</div>
</div>
</div>
<nav class="diff-overview" aria-label="Change overview">
{#each diffMarkers as marker, index (`${marker.start}-${marker.end}-${marker.kind}`)}
<button
class="diff-overview-marker {marker.kind}"
type="button"
style={`--marker-position: ${(marker.start / Math.max(splitRows.length - 1, 1)) * 100}%`}
onclick={() => scrollToDiffMarker(marker.start)}
title={`Jump to change ${index + 1} of ${diffMarkers.length}`}
aria-label={`Jump to change ${index + 1} of ${diffMarkers.length}`}
></button>
{/each}
</nav>
</div>
{/if}
</div>
+27 -10
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,10 +20,16 @@
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 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>
@@ -46,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>
+22 -2
View File
@@ -38,6 +38,8 @@
onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
let {
@@ -53,6 +55,8 @@
onSelectNode = () => {},
onOpenFile = () => {},
onBlame = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
let contextNode = $state<ExplorerNode | null>(null);
@@ -203,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>
@@ -231,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>
+44 -16
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>
<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" />
<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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,148 @@
<script lang="ts">
import { AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
interface PlanRow extends RebaseCommit {
action: RebaseAction;
message: string;
}
interface Props {
branches: GitBranchInfo[];
currentBranch: string;
base: string;
commits: RebaseCommit[];
isLoading: boolean;
isBusy: boolean;
operation: string;
error: string;
onBaseChange: (base: string) => void;
onStart: (plan: RebasePlanItem[]) => void;
onClose: () => void;
}
let {
branches = [], currentBranch = "", base = "", commits = [], isLoading = false,
isBusy = false, operation = "", error = "", onBaseChange = () => {},
onStart = () => {}, onClose = () => {},
}: Props = $props();
let rows = $state<PlanRow[]>([]);
$effect(() => {
rows = commits.map((commit) => ({ ...commit, action: "pick", message: commit.summary }));
});
let availableBases = $derived(branches.filter((branch) => !branch.current));
let keptCount = $derived(rows.filter((row) => row.action !== "drop").length);
let invalidSquash = $derived(rows.some((row, index) =>
(row.action === "squash" || row.action === "fixup")
&& rows.slice(0, index).every((previous) => previous.action === "drop")
));
let invalidReword = $derived(rows.some((row) => row.action === "reword" && !row.message.trim()));
let canStart = $derived(Boolean(base) && rows.length > 0 && keptCount > 0 && !invalidSquash && !invalidReword && !isLoading && !isBusy);
function updateAction(index: number, action: RebaseAction) {
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, action } : row);
}
function updateMessage(index: number, message: string) {
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, message } : row);
}
function move(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= rows.length) return;
const next = [...rows];
[next[index], next[target]] = [next[target], next[index]];
rows = next;
}
function start() {
if (!canStart) return;
onStart(rows.map((row) => ({
hash: row.hash,
action: row.action,
message: row.action === "reword" ? row.message.trim() : null,
})));
}
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog interactive-rebase-dialog" role="dialog" aria-modal="true" aria-label="Interactive rebase" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Rewrite local history</span>
<h2 class="dialog-title">Interactive rebase</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
</header>
<div class="interactive-rebase-body">
<section class="rebase-base-bar">
<label>
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
<select value={base} onchange={(event) => onBaseChange((event.target as HTMLSelectElement).value)} disabled={isBusy || isLoading}>
<option value="" disabled>Select a base branch</option>
{#each availableBases as branch (branch.name)}
<option value={branch.name}>{branch.remote ? "Remote · " : "Local · "}{branch.name}</option>
{/each}
</select>
</label>
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
</section>
{#if error}
<div class="rebase-warning error"><AlertTriangle size={16} aria-hidden="true" /><span>{error}</span></div>
{/if}
{#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading rebase range…</div>
{:else if !base}
<div class="blank-state">Select the branch or commit that should become the new base.</div>
{:else if rows.length === 0}
<div class="blank-state">No linear commits are available above this base.</div>
{:else}
<div class="rebase-plan" role="list" aria-label="Interactive rebase plan">
{#each rows as row, index (row.hash)}
<article class:drop={row.action === "drop"} class="rebase-plan-row" role="listitem">
<div class="rebase-order-actions">
<button type="button" onclick={() => move(index, -1)} disabled={isBusy || index === 0} title="Move up"><ArrowUp size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title="Move down"><ArrowDown size={14} aria-hidden="true" /></button>
</div>
<select class={`rebase-action ${row.action}`} value={row.action} onchange={(event) => updateAction(index, (event.target as HTMLSelectElement).value as RebaseAction)} disabled={isBusy} aria-label={`Action for ${row.short_hash}`}>
<option value="pick">pick</option><option value="reword">reword</option><option value="squash">squash</option><option value="fixup">fixup</option><option value="drop">drop</option>
</select>
<code>{row.short_hash}</code>
<div class="rebase-commit-copy">
{#if row.action === "reword"}
<input value={row.message} oninput={(event) => updateMessage(index, (event.target as HTMLInputElement).value)} disabled={isBusy} aria-label={`New message for ${row.short_hash}`} maxlength="240" />
{:else}
<strong>{row.summary}</strong>
{/if}
<span>{row.author_name} · {new Date(row.date).toLocaleString()}</span>
</div>
</article>
{/each}
</div>
{/if}
{#if invalidSquash}
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Squash and fixup need an earlier commit that is not dropped.</div>
{:else if invalidReword}
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Reword messages cannot be empty.</div>
{/if}
</div>
<footer class="dialog-footer">
<span class="dialog-footer-info">{keptCount} of {rows.length} commits kept</span>
<div class="rebase-footer-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-primary" type="button" onclick={start} disabled={!canStart}>
{#if operation === "Starting interactive rebase"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Play size={16} aria-hidden="true" />{/if}
Start rebase
</button>
</div>
</footer>
</div>
</div>
+34 -2
View File
@@ -47,6 +47,7 @@
}: Props = $props();
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
let patchScroll = $state<HTMLDivElement | null>(null);
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
@@ -118,6 +119,23 @@
if (isBusy || isLoading) return;
await onApply(action, buildHunkPatch(hunk));
}
function hunkPosition(index: number): number {
const totalLines = parsed.hunks.reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
const precedingLines = parsed.hunks.slice(0, index).reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
return (precedingLines / Math.max(totalLines - 1, 1)) * 100;
}
function hunkKind(hunk: PatchHunk): "add" | "delete" | "mixed" {
const hasAdd = hunk.lines.some((line) => line.kind === "add");
const hasDelete = hunk.lines.some((line) => line.kind === "delete");
return hasAdd && hasDelete ? "mixed" : hasAdd ? "add" : "delete";
}
function scrollToHunk(hunkId: string) {
const target = patchScroll?.querySelector<HTMLElement>(`[data-hunk-id="${hunkId}"]`);
if (patchScroll && target) patchScroll.scrollTop = Math.max(target.offsetTop - 8, 0);
}
</script>
<div class="dialog-backdrop" role="presentation">
@@ -148,9 +166,10 @@
{:else if parsed.binary || parsed.hunks.length === 0}
<div class="blank-state">This change cannot be split into text lines.</div>
{:else}
<div class="line-patch-scroll">
<div class="line-patch-workspace">
<div class="line-patch-scroll" bind:this={patchScroll}>
{#each parsed.hunks as hunk (hunk.id)}
<section class="line-patch-hunk">
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
<div class="line-patch-hunk-head">
<code>{hunk.header}</code>
<div class="line-patch-hunk-actions">
@@ -183,6 +202,19 @@
</section>
{/each}
</div>
<nav class="diff-overview line-patch-overview" aria-label="Change overview">
{#each parsed.hunks as hunk, index (hunk.id)}
<button
class="diff-overview-marker {hunkKind(hunk)}"
type="button"
style={`--marker-position: ${hunkPosition(index)}%`}
onclick={() => scrollToHunk(hunk.id)}
title={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
aria-label={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
></button>
{/each}
</nav>
</div>
{/if}
</div>
</div>
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import { GitBranch, History, LoaderCircle, Search, ShieldCheck, X } from "@lucide/svelte";
import type { ReflogEntry } from "../types";
interface Props {
entries: ReflogEntry[];
currentHash: string;
isLoading: boolean;
isBusy: boolean;
operation: string;
error: string;
onPreview: (entry: ReflogEntry) => void;
onRestore: (entry: ReflogEntry, branch: string) => void;
onClose: () => void;
}
let { entries = [], currentHash = "", isLoading = false, isBusy = false, operation = "", error = "", onPreview = () => {}, onRestore = () => {}, onClose = () => {} }: Props = $props();
let query = $state("");
let selectedHash = $state("");
let recoveryBranch = $state("");
let filteredEntries = $derived(entries.filter((entry) => `${entry.selector} ${entry.action} ${entry.short_hash} ${entry.author_name}`.toLowerCase().includes(query.trim().toLowerCase())));
let selected = $derived(entries.find((entry) => entry.hash === selectedHash) ?? filteredEntries[0] ?? null);
$effect(() => {
if (!selectedHash && entries.length > 0) select(entries[0]);
});
function select(entry: ReflogEntry) {
selectedHash = entry.hash;
recoveryBranch = `recovery/${entry.short_hash}`;
}
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog reflog-dialog" role="dialog" aria-modal="true" aria-label="Reflog" tabindex="-1">
<header class="dialog-header">
<div><span class="eyebrow">Recovery history</span><h2 class="dialog-title">Reflog</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
</header>
<div class="reflog-body">
<aside class="reflog-list-pane">
<label class="reflog-search"><Search size={15} aria-hidden="true" /><input bind:value={query} placeholder="Search actions, hashes or authors" aria-label="Search reflog" /></label>
{#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading reflog…</div>
{:else if filteredEntries.length === 0}
<div class="blank-state">No reflog entries match this search.</div>
{:else}
<div class="reflog-list" role="listbox" aria-label="Reflog entries">
{#each filteredEntries as entry (`${entry.selector}:${entry.hash}`)}
<button class:active={selected?.selector === entry.selector} type="button" role="option" aria-selected={selected?.selector === entry.selector} onclick={() => select(entry)}>
<span class="reflog-row-top"><code>{entry.selector}</code><span>{new Date(entry.date).toLocaleString()}</span></span>
<strong>{entry.action}</strong>
<span class="reflog-row-bottom"><code>{entry.short_hash}</code><span>{entry.author_name}</span></span>
</button>
{/each}
</div>
{/if}
</aside>
<section class="reflog-detail">
{#if error}<div class="rebase-warning error">{error}</div>{/if}
{#if selected}
<div class="reflog-detail-head"><History size={20} aria-hidden="true" /><div><span class="eyebrow">{selected.selector}</span><h3>{selected.action}</h3></div></div>
<dl><div><dt>Commit</dt><dd><code>{selected.hash}</code></dd></div><div><dt>Author</dt><dd>{selected.author_name}</dd></div><div><dt>Date</dt><dd>{new Date(selected.date).toLocaleString()}</dd></div></dl>
<button class="btn-secondary reflog-preview" type="button" onclick={() => onPreview(selected)} disabled={isBusy || selected.hash === currentHash}><History size={15} aria-hidden="true" /> Preview changes to current HEAD</button>
<div class="reflog-recovery-card">
<div class="reflog-recovery-title"><ShieldCheck size={18} aria-hidden="true" /><div><strong>Safe recovery</strong><span>Create a new branch here. The current branch is not reset or deleted.</span></div></div>
<label><span>Recovery branch</span><div><GitBranch size={15} aria-hidden="true" /><input bind:value={recoveryBranch} disabled={isBusy} spellcheck="false" /></div></label>
<button class="btn-primary" type="button" onclick={() => onRestore(selected, recoveryBranch.trim())} disabled={isBusy || !recoveryBranch.trim()}>
{#if operation === "Restoring reflog entry"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<ShieldCheck size={16} aria-hidden="true" />{/if}
Create and checkout recovery branch
</button>
</div>
{:else}
<div class="blank-state">Select a reflog entry to inspect or recover it.</div>
{/if}
</section>
</div>
</div>
</div>
+168 -88
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" />
<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,69 +33,116 @@
<style>
.repo-loading {
position: fixed;
top: var(--app-titlebar-height, 42px);
top: calc(var(--app-titlebar-height, 42px) + 56px);
right: 0;
bottom: 0;
left: 0;
z-index: 400;
display: grid;
place-items: center;
background: #050712;
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;
@@ -117,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 {
@@ -133,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>
+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>
+353 -95
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png";
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
interface Props {
@@ -8,12 +9,14 @@
unstagedCount: number;
hasRepository: boolean;
isBusy: boolean;
operation: string;
status: GitStatus | null;
selectedFilePath: string;
onSelectFile: (file: GitFileStatus) => void;
onStage: (file: GitFileStatus) => void;
onUnstage: (file: GitFileStatus) => void;
onDiscard: (file: GitFileStatus, staged: boolean) => void;
onStage: (files: GitFileStatus[]) => void;
onUnstage: (files: GitFileStatus[]) => void;
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
onDiscardMany: (files: GitFileStatus[]) => void;
onPatch: (file: GitFileStatus, staged: boolean) => void;
onStageAll: () => void;
onUnstageAll: () => void;
@@ -25,12 +28,14 @@
unstagedCount = 0,
hasRepository = false,
isBusy = false,
operation = "",
status = null,
selectedFilePath = "",
onSelectFile = () => {},
onStage = () => {},
onUnstage = () => {},
onDiscard = () => {},
onDiscardMany = () => {},
onPatch = () => {},
onStageAll = () => {},
onUnstageAll = () => {},
@@ -56,46 +61,116 @@
return kind === "modified";
}
function fileKey(file: GitFileStatus): string {
return `${file.old_path ?? ""}:${file.path}`;
}
let selectedStatusPaths = $state<Set<string>>(new Set());
let selectionAnchorKey = $state("");
function isStatusSelected(file: GitFileStatus): boolean {
return selectedStatusPaths.has(fileKey(file));
}
function selectedFiles(): GitFileStatus[] {
return changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)));
}
function selectedStageTargets(file: GitFileStatus): GitFileStatus[] {
const files = isStatusSelected(file) ? selectedFiles() : [file];
return files.filter((item) => item.unstaged !== null);
}
function selectedUnstageTargets(file: GitFileStatus): GitFileStatus[] {
const files = isStatusSelected(file) ? selectedFiles() : [file];
return files.filter((item) => item.staged !== null);
}
function handleFileSelect(event: MouseEvent, file: GitFileStatus) {
const key = fileKey(file);
const allKeys = changedFiles.map(fileKey);
const next = new Set(selectedStatusPaths);
if (event.shiftKey && selectionAnchorKey) {
const anchorIndex = allKeys.indexOf(selectionAnchorKey);
const currentIndex = allKeys.indexOf(key);
if (anchorIndex >= 0 && currentIndex >= 0) {
const start = Math.min(anchorIndex, currentIndex);
const end = Math.max(anchorIndex, currentIndex);
for (const itemKey of allKeys.slice(start, end + 1)) next.add(itemKey);
} else {
next.add(key);
}
} else if (event.ctrlKey || event.metaKey) {
if (next.has(key)) next.delete(key);
else next.add(key);
selectionAnchorKey = key;
} else {
next.clear();
next.add(key);
selectionAnchorKey = key;
}
selectedStatusPaths = next;
onSelectFile(file);
}
function stageFromFile(file: GitFileStatus) {
const targets = selectedStageTargets(file);
if (targets.length === 0) return;
onStage(targets);
}
function unstageFromFile(file: GitFileStatus) {
const targets = selectedUnstageTargets(file);
if (targets.length === 0) return;
onUnstage(targets);
}
// Discard mirrors the stage/unstage target selection: if the clicked row is
// part of the current multi-selection, the whole selection (filtered to the
// relevant lane) is discarded; otherwise just that one file.
function discardStagedFromFile(file: GitFileStatus) {
const targets = selectedUnstageTargets(file);
if (targets.length === 0) return;
onDiscard(targets, true);
}
function discardUnstagedFromFile(file: GitFileStatus) {
const targets = selectedStageTargets(file);
if (targets.length === 0) return;
onDiscard(targets, false);
}
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
$effect(() => {
const validKeys = new Set(changedFiles.map(fileKey));
const next = new Set([...selectedStatusPaths].filter((key) => validKeys.has(key)));
if (next.size !== selectedStatusPaths.size) selectedStatusPaths = next;
if (selectionAnchorKey && !validKeys.has(selectionAnchorKey)) selectionAnchorKey = "";
});
</script>
<section class="panel grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status">
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
<div class="section-head">
<div>
<span class="eyebrow">Working tree</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Status</h2>
<span class="eyebrow">Workspace</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
</div>
<div class="flex items-center gap-1.5">
<div class="status-head-actions">
<span class="pill pill-count">{stagedCount} staged</span>
<span class="pill pill-count">{unstagedCount} unstaged</span>
</div>
</div>
{#if hasRepository && changedFiles.length > 0}
<div class="status-toolbar">
<button
class="btn-sm"
type="button"
onclick={onStageAll}
disabled={isBusy || !hasUnstaged}
title="Stage all unstaged files"
>
<Check size={14} aria-hidden="true" />
Stage all
<button class="btn-sm status-discard-all" type="button" onclick={() => onDiscardMany(changedFiles)} disabled={isBusy} title="Discard all staged and unstaged changes">
<RotateCcw size={13} aria-hidden="true" /> Discard all
</button>
<button
class="btn-sm"
type="button"
onclick={onUnstageAll}
disabled={isBusy || !hasStaged}
title="Unstage all staged files"
>
<Undo2 size={14} aria-hidden="true" />
Unstage all
</button>
</div>
{/if}
</div>
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
@@ -104,76 +179,259 @@
{:else if changedFiles.length === 0}
<div class="blank-state">No file changes returned.</div>
{:else}
<div class="overflow-auto p-2">
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
<article
class="file-row"
class:selected={selectedFilePath === file.path}
>
<div class="file-title">
<button
class="file-title-button"
type="button"
onclick={() => onSelectFile(file)}
title={`Select ${displayPath(file)} in Explorer`}
>
<div class="status-lanes overflow-auto">
<section class="status-lane" aria-label="Unstaged changes">
<header class="status-lane-head">
<div class="status-lane-title"><strong>Unstaged</strong><span>{unstagedCount}</span></div>
<div class="status-lane-actions">
{#if selectedUnstagedCount > 1}
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={`Stage ${selectedUnstagedCount} selected files`}>
<Check size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
</button>
{/if}
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
<Check size={13} aria-hidden="true" /> Stage all
</button>
{#if selectedUnstagedCount > 1}
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={`Discard unstaged changes in ${selectedUnstagedCount} selected files`}>
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedUnstagedCount}
</button>
{/if}
</div>
</header>
<div class="status-file-list">
{#each changedFiles.filter((file) => file.unstaged !== null) as file (`unstaged:${fileKey(file)}`)}
{@const stageTargets = selectedStageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
</button>
</div>
<div class="change-lanes">
<div class="change-lane" class:inactive={!file.staged}>
<div class="lane-header">
<span class="lane-name">Staged</span>
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
</div>
<div class="lane-actions">
{#if file.staged}
<button class="btn-sm" type="button" onclick={() => onUnstage(file)} disabled={isBusy} title="Unstage file">
<Undo2 size={14} aria-hidden="true" />
Unstage
</button>
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
<FileDiff size={14} aria-hidden="true" />
Details
</button>
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
<RotateCcw size={14} aria-hidden="true" />
Discard
</button>
{:else}
<span class="quiet">No staged change</span>
{/if}
</div>
</div>
<div class="change-lane" class:inactive={!file.unstaged}>
<div class="lane-header">
<span class="lane-name">Unstaged</span>
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
</div>
<div class="lane-actions">
{#if file.unstaged}
<button class="btn-sm" type="button" onclick={() => onStage(file)} disabled={isBusy} title="Stage file">
<Check size={14} aria-hidden="true" />
Stage
</button>
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
<FileDiff size={14} aria-hidden="true" />
Details
</button>
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
<RotateCcw size={14} aria-hidden="true" />
Discard
</button>
{:else}
<span class="quiet">No unstaged change</span>
{/if}
</div>
</div>
<div class="status-file-actions">
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><Check size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{/each}
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
</div>
</section>
<section class="status-lane" aria-label="Staged changes">
<header class="status-lane-head">
<div class="status-lane-title"><strong>Staged</strong><span>{stagedCount}</span></div>
<div class="status-lane-actions">
{#if selectedStagedCount > 1}
<span class="status-selection-count">{selectedStagedCount} selected</span>
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={`Unstage ${selectedStagedCount} selected files`}>
<Undo2 size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
</button>
{/if}
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
<Undo2 size={13} aria-hidden="true" /> Unstage all
</button>
{#if selectedStagedCount > 1}
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={`Discard staged changes in ${selectedStagedCount} selected files`}>
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedStagedCount}
</button>
{/if}
</div>
</header>
<div class="status-file-list">
{#each changedFiles.filter((file) => file.staged !== null) as file (`staged:${fileKey(file)}`)}
{@const unstageTargets = selectedUnstageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
</button>
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
<div class="status-file-actions">
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><Undo2 size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{/each}
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
</div>
</section>
</div>
{/if}
{#if isBusy && hasRepository}
<div class="status-panel-overlay" role="status" aria-live="polite">
<div class="status-panel-overlay-card">
<div class="status-panel-overlay-mark">
<span class="status-panel-overlay-halo halo-one"></span>
<span class="status-panel-overlay-halo halo-two"></span>
<svg class="status-panel-overlay-traces" viewBox="0 0 220 220" aria-hidden="true">
<path class="trace trace-main" d="M28 154 C72 114, 88 108, 110 110 S156 116, 192 68" />
<path class="trace trace-branch" d="M62 74 C100 88, 126 124, 158 166" />
<path class="trace trace-cut" d="M46 180 L174 180" />
</svg>
<img src={iconUrl} alt="" class="status-panel-overlay-icon" />
</div>
<span class="status-panel-overlay-label">{operation || "Working"}</span>
<div class="status-panel-overlay-bar"><span></span></div>
</div>
</div>
{/if}
</section>
<style>
.status-panel-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
background:
radial-gradient(circle at 50% 38%, rgba(111, 140, 255, 0.1), transparent 55%),
rgba(7, 10, 16, 0.62);
backdrop-filter: blur(4px);
animation: status-panel-overlay-in 120ms ease;
}
.status-panel-overlay-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(300px, calc(100% - 32px));
padding: 26px 28px 26px;
border: 1px solid rgba(90, 111, 154, 0.28);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)),
var(--color-surface-raised);
color: var(--color-ink);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.status-panel-overlay-mark {
position: relative;
width: 104px;
height: 104px;
display: grid;
place-items: center;
isolation: isolate;
}
.status-panel-overlay-halo {
position: absolute;
inset: 6px;
border: 1px solid rgba(111, 140, 255, 0.22);
border-radius: 22px;
transform: rotate(45deg);
}
.status-panel-overlay-halo.halo-one {
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite;
}
.status-panel-overlay-halo.halo-two {
inset: 16px;
border-color: rgba(77, 182, 214, 0.24);
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse;
}
.status-panel-overlay-traces {
position: absolute;
inset: -12px;
width: 128px;
height: 128px;
overflow: visible;
z-index: 0;
}
.status-panel-overlay-traces .trace {
fill: none;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 165;
stroke-dashoffset: 165;
filter: drop-shadow(0 0 6px rgba(77, 182, 214, 0.32));
animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite;
}
.status-panel-overlay-traces .trace-main { stroke: #6f8cff; }
.status-panel-overlay-traces .trace-branch {
stroke: #4db6d6;
animation-delay: 0.28s;
}
.status-panel-overlay-traces .trace-cut {
stroke: rgba(177, 186, 208, 0.42);
stroke-dasharray: 128;
stroke-dashoffset: 128;
animation-delay: 0.55s;
}
.status-panel-overlay-icon {
position: relative;
z-index: 1;
width: 64px;
height: 64px;
object-fit: contain;
filter:
drop-shadow(0 8px 12px rgba(0, 0, 0, 0.5))
drop-shadow(0 0 10px rgba(77, 182, 214, 0.2));
animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite;
}
.status-panel-overlay-label {
max-width: 100%;
overflow: hidden;
color: var(--color-ink);
font-size: 14px;
font-weight: 700;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-panel-overlay-bar {
position: relative;
width: min(190px, 100%);
height: 4px;
overflow: hidden;
border-radius: 999px;
background: rgba(111, 140, 255, 0.14);
}
.status-panel-overlay-bar span {
position: absolute;
inset: 0;
width: 46%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
box-shadow: 0 0 12px rgba(77, 182, 214, 0.26);
animation: status-panel-overlay-bar-slide 1.35s ease-in-out infinite;
}
@keyframes status-panel-overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes status-panel-overlay-icon-float {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-3px) scale(1.015); }
}
@keyframes status-panel-overlay-halo-breathe {
0%, 100% { opacity: 0.35; transform: rotate(45deg) scale(0.95); }
50% { opacity: 0.8; transform: rotate(45deg) scale(1.04); }
}
@keyframes status-panel-overlay-bar-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
@keyframes status-panel-overlay-trace-draw {
0% { stroke-dashoffset: 165; opacity: 0; }
36% { opacity: 1; }
64%, 100% { stroke-dashoffset: 0; opacity: 0.72; }
}
@media (prefers-reduced-motion: reduce) {
.status-panel-overlay,
.status-panel-overlay-halo,
.status-panel-overlay-traces .trace,
.status-panel-overlay-icon,
.status-panel-overlay-bar span { animation: none; }
.status-panel-overlay-traces .trace { stroke-dashoffset: 0; opacity: 0.72; }
}
</style>

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