Compare commits

..
63 Commits
Author SHA1 Message Date
Christoph Brandau 40311275b0 style(app): remove border radius on commit-body
Removes the rounded corners on the commit-body by setting border-radius to 0.
This makes the component use a flat edge and aligns with the design system.

- Remove dependency on ui radius variable for commit-body
2026-08-13 23:07:19 +02:00
Christoph Brandau fe577d78a8 feat(app): add background commit notes fetch and refresh logic
This change adds a background path for fetching commit notes per repo
and a shared cache to avoid duplicate work. When notes are fetched
and the active repo is visible, history is refreshed to reflect
notes without blocking user actions.

- Adds per-path backgroundCommitNotesFetches cache to debounce fetches
- Integrates note fetch into the background tick and refresh flow
- Handles credential errors and shutdown gracefully during fetches
2026-08-13 22:56:53 +02:00
Christoph Brandau a27a8666ee feat(git): add commit-note support and previews in history
This change adds Git notes support to the history UI.
Commits now carry a has_note flag which triggers a note indicator.
Notes can be previewed on hover and loaded on demand,
then the history can be refreshed after edits.

- Adds has_note support on commits and parses from logs.
- Renders a note indicator in history rows with a hover preview.
- Triggers history refresh after note-related actions.
2026-08-13 22:51:24 +02:00
Christoph Brandau c442b3735f feat(auth): add credential mode (credentials/token) support
Introduces a credential mode for stored credentials and wire it to
per-remote URL resolution and operation flows. The UI, storage, and
remote interactions now track and persist the mode, enabling token
based auth alongside username/password credentials.

- Remote URL resolution now considers direction (pull/push) and mode
- Credential dialog, saving, and keychain handling updated to pass and
  respect the mode
- Unique askpass scripts generated per invocation to avoid clashes
2026-08-13 22:27:00 +02:00
Christoph Brandau f621638eb3 feat(system): enable window destroy capability and stabilize shutdown
publish / Build and publish Ubuntu AppImage (release) Successful in 22m40s
publish / Build and publish Windows installer (release) Successful in 31m3s
publish / Build and publish gitty-desktop to AUR (release) Successful in 50m26s
Adds a new window capability to allow destroying the window.
It also changes shutdown handling in telemetry to avoid errors.

- Add core:window:allow-destroy to default capabilities
- Avoid shutdown errors by returning a pending promise in tracedInvoke
- Improve stability during shutdown for in-flight telemetry calls
2026-08-13 22:11:45 +02:00
Christoph 56467f481d Merge pull request 'Open code test' (#26) from openCodeTest into main
publish / Build and publish Ubuntu AppImage (release) Failing after 2m20s
publish / Build and publish Windows installer (release) Successful in 27m20s
publish / Build and publish gitty-desktop to AUR (release) Canceled after 40m8s
Reviewed-on: #26
2026-08-13 18:48:11 +00:00
Christoph Brandau 6434a9f10c refactor(credentials): remove credential expiry support and API
The credential expiry feature has been removed from both the Rust
backend and the frontend. Stored credentials now include only
username and password.

- Remove expiresAt field from StoredCredential
- Simplify API by removing expiry param from credSave
- Drop expiry UI and expiry checks across the app
2026-08-13 20:45:43 +02:00
Christoph Brandau cc0f1c076f refactor(ui): simplify branch status display and adjust layout
The changes simplify how branch status is shown in HistoryPanel.
The remoteName helper and the check icon were removed.
Status is composed inline with the label for local and remote branches.
CSS tweaks wrap content and adjust widths for better responsiveness.

- Remove remoteName helper and Check icon from HistoryPanel
- Inline status text for local/remote branches and avoid icons
- Improve graph layout: wrap refs and constrain widths on hover
2026-08-13 20:06:27 +02:00
Christoph Brandau b6b9964a93 feat(git): add upstream tracking and local-only branch UI
This release adds upstream tracking for branches and a local-only
indicator in the UI.
The Git integration now exposes upstreams and supports publish flows.
This enables publishing and remote-tracking configuration.

- Introduces upstream tracking and local-only branch UI markers
- Updates toolbar to reflect publish-local state and status indicators
- Bumps version to 2026.8.3 and updates changelog
2026-08-13 19:32:26 +02:00
Christoph Brandau f43fe00873 feat(git): rename remote branches atomically via push
Adds a new command to rename remote branches and update tracking refs.
Renaming remote refs is performed atomically using a single push.
The push creates the new remote ref and deletes the old if it succeeds.
The frontend now invokes remote-rename when needed and shows labels.

- Atomic remote rename via push with create/ref and delete
- Frontend supports remote branch renames from the branch panel
- Compare UI now shows labels for remote refs in results
2026-08-13 18:32:16 +02:00
Christoph Brandau ca14fac90c feat(ui): refine history graph visuals and layout
Refines history panel visuals and layout, including min width handling.
Removes the old branch visibility select in favor of a dialog button.
It also adds a graph connector to show primary branches.
Adds branch chips, icons, and hover effects to commits for clarity.

- History panel visuals and min width updated to improve layout.
- Removed branch visibility select; added dialog to customize branches.
- Branch chips and graph connectors enhanced with icons and hover effects.
2026-08-13 18:01:10 +02:00
Christoph Brandau 608b3131d2 feat(history-panel): add branch visibility modes and richer refs
Adds branch visibility modes to the history panel and remote branches.
A data model supports commits and refs including local and remote.
UI tweaks add compact ref chips and a new details panel.

- Implement focus/local/all/custom modes for branch visibility
- Introduce CommitBranchDecoration and CommitRefSummary types
- Wire remote branches and ahead/behind data to UI
2026-08-13 15:02:42 +02:00
Christoph Brandau a823aabbb9 feat(external-tools): force tools to open in new windows where needed
Adds a helper that forces selected tools to launch in a new window
instead of reusing the current one. This is applied to code editors,
diff/merge, and terminal launches, aligning behavior across platforms.
Presets and defaults are updated to pass new-window or equivalent flags,
and tests verify the new behavior for common tools.

- Update code editors to always use a new window when opened
- Normalize launch flags for Windows terminals and diff tools
- Add tests covering new-window behavior for common tools
2026-08-13 14:33:05 +02:00
Christoph Brandau 15d1f2bfd6 feat(external-tools): add cross-platform external tool discovery
Adds a new external tools subsystem to detect and launch
diff and editor tools across Windows, macOS, and Linux.
It exposes data models for tools, commands, and results to the UI
and serializes them for consumption by the app.

- Implement cross-platform discovery of editors and diff tools
- Expose serialized results to the UI for user selection
- Centralize per-OS known tool lists and overrides
2026-08-13 14:08:02 +02:00
Christoph Brandau 3eb554fee7 feat(ui): add command palette and commit selection sync
Introduce a global command palette for quick access to common repository
actions, branches, files, and commits. Wire it into the main shell so it
can open settings, help, and other dialogs while keeping keyboard access
consistent.

Also add commit selection state to the history view so the active commit
is highlighted and brought into view when chosen from either the palette
or the history panel.
2026-08-13 07:53:19 +02:00
Christoph Brandau 1fa57eea6f feat(git): convert Git commands to async for better performance
This update modifies several Git command functions to be asynchronous,
improving the responsiveness of the application. By utilizing async
runtime, operations that involve I/O or long-running tasks can now run
without blocking the main thread, enhancing user experience.

- Converted multiple Git command functions to async
- Introduced a helper function to handle async tasks with error management
- Improved overall performance and responsiveness of Git operations
2026-08-11 09:53:34 +02:00
Christoph Brandau 444a7acadd Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-08-10 17:30:33 +02:00
Christoph Brandau ac7cacd687 feat(telemetry): handle application shutdown process
This update introduces a structured shutdown process for the application. It ensures that background tasks are properly terminated and telemetry is notified when the frontend is shutting down, preventing any ongoing operations from continuing during this state.

- Added event listeners to manage app shutdown events
- Implemented a shutdown handler to clear timers and notify telemetry
- Updated background task checks to respect the shutdown state
2026-08-10 17:29:03 +02:00
Christoph 80dc66366d Update version to 2026.8.2 2026-08-10 14:24:42 +02:00
Christoph Brandau a6c86daf62 feat(gitea): upload release assets to Gitea
publish / Build and publish Windows installer (release) Successful in 28m43s
publish / Build and publish Ubuntu AppImage (release) Successful in 22m26s
publish / Build and publish gitty-desktop to AUR (release) Successful in 45m32s
This update introduces functionality to upload release assets to Gitea. A new helper function has been added to handle the API requests and manage the upload process, ensuring that existing assets are not duplicated. The main function has been modified to call this new upload feature after resolving the version.

- Implemented asset upload to Gitea releases
- Added checks to prevent duplicate uploads
- Enhanced error handling for missing configuration variables
2026-08-10 14:07:08 +02:00
Christoph Brandau f88b6961d1 fix(history): preserve branch colors across parent lanes
Keep branch metadata attached to each parent lane so the history graph
can render consistent colors and markers after merges and branch
transitions. Also soften the styling for behind-path segments to better
match the updated dot coloring.
2026-08-10 10:48:31 +02:00
Christoph eb65c1def7 fix(workflows): improve AUR SSH key handling and clone/push retries
publish / Build and publish Windows installer (release) Successful in 22m29s
publish / Build and publish Ubuntu AppImage (release) Successful in 37m9s
publish / Build and publish gitty-desktop to AUR (release) Failing after 52m11s
This update enhances the workflow for interacting with the AUR by
adding robust error handling for SSH key retrieval and repository
cloning/pushing. It implements retry logic with exponential backoff
for both SSH host key retrieval and git operations, improving
resilience against transient network issues.

- Added retries for SSH key retrieval with exponential backoff
- Implemented retry logic for cloning and pushing to AUR repository
- Ensured script exits gracefully with error messages on failure
2026-08-04 22:23:50 +02:00
Christoph 96e3805568 fix(dialog): improve escape key handling for dialogs
publish / Build and publish Windows installer (release) Successful in 23m4s
publish / Build and publish Ubuntu AppImage (release) Successful in 44m12s
publish / Build and publish gitty-desktop to AUR (release) Failing after 54m38s
The handling of the Escape key has been refined to ensure that it
closes the correct dialog based on the current state. Additionally,
the z-index for the compare dialog backdrop has been updated to
ensure proper layering with other dialogs.

- Enhanced Escape key functionality for better user experience
- Updated z-index for compare dialog backdrop to avoid overlap
2026-08-04 21:17:04 +02:00
Christoph 2d9a10e85c feat(workflows): implement timeout for pacman installation
publish / Build and publish Windows installer (release) Successful in 24m55s
publish / Build and publish Ubuntu AppImage (release) Successful in 33m25s
publish / Build and publish gitty-desktop to AUR (release) Canceled after 16m4s
This update introduces a timeout mechanism for the pacman installation process in the app builder workflow. If the installation does not complete within 30 minutes, it will terminate and log a timeout message, improving the robustness of the build process.

- Adds a timeout for the pacman command to prevent indefinite hangs
- Implements a heartbeat to monitor the installation progress
- Provides clear error messages for timeout and failure scenarios
2026-08-04 20:26:20 +02:00
Christoph 12d1b2657f fix(workflow): improve Arch Linux package installation process
publish / Build and publish Ubuntu AppImage (release) Canceled after 6m6s
publish / Build and publish Windows installer (release) Canceled after 6m4s
publish / Build and publish gitty-desktop to AUR (release) Canceled after 6m2s
The workflow for building and publishing to Arch Linux has been updated to
enhance the package installation process. This includes adding multiple
mirror sources to ensure reliability and implementing retry logic for
package installation attempts.

- Removed dependency on the Windows publish job
- Added backup mirrors for Arch Linux package management
- Implemented retry logic for package installation failures
2026-08-04 20:19:20 +02:00
Christoph b0b4fb69cb Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-08-04 20:11:51 +02:00
Christoph f3a3fcc079 feat(ci): update workflow for Windows and add Ubuntu publishing
The workflow has been modified to focus on building and publishing
Windows installers, with the previous matrix strategy removed.
Additionally, a new job for building and publishing Ubuntu AppImages
has been added, ensuring that the necessary dependencies and steps
are included for a successful build.

- Renamed job to reflect Windows installer publishing
- Added Ubuntu AppImage publishing workflow
- Simplified job structure by removing matrix strategy
2026-08-04 20:11:44 +02:00
Christoph 9d18f3af61 Update version to 2026.8.1 2026-08-04 19:35:08 +02:00
Christoph c4a922894f style(app): update styles for file history dialog
publish / Build and publish gitty-desktop to AUR (release) Failing after 4m40s
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Canceled after 37m41s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 37m26s
2026-08-04 18:41:19 +02:00
Christoph 41f474f1eb feat(dialog): add file history dialog component 2026-08-04 18:41:19 +02:00
Christoph 6dd70ec52a refactor(app): remove file history panel and related logic 2026-08-04 18:41:19 +02:00
Christoph 90070697dd refactor(explorer): update context menu for file history 2026-08-04 18:41:19 +02:00
Christoph 50524c2759 feat(commits): enhance commit listing with pagination and skipping
This update introduces pagination and skipping functionality for the
commit listing feature, allowing users to load commits in pages and
navigate through them more efficiently. The UI has been adjusted to
support loading more commits dynamically, improving the overall user
experience when dealing with large repositories.

- Added pagination support for commit history
- Introduced a loading mechanism for fetching more commits
- Updated UI components to reflect changes in commit loading behavior
2026-08-04 18:04:10 +02:00
Christoph 0cd97db523 This diff correctly updates the help documentation in HelpOverlay.svelte. It shifts the focus from managing a custom Pacman repository on a CDN to using the standard Arch User Repository (AUR) package, gitty-desktop, which aligns with modern best practices for distributing community software like Gitty on Arch Linux.
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 22m25s
publish / Build and publish gitty-desktop to AUR (release) Successful in 45m7s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
The changes are consistent across both German and English sections:
1.  **Summary:** Updated to reflect building from source via AUR.
2.  **Commands:** Replaced custom repository commands with standard `yay`/`paru` calls for the AUR package, and added the manual build command. The package name is standardized to `gitty-desktop`.
3.  **Steps:** Changed the workflow description from managing `/etc/pacman.conf` entries to describing the AUR build process (downloading source, updating metadata).
4.  **Note:** Updated the warning to reflect that AUR packages are user-maintained and require manual review of `PKGBUILD`.

No changes are needed; the provided diff is correct.
2026-07-31 14:19:30 +02:00
Christoph Brandau 8079ac5f64 chore(deps): update lockfile metadata
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Canceled after 4m59s
publish / Build and publish Arch package (release) Canceled after 4m58s
2026-07-28 20:59:42 +02:00
Christoph Brandau df26ecf9fb chore(release): publish 2026.07.22 2026-07-28 20:59:40 +02:00
Christoph Brandau af7b993550 feat(ai-commit): improve split dialog validation and apply flow 2026-07-28 16:11:24 +02:00
Christoph Brandau e07bc96127 fix(app): ignore stale repository open responses 2026-07-28 16:11:21 +02:00
Christoph Brandau f0e87d67d5 feat(ai): add AI-assisted commit splitting flow
Introduce a split-planning path for staged changes that asks supported AI
providers to group files into ordered Conventional Commit messages. The
plan is validated before execution so every staged file is assigned once
and unsafe states are rejected.

A new dialog lets users review and adjust the proposed groups before
creating the commits in sequence, with safeguards to preserve remaining
changes if something fails.
2026-07-28 15:01:32 +02:00
Christoph 3246dfdcfd Update version to 2026.7.21 2026-07-26 23:13:26 +02:00
Christoph Brandau bc3d3e88e0 feat(changelog): Add comprehensive changelog and help documentation
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 22m46s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 24m51s
publish / Build and publish Arch package (release) Successful in 53m27s
A new Changelog file has been introduced, documenting major user-facing changes across recent releases. Additionally, the HelpOverlay component was updated to include detailed sections on advanced Git workflows, including worktree management, line-level staging, and Arch Linux package automation instructions in both German and English.

- Added full changelog documentation
- Enhanced help with worktrees and line-staging guides
- Implemented Arch Linux package build steps
2026-07-26 22:59:07 +02:00
Christoph 1721341e62 Merge pull request 'Worktrees' (#25) from worktrees into main
Reviewed-on: #25
2026-07-26 20:20:24 +00:00
Christoph Brandau eb99165931 feat(ui): add worktree management toggle to branch panel
Introduces a dedicated section and interactive button for managing repository worktrees within the branch list view. This improves visibility into the overall state of local repositories by grouping related controls.

- Adds visual styling and structure for the new worktree group toggle in CSS.
- Replaces the old "Manage worktrees" button with a more descriptive, styled component in BranchPanel.svelte.
2026-07-26 22:16:35 +02:00
Christoph Brandau 24201c1302 feat(diff): enable line-level patch selection and actions
Introduces granular control over diff operations by enabling users to select individual lines within a hunk. This refactors the core logic across multiple components, allowing for precise staging, unstaging, or discarding of specific changes rather than operating on entire hunks. The UI now features dedicated controls and visual feedback for line-level selection.

- Refactored patch parsing to track selected line IDs and calculate range information.
- Updated the diff view CSS and component structure to display interactive line selection bars.
- Extended action dialogs to support confirming operations on selected lines.
2026-07-26 22:10:33 +02:00
Christoph Brandau 38b7f1a536 feat(git): Add comprehensive worktree management capabilities
This update introduces full support for Git worktrees, allowing users to manage multiple isolated working copies within a single repository. This includes new functionality to list, add, remove, move, lock, and repair worktrees, significantly enhancing the repository's capability to handle parallel development streams.

- Added `GitWorktree` structure definition across API contracts and Rust backend
- Implemented full CRUD operations for worktrees in Tauri commands
- Updated UI components (App.svelte, BranchPanel.svelte) to expose worktree management dialog
2026-07-26 21:47:35 +02:00
Christoph afa85b97aa chore(packaging): stabilize build process and resource usage
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 21m57s
publish / Build and publish Arch package (release) Successful in 49m42s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
Updates the PKGBUILD to improve stability and manage memory consumption during the compilation of large Rust dependencies. This involves adding specific environment variables within the build function to constrain compiler resources, alongside refining package options.

- Restrict parallel jobs and set explicit optimization levels for cargo builds
- Disable debug symbols in package options
2026-07-23 18:39:29 +02:00
Christoph 7869efeab5 ci(builder): pass build directory to makepkg command
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 28m52s
publish / Build and publish Arch package (release) Failing after 1h3m16s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
The CI workflow for building applications has been updated to correctly manage the build environment. Previously, makepkg was run without explicitly defining where its output should go, which could lead to unpredictable file placement or conflicts. This change ensures that a dedicated temporary directory is used as the build output location.

- Explicitly create and use a makepkg directory
- Pass BUILDDIR variable to makepkg command
2026-07-23 17:17:33 +02:00
Christoph 2b88e5a189 chore(ci): update build root ownership permissions
publish / Build and publish Arch package (release) Failing after 1m25s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Canceled after 7m5s
The workflow script responsible for application building is updated to adjust file ownership. Previously, ownership was restricted only to the source directory. By changing the scope to the entire build root, we ensure that all generated files and intermediate artifacts are correctly owned by the builder user before running makepkg.
2026-07-23 17:10:08 +02:00
Christoph f7793bc908 refactor(ci): Streamline package building workflow
publish / Build and publish Arch package (release) Failing after 1m23s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Canceled after 6m6s
The CI/CD pipeline has been significantly refactored to improve efficiency and reduce dependency on Docker containers during the build process. The core logic now uses temporary directories and direct execution commands, streamlining both the packaging of tarballs and the generation of the repository database. This change results in a faster and more robust build environment within the runner.

- Removed reliance on docker run for package building
- Simplified the mechanism for updating the pacman repository database
- Added necessary dependencies to base system installation steps
2026-07-23 17:03:57 +02:00
Christoph a1325d85e8 chore(ci): prepare arch container dependencies for building
publish / Build and publish Arch package (release) Failing after 2m17s
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 19m45s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
Added initial setup steps to the GitLite job within the continuous integration workflow. This ensures that the underlying Arch Linux environment is fully updated and contains all necessary system packages (like nodejs, git, and docker) before the main build process begins, improving overall build reliability.
2026-07-23 15:33:45 +02:00
Christoph e7e313b7d7 chore(config): Update git commands and CI runner platform
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 20m7s
publish / Build and publish Arch package (release) Failing after 3s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
Updated local development settings to include comprehensive Git status checks, improving developer workflow visibility. Additionally, the Arch package building job in the CI pipeline now targets archlinux runners for better environment parity during builds.

- Added multiple bash commands for detailed git logging and status retrieval
- Switched app builder workflow runner to archlinux
2026-07-23 13:57:28 +02:00
Christoph 482d7857b5 chore(ci): enhance build diagnostics and update runner platform
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 20m54s
publish / Build and publish Arch package (release) Failing after 1m25s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
This commit updates local configuration settings with additional diagnostic steps to improve visibility into the build environment during execution. Furthermore, the primary application builder workflow has been migrated from running on Arch Linux to Ubuntu 22.04. This change ensures broader compatibility for the CI/CD pipeline while maintaining core publishing functionality.

- Added extensive system and context checks in local settings
- Migrated app builder job runner platform to ubuntu-22.04
2026-07-23 13:22:12 +02:00
Christoph Brandau 8220e36629 feat(ci): automate arch package build and distribution
publish / Build and publish Arch package (release) Failing after 1s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Canceled after 1m59s
The continuous integration pipeline is significantly enhanced to fully automate the process of building, packaging, and publishing Gitty as an Arch Linux repository. A new dedicated workflow job handles version setting, dependency resolution, and package creation using makepkg within a Docker container environment. This ensures that every release is properly packaged and uploaded to the configured MinIO/S3 location for system-wide distribution.

- Added `publish-arch` job to CI pipeline
- Implemented repository management logic in arch_repo.py
- Updated PKGBUILD with necessary desktop dependencies
2026-07-23 00:58:32 +02:00
Christoph Brandau 27c8b40dc6 feat(ui): enhance branch delete confirmation flow
This update introduces a comprehensive and visually refined dialog component for confirming the deletion of branches. The logic now correctly distinguishes between deleting local and remote branches, providing tailored warnings and context to the user. Corresponding global styles were updated to implement the new layout and visual fidelity for this specific dialog type.

- Implements structured display for branch name and location
- Improves distinction between local and remote delete contexts
- Updates backdrop filter CSS properties for better compatibility
2026-07-23 00:33:17 +02:00
Christoph Brandau 6aed0b933e feat(ui): new background for commit cards 2026-07-22 00:06:43 +02:00
Christoph 94083a7106 Merge pull request 'Telemetry' (#24) from telemetry into main
Reviewed-on: #24
2026-07-21 21:51:09 +00:00
Christoph Brandau 647f2b2076 feat(ui): implement global error toast and state management
Introduces a persistent, styled error toast notification system for displaying critical application errors. This required refactoring the local error handling logic in App.svelte to manage complex state objects instead of simple timers, allowing for better control over visibility and interaction (e.g., pausing on hover).

- Adds global CSS styling and animations for the visible error toast component
- Updates error auto-hide mechanism to use structured state management
- Implements interactive features like pause/resume timing on mouse events
2026-07-21 23:45:05 +02:00
Christoph Brandau cae8390b54 feat(telemetry): Implement structured telemetry logging and reporting
Adds comprehensive client-side telemetry capabilities for usage, errors, and performance metrics. This includes integrating OpenTelemetry standards into both the frontend (Svelte) and backend (Tauri/Rust) layers to capture events, spans, and system resource utilization.

The implementation ensures that all collected data is privacy-filtered by design, explicitly excluding sensitive information like repository paths, credentials, source code, or email addresses from being logged.

- Updates README with detailed SigNoz telemetry guide
- Adds process metrics collection (CPU/Memory) in Rust backend
- Exposes `setTelemetryEnabled` state management to the frontend
2026-07-21 23:40:39 +02:00
Christoph Brandau 5c9f67700b Merge branch 'new_featrues' 2026-07-20 23:05:00 +02:00
Christoph 2bd60f0e5b feat(git): enhance error reporting and auth robustness
The commit enhances Git operation reliability by improving how authentication errors are detected and displayed to the user. It standardizes Git output language across different locales and introduces a mechanism to summarize complex raw Git stderr messages, ensuring users receive clear feedback when cloning or interacting with repositories that fail due to credentials.

- Standardize git command output using LC_ALL=C for consistent English messaging.
- Implement error summarization logic to extract the most relevant message from raw Git stderr.
- Update credential dialogs to display summarized and improved authentication failure details.
2026-07-19 16:41:21 +02:00
Christoph Brandau 95c8b01ed1 feat(remote): Enhance remote branch management and stability
Improved handling for deleting remote branches across the application, enhancing both user experience and backend reliability. This includes adding structured logging to all Git remote operations in Rust, refining UI components to handle remote-specific deletion flows, and providing clear status/error feedback in sync settings.

- Standardized styling for action toggles (Stash, Branch, Explorer) using consistent dimensions.
- Implemented detailed console logging for all Git remote operations on the backend.
- Refined dialogs and sync settings to provide explicit status and error messages during remote management.
2026-07-14 00:29:37 +02:00
Christoph Brandau 7800f0fb24 feat(git): expand core git repository management features
This update significantly expands the available Git functionality by adding robust support for initializing repositories, managing remotes, and improving complex workflow operations like merging and reverting commits. New API endpoints are exposed across the backend and frontend to handle remote setup, branch tracking, and conflict resolution workflows.

- Added full remote management capabilities (add, update, remove).
- Implemented advanced merge strategies and commit reversion logic.
- Introduced a dedicated UI component for synchronization settings.
2026-07-13 23:24:36 +02:00
Christoph 35310bec6d Update version to 2026.7.20 2026-07-13 00:27:15 +02:00
55 changed files with 12534 additions and 1923 deletions
+18 -1
View File
@@ -116,7 +116,24 @@
"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)"
"Bash(rm PKGBUILD)",
"Bash(git -C /mnt/data/Development/GitLite stash)",
"Bash(git -C /mnt/data/Development/GitLite stash pop)",
"Bash(command -v docker)",
"Read(//home/christoph/.docker/**)",
"Bash(docker info *)",
"Bash(sudo -n test -f /root/.docker/config.json)",
"Bash(systemctl list-units *)",
"Bash(find / -maxdepth 4 -iname \"act_runner*\")",
"Bash(find / -maxdepth 6 -iname \"config.yaml\" -path \"*runner*\")",
"Read(//home/**)",
"Bash(getent passwd *)",
"Bash(docker context *)",
"Bash(git -C /mnt/data/Development/GitLite log --oneline -1 -- PKGBUILD)",
"Bash(git -C /mnt/data/Development/GitLite show --stat HEAD -- PKGBUILD)",
"Bash(git -C /mnt/data/Development/GitLite log --oneline -10)",
"Bash(git -C /mnt/data/Development/GitLite status)",
"Bash(git *)"
]
}
}
+284 -43
View File
@@ -5,7 +5,8 @@ on:
types: [published]
jobs:
publish-tauri:
publish-windows:
name: Build and publish Windows installer
permissions:
contents: write
environment: production
@@ -30,25 +31,7 @@ jobs:
run:
working-directory: GitLite
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
# - platform: 'macos-latest'
# args: '--target aarch64-apple-darwin'
# - platform: 'macos-latest'
# args: '--target x86_64-apple-darwin'
- platform: "windows-latest"
bundles: "nsis"
updater_platform: "windows-x86_64"
no_strip: ""
- platform: "ubuntu-22.04"
bundles: "appimage"
updater_platform: "linux-x86_64"
no_strip: "1"
runs-on: ${{ matrix.platform }}
runs-on: windows-latest
steps:
# cicd_tool/main.py expects the repo at <workspace>/GitLite, so check out there.
@@ -56,25 +39,11 @@ jobs:
with:
path: GitLite
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install uv
if: matrix.platform == 'ubuntu-22.04'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
- name: install rust toolchain
if: matrix.platform == 'ubuntu-22.04'
uses: dtolnay/rust-toolchain@stable
- name: install frontend dependencies
run: npm ci
@@ -87,19 +56,14 @@ 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
run: |
git config user.name '${{ vars.USERNAME_GIT }}'
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 PKGBUILD
git add package.json package-lock.json src-tauri/tauri.conf.json
git commit -m 'Update version to ${{ github.ref_name }}'
if ($LASTEXITCODE -ne 0) {
@@ -143,15 +107,292 @@ jobs:
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
NO_STRIP: ${{ matrix.no_strip }}
run: npm run tauri -- build --bundles ${{ matrix.bundles }}
NO_STRIP: ""
run: npm run tauri -- build --bundles nsis
# ----------------------
# Upload to MinIO (via mc) and create latest.json
# ----------------------
- name: Upload artifacts to MinIO with cicd_tool
working-directory: GitLite/cicd_tool
env:
PUBLISH_PLATFORM: ${{ matrix.updater_platform }}
PUBLISH_PLATFORM: windows-x86_64
run: |
uv sync
uv run main.py
publish-arch:
name: Build and publish gitty-desktop to AUR
runs-on: archlinux
environment: production
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
defaults:
run:
working-directory: GitLite
steps:
- name: Prepare Arch job container
working-directory: /
run: |
set -euo pipefail
# The runner image may contain only a single geo mirror. Seed several
# currently active HTTPS mirrors and retain the image's list as a
# fallback so one slow CDN cannot fail the complete release.
ARCH_MIRROR_BACKUP="$(mktemp)"
cp /etc/pacman.d/mirrorlist "$ARCH_MIRROR_BACKUP"
printf '%s\n' \
'Server = https://frankfurt.mirror.pkgbuild.com/$repo/os/$arch' \
'Server = https://mirror.osbeck.com/archlinux/$repo/os/$arch' \
'Server = https://mirror.ubrco.de/archlinux/$repo/os/$arch' \
'Server = https://ftp.halifax.rwth-aachen.de/archlinux/$repo/os/$arch' \
'Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch' \
> /etc/pacman.d/mirrorlist
sed -n '/^[[:space:]]*Server[[:space:]]*=/p' "$ARCH_MIRROR_BACKUP" \
>> /etc/pacman.d/mirrorlist
ARCH_PACKAGES=(
base-devel curl git nodejs npm openssh rust
webkit2gtk-4.1 gtk3 hicolor-icon-theme
libappindicator-gtk3 librsvg xdotool
)
for ARCH_INSTALL_ATTEMPT in 1 2 3; do
echo "pacman attempt $ARCH_INSTALL_ATTEMPT of 3"
timeout --signal=TERM 30m \
pacman -Syu --needed --noconfirm --noprogressbar \
"${ARCH_PACKAGES[@]}" \
</dev/null &
ARCH_PACMAN_PID=$!
(
ARCH_WAIT_SECONDS=0
while true; do
sleep 30
((ARCH_WAIT_SECONDS += 30))
echo "pacman is still running (${ARCH_WAIT_SECONDS}s elapsed)"
done
) &
ARCH_HEARTBEAT_PID=$!
ARCH_PACMAN_STATUS=0
wait "$ARCH_PACMAN_PID" || ARCH_PACMAN_STATUS=$?
kill "$ARCH_HEARTBEAT_PID" 2>/dev/null || true
wait "$ARCH_HEARTBEAT_PID" 2>/dev/null || true
if [ "$ARCH_PACMAN_STATUS" -eq 0 ]; then
break
fi
if [ "$ARCH_PACMAN_STATUS" -eq 124 ]; then
echo "pacman attempt timed out after 30 minutes" >&2
fi
if [ "$ARCH_INSTALL_ATTEMPT" -eq 3 ]; then
echo "pacman failed after 3 attempts" >&2
exit 1
fi
sleep "$((ARCH_INSTALL_ATTEMPT * 5))"
done
- uses: actions/checkout@v5
with:
path: GitLite
- name: Set release version
env:
RELEASE_VERSION: ${{ github.ref_name }}
run: |
npm version "$RELEASE_VERSION" --no-git-tag-version --allow-same-version
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
echo "RELEASE_TAG=$RELEASE_VERSION" >> "$GITHUB_ENV"
- name: Generate and build AUR package
run: |
if ! id -u builder >/dev/null 2>&1; then
useradd --create-home builder
fi
AUR_SOURCE_DIR="$(mktemp -d)"
cp PKGBUILD "$AUR_SOURCE_DIR/PKGBUILD"
SOURCE_ARCHIVE="$AUR_SOURCE_DIR/gitty-desktop-$PACKAGE_VERSION.tar.gz"
curl --fail --location --silent --show-error \
--output "$SOURCE_ARCHIVE" \
"https://git.cbsk-tech.de/Christoph/GitLite/archive/$RELEASE_TAG.tar.gz"
CHECKSUM="$(sha256sum "$SOURCE_ARCHIVE" | cut -d ' ' -f 1)"
sed -i \
-e "s/^pkgver=.*/pkgver=$PACKAGE_VERSION/" \
-e "s/^pkgrel=.*/pkgrel=1/" \
-e "s/^_tag=.*/_tag=$RELEASE_TAG/" \
-e "s/^sha256sums=.*/sha256sums=('$CHECKSUM')/" \
"$AUR_SOURCE_DIR/PKGBUILD"
chown -R builder:builder "$AUR_SOURCE_DIR"
runuser -u builder -- \
bash -lc "cd '$AUR_SOURCE_DIR' && makepkg --cleanbuild --noconfirm && makepkg --printsrcinfo > .SRCINFO"
echo "AUR_SOURCE_DIR=$AUR_SOURCE_DIR" >> "$GITHUB_ENV"
- name: Publish PKGBUILD to AUR
env:
AUR_GIT_NAME: ${{ vars.USERNAME_GIT }}
AUR_GIT_EMAIL: ${{ vars.EMAIL_GIT }}
run: |
set -euo pipefail
if [ -z "$AUR_SSH_PRIVATE_KEY" ]; then
echo "AUR_SSH_PRIVATE_KEY is required" >&2
exit 1
fi
install -d -m 0700 "$HOME/.ssh"
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$HOME/.ssh/aur"
chmod 0600 "$HOME/.ssh/aur"
AUR_RETRY_DELAYS=(30 60 120 240)
AUR_KNOWN_HOSTS_TEMP="$(mktemp)"
AUR_HOST_KEY_READY=0
for AUR_ATTEMPT in 1 2 3 4 5; do
echo "AUR host-key attempt $AUR_ATTEMPT of 5"
if timeout 30s ssh-keyscan -T 15 -H aur.archlinux.org > "$AUR_KNOWN_HOSTS_TEMP" \
&& [ -s "$AUR_KNOWN_HOSTS_TEMP" ]; then
AUR_HOST_KEY_READY=1
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ "$AUR_HOST_KEY_READY" -ne 1 ]; then
echo "Could not retrieve the AUR SSH host key after 5 attempts" >&2
exit 1
fi
install -m 0600 "$AUR_KNOWN_HOSTS_TEMP" "$HOME/.ssh/known_hosts"
export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=2"
AUR_CHECKOUT_ROOT="$(mktemp -d)"
AUR_CHECKOUT=""
for AUR_ATTEMPT in 1 2 3 4 5; do
AUR_CLONE_CANDIDATE="$AUR_CHECKOUT_ROOT/attempt-$AUR_ATTEMPT"
echo "AUR clone attempt $AUR_ATTEMPT of 5"
if timeout 3m git -c init.defaultBranch=master clone \
ssh://aur@aur.archlinux.org/gitty-desktop.git "$AUR_CLONE_CANDIDATE"; then
AUR_CHECKOUT="$AUR_CLONE_CANDIDATE"
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
echo "AUR is unavailable; retrying after backoff"
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ -z "$AUR_CHECKOUT" ]; then
echo "Could not clone the AUR repository after 5 attempts" >&2
exit 1
fi
cp "$AUR_SOURCE_DIR/PKGBUILD" "$AUR_SOURCE_DIR/.SRCINFO" "$AUR_CHECKOUT/"
cd "$AUR_CHECKOUT"
git config user.name "${AUR_GIT_NAME:-Gitty Release Bot}"
git config user.email "${AUR_GIT_EMAIL:-aur@localhost}"
git add PKGBUILD .SRCINFO
if git diff --cached --quiet; then
echo "AUR metadata already matches release $PACKAGE_VERSION"
exit 0
fi
git commit -m "Update to $PACKAGE_VERSION"
AUR_PUSHED=0
for AUR_ATTEMPT in 1 2 3 4 5; do
echo "AUR push attempt $AUR_ATTEMPT of 5"
if timeout 3m git push origin HEAD:master; then
AUR_PUSHED=1
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
echo "AUR push failed; retrying after backoff"
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ "$AUR_PUSHED" -ne 1 ]; then
echo "Could not publish to AUR after 5 attempts" >&2
exit 1
fi
publish-ubuntu:
name: Build and publish Ubuntu AppImage
permissions:
contents: write
environment: production
env:
GITEA_API: https://git.cbsk-tech.de/api/v1
OWNER: Christoph
REPO: GitLite
GITEA_TOKEN: ${{ secrets.ACTIONS_TOKEN }}
GITEA_FALLBACK_TOKEN: ${{ github.token }}
MINIO_ENDPOINT: ${{ vars.MINIO_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
ARTIFACT_BASE_URL: ${{ vars.ARTIFACT_BASE_URL }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
defaults:
run:
working-directory: GitLite
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
with:
path: GitLite
- name: Install Ubuntu dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install frontend dependencies
run: npm ci
- name: Update package.json version
run: npm version ${{ github.ref_name }} --no-git-tag-version --allow-same-version
- name: Update tauri.conf.json version
env:
RELEASE_VERSION: ${{ github.ref_name }}
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: Build Tauri App
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
NO_STRIP: "1"
run: npm run tauri -- build --bundles appimage
- name: Upload artifacts to MinIO with cicd_tool
working-directory: GitLite/cicd_tool
env:
PUBLISH_PLATFORM: linux-x86_64
run: |
uv sync
uv run main.py
+166
View File
@@ -0,0 +1,166 @@
# Changelog
All notable user-facing changes to Gitty are documented in this file.
The project uses calendar-style versions in the form `YYYY.M.PATCH`.
## [2026.8.3] - 2026-08-13
### Added
- Configurable external tools for editors, diff viewers, merge tools,
terminals, and file managers, including automatic cross-platform discovery
and presets for VS Code, JetBrains IDEs, Beyond Compare, and other common
applications.
- Repository and file actions for opening content in the configured external
application. Supported tools open in a separate window.
- A choice between Gitty's internal diff/merge views and the configured
external applications.
- Git Notes support for attaching editable notes to commits without rewriting
commit history, including fetch and push synchronization.
- A command palette for quickly opening repository actions, files, and commits.
- Complete branch-to-branch comparisons for local and remote branches. The
comparison dialog shows every changed file and its side-by-side diff.
- Safe remote branch renaming from the branch context menu.
### Changed
- Redesigned the settings window with tool categories, detected applications,
preset dropdowns, and clearer explanations of where each tool is used.
- Redesigned the history graph's branch presentation with compact labels,
hover details, cleaner flag connectors, and branch visibility controls.
- Reduced the minimum width of the commit history panel so the workspace can
be resized more freely.
- Local-only branches are now identified consistently in the toolbar,
repository summary, status bar, and commit graph. Their first push is labeled
Publish and configures the remote tracking branch automatically.
- Git operations now run asynchronously to keep the application responsive
during slower repository commands.
### Fixed
- Closing supported external tools no longer reports their documented
comparison result codes as application errors.
- External tools that otherwise reuse an existing process are explicitly
opened in a new window where supported.
- Remote branch renaming uses an atomic push with lease checks, preventing an
existing destination branch or a newly changed remote branch from being
overwritten.
## [2026.8.2] - 2026-08-10
### Changed
- History graph colors remain stable across parent lanes, making longer and
branching histories easier to follow.
- Release artifacts are published to the matching Gitea release automatically
without creating duplicate assets.
- Application shutdown now completes telemetry cleanup more reliably.
## [2026.8.1] - 2026-08-04
### Added
- Paginated commit history that loads older commits on demand instead of
limiting the visible repository history to the initial page.
- A dedicated file-history dialog opened from the explorer context menu.
- Windows and Ubuntu release publishing plus improved AUR packaging workflows.
### Changed
- File history moved out of the permanent workspace panel into a focused,
larger dialog.
- Dialogs close more consistently with the Escape key.
- Arch Linux installation documentation now uses the `gitty-desktop` AUR
package.
### Fixed
- AUR SSH setup, package installation timeouts, and clone/push retries are more
robust in the release workflow.
## [2026.07.22] - 2026-07-22
### Added
- AI-assisted commit splitting for staged changes:
- analyze the staged diff and propose an ordered set of logical commits;
- generate an editable Conventional Commit message for every group;
- move files between proposed commits before applying the plan;
- create all accepted commits sequentially with **Commit all**.
- Support for commit-splitting suggestions through OpenAI, Anthropic, and
custom OpenAI-compatible endpoints.
### Changed
- The commit-splitting dialog now explains why a plan cannot be applied and
clearly marks AI-generated commit messages as editable.
- Safety checks prevent applying a stale plan after the staged files change
and reject files that contain both staged and unstaged changes.
### Fixed
- **Commit all** now passes a plain state snapshot to the commit workflow
instead of failing silently when cloning a reactive UI proxy.
- Closing the active repository tab no longer allows an in-flight status or
fetch response to reopen and reactivate the closed repository.
## [2026.07.21] - 2026-07-21
### Added
- Full Git worktree management:
- create a worktree from an existing branch;
- create a new branch and worktree together;
- create a detached worktree at a commit;
- open a worktree as a repository tab;
- move, lock, unlock, repair, remove, and prune worktrees.
- A dedicated **Worktrees** entry below **Tags** in the branch panel.
- An **Open in new worktree** action in the local branch context menu.
- Line-level patch selection with Shift-click range selection.
- Stage, unstage, and discard actions for selected lines while retaining the
existing whole-file and whole-hunk workflows.
- Arch Linux package automation that builds from `PKGBUILD`, publishes the
`.pkg.tar.zst` artifact, and updates the Pacman repository database on the
CDN.
- Detailed German and English help for worktrees, line-level staging, Pacman
installation, and release changes.
### Changed
- Branch deletion now uses a clearer confirmation dialog with more explicit
branch and upstream information.
- Dialog backdrops blur the application instead of covering it with a nearly
black background.
- Remote operations and authentication errors provide more actionable
feedback.
- Build diagnostics and resource handling for the Arch package pipeline are
more robust.
### Infrastructure
- Added centralized error notifications.
- Added structured, privacy-conscious telemetry.
- Stabilized the Arch Linux container build and `makepkg` workflow.
## [2026.7.20] - 2026-07-13
### Added
- AI-assisted review of staged changes before committing.
- Optional automatic repository refresh.
- Comprehensive German Git documentation in the built-in help.
- Initial `PKGBUILD` and automated package version synchronization.
### Changed
- Redesigned the repository action toolbar and workspace status presentation.
- Improved repository tabs, diff styling, spacing, and panel structure.
- Improved file-history state handling.
[2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22
[2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21
[2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20
[2026.8.3]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.3
[2026.8.2]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.2
[2026.8.1]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.1
+33 -30
View File
@@ -1,66 +1,69 @@
# 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
pkgname=gitty-desktop
pkgver=2026.7.22
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')
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
makedepends=('rust' 'nodejs' 'npm')
options=('!lto')
options=('!lto' '!debug')
source=()
sha256sums=()
_tag=2026.7.22
source=("gitty-desktop-${pkgver}.tar.gz::${url}/archive/${_tag}.tar.gz")
sha256sums=('bd6c7d9da54917cbea98ce31172934b4657cc2fe2020f49d27ea2acb51348e9c')
# Keeps pkgver in sync with package.json (the release CI bumps that file).
pkgver() {
cd "$startdir"
node -p "require('./package.json').version"
prepare() {
cd "$srcdir/gitlite"
# Keep application metadata aligned even when a release tag contains
# leading zeroes that npm normalizes (for example 2026.7.01 -> 2026.7.1).
npm version "$pkgver" --no-git-tag-version --allow-same-version
RELEASE_VERSION="$pkgver" 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');"
}
build() {
cd "$startdir"
cd "$srcdir/gitlite"
# Keep the large Rust release build within the Arch runner's memory limit.
export CARGO_BUILD_JOBS=1
export CARGO_PROFILE_RELEASE_OPT_LEVEL=2
export CARGO_PROFILE_RELEASE_LTO=false
export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8
export CARGO_PROFILE_RELEASE_DEBUG=0
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"
cd "$srcdir/gitlite"
install -Dm755 "src-tauri/target/release/gitty" "$pkgdir/usr/bin/gitty"
install -Dm755 "src-tauri/target/release/gitty" "$pkgdir/usr/bin/gitty-desktop"
install -Dm644 "src-tauri/icons/32x32.png" \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/gitty.png"
"$pkgdir/usr/share/icons/hicolor/32x32/apps/gitty-desktop.png"
install -Dm644 "src-tauri/icons/128x128.png" \
"$pkgdir/usr/share/icons/hicolor/128x128/apps/gitty.png"
"$pkgdir/usr/share/icons/hicolor/128x128/apps/gitty-desktop.png"
install -Dm644 "src-tauri/icons/128x128@2x.png" \
"$pkgdir/usr/share/icons/hicolor/256x256@2/apps/gitty.png"
"$pkgdir/usr/share/icons/hicolor/256x256@2/apps/gitty-desktop.png"
install -Dm644 "src-tauri/icons/icon.png" \
"$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty.png"
"$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty-desktop.png"
install -d "$pkgdir/usr/share/applications"
cat > "$pkgdir/usr/share/applications/gitty.desktop" <<-EOF
cat > "$pkgdir/usr/share/applications/gitty-desktop.desktop" <<-EOF
[Desktop Entry]
Type=Application
Name=Gitty
Comment=$pkgdesc
Exec=gitty
Icon=gitty
Exec=gitty-desktop
Icon=gitty-desktop
Terminal=false
Categories=Development;RevisionControl;
StartupWMClass=gitty
EOF
install -Dm644 "README.md" "$pkgdir/usr/share/doc/$pkgname/README.md"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
}
+32 -1
View File
@@ -48,4 +48,35 @@ Contributions, feedback and feature requests are always welcome!
## 📄 License
MIT License
MIT License
---
## Arch Linux
Install Gitty from the AUR with an AUR helper:
```bash
yay -S gitty-desktop
```
Or build the AUR package manually:
```bash
git clone https://aur.archlinux.org/gitty-desktop.git
cd gitty-desktop
makepkg -si
```
The AUR recipe downloads the public Gitea release archive and builds Gitty from
source. The release pipeline updates its version, checksum, and `.SRCINFO`.
---
## SigNoz telemetry
After the user opts in, Gitty sends privacy-filtered product events and technical errors as OTLP/HTTP JSON logs. Every Git/Tauri command is also captured as a trace span with its command name, duration and success state, but without command arguments or results. CPU utilization and resident memory usage of the Gitty process are sampled every 30 seconds and exported as OpenTelemetry gauges. The default endpoints are `https://telemetry.cbsk-tech.de/v1/logs`, `https://telemetry.cbsk-tech.de/v1/traces`, and `https://telemetry.cbsk-tech.de/v1/metrics`.
The SigNoz reverse proxy must forward all three paths to the Collector's OTLP/HTTP receiver (normally port `4318`). For development or a separate collector hostname, set `GITTY_OTLP_LOGS_ENDPOINT`, `GITTY_OTLP_TRACES_ENDPOINT`, and `GITTY_OTLP_METRICS_ENDPOINT` to the full signal URLs before starting Gitty.
Telemetry is non-blocking, bounded, disabled until consent is stored, and never includes repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, source code, URLs, or email addresses.
+65
View File
@@ -1,11 +1,15 @@
import argparse
import io
import json
import mimetypes
import os
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, List, NamedTuple, Optional
from urllib.parse import urlparse
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen
from minio import Minio
from minio.error import S3Error
@@ -329,6 +333,65 @@ def _publish_target(
)
def _gitea_request(url: str, token: str, data: Optional[bytes] = None, **headers: str):
request = Request(url, data=data, headers={"Authorization": f"token {token}", **headers})
return urlopen(request, timeout=120)
def _upload_gitea_release_asset(version: str, artifact: Path) -> None:
"""Attach the installer to the Gitea release that triggered this build."""
api_url = os.environ.get("GITEA_API", "").rstrip("/")
owner = os.environ.get("OWNER", "")
repo = os.environ.get("REPO", "")
token = os.environ.get("GITEA_TOKEN") or os.environ.get("GITEA_FALLBACK_TOKEN")
if not all((api_url, owner, repo, token)):
raise ConfigurationError(
"GITEA_API, OWNER, REPO and GITEA_TOKEN are required to publish "
"the release asset."
)
release_url = (
f"{api_url}/repos/{quote(owner, safe='')}/{quote(repo, safe='')}"
f"/releases/tags/{quote(version, safe='')}"
)
with _gitea_request(release_url, token) as response:
release = json.load(response)
existing_names = {
asset.get("name") for asset in release.get("assets", []) if isinstance(asset, dict)
}
if artifact.name in existing_names:
print(f"Release asset {artifact.name} already exists; skipping upload.")
return
boundary = f"----gitty-{uuid.uuid4().hex}"
content_type = mimetypes.guess_type(artifact.name)[0] or "application/octet-stream"
body = b"".join(
(
f"--{boundary}\r\n".encode(),
(
f'Content-Disposition: form-data; name="attachment"; '
f'filename="{artifact.name}"\r\n'
).encode(),
f"Content-Type: {content_type}\r\n\r\n".encode(),
artifact.read_bytes(),
f"\r\n--{boundary}--\r\n".encode(),
)
)
upload_url = (
f"{api_url}/repos/{quote(owner, safe='')}/{quote(repo, safe='')}"
f"/releases/{release['id']}/assets?{urlencode({'name': artifact.name})}"
)
with _gitea_request(
upload_url,
token,
data=body,
**{"Content-Type": f"multipart/form-data; boundary={boundary}"},
):
pass
print(f"Attached {artifact.name} to Gitea release {version}.")
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", "")
@@ -385,6 +448,8 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
artifact_base_url=artifact_base_url,
)
_upload_gitea_release_asset(resolved_version, primary)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
+47 -4
View File
@@ -15,6 +15,9 @@ interface GitStatus {
behind: number;
files: GitFileStatus[];
clean: boolean;
rebase_in_progress: boolean;
cherry_pick_in_progress: boolean;
merge_in_progress: boolean;
}
interface GitFileStatus {
@@ -30,6 +33,24 @@ interface GitBranch {
remote: boolean;
}
interface GitWorktree {
path: string;
head: string | null;
short_head: string | null;
branch: string | null;
bare: boolean;
detached: boolean;
locked: boolean;
lock_reason: string | null;
prunable: boolean;
prune_reason: string | null;
missing: boolean;
is_main: boolean;
is_current: boolean;
clean: boolean;
changed_files: number;
}
interface GitCommit {
hash: string;
short_hash: string;
@@ -56,19 +77,41 @@ interface GitRepositoryFile {
## Commands
The command list below includes the repository-management and synchronization API. The TypeScript wrappers in `src/lib/git.ts` are the authoritative full list.
- `open_repository(path: string): Promise<GitStatus>`
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
- `clone_repository(...): Promise<RepositoryBundle>`
- `get_status(path: string): Promise<GitStatus>`
- `list_branches(path: string): Promise<GitBranch[]>`
- `list_remotes(path: string): Promise<GitRemote[]>`
- `add_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
- `update_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
- `remove_remote(path: string, name: string): Promise<GitRemote[]>`
- `set_branch_upstream(path: string, branch: string, upstream?: string): Promise<GitStatus>`
- `delete_remote_branch(path: string, remote: string, branch: string): Promise<GitStatus>`
- `checkout_branch(path: string, branch: string): Promise<GitStatus>`
- `list_worktrees(path: string): Promise<GitWorktree[]>`
- `add_worktree(path: string, worktreePath: string, ...): Promise<GitWorktree[]>`
- `remove_worktree(path: string, worktreePath: string, force?: boolean): Promise<GitWorktree[]>`
- `move_worktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]>`
- `lock_worktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]>`
- `unlock_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
- `prune_worktrees(path: string): Promise<GitWorktree[]>`
- `repair_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
- `commit(path: string, message: string): Promise<GitStatus>`
- `pull(path: string): Promise<GitStatus>`
- `push(path: string): Promise<GitStatus>`
- `list_commits(path: string, limit?: number): Promise<GitCommit[]>`
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
- `push(path: string, forceWithLease?: boolean, remote?: string): Promise<GitStatus>`
- `list_commits(path: string, limit?: number, skip?: number): Promise<GitCommit[]>`
- `restore_to_commit(path: string, commit: string): Promise<GitStatus>`
- `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path)
- `merge_branch(path: string, branch: string): Promise<GitStatus>`
- `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise<GitStatus>`
- `merge_continue(path: string): Promise<GitStatus>`
- `merge_abort(path: string): Promise<GitStatus>`
- `revert_commit(path: string, commit: string): Promise<GitStatus>`
- `list_repository_files(path: string): Promise<GitRepositoryFile[]>`
- `list_file_history(path: string, file: string, limit?: number): Promise<GitCommit[]>` (the `file` argument can also be a folder path)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitty",
"version": "2026.7.19",
"version": "2026.8.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "2026.7.19",
"version": "2026.8.3",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitty",
"version": "2026.7.19",
"version": "2026.8.3",
"private": true,
"type": "module",
"scripts": {
+253 -123
View File
@@ -574,13 +574,22 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
dependencies = [
"objc2 0.5.2",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
"objc2 0.6.4",
]
[[package]]
@@ -1119,6 +1128,19 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
]
[[package]]
name = "core-graphics"
version = "0.25.0"
@@ -1592,9 +1614,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.0",
"block2",
"block2 0.6.2",
"libc",
"objc2",
"objc2 0.6.4",
]
[[package]]
@@ -2537,8 +2559,11 @@ version = "0.1.0"
dependencies = [
"commit_ai",
"keyring",
"log",
"reqwest 0.12.28",
"serde",
"serde_json",
"sysinfo 0.38.3",
"tauri",
"tauri-build",
"tauri-plugin-aptabase",
@@ -3883,7 +3908,7 @@ dependencies = [
"statrs",
"strum 0.27.2",
"symphonia",
"sysinfo",
"sysinfo 0.36.1",
"thiserror 2.0.18",
"tokenizers 0.21.4",
"tokio",
@@ -4013,10 +4038,10 @@ dependencies = [
"dpi",
"gtk",
"keyboard-types",
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-foundation 0.3.1",
"once_cell",
"png 0.18.1",
"serde",
@@ -4310,6 +4335,22 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "objc-sys"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
[[package]]
name = "objc2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
dependencies = [
"objc-sys",
"objc2-encode",
]
[[package]]
name = "objc2"
version = "0.6.4"
@@ -4322,92 +4363,80 @@ dependencies = [
[[package]]
name = "objc2-app-kit"
version = "0.3.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc"
dependencies = [
"bitflags 2.13.0",
"block2",
"objc2",
"block2 0.6.2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-foundation",
"objc2-foundation 0.3.1",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2-foundation",
"objc2 0.6.4",
"objc2-foundation 0.3.1",
]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d"
dependencies = [
"objc2",
"objc2-foundation",
"objc2 0.6.4",
"objc2-foundation 0.3.1",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
dependencies = [
"bitflags 2.13.0",
"dispatch2",
"objc2",
"objc2 0.6.4",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4"
dependencies = [
"bitflags 2.13.0",
"dispatch2",
"objc2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-image"
version = "0.3.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e"
dependencies = [
"objc2",
"objc2-foundation",
"objc2 0.6.4",
"objc2-foundation 0.3.1",
]
[[package]]
name = "objc2-core-location"
version = "0.3.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
checksum = "ac0f75792558aa9d618443bbb5db7426a7a0b6fddf96903f86ef9ad02e135740"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2 0.6.4",
"objc2-foundation 0.3.1",
]
[[package]]
@@ -4427,22 +4456,34 @@ dependencies = [
[[package]]
name = "objc2-foundation"
version = "0.3.2"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
"bitflags 2.13.0",
"block2",
"block2 0.5.1",
"libc",
"objc2",
"objc2 0.5.2",
]
[[package]]
name = "objc2-foundation"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
dependencies = [
"bitflags 2.13.0",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a"
dependencies = [
"libc",
"objc2-core-foundation",
@@ -4450,82 +4491,106 @@ dependencies = [
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2 0.6.4",
"objc2-core-foundation",
]
[[package]]
name = "objc2-metal"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
"bitflags 2.13.0",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
checksum = "26bb88504b5a050dbba515d2414607bf5e57dd56b107bc5f0351197a3e7bdc5d"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-foundation",
"objc2-foundation 0.3.1",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
"bitflags 2.13.0",
"objc2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5"
dependencies = [
"bitflags 2.13.0",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-foundation",
"objc2-foundation 0.3.1",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed"
dependencies = [
"bitflags 2.13.0",
"block2",
"objc2",
"block2 0.6.2",
"objc2 0.6.4",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-location",
"objc2-core-text",
"objc2-foundation",
"objc2-quartz-core",
"objc2-foundation 0.3.1",
"objc2-quartz-core 0.3.1",
"objc2-user-notifications",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
checksum = "2a3f5ec77a81d9e0c5a0b32159b0cb143d7086165e79708351e02bf37dfc65cd"
dependencies = [
"objc2",
"objc2-foundation",
"objc2 0.6.4",
"objc2-foundation 0.3.1",
]
[[package]]
name = "objc2-web-kit"
version = "0.3.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad"
dependencies = [
"bitflags 2.13.0",
"block2",
"objc2",
"block2 0.6.2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-foundation 0.3.1",
]
[[package]]
@@ -4663,8 +4728,8 @@ dependencies = [
"android_system_properties",
"log",
"nix 0.31.3",
"objc2",
"objc2-foundation",
"objc2 0.6.4",
"objc2-foundation 0.3.1",
"objc2-ui-kit",
"serde",
"windows-sys 0.61.2",
@@ -4676,8 +4741,8 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2 0.6.4",
"objc2-foundation 0.3.1",
"objc2-osa-kit",
"serde",
"serde_json",
@@ -5539,17 +5604,17 @@ version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"block2 0.6.2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-foundation 0.3.1",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
@@ -6357,24 +6422,24 @@ dependencies = [
[[package]]
name = "softbuffer"
version = "0.4.8"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics 0.24.0",
"foreign-types 0.5.0",
"js-sys",
"ndk",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"objc2-quartz-core",
"log",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-quartz-core 0.2.2",
"raw-window-handle",
"redox_syscall",
"tracing",
"wasm-bindgen",
"web-sys",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -6771,7 +6836,21 @@ dependencies = [
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows",
"windows 0.61.3",
]
[[package]]
name = "sysinfo"
version = "0.38.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d03c61d2a49c649a15c407338afe7accafde9dac869995dccb73e5f7ef7d9034"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows 0.62.2",
]
[[package]]
@@ -6815,9 +6894,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"block2",
"block2 0.6.2",
"core-foundation 0.10.1",
"core-graphics",
"core-graphics 0.25.0",
"crossbeam-channel",
"dbus",
"dispatch2",
@@ -6831,9 +6910,9 @@ dependencies = [
"log",
"ndk",
"ndk-sys",
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-foundation",
"objc2-foundation 0.3.1",
"objc2-ui-kit",
"once_cell",
"parking_lot",
@@ -6842,7 +6921,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -6898,9 +6977,9 @@ dependencies = [
"log",
"mime",
"muda",
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-foundation",
"objc2-foundation 0.3.1",
"objc2-ui-kit",
"objc2-web-kit",
"percent-encoding",
@@ -6924,7 +7003,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -7053,7 +7132,7 @@ dependencies = [
"dunce",
"glob",
"log",
"objc2-foundation",
"objc2-foundation 0.3.1",
"percent-encoding",
"schemars 0.8.22",
"serde",
@@ -7126,7 +7205,7 @@ dependencies = [
"gtk",
"http",
"jni 0.21.1",
"objc2",
"objc2 0.6.4",
"objc2-ui-kit",
"objc2-web-kit",
"raw-window-handle",
@@ -7137,7 +7216,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -7150,7 +7229,7 @@ dependencies = [
"http",
"jni 0.21.1",
"log",
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"once_cell",
"percent-encoding",
@@ -7162,7 +7241,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@@ -7810,11 +7889,11 @@ dependencies = [
"dirs",
"libappindicator",
"muda",
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"objc2-foundation 0.3.1",
"once_cell",
"png 0.18.1",
"serde",
@@ -8388,7 +8467,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
@@ -8412,7 +8491,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -8475,10 +8554,10 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
dependencies = [
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-foundation 0.3.1",
"raw-window-handle",
"windows-sys 0.59.0",
"windows-version",
@@ -8490,11 +8569,23 @@ version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-collections 0.2.0",
"windows-core 0.61.2",
"windows-future",
"windows-future 0.2.1",
"windows-link 0.1.3",
"windows-numerics",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
]
[[package]]
@@ -8506,6 +8597,15 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.61.2"
@@ -8540,7 +8640,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading",
"windows-threading 0.1.0",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
"windows-threading 0.2.1",
]
[[package]]
@@ -8587,6 +8698,16 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
@@ -8760,6 +8881,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-version"
version = "0.1.7"
@@ -9002,7 +9132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
dependencies = [
"base64 0.22.1",
"block2",
"block2 0.6.2",
"cookie",
"crossbeam-channel",
"dirs",
@@ -9016,10 +9146,10 @@ dependencies = [
"jni 0.21.1",
"libc",
"ndk",
"objc2",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-foundation 0.3.1",
"objc2-ui-kit",
"objc2-web-kit",
"once_cell",
@@ -9033,7 +9163,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
+3
View File
@@ -21,6 +21,9 @@ tauri-plugin-aptabase = "1.0"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
commit_ai = { path = "crates/commit_ai" }
tokio = "1.52.3"
log = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
[build-dependencies]
tauri-build = { version = "2", features = [] }
+1
View File
@@ -9,6 +9,7 @@
"core:window:allow-toggle-maximize",
"core:window:allow-unminimize",
"core:window:allow-close",
"core:window:allow-destroy",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"dialog:allow-open",
+127
View File
@@ -215,6 +215,89 @@ pub async fn review_custom(
openai_compatible_review_request(url, api_key, model, diff).await
}
async fn openai_compatible_split_request(
url: String,
bearer: Option<&str>,
model: &str,
diff: &str,
) -> Result<String, String> {
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.";
let user =
format!("Analyze these staged changes and propose an ordered commit plan:\n\n{diff}");
let body = OpenAiRequest {
model: model.to_string(),
messages: vec![
OpenAiMessage {
role: "system",
content: system.to_string(),
},
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 commit plan.".to_string())
}
pub async fn split_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_split_request(
"https://api.openai.com/v1/chat/completions".to_string(),
Some(api_key),
model,
diff,
)
.await
}
pub async fn split_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());
}
openai_compatible_split_request(
format!("{}/chat/completions", base_url.trim_end_matches('/')),
api_key,
model,
diff,
)
.await
}
#[derive(Serialize)]
struct AnthropicMessage {
role: &'static str,
@@ -338,3 +421,47 @@ pub async fn review_anthropic(api_key: &str, model: &str, diff: &str) -> Result<
.filter(|text| !text.is_empty())
.ok_or_else(|| "The model did not return a review.".to_string())
}
pub async fn split_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 = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.".to_string();
let body = AnthropicRequest {
model: model.to_string(),
max_tokens: 2400,
system,
messages: vec![AnthropicMessage {
role: "user",
content: format!(
"Analyze these staged changes and propose an ordered commit plan:\n\n{diff}"
),
}],
};
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 commit plan.".to_string())
}
+1 -1
View File
@@ -2,7 +2,7 @@ mod cloud;
pub use cloud::{
generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom,
review_openai,
review_openai, split_anthropic, split_custom, split_openai,
};
use std::{
File diff suppressed because it is too large Load Diff
+1974 -163
View File
File diff suppressed because it is too large Load Diff
+91 -16
View File
@@ -1,27 +1,68 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod badge;
mod external_tools;
mod git;
mod telemetry;
use badge::set_sync_badge;
use external_tools::{
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
};
use git::{
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
commit_ai_local_models, commit_ai_review, commit_ai_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,
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes, get_commit_note,
get_file_blame, get_file_patch, get_remote_url, get_status, init_repository,
last_commit_message, list_branches, list_commits, list_file_history,
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
rename_remote_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
update_remote,
};
use tauri::Manager;
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
struct ConsoleLogger;
impl log::Log for ConsoleLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= log::Level::Info
}
fn log(&self, record: &log::Record<'_>) {
if self.enabled(record.metadata()) {
eprintln!(
"[{}] [{}] {}",
record.level(),
record.target(),
record.args()
);
}
}
fn flush(&self) {}
}
static CONSOLE_LOGGER: ConsoleLogger = ConsoleLogger;
fn init_console_logging() {
if log::set_logger(&CONSOLE_LOGGER).is_ok() {
log::set_max_level(log::LevelFilter::Info);
log::info!(target: "gitty", "Rust console logging initialized");
}
}
#[tauri::command]
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
@@ -45,6 +86,8 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
#[tokio::main]
async fn main() {
init_console_logging();
telemetry::init();
if let Some(result) = run_sequence_editor_if_requested() {
if let Err(error) = result {
eprintln!("{error}");
@@ -82,16 +125,36 @@ async fn main() {
builder
.invoke_handler(tauri::generate_handler![
open_repository,
init_repository,
clone_repository,
open_repo_in_explorer,
open_repository_file,
detect_external_tools,
launch_external_tool,
launch_external_diff,
launch_external_merge,
get_status,
list_branches,
list_remotes,
add_remote,
update_remote,
remove_remote,
set_branch_upstream,
delete_remote_branch,
list_stashes,
checkout_branch,
create_branch,
rename_branch,
rename_remote_branch,
delete_branch,
list_worktrees,
add_worktree,
remove_worktree,
move_worktree,
lock_worktree,
unlock_worktree,
prune_worktrees,
repair_worktree,
list_tags,
create_tag,
delete_tag,
@@ -117,13 +180,22 @@ async fn main() {
commit_ai_local_models,
commit_ai_generate,
commit_ai_review,
commit_ai_split,
pull,
push,
fetch,
list_commits,
get_commit_note,
set_commit_note,
delete_commit_note,
fetch_commit_notes,
push_commit_notes,
restore_to_commit,
restore_file_from_commit,
merge_branch,
merge_continue,
merge_abort,
revert_commit,
rebase_branch,
rebase_continue,
rebase_abort,
@@ -150,7 +222,10 @@ async fn main() {
cred_save,
cred_delete,
set_sync_badge,
close_splashscreen
close_splashscreen,
set_telemetry_enabled,
emit_frontend_log,
emit_frontend_span
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+363
View File
@@ -0,0 +1,363 @@
use std::{
sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
mpsc::{SyncSender, sync_channel},
},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use serde::Deserialize;
use serde_json::json;
const DEFAULT_OTLP_LOGS_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/logs";
const DEFAULT_OTLP_TRACES_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/traces";
const DEFAULT_OTLP_METRICS_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/metrics";
const METRICS_INTERVAL: Duration = Duration::from_secs(30);
const MAX_BODY_BYTES: usize = 2_048;
static ENABLED: AtomicBool = AtomicBool::new(false);
static SENDER: OnceLock<SyncSender<TelemetrySignal>> = OnceLock::new();
#[derive(Debug)]
enum TelemetrySignal {
Log(TelemetryRecord),
Span(FrontendSpan),
Metrics(ProcessMetrics),
}
#[derive(Debug)]
struct ProcessMetrics {
cpu_utilization: f64,
memory_bytes: u64,
}
#[derive(Debug)]
struct TelemetryRecord {
severity: &'static str,
severity_number: u8,
body: String,
event_name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrontendLog {
level: String,
message: String,
event_name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrontendSpan {
name: String,
trace_id: String,
span_id: String,
started_at_ms: u64,
duration_ms: f64,
success: bool,
}
pub fn init() {
let endpoint = std::env::var("GITTY_OTLP_LOGS_ENDPOINT")
.unwrap_or_else(|_| DEFAULT_OTLP_LOGS_ENDPOINT.to_string());
let traces_endpoint = std::env::var("GITTY_OTLP_TRACES_ENDPOINT")
.unwrap_or_else(|_| DEFAULT_OTLP_TRACES_ENDPOINT.to_string());
let metrics_endpoint = std::env::var("GITTY_OTLP_METRICS_ENDPOINT")
.unwrap_or_else(|_| DEFAULT_OTLP_METRICS_ENDPOINT.to_string());
let (sender, receiver) = sync_channel::<TelemetrySignal>(256);
if SENDER.set(sender.clone()).is_err() {
return;
}
start_process_metrics(sender);
std::thread::Builder::new()
.name("gitty-telemetry".into())
.spawn(move || {
let client = match reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(5))
.build()
{
Ok(client) => client,
Err(error) => {
eprintln!("[WARN] [gitty::telemetry] could not create OTLP client: {error}");
return;
}
};
let mut export_warning_shown = false;
while let Ok(signal) = receiver.recv() {
let (target_endpoint, payload) = match signal {
TelemetrySignal::Log(record) => (&endpoint, log_payload(record)),
TelemetrySignal::Span(span) => (&traces_endpoint, span_payload(span)),
TelemetrySignal::Metrics(metrics) => {
(&metrics_endpoint, metrics_payload(metrics))
}
};
export(
&client,
target_endpoint,
&payload,
&mut export_warning_shown,
);
}
})
.expect("failed to start telemetry worker");
}
fn start_process_metrics(sender: SyncSender<TelemetrySignal>) {
std::thread::Builder::new()
.name("gitty-process-metrics".into())
.spawn(move || {
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
let Ok(pid) = sysinfo::get_current_pid() else {
return;
};
let refresh_kind = ProcessRefreshKind::nothing().with_cpu().with_memory();
let cpu_count = std::thread::available_parallelism()
.map(|count| count.get())
.unwrap_or(1) as f64;
let mut system = System::new();
let mut primed = false;
loop {
std::thread::sleep(METRICS_INTERVAL);
if !ENABLED.load(Ordering::Relaxed) {
primed = false;
continue;
}
system.refresh_processes_specifics(
ProcessesToUpdate::Some(&[pid]),
true,
refresh_kind,
);
let Some(process) = system.process(pid) else {
continue;
};
if !primed {
primed = true;
continue;
}
let metrics = ProcessMetrics {
cpu_utilization: (f64::from(process.cpu_usage()) / (100.0 * cpu_count))
.clamp(0.0, 1.0),
memory_bytes: process.memory(),
};
let _ = sender.try_send(TelemetrySignal::Metrics(metrics));
}
})
.expect("failed to start process metrics worker");
}
fn resource_attributes() -> serde_json::Value {
json!([
{ "key": "service.name", "value": { "stringValue": "gitty-desktop" } },
{ "key": "service.version", "value": { "stringValue": env!("CARGO_PKG_VERSION") } },
{ "key": "deployment.environment.name", "value": { "stringValue": if cfg!(debug_assertions) { "development" } else { "production" } } },
{ "key": "os.type", "value": { "stringValue": std::env::consts::OS } },
{ "key": "host.arch", "value": { "stringValue": std::env::consts::ARCH } }
])
}
fn log_payload(record: TelemetryRecord) -> serde_json::Value {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.to_string();
let mut attributes = vec![json!({
"key": "telemetry.sdk.language",
"value": { "stringValue": "rust" }
})];
if let Some(event_name) = record.event_name {
attributes.push(json!({
"key": "event.name",
"value": { "stringValue": event_name }
}));
}
let payload = json!({
"resourceLogs": [{
"resource": { "attributes": resource_attributes() },
"scopeLogs": [{
"scope": { "name": "gitty.telemetry" },
"logRecords": [{
"timeUnixNano": timestamp,
"observedTimeUnixNano": timestamp,
"severityNumber": record.severity_number,
"severityText": record.severity,
"body": { "stringValue": record.body },
"attributes": attributes
}]
}]
}]
});
payload
}
fn span_payload(mut span: FrontendSpan) -> serde_json::Value {
if span.name.len() > 128 {
span.name.truncate(128);
}
let start = u128::from(span.started_at_ms) * 1_000_000;
let duration = (span.duration_ms.max(0.0) * 1_000_000.0) as u128;
let attributes = vec![
json!({ "key": "rpc.system", "value": { "stringValue": "tauri" } }),
json!({ "key": "rpc.method", "value": { "stringValue": span.name } }),
];
json!({
"resourceSpans": [{
"resource": { "attributes": resource_attributes() },
"scopeSpans": [{
"scope": { "name": "gitty.tauri", "version": env!("CARGO_PKG_VERSION") },
"spans": [{
"traceId": span.trace_id,
"spanId": span.span_id,
"name": format!("tauri.{}", span.name),
"kind": 1,
"startTimeUnixNano": start.to_string(),
"endTimeUnixNano": (start + duration).to_string(),
"attributes": attributes,
"status": {
"code": if span.success { 1 } else { 2 },
"message": if span.success { "" } else { "Tauri command failed" }
}
}]
}]
}]
})
}
fn metrics_payload(metrics: ProcessMetrics) -> serde_json::Value {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.to_string();
json!({
"resourceMetrics": [{
"resource": { "attributes": resource_attributes() },
"scopeMetrics": [{
"scope": { "name": "gitty.process", "version": env!("CARGO_PKG_VERSION") },
"metrics": [
{
"name": "process.cpu.utilization",
"description": "Normalized CPU utilization of the Gitty process",
"unit": "1",
"gauge": { "dataPoints": [{
"timeUnixNano": timestamp,
"asDouble": metrics.cpu_utilization,
"attributes": []
}] }
},
{
"name": "process.memory.usage",
"description": "Resident memory used by the Gitty process",
"unit": "By",
"gauge": { "dataPoints": [{
"timeUnixNano": timestamp,
"asInt": metrics.memory_bytes.to_string(),
"attributes": []
}] }
}
]
}]
}]
})
}
fn export(
client: &reqwest::blocking::Client,
endpoint: &str,
payload: &serde_json::Value,
export_warning_shown: &mut bool,
) {
match client.post(endpoint).json(payload).send() {
Ok(response) if response.status().is_success() => {
if response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.starts_with("text/html"))
&& !*export_warning_shown
{
eprintln!(
"[WARN] [gitty::telemetry] OTLP endpoint returned HTML; configure the collector endpoint with GITTY_OTLP_LOGS_ENDPOINT"
);
*export_warning_shown = true;
}
}
Ok(response) if !*export_warning_shown => {
eprintln!(
"[WARN] [gitty::telemetry] OTLP export failed with status {}",
response.status()
);
*export_warning_shown = true;
}
Err(error) if !*export_warning_shown => {
eprintln!("[WARN] [gitty::telemetry] OTLP export failed: {error}");
*export_warning_shown = true;
}
_ => {}
}
}
fn emit(record: TelemetryRecord) {
if !ENABLED.load(Ordering::Relaxed) {
return;
}
if let Some(sender) = SENDER.get() {
let _ = sender.try_send(TelemetrySignal::Log(record));
}
}
#[tauri::command]
pub fn set_telemetry_enabled(enabled: bool) {
ENABLED.store(enabled, Ordering::Relaxed);
if enabled {
emit(TelemetryRecord {
severity: "INFO",
severity_number: 9,
body: "Telemetry enabled".into(),
event_name: Some("telemetry.enabled".into()),
});
}
}
#[tauri::command]
pub fn emit_frontend_log(log: FrontendLog) {
let (severity, severity_number) = match log.level.as_str() {
"error" => ("ERROR", 17),
"warn" => ("WARN", 13),
_ => ("INFO", 9),
};
let mut body = log.message;
if body.len() > MAX_BODY_BYTES {
body.truncate(MAX_BODY_BYTES);
}
emit(TelemetryRecord {
severity,
severity_number,
body,
event_name: log.event_name,
});
}
#[tauri::command]
pub fn emit_frontend_span(span: FrontendSpan) {
if !ENABLED.load(Ordering::Relaxed) {
return;
}
if span.trace_id.len() != 32
|| span.span_id.len() != 16
|| !span.trace_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|| !span.span_id.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return;
}
if let Some(sender) = SENDER.get() {
let _ = sender.try_send(TelemetrySignal::Span(span));
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "2026.7.19",
"version": "2026.8.3",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run dev",
+1692 -502
View File
File diff suppressed because it is too large Load Diff
+1576 -420
View File
File diff suppressed because it is too large Load Diff
+57 -12
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import {
ChevronDown,
Code2,
CloudDownload,
CloudOff,
Download,
FolderOpen,
GitCompare,
@@ -11,6 +13,8 @@
RefreshCw,
Search,
Upload,
Settings2,
Terminal,
} from "@lucide/svelte";
export let hasRepository: boolean = false;
@@ -18,7 +22,11 @@
export let operation: string = "";
export let ahead: number = 0;
export let behind: number = 0;
export let localOnly: boolean = false;
export let language: "en" | "de" = "en";
export let editorName: string = "Editor";
export let terminalName: string = "Terminal";
export let fileManagerName: string = "Explorer";
export let onFetch: () => void = () => {};
export let onPull: () => void = () => {};
export let onPush: () => void = () => {};
@@ -28,11 +36,23 @@
export let onInteractiveRebase: () => void = () => {};
export let onReflog: () => void = () => {};
export let onOpenInExplorer: () => void = () => {};
export let onOpenInEditor: () => void = () => {};
export let onOpenTerminal: () => void = () => {};
export let onFetchPrune: () => void = () => {};
export let onForcePush: () => void = () => {};
export let onSyncOptions: () => void = () => {};
let historyOpen = false;
let syncOpen = false;
let toolbarElement: HTMLDivElement;
$: isGerman = language === "de";
$: pushLabel = localOnly ? (isGerman ? "Veröffentlichen" : "Publish") : "Push";
$: pushTitle = localOnly
? (isGerman
? "Dieser Branch existiert nur lokal. Veröffentlichen erstellt den Remote-Branch und richtet das Tracking ein."
: "This branch exists only locally. Publish creates the remote branch and configures tracking.")
: "Push";
function runHistoryAction(action: () => void) {
historyOpen = false;
@@ -40,9 +60,7 @@
}
function handleWindowClick(event: MouseEvent) {
if (historyOpen && toolbarElement && !toolbarElement.contains(event.target as Node)) {
historyOpen = false;
}
if (toolbarElement && !toolbarElement.contains(event.target as Node)) { historyOpen = false; syncOpen = false; }
}
function handleWindowKeydown(event: KeyboardEvent) {
@@ -93,10 +111,13 @@
<button
class="repo-action sync-primary"
class:publish-local={localOnly}
onclick={onPush}
disabled={!hasRepository || isBusy}
title="Push"
aria-label={ahead > 0
title={pushTitle}
aria-label={localOnly
? pushTitle
: ahead > 0
? `Push, ${ahead} ${isGerman ? (ahead === 1 ? "lokaler Commit voraus" : "lokale Commits voraus") : (ahead === 1 ? "commit ahead" : "commits ahead")}`
: "Push"}
>
@@ -105,9 +126,25 @@
{: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}
<span class="repo-action-label">{pushLabel}</span>
{#if localOnly}
<span class="repo-action-local-marker"><CloudOff size={9} aria-hidden="true" />{isGerman ? "NUR LOKAL" : "LOCAL"}</span>
{:else if ahead > 0}
<span class="repo-action-count ahead">{ahead}</span>
{/if}
</button>
<div class="repo-history-wrap">
<button class="repo-action" type="button" onclick={() => { syncOpen = !syncOpen; historyOpen = false; }} disabled={!hasRepository || isBusy} aria-label={isGerman ? "Sync-Optionen" : "Sync options"} aria-haspopup="menu">
<ChevronDown size={14} aria-hidden="true" />
</button>
{#if syncOpen}
<div class="repo-history-menu" role="menu">
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onFetchPrune(); }}><CloudDownload size={15} /><span><strong>Fetch + Prune</strong><small>{isGerman ? "Veraltete Remote-Branches entfernen" : "Remove stale remote branches"}</small></span></button>
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onForcePush(); }}><Upload size={15} /><span><strong>Force with lease</strong><small>{isGerman ? "Sicheres Pushen nach Rebase" : "Safe push after rebase"}</small></span></button>
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onSyncOptions(); }}><Settings2 size={15} /><span><strong>{isGerman ? "Remotes & Strategien" : "Remotes & strategies"}</strong><small>{isGerman ? "Upstream, Pull und Remote verwalten" : "Manage upstream, pull and remotes"}</small></span></button>
</div>
{/if}
</div>
</div>
</div>
@@ -129,8 +166,8 @@
class="repo-action"
onclick={onCompare}
disabled={!hasRepository || isBusy}
title={isGerman ? "Commits vergleichen" : "Compare commits"}
aria-label={isGerman ? "Commits vergleichen" : "Compare commits"}
title={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
aria-label={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
>
<GitCompare size={15} aria-hidden="true" />
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
@@ -177,15 +214,23 @@
<div class="repo-toolbar-divider utility" aria-hidden="true"></div>
<div class="repo-action-group repo-utility-actions">
<button class="repo-action" onclick={onOpenInEditor} disabled={!hasRepository || isBusy} title={isGerman ? `Repository in ${editorName} öffnen` : `Open repository in ${editorName}`}>
<Code2 size={15} aria-hidden="true" />
<span class="repo-action-label utility-label">{editorName}</span>
</button>
<button class="repo-action" onclick={onOpenTerminal} disabled={!hasRepository || isBusy} title={isGerman ? `${terminalName} im Repository öffnen` : `Open ${terminalName} in repository`}>
<Terminal size={15} aria-hidden="true" />
<span class="repo-action-label utility-label">{terminalName}</span>
</button>
<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"}
title={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
aria-label={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
>
<FolderOpen size={15} aria-hidden="true" />
<span class="repo-action-label utility-label">Explorer</span>
<span class="repo-action-label utility-label">{fileManagerName}</span>
</button>
<button
+3
View File
@@ -1,8 +1,11 @@
import { invoke } from "@tauri-apps/api/core";
import { telemetryLog } from "./telemetry";
export type AnalyticsEventProperties = Record<string, string | number>;
export function trackAnalyticsEvent(name: string, props?: AnalyticsEventProperties) {
const details = props && Object.keys(props).length > 0 ? ` ${JSON.stringify(props)}` : "";
telemetryLog("info", `${name}${details}`, `product.${name}`);
void invoke("plugin:aptabase|track_event", {
name,
props: props && Object.keys(props).length > 0 ? props : undefined,
@@ -0,0 +1,103 @@
<script lang="ts">
import { GitCommitHorizontal, LoaderCircle, Sparkles, X } from "@lucide/svelte";
import type { AiCommitPlan } from "../types";
interface Props {
plan: AiCommitPlan;
isApplying: boolean;
onApply: (plan: AiCommitPlan) => void;
onClose: () => void;
}
let { plan, isApplying = false, onApply, onClose }: Props = $props();
let draft = $state<AiCommitPlan>({ summary: "", groups: [] });
$effect.pre(() => {
if (draft.groups.length === 0) draft = structuredClone(plan);
});
let valid = $derived(draft.groups.length > 1 && draft.groups.every((group) => group.message.trim() && group.files.length));
let validationIssue = $derived(
draft.groups.findIndex((group) => group.files.length === 0) >= 0
? "Every commit needs at least one file."
: draft.groups.findIndex((group) => !group.message.trim()) >= 0
? "Every commit needs a message."
: "",
);
function setMessage(index: number, message: string) {
draft.groups[index].message = message;
}
function moveFile(file: string, from: number, to: number) {
if (from === to) return;
draft.groups[from].files = draft.groups[from].files.filter((path) => path !== file);
draft.groups[to].files = [...draft.groups[to].files, file];
}
function applyDraft() {
// `draft` is a deeply reactive Svelte proxy. `structuredClone(draft)`
// throws a DataCloneError before the callback runs, which made the button
// appear to do nothing. A state snapshot is a plain, cloneable object.
onApply($state.snapshot(draft));
}
</script>
<svelte:window onkeydown={(event) => { if (event.key === "Escape" && !isApplying) onClose(); }} />
<div class="dialog-backdrop" role="presentation">
<div class="dialog split-dialog" role="dialog" aria-modal="true" aria-label="AI commit split">
<header class="dialog-header">
<div><span class="eyebrow">Staged changes</span><h2>Split into logical commits</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isApplying} aria-label="Close"><X size={18} /></button>
</header>
<div class="split-intro"><Sparkles size={18} /><p>{draft.summary}</p></div>
<div class="split-groups">
{#each draft.groups as group, groupIndex}
<article class:empty={group.files.length === 0}>
<header><GitCommitHorizontal size={17} /><strong>Commit {groupIndex + 1}</strong><span>{group.files.length} files</span></header>
<label>
<span>Commit message <em>AI-generated · editable</em></span>
<input value={group.message} oninput={(event) => setMessage(groupIndex, event.currentTarget.value)} disabled={isApplying} />
</label>
{#if group.reason}<p>{group.reason}</p>{/if}
<div class="split-files">
{#each group.files as file}
<div><code>{file}</code>
<select value={groupIndex} onchange={(event) => moveFile(file, groupIndex, Number(event.currentTarget.value))} disabled={isApplying}>
{#each draft.groups as _, target}<option value={target}>Commit {target + 1}</option>{/each}
</select>
</div>
{/each}
</div>
</article>
{/each}
</div>
<footer class="dialog-footer">
<p class:invalid={!!validationIssue}>{validationIssue || "Messages are generated automatically. All commits are created in this order."}</p>
<div><button class="btn-secondary" type="button" onclick={onClose} disabled={isApplying}>Cancel</button>
<button class="btn-primary" type="button" onclick={applyDraft} disabled={!valid || isApplying}>
{#if isApplying}<LoaderCircle class="spin" size={14} />{/if}Commit all ({draft.groups.length})
</button></div>
</footer>
</div>
</div>
<style>
.split-dialog{width:min(820px,calc(100vw - 32px));max-height:min(820px,calc(100vh - 32px));display:flex;flex-direction:column}
.split-intro{display:flex;gap:10px;align-items:flex-start;padding:14px 18px;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
.split-intro p{margin:0;line-height:1.5}
.split-groups{display:grid;gap:10px;padding:14px 18px;overflow:auto}
article{display:grid;gap:10px;padding:13px;border:1px solid var(--color-border-subtle);border-radius:9px;background:var(--color-surface-raised)}
article.empty{border-color:#d88a45}
article>header{display:flex;align-items:center;gap:8px;color:var(--color-ink)}
article>header span{margin-left:auto;color:var(--color-ink-faint);font-size:11px}
label{display:grid;gap:5px;color:var(--color-ink-faint);font-size:10px;font-weight:800;text-transform:uppercase}
label span{display:flex;align-items:center;justify-content:space-between;gap:8px}
label em{color:var(--color-accent);font-size:9px;font-style:normal;font-weight:700;text-transform:none}
input{height:34px;padding:0 10px;border:1px solid var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-ink);font-family:var(--font-mono)}
article>p{margin:0;color:var(--color-ink-muted);font-size:12px;line-height:1.45}
.split-files{display:grid;gap:5px}
.split-files>div{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;background:var(--color-surface)}
code{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;color:var(--color-ink-muted);font-size:11px;white-space:nowrap}
select{height:28px;border:1px solid var(--color-border-subtle);border-radius:5px;background:var(--color-surface-raised);color:var(--color-ink);font-size:11px}
.dialog-footer p.invalid{color:#e0a040}
</style>
+1 -1
View File
@@ -85,7 +85,7 @@
const key = CRED_KEYS[target];
const trimmed = value.trim();
if (trimmed) {
await credSave(key, "api-key", trimmed, null);
await credSave(key, "api-key", trimmed);
} else {
await credDelete(key);
}
@@ -30,10 +30,10 @@
</div>
<div class="analytics-notice-copy">
<p>
Gitty can send anonymous usage events to Aptabase so crashes, rough edges, and commonly used workflows are easier to improve.
Gitty can send anonymous usage events and technical error logs to Aptabase and the self-hosted SigNoz service 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.
Telemetry does not include repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, or source code.
</p>
</div>
@@ -47,7 +47,7 @@
</div>
<footer class="dialog-footer analytics-notice-footer">
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage events.</span>
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage and technical errors.</span>
<button class="btn-primary" type="button" onclick={() => onContinue(allowAnalytics)}>
<Check size={16} aria-hidden="true" />
Continue
+529 -97
View File
@@ -1,22 +1,83 @@
<script lang="ts">
import { Check, Languages, RefreshCw, Settings, X } from "@lucide/svelte";
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types";
import { open } from "@tauri-apps/plugin-dialog";
import {
Check,
CheckCircle2,
ChevronDown,
ChevronRight,
CircleDashed,
Code2,
FolderOpen,
GitCompare,
GitMerge,
Languages,
Palette,
RefreshCw,
RotateCw,
Settings2,
ShieldCheck,
SlidersHorizontal,
Terminal,
Wrench,
X,
} from "@lucide/svelte";
import {
applyExternalToolPreset,
defaultExternalToolsSettings,
externalToolPresets,
isExternalToolPresetAvailable,
type ExternalToolKind,
type ExternalToolPreset,
} from "../externalTools";
import type {
AnalyticsSettings,
AppLanguage,
AppTheme,
DetectedExternalTool,
ExternalToolsSettings,
ToolOpenMode,
} from "../types";
type SettingsPage = "general" | "tools";
interface Props {
analytics: AnalyticsSettings;
theme: AppTheme;
language: AppLanguage;
autoRefresh: boolean;
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean) => void;
externalTools: ExternalToolsSettings;
detectedTools: DetectedExternalTool[];
detectionPending: boolean;
detectionUnavailable: boolean;
onRefreshDetectedTools: () => void | Promise<void>;
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean, externalTools: ExternalToolsSettings) => void;
onClose: () => void;
}
let { analytics, theme = "system", language = "en", autoRefresh = true, onSave = () => {}, onClose = () => {} }: Props = $props();
let {
analytics,
theme = "system",
language = "en",
autoRefresh = true,
externalTools,
detectedTools = [],
detectionPending = false,
detectionUnavailable = false,
onRefreshDetectedTools = () => {},
onSave = () => {},
onClose = () => {},
}: Props = $props();
const toolKinds: ExternalToolKind[] = ["editor", "diff", "merge", "terminal", "fileManager"];
let activePage = $state<SettingsPage>("tools");
let activeToolKind = $state<ExternalToolKind>("editor");
let advancedOpen = $state(false);
let analyticsEnabled = $state(true);
let selectedTheme = $state<AppTheme>("system");
let selectedLanguage = $state<AppLanguage>("en");
let autoRefreshEnabled = $state(true);
let tools = $state<ExternalToolsSettings>(defaultExternalToolsSettings());
const isGerman = $derived(selectedLanguage === "de");
$effect(() => {
@@ -24,6 +85,7 @@
selectedTheme = theme;
selectedLanguage = language;
autoRefreshEnabled = autoRefresh;
tools = structuredClone(externalTools);
});
function save() {
@@ -31,112 +93,482 @@
...analytics,
enabled: analyticsEnabled,
noticeSeen: true,
}, selectedTheme, selectedLanguage, autoRefreshEnabled);
}, selectedTheme, selectedLanguage, autoRefreshEnabled, $state.snapshot(tools));
}
function toolLabel(kind: ExternalToolKind): string {
const labels = {
editor: "Editor",
diff: isGerman ? "Diff-Tool" : "Diff tool",
merge: isGerman ? "Merge-Tool" : "Merge tool",
terminal: "Terminal",
fileManager: isGerman ? "Dateimanager" : "File manager",
};
return labels[kind];
}
function toolDescription(kind: ExternalToolKind): string {
const descriptions = isGerman
? {
editor: "Öffnet Repositories und einzelne Dateien zum Bearbeiten.",
diff: "Vergleicht eine Arbeitsdatei mit ihrer Version aus HEAD.",
merge: "Übergibt Base, Current, Incoming und Ergebnis an einen 3-Wege-Merger.",
terminal: "Startet eine Shell direkt im Repository-Verzeichnis.",
fileManager: "Öffnet das Repository im bevorzugten Dateimanager.",
}
: {
editor: "Opens repositories and individual files for editing.",
diff: "Compares a working file with its version from HEAD.",
merge: "Passes base, current, incoming, and result to a three-way merger.",
terminal: "Starts a shell directly in the repository directory.",
fileManager: "Opens the repository in your preferred file manager.",
};
return descriptions[kind];
}
function toolUsage(kind: ExternalToolKind): string {
const usage = isGerman
? {
editor: "Oben in der Repository-Leiste oder über das Code-Symbol im Datei-Explorer.",
diff: "Datei im Explorer markieren und das Vergleichs-Symbol anklicken alternativ Rechtsklick auf die Datei.",
merge: "Bei einem Konflikt „Konflikte lösen“ öffnen und anschließend dieses Merge-Tool starten.",
terminal: "Oben in der Repository-Leiste über den Terminal-Button.",
fileManager: "Oben in der Repository-Leiste über den Ordner-Button.",
}
: {
editor: "Use the repository toolbar or the code button in the file explorer.",
diff: "Select a file in Explorer and click the compare button, or right-click the file.",
merge: "Open Resolve conflicts and start this merge tool from the conflict view.",
terminal: "Use the terminal button in the repository toolbar.",
fileManager: "Use the folder button in the repository toolbar.",
};
return usage[kind];
}
function presetAvailable(kind: ExternalToolKind, preset: ExternalToolPreset): boolean {
return isExternalToolPresetAvailable(kind, preset, detectedTools);
}
function availablePresets(kind: ExternalToolKind): ExternalToolPreset[] {
return externalToolPresets[kind].filter((preset) => presetAvailable(kind, preset));
}
function otherPresets(kind: ExternalToolKind): ExternalToolPreset[] {
return externalToolPresets[kind].filter((preset) => !presetAvailable(kind, preset));
}
function selectedPreset(kind: ExternalToolKind): ExternalToolPreset | undefined {
return externalToolPresets[kind].find((preset) => preset.id === tools[kind].preset);
}
function selectedToolName(kind: ExternalToolKind): string {
return tools[kind].preset === "custom"
? tools[kind].program.split(/[\\/]/).pop() || (isGerman ? "Eigenes Programm" : "Custom application")
: selectedPreset(kind)?.label ?? tools[kind].program;
}
function openMode(kind: "diff" | "merge"): ToolOpenMode {
return kind === "diff" ? tools.diffOpenMode : tools.mergeOpenMode;
}
function setOpenMode(kind: "diff" | "merge", mode: ToolOpenMode) {
if (kind === "diff") tools.diffOpenMode = mode;
else tools.mergeOpenMode = mode;
}
function selectionAvailable(kind: ExternalToolKind): boolean {
if (tools[kind].preset === "custom") return tools[kind].program.trim().length > 0;
const preset = selectedPreset(kind);
return preset ? presetAvailable(kind, preset) : false;
}
function selectionStatus(kind: ExternalToolKind): string {
if (tools[kind].preset === "custom") {
return tools[kind].program.trim()
? (isGerman ? "Manuell konfiguriert" : "Manually configured")
: (isGerman ? "Programmpfad fehlt" : "Application path missing");
}
return selectionAvailable(kind)
? (isGerman ? "Installiert und verfügbar" : "Installed and available")
: (isGerman ? "Nicht automatisch erkannt" : "Not automatically detected");
}
function changePreset(kind: ExternalToolKind, id: string) {
if (id === "custom") {
tools[kind] = { ...tools[kind], preset: "custom" };
advancedOpen = true;
return;
}
tools[kind] = applyExternalToolPreset(kind, id, detectedTools);
}
function selectToolKind(kind: ExternalToolKind) {
activeToolKind = kind;
advancedOpen = tools[kind].preset === "custom";
}
function updateProgram(kind: ExternalToolKind, program: string) {
tools[kind] = { ...tools[kind], preset: "custom", program };
}
function updateArgs(kind: ExternalToolKind, value: string) {
tools[kind] = {
...tools[kind],
preset: "custom",
args: value.split("\n").map((arg) => arg.trim()).filter(Boolean),
};
}
async function browseProgram(kind: ExternalToolKind) {
const selected = await open({
title: isGerman ? `${toolLabel(kind)} auswählen` : `Choose ${toolLabel(kind)}`,
multiple: false,
directory: false,
});
if (typeof selected === "string") {
updateProgram(kind, selected);
advancedOpen = true;
}
}
</script>
<div class="dialog-backdrop" role="presentation">
<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">Gitty</span>
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2>
<header class="app-settings-head">
<div class="app-settings-title">
<span class="app-settings-mark"><Settings2 size={18} aria-hidden="true" /></span>
<div>
<h2>{isGerman ? "Einstellungen" : "Settings"}</h2>
<p>{isGerman ? "Gitty an deinen Workflow anpassen" : "Make Gitty fit your workflow"}</p>
</div>
</div>
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"}>
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Einstellungen schließen" : "Close settings"}>
<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>
<form class="app-settings-shell" onsubmit={(event) => { event.preventDefault(); save(); }}>
<div class="app-settings-body">
<nav class="settings-nav" aria-label={isGerman ? "Einstellungsbereiche" : "Settings sections"}>
<button type="button" class:active={activePage === "general"} onclick={() => { activePage = "general"; }}>
<SlidersHorizontal size={16} aria-hidden="true" />
<span>
<strong>{isGerman ? "Allgemein" : "General"}</strong>
<small>{isGerman ? "Darstellung & Verhalten" : "Appearance & behavior"}</small>
</span>
</button>
<button type="button" class:active={activePage === "tools"} onclick={() => { activePage = "tools"; }}>
<Wrench size={16} aria-hidden="true" />
<span>
<strong>{isGerman ? "Externe Tools" : "External tools"}</strong>
<small>{isGerman ? "Editor, Diff & Terminal" : "Editor, diff & terminal"}</small>
</span>
{#if !detectionUnavailable}<em>{detectedTools.length}</em>{/if}
</button>
<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 class="settings-nav-note">
<ShieldCheck size={15} aria-hidden="true" />
<p>{isGerman ? "Tool-Argumente werden direkt und ohne zusätzliche Shell übergeben." : "Tool arguments are passed directly without an extra shell."}</p>
</div>
</nav>
<div class="settings-content">
{#if activePage === "general"}
<div class="settings-page-head">
<div>
<h3>{isGerman ? "Allgemein" : "General"}</h3>
<p>{isGerman ? "Darstellung, Sprache und Hintergrundverhalten." : "Appearance, language, and background behavior."}</p>
</div>
</div>
<div class="general-settings-grid">
<section class="general-setting-panel">
<header><Palette size={16} /><div><h4>{isGerman ? "Farbschema" : "Theme"}</h4><p>{isGerman ? "Passend zu deiner Umgebung." : "Match your environment."}</p></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="general-setting-panel">
<header><Languages size={16} /><div><h4>{isGerman ? "Sprache" : "Language"}</h4><p>{isGerman ? "Sprache der Oberfläche." : "Language used by the interface."}</p></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="general-setting-panel general-setting-wide">
<header><RefreshCw size={16} /><div><h4>{isGerman ? "Repository-Aktualisierung" : "Repository refresh"}</h4><p>{isGerman ? "Arbeitsbereich und Remotes aktuell halten." : "Keep the working tree and remotes current."}</p></div></header>
<label class="settings-switch-row">
<span><strong>{isGerman ? "Automatisch aktualisieren" : "Refresh automatically"}</strong><small>{isGerman ? "Branch-Status und Änderungen regelmäßig im Hintergrund prüfen." : "Periodically check branch state and working-tree changes."}</small></span>
<input type="checkbox" bind:checked={autoRefreshEnabled} />
</label>
</section>
<section class="general-setting-panel general-setting-wide">
<header><ShieldCheck size={16} /><div><h4>{isGerman ? "Datenschutz" : "Privacy"}</h4><p>{isGerman ? "Anonyme Produkt- und Fehlerdiagnose." : "Anonymous product and error diagnostics."}</p></div></header>
<label class="settings-switch-row">
<span><strong>{isGerman ? "Anonyme Analytics erlauben" : "Allow anonymous analytics"}</strong><small>{isGerman ? "Keine Pfade, Remotes, Branches, Diffs, Zugangsdaten oder Quelltexte." : "No paths, remotes, branches, diffs, credentials, or source code."}</small></span>
<input type="checkbox" bind:checked={analyticsEnabled} />
</label>
</section>
</div>
{:else}
<div class="settings-page-head tools-page-head">
<div>
<h3>{isGerman ? "Externe Tools" : "External tools"}</h3>
<p>
{detectionUnavailable
? (isGerman ? "Automatische Erkennung ist in dieser Umgebung nicht verfügbar." : "Automatic detection is unavailable in this environment.")
: (isGerman ? `${detectedTools.length} installierte Programme erkannt.` : `${detectedTools.length} installed applications detected.`)}
</p>
</div>
<button class="tool-rescan-button" type="button" onclick={onRefreshDetectedTools} disabled={detectionPending}>
<RotateCw class={detectionPending ? "spin" : ""} size={14} aria-hidden="true" />
{detectionPending ? (isGerman ? "Erkennung läuft…" : "Detecting…") : (isGerman ? "Neu erkennen" : "Detect again")}
</button>
</div>
<div class="tool-kind-tabs" role="tablist" aria-label={isGerman ? "Tool-Kategorie" : "Tool category"}>
{#each toolKinds as kind}
<button type="button" role="tab" aria-selected={activeToolKind === kind} class:active={activeToolKind === kind} onclick={() => selectToolKind(kind)}>
{#if kind === "editor"}<Code2 size={16} />
{:else if kind === "diff"}<GitCompare size={16} />
{:else if kind === "merge"}<GitMerge size={16} />
{:else if kind === "terminal"}<Terminal size={16} />
{:else}<FolderOpen size={16} />{/if}
<span>{toolLabel(kind)}</span>
<small class:available={selectionAvailable(kind)}></small>
</button>
{/each}
</div>
<section class="tool-config-panel" aria-label={`${toolLabel(activeToolKind)} ${isGerman ? "konfigurieren" : "configuration"}`}>
<div class="tool-config-summary">
<span class="tool-config-icon">
{#if activeToolKind === "editor"}<Code2 size={22} />
{:else if activeToolKind === "diff"}<GitCompare size={22} />
{:else if activeToolKind === "merge"}<GitMerge size={22} />
{:else if activeToolKind === "terminal"}<Terminal size={22} />
{:else}<FolderOpen size={22} />{/if}
</span>
<div>
<h4>{toolLabel(activeToolKind)}</h4>
<p>{toolDescription(activeToolKind)}</p>
</div>
<span class="tool-status" class:available={selectionAvailable(activeToolKind)}>
{#if selectionAvailable(activeToolKind)}<CheckCircle2 size={13} />{:else}<CircleDashed size={13} />{/if}
{selectionStatus(activeToolKind)}
</span>
</div>
{#if activeToolKind === "diff" || activeToolKind === "merge"}
{@const openModeKind = activeToolKind as "diff" | "merge"}
<div class="tool-route" aria-label={isGerman ? "Aktuelle Standardansicht" : "Current default view"}>
<span>{isGerman ? "Standard" : "Default"}</span><ChevronRight size={15} aria-hidden="true" />
<strong>{openMode(openModeKind) === "gitty" ? (isGerman ? "Gitty · integriert" : "Gitty · built in") : selectedToolName(activeToolKind)}</strong>
</div>
<fieldset class="tool-open-mode">
<legend>{isGerman ? "Beim Öffnen verwenden" : "Use when opening"}</legend>
<div>
<button type="button" class:active={openMode(openModeKind) === "gitty"} aria-pressed={openMode(openModeKind) === "gitty"} onclick={() => setOpenMode(openModeKind, "gitty")}>
<span>Gitty</span><small>{isGerman ? "Integrierte Ansicht" : "Built-in view"}</small>
</button>
<button type="button" class:active={openMode(openModeKind) === "external"} aria-pressed={openMode(openModeKind) === "external"} onclick={() => setOpenMode(openModeKind, "external")}>
<span>{selectedToolName(activeToolKind)}</span><small>{isGerman ? "Externes Programm" : "External application"}</small>
</button>
</div>
</fieldset>
{:else}
<div class="tool-route" aria-label={isGerman ? "Aktuelle Standardzuordnung" : "Current default mapping"}>
<span>Gitty</span><ChevronRight size={15} aria-hidden="true" /><strong>{selectedToolName(activeToolKind)}</strong>
</div>
{/if}
<label class="tool-default-field">
<span>{isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}</span>
<select value={tools[activeToolKind].preset} onchange={(event) => changePreset(activeToolKind, event.currentTarget.value)} aria-label={isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}>
{#if availablePresets(activeToolKind).length > 0}
<optgroup label={isGerman ? "Installiert" : "Installed"}>
{#each availablePresets(activeToolKind) as preset}<option value={preset.id}> {preset.label}</option>{/each}
</optgroup>
{/if}
<optgroup label={isGerman ? "Weitere unterstützte Programme" : "Other supported applications"}>
{#each otherPresets(activeToolKind) as preset}<option value={preset.id}>{preset.label}</option>{/each}
</optgroup>
<option value="custom">{isGerman ? "Eigenes Programm auswählen…" : "Choose a custom application…"}</option>
</select>
<small>{isGerman ? "Diese Auswahl wird gespeichert und für alle passenden Aktionen verwendet." : "This selection is saved and used for every matching action."}</small>
</label>
<div class="tool-usage-callout">
<span>{isGerman ? "So öffnest du es" : "How to open it"}</span>
<p>{toolUsage(activeToolKind)}</p>
</div>
<button class="tool-advanced-toggle" type="button" aria-expanded={advancedOpen} onclick={() => { advancedOpen = !advancedOpen; }}>
<span>{isGerman ? "Programmpfad und Argumente" : "Application path and arguments"}</span>
{#if advancedOpen}<ChevronDown size={15} />{:else}<ChevronRight size={15} />{/if}
</button>
{#if advancedOpen}
<div class="tool-advanced-panel">
<label>
<span>{isGerman ? "Programmpfad" : "Application path"}</span>
<div class="tool-program-row">
<input value={tools[activeToolKind].program} oninput={(event) => updateProgram(activeToolKind, event.currentTarget.value)} spellcheck="false" />
<button type="button" onclick={() => browseProgram(activeToolKind)} title={isGerman ? "Programm auswählen" : "Choose application"} aria-label={isGerman ? "Programm auswählen" : "Choose application"}><FolderOpen size={15} /></button>
</div>
</label>
<label>
<span>{isGerman ? "Argumente · eine Zeile pro Argument" : "Arguments · one per line"}</span>
<textarea value={tools[activeToolKind].args.join("\n")} oninput={(event) => updateArgs(activeToolKind, event.currentTarget.value)} spellcheck="false"></textarea>
</label>
<p class="tool-placeholders">
<span>{isGerman ? "Verfügbare Platzhalter" : "Available placeholders"}</span>
<code>{"{repo}"}</code><code>{"{file}"}</code><code>{"{parent}"}</code><code>{"{left}"}</code><code>{"{right}"}</code><code>{"{base}"}</code><code>{"{ours}"}</code><code>{"{theirs}"}</code><code>{"{result}"}</code>
</p>
</div>
{/if}
</section>
{/if}
</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>{isGerman ? "Anonyme Nutzungsanalyse" : "Anonymous usage analytics"}</h3>
</div>
</header>
<label class="settings-toggle-row">
<input type="checkbox" bind:checked={analyticsEnabled} />
<span>
<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}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit">
<Check size={16} aria-hidden="true" />
{isGerman ? "Speichern" : "Save"}
</button>
</div>
<footer class="app-settings-footer">
<span>{isGerman ? "Änderungen werden erst beim Speichern übernommen." : "Changes are applied only after saving."}</span>
<div>
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit"><Check size={16} aria-hidden="true" />{isGerman ? "Änderungen speichern" : "Save changes"}</button>
</div>
</footer>
</form>
</div>
</div>
<style>
.app-settings-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr); width: min(920px, calc(100vw - 32px)); height: min(720px, calc(100vh - 32px)); overflow: hidden; }
.app-settings-head { display: flex; align-items: center; justify-content: space-between; min-height: 70px; padding: 14px 18px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.app-settings-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
.app-settings-mark { display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, transparent); }
.app-settings-title h2, .settings-page-head h3, .tool-config-summary h4, .general-setting-panel h4 { margin: 0; color: var(--color-ink); }
.app-settings-title h2 { font-size: 18px; line-height: 1.2; }
.app-settings-title p, .settings-page-head p, .tool-config-summary p, .general-setting-panel p { margin: 0; color: var(--color-ink-dim); }
.app-settings-title p { margin-top: 3px; font-size: 11px; }
.app-settings-shell { display: grid; min-height: 0; grid-template-rows: minmax(0, 1fr) auto; }
.app-settings-body { display: grid; min-height: 0; grid-template-columns: 205px minmax(0, 1fr); }
.settings-nav { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 14px 12px; border-right: 1px solid var(--color-border-subtle); background: color-mix(in srgb, var(--app-dialog-chrome) 72%, var(--app-dialog-bg)); }
.settings-nav > button { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; min-height: 50px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; color: var(--color-ink-dim); background: transparent; text-align: left; }
.settings-nav > button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
.settings-nav > button.active { border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
.settings-nav button > :global(svg) { color: var(--color-ink-muted); }
.settings-nav button.active > :global(svg) { color: var(--color-accent); }
.settings-nav button span { display: grid; min-width: 0; gap: 2px; }
.settings-nav button strong { font-size: 12px; }
.settings-nav button small { overflow: hidden; color: var(--color-ink-faint); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
.settings-nav button em { display: grid; place-items: center; min-width: 21px; height: 20px; padding-inline: 5px; border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 12%, transparent); font-size: 9px; font-style: normal; font-weight: 800; }
.settings-nav-note { display: flex; align-items: flex-start; gap: 8px; margin-top: auto; padding: 10px; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-faint); }
.settings-nav-note :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-success); }
.settings-nav-note p { margin: 0; font-size: 9.5px; line-height: 1.45; }
.settings-content { min-width: 0; overflow: auto; padding: 18px 20px 22px; }
.settings-page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.settings-page-head h3 { font-size: 18px; }
.settings-page-head p { margin-top: 4px; font-size: 11px; line-height: 1.45; }
.tool-rescan-button { display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; min-height: 30px; padding: 0 10px; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); font-size: 10px; font-weight: 750; }
.tool-rescan-button:hover:not(:disabled) { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
.tool-kind-tabs { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; margin-bottom: 14px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
.tool-kind-tabs button { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; min-width: 0; height: 38px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
.tool-kind-tabs button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
.tool-kind-tabs button.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-raised); box-shadow: 0 2px 8px rgba(0,0,0,.12); }
.tool-kind-tabs button.active :global(svg) { color: var(--color-accent); }
.tool-kind-tabs button small { position: absolute; top: 5px; right: 6px; width: 5px; height: 5px; border-radius: 50%; background: var(--color-ink-faint); }
.tool-kind-tabs button small.available { background: var(--color-success); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-success) 15%, transparent); }
.tool-config-panel { display: grid; gap: 14px; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 12px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
.tool-config-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; }
.tool-config-icon { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid color-mix(in srgb, var(--color-accent) 25%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 8%, transparent); }
.tool-config-summary h4 { font-size: 14px; }
.tool-config-summary p { margin-top: 3px; font-size: 10.5px; line-height: 1.4; }
.tool-status { display: inline-flex; align-items: center; gap: 5px; padding: 5px 7px; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-ink-faint); font-size: 9px; font-weight: 750; }
.tool-status.available { border-color: color-mix(in srgb, var(--color-success) 24%, var(--color-border)); color: var(--color-success); background: color-mix(in srgb, var(--color-success) 6%, transparent); }
.tool-route { display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 7px 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; color: var(--color-ink-faint); background: var(--color-surface-raised); font-size: 10.5px; }
.tool-route strong { color: var(--color-ink); }
.tool-open-mode { display: grid; gap: 6px; min-width: 0; margin: 0; padding: 0; border: 0; }
.tool-open-mode legend { margin-bottom: 6px; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
.tool-open-mode > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
.tool-open-mode button { display: grid; justify-items: start; gap: 2px; min-width: 0; min-height: 46px; padding: 7px 10px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; text-align: left; }
.tool-open-mode button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
.tool-open-mode button.active { border-color: color-mix(in srgb, var(--color-accent) 38%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-hover)); box-shadow: inset 2px 0 0 var(--color-accent); }
.tool-open-mode button span { max-width: 100%; overflow: hidden; font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
.tool-open-mode button small { color: var(--color-ink-faint); font-size: 9px; font-weight: 550; }
.tool-default-field, .tool-advanced-panel label { display: grid; gap: 6px; min-width: 0; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
.tool-default-field select, .tool-advanced-panel input, .tool-advanced-panel textarea { width: 100%; min-width: 0; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink); background: var(--color-surface-raised); font: inherit; }
.tool-default-field select { height: 38px; padding: 0 11px; font-size: 12px; font-weight: 700; }
.tool-default-field small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; }
.tool-usage-callout { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 10px; padding: 10px 11px; border-left: 2px solid var(--color-accent); border-radius: 0 7px 7px 0; background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
.tool-usage-callout span { color: var(--color-accent); font-size: 9.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
.tool-usage-callout p { margin: 0; color: var(--color-ink-muted); font-size: 10.5px; line-height: 1.45; }
.tool-advanced-toggle { display: flex; align-items: center; justify-content: space-between; min-height: 32px; padding: 0; border: 0; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
.tool-advanced-toggle:hover { color: var(--color-ink); }
.tool-advanced-panel { display: grid; gap: 11px; padding-top: 2px; }
.tool-program-row { display: grid; grid-template-columns: minmax(0, 1fr) 34px; gap: 6px; }
.tool-advanced-panel input { height: 34px; padding: 0 9px; font-family: var(--font-mono); font-size: 10.5px; }
.tool-advanced-panel textarea { min-height: 80px; padding: 8px 9px; resize: vertical; font-family: var(--font-mono); font-size: 10.5px; line-height: 1.45; }
.tool-program-row button { display: grid; place-items: center; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); }
.tool-program-row button:hover { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
.tool-placeholders { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin: 0; color: var(--color-ink-faint); font-size: 9px; }
.tool-placeholders span { margin-right: 3px; }
.tool-placeholders code { padding: 2px 4px; border-radius: 4px; color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 9%, transparent); }
.general-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.general-setting-panel { display: grid; align-content: start; gap: 14px; min-width: 0; padding: 14px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
.general-setting-panel.general-setting-wide { grid-column: 1 / -1; }
.general-setting-panel > header { display: flex; align-items: flex-start; gap: 9px; }
.general-setting-panel > header > :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-accent); }
.general-setting-panel h4 { font-size: 12.5px; }
.general-setting-panel p { margin-top: 3px; font-size: 9.5px; }
.settings-segmented { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 3px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
.settings-segmented.settings-language { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.settings-segmented label { display: flex; align-items: center; justify-content: center; min-height: 31px; border: 1px solid transparent; border-radius: 6px; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 750; }
.settings-segmented label.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-hover); }
.settings-segmented input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.settings-switch-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; }
.settings-switch-row span { display: grid; gap: 3px; }
.settings-switch-row strong { color: var(--color-ink); font-size: 11px; }
.settings-switch-row small { color: var(--color-ink-dim); font-size: 9.5px; line-height: 1.4; }
.settings-switch-row input { width: 32px; height: 18px; accent-color: var(--color-accent); }
.app-settings-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 62px; padding: 11px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.app-settings-footer > span { color: var(--color-ink-faint); font-size: 9.5px; }
.app-settings-footer > div { display: flex; gap: 8px; }
.app-settings-footer button { min-height: 32px; }
@media (max-width: 760px) {
.app-settings-dialog { height: min(760px, calc(100vh - 20px)); width: min(660px, calc(100vw - 20px)); }
.app-settings-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
.settings-nav { flex-direction: row; padding: 8px 10px; border-right: 0; border-bottom: 1px solid var(--color-border-subtle); }
.settings-nav > button { width: auto; min-width: 0; flex: 1 1 0; min-height: 42px; }
.settings-nav-note { display: none; }
.settings-content { padding: 14px; }
.tool-kind-tabs { grid-template-columns: repeat(5, minmax(42px, 1fr)); overflow-x: auto; }
.tool-kind-tabs button { height: 40px; }
.tool-kind-tabs button span { display: none; }
.tool-config-summary { grid-template-columns: auto minmax(0, 1fr); }
.tool-status { grid-column: 1 / -1; justify-self: start; }
.app-settings-footer > span { display: none; }
.app-settings-footer { justify-content: flex-end; }
}
@media (max-width: 520px) {
.app-settings-head { min-height: 58px; padding: 10px 12px; }
.app-settings-mark { width: 34px; height: 34px; }
.settings-nav button small, .settings-nav button em { display: none; }
.settings-nav > button { grid-template-columns: auto minmax(0, 1fr); }
.settings-page-head { align-items: stretch; flex-direction: column; }
.tool-rescan-button { align-self: flex-start; }
.general-settings-grid { grid-template-columns: 1fr; }
.general-setting-panel.general-setting-wide { grid-column: auto; }
.tool-config-panel { padding: 13px; }
.tool-usage-callout { grid-template-columns: 1fr; gap: 4px; }
}
</style>
+6 -1
View File
@@ -2,6 +2,11 @@
import { FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
import type { GitBlameLine } from "../types";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
interface BlameGroup {
id: string;
hash: string;
@@ -66,7 +71,7 @@
if (!seconds) return "";
const date = new Date(seconds * 1000);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
return commitDateFormatter.format(date);
}
function groupTooltip(group: BlameGroup): string {
@@ -18,16 +18,24 @@
onClose = () => {},
}: Props = $props();
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
let title = $derived(branch.remote ? "Delete remote branch?" : force ? "Force delete branch?" : "Delete branch?");
let remoteParts = $derived(branch.remote ? branch.name.split(/\/(.+)/) : []);
let branchName = $derived(branch.remote ? remoteParts[1] || branch.name : branch.name);
let branchLocation = $derived(branch.remote ? remoteParts[0] || "Remote" : "Local repository");
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header">
<div>
<span class="eyebrow">{force ? "Force delete" : "Delete branch"}</span>
<p class="dialog-title">{title}</p>
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="branch-delete-title">
<header class="dialog-header branch-delete-header">
<div class="branch-delete-heading">
<span class:force class="branch-delete-heading-icon" aria-hidden="true">
<Trash2 size={16} />
</span>
<div>
<span class="eyebrow">{branch.remote ? "Remote branch" : force ? "Force delete" : "Delete branch"}</span>
<p class="dialog-title" id="branch-delete-title">{title}</p>
</div>
</div>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
@@ -35,27 +43,35 @@
</header>
<div class="branch-delete-body">
<div class="discard-warning-icon" aria-hidden="true">
<div class:force class="discard-warning-icon branch-delete-warning-icon" aria-hidden="true">
<AlertTriangle size={22} />
</div>
<div class="discard-confirm-copy">
<p>
{#if force}
This branch is not fully merged. Force deleting removes the branch pointer even if some commits are only reachable from this branch.
<p class="branch-delete-lead">
{#if branch.remote}
This branch will be removed from the shared remote repository.
{:else if force}
This branch is not fully merged. Some commits may only exist here.
{:else}
Delete this local branch from the repository?
This branch will be removed from your local repository.
{/if}
</p>
<code class="discard-target" title={branch.name}>
<GitBranch size={13} aria-hidden="true" />
{branch.name}
</code>
<div class="branch-delete-target" title={branch.name}>
<span class="branch-delete-target-icon" aria-hidden="true"><GitBranch size={16} /></span>
<span class="branch-delete-target-copy">
<code>{branchName}</code>
<span>{branchLocation}</span>
</span>
<span class:remote={branch.remote} class="branch-delete-scope">{branch.remote ? "Remote" : "Local"}</span>
</div>
<p class="discard-warning-text">
{#if force}
Make sure you no longer need the unique commits on this branch.
{#if branch.remote}
This affects everyone using <strong>{remoteParts[0] || "the remote"}</strong>. Your local branch is kept.
{:else if force}
Force deletion can make unmerged commits difficult to recover.
{:else}
Git will refuse if the branch is not fully merged.
Git will stop the deletion if the branch contains unmerged commits.
{/if}
</p>
</div>
@@ -63,13 +79,13 @@
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-danger" type="button" onclick={onConfirm} disabled={isBusy}>
<button class="btn-danger branch-delete-confirm" type="button" onclick={onConfirm} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<Trash2 size={15} aria-hidden="true" />
{/if}
{force ? "Force delete" : "Delete"}
{branch.remote ? "Delete from remote" : force ? "Force delete" : "Delete"}
</button>
</footer>
</div>
+54 -11
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -49,14 +49,18 @@
hasRepository: boolean;
isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void;
onCompareBranch: (branch: GitBranchInfo) => void;
onMerge: (branch: GitBranchInfo) => void;
onRebase: (branch: GitBranchInfo) => void;
onCreateBranch: (branchName: string) => void | Promise<void>;
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>;
onManageWorktrees: () => void;
onCreateWorktree: (branch: GitBranchInfo) => void | Promise<void>;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
@@ -69,14 +73,18 @@
hasRepository = false,
isBusy = false,
onCheckout = () => {},
onCompareBranch = () => {},
onMerge = () => {},
onRebase = () => {},
onCreateBranch = () => {},
onRenameBranch = () => {},
onDeleteBranch = () => {},
onDeleteRemoteBranch = () => {},
onCreateTag = () => {},
onDeleteTag = () => {},
onPushTag = () => {},
onManageWorktrees = () => {},
onCreateWorktree = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
@@ -244,7 +252,7 @@
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 190);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 226);
contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX));
@@ -262,11 +270,25 @@
await onRenameBranch(branch);
}
function compareContextBranch() {
const branch = contextBranch;
if (!branch || isBusy) return;
closeBranchContextMenu();
onCompareBranch(branch);
}
async function deleteContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || branch.remote || isBusy) return;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onDeleteBranch(branch);
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
}
async function createContextWorktree() {
const branch = contextBranch;
closeBranchContextMenu();
if (!branch) return;
await onCreateWorktree(branch);
}
async function checkoutContextBranch() {
@@ -398,8 +420,6 @@
<!-- 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>
{:else}
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
{#if createOpen}
@@ -636,6 +656,21 @@
{/if}
{/if}
</div>
<div class="branch-group">
<button
class="branch-group-toggle worktree-group-toggle"
type="button"
onclick={onManageWorktrees}
disabled={isBusy}
aria-haspopup="dialog"
title="Manage repository worktrees"
>
<HardDrive size={14} aria-hidden="true" />
<span>Worktrees</span>
<span class="branch-group-count">Manage</span>
</button>
</div>
</div>
{/if}
@@ -651,6 +686,10 @@
<GitBranch size={14} aria-hidden="true" />
Checkout
</button>
<button type="button" role="menuitem" onclick={compareContextBranch} disabled={isBusy}>
<GitCompare size={14} aria-hidden="true" />
Compare with...
</button>
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
<GitMerge size={14} aria-hidden="true" />
Merge into current
@@ -659,21 +698,25 @@
<GitBranch size={14} aria-hidden="true" />
Rebase current onto this
</button>
<button type="button" role="menuitem" onclick={createContextWorktree} disabled={isBusy || contextBranch.remote || contextBranch.current}>
<HardDrive size={14} aria-hidden="true" />
Open in new worktree
</button>
<div class="menu-separator" role="separator"></div>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
<Pencil size={14} aria-hidden="true" />
Rename
{contextBranch.remote ? "Rename remote..." : "Rename"}
</button>
<button
class="danger"
type="button"
role="menuitem"
onclick={deleteContextBranch}
disabled={isBusy || contextBranch.current || contextBranch.remote}
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Remote branch cannot be deleted here" : "Delete local branch"}
disabled={isBusy || contextBranch.current}
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Delete remote branch" : "Delete local branch"}
>
<Trash2 size={14} aria-hidden="true" />
Delete
{contextBranch.remote ? "Delete remote" : "Delete"}
</button>
</div>
{/if}
+159
View File
@@ -0,0 +1,159 @@
<script lang="ts">
import { onMount } from "svelte";
import {
ArrowDownToLine, ArrowUpFromLine, Boxes, CircleHelp, FileCode, GitBranch,
GitCompare, History, RefreshCw, Search, Settings, SlidersHorizontal, Sparkles,
} from "@lucide/svelte";
import type { AppLanguage, GitBranch as GitBranchInfo, GitCommit, GitRepositoryFile } from "../types";
type ItemKind = "fetch" | "pull" | "push" | "refresh" | "search" | "compare" | "reflog" | "rebase" | "worktrees" | "sync" | "settings" | "ai-settings" | "help" | "branch" | "file" | "commit";
interface Item { id: string; group: string; kind: ItemKind; title: string; subtitle: string; search: string; disabled?: boolean; run: () => void | Promise<void>; }
interface Props {
language: AppLanguage; hasRepository: boolean; isBusy: boolean;
branches: GitBranchInfo[]; files: GitRepositoryFile[]; commits: GitCommit[];
onClose: () => void;
onCheckoutBranch: (branch: GitBranchInfo) => void | Promise<void>;
onOpenFile: (file: GitRepositoryFile) => void | Promise<void>;
onSelectCommit: (commit: GitCommit) => void | Promise<void>;
onFetch: () => void | Promise<void>; onPull: () => void | Promise<void>; onPush: () => void | Promise<void>;
onRefresh: () => void | Promise<void>; onOpenSearch: () => void; onOpenCompare: () => void;
onOpenReflog: () => void | Promise<void>; onOpenInteractiveRebase: () => void;
onOpenWorktrees: () => void | Promise<void>; onOpenSyncSettings: () => void | Promise<void>;
onOpenSettings: () => void; onOpenAiSettings: () => void; onOpenHelp: () => void;
}
let {
language = "en", hasRepository = false, isBusy = false, branches = [], files = [], commits = [], onClose = () => {},
onCheckoutBranch = () => {}, onOpenFile = () => {}, onSelectCommit = () => {}, onFetch = () => {}, onPull = () => {},
onPush = () => {}, onRefresh = () => {}, onOpenSearch = () => {}, onOpenCompare = () => {}, onOpenReflog = () => {},
onOpenInteractiveRebase = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {},
onOpenAiSettings = () => {}, onOpenHelp = () => {},
}: Props = $props();
let query = $state("");
let activeIndex = $state(0);
let inputElement = $state<HTMLInputElement | null>(null);
let listElement = $state<HTMLElement | null>(null);
const isGerman = $derived(language === "de");
const repositoryActionDisabled = $derived(!hasRepository || isBusy);
function action(id: string, kind: ItemKind, title: string, subtitle: string, run: () => void | Promise<void>, requiresRepository = true): Item {
return { id, group: isGerman ? "Aktionen" : "Actions", kind, title, subtitle, search: `${title} ${subtitle}`.toLowerCase(), disabled: requiresRepository ? repositoryActionDisabled : false, run };
}
const actionItems = $derived([
action("fetch", "fetch", "Fetch", isGerman ? "Remote-Änderungen abrufen" : "Download remote changes", onFetch),
action("pull", "pull", "Pull", isGerman ? "Änderungen abrufen und integrieren" : "Download and integrate changes", onPull),
action("push", "push", "Push", isGerman ? "Lokale Commits veröffentlichen" : "Publish local commits", onPush),
action("refresh", "refresh", isGerman ? "Repository aktualisieren" : "Refresh repository", isGerman ? "Status und Historie neu laden" : "Reload status and history", onRefresh),
action("search", "search", isGerman ? "Globale Codesuche" : "Global code search", isGerman ? "Code und Dateihistorie durchsuchen" : "Search code and file history", onOpenSearch),
action("compare", "compare", isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits", isGerman ? "Zwei vollständige Revisionen vergleichen" : "Diff two complete revisions", onOpenCompare),
action("reflog", "reflog", "Reflog", isGerman ? "Verlorene Commits finden und wiederherstellen" : "Find and recover lost commits", onOpenReflog),
action("rebase", "rebase", "Interactive Rebase", isGerman ? "Commit-Historie bearbeiten" : "Edit commit history", onOpenInteractiveRebase),
action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees),
action("sync", "sync", isGerman ? "Synchronisierung konfigurieren" : "Configure synchronization", isGerman ? "Remote, Upstream und Pull-Strategie" : "Remote, upstream and pull strategy", onOpenSyncSettings),
action("settings", "settings", isGerman ? "Einstellungen" : "Settings", isGerman ? "Darstellung, Sprache und Verhalten" : "Appearance, language and behavior", onOpenSettings, false),
action("ai-settings", "ai-settings", "AI Settings", isGerman ? "Provider und Modell konfigurieren" : "Configure provider and model", onOpenAiSettings, false),
action("help", "help", isGerman ? "Hilfe öffnen" : "Open help", isGerman ? "Git-Dokumentation und Tastenkürzel" : "Git documentation and keyboard shortcuts", onOpenHelp, false),
]);
const dynamicItems = $derived.by(() => {
if (!hasRepository) return [];
const branchItems: Item[] = branches.map((branch) => ({
id: `branch:${branch.remote ? "remote" : "local"}:${branch.name}`, group: "Branches", kind: "branch", title: branch.name,
subtitle: branch.current ? (isGerman ? "Aktueller Branch" : "Current branch") : branch.remote ? (isGerman ? "Remote-Branch auschecken" : "Check out remote branch") : (isGerman ? "Branch auschecken" : "Check out branch"),
search: `${branch.name} branch ${branch.remote ? "remote" : "local"}`.toLowerCase(), disabled: isBusy || branch.current, run: () => onCheckoutBranch(branch),
}));
const fileItems: Item[] = files.map((file) => ({
id: `file:${file.path}`, group: isGerman ? "Dateien" : "Files", kind: "file", title: file.path.split(/[\\/]/).pop() ?? file.path,
subtitle: file.path, search: `${file.path} file datei`.toLowerCase(), disabled: isBusy, run: () => onOpenFile(file),
}));
const commitItems: Item[] = commits.map((commit) => ({
id: `commit:${commit.hash}`, group: isGerman ? "Geladene Commits" : "Loaded commits", kind: "commit", title: commit.summary || (isGerman ? "Ohne Commit-Nachricht" : "No commit message"),
subtitle: `${commit.short_hash} · ${commit.author_name}`, search: `${commit.hash} ${commit.short_hash} ${commit.summary} ${commit.author_name} ${commit.author_email} ${commit.refs.join(" ")}`.toLowerCase(), run: () => onSelectCommit(commit),
}));
return [...branchItems, ...fileItems, ...commitItems];
});
const visibleItems = $derived.by(() => {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
const allItems = [...actionItems, ...dynamicItems];
if (terms.length === 0) return allItems.slice(0, 35);
return allItems.filter((item) => terms.every((term) => item.search.includes(term))).slice(0, 80);
});
$effect(() => { query; activeIndex = 0; });
$effect(() => { if (activeIndex >= visibleItems.length) activeIndex = Math.max(0, visibleItems.length - 1); });
$effect(() => { activeIndex; queueMicrotask(() => listElement?.querySelector<HTMLElement>("[data-active='true']")?.scrollIntoView({ block: "nearest" })); });
onMount(() => inputElement?.focus());
function execute(item: Item | undefined) { if (!item || item.disabled) return; onClose(); queueMicrotask(() => { void item.run(); }); }
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); return; }
if (event.key === "ArrowDown") { event.preventDefault(); activeIndex = Math.min(activeIndex + 1, visibleItems.length - 1); return; }
if (event.key === "ArrowUp") { event.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); return; }
if (event.key === "Enter") { event.preventDefault(); execute(visibleItems[activeIndex]); }
}
</script>
<div class="command-palette-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) onClose(); }}>
<div class="command-palette" role="dialog" aria-modal="true" aria-label={isGerman ? "Befehlspalette" : "Command palette"}>
<div class="command-palette-search">
<Search size={19} aria-hidden="true" />
<input bind:this={inputElement} bind:value={query} onkeydown={handleKeydown} placeholder={isGerman ? "Aktion, Branch, Datei oder Commit suchen…" : "Search actions, branches, files, or commits…"} aria-label={isGerman ? "Befehl suchen" : "Search commands"} autocomplete="off" spellcheck="false" />
<kbd>ESC</kbd>
</div>
<div class="command-palette-results" bind:this={listElement} role="listbox" aria-label={isGerman ? "Ergebnisse" : "Results"}>
{#if visibleItems.length === 0}
<div class="command-palette-empty"><Search size={24} aria-hidden="true" /><strong>{isGerman ? "Keine Treffer" : "No results"}</strong><span>{isGerman ? "Versuche einen anderen Suchbegriff." : "Try a different search term."}</span></div>
{:else}
{#each visibleItems as item, index (item.id)}
{#if index === 0 || visibleItems[index - 1].group !== item.group}<div class="command-palette-group">{item.group}</div>{/if}
<button class="command-palette-item" class:active={index === activeIndex} type="button" role="option" aria-selected={index === activeIndex} data-active={index === activeIndex} disabled={item.disabled} onmouseenter={() => { activeIndex = index; }} onclick={() => execute(item)}>
<span class={`command-palette-icon ${item.kind}`}>
{#if item.kind === "fetch" || item.kind === "pull"}<ArrowDownToLine size={16} />
{:else if item.kind === "push"}<ArrowUpFromLine size={16} />
{:else if item.kind === "refresh"}<RefreshCw size={16} />
{:else if item.kind === "search"}<Search size={16} />
{:else if item.kind === "compare"}<GitCompare size={16} />
{:else if item.kind === "reflog" || item.kind === "commit"}<History size={16} />
{:else if item.kind === "rebase" || item.kind === "branch"}<GitBranch size={16} />
{:else if item.kind === "worktrees"}<Boxes size={16} />
{:else if item.kind === "sync"}<SlidersHorizontal size={16} />
{:else if item.kind === "settings"}<Settings size={16} />
{:else if item.kind === "ai-settings"}<Sparkles size={16} />
{:else if item.kind === "help"}<CircleHelp size={16} />
{:else}<FileCode size={16} />{/if}
</span>
<span class="command-palette-copy"><strong>{item.title}</strong><small>{item.subtitle}</small></span>
{#if item.kind === "branch" && item.disabled && !isBusy}<span class="command-palette-current">{isGerman ? "AKTUELL" : "CURRENT"}</span>{/if}
</button>
{/each}
{/if}
</div>
<footer class="command-palette-footer"><span><kbd></kbd><kbd></kbd>{isGerman ? "Navigieren" : "Navigate"}</span><span><kbd></kbd>{isGerman ? "Öffnen" : "Open"}</span>{#if commits.length > 0}<span class="command-palette-hint">{isGerman ? `${commits.length} geladene Commits` : `${commits.length} loaded commits`}</span>{/if}</footer>
</div>
</div>
<style>
.command-palette-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: start center; padding: min(14vh, 120px) 20px 20px; background: color-mix(in srgb, var(--app-dialog-backdrop) 76%, transparent); backdrop-filter: blur(7px) saturate(.82); }
.command-palette { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(720px, 100%); max-height: min(650px, 76vh); overflow: hidden; border: 1px solid color-mix(in srgb, var(--color-primary) 22%, var(--color-border)); border-radius: 14px; background: var(--app-dialog-bg); box-shadow: 0 28px 90px rgba(0,0,0,.42); }
.command-palette-search { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 15px 17px; border-bottom: 1px solid var(--color-border); color: var(--color-primary); }
.command-palette-search input { min-width: 0; border: 0; outline: 0; color: var(--color-ink); background: transparent; font: inherit; font-size: 15px; }
.command-palette-search input::placeholder { color: var(--color-ink-dim); }
kbd { display: inline-grid; place-items: center; min-width: 23px; height: 21px; padding: 0 5px; border: 1px solid var(--color-border); border-radius: 5px; color: var(--color-ink-dim); background: var(--color-surface-raised); font-family: var(--font-mono); font-size: 9px; font-weight: 700; }
.command-palette-results { min-height: 120px; overflow-y: auto; padding: 7px; }
.command-palette-group { padding: 10px 9px 5px; color: var(--color-ink-dim); font-size: 10px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
.command-palette-item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; width: 100%; gap: 10px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; text-align: left; color: var(--color-ink); background: transparent; cursor: pointer; }
.command-palette-item.active:not(:disabled) { border-color: color-mix(in srgb, var(--color-primary) 24%, transparent); background: color-mix(in srgb, var(--color-primary) 11%, var(--color-surface-raised)); }
.command-palette-item:disabled { cursor: default; opacity: .48; }
.command-palette-icon { display: grid; place-items: center; width: 31px; height: 31px; border: 1px solid var(--color-border-subtle); border-radius: 8px; color: var(--color-ink-muted); background: var(--color-surface-raised); }
.command-palette-icon.branch { color: #65c98b; } .command-palette-icon.file { color: #69a7ff; } .command-palette-icon.commit { color: #ba82ff; }
.command-palette-copy { display: grid; min-width: 0; gap: 2px; } .command-palette-copy strong, .command-palette-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.command-palette-copy strong { font-size: 12.5px; font-weight: 700; } .command-palette-copy small { color: var(--color-ink-dim); font-size: 10.5px; }
.command-palette-current { color: var(--color-ink-dim); font-family: var(--font-mono); font-size: 9px; font-weight: 700; }
.command-palette-empty { display: grid; place-items: center; gap: 5px; padding: 54px 20px; color: var(--color-ink-dim); } .command-palette-empty strong { margin-top: 5px; color: var(--color-ink); font-size: 13px; } .command-palette-empty span { font-size: 11px; }
.command-palette-footer { display: flex; align-items: center; gap: 16px; min-height: 38px; padding: 7px 12px; border-top: 1px solid var(--color-border); color: var(--color-ink-dim); font-size: 10px; }
.command-palette-footer span { display: flex; align-items: center; gap: 5px; } .command-palette-footer span kbd + kbd { margin-left: -3px; } .command-palette-hint { margin-left: auto; }
@media (max-width: 640px) { .command-palette-backdrop { padding: 60px 10px 10px; } .command-palette { max-height: calc(100vh - 80px); } .command-palette-hint { display: none !important; } }
</style>
+303
View File
@@ -0,0 +1,303 @@
<script lang="ts">
import {
Download,
GitCommitHorizontal,
Info,
LoaderCircle,
Save,
StickyNote,
Trash2,
Upload,
X,
} from "@lucide/svelte";
import type { AppLanguage, GitCommit, GitRemote } from "../types";
interface Props {
commit: GitCommit;
note: string;
remotes: GitRemote[];
preferredRemote: string;
language: AppLanguage;
isLoading: boolean;
isBusy: boolean;
error: string;
status: string;
onSave: (note: string) => void | Promise<void>;
onDelete: () => void | Promise<void>;
onFetch: (remote: string) => void | Promise<void>;
onPush: (remote: string) => void | Promise<void>;
onClose: () => void;
}
let {
commit,
note = "",
remotes = [],
preferredRemote = "",
language = "en",
isLoading = false,
isBusy = false,
error = "",
status = "",
onSave = () => {},
onDelete = () => {},
onFetch = () => {},
onPush = () => {},
onClose = () => {},
}: Props = $props();
let draft = $state("");
let selectedRemote = $state("");
let deleteConfirmOpen = $state(false);
let lastLoadedKey = $state("");
let hasChanges = $derived(draft !== note);
let canSave = $derived(!isLoading && !isBusy && draft.trim().length > 0 && hasChanges);
const text = $derived(language === "de" ? {
eyebrow: "Interne Git-Notiz",
title: "Commit-Notiz",
commit: "Commit",
explanation: "Die Notiz wird separat unter refs/notes/commits gespeichert. Hash und Commit-Historie bleiben unverändert.",
label: "Notiz",
placeholder: "Zum Beispiel Review-Hinweise, Ticket-Kontext, Build-ID oder Freigabestatus …",
loading: "Notiz wird geladen …",
syncTitle: "Mit Remote synchronisieren",
syncHelp: "Git Notes reisen nicht automatisch mit Branches. Lade sie gezielt vom Remote oder sende deine lokalen Notizen dorthin.",
noRemotes: "Für dieses Repository ist kein Remote eingerichtet.",
remote: "Remote",
fetch: "Vom Remote laden",
push: "Zum Remote senden",
delete: "Notiz löschen",
deleteQuestion: "Diese Notiz wirklich löschen?",
deleteConfirm: "Ja, löschen",
cancel: "Abbrechen",
close: "Schließen",
save: "Notiz speichern",
characters: "Zeichen",
} : {
eyebrow: "Internal Git note",
title: "Commit note",
commit: "Commit",
explanation: "The note is stored separately under refs/notes/commits. The commit hash and history stay unchanged.",
label: "Note",
placeholder: "For example review findings, ticket context, a build ID, or approval status…",
loading: "Loading note…",
syncTitle: "Sync with a remote",
syncHelp: "Git Notes do not travel with branches automatically. Fetch them explicitly from a remote or push your local notes there.",
noRemotes: "No remote is configured for this repository.",
remote: "Remote",
fetch: "Fetch from remote",
push: "Push to remote",
delete: "Delete note",
deleteQuestion: "Delete this note?",
deleteConfirm: "Yes, delete",
cancel: "Cancel",
close: "Close",
save: "Save note",
characters: "characters",
});
$effect(() => {
const loadedKey = `${commit.hash}:${note}`;
if (loadedKey === lastLoadedKey) return;
draft = note;
lastLoadedKey = loadedKey;
deleteConfirmOpen = false;
});
$effect(() => {
if (selectedRemote && remotes.some((remote) => remote.name === selectedRemote)) return;
selectedRemote = remotes.some((remote) => remote.name === preferredRemote)
? preferredRemote
: (remotes[0]?.name ?? "");
});
function submit(event: SubmitEvent) {
event.preventDefault();
if (!canSave) return;
void onSave(draft);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && !isBusy) onClose();
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
if (canSave) void onSave(draft);
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="dialog-backdrop app-chrome-backdrop commit-note-backdrop" role="presentation">
<div class="commit-note-dialog" role="dialog" aria-modal="true" aria-labelledby="commit-note-title" tabindex="-1">
<header class="commit-note-head">
<div class="commit-note-heading">
<span class="commit-note-icon"><StickyNote size={20} aria-hidden="true" /></span>
<div>
<span class="eyebrow">{text.eyebrow}</span>
<h2 id="commit-note-title">{text.title}</h2>
</div>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={text.close} aria-label={text.close}>
<X size={18} aria-hidden="true" />
</button>
</header>
<div class="commit-note-body">
<div class="commit-note-target">
<span class="commit-target-icon"><GitCommitHorizontal size={18} aria-hidden="true" /></span>
<div>
<span>{text.commit} <code>{commit.short_hash}</code></span>
<strong title={commit.summary}>{commit.summary}</strong>
<small>{commit.author_name} · {new Date(commit.date).toLocaleString()}</small>
</div>
</div>
<div class="commit-note-info">
<Info size={16} aria-hidden="true" />
<span>{text.explanation}</span>
</div>
{#if error}
<div class="commit-note-message error" role="alert">{error}</div>
{:else if status}
<div class="commit-note-message success" role="status">{status}</div>
{/if}
<form class="commit-note-form" onsubmit={submit}>
<label for="commit-note-editor">
<span>{text.label}</span>
<small>{draft.length.toLocaleString()} {text.characters}</small>
</label>
<div class="commit-note-editor-wrap">
{#if isLoading}
<div class="commit-note-loading"><LoaderCircle class="spin" size={18} aria-hidden="true" />{text.loading}</div>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<textarea
id="commit-note-editor"
bind:value={draft}
placeholder={text.placeholder}
disabled={isLoading || isBusy}
maxlength={262144}
spellcheck="true"
autofocus
></textarea>
</div>
</form>
<section class="commit-note-sync" aria-labelledby="commit-note-sync-title">
<div class="commit-note-sync-copy">
<strong id="commit-note-sync-title">{text.syncTitle}</strong>
<span>{text.syncHelp}</span>
</div>
{#if remotes.length > 0}
<div class="commit-note-sync-controls">
<label>
<span>{text.remote}</span>
<select bind:value={selectedRemote} disabled={isBusy}>
{#each remotes as remote (remote.name)}
<option value={remote.name}>{remote.name}</option>
{/each}
</select>
</label>
<button type="button" onclick={() => onFetch(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Download size={15} aria-hidden="true" />{/if}
{text.fetch}
</button>
<button type="button" onclick={() => onPush(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Upload size={15} aria-hidden="true" />{/if}
{text.push}
</button>
</div>
{:else}
<span class="commit-note-no-remotes">{text.noRemotes}</span>
{/if}
</section>
</div>
<footer class="commit-note-footer">
<div class="commit-note-delete">
{#if deleteConfirmOpen}
<span>{text.deleteQuestion}</span>
<button class="btn-danger" type="button" onclick={() => onDelete()} disabled={isBusy}>{text.deleteConfirm}</button>
<button type="button" onclick={() => { deleteConfirmOpen = false; }} disabled={isBusy}>{text.cancel}</button>
{:else}
<button type="button" class="commit-note-delete-trigger" onclick={() => { deleteConfirmOpen = true; }} disabled={isBusy || isLoading || !note}>
<Trash2 size={15} aria-hidden="true" />{text.delete}
</button>
{/if}
</div>
<div class="commit-note-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{text.close}</button>
<button class="btn-primary" type="button" onclick={() => onSave(draft)} disabled={!canSave}>
{#if isBusy}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Save size={16} aria-hidden="true" />{/if}
{text.save}
</button>
</div>
</footer>
</div>
</div>
<style>
.commit-note-backdrop { z-index: 72; }
.commit-note-dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(720px, calc(100vw - 32px));
max-height: min(780px, calc(100vh - 74px));
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 14px;
background: var(--app-dialog-bg);
box-shadow: var(--app-dialog-shadow);
}
.commit-note-head,
.commit-note-footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; background: var(--app-dialog-chrome); }
.commit-note-head { padding: 16px 18px; border-bottom: 1px solid var(--color-border-subtle); }
.commit-note-heading { display: flex; align-items: center; gap: 12px; min-width: 0; }
.commit-note-heading h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 18px; line-height: 1.2; }
.commit-note-icon,
.commit-target-icon { display: grid; flex: 0 0 auto; place-items: center; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
.commit-note-icon { width: 40px; height: 40px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 11px; }
.commit-note-body { display: grid; align-content: start; gap: 13px; min-height: 0; padding: 16px 18px; overflow: auto; }
.commit-note-target { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 11px; padding: 11px 12px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--color-surface-raised); }
.commit-target-icon { width: 34px; height: 34px; border-radius: 8px; }
.commit-note-target > div { display: grid; gap: 3px; min-width: 0; }
.commit-note-target span,
.commit-note-target small { color: var(--color-ink-faint); font-size: 10.5px; }
.commit-note-target code { margin-left: 4px; color: var(--color-accent); font: 700 10.5px var(--font-mono); }
.commit-note-target strong { overflow: hidden; color: var(--color-ink); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.commit-note-info { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 8px; padding: 10px 11px; border: 1px solid color-mix(in srgb, var(--color-accent) 22%, var(--color-border-subtle)); border-radius: 9px; color: var(--color-ink-muted); background: color-mix(in srgb, var(--color-accent) 6%, transparent); font-size: 11px; line-height: 1.45; }
.commit-note-info :global(svg) { margin-top: 1px; color: var(--color-accent); }
.commit-note-message { padding: 9px 11px; border: 1px solid; border-radius: 8px; font-size: 11px; font-weight: 700; }
.commit-note-message.error { border-color: rgba(232, 96, 96, .32); color: #ef8888; background: rgba(232, 96, 96, .08); }
.commit-note-message.success { border-color: color-mix(in srgb, #5bd18a 34%, var(--color-border)); color: #70dc99; background: color-mix(in srgb, #5bd18a 8%, transparent); }
.commit-note-form { display: grid; gap: 6px; }
.commit-note-form > label { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--color-ink-muted); font-size: 11px; font-weight: 800; }
.commit-note-form > label small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 600; }
.commit-note-editor-wrap { position: relative; }
.commit-note-editor-wrap textarea { min-height: 164px; max-height: 320px; resize: vertical; font-size: 12.5px; line-height: 1.55; }
.commit-note-loading { position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; justify-content: center; gap: 8px; border-radius: 8px; color: var(--color-ink-muted); background: color-mix(in srgb, var(--app-input-bg) 92%, transparent); font-size: 11px; font-weight: 700; }
.commit-note-sync { display: grid; gap: 11px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--color-surface-raised); }
.commit-note-sync-copy { display: grid; gap: 4px; }
.commit-note-sync-copy strong { color: var(--color-ink); font-size: 12px; }
.commit-note-sync-copy span,
.commit-note-no-remotes { color: var(--color-ink-faint); font-size: 10.5px; line-height: 1.45; }
.commit-note-sync-controls { display: grid; grid-template-columns: minmax(130px, 1fr) auto auto; align-items: end; gap: 8px; }
.commit-note-sync-controls label { display: grid; gap: 5px; color: var(--color-ink-muted); font-size: 10px; font-weight: 800; }
.commit-note-sync-controls button { min-height: 34px; font-size: 10.5px; font-weight: 750; }
.commit-note-footer { min-height: 66px; padding: 12px 18px; border-top: 1px solid var(--color-border-subtle); }
.commit-note-delete,
.commit-note-actions { display: flex; align-items: center; gap: 8px; }
.commit-note-delete > span { color: var(--color-ink-muted); font-size: 10.5px; font-weight: 700; }
.commit-note-delete-trigger { border-color: transparent; color: #e87a7a; background: transparent; }
.commit-note-delete-trigger:hover:not(:disabled) { border-color: rgba(232, 96, 96, .24); color: #ff9a9a; background: rgba(232, 96, 96, .08); }
@media (max-width: 660px) {
.commit-note-sync-controls { grid-template-columns: 1fr; }
.commit-note-footer { align-items: stretch; flex-direction: column; }
.commit-note-delete,
.commit-note-actions { justify-content: flex-end; }
}
</style>
+16 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
import { Check, GitCommitHorizontal, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props {
@@ -14,12 +14,14 @@
commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean;
commitAiReviewing: boolean;
commitAiSplitting: boolean;
canAmend: boolean;
amendMode: boolean;
onCommit: () => void;
onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void;
onReviewStaged: () => void;
onSplitStaged: () => void;
onOpenAiSettings: () => void;
onToggleAmend: (checked: boolean) => void;
onUndoLastCommit: () => void;
@@ -37,12 +39,14 @@
commitAiPhase = "idle",
commitAiGenerating = false,
commitAiReviewing = false,
commitAiSplitting = false,
canAmend = false,
amendMode = false,
onCommit = () => {},
onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {},
onReviewStaged = () => {},
onSplitStaged = () => {},
onOpenAiSettings = () => {},
onToggleAmend = () => {},
onUndoLastCommit = () => {},
@@ -70,6 +74,7 @@
(commitAiProvider !== "local" || commitAiPhase === "ready"),
);
let canReview = $derived(canGenerate && commitAiProvider !== "local");
let canSplit = $derived(canReview && stagedCount > 1 && !commitAiSplitting);
</script>
<section class="panel commit-panel" aria-label="Commit">
@@ -80,6 +85,16 @@
</div>
<div class="commit-head-actions">
<span class="pill pill-count">{stagedCount} staged</span>
<button
class="commit-review-button"
type="button"
onclick={onSplitStaged}
disabled={!canSplit}
title={commitAiProvider === "local" ? "Commit splitting currently requires an API provider" : "Suggest logical commits for the staged files"}
>
{#if commitAiSplitting}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<GitCommitHorizontal size={14} aria-hidden="true" />{/if}
Split
</button>
<button
class="commit-review-button"
type="button"
+10 -6
View File
@@ -20,6 +20,8 @@
comparison: GitCommitComparison;
selectedDiffPath: string;
isBusy: boolean;
fromLabel?: string;
toLabel?: string;
restoreLabel?: string;
/** When opened from a search hit, the term to highlight on matching lines. */
highlightQuery?: string;
@@ -32,6 +34,8 @@
comparison,
selectedDiffPath = "",
isBusy = false,
fromLabel = "",
toLabel = "",
restoreLabel = "",
highlightQuery = "",
onClose = () => {},
@@ -216,18 +220,18 @@
</script>
<div
class="dialog-backdrop"
class="dialog-backdrop compare-dialog-backdrop"
role="presentation"
>
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Branch or commit comparison" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Compare</span>
<h2 class="dialog-range">
<span class="hash">{comparison.from_short}</span>
<span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
<ArrowRight size={14} aria-hidden="true" />
<span class="hash">{comparison.to_short}</span>
<span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
</h2>
</div>
<div class="dialog-header-actions">
@@ -289,11 +293,11 @@
<div class="split-col-headers">
<div class="split-col-label">
<span>Before</span>
<span class="split-col-hash">{comparison.from_short}</span>
<span class="split-col-hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
</div>
<div class="split-col-label">
<span>After</span>
<span class="split-col-hash">{comparison.to_short}</span>
<span class="split-col-hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
</div>
</div>
+66 -18
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
import type { GitCommit } from "../types";
import type { GitBranch, GitCommit } from "../types";
interface Props {
commits: GitCommit[];
branches: GitBranch[];
compareFrom: string;
compareTo: string;
canCompare: boolean;
@@ -17,6 +18,7 @@
let {
commits = [],
branches = [],
compareFrom = "",
compareTo = "",
canCompare = false,
@@ -32,6 +34,14 @@
return `${item.short_hash} - ${item.summary}`;
}
function branchValue(branch: GitBranch): string {
return branch.remote ? `refs/remotes/${branch.name}` : `refs/heads/${branch.name}`;
}
let localBranches = $derived(branches.filter((branch) => !branch.remote));
let remoteBranches = $derived(branches.filter((branch) => branch.remote));
let targetCount = $derived(branches.length + commits.length);
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
onCompare();
@@ -42,53 +52,89 @@
class="dialog-backdrop"
role="presentation"
>
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select branches or commits to compare" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Compare</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Select commits</h2>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Compare branches or commits</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
{#if commits.length < 2}
<div class="blank-state">At least two commits are needed to compare.</div>
{#if targetCount < 2}
<div class="blank-state">At least two branches or commits are needed to compare.</div>
{:else}
<form class="compare-form" onsubmit={handleSubmit}>
<label class="compare-field">
<span>From (older)</span>
<span>Base</span>
<select
value={compareFrom}
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
disabled={isBusy}
>
<option value="" disabled>Select a commit</option>
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
<option value="" disabled>Select a branch or commit</option>
{#if localBranches.length > 0}
<optgroup label="Local branches">
{#each localBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
{/each}
</optgroup>
{/if}
{#if remoteBranches.length > 0}
<optgroup label="Remote branches">
{#each remoteBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}</option>
{/each}
</optgroup>
{/if}
{#if commits.length > 0}
<optgroup label="Recent commits">
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</optgroup>
{/if}
</select>
</label>
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
<label class="compare-field">
<span>To (newer)</span>
<span>Compare with</span>
<select
value={compareTo}
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
disabled={isBusy}
>
<option value="" disabled>Select a commit</option>
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
<option value="" disabled>Select a branch or commit</option>
{#if localBranches.length > 0}
<optgroup label="Local branches">
{#each localBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
{/each}
</optgroup>
{/if}
{#if remoteBranches.length > 0}
<optgroup label="Remote branches">
{#each remoteBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}</option>
{/each}
</optgroup>
{/if}
{#if commits.length > 0}
<optgroup label="Recent commits">
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</optgroup>
{/if}
</select>
</label>
<button class="btn-primary" type="submit" disabled={!canCompare}>
{#if operation === "Comparing commits"}
{#if operation === "Comparing revisions"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<GitCompare size={16} aria-hidden="true" />
@@ -98,9 +144,11 @@
</form>
{#if compareFrom && compareTo && compareFrom === compareTo}
<div class="blank-state">Select two different commits to compare.</div>
<div class="blank-state">Select two different branches or commits to compare.</div>
{:else}
<div class="blank-state">Pick two commits and run a comparison.</div>
<div class="compare-target-help">
The two branch tips are compared across the entire repository. Uncommitted working-tree changes are not included.
</div>
{/if}
{/if}
</div>
+26 -42
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from "svelte";
import {
AlertCircle,
Download,
@@ -17,7 +18,9 @@
action: "push" | "pull" | "fetch" | "clone";
error: string;
isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
initialUsername?: string;
initialMode?: Mode;
onSubmit: (username: string, password: string, save: boolean, mode: Mode) => void;
onCancel: () => void;
}
@@ -25,23 +28,24 @@
action,
error = "",
isBusy = false,
initialUsername = "",
initialMode = "credentials",
onSubmit,
onCancel,
}: Props = $props();
type Mode = "credentials" | "token";
let mode = $state<Mode>("credentials");
let username = $state("");
let mode = $state<Mode>(untrack(() => initialMode));
let username = $state(untrack(() => initialUsername === "oauth2" ? "" : initialUsername));
let password = $state("");
let showPassword = $state(false);
let saveSession = $state(true);
let expiresAt = $state("");
let canSubmit = $derived(
!isBusy &&
password.trim().length > 0 &&
(mode === "token" || username.trim().length > 0),
username.trim().length > 0,
);
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
let actionTitle = $derived(
@@ -62,12 +66,7 @@
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (!canSubmit) return;
onSubmit(
mode === "token" ? "oauth2" : username,
password,
saveSession,
saveSession && expiresAt ? expiresAt : null,
);
onSubmit(username.trim(), password, saveSession, mode);
}
</script>
@@ -98,7 +97,7 @@
<div class="cred-security-note">
<ShieldCheck size={14} aria-hidden="true" />
<span>When saved, the token is stored encrypted in the operating system's keychain — never in plain text.</span>
<span>When saved, the credentials are stored encrypted in the operating system's keychain — never in plain text.</span>
</div>
</div>
@@ -122,27 +121,25 @@
aria-pressed={mode === "token"}
>
<Key size={13} aria-hidden="true" />
Token
Access token
</button>
</div>
<div class="cred-fields">
{#if mode === "credentials"}
<div class="cred-field">
<label class="cred-field-label" for="cred-username">Username</label>
<div class="cred-input">
<User size={15} class="cred-field-icon" aria-hidden="true" />
<input
id="cred-username"
type="text"
bind:value={username}
placeholder="e.g. my-github-username"
autocomplete="username"
disabled={isBusy}
/>
</div>
<div class="cred-field">
<label class="cred-field-label" for="cred-username">Username</label>
<div class="cred-input">
<User size={15} class="cred-field-icon" aria-hidden="true" />
<input
id="cred-username"
type="text"
bind:value={username}
placeholder="Your account username"
autocomplete="username"
disabled={isBusy}
/>
</div>
{/if}
</div>
<div class="cred-field">
<label class="cred-field-label" for="cred-password">
@@ -180,7 +177,7 @@
{#if mode === "token"}
<div class="cred-token-hint">
<Key size={13} aria-hidden="true" />
<span>Username is automatically set to <code>oauth2</code>. This works with GitHub, GitLab, and Bitbucket.</span>
<span>Use your normal account username. The access token is sent as the password, as required by Gitea and most Git providers.</span>
</div>
{/if}
@@ -191,19 +188,6 @@
</div>
{/if}
{#if saveSession}
<div class="cred-expiry">
<label class="cred-field-label" for="cred-expiry">Expiration date (optional)</label>
<input
id="cred-expiry"
type="date"
bind:value={expiresAt}
disabled={isBusy}
/>
<span class="cred-expiry-hint">After this date you'll automatically be asked to log in again.</span>
</div>
{/if}
<div class="cred-footer">
<label class="cred-save">
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
@@ -5,7 +5,7 @@
interface Props {
files: GitFileStatus[];
staged: boolean | null;
scope: "file" | "hunk";
scope: "file" | "hunk" | "lines";
isBusy: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
@@ -26,9 +26,9 @@
let count = $derived(files.length);
let title = $derived(
scope === "hunk" ? "Discard hunk?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
scope === "hunk" ? "Discard hunk?" : scope === "lines" ? "Discard selected lines?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
);
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : count > 1 ? `${count} files` : "file");
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : scope === "lines" ? "selected lines" : count > 1 ? `${count} files` : "file");
let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
</script>
+93 -4
View File
@@ -11,6 +11,8 @@
FileCog,
FileImage,
FileJson,
FileSearch,
GitCompare,
FileSpreadsheet,
FileText,
FileType,
@@ -32,11 +34,17 @@
selectedExplorerKind: ExplorerNodeKind;
hasRepository: boolean;
isBusy: boolean;
language?: "en" | "de";
editorName?: string;
diffName?: string;
onToggleFolder: (node: ExplorerNode) => void;
onExpandAllFolders: () => void;
onCollapseAllFolders: () => void;
onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void;
onOpenInEditor: (node: ExplorerNode) => void;
onExternalDiff: (node: ExplorerNode) => void;
onFileHistory: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
@@ -49,11 +57,17 @@
selectedExplorerKind = "file",
hasRepository = false,
isBusy = false,
language = "en",
editorName = "Editor",
diffName = "diff tool",
onToggleFolder = () => {},
onExpandAllFolders = () => {},
onCollapseAllFolders = () => {},
onSelectNode = () => {},
onOpenFile = () => {},
onOpenInEditor = () => {},
onExternalDiff = () => {},
onFileHistory = () => {},
onBlame = () => {},
collapsed = false,
onToggleCollapsed = () => {},
@@ -62,6 +76,7 @@
let contextNode = $state<ExplorerNode | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
const isGerman = $derived(language === "de");
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
if (!next) return current;
@@ -175,7 +190,7 @@
contextNode = node;
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 56));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 220));
}
function closeFileContextMenu() {
@@ -189,6 +204,20 @@
onOpenFile(node);
}
function openContextFileInEditor() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onOpenInEditor(node);
}
function openContextExternalDiff() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onExternalDiff(node);
}
function openContextBlame() {
const node = contextNode;
if (!node || node.kind !== "file") return;
@@ -196,6 +225,13 @@
onBlame(node);
}
function openContextFileHistory() {
const node = contextNode;
if (!node || node.kind !== "file" || !node.tracked) return;
closeFileContextMenu();
onFileHistory(node);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeFileContextMenu();
}
@@ -203,6 +239,11 @@
let explorerTree = $derived(buildExplorerTree(repoFiles));
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
let selectedFileNode = $derived(
selectedExplorerKind === "file"
? visibleNodes.find((node) => node.kind === "file" && node.path === selectedExplorerPath) ?? null
: null,
);
</script>
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
@@ -214,6 +255,35 @@
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2>
</div>
<div class="explorer-head-actions">
<button
class="explorer-bulk-button explorer-tool-action"
type="button"
onclick={() => selectedFileNode && onOpenInEditor(selectedFileNode)}
disabled={isBusy || !selectedFileNode || selectedFileNode.status === "deleted"}
title={isGerman
? `Ausgewählte Datei in ${editorName} öffnen`
: `Open selected file in ${editorName}`}
aria-label={isGerman
? `Ausgewählte Datei in ${editorName} öffnen`
: `Open selected file in ${editorName}`}
>
<FileCode size={14} aria-hidden="true" />
</button>
<button
class="explorer-bulk-button explorer-tool-action"
type="button"
onclick={() => selectedFileNode && onExternalDiff(selectedFileNode)}
disabled={isBusy || !selectedFileNode || !selectedFileNode.tracked || selectedFileNode.status === "deleted"}
title={isGerman
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
: `Compare selected file with HEAD in ${diffName}`}
aria-label={isGerman
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
: `Compare selected file with HEAD in ${diffName}`}
>
<GitCompare size={14} aria-hidden="true" />
</button>
<span class="explorer-action-divider" aria-hidden="true"></span>
<button
class="explorer-bulk-button"
type="button"
@@ -261,12 +331,14 @@
{:else}
<div class="explorer-list overflow-auto p-2">
{#each visibleNodes as node (`${node.kind}:${node.path}`)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="explorer-row"
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
class:folder={node.kind === "folder"}
style={`--depth: ${node.depth}`}
title={node.path}
oncontextmenu={(event) => openFileContextMenu(event, node)}
>
{#if node.kind === "folder"}
<button
@@ -328,9 +400,8 @@
class="explorer-select"
type="button"
onclick={() => onSelectNode(node)}
oncontextmenu={(event) => openFileContextMenu(event, node)}
disabled={isBusy}
title={`Show history for ${node.path}`}
title={`Select ${node.path}`}
>
<span>{node.name}</span>
</button>
@@ -355,6 +426,24 @@
tabindex="-1"
aria-label={`Actions for ${contextNode.path}`}
>
<button type="button" role="menuitem" onclick={openContextFileInEditor} disabled={contextNode.status === "deleted"}>
<FileCode size={14} aria-hidden="true" />
{isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`}
</button>
<button type="button" role="menuitem" onclick={openContextExternalDiff} disabled={!contextNode.tracked || contextNode.status === "deleted"}>
<GitCompare size={14} aria-hidden="true" />
{isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`}
</button>
<button
type="button"
role="menuitem"
onclick={openContextFileHistory}
disabled={!contextNode.tracked}
title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}
>
<History size={14} aria-hidden="true" />
File history
</button>
<button
type="button"
role="menuitem"
@@ -372,7 +461,7 @@
disabled={!contextNode.tracked || contextNode.status === "deleted"}
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
>
<History size={14} aria-hidden="true" />
<FileSearch size={14} aria-hidden="true" />
Blame
</button>
</div>
+106
View File
@@ -0,0 +1,106 @@
<script lang="ts">
import { AlertCircle, GitCompare, History, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
import type { GitCommit } from "../types";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
interface Props {
fileHistory: GitCommit[];
filePath: string;
isBusy: boolean;
isLoading: boolean;
error?: string;
onDiff: (commit: GitCommit) => void;
onRestore: (commit: GitCommit) => void;
onClose: () => void;
}
let {
fileHistory = [],
filePath = "",
isBusy = false,
isLoading = false,
error = "",
onDiff = () => {},
onRestore = () => {},
onClose = () => {},
}: Props = $props();
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return commitDateFormatter.format(date);
}
function fileName(path: string): string {
return path.split(/[\\/]/).pop() ?? path;
}
function closeFromBackdrop(event: MouseEvent) {
if (event.target === event.currentTarget && !isBusy) onClose();
}
</script>
<div class="dialog-backdrop app-chrome-backdrop file-history-dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
<div class="file-history-dialog" role="dialog" aria-modal="true" aria-labelledby="file-history-dialog-title" tabindex="-1">
<header class="file-history-dialog-header">
<div class="file-history-dialog-icon" aria-hidden="true"><History size={19} /></div>
<div class="file-history-dialog-heading">
<span class="eyebrow">File history</span>
<h2 id="file-history-dialog-title">{fileName(filePath)}</h2>
<span class="file-history-dialog-path" title={filePath}>{filePath}</span>
</div>
<button class="dialog-icon-button" type="button" onclick={onClose} disabled={isBusy} aria-label="Close file history">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="file-history-dialog-body">
{#if isLoading}
<div class="file-history-dialog-state" role="status" aria-live="polite">
<LoaderCircle class="spin" size={22} aria-hidden="true" />
<strong>Loading file history…</strong>
<span>Reading commits that changed this file.</span>
</div>
{:else if error}
<div class="file-history-dialog-state error" role="alert">
<AlertCircle size={22} aria-hidden="true" />
<strong>File history could not be loaded.</strong>
<span>{error}</span>
</div>
{:else if fileHistory.length === 0}
<div class="file-history-dialog-state">
<History size={22} aria-hidden="true" />
<strong>No history found.</strong>
<span>This file has no committed changes yet.</span>
</div>
{:else}
<div class="file-history-dialog-list">
{#each fileHistory as item (item.hash)}
<article class="file-history-dialog-row">
<div class="file-history-dialog-commit">
<span class="commit-avatar" aria-hidden="true">{item.author_name.trim().slice(0, 2).toUpperCase() || "?"}</span>
<div>
<strong title={item.summary}>{item.summary}</strong>
<span><code>{item.short_hash}</code> · {item.author_name}</span>
</div>
</div>
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="file-history-dialog-actions">
<button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes against the working tree">
<GitCompare size={14} aria-hidden="true" /> Diff
</button>
<button class="btn-sm" type="button" onclick={() => onRestore(item)} disabled={isBusy} title="Restore this file from the selected commit">
<RotateCcw size={14} aria-hidden="true" /> Restore
</button>
</div>
</article>
{/each}
</div>
{/if}
</div>
</div>
</div>
-172
View File
@@ -1,172 +0,0 @@
<script lang="ts">
import { ChevronLeft, ChevronRight, GitCompare, History, RotateCcw } from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png";
import type { GitCommit } from "../types";
interface Props {
fileHistory: GitCommit[];
selectedExplorerPath: string;
selectedExplorerLabel: string;
hasRepository: boolean;
isBusy: boolean;
isLoading?: boolean;
onDiff: (commit: GitCommit) => void;
onRestore: (commit: GitCommit) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
let {
fileHistory = [],
selectedExplorerPath = "",
selectedExplorerLabel = "File history",
hasRepository = false,
isBusy = false,
isLoading = false,
onDiff = () => {},
onRestore = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function fileName(path: string): string {
return path.split("/").pop() ?? path;
}
function pathTooltip(node: HTMLElement, text: string) {
let el: HTMLDivElement | null = null;
function show(e: MouseEvent) {
if (!text || !text.includes("/")) return;
el = document.createElement("div");
el.className = "path-tooltip";
el.textContent = text;
document.body.appendChild(el);
move(e);
}
function move(e: MouseEvent) {
if (!el) return;
const tw = el.offsetWidth;
const left = Math.max(8, Math.min(e.clientX - tw / 2, window.innerWidth - tw - 8));
el.style.left = `${left}px`;
el.style.top = `${e.clientY - 44}px`;
}
function hide() {
el?.remove();
el = null;
}
node.addEventListener("mouseenter", show);
node.addEventListener("mousemove", move);
node.addEventListener("mouseleave", hide);
return {
update(v: string) { text = v; },
destroy() {
hide();
node.removeEventListener("mouseenter", show);
node.removeEventListener("mousemove", move);
node.removeEventListener("mouseleave", hide);
},
};
}
</script>
<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>
<h2
class="file-history-name"
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}
<div class="blank-state">No repository loaded.</div>
{:else if !selectedExplorerPath}
<div class="blank-state">Select a file in Explorer.</div>
{:else if isLoading}
<div class="file-history-loading" role="status" aria-live="polite">
<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>
{:else}
<div class="history-list overflow-auto p-2">
{#each fileHistory as item (item.hash)}
<article class="commit-row compact file-history-row">
<div class="commit-line">
<History size={16} aria-hidden="true" />
<div class="commit-line-text">
<strong title={item.summary}>{item.summary}</strong>
<span>{item.short_hash} - {item.author_name}</span>
</div>
</div>
<div class="commit-actions file-history-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons">
<button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes vs working tree">
<GitCompare size={15} aria-hidden="true" />
Diff
</button>
<button class="btn-sm" type="button" onclick={() => onRestore(item)} disabled={isBusy} title="Restore selected file from this commit">
<RotateCcw size={15} aria-hidden="true" />
Restore
</button>
</div>
</div>
</article>
{/each}
</div>
{/if}
{/if}
</section>
+6 -1
View File
@@ -15,6 +15,11 @@
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
import LanguageIcon from "./LanguageIcon.svelte";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
type SearchTab = "code" | "files";
interface Props {
@@ -82,7 +87,7 @@
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
return commitDateFormatter.format(date);
}
function displayPath(hit: GitSearchHit): string {
+342
View File
@@ -17,6 +17,7 @@
Lightbulb,
ListChecks,
Search,
Sparkles,
Wrench,
X,
} from "@lucide/svelte";
@@ -617,6 +618,87 @@
},
);
deCategories.find((category) => category.id === "app")?.sections.push(
{
id: "app-line-staging",
title: "Änderungen zeilenweise stagen",
summary: "Im Patch-Dialog kannst du nicht nur vollständige Dateien oder Hunks, sondern einzelne hinzugefügte und entfernte Zeilen für den nächsten Commit auswählen.",
steps: [
"Öffne im Bereich Unstaged den Patch einer geänderten Textdatei.",
"Klicke auf die Auswahlfläche vor einer grünen oder roten Zeile. Mit Shift-Klick markierst du einen zusammenhängenden Bereich.",
"Die Auswahlleiste zeigt die Anzahl der gewählten Zeilen. Stage selected übernimmt nur diese Auswahl in den Staging-Bereich.",
"Öffne denselben Dialog unter Staged und nutze Unstage selected, wenn einzelne Zeilen wieder zurück ins Arbeitsverzeichnis sollen.",
"Prüfe danach den Staged-Diff. Er ist die verbindliche Vorschau des nächsten Commits.",
],
note: "Bei einer geänderten Zeile stellt Git intern meist eine entfernte und eine hinzugefügte Zeile dar. Wähle beide Seiten, wenn die vollständige Ersetzung in denselben Commit gehört.",
},
{
id: "app-line-discard",
title: "Hunks und einzelne Zeilen verwerfen",
summary: "Discard selected verwirft nur die markierten Zeilen; Discard Hunk verwirft den vollständigen Abschnitt. Gitty zeigt vor dem Löschen eine Sicherheitsabfrage.",
steps: [
"Kontrolliere, ob der Dialog Staged changes oder Unstaged changes anzeigt.",
"Markiere exakt die Zeilen, die nicht behalten werden sollen.",
"Lies in der Sicherheitsabfrage Pfad und Umfang und bestätige erst danach.",
"Aktualisiere den Patch, falls Dateien während des geöffneten Dialogs extern verändert wurden.",
],
note: "Vorsicht: Verwerfen kann nicht zuverlässig rückgängig gemacht werden. Erstelle bei wichtigen Zwischenständen zuerst einen Commit oder Stash.",
},
{
id: "app-package-updates",
title: "Gitty unter Arch Linux installieren und aktualisieren",
summary: "Das AUR-Paket gitty-desktop lädt das öffentliche Gitea-Release und baut Gitty lokal aus dem Quellcode.",
commands: [
{ command: "yay -S gitty-desktop", description: "Gitty mit yay aus dem AUR bauen, installieren oder aktualisieren" },
{ command: "paru -S gitty-desktop", description: "Gitty alternativ mit paru bauen, installieren oder aktualisieren" },
{ command: "git clone https://aur.archlinux.org/gitty-desktop.git && cd gitty-desktop && makepkg -si", description: "AUR-Paket ohne AUR-Helfer prüfen und manuell bauen" },
{ command: "pacman -Qi gitty-desktop", description: "Installierte Version und Paketinformationen anzeigen" },
],
steps: [
"Das PKGBUILD lädt das öffentliche Quellarchiv des jeweiligen Gitea-Tags herunter.",
"Die Pipeline aktualisiert Version, Prüfsumme und .SRCINFO im AUR-Paket gitty-desktop.",
"AUR-Helfer erkennen neue Versionen und erzeugen daraus lokal ein normales Pacman-Paket.",
"Ohne AUR-Helfer kannst du das AUR-Git-Repository klonen, die Dateien prüfen und makepkg -si ausführen.",
],
note: "AUR-Pakete werden von Nutzern gepflegt. Prüfe PKGBUILD und .SRCINFO vor der Installation, besonders nach größeren Änderungen.",
},
);
deCategories.find((category) => category.id === "branches")?.sections.push(
{
id: "branches-worktrees-concept",
title: "Was ein Worktree ist",
summary: "Ein Repository besitzt eine gemeinsame Git-Datenbank, kann aber mehrere Arbeitsordner haben. Jeder Worktree hat eigene ausgecheckte Dateien und normalerweise einen eigenen Branch; Commits und Referenzen sind sofort in allen Worktrees sichtbar.",
steps: [
"Das Haupt-Worktree ist der Ordner, den du ursprünglich als Repository geöffnet hast.",
"Ein zusätzlicher Worktree ist kein vollständiger Clone: Git-Objekte und Historie werden gemeinsam genutzt, nur Arbeitsordner und Index sind getrennt.",
"Änderungen in Dateien bleiben im jeweiligen Worktree. Ein dort erstellter Commit ist dagegen sofort Teil desselben Repositorys.",
"Worktrees eignen sich besonders für parallele Features, dringende Hotfixes, Reviews oder längere Builds auf einem zweiten Branch.",
"Für vollständig unabhängige Remotes, Konfigurationen oder Experimente ist ein eigener Clone weiterhin die passendere Wahl.",
],
note: "Ein Branch kann normalerweise nicht gleichzeitig in zwei Worktrees ausgecheckt werden. Das verhindert, dass zwei Ordner denselben Branch widersprüchlich verändern.",
},
{
id: "branches-worktrees",
title: "Mehrere Branches mit Worktrees parallel öffnen",
summary: "Ein Worktree checkt einen weiteren Branch in einen eigenen Ordner aus. So kannst du an mehreren Aufgaben arbeiten, ohne Branches im Hauptordner umzuschalten oder lokale Änderungen zu stashen.",
steps: [
"Öffne im Branch-Panel unter Tags den Reiter Worktrees.",
"Wähle Existing branch für einen vorhandenen lokalen Branch, New branch für einen neuen Branch oder Detached für einen festen Commit ohne Branch.",
"Lege einen leeren Zielordner fest und erstelle den Worktree. Open tab öffnet ihn anschließend als eigenen Repository-Tab.",
"Move verschiebt einen nicht aktiven Worktree. Lock schützt ihn vor versehentlichem Prune oder Entfernen, etwa auf einem externen Laufwerk.",
"Remove löscht den Worktree-Ordner und seine Registrierung, behält aber den Branch. Prune entfernt nur veraltete Registrierungen nicht mehr vorhandener Ordner.",
],
commands: [
{ command: "git worktree list", description: "Alle registrierten Worktrees anzeigen" },
{ command: "git worktree add ../projekt-fix fix/login", description: "Vorhandenen Branch in neuem Ordner auschecken" },
{ command: "git worktree add -b feature/name ../projekt-feature main", description: "Neuen Branch und Worktree von main erstellen" },
{ command: "git worktree prune --dry-run", description: "Veraltete Registrierungen vor dem Aufräumen prüfen" },
],
note: "Ein Branch kann normalerweise nur in einem Worktree gleichzeitig ausgecheckt sein. Ein Worktree mit lokalen Änderungen sollte vor Remove committed, gestasht oder bewusst erzwungen entfernt werden.",
},
);
deCategories.find((category) => category.id === "branches")?.sections.push(
{
id: "branches-strategy",
@@ -979,6 +1061,87 @@
},
);
enCategories.find((category) => category.id === "app")?.sections.push(
{
id: "app-line-staging",
title: "Stage changes line by line",
summary: "The patch dialog lets you prepare individual added and deleted lines instead of staging an entire file or hunk.",
steps: [
"Open the patch of a changed text file in Unstaged.",
"Click the selector in front of a green or red line. Shift-click selects a continuous range.",
"The selection bar shows the number of selected lines. Stage selected moves only that selection into Staged.",
"Open the same dialog under Staged and use Unstage selected to return individual lines to the working tree.",
"Review the Staged diff afterward. It is the authoritative preview of the next commit.",
],
note: "Git usually represents one edited line as one deletion plus one addition. Select both sides when the complete replacement belongs in the same commit.",
},
{
id: "app-line-discard",
title: "Discard hunks or individual lines",
summary: "Discard selected removes only selected lines; Discard Hunk removes the complete section. Gitty shows a confirmation before deleting changes.",
steps: [
"Check whether the dialog currently shows Staged changes or Unstaged changes.",
"Select exactly the lines that should not be kept.",
"Review the file path and scope in the confirmation, then confirm.",
"Refresh the patch if another application changed the file while the dialog was open.",
],
note: "Caution: discarded work cannot always be recovered. Commit or stash important intermediate work first.",
},
{
id: "app-package-updates",
title: "Install and update Gitty on Arch Linux",
summary: "The gitty-desktop AUR package downloads the public Gitea release and builds Gitty locally from source.",
commands: [
{ command: "yay -S gitty-desktop", description: "Build, install, or update Gitty from the AUR with yay" },
{ command: "paru -S gitty-desktop", description: "Alternatively build, install, or update Gitty with paru" },
{ command: "git clone https://aur.archlinux.org/gitty-desktop.git && cd gitty-desktop && makepkg -si", description: "Inspect and build the AUR package without an AUR helper" },
{ command: "pacman -Qi gitty-desktop", description: "Show the installed version and package information" },
],
steps: [
"PKGBUILD downloads the public source archive for the corresponding Gitea tag.",
"The pipeline updates the version, checksum, and .SRCINFO in the gitty-desktop AUR package.",
"AUR helpers detect new versions and turn the recipe into a regular Pacman package locally.",
"Without an AUR helper, clone the AUR Git repository, inspect its files, and run makepkg -si.",
],
note: "AUR packages are user-maintained. Review PKGBUILD and .SRCINFO before installing, especially after major changes.",
},
);
enCategories.find((category) => category.id === "branches")?.sections.push(
{
id: "branches-worktrees-concept",
title: "What a worktree is",
summary: "One repository has a shared Git database but can have multiple working folders. Each worktree has its own checked-out files and usually its own branch; commits and references are immediately visible from every worktree.",
steps: [
"The main worktree is the folder you originally opened as the repository.",
"An additional worktree is not a full clone: Git objects and history are shared while the working folder and index remain separate.",
"File changes stay inside their worktree. A commit created there immediately becomes part of the shared repository.",
"Worktrees are useful for parallel features, urgent hotfixes, reviews, or long-running builds on another branch.",
"Use a separate clone when you need independent remotes, configuration, or isolated experiments.",
],
note: "A branch normally cannot be checked out in two worktrees at once. This prevents two folders from changing the same branch inconsistently.",
},
{
id: "branches-worktrees",
title: "Open multiple branches in parallel with worktrees",
summary: "A worktree checks out another branch into a separate folder. This lets you work on multiple tasks without switching the main folder or stashing local changes.",
steps: [
"Open Worktrees below Tags in the Branch panel.",
"Choose Existing branch for a local branch, New branch to create one, or Detached for a fixed commit without a branch.",
"Choose an empty destination folder and create the worktree. Open tab then opens it as a separate repository tab.",
"Move relocates an inactive worktree. Lock protects it from accidental pruning or removal, for example while stored on an external drive.",
"Remove deletes the worktree folder and registration but keeps its branch. Prune removes only stale registrations for missing folders.",
],
commands: [
{ command: "git worktree list", description: "Show all registered worktrees" },
{ command: "git worktree add ../project-fix fix/login", description: "Check out an existing branch in a new folder" },
{ command: "git worktree add -b feature/name ../project-feature main", description: "Create a branch and worktree from main" },
{ command: "git worktree prune --dry-run", description: "Preview stale registrations before pruning" },
],
note: "A branch can normally be checked out in only one worktree at a time. Commit, stash, or deliberately force removal of local changes before removing a worktree.",
},
);
enCategories.find((category) => category.id === "branches")?.sections.push(
{
id: "branches-strategy",
@@ -1193,6 +1356,184 @@
},
);
deCategories.splice(1, 0, {
id: "changelog",
label: "Neu in Gitty",
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
sections: [
{
id: "changelog-2026-8-3",
title: "Version 2026.8.3",
summary: "Dieses Release verbindet Gitty enger mit deinen Entwicklungswerkzeugen und macht Branches, Historie und Vergleiche deutlich leistungsfähiger.",
steps: [
"Externe Tools: Editor, Diff-Tool, Merge-Tool, Terminal und Dateimanager lassen sich in den neu gestalteten Einstellungen erkennen, auswählen und individuell konfigurieren.",
"VS Code, JetBrains-IDEs, Beyond Compare und weitere unterstützte Programme werden in einem eigenen Fenster geöffnet; dokumentierte Ergebnis-Codes werden beim Schließen korrekt behandelt.",
"Git Notes: Commits erhalten lokale Notizen, ohne ihre Historie umzuschreiben. Notizen lassen sich bearbeiten, löschen sowie gezielt vom Remote abrufen oder dorthin übertragen.",
"Vollständiger Branch-Vergleich: Lokale Branches, Remote-Branches und Commits können direkt ausgewählt und dateiweise im Side-by-Side-Diff verglichen werden.",
"Remote-Branches lassen sich im Kontextmenü sicher umbenennen. Gitty schützt dabei vorhandene Ziel-Branches und zwischenzeitlich geänderte Remote-Stände.",
"Der überarbeitete Commit-Graph zeigt Branches kompakter, reduziert überladene Commit-Zeilen und blendet zusätzliche Flag-Details beim Darüberfahren ein.",
"Noch nicht veröffentlichte Branches sind als „Nur lokal“ erkennbar in der Werkzeugleiste, Repository-Übersicht, Statuszeile und direkt an der Branch-Flag. Die erste Push-Aktion heißt passend „Veröffentlichen“.",
"Branch-Sichtbarkeit, feinere Graph-Verbindungen und ein kleineres Mindestmaß des Verlaufsbereichs verbessern die Übersicht bei großen Repositories.",
"Die neue Befehlspalette öffnet Aktionen, Dateien und Commits schneller; asynchrone Git-Befehle halten Gitty auch bei langsameren Operationen reaktionsfähig.",
],
note: "Der Branch-Vergleich zeigt die vollständig festgeschriebenen Zustände der beiden Branch-Spitzen. Nicht commitete Änderungen im Arbeitsverzeichnis sind nicht enthalten.",
},
{
id: "changelog-2026-8-2",
title: "Version 2026.8.2",
summary: "Dieses Release stabilisiert die Darstellung komplexer Verläufe und verbessert die Veröffentlichung neuer Gitty-Versionen.",
steps: [
"Branch-Farben bleiben über Eltern-Lanes hinweg stabil, sodass sich Linien in längeren und verzweigten Historien leichter verfolgen lassen.",
"Release-Artefakte werden automatisch und ohne doppelte Dateien an das passende Gitea-Release angehängt.",
"Beim Beenden der Anwendung wird die Telemetrie zuverlässiger abgeschlossen.",
],
},
{
id: "changelog-2026-8-1",
title: "Version 2026.8.1",
summary: "Dieses Release macht große Commit-Verläufe und die Historie einzelner Dateien leichter zugänglich und verbessert die Paketverteilung.",
steps: [
"Die Commit-Historie lädt ältere Einträge seitenweise nach und ist nicht mehr auf die erste Ergebnismenge begrenzt.",
"Die Dateihistorie öffnet sich aus dem Explorer-Kontextmenü in einem eigenen, größeren Dialog statt in einem dauerhaft belegten Seitenbereich.",
"Dialoge reagieren konsistenter auf die Escape-Taste.",
"Windows- und Ubuntu-Releases sowie der AUR-Paketablauf wurden erweitert und robuster gemacht.",
"Die Arch-Linux-Anleitung verwendet jetzt das AUR-Paket gitty-desktop; SSH-Einrichtung, Zeitlimits und Wiederholungsversuche wurden verbessert.",
],
},
{
id: "changelog-2026-07-22",
title: "Version 2026.07.22",
summary: "Dieses Release erweitert Gitty um eine AI-gestützte Aufteilung gestagter Änderungen in logisch getrennte Commits.",
steps: [
"AI-Commit-Aufteilung: Der gestagte Diff wird analysiert und als geordneter Plan aus mehreren logisch zusammengehörenden Commits vorgeschlagen.",
"Für jede Gruppe wird automatisch eine editierbare Conventional-Commit-Nachricht erzeugt.",
"Dateien können vor dem Commit zwischen den vorgeschlagenen Gruppen verschoben werden.",
"Mit „Commit all“ werden alle bestätigten Gruppen sicher und der Reihe nach committed.",
"Der Dialog erklärt leere Gruppen oder fehlende Nachrichten und schützt vor einem zwischenzeitlich veränderten Staging-Bereich.",
"„Commit all“ reagiert wieder zuverlässig und bricht nicht mehr beim Kopieren des reaktiven Dialogzustands ab.",
"Ein geschlossenes aktives Repository kann nicht mehr durch einen verspäteten Status- oder Fetch-Request erneut geöffnet werden.",
],
note: "Die AI-Commit-Aufteilung unterstützt OpenAI, Anthropic und eigene OpenAI-kompatible Endpunkte. In Paketdateien erscheint diese Version als 2026.7.22.",
},
{
id: "changelog-2026-07-21",
title: "Version 2026.07.21",
summary: "Dieses Release bündelt paralleles Arbeiten mit Worktrees, präzisere Commits und die neue Arch-Linux-Verteilung.",
steps: [
"Worktree-Verwaltung: zusätzliche Arbeitsordner erstellen, öffnen, verschieben, sperren, entsperren, reparieren, entfernen und veraltete Registrierungen aufräumen.",
"Worktrees sind direkt über den neuen Reiter unter Tags erreichbar; Branches lassen sich außerdem aus ihrem Kontextmenü in einem Worktree öffnen.",
"Zeilenweises Staging: einzelne Ergänzungen und Löschungen auswählen, per Shift-Klick Bereiche markieren sowie ausgewählte Zeilen stagen, unstagen oder verwerfen.",
"Sicherere Dialoge: verständlichere Branch-Löschabfrage und weichgezeichneter Hintergrund bei geöffneten Dialogen.",
"Arch-Linux-Pakete: automatisierter Build aus dem PKGBUILD, Veröffentlichung von .pkg.tar.zst und Repository-Datenbank für Pacman auf dem CDN.",
"Robustere Remote-Aktionen, zentrale Fehlermeldungen und strukturierte, datensparsame Telemetrie.",
"Erweiterte zweisprachige Hilfe mit Worktree-, Pacman- und Line-Staging-Anleitungen.",
],
note: "In Paketdateien kann dieselbe Version als 2026.7.21 erscheinen, weil Paketmanager numerische Versionssegmente ohne führende Null verwenden.",
},
{
id: "changelog-2026-7-20",
title: "Version 2026.7.20",
summary: "Der letzte veröffentlichte Stand konzentrierte sich auf produktiveres Arbeiten, bessere Orientierung und einen stabileren Paket-Build.",
steps: [
"Vor dem Commit kann eine AI-gestützte Codeprüfung den Staged-Diff analysieren.",
"Automatische Aktualisierung hält Repository-Status und Arbeitsbereich auf Wunsch aktuell.",
"Repository-Aktionsleiste, Statusdarstellung, Tabs und Diff-Ansicht wurden übersichtlicher gestaltet.",
"Die integrierte Hilfe wurde um ausführliche deutsche Git-Dokumentation ergänzt.",
"PKGBUILD und Build-Skripte wurden für die Arch-Linux-Verteilung vorbereitet.",
],
},
],
});
enCategories.splice(1, 0, {
id: "changelog",
label: "What's new",
description: "Changes since the latest published version and notable additions from earlier releases.",
sections: [
{
id: "changelog-2026-8-3",
title: "Version 2026.8.3",
summary: "This release connects Gitty more closely with your development tools and makes branches, history, and comparisons substantially more capable.",
steps: [
"External tools: editors, diff tools, merge tools, terminals, and file managers can be detected, selected, and customized in the redesigned settings.",
"VS Code, JetBrains IDEs, Beyond Compare, and other supported applications open in a separate window; documented result codes are handled correctly when they close.",
"Git Notes: attach local notes to commits without rewriting history. Notes can be edited, deleted, fetched from a remote, or pushed explicitly.",
"Complete branch comparison: choose local branches, remote branches, or commits and inspect every changed file in a side-by-side diff.",
"Remote branches can be renamed safely from the context menu. Gitty protects existing destination branches and remote branches that changed after the last fetch.",
"The redesigned commit graph presents branches more compactly, reduces crowded commit rows, and reveals additional flag details on hover.",
"Unpublished branches are clearly marked as Local only in the toolbar, repository summary, status bar, and on the graph flag. Their first push is labeled Publish.",
"Branch visibility controls, refined graph connectors, and a smaller minimum history width improve navigation in large repositories.",
"The new command palette opens actions, files, and commits faster; asynchronous Git commands keep Gitty responsive during slower operations.",
],
note: "Branch comparison uses the fully committed state at each branch tip. Uncommitted working-tree changes are not included.",
},
{
id: "changelog-2026-8-2",
title: "Version 2026.8.2",
summary: "This release stabilizes complex history rendering and improves publication of new Gitty versions.",
steps: [
"Branch colors remain stable across parent lanes, making longer and branching histories easier to follow.",
"Release artifacts are attached to the matching Gitea release automatically without uploading duplicates.",
"Telemetry cleanup completes more reliably while the application is shutting down.",
],
},
{
id: "changelog-2026-8-1",
title: "Version 2026.8.1",
summary: "This release makes large commit histories and individual file histories easier to access and improves package distribution.",
steps: [
"Commit history loads older entries page by page instead of stopping after the initial result set.",
"File history opens from the explorer context menu in a dedicated larger dialog instead of occupying a permanent workspace panel.",
"Dialogs respond more consistently to the Escape key.",
"Windows and Ubuntu publishing plus the AUR package workflow were expanded and made more robust.",
"The Arch Linux guide now uses the gitty-desktop AUR package; SSH setup, timeouts, and retry handling were improved.",
],
},
{
id: "changelog-2026-07-22",
title: "Version 2026.07.22",
summary: "This release adds AI-assisted splitting of staged changes into separate logical commits.",
steps: [
"AI commit splitting analyzes the staged diff and proposes an ordered plan of logically related commits.",
"Every group receives an automatically generated, editable Conventional Commit message.",
"Files can be moved between proposed groups before committing.",
"Commit all safely creates every accepted group in sequence.",
"The dialog explains empty groups or missing messages and protects against a staging area that changed after planning.",
"Commit all now responds reliably instead of failing while copying reactive dialog state.",
"Closing the active repository no longer lets a delayed status or fetch request reopen the closed tab.",
],
note: "AI commit splitting supports OpenAI, Anthropic, and custom OpenAI-compatible endpoints. Package metadata represents this version as 2026.7.22.",
},
{
id: "changelog-2026-07-21",
title: "Version 2026.07.21",
summary: "This release combines parallel worktree workflows, more precise commits, and the new Arch Linux distribution.",
steps: [
"Worktree management: create, open, move, lock, unlock, repair, remove, and prune additional working folders.",
"Worktrees are available from the new entry below Tags; branches can also be opened in a worktree from their context menu.",
"Line-level staging: select additions and deletions, Shift-click ranges, and stage, unstage, or discard selected lines.",
"Safer dialogs: a clearer branch deletion confirmation and a blurred background while dialogs are open.",
"Arch Linux packages: automated PKGBUILD builds plus publication of .pkg.tar.zst and the Pacman repository database to the CDN.",
"More robust remote operations, centralized error messages, and structured privacy-conscious telemetry.",
"Expanded bilingual help for worktrees, Pacman installation, and line-level staging.",
],
note: "Package metadata may represent the same release as 2026.7.21 because package managers use numeric version segments without leading zeroes.",
},
{
id: "changelog-2026-7-20",
title: "Version 2026.7.20",
summary: "The latest published version focused on productivity, clearer navigation, and a more stable package build.",
steps: [
"AI-assisted pre-commit review can analyze the staged diff before committing.",
"Optional automatic refresh keeps repository status and the workspace current.",
"The repository action bar, status presentation, tabs, and diff view became easier to scan.",
"The built-in help gained comprehensive German Git documentation.",
"PKGBUILD and build scripts prepared the Arch Linux distribution workflow.",
],
},
],
});
let { language = "en", onClose = () => {} }: Props = $props();
const isGerman = $derived(language === "de");
const categories = $derived(isGerman ? deCategories : enCategories);
@@ -1297,6 +1638,7 @@
>
<span class="help-nav-icon">
{#if category.id === "start"}<Home size={17} aria-hidden="true" />
{:else if category.id === "changelog"}<Sparkles size={17} aria-hidden="true" />
{:else if category.id === "app"}<BookOpen size={17} aria-hidden="true" />
{:else if category.id === "basics"}<GitCommitHorizontal size={17} aria-hidden="true" />
{:else if category.id === "branches"}<GitBranch size={17} aria-hidden="true" />
+591 -122
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import { Cherry, ChevronDown, ChevronRight, CloudOff, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment {
@@ -22,52 +22,127 @@
graphCommit: GitCommit;
}
type BranchVisibilityMode = "focus" | "local" | "all" | "custom";
type CommitRefKind = "local" | "remote" | "head";
interface CommitBranchDecoration {
label: string;
kind: CommitRefKind;
current: boolean;
trackedRemote: string;
localOnly: boolean;
representedBranches: string[];
}
interface CommitRefSummary {
branches: CommitBranchDecoration[];
tags: string[];
other: string[];
primaryBranch: CommitBranchDecoration | null;
primaryTag: string;
overflowCount: number;
}
const GRAPH_COLORS = [
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
];
const GRAPH_LANE = 18;
const GRAPH_REF_ARM = 16;
const GRAPH_VISIBILITY_STORAGE_PREFIX = "gitlite.graphVisibility.v1:";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
interface Props {
commits: GitCommit[];
localBranchNames: string[];
localBranchUpstreams: Record<string, string>;
remoteBranchNames: string[];
activeBranch: string;
activeUpstream: string;
activeAhead: number;
activeBehind: number;
repositoryKey: string;
hasRepository: boolean;
isBusy: boolean;
hasMore: boolean;
isLoadingMore: boolean;
loadMoreError: string;
expandedCommitHashes: Set<string>;
selectedCommitHash: string;
onLoadMore: () => void | Promise<void>;
onRestoreCommit: (commit: GitCommit) => void;
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
onToggleCommitFiles: (hash: string) => void;
onCreateBranchFromCommit: (commit: GitCommit) => void;
onCherryPickCommit: (commit: GitCommit) => void;
onRevertCommit: (commit: GitCommit) => void;
onOpenCommitNote: (commit: GitCommit) => void;
onLoadCommitNote: (commit: GitCommit) => Promise<string | null>;
onSelectCommit: (commit: GitCommit) => void;
}
let {
commits = [],
localBranchNames = [],
localBranchUpstreams = {},
remoteBranchNames = [],
activeBranch = "",
activeUpstream = "",
activeAhead = 0,
activeBehind = 0,
repositoryKey = "",
hasRepository = false,
isBusy = false,
hasMore = false,
isLoadingMore = false,
loadMoreError = "",
expandedCommitHashes = new Set(),
selectedCommitHash = "",
onLoadMore = () => {},
onRestoreCommit = () => {},
onPreviewCommitFile = () => {},
onToggleCommitFiles = () => {},
onCreateBranchFromCommit = () => {},
onCherryPickCommit = () => {},
onRevertCommit = () => {},
onOpenCommitNote = () => {},
onLoadCommitNote = async () => null,
onSelectCommit = () => {},
}: Props = $props();
let hiddenGraphBranches = $state<Set<string>>(new Set());
let branchVisibilityMode = $state<BranchVisibilityMode>("focus");
let customVisibleBranches = $state<Set<string>>(new Set());
let loadedVisibilityRepository = $state("");
let branchDialogOpen = $state(false);
let userAdjustedBranchFilter = $state(false);
let lastDefaultFilterKey = $state("");
let expandedRefsCommitHash = $state("");
let panelElement = $state<HTMLElement | null>(null);
let contextCommit = $state<GitCommit | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let notePreviews = $state<Record<string, string>>({});
let notePreviewLoading = $state<Set<string>>(new Set());
let notePreviewErrors = $state<Set<string>>(new Set());
$effect(() => {
repositoryKey;
notePreviews = {};
notePreviewLoading = new Set();
notePreviewErrors = new Set();
});
function observeHistoryEnd(node: HTMLElement) {
const root = node.closest<HTMLElement>(".history-list");
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting) && hasMore && !isLoadingMore && !loadMoreError && !isBusy) {
void onLoadMore();
}
}, { root, rootMargin: "240px 0px" });
observer.observe(node);
return { destroy: () => observer.disconnect() };
}
function laneColor(col: number): string {
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
@@ -115,15 +190,18 @@
const currentBranches = branchMembership.get(commit.hash) ?? localBranchRefs(commit);
const commitBranches = uniqueStrings([...(beforeBranches[col] ?? []), ...currentBranches]);
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
afterBranches[col] = after[col] ? commitBranches.slice() : [];
const firstParent = commit.parents[0] ?? null;
after[col] = firstParent;
afterBranches[col] = firstParent
? (branchMembership.get(firstParent) ?? commitBranches).slice()
: [];
const fromCommit = new Set<number>([col]);
for (let p = 1; p < commit.parents.length; p++) {
let slot = after.indexOf(null);
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
after[slot] = commit.parents[p];
afterBranches[slot] = [];
afterBranches[slot] = (branchMembership.get(commit.parents[p]) ?? commitBranches).slice();
fromCommit.add(slot);
}
@@ -198,39 +276,40 @@
}
let localBranchNameSet = $derived(new Set(localBranchNames));
let remoteBranchNameSet = $derived(new Set(remoteBranchNames));
function uniqueStrings(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, activeUpstream].filter(Boolean)));
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, ...remoteBranchNames, activeUpstream].filter(Boolean)));
let graphBranchNameSet = $derived(new Set(graphBranchNames));
function focusBranchNames(): string[] {
const focused = uniqueStrings([activeBranch, activeUpstream].filter((branch) => graphBranchNameSet.has(branch)));
if (focused.length > 0) return focused;
return localBranchNames[0] ? [localBranchNames[0]] : graphBranchNames.slice(0, 1);
}
function branchNamesForMode(): string[] {
if (branchVisibilityMode === "focus") return focusBranchNames();
if (branchVisibilityMode === "local") return localBranchNames;
if (branchVisibilityMode === "all") return graphBranchNames;
return graphBranchNames.filter((branch) => customVisibleBranches.has(branch));
}
let visibleGraphBranchNames = $derived(branchNamesForMode());
let visibleGraphBranchNameSet = $derived(new Set(visibleGraphBranchNames));
function branchIsVisible(branch: string): boolean {
if (branch === activeUpstream && activeUpstream) {
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
}
return !hiddenGraphBranches.has(branch);
}
function visibleBranchLabels(labels: string[]): string[] {
return labels.filter(branchIsVisible);
}
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
const directBranches = graphBranchRefs(commit);
if (directBranches.length > 0) return directBranches;
const containingBranches = row?.branchLabels ?? [];
if (containingBranches.length <= 3) return containingBranches;
return [...containingBranches.slice(0, 3), `+${containingBranches.length - 3} more`];
return visibleGraphBranchNameSet.has(branch);
}
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
const directBranches = graphBranchRefs(commit);
const directBranches = graphBranchRefs(commit).filter(branchIsVisible);
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
const containingBranches = row?.branchLabels ?? [];
const containingBranches = (row?.branchLabels ?? []).filter(branchIsVisible);
if (containingBranches.length === 0) return commit.short_hash;
return `Branches containing this commit: ${containingBranches.join(", ")}`;
}
@@ -250,7 +329,7 @@
function branchesAreVisible(branches: string[]): boolean {
if (graphBranchNames.length === 0) return true;
return branches.some(branchIsVisible);
return branches.some((branch) => visibleGraphBranchNameSet.has(branch));
}
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
@@ -327,20 +406,25 @@
}
function toggleGraphBranch(branch: string) {
const next = new Set(hiddenGraphBranches);
const next = branchVisibilityMode === "custom"
? new Set(customVisibleBranches)
: new Set(visibleGraphBranchNames);
if (next.has(branch)) next.delete(branch); else next.add(branch);
hiddenGraphBranches = next;
userAdjustedBranchFilter = true;
customVisibleBranches = next;
branchVisibilityMode = "custom";
}
function showAllGraphBranches() {
hiddenGraphBranches = new Set();
userAdjustedBranchFilter = true;
branchVisibilityMode = "all";
}
function hideAllGraphBranches() {
hiddenGraphBranches = new Set(localBranchNames);
userAdjustedBranchFilter = true;
customVisibleBranches = new Set();
branchVisibilityMode = "custom";
}
function showFocusGraphBranches() {
branchVisibilityMode = "focus";
}
function openBranchDialog() {
@@ -357,6 +441,10 @@
}
}
function toggleCommitRefs(commit: GitCommit) {
expandedRefsCommitHash = expandedRefsCommitHash === commit.hash ? "" : commit.hash;
}
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
event.preventDefault();
event.stopPropagation();
@@ -406,9 +494,59 @@
await onCherryPickCommit(commit);
}
async function revertContextCommit() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
await onRevertCommit(commit);
}
async function openContextCommitNote() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
onSelectCommit(commit);
await onOpenCommitNote(commit);
}
async function openCommitNote(commit: GitCommit) {
if (isBusy) return;
onSelectCommit(commit);
await onOpenCommitNote(commit);
}
async function loadCommitNotePreview(commit: GitCommit) {
if (!commit.has_note || notePreviewLoading.has(commit.hash)) return;
const loading = new Set(notePreviewLoading);
loading.add(commit.hash);
notePreviewLoading = loading;
const errors = new Set(notePreviewErrors);
errors.delete(commit.hash);
notePreviewErrors = errors;
try {
const note = await onLoadCommitNote(commit);
notePreviews = {
...notePreviews,
[commit.hash]: note?.trim() || "This Git note is empty.",
};
} catch {
const nextErrors = new Set(notePreviewErrors);
nextErrors.add(commit.hash);
notePreviewErrors = nextErrors;
} finally {
const nextLoading = new Set(notePreviewLoading);
nextLoading.delete(commit.hash);
notePreviewLoading = nextLoading;
}
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
closeCommitContextMenu();
expandedRefsCommitHash = "";
handleBranchDialogKeydown(event);
}
@@ -436,66 +574,217 @@
return labels;
}
function visibleRefs(commit: GitCommit): string[] {
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
}
function refClass(ref: string): string {
if (ref.startsWith("HEAD")) return "head";
if (ref.startsWith("tag:")) return "tag";
if (localBranchNameSet.has(refLabel(ref))) return "branch";
if (ref.includes("/")) return "remote";
return "branch";
}
function refLabel(ref: string): string {
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
}
function isSymbolicRemoteHead(ref: string): boolean {
if (ref.startsWith("HEAD ->") || ref.startsWith("tag:")) return false;
const label = refLabel(ref);
return !localBranchNameSet.has(label) && /(?:^|\/)HEAD(?:\s*->|$)/.test(ref);
}
function configuredUpstreamForBranch(local: string): string {
return localBranchUpstreams[local] ?? (local === activeBranch ? activeUpstream : "");
}
function matchingRemoteBranch(local: string, remoteBranches: string[]): string {
const configuredUpstream = configuredUpstreamForBranch(local);
return configuredUpstream && remoteBranches.includes(configuredUpstream) ? configuredUpstream : "";
}
function compareBranchDecorations(left: CommitBranchDecoration, right: CommitBranchDecoration): number {
const priority = (item: CommitBranchDecoration) => {
if (item.label === activeBranch) return 0;
if (item.kind === "head") return 1;
if (item.kind === "local") return 2;
if (item.label === activeUpstream) return 3;
return 4;
};
return priority(left) - priority(right) || left.label.localeCompare(right.label, undefined, { numeric: true });
}
function commitRefSummary(commit: GitCommit): CommitRefSummary {
const local = new Set<string>();
const remote = new Set<string>();
const tags = new Set<string>();
const other = new Set<string>();
let detachedHead = false;
for (const ref of commit.refs) {
if (isSymbolicRemoteHead(ref)) continue;
const label = refLabel(ref);
if (!label) continue;
if (ref.startsWith("tag:")) {
tags.add(label);
} else if (localBranchNameSet.has(label)) {
local.add(label);
} else if (remoteBranchNameSet.has(label) || label === activeUpstream) {
remote.add(label);
} else if (label === "HEAD") {
detachedHead = true;
} else {
other.add(label);
}
}
const remainingRemote = [...remote];
const branches: CommitBranchDecoration[] = [...local].map((label) => {
const configuredUpstream = configuredUpstreamForBranch(label);
const trackedRemote = matchingRemoteBranch(label, remainingRemote);
if (trackedRemote) remainingRemote.splice(remainingRemote.indexOf(trackedRemote), 1);
return {
label,
kind: "local",
current: label === activeBranch,
trackedRemote,
localOnly: !configuredUpstream,
representedBranches: trackedRemote ? [label, trackedRemote] : [label],
};
});
if (detachedHead) {
branches.push({
label: "HEAD",
kind: "head",
current: true,
trackedRemote: "",
localOnly: false,
representedBranches: [],
});
}
branches.push(...remainingRemote.map((label) => ({
label,
kind: "remote" as const,
current: false,
trackedRemote: "",
localOnly: false,
representedBranches: [label],
})));
const sortedBranches = branches.sort(compareBranchDecorations);
const primaryBranch = sortedBranches.find(
(branch) => branch.kind === "head" || branch.representedBranches.some(branchIsVisible),
) ?? null;
const sortedTags = [...tags].sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
const sortedOther = [...other].sort((left, right) => left.localeCompare(right));
const primaryTag = sortedTags[0] ?? "";
return {
branches: sortedBranches,
tags: sortedTags,
other: sortedOther,
primaryBranch,
primaryTag,
overflowCount:
Math.max(0, sortedBranches.length - (primaryBranch ? 1 : 0))
+ Math.max(0, sortedTags.length - (primaryTag ? 1 : 0))
+ sortedOther.length,
};
}
function branchStatusLabel(branch: CommitBranchDecoration): string {
if (branch.label === activeBranch) {
const parts = [];
if (activeAhead > 0) parts.push(`↑${activeAhead}`);
if (activeBehind > 0) parts.push(`↓${activeBehind}`);
return parts.join(" ");
}
if (branch.label === activeUpstream && activeBehind > 0) return `↓${activeBehind}`;
return "";
}
function branchDecorationTitle(branch: CommitBranchDecoration): string {
if (branch.localOnly) {
const status = branchStatusLabel(branch);
return `${branch.label} · Local only — not published yet${status ? ` · ${status}` : ""}`;
}
const status = branchStatusLabel(branch);
if (branch.trackedRemote) {
return `${branch.label} · Tracks ${branch.trackedRemote}${status ? ` · ${status}` : ""}`;
}
if (status) return `${branch.label} · ${status}`;
return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`;
}
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
return commitDateFormatter.format(date);
}
$effect(() => {
const available = new Set(localBranchNames);
const nextHidden = new Set([...hiddenGraphBranches].filter((branch) => available.has(branch)));
if (nextHidden.size !== hiddenGraphBranches.size) {
hiddenGraphBranches = nextHidden;
const currentRepository = repositoryKey;
if (loadedVisibilityRepository === currentRepository) return;
loadedVisibilityRepository = currentRepository;
expandedRefsCommitHash = "";
if (!currentRepository) {
branchVisibilityMode = "focus";
customVisibleBranches = new Set();
return;
}
try {
const stored = localStorage.getItem(`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`);
if (!stored) {
branchVisibilityMode = "focus";
customVisibleBranches = new Set();
return;
}
const parsed = JSON.parse(stored) as { mode?: unknown; branches?: unknown };
const mode = parsed.mode;
branchVisibilityMode = mode === "focus" || mode === "local" || mode === "all" || mode === "custom"
? mode
: "focus";
customVisibleBranches = new Set(
Array.isArray(parsed.branches)
? parsed.branches.filter((branch): branch is string => typeof branch === "string")
: [],
);
} catch {
branchVisibilityMode = "focus";
customVisibleBranches = new Set();
}
});
$effect(() => {
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
? activeBranch
: (graphBranchNames[0] ?? "");
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
if (!defaultBranch) {
if (lastDefaultFilterKey !== defaultFilterKey) {
hiddenGraphBranches = new Set();
userAdjustedBranchFilter = false;
lastDefaultFilterKey = defaultFilterKey;
}
return;
const currentRepository = repositoryKey;
const mode = branchVisibilityMode;
const branches = [...customVisibleBranches];
if (!currentRepository || loadedVisibilityRepository !== currentRepository) return;
try {
localStorage.setItem(
`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`,
JSON.stringify({ mode, branches }),
);
} catch {
// The graph still works if storage is unavailable.
}
});
if (lastDefaultFilterKey !== defaultFilterKey) {
userAdjustedBranchFilter = false;
lastDefaultFilterKey = defaultFilterKey;
}
$effect(() => {
if (graphBranchNames.length === 0 || customVisibleBranches.size === 0) return;
const available = new Set(graphBranchNames);
const next = new Set([...customVisibleBranches].filter((branch) => available.has(branch)));
if (next.size !== customVisibleBranches.size) customVisibleBranches = next;
});
if (!userAdjustedBranchFilter) {
hiddenGraphBranches = new Set(localBranchNames.filter((branch) => branch !== defaultBranch));
}
$effect(() => {
if (!selectedCommitHash) return;
queueMicrotask(() => {
panelElement
?.querySelector<HTMLElement>(`[data-commit-hash="${CSS.escape(selectedCommitHash)}"]`)
?.scrollIntoView({ block: "center", behavior: "smooth" });
});
});
let branchMembership = $derived(branchMembershipByHash(commits));
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
let visibleBranchCount = $derived(localBranchNames.filter(branchIsVisible).length);
let visibleBranchCount = $derived(visibleGraphBranchNames.length);
let graph = $derived(computeGraph(graphCommits, branchMembership));
let graphRows = $derived(graph.rows);
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
@@ -509,41 +798,42 @@
<span class="eyebrow">History</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
</div>
<div class="section-head-actions">
{#if localBranchNames.length > 0}
{#if graphBranchNames.length > 0}
<div class="section-head-actions">
<button
class="graph-branch-dialog-button"
type="button"
onclick={openBranchDialog}
title="Select branches shown in the graph"
title="Customize visible branches"
aria-label={`${visibleBranchCount} of ${graphBranchNames.length} branches visible. Customize branches.`}
>
<GitBranch size={13} aria-hidden="true" />
Branches
<span>{visibleBranchCount}/{localBranchNames.length}</span>
<span>{visibleBranchCount}/{graphBranchNames.length}</span>
</button>
{/if}
<span class="pill pill-count" title={visibleCommits.length === commits.length ? "Commits" : `${visibleCommits.length} of ${commits.length} commits shown`}>
{visibleCommits.length}
</span>
</div>
</div>
{/if}
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else if commits.length === 0}
<div class="blank-state">No commits returned.</div>
{:else if visibleCommits.length === 0}
<div class="blank-state">No commits match the selected branches.</div>
{:else}
<div class="history-list graph-list overflow-auto">
{#if visibleCommits.length === 0}
<div class="blank-state">No loaded commits match the selected branches.</div>
{/if}
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
{@const item = entry.commit}
{@const row = graphRows[rowIndex]}
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
{@const otherRefs = visibleRefs(item)}
{@const refSummary = commitRefSummary(item)}
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
<article
class="commit-row graph-row"
class:selected={selectedCommitHash === item.hash}
class:has-note={item.has_note}
data-commit-hash={item.hash}
class:graph-ahead-row={rowSyncClass === "ahead"}
class:graph-behind-row={rowSyncClass === "behind"}
class:merge-row={item.parents.length > 1}
@@ -575,6 +865,15 @@
vector-effect="non-scaling-stroke"
/>
{/each}
{#if refSummary.primaryBranch}
<path
class="graph-ref-connector"
d={`M ${graphColX(row.dotCol)} 50 L ${graphWidth - GRAPH_REF_ARM * 2} 50`}
stroke={row.dotColor}
stroke-width="1.5"
vector-effect="non-scaling-stroke"
/>
{/if}
</svg>
<span
class="graph-dot"
@@ -586,25 +885,114 @@
title={commitHoverTitle(item, row)}
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
></span>
{#if hoverBranchRefs.length > 0}
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
{#each hoverBranchRefs as branch}
<span
class:remote={branch === activeUpstream}
class:ahead={activeBranch === branch && rowSyncClass === "ahead"}
class:behind={activeUpstream === branch && rowSyncClass === "behind"}
title={branch}
>
<GitBranch size={10} aria-hidden="true" />
{branch}
</span>
{/each}
</div>
{/if}
{/if}
</div>
<div class="commit-body">
<div
class="commit-body"
class:has-branch-ref={Boolean(refSummary.primaryBranch)}
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`}
>
{#if item.has_note}
<span class="commit-note-rail" aria-hidden="true"></span>
{/if}
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
<div class="commit-ref-area">
<div class="commit-ref-strip" aria-label="Commit references">
{#if refSummary.primaryBranch}
<span class="branch-ref-cluster" class:local-only={refSummary.primaryBranch.localOnly}>
<span
class="compact-ref-chip branch"
class:current={refSummary.primaryBranch.current}
class:remote={refSummary.primaryBranch.kind === "remote"}
title={branchDecorationTitle(refSummary.primaryBranch)}
>
<GitBranch class="compact-ref-branch-icon" size={10} aria-hidden="true" />
<span>{refSummary.primaryBranch.label}</span>
{#if !refSummary.primaryBranch.localOnly && branchStatusLabel(refSummary.primaryBranch)}
<small>{branchStatusLabel(refSummary.primaryBranch)}</small>
{/if}
</span>
{#if refSummary.primaryBranch.localOnly}
<span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet">
LOCAL
</span>
{/if}
</span>
{/if}
{#if refSummary.primaryTag}
<span class="compact-ref-chip tag" title={`Tag ${refSummary.primaryTag}`}>
<Tag size={10} aria-hidden="true" />
<span>{refSummary.primaryTag}</span>
</span>
{/if}
{#if refSummary.overflowCount > 0}
<button
class="compact-ref-overflow"
type="button"
onclick={() => toggleCommitRefs(item)}
aria-expanded={expandedRefsCommitHash === item.hash}
aria-controls={`commit-refs-${item.hash}`}
title={`Show ${refSummary.overflowCount} more ${refSummary.overflowCount === 1 ? "reference" : "references"}`}
>
+{refSummary.overflowCount}
</button>
{/if}
</div>
{#if refSummary.overflowCount > 0 && expandedRefsCommitHash === item.hash}
<div class="commit-ref-details" id={`commit-refs-${item.hash}`}>
<strong>References on this commit</strong>
{#if refSummary.branches.some((branch) => branch.kind !== "remote")}
<section>
<span>Local</span>
<div>
{#each refSummary.branches.filter((branch) => branch.kind !== "remote") as branch}
<span class="commit-ref-detail-item local" title={branchDecorationTitle(branch)}>
<i aria-hidden="true"></i>{branch.label}
{#if branch.current}<small>Current</small>{/if}
{#if branch.trackedRemote}<small>{branch.trackedRemote}</small>{/if}
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />Local only</small>{/if}
</span>
{/each}
</div>
</section>
{/if}
{#if refSummary.branches.some((branch) => branch.kind === "remote")}
<section>
<span>Remote</span>
<div>
{#each refSummary.branches.filter((branch) => branch.kind === "remote") as branch}
<span class="commit-ref-detail-item remote"><i aria-hidden="true"></i>{branch.label}</span>
{/each}
</div>
</section>
{/if}
{#if refSummary.tags.length > 0}
<section>
<span>Tags</span>
<div>
{#each refSummary.tags as tag}
<span class="commit-ref-detail-item tag"><Tag size={10} aria-hidden="true" />{tag}</span>
{/each}
</div>
</section>
{/if}
{#if refSummary.other.length > 0}
<section>
<span>Other</span>
<div>
{#each refSummary.other as ref}
<span class="commit-ref-detail-item">{ref}</span>
{/each}
</div>
</section>
{/if}
</div>
{/if}
</div>
{/if}
<div class="commit-card-head">
<span class="commit-avatar">
{authorInitials(item.author_name)}
@@ -622,18 +1010,47 @@
<div class="commit-meta-line">
<span class="commit-hash">{item.short_hash}</span>
<span class="commit-author" title={item.author_email}>{item.author_name}</span>
{#if item.has_note}
<span class="commit-note-indicator">
<button
class="commit-note-presence"
type="button"
onpointerenter={() => void loadCommitNotePreview(item)}
onfocus={() => void loadCommitNotePreview(item)}
onclick={() => openCommitNote(item)}
disabled={isBusy}
aria-label={`Open Git note for ${item.short_hash}`}
aria-describedby={`commit-note-preview-${item.hash}`}
>
<StickyNote size={11} aria-hidden="true" />
<span>Note</span>
</button>
<span
class="commit-note-tooltip"
id={`commit-note-preview-${item.hash}`}
role="tooltip"
>
<span class="commit-note-tooltip-head">
<StickyNote size={12} aria-hidden="true" />
Git Note
<small>Click to open</small>
</span>
<span class="commit-note-tooltip-body">
{#if notePreviewLoading.has(item.hash)}
Loading note…
{:else if notePreviewErrors.has(item.hash)}
Note could not be loaded.
{:else}
{notePreviews[item.hash] ?? "Hover to load the note."}
{/if}
</span>
</span>
</span>
{/if}
</div>
</div>
</div>
{#if otherRefs.length > 0}
<div class="ref-list" aria-label="Commit refs">
{#each otherRefs as ref}
<span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
{/each}
</div>
{/if}
{#if item.files.length > 0}
<div class="commit-files">
<button
@@ -672,6 +1089,18 @@
<div class="commit-actions">
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons">
{#if !item.has_note}
<button
class="commit-menu-button commit-note-button"
type="button"
onclick={() => openCommitNote(item)}
disabled={isBusy}
title={`Add a Git note to ${item.short_hash}`}
aria-label={`Add a Git note to ${item.short_hash}`}
>
<StickyNote size={14} aria-hidden="true" />
</button>
{/if}
<button
class="commit-menu-button"
type="button"
@@ -689,6 +1118,21 @@
</div>
</article>
{/each}
{#if hasMore || isLoadingMore || loadMoreError}
<div class="history-load-more" use:observeHistoryEnd aria-live="polite">
{#if isLoadingMore}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
<span>Loading older commits…</span>
{:else if loadMoreError}
<span title={loadMoreError}>Older commits could not be loaded.</span>
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>Retry</button>
{:else}
<button type="button" class="history-load-more-button" onclick={() => { void onLoadMore(); }} disabled={isBusy}>
Load older commits
</button>
{/if}
</div>
{/if}
</div>
{/if}
@@ -704,6 +1148,10 @@
<GitBranch size={14} aria-hidden="true" />
Branch
</button>
<button type="button" role="menuitem" onclick={openContextCommitNote} disabled={isBusy}>
<StickyNote size={14} aria-hidden="true" />
Note
</button>
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
<RotateCcw size={14} aria-hidden="true" />
Restore
@@ -718,6 +1166,9 @@
<Cherry size={14} aria-hidden="true" />
Cherry-pick
</button>
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title="Create a new commit that reverses this commit">
<RotateCcw size={14} aria-hidden="true" /> Revert
</button>
</div>
{/if}
</section>
@@ -741,25 +1192,43 @@
</header>
<div class="branch-filter-summary">
<span>{visibleBranchCount} of {localBranchNames.length} branches selected</span>
<span>{visibleBranchCount} of {graphBranchNames.length} branches selected</span>
<div class="branch-filter-actions">
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === localBranchNames.length}>Show all</button>
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>Focus</button>
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>Show all</button>
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
</div>
</div>
<div class="branch-filter-dialog-list">
{#each localBranchNames as branch}
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
<input
type="checkbox"
checked={branchIsVisible(branch)}
onchange={() => toggleGraphBranch(branch)}
/>
<GitBranch size={14} aria-hidden="true" />
<span>{branch}</span>
</label>
{/each}
{#if localBranchNames.length > 0}
<span class="branch-filter-group-label">Local</span>
{#each localBranchNames as branch}
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
<input
type="checkbox"
checked={branchIsVisible(branch)}
onchange={() => toggleGraphBranch(branch)}
/>
<GitBranch size={14} aria-hidden="true" />
<span>{branch}</span>
</label>
{/each}
{/if}
{#if remoteBranchNames.length > 0}
<span class="branch-filter-group-label">Remote</span>
{#each remoteBranchNames as branch}
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option remote" title={branch}>
<input
type="checkbox"
checked={branchIsVisible(branch)}
onchange={() => toggleGraphBranch(branch)}
/>
<GitBranch size={14} aria-hidden="true" />
<span>{branch}</span>
</label>
{/each}
{/if}
</div>
</div>
</div>
+244 -51
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { LoaderCircle, X } from "@lucide/svelte";
import { Check, ExternalLink, FileDiff, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
import type { GitFileStatus, PatchApplyAction } from "../types";
type PatchLineKind = "context" | "add" | "delete" | "meta";
@@ -8,12 +8,17 @@
id: string;
text: string;
kind: PatchLineKind;
oldLine: number | null;
newLine: number | null;
}
interface PatchHunk {
id: string;
header: string;
lines: PatchLine[];
oldStart: number;
newStart: number;
suffix: string;
}
interface ParsedPatch {
@@ -29,9 +34,12 @@
isBusy: boolean;
isLoading: boolean;
error: string;
language?: "en" | "de";
diffName?: string;
onClose: () => void;
onRefresh: () => void | Promise<void>;
onApply: (action: PatchApplyAction, patch: string) => void | Promise<void>;
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
onExternalDiff: () => void | Promise<void>;
}
let {
@@ -41,19 +49,32 @@
isBusy = false,
isLoading = false,
error = "",
language = "en",
diffName = "diff tool",
onClose = () => {},
onRefresh = () => {},
onApply = () => {},
onExternalDiff = () => {},
}: Props = $props();
const isGerman = $derived(language === "de");
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
let patchScroll = $state<HTMLDivElement | null>(null);
let selectedLineIds = $state<Set<string>>(new Set());
let lastSelectedLineId = $state("");
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
let selectableLines = $derived(
parsed.hunks.flatMap((hunk) => hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete")),
);
let selectedCount = $derived(selectableLines.filter((line) => selectedLineIds.has(line.id)).length);
$effect(() => {
parsed = parsePatch(patch);
selectedLineIds = new Set();
lastSelectedLineId = "";
});
function parsePatch(input: string): ParsedPatch {
@@ -64,10 +85,22 @@
const headerLines: string[] = [];
const hunks: PatchHunk[] = [];
let current: PatchHunk | null = null;
let oldCursor = 0;
let newCursor = 0;
for (const line of lines) {
if (line.startsWith("@@ ")) {
current = { id: `hunk-${hunks.length}`, header: line, lines: [] };
const range = parseHunkHeader(line);
current = {
id: `hunk-${hunks.length}`,
header: line,
lines: [],
oldStart: range.oldStart,
newStart: range.newStart,
suffix: range.suffix,
};
oldCursor = range.oldStart;
newCursor = range.newStart;
hunks.push(current);
continue;
}
@@ -78,11 +111,17 @@
}
const kind = patchLineKind(line);
const oldLine = kind === "context" || kind === "delete" ? oldCursor : null;
const newLine = kind === "context" || kind === "add" ? newCursor : null;
current.lines.push({
id: `${current.id}-line-${current.lines.length}`,
text: line,
kind,
oldLine,
newLine,
});
if (oldLine !== null) oldCursor += 1;
if (newLine !== null) newCursor += 1;
}
return {
@@ -92,6 +131,15 @@
};
}
function parseHunkHeader(header: string): { oldStart: number; newStart: number; suffix: string } {
const match = header.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
return {
oldStart: Number(match?.[1] ?? 0),
newStart: Number(match?.[2] ?? 0),
suffix: match?.[3] ?? "",
};
}
function patchLineKind(line: string): PatchLineKind {
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
@@ -117,7 +165,91 @@
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
if (isBusy || isLoading) return;
await onApply(action, buildHunkPatch(hunk));
await onApply(action, buildHunkPatch(hunk), "hunk");
}
function toggleLine(event: MouseEvent, line: PatchLine) {
if (isBusy || isLoading || (line.kind !== "add" && line.kind !== "delete")) return;
const next = new Set(selectedLineIds);
const selecting = !next.has(line.id);
if (event.shiftKey && lastSelectedLineId) {
const start = selectableLines.findIndex((candidate) => candidate.id === lastSelectedLineId);
const end = selectableLines.findIndex((candidate) => candidate.id === line.id);
if (start >= 0 && end >= 0) {
for (const candidate of selectableLines.slice(Math.min(start, end), Math.max(start, end) + 1)) {
if (selecting) next.add(candidate.id);
else next.delete(candidate.id);
}
}
} else if (selecting) {
next.add(line.id);
} else {
next.delete(line.id);
}
selectedLineIds = next;
lastSelectedLineId = line.id;
}
function toggleHunkSelection(hunk: PatchHunk) {
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
const allSelected = changed.length > 0 && changed.every((line) => selectedLineIds.has(line.id));
const next = new Set(selectedLineIds);
for (const line of changed) {
if (allSelected) next.delete(line.id);
else next.add(line.id);
}
selectedLineIds = next;
lastSelectedLineId = changed[changed.length - 1]?.id ?? "";
}
function hunkSelectionState(hunk: PatchHunk): "none" | "some" | "all" {
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
const count = changed.filter((line) => selectedLineIds.has(line.id)).length;
return count === 0 ? "none" : count === changed.length ? "all" : "some";
}
function rangePart(start: number, count: number): string {
return count === 1 ? `${start}` : `${start},${count}`;
}
function buildSelectedHunk(hunk: PatchHunk): string | null {
if (!hunk.lines.some((line) => selectedLineIds.has(line.id))) return null;
const output: string[] = [];
let previousIncluded = false;
for (const line of hunk.lines) {
if (line.kind === "context") {
output.push(line.text);
previousIncluded = true;
} else if (line.kind === "delete") {
output.push(selectedLineIds.has(line.id) ? line.text : ` ${line.text.slice(1)}`);
previousIncluded = true;
} else if (line.kind === "add") {
if (selectedLineIds.has(line.id)) {
output.push(line.text);
previousIncluded = true;
} else {
previousIncluded = false;
}
} else if (previousIncluded) {
output.push(line.text);
}
}
const oldCount = output.filter((line) => line.startsWith(" ") || line.startsWith("-")).length;
const newCount = output.filter((line) => line.startsWith(" ") || line.startsWith("+")).length;
const header = `@@ -${rangePart(hunk.oldStart, oldCount)} +${rangePart(hunk.newStart, newCount)} @@${hunk.suffix}`;
return [header, ...output].join("\n");
}
function buildSelectedPatch(): string {
const hunks = parsed.hunks.map(buildSelectedHunk).filter((hunk): hunk is string => Boolean(hunk));
return `${[...parsed.headerLines, ...hunks].join("\n")}\n`;
}
async function applySelected(action: PatchApplyAction) {
if (isBusy || isLoading || selectedCount === 0) return;
await onApply(action, buildSelectedPatch(), "lines");
}
function hunkPosition(index: number): number {
@@ -146,6 +278,15 @@
<p class="dialog-title" title={displayPath}>{displayPath}</p>
</div>
<div class="dialog-header-actions">
<div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}>
<span>{isGerman ? "Öffnen mit" : "Open with"}</span>
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}>
<FileDiff size={13} aria-hidden="true" />Gitty
</button>
<button type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={isGerman ? `In ${diffName} öffnen` : `Open in ${diffName}`}>
<ExternalLink size={13} aria-hidden="true" /><span>{diffName}</span>
</button>
</div>
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
@@ -166,54 +307,106 @@
{: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-workspace">
<div class="line-patch-scroll" bind:this={patchScroll}>
{#each parsed.hunks as hunk (hunk.id)}
<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">
{#if staged}
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
Discard Hunk
</button>
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
Unstage Hunk
</button>
{:else}
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
Discard Hunk
</button>
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
Stage Hunk
</button>
{/if}
</div>
</div>
<div class="line-patch-lines">
{#each hunk.lines as line (line.id)}
<div class={`line-patch-row ${line.kind}`}>
<span class="line-patch-prefix">{linePrefix(line)}</span>
<code>{lineBody(line)}</code>
</div>
{/each}
</div>
</section>
{/each}
<div class:active={selectedCount > 0} class="line-patch-selection-bar">
<div>
<MousePointer2 size={14} aria-hidden="true" />
{#if selectedCount > 0}
<strong>{selectedCount} {selectedCount === 1 ? "line" : "lines"} selected</strong>
<span>Shift-click to select a range.</span>
{:else}
<strong>Select changed lines</strong>
<span>Choose individual additions or deletions below.</span>
{/if}
</div>
{#if selectedCount > 0}
<div class="line-patch-selected-actions">
<button class="line-patch-hunk-button discard" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy}>
Discard selected
</button>
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy}>
{staged ? "Unstage selected" : "Stage selected"}
</button>
<button class="btn-sm" type="button" onclick={() => { selectedLineIds = new Set(); }} disabled={isBusy}>Clear</button>
</div>
{/if}
</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 class="line-patch-workspace">
<div class="line-patch-scroll" bind:this={patchScroll}>
{#each parsed.hunks as hunk (hunk.id)}
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
<div class="line-patch-hunk-head">
<button
class:all={hunkSelectionState(hunk) === "all"}
class:some={hunkSelectionState(hunk) === "some"}
class="line-patch-select-hunk"
type="button"
onclick={() => toggleHunkSelection(hunk)}
aria-label={`Select changed lines in ${hunk.header}`}
aria-pressed={hunkSelectionState(hunk) === "all"}
title="Select all changed lines in this hunk"
>
{#if hunkSelectionState(hunk) !== "none"}<Check size={12} aria-hidden="true" />{/if}
</button>
<code>{hunk.header}</code>
<div class="line-patch-hunk-actions">
{#if staged}
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
Discard Hunk
</button>
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
Unstage Hunk
</button>
{:else}
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
Discard Hunk
</button>
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
Stage Hunk
</button>
{/if}
</div>
</div>
<div class="line-patch-lines">
{#each hunk.lines as line (line.id)}
<div class:selected={selectedLineIds.has(line.id)} class:selectable={line.kind === "add" || line.kind === "delete"} class={`line-patch-row ${line.kind}`}>
{#if line.kind === "add" || line.kind === "delete"}
<button
class="line-patch-line-select"
type="button"
onclick={(event) => toggleLine(event, line)}
aria-label={`${selectedLineIds.has(line.id) ? "Deselect" : "Select"} ${line.kind === "add" ? "added" : "deleted"} line ${line.newLine ?? line.oldLine ?? ""}`}
aria-pressed={selectedLineIds.has(line.id)}
title="Select line (Shift-click for range)"
>
{#if selectedLineIds.has(line.id)}<Check size={11} aria-hidden="true" />{/if}
</button>
{:else}
<span class="line-patch-line-select-placeholder"></span>
{/if}
<span class="line-patch-line-number">{line.oldLine ?? ""}</span>
<span class="line-patch-line-number">{line.newLine ?? ""}</span>
<span class="line-patch-prefix">{linePrefix(line)}</span>
<code>{lineBody(line)}</code>
</div>
{/each}
</div>
</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>
+39 -14
View File
@@ -18,14 +18,29 @@
let name = $state("");
function remoteName(branch: GitBranchInfo): string {
if (!branch.remote) return "";
const slash = branch.name.indexOf("/");
return slash > 0 ? branch.name.slice(0, slash) : branch.name;
}
function editableName(branch: GitBranchInfo): string {
if (!branch.remote) return branch.name;
const slash = branch.name.indexOf("/");
return slash >= 0 ? branch.name.slice(slash + 1) : branch.name;
}
let originalName = $derived(editableName(branch));
let remote = $derived(remoteName(branch));
$effect(() => {
name = branch.name;
name = originalName;
});
function submit(event: SubmitEvent) {
event.preventDefault();
const value = name.trim();
if (!value || value === branch.name) return;
if (!value || value === originalName) return;
onRename(value);
}
</script>
@@ -34,10 +49,10 @@
class="dialog-backdrop"
role="presentation"
>
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label={branch.remote ? "Rename remote branch" : "Rename branch"} tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Rename branch</span>
<span class="eyebrow">{branch.remote ? "Rename remote branch" : "Rename branch"}</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
@@ -48,27 +63,37 @@
<form class="rename-branch-form" onsubmit={submit}>
<label class="new-branch-field">
<span>Branch name</span>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={name}
autocomplete="off"
spellcheck="false"
disabled={isBusy}
autofocus
/>
<div class:remote-branch-name-field={branch.remote}>
{#if branch.remote}<strong>{remote}/</strong>{/if}
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={name}
autocomplete="off"
spellcheck="false"
disabled={isBusy}
autofocus
/>
</div>
</label>
{#if branch.remote}
<p class="rename-remote-note">
Gitty creates <strong>{remote}/{name.trim() || "new-name"}</strong> and removes
<strong>{branch.name}</strong> in one atomic push. The operation stops if the remote changed in the meantime.
</p>
{/if}
<div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
Cancel
</button>
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === branch.name}>
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === originalName}>
{#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<GitBranch size={16} aria-hidden="true" />
{/if}
Rename
{branch.remote ? "Rename on remote" : "Rename"}
</button>
</div>
</form>
+24 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { untrack } from "svelte";
import { AlertCircle, Check, LoaderCircle, X } from "@lucide/svelte";
import { AlertCircle, Check, ExternalLink, GitMerge, LoaderCircle, X } from "@lucide/svelte";
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
@@ -24,6 +24,9 @@
onSelectFile: (path: string) => void;
onMarkResolved: (path: string, resolution: PreparedResolution) => void;
onApply: () => void;
onExternalMerge: (path: string) => void | Promise<void>;
language?: "en" | "de";
mergeName?: string;
}
let {
@@ -37,7 +40,11 @@
onSelectFile = () => {},
onMarkResolved = () => {},
onApply = () => {},
onExternalMerge = () => {},
language = "en",
mergeName = "merge tool",
}: Props = $props();
const isGerman = $derived(language === "de");
let conflictParts = $derived<ConflictPart[]>(
conflict && !conflict.binary ? parseConflicts(conflict.content) : [],
@@ -268,9 +275,22 @@
<span class="eyebrow">Resolve</span>
<h2 class="dialog-title">Conflicts</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
<div class="dialog-header-actions">
{#if conflictTarget}
<div class="tool-surface-choice" aria-label={isGerman ? "Konflikt bearbeiten mit" : "Edit conflict with"}>
<span>{isGerman ? "Bearbeiten mit" : "Edit with"}</span>
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty bearbeiten" : "Edit in Gitty"}>
<GitMerge size={13} aria-hidden="true" />Gitty
</button>
<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={isGerman ? `In ${mergeName} öffnen` : `Open in ${mergeName}`}>
<ExternalLink size={13} aria-hidden="true" /><span>{mergeName}</span>
</button>
</div>
{/if}
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Konflikte schließen" : "Close conflicts"}>
<X size={18} aria-hidden="true" />
</button>
</div>
</header>
{#if conflictedFiles.length === 0}
+4 -2
View File
@@ -144,6 +144,8 @@
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
let unstagedFiles = $derived(changedFiles.filter((file) => file.unstaged !== null));
let stagedFiles = $derived(changedFiles.filter((file) => file.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);
@@ -201,7 +203,7 @@
</div>
</header>
<div class="status-file-list">
{#each changedFiles.filter((file) => file.unstaged !== null) as file (`unstaged:${fileKey(file)}`)}
{#each unstagedFiles 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`}>
@@ -241,7 +243,7 @@
</div>
</header>
<div class="status-file-list">
{#each changedFiles.filter((file) => file.staged !== null) as file (`staged:${fileKey(file)}`)}
{#each stagedFiles 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`}>
@@ -0,0 +1,109 @@
<script lang="ts">
import { Cloud, GitBranch, Plus, Save, Trash2, X } from "@lucide/svelte";
import type { GitRemote, PullStrategy } from "../types";
export let remotes: GitRemote[] = [];
export let remoteBranches: string[] = [];
export let currentBranch = "";
export let currentUpstream = "";
export let strategy: PullStrategy = "merge";
export let selectedRemote = "";
export let isBusy = false;
export let language: "en" | "de" = "en";
export let onSaveSync: (strategy: PullStrategy, remote: string, upstream: string) => void | Promise<void> = () => {};
export let onAddRemote: (name: string, url: string) => void | Promise<void> = () => {};
export let onUpdateRemote: (name: string, url: string) => void | Promise<void> = () => {};
export let onRemoveRemote: (name: string) => void | Promise<void> = () => {};
export let onClose: () => void = () => {};
let draftStrategy = strategy;
let draftRemote = selectedRemote;
let draftUpstream = currentUpstream;
let newName = "origin";
let newUrl = "";
let editingName = "";
let editingUrl = "";
let actionError = "";
let actionStatus = "";
$: de = language === "de";
function beginEdit(remote: GitRemote) { actionError = ""; editingName = remote.name; editingUrl = remote.fetch_url; }
async function requestDelete(event: MouseEvent, name: string) {
console.log(name)
event.preventDefault();
event.stopPropagation();
editingName = "";
actionError = "";
actionStatus = de ? `Remote „${name}“ wird entfernt …` : `Removing remote “${name}” …`;
console.info("[Gitty remote] remove button activated", { name });
await remove(name);
}
function cancelEdit() { editingName = ""; editingUrl = ""; }
async function add() { if (!newName.trim() || !newUrl.trim()) return; await onAddRemote(newName.trim(), newUrl.trim()); newName = "origin"; newUrl = ""; }
async function update() { if (!editingName || !editingUrl.trim()) return; await onUpdateRemote(editingName, editingUrl.trim()); cancelEdit(); }
async function remove(name: string) {
actionError = "";
console.log("remove")
try {
await onRemoveRemote(name);
if (draftRemote === name) draftRemote = "";
if (draftUpstream.startsWith(`${name}/`)) draftUpstream = "";
} catch (error) {
actionError = error instanceof Error ? error.message : String(error);
} finally {
actionStatus = "";
}
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="sync-settings-dialog" role="dialog" aria-modal="true" aria-labelledby="sync-settings-title">
<header class="sync-settings-head">
<div class="sync-settings-title">
<span class="sync-settings-icon"><Cloud size={18} aria-hidden="true" /></span>
<div><span class="eyebrow">Git sync</span><h2 id="sync-settings-title">{de ? "Synchronisierung & Remotes" : "Sync & remotes"}</h2></div>
</div>
<button class="dialog-icon-button" type="button" onclick={onClose} disabled={isBusy} aria-label={de ? "Schließen" : "Close"}><X size={17} /></button>
</header>
<div class="sync-settings-body">
<section class="sync-settings-card">
<div class="sync-card-heading"><div><h3>{de ? "Pull-Verhalten" : "Pull behavior"}</h3><p>{de ? "Legt fest, wie entfernte Änderungen in deinen aktuellen Branch übernommen werden." : "Controls how remote changes are integrated into your current branch."}</p></div></div>
<div class="strategy-options">
<label class:active={draftStrategy === "merge"}><input type="radio" bind:group={draftStrategy} value="merge" /><span><strong>Merge</strong><small>{de ? "Erstellt bei getrennten Verläufen einen Merge-Commit. Sicher und leicht nachvollziehbar." : "Creates a merge commit for diverged history. Safe and easy to follow."}</small></span></label>
<label class:active={draftStrategy === "rebase"}><input type="radio" bind:group={draftStrategy} value="rebase" /><span><strong>Rebase</strong><small>{de ? "Setzt deine lokalen Commits auf die Remote-Änderungen. Ergibt eine lineare Historie." : "Replays your local commits on remote changes for a linear history."}</small></span></label>
<label class:active={draftStrategy === "ff-only"}><input type="radio" bind:group={draftStrategy} value="ff-only" /><span><strong>Fast-forward only</strong><small>{de ? "Pull stoppt, sobald ein Merge nötig wäre. Verändert die Historie nie automatisch." : "Stops when a merge would be required. Never combines diverged history automatically."}</small></span></label>
</div>
<div class="sync-fields">
<label><span>{de ? "Remote für Sync" : "Remote used for sync"}</span><select bind:value={draftRemote}><option value="">{de ? "Automatisch wählen" : "Choose automatically"}</option>{#each remotes as remote}<option value={remote.name}>{remote.name}</option>{/each}</select><small>{de ? "Ein Remote ist die gespeicherte Verbindung zu einem Server-Repository." : "A remote is a saved connection to a repository on a server."}</small></label>
<label><span>{de ? `Upstream für ${currentBranch || "aktuellen Branch"}` : `Upstream for ${currentBranch || "current branch"}`}</span><select bind:value={draftUpstream}><option value="">{de ? "Kein Upstream" : "No upstream"}</option>{#each remoteBranches as branch}<option value={branch}>{branch}</option>{/each}</select><small>{de ? "Der Upstream ist der Remote-Branch, mit dem Pull, Push und Ahead/Behind verglichen werden." : "The upstream is the remote branch used by Pull, Push, and Ahead/Behind."}</small></label>
</div>
</section>
<section class="sync-settings-card">
<div class="sync-card-heading"><div><h3>Remotes</h3><p>{de ? "Server-Verbindungen dieses Repositorys verwalten." : "Manage this repository's server connections."}</p></div><span class="count-pill">{remotes.length}</span></div>
<div class="remote-list">
{#if actionError}<div class="sync-action-error" role="alert"><strong>{de ? "Remote konnte nicht entfernt werden" : "Remote could not be removed"}</strong><span>{actionError}</span></div>{/if}
{#if actionStatus}<div class="sync-action-status" role="status">{actionStatus}</div>{/if}
{#each remotes as remote (remote.name)}
<div class="remote-row">
<span class="remote-mark"><GitBranch size={15} /></span>
{#if editingName === remote.name}
<div class="remote-edit"><strong>{remote.name}</strong><input bind:value={editingUrl} aria-label={`URL for ${remote.name}`} /></div>
<button class="btn-sm" type="button" onclick={update} disabled={isBusy || !editingUrl.trim()}><Save size={13} /> {de ? "Speichern" : "Save"}</button>
<button class="btn-sm" type="button" onclick={cancelEdit} disabled={isBusy}>{de ? "Abbrechen" : "Cancel"}</button>
{:else}
<button class="remote-main" type="button" onclick={() => beginEdit(remote)} disabled={isBusy}><strong>{remote.name}</strong><span>{remote.fetch_url}</span></button>
<button class="remote-delete" type="button" onclick={(event) => requestDelete(event, remote.name)} data-remote-name={remote.name} title={de ? "Remote-Verbindung sofort entfernen; lokale Daten bleiben erhalten" : "Remove remote connection now; local data is kept"}><Trash2 size={13} /><span>{de ? "Entfernen" : "Remove"}</span></button>
{/if}
</div>
{:else}<p class="remote-empty">{de ? "Noch kein Remote eingerichtet." : "No remote configured yet."}</p>{/each}
</div>
<div class="remote-add"><input bind:value={newName} placeholder={de ? "Name, z. B. origin" : "Name, e.g. origin"} aria-label="Remote name" /><input bind:value={newUrl} placeholder="https://… or git@…" aria-label="Remote URL" /><button class="btn-secondary" type="button" onclick={add} disabled={isBusy || !newName.trim() || !newUrl.trim()}><Plus size={14} /> {de ? "Hinzufügen" : "Add remote"}</button></div>
</section>
</div>
<footer class="sync-settings-footer"><p>{de ? "Fetch + Prune entfernt veraltete Remote-Verweise. Force with lease ist ein geschütztes Force-Push nach einem Rebase." : "Fetch + Prune removes stale remote references. Force with lease is a protected force-push after a rebase."}</p><div><button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{de ? "Abbrechen" : "Cancel"}</button><button class="btn-primary" type="button" onclick={() => onSaveSync(draftStrategy, draftRemote, draftUpstream)} disabled={isBusy}><Save size={14} /> {de ? "Einstellungen speichern" : "Save settings"}</button></div></footer>
</div>
</div>
+455
View File
@@ -0,0 +1,455 @@
<script lang="ts">
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import {
AlertTriangle,
Check,
CircleDot,
ExternalLink,
FolderInput,
FolderOpen,
GitBranch,
HardDrive,
LoaderCircle,
Lock,
MapPin,
Plus,
RefreshCw,
ShieldCheck,
Trash2,
Unlock,
Wrench,
X,
} from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitWorktree } from "../types";
type CreateMode = "existing" | "new" | "detached";
interface AddRequest {
worktreePath: string;
branch?: string;
newBranch?: string;
startPoint?: string;
detached?: boolean;
lock?: boolean;
}
interface Props {
worktrees: GitWorktree[];
branches: GitBranchInfo[];
initialBranch?: string;
isLoading: boolean;
isBusy: boolean;
error?: string;
onRefresh: () => void | Promise<void>;
onOpen: (worktree: GitWorktree) => void | Promise<void>;
onAdd: (request: AddRequest) => boolean | Promise<boolean>;
onRemove: (worktree: GitWorktree, force: boolean) => boolean | Promise<boolean>;
onMove: (worktree: GitWorktree, destination: string) => void | Promise<void>;
onLock: (worktree: GitWorktree, reason: string) => boolean | Promise<boolean>;
onUnlock: (worktree: GitWorktree) => void | Promise<void>;
onPrune: () => void | Promise<void>;
onRepair: (worktree: GitWorktree, location: string) => void | Promise<void>;
onClose: () => void;
}
let {
worktrees = [],
branches = [],
initialBranch = "",
isLoading = false,
isBusy = false,
error = "",
onRefresh = () => {},
onOpen = () => {},
onAdd = () => false,
onRemove = () => false,
onMove = () => {},
onLock = () => false,
onUnlock = () => {},
onPrune = () => {},
onRepair = () => {},
onClose = () => {},
}: Props = $props();
let createOpen = $state(false);
let createMode = $state<CreateMode>("new");
let selectedBranch = $state("");
let newBranch = $state("");
let startPoint = $state("HEAD");
let destination = $state("");
let lockAfterCreate = $state(false);
let pendingRemoval = $state<GitWorktree | null>(null);
let forceRemoval = $state(false);
let pendingLock = $state<GitWorktree | null>(null);
let lockReason = $state("");
let initialized = false;
$effect(() => {
if (initialized) return;
createOpen = initialBranch.length > 0;
createMode = initialBranch ? "existing" : "new";
selectedBranch = initialBranch;
initialized = true;
});
let localBranches = $derived(branches.filter((branch) => !branch.remote));
let prunableCount = $derived(worktrees.filter((worktree) => worktree.prunable).length);
let linkedCount = $derived(Math.max(0, worktrees.length - 1));
let checkedOutBranches = $derived(new Set(worktrees.map((worktree) => worktree.branch).filter((branch): branch is string => Boolean(branch))));
function displayName(worktree: GitWorktree): string {
return worktree.branch || (worktree.detached ? `Detached at ${worktree.short_head || "HEAD"}` : "Bare worktree");
}
function pathName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() || path;
}
function branchAvailable(branch: string): boolean {
return !checkedOutBranches.has(branch);
}
function joinPath(parent: string, name: string): string {
const separator = parent.includes("\\") ? "\\" : "/";
return `${parent.replace(/[\\/]+$/, "")}${separator}${name}`;
}
async function chooseDestination(current = "") {
const selected = await openDialog({
title: current ? "Choose new worktree location" : "Choose worktree folder",
directory: true,
multiple: false,
defaultPath: current || undefined,
});
if (typeof selected === "string") destination = selected;
}
async function submitCreate(event: SubmitEvent) {
event.preventDefault();
if (!destination.trim()) return;
const request: AddRequest = {
worktreePath: destination.trim(),
lock: lockAfterCreate,
};
if (createMode === "existing") request.branch = selectedBranch;
if (createMode === "new") {
request.newBranch = newBranch.trim();
request.startPoint = startPoint.trim() || "HEAD";
}
if (createMode === "detached") {
request.detached = true;
request.startPoint = startPoint.trim() || "HEAD";
}
if (await onAdd(request)) {
createOpen = false;
destination = "";
newBranch = "";
}
}
async function chooseMoveDestination(worktree: GitWorktree) {
const selected = await openDialog({
title: `Choose parent folder for ${displayName(worktree)}`,
directory: true,
multiple: false,
defaultPath: worktree.path,
});
if (typeof selected === "string") {
const target = joinPath(selected, pathName(worktree.path));
if (target !== worktree.path) await onMove(worktree, target);
}
}
async function chooseRepairLocation(worktree: GitWorktree) {
const selected = await openDialog({
title: `Locate ${displayName(worktree)}`,
directory: true,
multiple: false,
});
if (typeof selected === "string") await onRepair(worktree, selected);
}
function requestRemoval(worktree: GitWorktree) {
pendingRemoval = worktree;
forceRemoval = false;
}
async function confirmRemoval() {
if (!pendingRemoval) return;
if (await onRemove(pendingRemoval, forceRemoval)) {
pendingRemoval = null;
forceRemoval = false;
}
}
function requestLock(worktree: GitWorktree) {
pendingLock = worktree;
lockReason = "";
}
async function confirmLock() {
if (!pendingLock) return;
if (await onLock(pendingLock, lockReason)) {
pendingLock = null;
lockReason = "";
}
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog worktree-dialog" role="dialog" aria-modal="true" aria-labelledby="worktree-dialog-title">
<header class="dialog-header worktree-dialog-header">
<div class="worktree-dialog-heading">
<span class="worktree-dialog-mark" aria-hidden="true"><HardDrive size={18} /></span>
<div>
<span class="eyebrow">Parallel workspaces</span>
<p class="dialog-title" id="worktree-dialog-title">Worktrees</p>
</div>
</div>
<div class="dialog-header-actions">
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading} title="Refresh worktrees">
<RefreshCw class={isLoading ? "spin" : undefined} size={15} aria-hidden="true" />
Refresh
</button>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</div>
</header>
<div class="worktree-summary">
<div><strong>{linkedCount}</strong><span>linked worktrees</span></div>
<div><strong>{worktrees.filter((worktree) => !worktree.clean).length}</strong><span>with changes</span></div>
<div class:attention={prunableCount > 0}><strong>{prunableCount}</strong><span>stale entries</span></div>
<button class="btn-primary" type="button" onclick={() => { createOpen = !createOpen; }} disabled={isBusy}>
<Plus size={15} aria-hidden="true" />
New worktree
</button>
</div>
{#if error}
<div class="worktree-error" role="alert"><AlertTriangle size={15} aria-hidden="true" />{error}</div>
{/if}
<div class="worktree-content">
{#if createOpen}
<form class="worktree-create-card" onsubmit={submitCreate}>
<header>
<div>
<span class="eyebrow">Create</span>
<h3>Choose what this workspace should track</h3>
</div>
<button class="btn-sm dialog-close" type="button" onclick={() => { createOpen = false; }} disabled={isBusy} aria-label="Close create form">
<X size={15} aria-hidden="true" />
</button>
</header>
<div class="worktree-mode-tabs" role="tablist" aria-label="Worktree type">
<button class:active={createMode === "existing"} type="button" role="tab" aria-selected={createMode === "existing"} onclick={() => { createMode = "existing"; }}>
<GitBranch size={14} aria-hidden="true" />Existing branch
</button>
<button class:active={createMode === "new"} type="button" role="tab" aria-selected={createMode === "new"} onclick={() => { createMode = "new"; }}>
<Plus size={14} aria-hidden="true" />New branch
</button>
<button class:active={createMode === "detached"} type="button" role="tab" aria-selected={createMode === "detached"} onclick={() => { createMode = "detached"; }}>
<CircleDot size={14} aria-hidden="true" />Detached
</button>
</div>
<div class="worktree-create-fields">
{#if createMode === "existing"}
<label>
<span>Branch</span>
<select bind:value={selectedBranch} disabled={isBusy}>
<option value="" disabled>Select a local branch</option>
{#each localBranches as branch (branch.name)}
<option value={branch.name} disabled={!branchAvailable(branch.name)}>{branch.name}{!branchAvailable(branch.name) ? " (already checked out)" : ""}</option>
{/each}
</select>
</label>
{:else if createMode === "new"}
<label>
<span>New branch name</span>
<input bind:value={newBranch} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="feature/my-change" />
</label>
<label>
<span>Start point</span>
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD, branch or commit" />
</label>
{:else}
<label>
<span>Commit or ref</span>
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD" />
</label>
{/if}
<label class="worktree-path-field">
<span>Folder</span>
<div>
<input bind:value={destination} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="Choose an empty folder" />
<button class="btn-secondary" type="button" onclick={() => chooseDestination()} disabled={isBusy}>
<FolderOpen size={15} aria-hidden="true" />Browse
</button>
</div>
</label>
</div>
<footer>
<label class="worktree-check">
<input type="checkbox" bind:checked={lockAfterCreate} disabled={isBusy} />
<span><strong>Lock after creation</strong><small>Protects removable or temporary locations from pruning.</small></span>
</label>
<button
class="btn-primary"
type="submit"
disabled={isBusy || !destination.trim() || (createMode === "existing" && (!selectedBranch || !branchAvailable(selectedBranch))) || (createMode === "new" && !newBranch.trim())}
>
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Plus size={15} aria-hidden="true" />{/if}
Create worktree
</button>
</footer>
</form>
{/if}
{#if isLoading && worktrees.length === 0}
<div class="worktree-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>Reading worktrees…</span></div>
{:else if worktrees.length === 0}
<div class="worktree-empty"><HardDrive size={24} aria-hidden="true" /><strong>No worktrees found</strong><span>Create one to work on another branch without switching this workspace.</span></div>
{:else}
<div class="worktree-list">
{#each worktrees as worktree (worktree.path)}
<article class:current={worktree.is_current} class:stale={worktree.prunable || worktree.missing} class="worktree-card">
<div class="worktree-rail" aria-hidden="true">
<span></span>
{#if !worktree.is_main}<i></i>{/if}
</div>
<div class="worktree-card-main">
<header>
<div class="worktree-name">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{displayName(worktree)}</strong>
<span>{pathName(worktree.path)}</span>
</div>
</div>
<div class="worktree-badges">
{#if worktree.is_main}<span>Main</span>{/if}
{#if worktree.is_current}<span class="active">Open</span>{/if}
{#if worktree.detached}<span>Detached</span>{/if}
{#if worktree.locked}<span class="locked"><Lock size={10} aria-hidden="true" />Locked</span>{/if}
{#if worktree.prunable || worktree.missing}<span class="danger">Stale</span>{/if}
</div>
</header>
<div class="worktree-path" title={worktree.path}><MapPin size={13} aria-hidden="true" /><code>{worktree.path}</code></div>
<div class="worktree-meta">
<span class:dirty={!worktree.clean}>
{#if worktree.clean}<Check size={12} aria-hidden="true" />Clean{:else}<CircleDot size={12} aria-hidden="true" />{worktree.changed_files} changed{/if}
</span>
{#if worktree.short_head}<span><code>{worktree.short_head}</code></span>{/if}
{#if worktree.lock_reason}<span><Lock size={12} aria-hidden="true" />{worktree.lock_reason}</span>{/if}
{#if worktree.prune_reason}<span class="danger"><AlertTriangle size={12} aria-hidden="true" />{worktree.prune_reason}</span>{/if}
</div>
<footer>
<button class="btn-secondary" type="button" onclick={() => onOpen(worktree)} disabled={isBusy || worktree.missing || worktree.bare}>
<ExternalLink size={14} aria-hidden="true" />{worktree.is_current ? "Refresh tab" : "Open tab"}
</button>
<div class="worktree-card-actions">
{#if worktree.prunable}
<button class="btn-sm" type="button" onclick={() => chooseRepairLocation(worktree)} disabled={isBusy} title="Locate and repair worktree">
<Wrench size={14} aria-hidden="true" />Repair
</button>
{/if}
{#if !worktree.is_main && !worktree.missing}
<button class="btn-sm" type="button" onclick={() => chooseMoveDestination(worktree)} disabled={isBusy || worktree.locked || worktree.is_current} title="Move worktree">
<FolderInput size={14} aria-hidden="true" />Move
</button>
{/if}
{#if worktree.locked}
<button class="btn-sm" type="button" onclick={() => onUnlock(worktree)} disabled={isBusy} title="Unlock worktree">
<Unlock size={14} aria-hidden="true" />Unlock
</button>
{:else if !worktree.is_main}
<button class="btn-sm" type="button" onclick={() => requestLock(worktree)} disabled={isBusy} title="Lock worktree">
<Lock size={14} aria-hidden="true" />Lock
</button>
{/if}
{#if !worktree.is_main}
<button class="btn-sm danger" type="button" onclick={() => requestRemoval(worktree)} disabled={isBusy || worktree.is_current || worktree.locked || worktree.missing} title={worktree.missing ? "Use Prune to remove stale metadata" : "Remove worktree"}>
<Trash2 size={14} aria-hidden="true" />Remove
</button>
{/if}
</div>
</footer>
</div>
</article>
{/each}
</div>
{/if}
</div>
<footer class="worktree-dialog-footer">
<div><ShieldCheck size={14} aria-hidden="true" /><span>Dirty, active and locked worktrees are protected.</span></div>
<button class="btn-secondary" type="button" onclick={onPrune} disabled={isBusy || prunableCount === 0}>
<Wrench size={14} aria-hidden="true" />Prune {prunableCount || ""}
</button>
</footer>
</div>
</div>
{#if pendingRemoval}
<div class="dialog-backdrop worktree-nested-backdrop" role="presentation">
<div class="dialog worktree-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="worktree-remove-title">
<header class="dialog-header">
<div>
<span class="eyebrow">Remove worktree</span>
<p class="dialog-title" id="worktree-remove-title">Remove {displayName(pendingRemoval)}?</p>
</div>
</header>
<div class="worktree-confirm-body">
<span class="discard-warning-icon" aria-hidden="true"><AlertTriangle size={21} /></span>
<div>
<p>This removes the worktree folder and its Git registration. The branch itself is kept.</p>
<code>{pendingRemoval.path}</code>
{#if !pendingRemoval.clean}
<label class="worktree-check danger">
<input type="checkbox" bind:checked={forceRemoval} disabled={isBusy} />
<span><strong>Remove despite local changes</strong><small>{pendingRemoval.changed_files} changed files may be permanently deleted.</small></span>
</label>
{/if}
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={() => { pendingRemoval = null; }} disabled={isBusy}>Cancel</button>
<button class="btn-danger" type="button" onclick={confirmRemoval} disabled={isBusy || (!pendingRemoval.clean && !forceRemoval)}>
<Trash2 size={15} aria-hidden="true" />Remove worktree
</button>
</footer>
</div>
</div>
{/if}
{#if pendingLock}
<div class="dialog-backdrop worktree-nested-backdrop" role="presentation">
<div class="dialog worktree-confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="worktree-lock-title">
<header class="dialog-header">
<div>
<span class="eyebrow">Protect worktree</span>
<p class="dialog-title" id="worktree-lock-title">Lock {displayName(pendingLock)}</p>
</div>
</header>
<div class="worktree-lock-body">
<label>
<span>Reason <small>optional</small></span>
<input bind:value={lockReason} disabled={isBusy} autocomplete="off" placeholder="External drive, long-running work…" />
</label>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy}>Cancel</button>
<button class="btn-primary" type="button" onclick={confirmLock} disabled={isBusy}><Lock size={15} aria-hidden="true" />Lock</button>
</footer>
</div>
</div>
{/if}
+14 -9
View File
@@ -1,5 +1,3 @@
import type { StoredCredential } from "./types";
/**
* Derives a credential key from a remote URL, scoped to host + organisation
* the same granularity Azure DevOps / GitHub use. Examples:
@@ -37,13 +35,6 @@ export function orgKeyFromUrl(raw: string): string | null {
return org ? `${host}/${org}` : host;
}
/** A stored credential is expired only if it carries a past expiry date. */
export function isCredentialExpired(cred: StoredCredential): boolean {
if (!cred.expiresAt) return false;
const time = new Date(cred.expiresAt).getTime();
return !Number.isNaN(time) && time < Date.now();
}
const AUTH_PREFIX = "AUTH_FAILED:";
export function isAuthError(message: string): boolean {
@@ -55,3 +46,17 @@ export function stripAuthPrefix(message: string): string {
? message.slice(AUTH_PREFIX.length).trim()
: message;
}
/**
* Condenses raw git stderr to the single most relevant line for the
* credential dialog. Prefers the server's own explanation ("remote: ...")
* over git's generic wrapper ("fatal: Authentication failed for ...").
*/
export function summarizeGitError(message: string): string {
const lines = message
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const remote = lines.find((line) => line.toLowerCase().startsWith("remote:"));
return remote ?? lines[0] ?? "";
}
+288
View File
@@ -0,0 +1,288 @@
import type {
DetectedExternalTool,
ExternalToolKind,
ExternalToolSetting,
ExternalToolsSettings,
ToolOpenMode,
} from "./types";
export type { ExternalToolKind } from "./types";
export interface ExternalToolPreset {
id: string;
label: string;
program: string;
args: string[];
builtIn?: boolean;
}
type ToolIdentity = Pick<ExternalToolPreset, "id" | "label" | "program">;
const userAgent = navigator.userAgent;
export const externalToolsPlatform = userAgent.includes("Windows")
? "windows"
: userAgent.includes("Mac")
? "macos"
: "linux";
const codeFamily: ToolIdentity[] = externalToolsPlatform === "windows"
? [
{ id: "vscode", label: "Visual Studio Code", program: "Code.exe" },
{ id: "vscode-insiders", label: "Visual Studio Code Insiders", program: "Code - Insiders.exe" },
{ id: "cursor", label: "Cursor", program: "Cursor.exe" },
{ id: "windsurf", label: "Windsurf", program: "Windsurf.exe" },
{ id: "vscodium", label: "VSCodium", program: "VSCodium.exe" },
]
: [
{ id: "vscode", label: "Visual Studio Code", program: "code" },
{ id: "vscode-insiders", label: "Visual Studio Code Insiders", program: "code-insiders" },
{ id: "cursor", label: "Cursor", program: "cursor" },
{ id: "windsurf", label: "Windsurf", program: "windsurf" },
{ id: "vscodium", label: "VSCodium", program: "codium" },
];
const jetBrains: ToolIdentity[] = [
{ id: "intellij-idea", label: "IntelliJ IDEA", program: externalToolsPlatform === "windows" ? "idea64.exe" : "idea" },
{ id: "webstorm", label: "WebStorm", program: externalToolsPlatform === "windows" ? "webstorm64.exe" : "webstorm" },
{ id: "pycharm", label: "PyCharm", program: externalToolsPlatform === "windows" ? "pycharm64.exe" : "pycharm" },
{ id: "phpstorm", label: "PhpStorm", program: externalToolsPlatform === "windows" ? "phpstorm64.exe" : "phpstorm" },
{ id: "rider", label: "JetBrains Rider", program: externalToolsPlatform === "windows" ? "rider64.exe" : "rider" },
{ id: "clion", label: "CLion", program: externalToolsPlatform === "windows" ? "clion64.exe" : "clion" },
{ id: "rustrover", label: "RustRover", program: externalToolsPlatform === "windows" ? "rustrover64.exe" : "rustrover" },
{ id: "goland", label: "GoLand", program: externalToolsPlatform === "windows" ? "goland64.exe" : "goland" },
];
const editor = (tool: ToolIdentity, args = ["{file}"]): ExternalToolPreset => ({ ...tool, args });
const codeDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--new-window", "--wait", "--diff", "{left}", "{right}"] });
const codeMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--new-window", "--wait", "--merge", "{ours}", "{theirs}", "{base}", "{result}"] });
const jetBrainsDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["diff", "{left}", "{right}"] });
const jetBrainsMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["merge", "{ours}", "{theirs}", "{base}", "{result}"] });
const commonEditors: ExternalToolPreset[] = [
...codeFamily.map((item) => editor(item, ["--new-window", "{file}"])),
editor({ id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed" }, ["--new", "{file}"]),
editor({ id: "sublime-text", label: "Sublime Text", program: externalToolsPlatform === "windows" ? "subl.exe" : "subl" }, ["--new-window", "{file}"]),
...jetBrains.map((item) => editor(item)),
editor({ id: "neovim", label: "Neovim", program: externalToolsPlatform === "windows" ? "nvim.exe" : "nvim" }),
editor({ id: "vim", label: "Vim", program: externalToolsPlatform === "windows" ? "gvim.exe" : "gvim" }),
editor({ id: "emacs", label: "Emacs", program: externalToolsPlatform === "windows" ? "runemacs.exe" : "emacs" }),
];
const platformEditors: ExternalToolPreset[] = externalToolsPlatform === "windows"
? [
editor({ id: "notepad-plus-plus", label: "Notepad++", program: "notepad++.exe" }, ["-multiInst", "{file}"]),
{ ...editor({ id: "notepad", label: "Windows Notepad", program: "notepad.exe" }), builtIn: true },
]
: externalToolsPlatform === "macos"
? [
editor({ id: "nova", label: "Nova", program: "nova" }),
editor({ id: "textmate", label: "TextMate", program: "mate" }),
editor({ id: "bbedit", label: "BBEdit", program: "bbedit" }),
editor({ id: "xcode", label: "Xcode", program: "xed" }),
]
: [
editor({ id: "lapce", label: "Lapce", program: "lapce" }),
editor({ id: "kate", label: "Kate", program: "kate" }, ["--new", "{file}"]),
editor({ id: "gedit", label: "GNOME Text Editor", program: "gnome-text-editor" }),
editor({ id: "geany", label: "Geany", program: "geany" }, ["--new-instance", "{file}"]),
editor({ id: "helix", label: "Helix", program: "hx" }),
];
const dedicatedDiff: ExternalToolPreset[] = externalToolsPlatform === "windows"
? [
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/solo", "/readonly", "{left}", "{right}"] },
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/s-", "/u", "/e", "/wl", "/wr", "{left}", "{right}"] },
{ id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "{left}", "{right}"] },
{ id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{left}", "{right}"] },
{ id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{left}", "{right}"] },
{ id: "araxis-merge", label: "Araxis Merge", program: "Compare.exe", args: ["/wait", "{left}", "{right}"] },
{ id: "tortoisegitmerge", label: "TortoiseGitMerge", program: "TortoiseGitMerge.exe", args: ["/base:{left}", "/mine:{right}"] },
]
: externalToolsPlatform === "macos"
? [
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{left}", "{right}"] },
{ id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--wait", "{left}", "{right}"] },
{ id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{left}", "{right}"] },
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] },
{ id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{left}", "{right}"] },
]
: [
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{left}", "{right}"] },
{ id: "meld", label: "Meld", program: "meld", args: ["--wait", "{left}", "{right}"] },
{ id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{left}", "{right}"] },
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] },
{ id: "kompare", label: "Kompare", program: "kompare", args: ["{left}", "{right}"] },
];
const dedicatedMerge: ExternalToolPreset[] = externalToolsPlatform === "windows"
? [
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/s-", "/u", "/e", "{ours}", "{theirs}", "{base}", "/o", "{result}"] },
{ id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] },
{ id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] },
{ id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
{ id: "araxis-merge", label: "Araxis Merge", program: "Compare.exe", args: ["/wait", "{ours}", "{base}", "{theirs}", "{result}"] },
{ id: "tortoisegitmerge", label: "TortoiseGitMerge", program: "TortoiseGitMerge.exe", args: ["/base:{base}", "/theirs:{theirs}", "/mine:{ours}", "/merged:{result}"] },
]
: externalToolsPlatform === "macos"
? [
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
{ id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--merge", "--output", "{result}", "{base}", "{ours}", "{theirs}"] },
{ id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{ours}", "{base}", "{theirs}", "{result}"] },
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
{ id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{ours}", "{theirs}", "-ancestor", "{base}", "-merge", "{result}"] },
]
: [
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
{ id: "meld", label: "Meld", program: "meld", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] },
{ id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] },
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
];
const terminalPresets: ExternalToolPreset[] = externalToolsPlatform === "windows"
? [
{ id: "windows-terminal", label: "Windows Terminal", program: "wt.exe", args: ["-w", "new", "-d", "{repo}"] },
{ id: "powershell", label: "PowerShell 7", program: "pwsh.exe", args: ["-NoExit", "-WorkingDirectory", "{repo}"] },
{ id: "windows-powershell", label: "Windows PowerShell", program: "powershell.exe", args: ["-NoExit"], builtIn: true },
{ id: "git-bash", label: "Git Bash", program: "git-bash.exe", args: ["--login", "-i"] },
{ id: "cmd", label: "Command Prompt", program: "cmd.exe", args: ["/K"], builtIn: true },
{ id: "wezterm", label: "WezTerm", program: "wezterm-gui.exe", args: ["start", "--cwd", "{repo}"] },
{ id: "alacritty", label: "Alacritty", program: "alacritty.exe", args: ["--working-directory", "{repo}"] },
{ id: "kitty-terminal", label: "kitty", program: "kitty.exe", args: ["--directory", "{repo}"] },
]
: externalToolsPlatform === "macos"
? [
{ id: "terminal", label: "Terminal", program: "open", args: ["-n", "-a", "Terminal", "{repo}"], builtIn: true },
{ id: "iterm2", label: "iTerm2", program: "open", args: ["-n", "-a", "iTerm", "{repo}"] },
{ id: "warp", label: "Warp", program: "open", args: ["-n", "-a", "Warp", "{repo}"] },
{ id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] },
{ id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] },
]
: [
{ id: "x-terminal", label: "System terminal", program: "x-terminal-emulator", args: ["--working-directory={repo}"] },
{ id: "gnome-terminal", label: "GNOME Terminal", program: "gnome-terminal", args: ["--working-directory={repo}"] },
{ id: "konsole", label: "Konsole", program: "konsole", args: ["--workdir", "{repo}"] },
{ id: "kitty-terminal", label: "kitty", program: "kitty", args: ["--directory", "{repo}"] },
{ id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] },
{ id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] },
{ id: "xfce-terminal", label: "Xfce Terminal", program: "xfce4-terminal", args: ["--working-directory={repo}"] },
{ id: "tilix", label: "Tilix", program: "tilix", args: ["--working-directory={repo}"] },
];
const fileManagerPresets: ExternalToolPreset[] = externalToolsPlatform === "windows"
? [
{ id: "explorer", label: "Windows Explorer", program: "explorer.exe", args: ["/n,", "{repo}"], builtIn: true },
{ id: "total-commander", label: "Total Commander", program: "TOTALCMD64.EXE", args: ["/N", "/T", "{repo}"] },
{ id: "directory-opus", label: "Directory Opus", program: "dopus.exe", args: ["{repo}"] },
{ id: "double-commander", label: "Double Commander", program: "doublecmd.exe", args: ["{repo}"] },
{ id: "freecommander", label: "FreeCommander XE", program: "FreeCommander.exe", args: ["/L={repo}"] },
{ id: "xyplorer", label: "XYplorer", program: "XYplorer.exe", args: ["{repo}"] },
]
: externalToolsPlatform === "macos"
? [
{ id: "finder", label: "Finder", program: "open", args: ["-n", "{repo}"], builtIn: true },
{ id: "forklift", label: "ForkLift", program: "forklift", args: ["{repo}"] },
{ id: "path-finder", label: "Path Finder", program: "Path Finder", args: ["{repo}"] },
]
: [
{ id: "system-file-manager", label: "System file manager", program: "xdg-open", args: ["{repo}"], builtIn: true },
{ id: "nautilus", label: "GNOME Files", program: "nautilus", args: ["{repo}"] },
{ id: "dolphin", label: "Dolphin", program: "dolphin", args: ["{repo}"] },
{ id: "thunar", label: "Thunar", program: "thunar", args: ["{repo}"] },
{ id: "nemo", label: "Nemo", program: "nemo", args: ["{repo}"] },
{ id: "pcmanfm", label: "PCManFM", program: "pcmanfm", args: ["{repo}"] },
{ id: "double-commander", label: "Double Commander", program: "doublecmd", args: ["{repo}"] },
];
export const externalToolPresets: Record<ExternalToolKind, ExternalToolPreset[]> = {
editor: [...commonEditors, ...platformEditors],
diff: [...dedicatedDiff, ...codeFamily.map(codeDiff), { id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed", args: ["--new", "--diff", "{left}", "{right}"] }, ...jetBrains.map(jetBrainsDiff)],
merge: [...dedicatedMerge, ...codeFamily.map(codeMerge), ...jetBrains.map(jetBrainsMerge)],
terminal: terminalPresets,
fileManager: fileManagerPresets,
};
function detectedPreset(kind: ExternalToolKind, id: string, detectedTools: DetectedExternalTool[]): DetectedExternalTool | undefined {
return detectedTools.find((tool) => tool.id === id && tool.kinds.includes(kind));
}
export function isExternalToolPresetAvailable(kind: ExternalToolKind, preset: ExternalToolPreset, detectedTools: DetectedExternalTool[]): boolean {
return preset.builtIn === true || detectedPreset(kind, preset.id, detectedTools) != null;
}
export function applyExternalToolPreset(kind: ExternalToolKind, id: string, detectedTools: DetectedExternalTool[] = []): ExternalToolSetting {
const value = externalToolPresets[kind].find((item) => item.id === id) ?? externalToolPresets[kind][0];
const detected = detectedPreset(kind, value.id, detectedTools);
return { preset: value.id, program: detected?.program ?? value.program, args: [...value.args] };
}
function preferredPreset(kind: ExternalToolKind, detectedTools: DetectedExternalTool[]): ExternalToolPreset {
return externalToolPresets[kind].find((item) => isExternalToolPresetAvailable(kind, item, detectedTools))
?? externalToolPresets[kind][0];
}
export function defaultExternalToolsSettings(detectedTools: DetectedExternalTool[] = []): ExternalToolsSettings {
return {
editor: applyExternalToolPreset("editor", preferredPreset("editor", detectedTools).id, detectedTools),
diff: applyExternalToolPreset("diff", preferredPreset("diff", detectedTools).id, detectedTools),
merge: applyExternalToolPreset("merge", preferredPreset("merge", detectedTools).id, detectedTools),
terminal: applyExternalToolPreset("terminal", preferredPreset("terminal", detectedTools).id, detectedTools),
fileManager: applyExternalToolPreset("fileManager", preferredPreset("fileManager", detectedTools).id, detectedTools),
diffOpenMode: "gitty",
mergeOpenMode: "gitty",
};
}
function normaliseSetting(kind: ExternalToolKind, value: unknown, fallback: ExternalToolSetting): ExternalToolSetting {
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback;
const candidate = value as Partial<ExternalToolSetting>;
const program = typeof candidate.program === "string" && candidate.program.trim() ? candidate.program : fallback.program;
const storedArgs = Array.isArray(candidate.args)
? candidate.args.filter((item): item is string => typeof item === "string").slice(0, 64)
: fallback.args;
const preset = typeof candidate.preset === "string" && candidate.preset ? candidate.preset : fallback.preset;
// Preset arguments are application-owned and can be upgraded safely. Editing either
// the executable or arguments in Settings changes the preset to "custom", which keeps
// genuine user commands untouched.
const currentPreset = preset === "custom"
? undefined
: externalToolPresets[kind].find((item) => item.id === preset);
const args = currentPreset ? [...currentPreset.args] : storedArgs;
return { preset, program, args };
}
export function normaliseExternalToolsSettings(value: unknown): ExternalToolsSettings {
const defaults = defaultExternalToolsSettings();
if (!value || typeof value !== "object" || Array.isArray(value)) return defaults;
const candidate = value as Partial<ExternalToolsSettings>;
return {
editor: normaliseSetting("editor", candidate.editor, defaults.editor),
diff: normaliseSetting("diff", candidate.diff, defaults.diff),
merge: normaliseSetting("merge", candidate.merge, defaults.merge),
terminal: normaliseSetting("terminal", candidate.terminal, defaults.terminal),
fileManager: normaliseSetting("fileManager", candidate.fileManager, defaults.fileManager),
diffOpenMode: normaliseOpenMode(candidate.diffOpenMode, defaults.diffOpenMode),
mergeOpenMode: normaliseOpenMode(candidate.mergeOpenMode, defaults.mergeOpenMode),
};
}
function normaliseOpenMode(value: unknown, fallback: ToolOpenMode): ToolOpenMode {
return value === "gitty" || value === "external" ? value : fallback;
}
export function resolveDetectedExternalToolPrograms(settings: ExternalToolsSettings, detectedTools: DetectedExternalTool[]): ExternalToolsSettings {
const resolved = structuredClone(settings);
for (const kind of ["editor", "diff", "merge", "terminal", "fileManager"] as ExternalToolKind[]) {
if (resolved[kind].preset === "custom") continue;
const detected = detectedPreset(kind, resolved[kind].preset, detectedTools);
if (detected) resolved[kind].program = detected.program;
}
return resolved;
}
export function externalToolDisplayName(kind: ExternalToolKind, setting: ExternalToolSetting, detectedTools: DetectedExternalTool[] = []): string {
if (setting.preset === "custom") return setting.program.split(/[\\/]/).pop() || setting.program;
return detectedPreset(kind, setting.preset, detectedTools)?.label
?? externalToolPresets[kind].find((item) => item.id === setting.preset)?.label
?? setting.program;
}
+154 -20
View File
@@ -1,16 +1,23 @@
import { invoke } from "@tauri-apps/api/core";
import { tracedInvoke as invoke } from "./telemetry";
import type {
AiReviewResult,
AiCommitPlan,
CommitAiLocalProfile,
CommitAiProvider,
CommitAiStatus,
ConflictFile,
DetectedExternalTool,
ExternalToolCommand,
ExternalDiffScope,
GitBlameResult,
GitBranch,
GitCommit,
GitCommitComparison,
GitRepositoryFile,
GitRemote,
MergeStrategy,
PullStrategy,
RebaseCommit,
RebasePlanItem,
ReflogEntry,
@@ -18,6 +25,7 @@ import type {
GitStash,
GitStatus,
GitTag,
GitWorktree,
LocalModelOption,
PatchApplyAction,
RepositoryBundle,
@@ -28,6 +36,10 @@ export function openRepository(path: string): Promise<GitStatus> {
return invoke<GitStatus>("open_repository", { path });
}
export function initRepository(path: string, initialBranch = "main"): Promise<GitStatus> {
return invoke<GitStatus>("init_repository", { path, initialBranch });
}
export function openRepoInExplorer(path: string): Promise<void> {
return invoke<void>("open_repo_in_explorer", { path });
}
@@ -36,6 +48,22 @@ export function openRepositoryFile(path: string, file: string): Promise<void> {
return invoke<void>("open_repository_file", { path, file });
}
export function detectExternalTools(): Promise<DetectedExternalTool[]> {
return invoke<DetectedExternalTool[]>("detect_external_tools");
}
export function launchExternalTool(path: string, command: ExternalToolCommand, file?: string): Promise<void> {
return invoke<void>("launch_external_tool", { path, file: file ?? null, command });
}
export function launchExternalDiff(path: string, file: string, command: ExternalToolCommand, scope: ExternalDiffScope = "head"): Promise<void> {
return invoke<void>("launch_external_diff", { path, file, command, scope });
}
export function launchExternalMerge(path: string, file: string, command: ExternalToolCommand): Promise<void> {
return invoke<void>("launch_external_merge", { path, file, command });
}
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
}
@@ -72,6 +100,15 @@ export function listBranches(path: string): Promise<GitBranch[]> {
return invoke<GitBranch[]>("list_branches", { path });
}
export function listRemotes(path: string): Promise<GitRemote[]> { return invoke("list_remotes", { path }); }
export function addRemote(path: string, name: string, url: string): Promise<GitRemote[]> { return invoke("add_remote", { path, name, url }); }
export function updateRemote(path: string, name: string, url: string): Promise<GitRemote[]> { return invoke("update_remote", { path, name, url }); }
export function removeRemote(path: string, name: string): Promise<GitRemote[]> {
console.log("remove_remote")
return invoke("remove_remote", { path, name }); }
export function setBranchUpstream(path: string, branch: string, upstream?: string): Promise<GitStatus> { return invoke("set_branch_upstream", { path, branch, upstream: upstream || null }); }
export function deleteRemoteBranch(path: string, remote: string, branch: string): Promise<GitStatus> { return invoke("delete_remote_branch", { path, remote, branch }); }
export function listStashes(path: string): Promise<GitStash[]> {
return invoke<GitStash[]>("list_stashes", { path });
}
@@ -96,10 +133,69 @@ export function renameBranch(
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
}
export function renameRemoteBranch(
path: string,
remote: string,
oldBranch: string,
newBranch: string,
): Promise<GitStatus> {
return invoke<GitStatus>("rename_remote_branch", { path, remote, oldBranch, newBranch });
}
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch, force });
}
export function listWorktrees(path: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("list_worktrees", { path });
}
export function addWorktree(
path: string,
worktreePath: string,
options: {
branch?: string;
newBranch?: string;
startPoint?: string;
detached?: boolean;
lock?: boolean;
} = {},
): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("add_worktree", {
path,
worktreePath,
branch: options.branch ?? null,
newBranch: options.newBranch ?? null,
startPoint: options.startPoint ?? null,
detached: options.detached ?? false,
lock: options.lock ?? false,
});
}
export function removeWorktree(path: string, worktreePath: string, force = false): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("remove_worktree", { path, worktreePath, force });
}
export function moveWorktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("move_worktree", { path, worktreePath, destination });
}
export function lockWorktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("lock_worktree", { path, worktreePath, reason: reason?.trim() || null });
}
export function unlockWorktree(path: string, worktreePath: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("unlock_worktree", { path, worktreePath });
}
export function pruneWorktrees(path: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("prune_worktrees", { path });
}
export function repairWorktree(path: string, worktreePath: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("repair_worktree", { path, worktreePath });
}
export function listTags(path: string): Promise<GitTag[]> {
return invoke<GitTag[]>("list_tags", { path });
}
@@ -255,41 +351,76 @@ export function commitAiReview(path: string, options: CommitAiGenerateOptions):
});
}
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
export function commitAiSplit(path: string, options: CommitAiGenerateOptions): Promise<AiCommitPlan> {
return invoke<AiCommitPlan>("commit_ai_split", {
path,
provider: options.provider,
model: options.model,
apiKey: options.apiKey,
baseUrl: options.baseUrl,
});
}
export function fetchRemote(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null });
export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise<GitStatus> {
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null });
}
export function push(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null });
export function fetchRemote(path: string, username?: string, password?: string, prune = false, remote?: string): Promise<GitStatus> {
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null, prune, remote: remote || null });
}
export function getRemoteUrl(path: string): Promise<string | null> {
return invoke<string | null>("get_remote_url", { path });
export function push(path: string, username?: string, password?: string, forceWithLease = false, remote?: string): Promise<GitStatus> {
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null });
}
export function getRemoteUrl(path: string, remote?: string, push = false): Promise<string | null> {
return invoke<string | null>("get_remote_url", { path, remote: remote || null, push });
}
export function credLoad(key: string): Promise<StoredCredential | null> {
return invoke<StoredCredential | null>("cred_load", { key });
}
export function credSave(
key: string,
username: string,
password: string,
expiresAt: string | null,
): Promise<void> {
return invoke<void>("cred_save", { key, username, password, expiresAt });
export function credSave(key: string, username: string, password: string, mode: "credentials" | "token" = "credentials"): Promise<void> {
return invoke<void>("cred_save", { key, username, password, mode });
}
export function credDelete(key: string): Promise<void> {
return invoke<void>("cred_delete", { key });
}
export function listCommits(path: string, limit = 100): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_commits", { path, limit });
export function listCommits(path: string, limit = 100, skip = 0): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_commits", { path, limit, skip });
}
export function getCommitNote(path: string, commit: string): Promise<string | null> {
return invoke<string | null>("get_commit_note", { path, commit });
}
export function setCommitNote(path: string, commit: string, note: string): Promise<void> {
return invoke<void>("set_commit_note", { path, commit, note });
}
export function deleteCommitNote(path: string, commit: string): Promise<void> {
return invoke<void>("delete_commit_note", { path, commit });
}
export function fetchCommitNotes(path: string, remote: string, username?: string, password?: string): Promise<void> {
return invoke<void>("fetch_commit_notes", {
path,
remote,
username: username ?? null,
password: password ?? null,
});
}
export function pushCommitNotes(path: string, remote: string, username?: string, password?: string): Promise<void> {
return invoke<void>("push_commit_notes", {
path,
remote,
username: username ?? null,
password: password ?? null,
});
}
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {
@@ -304,9 +435,12 @@ export function restoreFileFromCommit(
return invoke<GitStatus>("restore_file_from_commit", { path, commit, file });
}
export function mergeBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("merge_branch", { path, branch });
export function mergeBranch(path: string, branch: string, strategy: MergeStrategy = "default"): Promise<GitStatus> {
return invoke<GitStatus>("merge_branch", { path, branch, strategy });
}
export function mergeContinue(path: string): Promise<GitStatus> { return invoke("merge_continue", { path }); }
export function mergeAbort(path: string): Promise<GitStatus> { return invoke("merge_abort", { path }); }
export function revertCommit(path: string, commit: string): Promise<GitStatus> { return invoke("revert_commit", { path, commit }); }
export function rebaseBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_branch", { path, branch });
+85
View File
@@ -0,0 +1,85 @@
import { invoke } from "@tauri-apps/api/core";
type TelemetryLevel = "info" | "warn" | "error";
const MAX_MESSAGE_LENGTH = 2_048;
let telemetryEnabled = false;
let configuration: Promise<unknown> = Promise.resolve();
let frontendShuttingDown = false;
export function beginFrontendShutdown() {
frontendShuttingDown = true;
}
// Telemetry must never contain repository contents or identifying local data.
function sanitize(message: string): string {
return message
.replace(/https?:\/\/\S+/gi, "[url]")
.replace(/(?:[A-Za-z]:\\|\/)(?:[^\s<>:"|?*]+[\\/])*[^\s<>:"|?*]*/g, "[path]")
.replace(/(token|password|secret|authorization|credential)\s*[:=]\s*\S+/gi, "$1=[redacted]")
.replace(/[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}/g, "[email]")
.slice(0, MAX_MESSAGE_LENGTH);
}
export function setTelemetryEnabled(enabled: boolean) {
telemetryEnabled = enabled;
configuration = invoke("set_telemetry_enabled", { enabled }).catch(() => {});
}
export function telemetryLog(level: TelemetryLevel, message: string, eventName?: string) {
if (!telemetryEnabled) return;
void configuration.then(() => invoke("emit_frontend_log", {
log: {
level,
message: sanitize(message),
eventName,
},
}))
.catch(() => {
// Observability must never interrupt the Git workflow.
});
}
function randomHex(bytes: number): string {
const values = new Uint8Array(bytes);
crypto.getRandomValues(values);
return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("");
}
export async function tracedInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
if (frontendShuttingDown) {
// The window is on its way out and nothing calls preventDefault() on the
// close event, so it will finish closing regardless. Never resolving
// avoids surfacing a spurious "shutting down" error in whatever UI catch
// block happens to be awaiting an in-flight operation (e.g. a pull/push
// that makes several sequential calls) when the close request lands.
return new Promise<T>(() => {});
}
if (!telemetryEnabled) return invoke<T>(command, args);
const startedAtMs = Date.now();
const started = performance.now();
const traceId = randomHex(16);
const spanId = randomHex(8);
try {
const result = await invoke<T>(command, args);
void configuration.then(() => invoke("emit_frontend_span", {
span: { name: command, traceId, spanId, startedAtMs, durationMs: performance.now() - started, success: true },
})).catch(() => {});
return result;
} catch (error) {
void configuration.then(() => invoke("emit_frontend_span", {
span: { name: command, traceId, spanId, startedAtMs, durationMs: performance.now() - started, success: false },
})).catch(() => {});
throw error;
}
}
export function installGlobalErrorTelemetry() {
window.addEventListener("error", (event) => {
telemetryLog("error", event.message || "Unhandled frontend error", "frontend.unhandled_error");
});
window.addEventListener("unhandledrejection", (event) => {
const message = event.reason instanceof Error ? event.reason.message : String(event.reason ?? "Unhandled promise rejection");
telemetryLog("error", message, "frontend.unhandled_rejection");
});
}
+67 -1
View File
@@ -37,6 +37,17 @@ export interface AiReviewResult {
findings: AiReviewFinding[];
}
export interface AiCommitGroup {
message: string;
reason: string;
files: string[];
}
export interface AiCommitPlan {
summary: string;
groups: AiCommitGroup[];
}
export interface LocalModelOption {
id: string;
label: string;
@@ -58,6 +69,36 @@ export interface AnalyticsSettings {
noticeSeen: boolean;
}
export interface ExternalToolCommand {
program: string;
args: string[];
}
export interface ExternalToolSetting extends ExternalToolCommand {
preset: string;
}
export type ExternalToolKind = "editor" | "diff" | "merge" | "terminal" | "fileManager";
export type ToolOpenMode = "gitty" | "external";
export type ExternalDiffScope = "head" | "staged" | "unstaged";
export interface ExternalToolsSettings {
editor: ExternalToolSetting;
diff: ExternalToolSetting;
merge: ExternalToolSetting;
terminal: ExternalToolSetting;
fileManager: ExternalToolSetting;
diffOpenMode: ToolOpenMode;
mergeOpenMode: ToolOpenMode;
}
export interface DetectedExternalTool {
id: string;
label: string;
program: string;
kinds: ExternalToolKind[];
}
export interface GitStatus {
repo_path: string;
current_branch: string | null;
@@ -68,8 +109,13 @@ export interface GitStatus {
clean: boolean;
rebase_in_progress: boolean;
cherry_pick_in_progress: boolean;
merge_in_progress: boolean;
}
export interface GitRemote { name: string; fetch_url: string; push_url: string; }
export type PullStrategy = "merge" | "rebase" | "ff-only";
export type MergeStrategy = "default" | "squash" | "ff-only" | "no-ff";
export interface GitFileStatus {
path: string;
old_path: string | null;
@@ -83,6 +129,25 @@ export interface GitBranch {
name: string;
current: boolean;
remote: boolean;
upstream: string | null;
}
export interface GitWorktree {
path: string;
head: string | null;
short_head: string | null;
branch: string | null;
bare: boolean;
detached: boolean;
locked: boolean;
lock_reason: string | null;
prunable: boolean;
prune_reason: string | null;
missing: boolean;
is_main: boolean;
is_current: boolean;
clean: boolean;
changed_files: number;
}
export interface GitTag {
@@ -113,6 +178,7 @@ export interface GitCommit {
refs: string[];
parents: string[];
files: GitCommitFile[];
has_note: boolean;
}
export interface GitCommitFile {
@@ -245,5 +311,5 @@ export interface ReflogEntry {
export interface StoredCredential {
username: string;
password: string;
expiresAt?: string | null;
mode?: "credentials" | "token";
}
+3
View File
@@ -2,10 +2,13 @@ import { mount } from "svelte";
import App from "./App.svelte";
import "./app.css";
import { installGlobalErrorTelemetry } from "./lib/telemetry";
const THEME_KEY = "gitlite.theme.v1";
const target = document.getElementById("app");
installGlobalErrorTelemetry();
if (!target) {
throw new Error("App target element was not found.");
}