Compare commits

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

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

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

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

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

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

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

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

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

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

- Add clone_repository command and core cloning logic in Rust
- Create CloneRepositoryDialog and integrate it into App.svelte
- Improve clone-related styling and tighten dialog backdrop behavior
2026-07-05 01:05:23 +02:00
Christoph 6365e164aa update for linux 2026-07-04 18:14:25 +02:00
Christoph f6af156fbd install on linux 2026-07-04 17:49:08 +02:00
Christoph 33cef059e8 Update version to 2026.7.11 2026-07-04 13:56:50 +02:00
Christoph Brandau 9a4c6e5b9b feat(branches): add force delete flow for local branches
publish / publish-tauri (, windows-latest) (release) Successful in 22m57s
Branch deletion now supports an optional force mode in the Tauri
command, enabling deletion of branches that are not fully merged.
The UI replaces the simple confirm prompt with a dedicated dialog and
auto-escalates to force delete when Git rejects a normal delete.

- Add force flag to delete_branch command and tests
- Implement BranchDeleteConfirmDialog and wire it into App.svelte
- Update branch context menu actions and styling
2026-07-04 00:52:40 +02:00
Christoph c2d7fefb47 Merge pull request 'feat(git): add rebase workflow with UI state handling' (#14) from feature/rebase into master
Reviewed-on: #14
2026-07-03 22:36:42 +00:00
Christoph ede2e46d50 Merge pull request 'feat(git stash): add stash listing and push/apply/pop/drop UI' (#13) from feature/stash into master
Reviewed-on: #13
2026-07-03 22:36:24 +00:00
Christoph Brandau 3c4425a408 feat(git): add rebase workflow with UI state handling
This introduces Git rebase commands (start, continue, abort) and
exposes whether a rebase is currently in progress via the status
payload. The UI now blocks committing during an active rebase and
shows a dedicated rebase notice with continue/abort actions.

- Add tauri commands for rebase operations and status detection
- Update frontend to render rebase state and controls
- Adjust conflict/resolution messaging and related UI styling
2026-07-04 00:34:36 +02:00
Christoph Brandau 1d67312ee4 feat(git stash): add stash listing and push/apply/pop/drop UI
This change introduces Git stash support end-to-end, including a new
backend command to list stashes and operations to push, apply, pop, and
drop them. The frontend now fetches stashes as part of the repository
bundle and provides a dedicated panel to manage shelved changes.

- Add GitStash model and Tauri commands for stash operations
- Implement StashPanel component and wire it into the app
- Adjust sidebar layout and add stash-specific styling
2026-07-04 00:16:27 +02:00
Christoph 201bb90bf7 Merge pull request 'feat(history-ui): add resizable commit history aside' (#12) from feature/git_graph into master
Reviewed-on: #12
2026-07-03 21:39:58 +00:00
Christoph Brandau 9c371ec520 feat(history-ui): add resizable commit history aside
The commit history panel is now resizable with a draggable handle and
keyboard support. The chosen width is persisted in local storage so the
layout stays consistent across sessions. Git commit loading was also
adjusted to include all branch tips for a more complete graph.

- Add width persistence and pointer/keyboard resizing for history panel
- Update commit graph query to use topo-order across all refs
- Extend tests to ensure branch tips are included in commit results
2026-07-03 23:35:46 +02:00
Christoph Brandau 835bfae254 feat(history): enhance commit history UI with richer styling
The commit history panel now renders a more structured commit header,
including an avatar, kind badge, and clearer metadata layout. The git
graph visuals were also refined with smoother paths and improved hover
effects to make the history easier to scan.

- Add commit kind/root/merge badges and author initials
- Improve graph rendering with curved paths and hover emphasis
- Update CSS for commit cards, ref chips, and graph styling
2026-07-03 21:32:20 +02:00
Christoph 2bacd473fc Update version to 2026.7.10 2026-07-03 17:17:44 +02:00
Christoph 65e5508d4b Merge pull request 'feat(git): make fetch command asynchronous for improved performance' (#11) from bug/blocking_ui into master
publish / publish-tauri (, windows-latest) (release) Successful in 23m55s
Reviewed-on: #11
2026-07-03 15:15:51 +00:00
Christoph Brandau 1576f61234 feat(git): make fetch command asynchronous for improved performance
The fetch command has been updated to run asynchronously, allowing for
better performance and responsiveness in the application. This change
ensures that the command does not block the main thread, enhancing the
user experience when interacting with repositories.

- Fetch command now uses async/await for non-blocking execution
- Added a grace period to prevent immediate background fetch after
  switching repositories
- Improved handling of authentication errors during fetch operations
2026-07-03 17:14:53 +02:00
Christoph 08de211ba0 Update version to 2026.7.9 2026-07-03 16:43:53 +02:00
Christoph 7001616779 Merge pull request 'Features/badges' (#10) from features/badges into master
publish / publish-tauri (, windows-latest) (release) Successful in 27m44s
Reviewed-on: #10
2026-07-03 14:41:35 +00:00
Christoph Brandau 9228cd1e08 Merge branch 'features/badges' of https://git.cbsk-tech.de/Christoph/GitLite into features/badges 2026-07-03 16:40:57 +02:00
Christoph Brandau 399ba80550 feat(tauri): update Windows taskbar badge with combined count
The Windows overlay badge now renders a single combined number and
includes an additional “changes” component in the total. This makes
the badge more informative while keeping the icon compact and
legible across small sizes.

- Simplify badge rendering to one circle with centered text
- Extend the Tauri command and frontend call to pass changes count
2026-07-03 16:40:47 +02:00
Christoph Brandau b69538de85 chore(badge): adjust badge icon sizing constants
The badge icon rendering was tweaked by slightly reducing the
radius and scale constants. This helps align the icon proportions
and spacing for a cleaner visual result.
2026-07-03 14:51:12 +02:00
Christoph 7b90f4bcc2 Update README.md 2026-07-03 12:42:14 +00:00
Christoph Brandau 96eb8c109c feat(git): add authenticated fetch and dual sync badge
This updates the Windows taskbar overlay badge to show ahead and
behind separately with distinct colors, and clears it when both are
zero. It also adds a new authenticated fetch command wired through
the Tauri backend and UI, including a silent background fetch to keep
ahead/behind (and the badge) accurate without user interaction.

- Add Windows dual badge rendering for ahead/behind
- Implement Tauri fetch command with auth error handling
- Add UI fetch action plus periodic background fetch updates
2026-07-03 14:29:29 +02:00
Christoph Brandau 276dfbab07 feat(tauri): add Windows taskbar overlay badge for git sync status
This change introduces a Windows-only taskbar badge that displays the
ahead+behind count as a rendered overlay icon. The UI now updates
the badge whenever the repository sync status changes, and the git
diff commands are adjusted to avoid external diff/text conversion noise.

- Add badge rendering and Tauri command for setting overlay icon
- Wire badge updates into the Svelte app based on git status
- Update git diff invocations to disable ext-diff/textconv
2026-07-03 13:49:43 +02:00
Christoph 883cf3ebd1 Update version to 2026.7.8 2026-07-03 07:35:17 +02:00
Christoph 6e283ddf83 Merge pull request 'Features/ai commits' (#9) from features/ai_commits into master
publish / publish-tauri (, windows-latest) (release) Successful in 31m38s
Reviewed-on: #9
2026-07-03 05:27:15 +00:00
Christoph Brandau 979d5aed80 feat(ai): disable local provider and improve commit file display
Local AI is now treated as unavailable in the settings UI, with a
migration to ensure any previously saved "local" selection switches
back to OpenAI. The history panel also improves how commit file
names are presented, including clearer old->new path formatting.

- Migrate stored AI provider away from local to prevent dead state
- Disable local provider option with an "in development" badge
- Refine history panel filename rendering and tooltip context
2026-07-03 07:26:23 +02:00
Christoph Brandau 791d686c48 feat(commit-ai): enhance commit message generation and caching
Improve the commit message generation process by adding a caching mechanism and refining the input handling. This change aims to enhance performance and prevent redundant computations when generating commit messages based on staged changes.

- **src-tauri/crates/commit_ai/src/cloud.rs**:
  - Introduced `looks_like_diff_echo` function to detect if the model's output is a diff instead of a commit message.
  - Updated `openai_compatible_request` and `generate_anthropic` to utilize the new function for error handling.

- **src-tauri/crates/commit_ai/src/lib.rs**:
  - Added `LocalGenerationProfile` enum for managing different generation profiles.
  - Implemented caching for generated messages to avoid redundant processing.
  - Updated `generate_commit_message` to incorporate caching logic.

- **src-tauri/src/git.rs**:
  - Added `staged_diff_local` function to handle local profile generation and exclude specific lock files from the diff.
  - Modified `commit_ai_generate` to accept and process the local generation profile.

- **src/App.svelte**:
  - Added `lastLocalAiGeneratedMessage` state to track the last generated message and prevent unnecessary updates.

- **.claude/settings.local.json**:
  - Updated settings to include additional commands for better functionality.
2026-07-03 07:03:35 +02:00
Christoph 7ca6abf962 Update version to 2026.7.7 2026-07-02 22:17:49 +02:00
Christoph 6c1c4cdbfe Merge pull request 'Features/ai commits' (#8) from features/ai_commits into master
publish / publish-tauri (, windows-latest) (release) Successful in 37m46s
Reviewed-on: #8
2026-07-02 20:15:00 +00:00
Christoph Brandau 70de45e1ba feat(ui): localize commit AI and git error messages to English
Translate commit AI prompts, model labels, and git/keychain errors to
English so the app and generated messages are consistent. Also add a
discard confirmation dialog and update the UI text/styles to match the
new flow.

- src-tauri/crates/commit_ai/src/cloud.rs
  - Translate HTTP and API error messages to English.
  - Keep request timeout and token sizing behavior unchanged.
- src-tauri/crates/commit_ai/src/lib.rs
  - Translate model labels, prompt text, and validation errors.
  - Keep diff truncation and message sanitization logic intact.
- src-tauri/src/git.rs
  - Translate git, credential, merge, and history errors.
  - Update AI provider validation messages to English.
- src/lib/components/*
  - Update AI settings, commit panel, credential, and loading UI text.
  - Add discard confirmation dialog for destructive actions.
- src/App.svelte, src/app.css
  - Adjust app layout and styling for the new dialog and text changes.
2026-07-02 22:12:20 +02:00
Christoph Brandau 6b7186d040 feat(ai): add cloud providers and local model selection
Add OpenAI-compatible, Anthropic, and custom endpoint support while
keeping the local model path intact. The UI now lets users choose the
provider and local model, and staged diffs are prepared more carefully
so generated commit messages stay focused and usable.

- src-tauri/crates/commit_ai/*
  - Add HTTP-based generators for OpenAI, Anthropic, and custom APIs.
  - Introduce shared request/response handling and message sanitizing.
  - Expand prompt building to require a body and trim long diffs safely.
  - Expose selectable local model metadata and loading by model ID.
- src-tauri/src/git.rs
  - Add commands for listing local models and loading them in background.
  - Route generation by provider and include staged file lists in prompts.
  - Exclude noisy lockfiles from detailed staged diffs.
- src-tauri/src/main.rs
  - Wire the new AI commands into the Tauri app setup.
- src/lib/components/*
  - Add an AI settings dialog and update the commit panel for provider
    and model selection.
- src/lib/git.ts, src/lib/types.ts, src/App.svelte, src/app.css
  - Extend frontend state, types, and styling for AI provider settings.
- src-tauri/Cargo.lock, src-tauri/crates/commit_ai/Cargo.toml
  - Add reqwest and serde_json for cloud API requests.
2026-07-02 21:19:16 +02:00
Christoph Brandau d415cbd3a1 add upstream 2026-07-02 20:24:46 +02:00
Christoph Brandau f2aa48d2ec try to use local ai to generate commit message 2026-07-02 19:59:16 +02:00
Christoph 2a96e79d27 Update version to 2026.7.6 2026-07-02 16:27:47 +02:00
Christoph 2f0904e839 Merge pull request 'add new Context Menu for file Explorer' (#7) from bug/blockingUI into master
publish / publish-tauri (, windows-latest) (release) Successful in 7m58s
Reviewed-on: #7
2026-07-02 14:25:56 +00:00
Christoph Brandau e3df75cc38 add new Context Menu for file Explorer
fix blocking UI
when select the file in Status also select in the file History
2026-07-02 16:24:38 +02:00
Christoph 794680a696 Update version to 2026.7.5 2026-07-02 12:43:41 +02:00
Christoph 18476d9d92 Merge pull request 'make the Commit restore more safe' (#6) from bug/blockingUI into master
publish / publish-tauri (, windows-latest) (release) Successful in 8m34s
Reviewed-on: #6
2026-07-02 10:41:33 +00:00
Christoph Brandau 3a8c114d82 make the Commit restore more safe 2026-07-02 12:40:53 +02:00
Christoph 0edf2819dc Update version to 2026.7.4 2026-07-02 12:20:24 +02:00
Christoph 5d34147b41 Merge pull request 'add Loading Spinner when file History is loading' (#5) from bug/blockingUI into master
publish / publish-tauri (, windows-latest) (release) Successful in 8m44s
Reviewed-on: #5
2026-07-02 10:18:31 +00:00
Christoph Brandau e18f8f1040 add Loading Spinner when file History is loading
Make Renamed instad of Del and untracked
2026-07-02 12:17:57 +02:00
Christoph 310a0fb09a Update version to 2026.7.3 2026-07-02 08:38:23 +02:00
Christoph b04158926d Merge pull request 'Features/repo mangament' (#4) from Features/repo_mangament into master
publish / publish-tauri (, windows-latest) (release) Successful in 9m10s
Reviewed-on: #4
2026-07-02 06:36:27 +00:00
Christoph Brandau eef4869bbb add Repo Managment / Kontext Menu in Braches 2026-07-02 08:35:29 +02:00
Christoph Brandau 97fc4fc1e0 Implement repository management view and multi-repo tabs
Introduces a new "Repository Management" view for browsing open and recent repositories. Adds a tabbed interface for quickly switching between multiple active repositories, with persistent state. Also includes a new command to open the current repository in the native file explorer.
2026-07-02 07:53:29 +02:00
Christoph e6d22765be Update version to 2026.7.2 2026-07-01 14:51:02 +02:00
Christoph 312727ee73 Merge pull request 'Implement interactive hunk staging and discarding' (#3) from Features/lineStageDiscard into master
publish / publish-tauri (, windows-latest) (release) Successful in 8m48s
Reviewed-on: #3
2026-07-01 12:48:55 +00:00
Christoph Brandau 5752243e6e Implement interactive hunk staging and discarding
Introduce a new dialog that allows users to view file changes as individual hunks and selectively stage, unstage, or discard them. This provides more granular control over modifications, similar to `git add -p`.

Enhance branch panel usability by allowing double-click to checkout a branch.
2026-07-01 14:47:36 +02:00
Christoph Brandau 0bbd5ce1e8 Limit Tauri bundle targets to NSIS
publish / publish-tauri (, windows-latest) (release) Successful in 9m11s
2026-07-01 13:03:45 +02:00
Christoph 50d9232084 Update version to 2026.7.1 2026-07-01 11:47:09 +02:00
Christoph 357a0b7c5f Update version to 2026.07.1
publish / publish-tauri (, windows-latest) (release) Failing after 7m21s
2026-07-01 11:42:53 +02:00
Christoph ab54b9b6b2 Merge pull request 'Features/extend search' (#2) from Features/extendSearch into master
publish / publish-tauri (, windows-latest) (release) Failing after 2m24s
Reviewed-on: #2
2026-07-01 09:40:53 +00:00
Christoph Brandau fe392d38bf Optimize repository loading and enhance Git UI
Consolidate multiple Git backend calls into a single bundle to reduce
overhead when opening and auto-refreshing repositories. This significantly
improves performance by fetching status, branches, commits, and files
in one optimized operation, instead of re-running 'git rev-parse' and
'git status' multiple times.

Also, streamline commit history loading by fetching file changes inline
via 'git log --name-status -z', eliminating expensive per-commit
'git diff-tree' processes.

Additionally, introduce the ability to create new branches from a
specific commit in the history and refactor the commit comparison
feature into a dedicated dialog. The status panel now displays concise
file names.
2026-07-01 11:39:22 +02:00
Christoph Brandau 628e2f7c0b Add repository opening loading overlay
Introduce a full-screen, animated overlay to provide visual feedback
when the application is opening a repository. This prevents perceived
delays and informs the user about the ongoing operation.

Also, enhance build performance and binary size by configuring
Cargo profiles:
- Development: Enable incremental compilation, use line-tables-only
  debug info, and optimize third-party dependencies to speed up
  rebuilds and improve runtime snappiness during development.
- Release: Apply aggressive optimizations (opt-level 3, thin LTO,
  single codegen unit, stripping) for smaller, faster binaries.
2026-07-01 10:40:22 +02:00
Christoph Brandau 1c7c53ddf9 Add branch creation and file history search
Implement the ability to create new local branches directly from the application's UI. This includes a new backend command to handle branch creation with validation and a frontend form in the branch panel.

Additionally, extend the global search dialog to include a "Files" tab. Users can now search for files by name or path within the repository. Selecting a file in the search results displays its full commit history, with options to diff the file at a specific commit or restore it to that version.
2026-07-01 10:07:59 +02:00
Christoph 900159a3a9 Update version to 0.1.0 2026-06-30 20:36:09 +02:00
Christoph d800417d6d Merge pull request 'Improve global search: Add diffing from results with query highlighting' (#1) from FixSearch into master
publish / publish-tauri (, windows-latest) (release) Successful in 6m34s
Reviewed-on: #1
2026-06-30 18:33:59 +00:00
Christoph Brandau 405f302db9 Improve global search: Add diffing from results with query highlighting
Users can now open a diff for a specific search hit directly from the global search results. The original search query will be highlighted within the diff view, making it easier to locate the relevant changes.

Additionally, add `--no-textconv` to git commands used in search and diff operations. This prevents failures when git encounters binary files that textconv drivers might otherwise attempt to process.
2026-06-30 20:32:59 +02:00
Christoph 2cc1d34fc3 Update version to 0.0.6 2026-06-30 19:19:22 +02:00
Christoph Brandau 089aae5f5b Merge remote-tracking branch 'origin/master'
publish / publish-tauri (, windows-latest) (release) Successful in 7m17s
2026-06-30 19:17:25 +02:00
Christoph Brandau 841d1a41b7 Suppress Git console window on Windows
When executing Git commands on Windows, a console window would briefly flash. This change applies the CREATE_NO_WINDOW flag to prevent this, providing a smoother user experience.
2026-06-30 19:17:17 +02:00
Christoph d57b574fe1 Update version to 0.0.5 2026-06-30 17:27:18 +02:00
Christoph ec1f10d535 Update version to 0.0.3
publish / publish-tauri (, windows-latest) (release) Successful in 6m45s
2026-06-30 16:58:36 +02:00
Christoph Brandau 727461ce6c Merge remote-tracking branch 'origin/master'
publish / publish-tauri (, windows-latest) (release) Successful in 7m16s
2026-06-30 16:56:34 +02:00
Christoph Brandau c5853a6e9b Add in-app updater functionality 2026-06-30 16:56:17 +02:00
Christoph ea308c47f1 Update version to 0.0.2 2026-06-30 16:17:52 +02:00
Christoph Brandau 9ebddf3abe Rename project and identifiers to GitLite
publish / publish-tauri (, windows-latest) (release) Successful in 6m32s
The "Tauri" prefix was removed for conciseness, as it's implicit to the project's framework.
2026-06-30 16:15:46 +02:00
43 changed files with 13885 additions and 721 deletions
+57 -1
View File
@@ -23,7 +23,63 @@
"Bash(npx vite *)",
"Bash(cargo tree *)",
"Bash(jobs)",
"Bash(npx svelte-check *)"
"Bash(npx svelte-check *)",
"Bash(git log *)",
"Bash(xxd)",
"Bash(python3 -)",
"Bash(echo \"exit: $?\")",
"Bash(grep -n \"input,\\\\|select,\\\\|input {\\\\|select {\\\\|.repo-form input\\\\|input:focus\\\\|::placeholder\" src/app.css)",
"Bash(sudo -n true)",
"Bash(rustc --version)",
"Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)",
"Bash(sudo apt install -y libdbus-1-dev pkg-config)",
"Bash(dpkg -l)",
"Bash(apt list *)",
"Bash(cargo search *)",
"Bash(curl -s \"https://crates.io/api/v1/crates/mistralrs\")",
"Bash(cargo info *)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(gh api *)",
"WebFetch(domain:github.com)",
"WebFetch(domain:ericlbuehler.github.io)",
"WebFetch(domain:docs.rs)",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF\")",
"Bash(python3 -c ' *)",
"Bash(curl -sI \"https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf\")",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF?blobs=true\")",
"Bash(grep -n 'from \"\\\\./lib/git\"\\\\|from \"\\\\./lib/types\"\\\\|^ commit,$' src/App.svelte)",
"Bash(kill 24343 24363 24375 24376)",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-0.5B-Instruct-GGUF?blobs=true\")",
"Bash(curl -s \"https://huggingface.co/api/models/bartowski/Llama-3.2-3B-Instruct-GGUF?blobs=true\")",
"Bash(curl -s \"https://huggingface.co/api/models/bartowski/Llama-3.2-3B-Instruct-GGUF\")",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct-GGUF\")",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct-GGUF?blobs=true\")",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct\")",
"Bash(: *)",
"Bash(exit 0 *)",
"Bash(rustc -O sanitize_test.rs -o sanitize_test)",
"Bash(./sanitize_test)",
"Bash(echo \"exit:$?\")",
"Bash(grep -rlP \"[äöüßÄÖÜ]\" src src-tauri/src src-tauri/crates --include=\"*.rs\" --include=\"*.svelte\" --include=\"*.ts\")",
"Bash(echo \"---exit $?---\")",
"Bash(apt-cache policy *)",
"Bash(timeout 5 curl -sI http://archive.ubuntu.com)",
"Bash(sudo -n apt-get install -y libdbus-1-dev pkg-config)",
"Bash(grep -n '\"Unerwarteter Git-Log-Eintrag: {}\",' src-tauri/src/git.rs)",
"Bash(grep -E \"git_lite$|tauri_git_lite$\")",
"Bash(rustfmt --edition 2024 --check src-tauri/src/git.rs)",
"Bash(echo \"EXIT:$?\")",
"Bash(ls target/)",
"Bash(rustup target *)",
"Bash(echo \"exit code: $?\")",
"Read(//home/cbr/.cargo/registry/src/**)",
"Bash(find / -maxdepth 6 -iname \"mistralrs-*\" -type d)",
"Bash(grep -A2 '^name = \"tauri\"$' \"/mnt/c/Users/cbr/Desktop/Neuer Ordner \\(6\\)/src-tauri/Cargo.lock\")",
"Bash(rustfmt --edition 2024 --check src/badge.rs src/main.rs)",
"Bash(rustfmt --edition 2024 --check src/git.rs)",
"Bash(rustfmt --edition 2024 --check src/badge.rs)",
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)",
"Bash(pkg-config --list-all)"
]
}
}
+25 -14
View File
@@ -32,16 +32,21 @@ jobs:
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
# - platform: 'macos-latest'
# args: '--target aarch64-apple-darwin'
# - platform: 'macos-latest'
# args: '--target x86_64-apple-darwin'
# - platform: 'ubuntu-22.04'
# args: ''
- platform: "windows-latest"
args: ""
bundles: "nsis"
updater_platform: "windows-x86_64"
no_strip: ""
- platform: "ubuntu-22.04"
bundles: "appimage"
updater_platform: "linux-x86_64"
no_strip: "1"
runs-on: ${{ matrix.platform }}
@@ -55,13 +60,21 @@ jobs:
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install uv
if: matrix.platform == 'ubuntu-22.04'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
- name: install rust toolchain
if: matrix.platform == 'ubuntu-22.04'
uses: dtolnay/rust-toolchain@stable
- name: install frontend dependencies
run: npm ci
@@ -69,18 +82,13 @@ jobs:
run: npm version ${{ github.ref_name }} --no-git-tag-version --allow-same-version
- name: Update tauri.conf.json Version
shell: powershell
env:
RELEASE_VERSION: ${{ github.ref_name }}
run: |
$path = ".\src-tauri\tauri.conf.json"
$version = '${{ github.ref_name }}'
$content = Get-Content -Raw -Encoding UTF8 $path
$regex = [regex]'"version"\s*:\s*"[^"]*"'
$replacement = '"version": "' + $version + '"'
$updated = $regex.Replace($content, $replacement, 1)
$encoding = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($path, $updated, $encoding)
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
- name: Commit and Push Changes
if: matrix.platform == 'windows-latest'
shell: powershell
run: |
git config user.name '${{ vars.USERNAME_GIT }}'
@@ -131,12 +139,15 @@ jobs:
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
run: npm run tauri build
NO_STRIP: ${{ matrix.no_strip }}
run: npm run tauri -- build --bundles ${{ matrix.bundles }}
# ----------------------
# Upload to MinIO (via mc) and create latest.json
# ----------------------
- name: Upload artifacts to MinIO with cicd_tool
working-directory: GitLite/cicd_tool
env:
PUBLISH_PLATFORM: ${{ matrix.updater_platform }}
run: |
uv sync
uv run main.py
+3 -1
View File
@@ -4,4 +4,6 @@
*.log
.idea
.DS_Store
~
~
.codex*
target
-44
View File
@@ -1,45 +1 @@
# Tauri Git Lite
Eine kleine Git-Desktop-App mit Tauri, Rust und Svelte.
## Funktionen
- Repository per lokalem Pfad öffnen
- aktuellen Branch, Upstream, Ahead/Behind und Arbeitsbaumstatus anzeigen
- Branches auflisten und auschecken
- Branches in den aktuellen Branch mergen
- Commit-History mit geänderten Dateien anzeigen
- Explorer-Ansicht mit Ordnerbaum sowie getrackten und ungetrackten Dateien
- Datei- und Ordner-History direkt aus dem Explorer anzeigen
- einzelne Dateien stagen, unstagen und wiederherstellen
- einzelne Dateien oder ganze Ordner aus einem History-Commit wiederherstellen
- aktuellen Branch auf einen ausgewählten Commit zurücksetzen
- Commit mit Message erstellen
- Pull mit Fast-Forward-Strategie
- Push auf den konfigurierten Upstream
## Entwicklung
```powershell
npm install
npm run tauri:dev
```
Frontend allein:
```powershell
npm run dev
```
Checks:
```powershell
npm run check
npm run build
cd src-tauri
cargo check
```
## Hinweis
Die App nutzt das lokal installierte `git` CLI. Ein Repository muss daher bereits auf der Maschine vorhanden sein, und Push/Pull verwenden die Credentials, die Git lokal kennt.
+122 -29
View File
@@ -4,7 +4,7 @@ import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, List, Optional
from typing import Any, Iterable, List, Optional
from urllib.parse import urlparse
from minio import Minio
@@ -115,23 +115,57 @@ def _ensure_bucket(client: Minio, bucket_name: str) -> None:
def _collect_artifacts() -> List[Path]:
artifacts: List[Path] = []
for directory in (
ARTIFACT_ROOT / "msi",
ARTIFACT_ROOT / "nsis",
ARTIFACT_ROOT / "app",
):
bundle_dirs = {
ARTIFACT_ROOT / "msi": (".msi", ".sig"),
ARTIFACT_ROOT / "nsis": (".exe", ".sig"),
ARTIFACT_ROOT / "appimage": (".appimage", ".sig"),
ARTIFACT_ROOT / "deb": (".deb", ".sig"),
ARTIFACT_ROOT / "rpm": (".rpm", ".sig"),
ARTIFACT_ROOT / "app": (".dmg", ".zip", ".sig"),
}
for directory, suffixes in bundle_dirs.items():
if directory.exists():
artifacts.extend(p for p in directory.rglob("*") if p.is_file())
artifacts.extend(
p
for p in directory.iterdir()
if p.is_file() and p.suffix.lower() in suffixes
)
return artifacts
def _select_primary_artifact(artifacts: Iterable[Path]) -> Optional[Path]:
priority = (".exe", ".msi", ".zip", ".dmg")
def _select_primary_artifact(
artifacts: Iterable[Path], platform: Optional[str] = None
) -> Optional[Path]:
artifact_list = list(artifacts)
priorities = {
"windows-x86_64": (".exe", ".msi"),
"linux-x86_64": (".appimage", ".deb", ".rpm"),
"darwin-x86_64": (".dmg", ".zip"),
"darwin-aarch64": (".dmg", ".zip"),
}
priority = priorities.get(platform or "", ())
priority += (".exe", ".msi", ".appimage", ".deb", ".rpm", ".zip", ".dmg")
for ext in priority:
for artifact in artifacts:
for artifact in artifact_list:
if artifact.suffix.lower() == ext:
return artifact
return next(iter(artifacts), None)
return next(
(artifact for artifact in artifact_list if artifact.suffix != ".sig"), None
)
def _infer_platform(artifact: Path) -> str:
suffix = artifact.suffix.lower()
if suffix in {".exe", ".msi"}:
return "windows-x86_64"
if suffix in {".appimage", ".deb", ".rpm"}:
return "linux-x86_64"
if suffix in {".dmg", ".zip"}:
return "darwin-x86_64"
raise ConfigurationError(
f"Cannot infer updater platform from artifact: {artifact.name}. "
"Set PUBLISH_PLATFORM explicitly."
)
def _read_signature(artifact: Path) -> str:
@@ -145,16 +179,55 @@ def _read_signature(artifact: Path) -> str:
raise FileNotFoundError(f"Signature not found for {artifact.name}")
def _build_latest_json(
def _build_artifact_url(
version: str,
primary_artifact: Path,
signature: str,
artifact_base_url: str,
app_dir: str,
) -> str:
base_url = artifact_base_url.rstrip("/")
path_parts = [app_dir.strip("/"), version, primary_artifact.name]
artifact_url = "/".join([base_url, *filter(None, path_parts)])
return "/".join([base_url, *filter(None, path_parts)])
def _read_existing_latest_json(
client: Minio, bucket_name: str
) -> Optional[dict[str, Any]]:
try:
response = client.get_object(bucket_name, "latest.json")
except S3Error as exc:
if exc.code in {"NoSuchKey", "NoSuchObject"}:
return None
raise
try:
payload = json.loads(response.read().decode("utf-8"))
except json.JSONDecodeError as exc:
raise ConfigurationError("Existing latest.json is invalid JSON") from exc
finally:
response.close()
response.release_conn()
if not isinstance(payload, dict):
raise ConfigurationError("Existing latest.json must be a JSON object")
return payload
def _build_latest_payload(
version: str,
platform: str,
primary_artifact: Path,
signature: str,
artifact_base_url: str,
app_dir: str,
existing_payload: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
artifact_url = _build_artifact_url(
version=version,
primary_artifact=primary_artifact,
artifact_base_url=artifact_base_url,
app_dir=app_dir,
)
pub_date = (
datetime.now(timezone.utc)
@@ -163,18 +236,27 @@ def _build_latest_json(
.replace("+00:00", "Z")
)
latest_payload = {
platforms: dict[str, Any] = {}
existing_notes = None
if existing_payload and existing_payload.get("version") == version:
existing_platforms = existing_payload.get("platforms")
if isinstance(existing_platforms, dict):
platforms.update(existing_platforms)
existing_notes = existing_payload.get("notes")
platforms[platform] = {
"signature": signature,
"url": artifact_url,
}
return {
"version": version,
"pub_date": pub_date,
"notes": os.environ.get("RELEASE_NOTES", "Automated release"),
"platforms": {
"windows-x86_64": {
"signature": signature,
"url": artifact_url,
}
},
"notes": os.environ.get("RELEASE_NOTES")
or existing_notes
or "Automated release",
"platforms": platforms,
}
return json.dumps(latest_payload, separators=(",", ":"))
def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
@@ -194,28 +276,36 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
"No release artifacts found in src-tauri/target/release/bundle."
)
primary = _select_primary_artifact(artifacts)
publish_platform = os.environ.get("PUBLISH_PLATFORM") or os.environ.get(
"UPDATER_PLATFORM"
)
primary = _select_primary_artifact(artifacts, publish_platform)
if not primary:
raise FileNotFoundError("Unable to determine primary artifact to publish.")
platform = publish_platform or _infer_platform(primary)
signature = _read_signature(primary)
latest_json = _build_latest_json(
client = _create_minio_client()
_ensure_bucket(client, bucket_name)
existing_latest_json = _read_existing_latest_json(client, bucket_name)
latest_payload = _build_latest_payload(
version=resolved_version,
platform=platform,
primary_artifact=primary,
signature=signature,
artifact_base_url=artifact_base_url,
app_dir=bucket_prefix,
existing_payload=existing_latest_json,
)
latest_json = json.dumps(latest_payload, separators=(",", ":"))
local_dist_dir = PROJECT_DIR / "dist"
local_dist_dir.mkdir(parents=True, exist_ok=True)
latest_json_path = local_dist_dir / "latest.json"
latest_json_path.write_text(latest_json + "\n", encoding="utf-8")
client = _create_minio_client()
_ensure_bucket(client, bucket_name)
for artifact in artifacts:
object_segments = [part for part in (resolved_version, artifact.name) if part]
object_name = "/".join(object_segments)
@@ -231,7 +321,10 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
length=len(latest_bytes),
content_type="application/json",
)
print(f"Uploaded latest.json -> s3://{bucket_name}/{latest_object_name}")
print(
f"Uploaded latest.json for {platform} -> "
f"s3://{bucket_name}/{latest_object_name}"
)
if __name__ == "__main__":
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "tauri-git-lite",
"version": "0.0.1",
"name": "git-lite",
"version": "2026.7.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tauri-git-lite",
"version": "0.0.1",
"name": "git-lite",
"version": "2026.7.13",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "tauri-git-lite",
"version": "0.0.1",
"name": "git-lite",
"version": "2026.7.13",
"private": true,
"type": "module",
"scripts": {
+3543 -54
View File
File diff suppressed because it is too large Load Diff
+31 -3
View File
@@ -1,20 +1,48 @@
[package]
name = "tauri_git_lite"
name = "git_lite"
version = "0.1.0"
description = "Rust backend for a lightweight Git desktop client"
edition = "2021"
rust-version = "1.77"
edition = "2024"
rust-version = "1.88"
build = "build.rs"
[workspace]
members = [".", "crates/commit_ai"]
[workspace.package]
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "=2.7.0"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
commit_ai = { path = "crates/commit_ai" }
[build-dependencies]
tauri-build = { version = "2", features = [] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
# ── Build profiles ───────────────────────────────────────────────────────────
# Dev: keep incremental compilation and emit only line-table debug info instead
# of full debuginfo. This cuts link time noticeably (linking is the slow part of
# a `tauri dev` recompile) while still giving panic backtraces with line numbers.
[profile.dev]
incremental = true
debug = "line-tables-only"
# Optimize third-party dependencies once (they are cached), so the running dev
# build is snappy without slowing down rebuilds of our own crate.
[profile.dev.package."*"]
opt-level = 2
# Release: smaller binary (also shrinks the auto-updater download) without
# regressing runtime performance of the code-search feature.
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "commit_ai"
version = "0.1.0"
edition = "2024"
[dependencies]
mistralrs = "0.8"
tokio = { version = "1", features = ["sync"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+222
View File
@@ -0,0 +1,222 @@
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::{build_messages, looks_like_diff_echo, sanitize_message};
// Generous sizing so a detailed body with bullet points isn't cut off.
const DEFAULT_MAX_TOKENS: u32 = 1500;
// Without a timeout, a hanging endpoint would permanently block the "AI" button.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
fn http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(|err| format!("Could not create HTTP client: {err}"))
}
#[derive(Serialize)]
struct OpenAiMessage {
role: &'static str,
content: String,
}
#[derive(Serialize)]
struct OpenAiRequest {
model: String,
messages: Vec<OpenAiMessage>,
temperature: f32,
}
#[derive(Deserialize)]
struct OpenAiResponseMessage {
content: Option<String>,
}
#[derive(Deserialize)]
struct OpenAiChoice {
message: OpenAiResponseMessage,
}
#[derive(Deserialize)]
struct OpenAiResponse {
#[serde(default)]
choices: Vec<OpenAiChoice>,
}
async fn openai_compatible_request(
url: String,
bearer: Option<&str>,
model: &str,
diff: &str,
notes: Option<&str>,
) -> Result<String, String> {
let (system, user) = build_messages(diff, notes)?;
let body = OpenAiRequest {
model: model.to_string(),
messages: vec![
OpenAiMessage {
role: "system",
content: system,
},
OpenAiMessage {
role: "user",
content: user,
},
],
temperature: 0.3,
};
let client = http_client()?;
let mut request = client.post(url).json(&body);
if let Some(key) = bearer.filter(|k| !k.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}"))?;
let message = 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 response.".to_string())?;
if looks_like_diff_echo(&message) {
return Err("The model returned the diff instead of a commit message.".to_string());
}
Ok(message)
}
pub async fn generate_openai(
api_key: &str,
model: &str,
diff: &str,
notes: Option<&str>,
) -> Result<String, String> {
if api_key.trim().is_empty() {
return Err("OpenAI API key is missing.".to_string());
}
openai_compatible_request(
"https://api.openai.com/v1/chat/completions".to_string(),
Some(api_key),
model,
diff,
notes,
)
.await
}
pub async fn generate_custom(
base_url: &str,
api_key: Option<&str>,
model: &str,
diff: &str,
notes: Option<&str>,
) -> Result<String, String> {
if base_url.trim().is_empty() {
return Err("Endpoint URL is missing.".to_string());
}
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
openai_compatible_request(url, api_key, model, diff, notes).await
}
#[derive(Serialize)]
struct AnthropicMessage {
role: &'static str,
content: String,
}
#[derive(Serialize)]
struct AnthropicRequest {
model: String,
max_tokens: u32,
system: String,
messages: Vec<AnthropicMessage>,
}
#[derive(Deserialize)]
struct AnthropicContentBlock {
#[serde(default)]
text: Option<String>,
}
#[derive(Deserialize)]
struct AnthropicResponse {
#[serde(default)]
content: Vec<AnthropicContentBlock>,
}
pub async fn generate_anthropic(
api_key: &str,
model: &str,
diff: &str,
notes: Option<&str>,
) -> Result<String, String> {
if api_key.trim().is_empty() {
return Err("Anthropic API key is missing.".to_string());
}
let (system, user) = build_messages(diff, notes)?;
let body = AnthropicRequest {
model: model.to_string(),
max_tokens: DEFAULT_MAX_TOKENS,
system,
messages: vec![AnthropicMessage {
role: "user",
content: user,
}],
};
let client = http_client()?;
let response = client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.json(&body)
.send()
.await
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Could not read response: {err}"))?;
if !status.is_success() {
return Err(format!("API error ({status}): {text}"));
}
let parsed: AnthropicResponse =
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
let message = 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 response.".to_string())?;
if looks_like_diff_echo(&message) {
return Err("The model returned the diff instead of a commit message.".to_string());
}
Ok(message)
}
+445
View File
@@ -0,0 +1,445 @@
mod cloud;
pub use cloud::{generate_anthropic, generate_custom, generate_openai};
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
sync::Arc,
};
use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole};
use tokio::sync::RwLock;
/// One selectable local (on-device) model. Larger models produce better commit messages
/// but take longer to download (first run only, then cached) and run slower on CPU.
#[derive(Debug, Clone, serde::Serialize)]
pub struct LocalModelOption {
pub id: &'static str,
pub label: &'static str,
pub approx_size_mb: u32,
repo: &'static str,
file: &'static str,
tokenizer_repo: &'static str,
}
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-0.5b";
pub const LOCAL_MODELS: &[LocalModelOption] = &[
LocalModelOption {
id: "qwen2.5-0.5b",
label: "Qwen2.5 0.5B Instruct — fast, lower quality",
approx_size_mb: 490,
repo: "Qwen/Qwen2.5-0.5B-Instruct-GGUF",
file: "qwen2.5-0.5b-instruct-q4_k_m.gguf",
tokenizer_repo: "Qwen/Qwen2.5-0.5B-Instruct",
},
LocalModelOption {
id: "qwen2.5-1.5b",
label: "Qwen2.5 1.5B Instruct — recommended",
approx_size_mb: 1050,
repo: "Qwen/Qwen2.5-1.5B-Instruct-GGUF",
file: "qwen2.5-1.5b-instruct-q4_k_m.gguf",
tokenizer_repo: "Qwen/Qwen2.5-1.5B-Instruct",
},
LocalModelOption {
id: "qwen2.5-3b",
label: "Qwen2.5 3B Instruct — best quality, slower",
approx_size_mb: 2100,
repo: "Qwen/Qwen2.5-3B-Instruct-GGUF",
file: "qwen2.5-3b-instruct-q4_k_m.gguf",
tokenizer_repo: "Qwen/Qwen2.5-3B-Instruct",
},
];
fn find_local_model(model_id: &str) -> Option<&'static LocalModelOption> {
LOCAL_MODELS.iter().find(|option| option.id == model_id)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LocalGenerationProfile {
Fast,
Balanced,
Detailed,
}
impl Default for LocalGenerationProfile {
fn default() -> Self {
Self::Fast
}
}
impl LocalGenerationProfile {
pub fn from_id(value: Option<&str>) -> Self {
match value
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"balanced" => Self::Balanced,
"detailed" => Self::Detailed,
_ => Self::Fast,
}
}
pub fn diff_unified_context(self) -> &'static str {
match self {
Self::Fast => "--unified=1",
Self::Balanced => "--unified=2",
Self::Detailed => "--unified=3",
}
}
fn max_diff_chars(self) -> usize {
match self {
Self::Fast => 8_000,
Self::Balanced => 12_000,
Self::Detailed => 24_000,
}
}
fn max_output_tokens(self) -> usize {
match self {
Self::Fast => 160,
Self::Balanced => 360,
Self::Detailed => 750,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CommitAiPhase {
/// Nothing has been requested yet.
Idle,
/// Downloading (first run only, then cached by hf-hub) and/or loading into memory.
Loading,
Ready,
Error,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CommitAiStatus {
pub phase: CommitAiPhase,
pub model_id: Option<String>,
pub error: Option<String>,
}
struct Inner {
phase: CommitAiPhase,
model_id: Option<String>,
error: Option<String>,
model: Option<Arc<Model>>,
cache: Option<GenerationCache>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GenerationCacheKey {
model_id: String,
profile: LocalGenerationProfile,
input_hash: u64,
}
#[derive(Debug, Clone)]
struct GenerationCache {
key: GenerationCacheKey,
message: String,
}
/// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls
/// (see [`cloud`]) and don't need this — there's nothing to download or keep loaded.
#[derive(Clone)]
pub struct CommitAiEngine {
inner: Arc<RwLock<Inner>>,
}
impl Default for CommitAiEngine {
fn default() -> Self {
Self {
inner: Arc::new(RwLock::new(Inner {
phase: CommitAiPhase::Idle,
model_id: None,
error: None,
model: None,
cache: None,
})),
}
}
}
impl CommitAiEngine {
pub fn new() -> Self {
Self::default()
}
pub async fn status(&self) -> CommitAiStatus {
let guard = self.inner.read().await;
CommitAiStatus {
phase: guard.phase,
model_id: guard.model_id.clone(),
error: guard.error.clone(),
}
}
/// Downloads (first run only; hf-hub caches the files afterwards) and loads the given
/// local model. Safe to call repeatedly — a call for the model that's already
/// ready/loading is a no-op; a call for a *different* model switches to it (the
/// previous one is dropped once no generation is still using it).
pub async fn ensure_loaded(&self, model_id: &str) {
{
let guard = self.inner.read().await;
let same_model = guard.model_id.as_deref() == Some(model_id);
if same_model && matches!(guard.phase, CommitAiPhase::Ready | CommitAiPhase::Loading) {
return;
}
}
let Some(option) = find_local_model(model_id) else {
let mut guard = self.inner.write().await;
guard.phase = CommitAiPhase::Error;
guard.model_id = Some(model_id.to_string());
guard.error = Some(format!("Unknown local model: {model_id}"));
guard.cache = None;
return;
};
{
let mut guard = self.inner.write().await;
guard.phase = CommitAiPhase::Loading;
guard.model_id = Some(model_id.to_string());
guard.error = None;
guard.model = None;
guard.cache = None;
}
let result = GgufModelBuilder::new(option.repo, vec![option.file])
.with_tok_model_id(option.tokenizer_repo)
.with_logging()
.build()
.await;
let mut guard = self.inner.write().await;
// If the user switched to yet another model while this one was loading, drop this
// (now stale) result instead of overwriting the newer request's state.
if guard.model_id.as_deref() != Some(model_id) {
return;
}
match result {
Ok(model) => {
guard.model = Some(Arc::new(model));
guard.phase = CommitAiPhase::Ready;
guard.error = None;
guard.cache = None;
}
Err(err) => {
guard.phase = CommitAiPhase::Error;
guard.error = Some(err.to_string());
guard.cache = None;
}
}
}
pub async fn generate_commit_message(
&self,
diff: &str,
notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<String, String> {
let (model, cache_key) = {
let guard = self.inner.read().await;
match (guard.phase, &guard.model) {
(CommitAiPhase::Ready, Some(model)) => {
let cache_key = GenerationCacheKey {
model_id: guard.model_id.clone().unwrap_or_default(),
profile,
input_hash: generation_input_hash(diff, notes),
};
if let Some(cache) = &guard.cache {
if cache.key == cache_key {
return Ok(cache.message.clone());
}
}
(model.clone(), cache_key)
}
_ => return Err("The local AI model is not ready yet.".to_string()),
}
};
let (system, user) = build_local_messages(diff, notes, profile)?;
let request = RequestBuilder::new()
.set_sampler_max_len(profile.max_output_tokens())
.add_message(TextMessageRole::System, system)
.add_message(TextMessageRole::User, user);
let response = model
.send_chat_request(request)
.await
.map_err(|err| err.to_string())?;
let content = response
.choices
.first()
.and_then(|choice| choice.message.content.clone())
.ok_or_else(|| "The model did not return a response.".to_string())?;
let message = sanitize_message(&content);
if message.is_empty() {
return Err("The model did not return a response.".to_string());
}
if looks_like_diff_echo(&message) {
return Err(
"The local model returned the diff instead of a commit message. Try a larger local model (1.5B or 3B) or a cloud provider.".to_string(),
);
}
{
let mut guard = self.inner.write().await;
guard.cache = Some(GenerationCache {
key: cache_key,
message: message.clone(),
});
}
Ok(message)
}
}
fn generation_input_hash(diff: &str, notes: Option<&str>) -> u64 {
let mut hasher = DefaultHasher::new();
diff.hash(&mut hasher);
notes.unwrap_or("").hash(&mut hasher);
hasher.finish()
}
/// Models occasionally ignore the "no code fences" instruction (small local models
/// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go
/// straight into the commit-message box.
pub(crate) fn sanitize_message(raw: &str) -> String {
let mut text = raw.trim().to_string();
if text.starts_with("```") {
text = match text.split_once('\n') {
// Drop the opening fence line (which may carry a language tag) and the closing fence.
Some((_fence, rest)) => rest.trim_end().trim_end_matches("```").trim().to_string(),
None => text.trim_matches('`').trim().to_string(),
};
}
let trimmed = text.trim();
if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
return trimmed[1..trimmed.len() - 1].trim().to_string();
}
trimmed.to_string()
}
/// Weak models (small local ones especially) sometimes just echo the prompt's diff
/// sections back instead of writing a commit message. Catch that so the UI can show a
/// clear error instead of dumping raw diff text into the commit-message box.
pub(crate) fn looks_like_diff_echo(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("diff --git")
|| lower.contains("staged files:")
|| lower.contains("staged changes:")
|| lower.contains("diff stat:")
|| lower.contains("detailed diff:")
|| message.lines().any(|line| line.starts_with("@@ "))
}
fn truncate_at_char_boundary(input: &str, max_chars: usize) -> String {
if input.len() <= max_chars {
return input.to_string();
}
let mut cut = max_chars;
while !input.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n\n[... diff truncated ...]", &input[..cut])
}
pub(crate) fn build_local_messages(
diff: &str,
notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string());
}
let diff = truncate_at_char_boundary(diff, profile.max_diff_chars());
// Appended to every profile below: small local models occasionally just echo the input
// (the "Staged files:" / "Diff stat:" / "Detailed diff:" sections built in git.rs's
// `staged_diff_local`) instead of writing a new commit message. Naming those exact
// section headers here makes the failure mode explicit enough for weak models to avoid.
const ANTI_ECHO: &str = " Never repeat, quote, or paraphrase the diff or its headers — \
do not include 'diff --git', '@@', 'Staged files:', 'Diff stat:', or 'Detailed diff:' \
anywhere in your answer.";
let system = match profile {
LocalGenerationProfile::Fast => {
format!(
"You generate Git commit messages. Respond only with one Conventional Commits subject line: <type>(<scope>): <subject>. Max 72 characters. No body, bullets, preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
LocalGenerationProfile::Balanced => {
format!(
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then at most two short bullet points. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
LocalGenerationProfile::Detailed => {
format!(
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then a concise body and up to four short bullet points grouped by affected area/file. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
};
let mut user = String::new();
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
user.push_str(&format!("Developer notes:\n{n}\n\n"));
}
user.push_str(&format!("Staged changes:\n{diff}"));
Ok((system, user))
}
pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string());
}
// Rough token estimate — small models often have an 8-32k context window.
const MAX_CHARS: usize = 24_000;
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
let system = "You are a tool that writes a Git commit message describing a staged diff. \
Output ONLY the commit message text. Never quote, restate, or paraphrase the diff itself: \
do not include lines starting with 'diff --git', '@@', '+', '-', 'index ', 'Staged files:', \
or 'Diff stat:' anywhere in your answer. \
Format: a Conventional Commits header (<type>(<scope>): <subject>) in imperative mood, \
max. 72 characters, then a blank line, then a body. \
The body is required: a short, general paragraph (2-4 sentences) summarizing what changed \
and why at a high level — do NOT enumerate every changed file individually. \
You may optionally add up to 3 bullet points (- ...) afterward, but only for the most \
significant changes overall, never one bullet or heading per file. \
Never use bold text, backticks, or markdown headings for file names. \
Lines in the body max. 72 characters. \
No preamble, no explanation, no code fences, answer in English.\n\n\
Example:\n\
Diff:\n\
diff --git a/src/auth.py b/src/auth.py\n\
+def hash_password(pw):\n\
+ return bcrypt.hash(pw)\n\
diff --git a/src/routes.py b/src/routes.py\n\
-if password == stored_password:\n\
+if bcrypt.check(password, stored_password):\n\n\
Commit message:\n\
feat(auth): hash and verify passwords with bcrypt\n\n\
Passwords were previously compared as plain text. This adds a bcrypt-based\n\
hashing helper and updates the login check to verify against the hash\n\
instead of a direct string comparison.\n\n\
- Hash passwords on write, verify with bcrypt on login"
.to_string();
let mut user = String::new();
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
user.push_str(&format!("Developer notes:\n{n}\n\n"));
}
user.push_str(&format!("Staged diff:\n{diff}"));
Ok((system, user))
}
+186
View File
@@ -0,0 +1,186 @@
//! Renders the combined repository attention count as a taskbar badge and applies it as the
//! Windows taskbar overlay. Windows has no native numeric taskbar badge (unlike
//! macOS/Linux, which support `Window::set_badge_count`), so the numbers have to be drawn
//! into a plain RGBA icon ourselves and set via `Window::set_overlay_icon`.
use tauri::Manager;
use tauri::image::Image;
const GLYPH_WIDTH: usize = 3;
const GLYPH_HEIGHT: usize = 5;
/// A minimal 3x5 pixel-grid font, just enough to draw digits and "+" legibly at badge size.
fn glyph_rows(ch: char) -> [&'static str; GLYPH_HEIGHT] {
match ch {
'0' => ["111", "101", "101", "101", "111"],
'1' => ["010", "110", "010", "010", "111"],
'2' => ["111", "001", "111", "100", "111"],
'3' => ["111", "001", "111", "001", "111"],
'4' => ["101", "101", "111", "001", "001"],
'5' => ["111", "100", "111", "001", "111"],
'6' => ["111", "100", "111", "101", "111"],
'7' => ["111", "001", "010", "010", "010"],
'8' => ["111", "101", "111", "101", "111"],
'9' => ["111", "101", "111", "001", "111"],
'+' => ["000", "010", "111", "010", "000"],
_ => ["000", "000", "000", "000", "000"],
}
}
const BADGE_FILL: [u8; 4] = [224, 160, 64, 255];
const BADGE_BORDER: [u8; 4] = [176, 118, 40, 255];
const TEXT_FILL: [u8; 4] = [255, 255, 255, 255];
const TEXT_SHADOW: [u8; 4] = [10, 12, 24, 190];
const CIRCLE_BORDER_WIDTH: f32 = 3.0;
/// Caps the displayed text at three characters ("99+") so it always fits legibly.
fn cap_text(count: u32) -> String {
if count > 99 {
"99+".to_string()
} else {
count.to_string()
}
}
/// Draws a filled circle (with a slightly darker rim) centered at `(cx, cy)`.
fn draw_circle(
rgba: &mut [u8],
size: usize,
cx: f32,
cy: f32,
radius: f32,
fill: [u8; 4],
border: [u8; 4],
) {
for y in 0..size {
for x in 0..size {
let dx = x as f32 + 0.5 - cx;
let dy = y as f32 + 0.5 - cy;
let dist = (dx * dx + dy * dy).sqrt();
if dist <= radius {
let color = if dist >= radius - CIRCLE_BORDER_WIDTH {
border
} else {
fill
};
let idx = (y * size + x) * 4;
rgba[idx..idx + 4].copy_from_slice(&color);
}
}
}
}
fn draw_text_layer(
rgba: &mut [u8],
size: usize,
text: &str,
cx: f32,
cy: f32,
scale: usize,
offset: (isize, isize),
color: [u8; 4],
) {
let glyphs: Vec<[&'static str; GLYPH_HEIGHT]> = text.chars().map(glyph_rows).collect();
let glyph_px_w = GLYPH_WIDTH * scale;
let glyph_px_h = GLYPH_HEIGHT * scale;
let gap = scale.max(1);
let total_w = glyphs.len() * glyph_px_w + gap * glyphs.len().saturating_sub(1);
let start_x = (cx - total_w as f32 / 2.0).round() as isize + offset.0;
let start_y = (cy - glyph_px_h as f32 / 2.0).round() as isize + offset.1;
for (gi, rows) in glyphs.iter().enumerate() {
let glyph_x = start_x + (gi * (glyph_px_w + gap)) as isize;
for (gy, row) in rows.iter().enumerate() {
for (gx, pixel) in row.chars().enumerate() {
if pixel != '1' {
continue;
}
for py in 0..scale {
for px in 0..scale {
let x = glyph_x + (gx * scale + px) as isize;
let y = start_y + (gy * scale + py) as isize;
if x >= 0 && y >= 0 && (x as usize) < size && (y as usize) < size {
let idx = (y as usize * size + x as usize) * 4;
rgba[idx..idx + 4].copy_from_slice(&color);
}
}
}
}
}
}
}
/// Draws white `text` centered at `(cx, cy)`, scaling each font pixel up by `scale`.
fn draw_text(rgba: &mut [u8], size: usize, text: &str, cx: f32, cy: f32, scale: usize) {
for offset in [(-1, 0), (1, 0), (0, -1), (0, 1)] {
draw_text_layer(rgba, size, text, cx, cy, scale, offset, TEXT_SHADOW);
}
draw_text_layer(rgba, size, text, cx, cy, scale, (0, 0), TEXT_FILL);
}
fn text_scale(text: &str) -> usize {
match text.len() {
0 | 1 => 7,
2 => 5,
_ => 4,
}
}
/// Renders a single large badge with `count`. Returns `None` when there's nothing to show,
/// so the caller can clear the overlay icon.
fn render_badge_icon(count: u32) -> Option<Image<'static>> {
if count == 0 {
return None;
}
const SIZE: usize = 64;
const RADIUS: f32 = 29.0;
let mut rgba = vec![0u8; SIZE * SIZE * 4];
let center = SIZE as f32 / 2.0;
let text = cap_text(count);
draw_circle(
&mut rgba,
SIZE,
center,
center,
RADIUS,
BADGE_FILL,
BADGE_BORDER,
);
draw_text(&mut rgba, SIZE, &text, center, center, text_scale(&text));
Some(Image::new_owned(rgba, SIZE as u32, SIZE as u32))
}
/// Sets the taskbar badge to `ahead + behind + changes` (0 clears it). Windows-only: Windows
/// has no native numeric badge API, so this draws and applies a small overlay icon instead.
/// No-op on other platforms: non-Windows desktops should use `Window::set_badge_count`
/// for a real native badge instead, which this app doesn't currently wire up.
#[tauri::command]
pub fn set_sync_badge(
app: tauri::AppHandle,
ahead: u32,
behind: u32,
changes: u32,
) -> Result<(), String> {
let count = ahead.saturating_add(behind).saturating_add(changes);
#[cfg(target_os = "windows")]
{
let Some(window) = app.get_webview_window("main") else {
return Ok(());
};
let icon = render_badge_icon(count);
window
.set_overlay_icon(icon)
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
}
#[cfg(not(target_os = "windows"))]
{
let _ = (app, count);
}
Ok(())
}
+1942 -159
View File
File diff suppressed because it is too large Load Diff
+39 -8
View File
@@ -1,39 +1,69 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod badge;
mod git;
use badge::set_sync_badge;
use git::{
cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head,
compare_file_to_parent, cred_delete, cred_load, cred_save, diff_file_against_working_tree,
get_remote_url, get_status, list_branches, list_commits, list_file_history,
list_repository_files, merge_branch, open_repository, pull, push, read_conflict,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
restore_to_commit, search_code_introductions, stage_files, unstage_files,
SearchCancellationState,
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
checkout_branch, clone_repository, commit, commit_ai_generate, commit_ai_load,
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch,
diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status,
list_branches, list_commits, list_file_history, list_repository_files, list_stashes,
merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle,
open_repository_file, pull, push, read_conflict, rebase_abort, rebase_branch, rebase_continue,
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
restore_files, restore_to_commit, search_code_introductions, stage_files, stash_apply,
stash_drop, stash_pop, stash_push, unstage_files,
};
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(SearchCancellationState::default())
.manage(commit_ai::CommitAiEngine::new())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
open_repository,
clone_repository,
open_repo_in_explorer,
open_repository_file,
get_status,
list_branches,
list_stashes,
checkout_branch,
create_branch,
rename_branch,
delete_branch,
stage_files,
unstage_files,
stash_push,
stash_apply,
stash_pop,
stash_drop,
restore_files,
get_file_patch,
apply_file_patch,
commit,
commit_ai_status,
commit_ai_load,
commit_ai_local_models,
commit_ai_generate,
pull,
push,
fetch,
list_commits,
restore_to_commit,
restore_file_from_commit,
merge_branch,
rebase_branch,
rebase_continue,
rebase_abort,
list_repository_files,
open_repository_bundle,
list_file_history,
cancel_file_history,
compare_commits,
compare_file_to_head,
compare_file_to_parent,
@@ -46,7 +76,8 @@ fn main() {
get_remote_url,
cred_load,
cred_save,
cred_delete
cred_delete,
set_sync_badge
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+11 -6
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Tauri Git Lite",
"version": "0.0.1",
"identifier": "com.example.tauri-git-lite",
"productName": "GitLite",
"version": "2026.7.13",
"identifier": "com.git-lite",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://127.0.0.1:1420",
@@ -28,8 +28,13 @@
},
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/icon.ico"],
"targets": [
"nsis"
],
"icon": [
"icons/icon.png",
"icons/icon.ico"
],
"createUpdaterArtifacts": true,
"windows": {
"nsis": {
@@ -40,7 +45,7 @@
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdDNEJGRTZERTREOTc5QjkKUldTNWVkbmtiZjVMZk02YzdpenVLOVltREJpRkFrc2F4dUwzaTV5M3pacFRVR0tmd25xSGtjK1MK",
"endpoints" : [
"endpoints": [
"https://cdn.cbsk-tech.de/gitlite/latest.json"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"targets": ["appimage"]
}
}
+1748 -122
View File
File diff suppressed because it is too large Load Diff
+2180 -37
View File
File diff suppressed because it is too large Load Diff
+47 -1
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Download, GitBranch, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
export let branch: string = "";
export let ahead: number = 0;
@@ -12,21 +13,26 @@
export let operation: string = "";
export let autoRefreshEnabled: boolean = true;
export let autoRefreshInFlight: boolean = false;
export let onFetch: () => void = () => {};
export let onPull: () => void = () => {};
export let onPush: () => void = () => {};
export let onRefresh: () => void = () => {};
export let onSearch: () => void = () => {};
export let onCompare: () => void = () => {};
export let onOpenInExplorer: () => void = () => {};
export let onToggleAutoRefresh: () => void = () => {};
const win = getCurrentWindow();
let isMaximized = false;
let unlisten: (() => void) | undefined;
let appVersion = "";
onMount(async () => {
isMaximized = await win.isMaximized();
unlisten = await win.onResized(async () => {
isMaximized = await win.isMaximized();
});
appVersion = await getVersion();
});
onDestroy(() => {
@@ -56,6 +62,9 @@
<line x1="12" y1="12" x2="12" y2="15" />
</svg>
<span data-tauri-drag-region>GitLite</span>
{#if appVersion}
<span class="tb-version" data-tauri-drag-region title="Version {appVersion}">v{appVersion}</span>
{/if}
</div>
<!-- Center: repo + branch info -->
@@ -81,6 +90,17 @@
<!-- Right: actions + window controls -->
<div class="titlebar-right">
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
<button
class="tb-action"
onclick={onOpenInExplorer}
disabled={!hasRepository || isBusy}
title="Open repository in Explorer"
aria-label="Open repository in Explorer"
>
<FolderOpen size={14} aria-hidden="true" />
<span class="tb-action-label">Explorer</span>
</button>
<button
class="tb-action"
onclick={onSearch}
@@ -92,6 +112,32 @@
<span class="tb-action-label">Search</span>
</button>
<button
class="tb-action"
onclick={onCompare}
disabled={!hasRepository || isBusy}
title="Compare commits"
aria-label="Compare commits"
>
<GitCompare size={14} aria-hidden="true" />
<span class="tb-action-label">Compare</span>
</button>
<button
class="tb-action"
onclick={onFetch}
disabled={!hasRepository || isBusy}
title="Fetch"
aria-label="Fetch"
>
{#if operation === "Fetching"}
<LoaderCircle class="spin" size={14} aria-hidden="true" />
{:else}
<CloudDownload size={14} aria-hidden="true" />
{/if}
<span class="tb-action-label">Fetch</span>
</button>
<button
class="tb-action"
onclick={onPull}
+313
View File
@@ -0,0 +1,313 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
interface Props {
settings: AiSettings;
localModels: LocalModelOption[];
onSave: (settings: AiSettings) => void;
onClose: () => void;
}
let { settings, localModels = [], onSave, onClose }: Props = $props();
type CloudProvider = Exclude<CommitAiProvider, "local">;
const CRED_KEYS: Record<CloudProvider, string> = {
openai: "ai:openai",
anthropic: "ai:anthropic",
custom: "ai:custom",
};
let provider = $state<CommitAiProvider>("local");
let localModelId = $state("");
let localProfile = $state<CommitAiLocalProfile>("fast");
let openaiModel = $state("");
let anthropicModel = $state("");
let customBaseUrl = $state("");
let customModel = $state("");
let openaiApiKey = $state("");
let anthropicApiKey = $state("");
let customApiKey = $state("");
let showKey = $state(false);
let loadingKeys = $state(true);
let saving = $state(false);
let error = $state("");
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
provider = settings.provider;
localModelId = settings.localModelId;
localProfile = settings.localProfile ?? "fast";
openaiModel = settings.openaiModel;
anthropicModel = settings.anthropicModel;
customBaseUrl = settings.customBaseUrl;
customModel = settings.customModel;
});
$effect(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
const currentError = error;
if (currentError) {
errorHideTimer = setTimeout(() => {
if (error === currentError) error = "";
}, 6000);
}
});
onMount(() => {
(async () => {
try {
const [openai, anthropic, custom] = await Promise.all([
credLoad(CRED_KEYS.openai),
credLoad(CRED_KEYS.anthropic),
credLoad(CRED_KEYS.custom),
]);
openaiApiKey = openai?.password ?? "";
anthropicApiKey = anthropic?.password ?? "";
customApiKey = custom?.password ?? "";
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loadingKeys = false;
}
})();
});
onDestroy(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
});
async function persistKey(target: CloudProvider, value: string) {
const key = CRED_KEYS[target];
const trimmed = value.trim();
if (trimmed) {
await credSave(key, "api-key", trimmed, null);
} else {
await credDelete(key);
}
}
async function handleSave() {
saving = true;
error = "";
try {
await Promise.all([
persistKey("openai", openaiApiKey),
persistKey("anthropic", anthropicApiKey),
persistKey("custom", customApiKey),
]);
onSave({
provider,
localModelId,
localProfile,
openaiModel: openaiModel.trim() || "gpt-4o-mini",
anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest",
customBaseUrl: customBaseUrl.trim(),
customModel: customModel.trim(),
});
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
saving = false;
}
}
function formatSize(mb: number): string {
return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`;
}
function recommendedModelForProfile(profile: CommitAiLocalProfile): string {
if (profile === "balanced") return "qwen2.5-1.5b";
if (profile === "detailed") return "qwen2.5-3b";
return "qwen2.5-0.5b";
}
function selectLocalProfile(profile: CommitAiLocalProfile) {
const previousRecommended = recommendedModelForProfile(localProfile);
localProfile = profile;
const nextRecommended = recommendedModelForProfile(profile);
if (!localModelId || localModelId === previousRecommended) {
localModelId = nextRecommended;
}
}
let selectedLocalModel = $derived(localModels.find((option) => option.id === localModelId));
</script>
<div
class="dialog-backdrop"
role="presentation"
>
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Commit AI</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">AI settings</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}>
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider">
<button
type="button"
class="ai-provider-option ai-provider-option-local"
class:active={provider === "local"}
disabled
title="Local AI is still in development and not yet available"
>
<Cpu size={16} aria-hidden="true" />
Local AI
<span class="ai-provider-badge">In development</span>
</button>
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
<Bot size={16} aria-hidden="true" />
OpenAI
</button>
<button type="button" class="ai-provider-option" class:active={provider === "anthropic"} onclick={() => { provider = "anthropic"; }}>
<Bot size={16} aria-hidden="true" />
Anthropic (Claude)
</button>
<button type="button" class="ai-provider-option" class:active={provider === "custom"} onclick={() => { provider = "custom"; }}>
<Globe size={16} aria-hidden="true" />
Custom endpoint
</button>
</div>
{#if provider === "local"}
<div class="cred-field">
<span class="cred-field-label">Local speed</span>
<div class="ai-local-profile-options" role="radiogroup" aria-label="Local AI speed">
<button type="button" class="ai-local-profile-option" class:active={localProfile === "fast"} onclick={() => selectLocalProfile("fast")}>
<Zap size={15} aria-hidden="true" />
Fast
</button>
<button type="button" class="ai-local-profile-option" class:active={localProfile === "balanced"} onclick={() => selectLocalProfile("balanced")}>
<Gauge size={15} aria-hidden="true" />
Balanced
</button>
<button type="button" class="ai-local-profile-option" class:active={localProfile === "detailed"} onclick={() => selectLocalProfile("detailed")}>
<Sparkles size={15} aria-hidden="true" />
Detailed
</button>
</div>
</div>
<label class="cred-field">
<span class="cred-field-label">Model</span>
<select bind:value={localModelId}>
{#each localModels as option (option.id)}
<option value={option.id}>{option.label} {formatSize(option.approx_size_mb)}</option>
{/each}
</select>
</label>
<div class="cred-token-hint">
<AlertCircle size={13} aria-hidden="true" />
<span>
Switching downloads the model{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""}
in the background — depending on your internet connection this can take several minutes.
After that it stays cached locally and loads instantly on the next start.
The speed setting only changes Local AI; API providers keep their existing prompt.
</span>
</div>
{:else if provider === "openai"}
<label class="cred-field">
<span class="cred-field-label">Model</span>
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
type={showKey ? "text" : "password"}
bind:value={openaiApiKey}
placeholder="sk-..."
autocomplete="off"
spellcheck="false"
disabled={loadingKeys}
/>
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
</button>
</div>
</div>
{:else if provider === "anthropic"}
<label class="cred-field">
<span class="cred-field-label">Model</span>
<input type="text" bind:value={anthropicModel} placeholder="claude-3-5-haiku-latest" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
type={showKey ? "text" : "password"}
bind:value={anthropicApiKey}
placeholder="sk-ant-..."
autocomplete="off"
spellcheck="false"
disabled={loadingKeys}
/>
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
</button>
</div>
</div>
{:else}
<label class="cred-field">
<span class="cred-field-label">Endpoint URL</span>
<input type="text" bind:value={customBaseUrl} placeholder="http://localhost:11434/v1" autocomplete="off" spellcheck="false" />
</label>
<label class="cred-field">
<span class="cred-field-label">Model</span>
<input type="text" bind:value={customModel} placeholder="llama3.1" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key (optional)</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
type={showKey ? "text" : "password"}
bind:value={customApiKey}
placeholder="Optional"
autocomplete="off"
spellcheck="false"
disabled={loadingKeys}
/>
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
</button>
</div>
</div>
<div class="cred-token-hint">
<Globe size={13} aria-hidden="true" />
<span>For local OpenAI-compatible servers like Ollama or LM Studio. The base URL should end in /v1.</span>
</div>
{/if}
{#if error}
<p class="commit-block-reason">{error}</p>
{/if}
<div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={saving}>
Cancel
</button>
<button class="btn-primary" type="submit" disabled={saving || loadingKeys}>
{#if saving}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Save
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import { AlertTriangle, GitBranch, LoaderCircle, Trash2, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
interface Props {
branch: GitBranchInfo;
force: boolean;
isBusy: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}
let {
branch,
force = false,
isBusy = false,
onConfirm = () => {},
onClose = () => {},
}: Props = $props();
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
</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>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="branch-delete-body">
<div class="discard-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.
{:else}
Delete this local branch from the repository?
{/if}
</p>
<code class="discard-target" title={branch.name}>
<GitBranch size={13} aria-hidden="true" />
{branch.name}
</code>
<p class="discard-warning-text">
{#if force}
Make sure you no longer need the unique commits on this branch.
{:else}
Git will refuse if the branch is not fully merged.
{/if}
</p>
</div>
</div>
<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}>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<Trash2 size={15} aria-hidden="true" />
{/if}
{force ? "Force delete" : "Delete"}
</button>
</footer>
</div>
</div>
+458 -63
View File
@@ -1,7 +1,46 @@
<script lang="ts">
import { GitBranch, GitMerge } from "@lucide/svelte";
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Trash2, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
interface BranchFolderNode {
kind: "folder";
id: string;
name: string;
children: BranchTreeNode[];
branchCount: number;
current: boolean;
folders: Map<string, BranchFolderNode>;
}
interface BranchLeafNode {
kind: "branch";
id: string;
branch: GitBranchInfo;
displayName: string;
}
type BranchRow = BranchFolderRow | BranchLeafRow;
interface BranchFolderRow {
kind: "folder";
id: string;
name: string;
depth: number;
branchCount: number;
current: boolean;
}
interface BranchLeafRow {
kind: "branch";
id: string;
branch: GitBranchInfo;
displayName: string;
depth: number;
scopeLabel: string;
}
interface Props {
branches: GitBranchInfo[];
localBranches: GitBranchInfo[];
@@ -10,6 +49,10 @@
isBusy: boolean;
onCheckout: (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>;
}
let {
@@ -20,16 +63,239 @@
isBusy = false,
onCheckout = () => {},
onMerge = () => {},
onRebase = () => {},
onCreateBranch = () => {},
onRenameBranch = () => {},
onDeleteBranch = () => {},
}: Props = $props();
let localOpen = $state(true);
let remoteOpen = $state(false);
let createOpen = $state(false);
let newBranchName = $state("");
let createInput = $state<HTMLInputElement | null>(null);
let panelElement = $state<HTMLElement | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let collapsedBranchFolders = $state<Set<string>>(new Set());
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
let remoteBranchRows = $derived(buildBranchRows("remote", remoteBranches, "remote"));
function createFolder(id: string, name: string): BranchFolderNode {
return {
kind: "folder",
id,
name,
children: [],
branchCount: 0,
current: false,
folders: new Map(),
};
}
function buildBranchRows(scope: string, branchList: GitBranchInfo[], scopeLabel: string): BranchRow[] {
const root = createFolder(`${scope}:root`, "");
for (const branch of branchList) {
const parts = branch.name.split("/").filter(Boolean);
const displayName = parts.length > 0 ? parts[parts.length - 1] : branch.name;
const folderParts = parts.slice(0, -1);
let parent = root;
for (let index = 0; index < folderParts.length; index += 1) {
const folderName = folderParts[index];
const folderPath = folderParts.slice(0, index + 1).join("/");
let folder = parent.folders.get(folderName);
if (!folder) {
folder = createFolder(`${scope}:folder:${folderPath}`, folderName);
parent.folders.set(folderName, folder);
parent.children.push(folder);
}
folder.branchCount += 1;
folder.current ||= branch.current;
parent = folder;
}
parent.children.push({
kind: "branch",
id: `${scope}:branch:${branch.name}`,
branch,
displayName,
});
}
sortBranchNodes(root.children);
const rows: BranchRow[] = [];
flattenBranchNodes(root.children, rows, 0, scopeLabel);
return rows;
}
function sortBranchNodes(nodes: BranchTreeNode[]) {
nodes.sort((left, right) => {
if (left.kind !== right.kind) return left.kind === "folder" ? -1 : 1;
const leftName = left.kind === "folder" ? left.name : left.displayName;
const rightName = right.kind === "folder" ? right.name : right.displayName;
return leftName.localeCompare(rightName, undefined, { sensitivity: "base" });
});
for (const node of nodes) {
if (node.kind === "folder") sortBranchNodes(node.children);
}
}
function flattenBranchNodes(nodes: BranchTreeNode[], rows: BranchRow[], depth: number, scopeLabel: string) {
for (const node of nodes) {
if (node.kind === "folder") {
rows.push({
kind: "folder",
id: node.id,
name: node.name,
depth,
branchCount: node.branchCount,
current: node.current,
});
if (isBranchFolderOpen(node.id)) {
flattenBranchNodes(node.children, rows, depth + 1, scopeLabel);
}
} else {
rows.push({
kind: "branch",
id: node.id,
branch: node.branch,
displayName: node.displayName,
depth,
scopeLabel,
});
}
}
}
function isBranchFolderOpen(id: string) {
return !collapsedBranchFolders.has(id);
}
function toggleBranchFolder(id: string) {
const next = new Set(collapsedBranchFolders);
if (next.has(id)) next.delete(id);
else next.add(id);
collapsedBranchFolders = next;
}
function openCreateForm() {
if (!hasRepository || isBusy) return;
createOpen = true;
queueMicrotask(() => createInput?.focus());
}
function closeCreateForm() {
createOpen = false;
newBranchName = "";
}
async function submitCreate(event: SubmitEvent) {
event.preventDefault();
const value = newBranchName.trim();
if (!value || !hasRepository || isBusy) return;
await onCreateBranch(value);
newBranchName = "";
createOpen = false;
localOpen = true;
}
function checkoutOnDoubleClick(event: MouseEvent, branch: GitBranchInfo) {
if (branch.current || isBusy) return;
const target = event.target instanceof HTMLElement ? event.target : null;
if (target?.closest("button")) return;
onCheckout(branch);
}
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 190);
contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX));
contextMenuY = Math.max(8, Math.min(rawY, maxY));
}
function closeBranchContextMenu() {
contextBranch = null;
}
async function renameContextBranch() {
const branch = contextBranch;
if (!branch || isBusy) return;
closeBranchContextMenu();
await onRenameBranch(branch);
}
async function deleteContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || branch.remote || isBusy) return;
closeBranchContextMenu();
await onDeleteBranch(branch);
}
async function checkoutContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onCheckout(branch);
}
async function mergeContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onMerge(branch);
}
async function rebaseContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onRebase(branch);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeBranchContextMenu();
}
</script>
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeBranchContextMenu} />
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
<div class="section-head">
<div>
<span class="eyebrow">Branches</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Refs</h2>
</div>
<span class="pill pill-count">{branches.length}</span>
<div class="branch-head-actions">
<button
class="branch-create-toggle"
type="button"
onclick={openCreateForm}
disabled={!hasRepository || isBusy}
title="Create new branch"
aria-label="Create new branch"
>
<Plus size={14} aria-hidden="true" />
</button>
<span class="pill pill-count">{branches.length}</span>
</div>
</div>
{#if !hasRepository}
@@ -37,71 +303,200 @@
{:else if branches.length === 0}
<p class="blank-state">No branches returned.</p>
{:else}
<div class="overflow-auto p-2 flex flex-col gap-0">
{#if localBranches.length > 0}
<div class="branch-group-label">
<span>Local</span>
<span class="branch-group-count">{localBranches.length}</span>
</div>
{#each localBranches as branch (branch.name)}
{#snippet branchCard()}
<article class="branch-row" class:current={branch.current}>
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{branch.name}</strong>
<span>local</span>
</div>
</div>
{#if branch.current}
<span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/snippet}
{@render branchCard()}
{/each}
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
{#if createOpen}
<form class="branch-create-form" onsubmit={submitCreate}>
<GitBranch size={15} aria-hidden="true" />
<input
bind:this={createInput}
bind:value={newBranchName}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="new-branch-name"
aria-label="New branch name"
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newBranchName.trim().length === 0} title="Create branch">
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeCreateForm} disabled={isBusy} title="Cancel">
<X size={14} aria-hidden="true" />
</button>
</form>
{/if}
{#if remoteBranches.length > 0}
<div class="branch-group-label" style="margin-top: {localBranches.length > 0 ? '12px' : '0'}">
<div class="branch-group">
<button
class="branch-group-toggle"
type="button"
onclick={() => { localOpen = !localOpen; }}
aria-expanded={localOpen}
>
{#if localOpen}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Local</span>
<span class="branch-group-count">{localBranches.length}</span>
</button>
{#if localOpen}
{#if localBranches.length === 0}
<div class="branch-empty">No local branches.</div>
{:else}
{#each localBranchRows as row (row.id)}
{#if row.kind === "folder"}
<button
class="branch-folder-row"
class:current={row.current}
style={`--branch-indent: ${row.depth * 16}px;`}
type="button"
onclick={() => toggleBranchFolder(row.id)}
aria-expanded={isBranchFolderOpen(row.id)}
title={`${row.name} (${row.branchCount})`}
>
{#if isBranchFolderOpen(row.id)}
<ChevronDown size={14} aria-hidden="true" />
<FolderOpen size={15} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
<Folder size={15} aria-hidden="true" />
{/if}
<span class="branch-folder-name">{row.name}</span>
<span class="branch-folder-count">{row.branchCount}</span>
</button>
{:else}
<article
class="branch-row"
class:current={row.branch.current}
style={`--branch-indent: ${row.depth * 16}px;`}
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
title={row.branch.current ? "Current branch" : row.branch.name}
>
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{row.displayName}</strong>
<span>{row.scopeLabel}</span>
</div>
</div>
{#if row.branch.current}
<span class="pill pill-active">Current</span>
{/if}
</article>
{/if}
{/each}
{/if}
{/if}
</div>
<div class="branch-group">
<button
class="branch-group-toggle"
type="button"
onclick={() => { remoteOpen = !remoteOpen; }}
aria-expanded={remoteOpen}
>
{#if remoteOpen}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Remote</span>
<span class="branch-group-count">{remoteBranches.length}</span>
</div>
{#each remoteBranches as branch (branch.name)}
<article class="branch-row" class:current={branch.current}>
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{branch.name}</strong>
<span>remote</span>
</div>
</div>
{#if branch.current}
<span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
Checkout
</button>
{#if remoteOpen}
{#if remoteBranches.length === 0}
<div class="branch-empty">No remote branches.</div>
{:else}
{#each remoteBranchRows as row (row.id)}
{#if row.kind === "folder"}
<button
class="branch-folder-row"
class:current={row.current}
style={`--branch-indent: ${row.depth * 16}px;`}
type="button"
onclick={() => toggleBranchFolder(row.id)}
aria-expanded={isBranchFolderOpen(row.id)}
title={`${row.name} (${row.branchCount})`}
>
{#if isBranchFolderOpen(row.id)}
<ChevronDown size={14} aria-hidden="true" />
<FolderOpen size={15} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
<Folder size={15} aria-hidden="true" />
{/if}
<span class="branch-folder-name">{row.name}</span>
<span class="branch-folder-count">{row.branchCount}</span>
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/each}
{/if}
{:else}
<article
class="branch-row"
class:current={row.branch.current}
style={`--branch-indent: ${row.depth * 16}px;`}
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
title={row.branch.current ? "Current branch" : row.branch.name}
>
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{row.displayName}</strong>
<span>{row.scopeLabel}</span>
</div>
</div>
{#if row.branch.current}
<span class="pill pill-active">Current</span>
{/if}
</article>
{/if}
{/each}
{/if}
{/if}
</div>
</div>
{/if}
{#if contextBranch}
<div
class="branch-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextBranch.name}`}
>
<button type="button" role="menuitem" onclick={checkoutContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Checkout
</button>
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
<GitMerge size={14} aria-hidden="true" />
Merge into current
</button>
<button type="button" role="menuitem" onclick={rebaseContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Rebase current onto this
</button>
<div class="menu-separator" role="separator"></div>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
<Pencil size={14} aria-hidden="true" />
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"}
>
<Trash2 size={14} aria-hidden="true" />
Delete
</button>
</div>
{/if}
</section>
@@ -0,0 +1,175 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { Download, FolderOpen, LoaderCircle, X } from "@lucide/svelte";
interface Props {
isBusy: boolean;
error: string;
onClone: (remoteUrl: string, parentPath: string, directoryName: string) => void;
onClose: () => void;
}
let {
isBusy = false,
error = "",
onClone = () => {},
onClose = () => {},
}: Props = $props();
let remoteUrl = $state("");
let parentPath = $state("");
let directoryName = $state("");
let directoryNameEdited = $state(false);
let directoryAutoName = $state("");
let browseError = $state("");
let visibleError = $state("");
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
let directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
let canSubmit = $derived(
!isBusy &&
remoteUrl.trim().length > 0 &&
parentPath.trim().length > 0,
);
$effect(() => {
const nextError = error || browseError;
if (errorHideTimer) clearTimeout(errorHideTimer);
visibleError = nextError;
if (nextError) {
errorHideTimer = setTimeout(() => {
visibleError = "";
}, 6000);
}
});
onDestroy(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
});
function directoryNameFromRemoteUrl(url: string): string {
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
return lastSegment.replace(/\.git$/i, "").trim();
}
function errorToMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
try { return JSON.stringify(error) ?? "Unknown error"; } catch { return "Unknown error"; }
}
async function chooseParentFolder() {
if (isBusy) return;
browseError = "";
try {
const selected = await openDialog({
title: "Select clone destination",
directory: true,
multiple: false,
defaultPath: parentPath.trim() || undefined,
});
if (typeof selected !== "string") return;
parentPath = selected;
} catch (error) {
browseError = errorToMessage(error);
}
}
function handleRemoteInput(event: Event) {
const nextRemoteUrl = (event.currentTarget as HTMLInputElement).value;
if (directoryNameEdited) return;
directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl);
directoryName = directoryAutoName;
}
function handleDirectoryInput(event: Event) {
const nextDirectoryName = (event.currentTarget as HTMLInputElement).value;
directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName;
}
function submit(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim());
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label="Clone repository" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Repository Management</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Clone repository</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="clone-dialog-form" onsubmit={submit}>
<label class="clone-dialog-field">
<span>Remote URL</span>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={remoteUrl}
oninput={handleRemoteInput}
autocomplete="off"
spellcheck="false"
placeholder="https://github.com/org/project.git"
disabled={isBusy}
autofocus
/>
</label>
<label class="clone-dialog-field">
<span>Destination</span>
<div class="clone-dialog-path-field">
<input
bind:value={parentPath}
autocomplete="off"
spellcheck="false"
placeholder="Choose parent folder"
disabled={isBusy}
/>
<button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}>
<FolderOpen size={14} aria-hidden="true" />
Browse
</button>
</div>
</label>
<label class="clone-dialog-field">
<span>Folder name</span>
<input
bind:value={directoryName}
oninput={handleDirectoryInput}
autocomplete="off"
spellcheck="false"
placeholder={directorySuggestion || "Optional"}
disabled={isBusy}
/>
</label>
{#if visibleError}
<div class="clone-dialog-error" role="alert">{visibleError}</div>
{/if}
<div class="clone-dialog-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
Cancel
</button>
<button class="btn-primary" type="submit" disabled={!canSubmit}>
{#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Download size={16} aria-hidden="true" />
{/if}
Clone
</button>
</div>
</form>
</div>
</div>
+63 -10
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { Check, LoaderCircle } from "@lucide/svelte";
import { Check, LoaderCircle, Settings, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props {
commitMessage: string;
@@ -9,8 +10,13 @@
isBusy: boolean;
operation: string;
stagedCount: number;
commitAiProvider: CommitAiProvider;
commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean;
onCommit: () => void;
onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void;
onOpenAiSettings: () => void;
}
let {
@@ -21,17 +27,38 @@
isBusy = false,
operation = "",
stagedCount = 0,
commitAiProvider = "local",
commitAiPhase = "idle",
commitAiGenerating = false,
onCommit = () => {},
onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {},
onOpenAiSettings = () => {},
}: Props = $props();
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
onCommit();
}
function aiButtonTitle(provider: CommitAiProvider, phase: CommitAiPhase, staged: number): string {
if (staged === 0) return "Stage changes first";
if (provider === "local" && phase === "loading") return "AI model is downloading/loading — this happens once";
if (provider === "local" && phase === "error") return "AI model failed to load — check AI settings";
return "Generate commit message with AI from the staged diff";
}
let localModelLoading = $derived(commitAiProvider === "local" && commitAiPhase === "loading");
let canGenerate = $derived(
hasRepository &&
!isBusy &&
!commitAiGenerating &&
stagedCount > 0 &&
(commitAiProvider !== "local" || commitAiPhase === "ready"),
);
</script>
<section class="panel flex flex-col" aria-label="Commit">
<section class="panel commit-panel" aria-label="Commit">
<div class="section-head">
<div>
<span class="eyebrow">Commit</span>
@@ -50,13 +77,39 @@
{#if commitBlockReason}
<p class="commit-block-reason">{commitBlockReason}</p>
{/if}
<button class="btn-primary w-full flex-shrink-0" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
{#if operation === "Committing"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Commit
</button>
<div class="commit-actions-row flex-shrink-0">
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
{#if operation === "Committing"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Commit
</button>
<button
class="btn-secondary commit-ai-button"
type="button"
onclick={onGenerateCommitMessage}
disabled={!canGenerate}
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
>
{#if commitAiGenerating || localModelLoading}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Sparkles size={16} aria-hidden="true" />
{/if}
AI
</button>
<button
class="btn-secondary commit-ai-settings-button"
type="button"
onclick={onOpenAiSettings}
disabled={isBusy}
title="AI settings"
aria-label="AI settings"
>
<Settings size={16} aria-hidden="true" />
</button>
</div>
</form>
</section>
+20 -5
View File
@@ -15,6 +15,8 @@
selectedDiffPath: string;
isBusy: boolean;
restoreLabel?: string;
/** When opened from a search hit, the term to highlight on matching lines. */
highlightQuery?: string;
onClose: () => void;
onRestore?: () => void;
onSelectFile: (file: GitDiffFile) => void;
@@ -25,11 +27,25 @@
selectedDiffPath = "",
isBusy = false,
restoreLabel = "",
highlightQuery = "",
onClose = () => {},
onRestore = undefined,
onSelectFile = () => {},
}: Props = $props();
// Needle = first non-empty line of the search query, lowercased for matching.
let highlightNeedle = $derived(
highlightQuery
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0)
?.toLowerCase() ?? ""
);
function isMatch(text?: string): boolean {
return highlightNeedle.length > 0 && !!text && text.toLowerCase().includes(highlightNeedle);
}
let beforePane = $state<HTMLDivElement | null>(null);
let afterPane = $state<HTMLDivElement | null>(null);
let isSyncingSplitScroll = false;
@@ -166,7 +182,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
@@ -259,8 +274,8 @@
{#if row.type === "span"}
<div class="split-span split-{row.kind}">{row.text}</div>
{:else}
<div class="split-num" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"}>{row.leftNum ?? ""}</div>
<div class="split-cell" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"}>{row.leftText ?? " "}</div>
<div class="split-num" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"} class:match={isMatch(row.leftText)}>{row.leftNum ?? ""}</div>
<div class="split-cell" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"} class:match={isMatch(row.leftText)}>{row.leftText ?? " "}</div>
{/if}
{/each}
</div>
@@ -276,8 +291,8 @@
{#if row.type === "span"}
<div class="split-span split-{row.kind}">{row.text}</div>
{:else}
<div class="split-num" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"}>{row.rightNum ?? ""}</div>
<div class="split-cell" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"}>{row.rightText ?? " "}</div>
<div class="split-num" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"} class:match={isMatch(row.rightText)}>{row.rightNum ?? ""}</div>
<div class="split-cell" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"} class:match={isMatch(row.rightText)}>{row.rightText ?? " "}</div>
{/if}
{/each}
</div>
@@ -0,0 +1,107 @@
<script lang="ts">
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
import type { GitCommit } from "../types";
interface Props {
commits: GitCommit[];
compareFrom: string;
compareTo: string;
canCompare: boolean;
isBusy: boolean;
operation: string;
onCompareFromChange: (val: string) => void;
onCompareToChange: (val: string) => void;
onCompare: () => void;
onClose: () => void;
}
let {
commits = [],
compareFrom = "",
compareTo = "",
canCompare = false,
isBusy = false,
operation = "",
onCompareFromChange = () => {},
onCompareToChange = () => {},
onCompare = () => {},
onClose = () => {},
}: Props = $props();
function commitOptionLabel(item: GitCommit): string {
return `${item.short_hash} - ${item.summary}`;
}
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
onCompare();
}
</script>
<div
class="dialog-backdrop"
role="presentation"
>
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select 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>
</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>
{:else}
<form class="compare-form" onsubmit={handleSubmit}>
<label class="compare-field">
<span>From (older)</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}
</select>
</label>
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
<label class="compare-field">
<span>To (newer)</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}
</select>
</label>
<button class="btn-primary" type="submit" disabled={!canCompare}>
{#if operation === "Comparing commits"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<GitCompare size={16} aria-hidden="true" />
{/if}
Compare
</button>
</form>
{#if compareFrom && compareTo && compareFrom === compareTo}
<div class="blank-state">Select two different commits to compare.</div>
{:else}
<div class="blank-state">Pick two commits and run a comparison.</div>
{/if}
{/if}
</div>
</div>
+30 -21
View File
@@ -14,7 +14,7 @@
} from "@lucide/svelte";
interface Props {
action: "push" | "pull";
action: "push" | "pull" | "fetch" | "clone";
error: string;
isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
@@ -43,11 +43,21 @@
password.trim().length > 0 &&
(mode === "token" || username.trim().length > 0),
);
let actionLabel = $derived(action === "push" ? "Push" : "Pull");
let actionTitle = $derived(action === "push" ? "Push authentifizieren" : "Pull authentifizieren");
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
let actionTitle = $derived(
action === "push"
? "Authenticate push"
: action === "fetch"
? "Authenticate fetch"
: action === "clone"
? "Authenticate clone"
: "Authenticate pull",
);
let actionHint = $derived(action === "push"
? "Der Remote braucht Schreibrechte. Nutze ein Passwort oder einen Token mit passenden Repository-Rechten."
: "Der Remote braucht Zugriff auf das Repository. Nutze deine Git-Zugangsdaten oder einen Personal Access Token.");
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: action === "clone"
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
: "The remote needs access to the repository. Use your Git credentials or a personal access token.");
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
@@ -64,9 +74,8 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git-Zugangsdaten" tabindex="-1">
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git credentials" tabindex="-1">
<div class="cred-hero">
<div class="cred-hero-top">
<div class="cred-hero-icon">
@@ -80,7 +89,7 @@
<p class="cred-hero-label">{actionLabel} Remote</p>
<h2 class="cred-hero-title">{actionTitle}</h2>
</div>
<button class="cred-close" type="button" onclick={onCancel} title="Abbrechen" aria-label="Abbrechen">
<button class="cred-close" type="button" onclick={onCancel} title="Cancel" aria-label="Cancel">
<X size={16} aria-hidden="true" />
</button>
</div>
@@ -89,12 +98,12 @@
<div class="cred-security-note">
<ShieldCheck size={14} aria-hidden="true" />
<span>Beim Speichern landet der Token verschluesselt im Schluesselbund des Betriebssystems nie im Klartext.</span>
<span>When saved, the token is stored encrypted in the operating system's keychain — never in plain text.</span>
</div>
</div>
<form class="cred-body" onsubmit={handleSubmit}>
<div class="cred-segment" role="group" aria-label="Authentifizierungsart">
<div class="cred-segment" role="group" aria-label="Authentication method">
<button
type="button"
class="cred-seg-btn"
@@ -103,7 +112,7 @@
aria-pressed={mode === "credentials"}
>
<User size={13} aria-hidden="true" />
Username + Passwort
Username + password
</button>
<button
type="button"
@@ -127,7 +136,7 @@
id="cred-username"
type="text"
bind:value={username}
placeholder="z. B. mein-github-username"
placeholder="e.g. my-github-username"
autocomplete="username"
disabled={isBusy}
/>
@@ -137,7 +146,7 @@
<div class="cred-field">
<label class="cred-field-label" for="cred-password">
{mode === "token" ? "Token" : "Passwort"}
{mode === "token" ? "Token" : "Password"}
</label>
<div class="cred-input">
<Lock size={15} class="cred-field-icon" aria-hidden="true" />
@@ -146,8 +155,8 @@
type={showPassword ? "text" : "password"}
bind:value={password}
placeholder={mode === "token"
? "ghp_... oder anderer Zugangstoken"
: "Passwort oder Personal Access Token"}
? "ghp_... or another access token"
: "Password or personal access token"}
autocomplete="current-password"
disabled={isBusy}
/>
@@ -156,7 +165,7 @@
class="cred-reveal"
onclick={() => { showPassword = !showPassword; }}
tabindex="-1"
aria-label={showPassword ? "Verbergen" : "Anzeigen"}
aria-label={showPassword ? "Hide" : "Show"}
>
{#if showPassword}
<EyeOff size={14} aria-hidden="true" />
@@ -171,7 +180,7 @@
{#if mode === "token"}
<div class="cred-token-hint">
<Key size={13} aria-hidden="true" />
<span>Username wird automatisch auf <code>oauth2</code> gesetzt. Das funktioniert mit GitHub, GitLab und Bitbucket.</span>
<span>Username is automatically set to <code>oauth2</code>. This works with GitHub, GitLab, and Bitbucket.</span>
</div>
{/if}
@@ -184,26 +193,26 @@
{#if saveSession}
<div class="cred-expiry">
<label class="cred-field-label" for="cred-expiry">Ablaufdatum (optional)</label>
<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">Nach diesem Datum wird automatisch erneut nach dem Login gefragt.</span>
<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} />
<span>Im Schluesselbund speichern</span>
<span>Save in keychain</span>
</label>
<div class="cred-btns">
<button type="button" class="cred-cancel" onclick={onCancel} disabled={isBusy}>
Abbrechen
Cancel
</button>
<button class="cred-submit" type="submit" disabled={!canSubmit}>
{#if isBusy}
@@ -0,0 +1,70 @@
<script lang="ts">
import { AlertTriangle, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
import type { GitFileStatus } from "../types";
interface Props {
file: GitFileStatus;
staged: boolean;
scope: "file" | "hunk";
isBusy: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}
let {
file,
staged = false,
scope = "file",
isBusy = false,
onConfirm = () => {},
onClose = () => {},
}: Props = $props();
let targetPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
let title = $derived(scope === "hunk" ? "Discard hunk?" : "Discard file changes?");
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header">
<div>
<span class="eyebrow">Confirm discard</span>
<p class="dialog-title">{title}</p>
</div>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="discard-confirm-body">
<div class="discard-warning-icon" aria-hidden="true">
<AlertTriangle size={22} />
</div>
<div class="discard-confirm-copy">
<p>
This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below.
</p>
<code class="discard-target" title={targetPath}>{targetPath}</code>
<p class="discard-warning-text">
This cannot be undone. If the file only exists in your working tree, it can be deleted entirely.
</p>
</div>
</div>
<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}>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<RotateCcw size={15} aria-hidden="true" />
{/if}
Discard
</button>
</footer>
</div>
</div>
+58 -1
View File
@@ -17,6 +17,7 @@
FileVideo,
Folder,
FolderOpen,
ExternalLink,
Terminal,
} from "@lucide/svelte";
import { languageIconForPath } from "../languageIcons";
@@ -34,6 +35,7 @@
onExpandAllFolders: () => void;
onCollapseAllFolders: () => void;
onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void;
}
let {
@@ -47,8 +49,13 @@
onExpandAllFolders = () => {},
onCollapseAllFolders = () => {},
onSelectNode = () => {},
onOpenFile = () => {},
}: Props = $props();
let contextNode = $state<ExplorerNode | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
if (!next) return current;
if (!current) return next;
@@ -154,12 +161,39 @@
return "text";
}
function openFileContextMenu(event: MouseEvent, node: ExplorerNode) {
if (node.kind !== "file") return;
event.preventDefault();
event.stopPropagation();
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));
}
function closeFileContextMenu() {
contextNode = null;
}
function openContextFile() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onOpenFile(node);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeFileContextMenu();
}
let explorerTree = $derived(buildExplorerTree(repoFiles));
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
</script>
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="File explorer">
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
<section class="panel explorer-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="File explorer">
<div class="section-head">
<div>
<span class="eyebrow">Explorer</span>
@@ -264,6 +298,7 @@
class="explorer-select"
type="button"
onclick={() => onSelectNode(node)}
oncontextmenu={(event) => openFileContextMenu(event, node)}
disabled={isBusy}
title={`Show history for ${node.path}`}
>
@@ -279,4 +314,26 @@
{/each}
</div>
{/if}
</section>
{#if contextNode}
<div
class="explorer-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextNode.path}`}
>
<button
type="button"
role="menuitem"
onclick={openContextFile}
disabled={contextNode.status === "deleted"}
title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}
>
<ExternalLink size={14} aria-hidden="true" />
Open in Explorer
</button>
</div>
{/if}
@@ -8,6 +8,7 @@
selectedExplorerLabel: string;
hasRepository: boolean;
isBusy: boolean;
isLoading?: boolean;
onDiff: (commit: GitCommit) => void;
onRestore: (commit: GitCommit) => void;
}
@@ -18,6 +19,7 @@
selectedExplorerLabel = "File history",
hasRepository = false,
isBusy = false,
isLoading = false,
onDiff = () => {},
onRestore = () => {},
}: Props = $props();
@@ -89,6 +91,25 @@
<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">
<svg class="file-history-loading-graph" viewBox="0 0 120 120" aria-hidden="true">
<defs>
<linearGradient id="fhl-line" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#646cff" />
<stop offset="100%" stop-color="#41d1ff" />
</linearGradient>
</defs>
<circle class="fhl-ring" cx="60" cy="60" r="52" />
<path class="fhl-trunk" d="M42 22 L42 98" />
<path class="fhl-branch" d="M42 44 C42 62, 82 58, 82 76 L82 88" />
<circle class="fhl-node fhl-n1" cx="42" cy="30" r="6" />
<circle class="fhl-node fhl-n2" cx="42" cy="60" r="6" />
<circle class="fhl-node fhl-n3" cx="82" cy="88" r="6" />
<circle class="fhl-node fhl-n4" cx="42" cy="90" r="6" />
</svg>
<span class="file-history-loading-label">Loading history…</span>
</div>
{:else if fileHistory.length === 0}
<div class="blank-state">No history returned for this selection.</div>
{:else}
+310 -89
View File
@@ -1,6 +1,21 @@
<script lang="ts">
import { CalendarDays, FileCode, LoaderCircle, Search, User, X } from "@lucide/svelte";
import type { GitSearchHit } from "../types";
import {
CalendarDays,
FileCode,
FileText,
GitCompare,
History,
LoaderCircle,
RotateCcw,
Search,
User,
X,
} from "@lucide/svelte";
import { languageIconForPath } from "../languageIcons";
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
import LanguageIcon from "./LanguageIcon.svelte";
type SearchTab = "code" | "files";
interface Props {
hasRepository: boolean;
@@ -8,9 +23,16 @@
isSearching: boolean;
error: string;
results: GitSearchHit[];
files: GitRepositoryFile[];
fileHistory: GitCommit[];
selectedFilePath: string;
onClose: () => void;
onSearch: (query: string, caseSensitive: boolean, limit: number) => void | Promise<void>;
onCancel: () => void | Promise<void>;
onDiff: (hit: GitSearchHit) => void | Promise<void>;
onSelectFile: (file: GitRepositoryFile) => void | Promise<void>;
onFileHistoryDiff: (commit: GitCommit) => void | Promise<void>;
onFileHistoryRestore: (commit: GitCommit) => void | Promise<void>;
}
let {
@@ -19,16 +41,28 @@
isSearching = false,
error = "",
results = [],
files = [],
fileHistory = [],
selectedFilePath = "",
onClose = () => {},
onSearch = () => {},
onCancel = () => {},
onDiff = () => {},
onSelectFile = () => {},
onFileHistoryDiff = () => {},
onFileHistoryRestore = () => {},
}: Props = $props();
let activeTab = $state<SearchTab>("code");
let query = $state("");
let caseSensitive = $state(false);
let limit = $state(250);
let searchedQuery = $state("");
let searched = $state(false);
let fileQuery = $state("");
const fileSearchActive = $derived(fileQuery.trim().length > 0);
const fileSearchResults = $derived(filterFiles(files, fileQuery, 200));
function submit(event?: SubmitEvent) {
event?.preventDefault();
@@ -54,18 +88,53 @@
function displayPath(hit: GitSearchHit): string {
return hit.old_file ? `${hit.old_file} -> ${hit.file}` : hit.file;
}
function fileName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function folderName(path: string): string {
const parts = path.split(/[\\/]/).filter(Boolean);
parts.pop();
return parts.length > 0 ? parts.join("/") : "Repository root";
}
function filterFiles(source: GitRepositoryFile[], value: string, maxResults: number): GitRepositoryFile[] {
const terms = value
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
if (terms.length === 0) return [];
return source
.filter((file) => {
const path = file.path.toLowerCase();
const name = fileName(file.path).toLowerCase();
return terms.every((term) => path.includes(term) || name.includes(term));
})
.sort((a, b) => {
const aName = fileName(a.path).toLowerCase();
const bName = fileName(b.path).toLowerCase();
const first = terms[0] ?? "";
const aStarts = aName.startsWith(first) ? 0 : 1;
const bStarts = bName.startsWith(first) ? 0 : 1;
if (aStarts !== bStarts) return aStarts - bStarts;
return a.path.localeCompare(b.path);
})
.slice(0, maxResults);
}
</script>
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global code search" tabindex="-1">
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Global search</span>
<h2 class="dialog-title">Find where code was introduced</h2>
<h2 class="dialog-title">{activeTab === "code" ? "Find where code was introduced" : "Find file history"}</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
@@ -73,102 +142,254 @@
</header>
<div class="global-search-body">
<form class="global-search-form" onsubmit={submit}>
<label class="global-search-query">
<span>String or function</span>
<textarea
bind:value={query}
onkeydown={handleKeydown}
disabled={!hasRepository || isBusy || isSearching}
spellcheck="false"
placeholder={"Paste a string, symbol, or full function body..."}
></textarea>
</label>
<div class="global-search-tabs" role="tablist" aria-label="Search mode">
<button
class="global-search-tab"
class:active={activeTab === "code"}
type="button"
role="tab"
aria-selected={activeTab === "code"}
onclick={() => { activeTab = "code"; }}
>
<Search size={14} aria-hidden="true" />
Code
</button>
<button
class="global-search-tab"
class:active={activeTab === "files"}
type="button"
role="tab"
aria-selected={activeTab === "files"}
onclick={() => { activeTab = "files"; }}
>
<FileText size={14} aria-hidden="true" />
Files
</button>
</div>
<div class="global-search-options">
<label class="check-row">
<input type="checkbox" bind:checked={caseSensitive} disabled={!hasRepository || isBusy || isSearching} />
<span>Exact case</span>
{#if activeTab === "code"}
<form class="global-search-form" onsubmit={submit}>
<label class="global-search-query">
<span>String or function</span>
<textarea
bind:value={query}
onkeydown={handleKeydown}
disabled={!hasRepository || isBusy || isSearching}
spellcheck="false"
placeholder={"Paste a string, symbol, or full function body..."}
></textarea>
</label>
<label class="search-limit">
<span>Results</span>
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}>
<option value={100}>100</option>
<option value={250}>250</option>
<option value={500}>500</option>
<option value={1000}>1000</option>
</select>
</label>
<div class="global-search-options">
<label class="check-row">
<input type="checkbox" bind:checked={caseSensitive} disabled={!hasRepository || isBusy || isSearching} />
<span>Exact case</span>
</label>
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
{#if isSearching}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Search size={16} aria-hidden="true" />
{/if}
Search
</button>
<label class="search-limit">
<span>Results</span>
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}>
<option value={100}>100</option>
<option value={250}>250</option>
<option value={500}>500</option>
<option value={1000}>1000</option>
</select>
</label>
{#if isSearching}
<button class="btn-secondary search-cancel" type="button" onclick={onCancel}>
<X size={16} aria-hidden="true" />
Cancel
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
{#if isSearching}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Search size={16} aria-hidden="true" />
{/if}
Search
</button>
{#if isSearching}
<button class="btn-secondary search-cancel" type="button" onclick={onCancel}>
<X size={16} aria-hidden="true" />
Cancel
</button>
{/if}
</div>
</form>
<section class="global-search-results" aria-live="polite">
{#if !hasRepository}
<div class="blank-state">Open a repository first.</div>
{:else if isSearching}
<div class="blank-state">
<LoaderCircle class="spin" size={20} aria-hidden="true" />
Searching all branches...
</div>
{:else if error}
<div class="blank-state search-error">{error}</div>
{:else if !searched}
<div class="blank-state">Search a string or paste a complete function to find where it was added.</div>
{:else if results.length === 0}
<div class="blank-state">No introduction found for "{searchedQuery}".</div>
{:else}
<div class="search-result-head">
<strong>{results.length}</strong>
<span>{results.length === 1 ? "introduction" : "introductions"} found for "{searchedQuery}"</span>
</div>
<div class="search-hit-list">
{#each results as hit (`${hit.commit_hash}:${hit.file}:${hit.line_number ?? 0}`)}
<article class="search-hit">
<header class="search-hit-top">
<span class="hash">{hit.short_hash}</span>
<strong title={hit.summary}>{hit.summary || "No commit message"}</strong>
{#if hit.matches_added > 1}
<span class="pill pill-active">+{hit.matches_added} matches</span>
{/if}
<button
class="btn-secondary search-hit-diff"
type="button"
disabled={isBusy}
title={`Compare this version of ${hit.file} with the current version`}
onclick={() => onDiff(hit)}
>
<GitCompare size={14} aria-hidden="true" />
DIFF
</button>
</header>
<div class="search-hit-meta">
<span><User size={12} aria-hidden="true" />{hit.author_name || "Unknown author"}</span>
<span><CalendarDays size={12} aria-hidden="true" />{formatCommitDate(hit.date)}</span>
</div>
<div class="search-hit-file" title={displayPath(hit)}>
<FileCode size={14} aria-hidden="true" />
<span>{displayPath(hit)}</span>
{#if hit.line_number}
<strong>:{hit.line_number}</strong>
{/if}
</div>
<pre class="search-hit-line">{hit.line || searchedQuery}</pre>
</article>
{/each}
</div>
{/if}
</div>
</form>
</section>
{:else}
<form class="global-search-form file-search-form" onsubmit={(event) => event.preventDefault()}>
<label class="global-search-query">
<span>File name or path</span>
<input
bind:value={fileQuery}
disabled={!hasRepository || isBusy}
spellcheck="false"
autocomplete="off"
placeholder="Search files, folders, extensions..."
/>
</label>
</form>
<section class="global-search-results" aria-live="polite">
{#if !hasRepository}
<div class="blank-state">Open a repository first.</div>
{:else if isSearching}
<div class="blank-state">
<LoaderCircle class="spin" size={20} aria-hidden="true" />
Searching all branches...
</div>
{:else if error}
<div class="blank-state search-error">{error}</div>
{:else if !searched}
<div class="blank-state">Search a string or paste a complete function to find where it was added.</div>
{:else if results.length === 0}
<div class="blank-state">No introduction found for "{searchedQuery}".</div>
{:else}
<div class="search-result-head">
<strong>{results.length}</strong>
<span>{results.length === 1 ? "introduction" : "introductions"} found for "{searchedQuery}"</span>
</div>
<section class="global-search-results file-search-results" aria-live="polite">
{#if !hasRepository}
<div class="blank-state">Open a repository first.</div>
{:else if files.length === 0}
<div class="blank-state">No files loaded for this repository.</div>
{:else}
<div class="file-search-split">
<div class="file-search-column">
{#if !fileSearchActive}
<div class="blank-state">Search a file name or path, then choose a result to show its history.</div>
{:else if fileSearchResults.length === 0}
<div class="blank-state">No file found for "{fileQuery.trim()}".</div>
{:else}
<div class="search-result-head">
<strong>{fileSearchResults.length}</strong>
<span>{fileSearchResults.length === 1 ? "file" : "files"} found for "{fileQuery.trim()}"</span>
</div>
<div class="search-hit-list">
{#each results as hit (`${hit.commit_hash}:${hit.file}:${hit.line_number ?? 0}`)}
<article class="search-hit">
<header class="search-hit-top">
<span class="hash">{hit.short_hash}</span>
<strong title={hit.summary}>{hit.summary || "No commit message"}</strong>
{#if hit.matches_added > 1}
<span class="pill pill-active">+{hit.matches_added} matches</span>
{/if}
<div class="file-search-list">
{#each fileSearchResults as file (file.path)}
{@const languageIcon = languageIconForPath(file.path)}
<button
class="file-search-hit"
class:active={selectedFilePath === file.path}
type="button"
disabled={isBusy || isSearching}
title={`Show history for ${file.path}`}
onclick={() => onSelectFile(file)}
>
<span class="file-search-icon">
{#if languageIcon}
<LanguageIcon icon={languageIcon.icon} title={languageIcon.title} />
{:else}
<FileText size={16} aria-hidden="true" />
{/if}
</span>
<span class="file-search-main">
<strong>{fileName(file.path)}</strong>
<span>{folderName(file.path)}</span>
</span>
{#if file.status}
<small class={`status-badge ${file.status}`}>{file.status}</small>
{:else if !file.tracked}
<small class="status-badge untracked">untracked</small>
{/if}
<span class="file-search-action">
<History size={14} aria-hidden="true" />
History
</span>
</button>
{/each}
</div>
{/if}
</div>
<aside class="file-search-history" aria-label="Selected file history">
<header class="file-search-history-head">
<span>History</span>
<strong title={selectedFilePath}>{selectedFilePath ? fileName(selectedFilePath) : "No file selected"}</strong>
</header>
<div class="search-hit-meta">
<span><User size={12} aria-hidden="true" />{hit.author_name || "Unknown author"}</span>
<span><CalendarDays size={12} aria-hidden="true" />{formatCommitDate(hit.date)}</span>
</div>
{#if !selectedFilePath}
<div class="blank-state">Select a file result to load its commit history.</div>
{:else if isBusy}
<div class="blank-state">
<LoaderCircle class="spin" size={18} aria-hidden="true" />
Loading history...
</div>
{:else if fileHistory.length === 0}
<div class="blank-state">No history returned for this file.</div>
{:else}
<div class="file-search-history-list">
{#each fileHistory as commit (commit.hash)}
<article class="file-search-history-row">
<div class="file-search-history-main">
<span class="hash">{commit.short_hash}</span>
<strong title={commit.summary}>{commit.summary || "No commit message"}</strong>
<span>{commit.author_name || "Unknown author"} - {formatCommitDate(commit.date)}</span>
</div>
<div class="search-hit-file" title={displayPath(hit)}>
<FileCode size={14} aria-hidden="true" />
<span>{displayPath(hit)}</span>
{#if hit.line_number}
<strong>:{hit.line_number}</strong>
{/if}
</div>
<pre class="search-hit-line">{hit.line || searchedQuery}</pre>
</article>
{/each}
</div>
{/if}
</section>
<div class="file-search-history-actions">
<button class="btn-sm" type="button" onclick={() => onFileHistoryDiff(commit)} disabled={isBusy}>
<GitCompare size={14} aria-hidden="true" />
Diff
</button>
<button class="btn-sm" type="button" onclick={() => onFileHistoryRestore(commit)} disabled={isBusy}>
<RotateCcw size={14} aria-hidden="true" />
Restore
</button>
</div>
</article>
{/each}
</div>
{/if}
</aside>
</div>
{/if}
</section>
{/if}
</div>
</div>
</div>
+508 -38
View File
@@ -1,46 +1,70 @@
<script lang="ts">
import { ChevronDown, ChevronRight, RotateCcw } from "@lucide/svelte";
import { ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment {
fromCol: number;
toCol: number;
color: string;
branches: string[];
}
interface GraphRow {
dotCol: number;
dotColor: string;
branchLabels: string[];
top: GraphSegment[];
bottom: GraphSegment[];
}
interface VisibleCommitEntry {
commit: GitCommit;
graphCommit: GitCommit;
}
const GRAPH_COLORS = [
"#2f6fb0", "#4aa777", "#c9851f", "#a05bd0",
"#cc4b6e", "#1f9ab0", "#7a8a1f", "#b0631f",
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
];
const GRAPH_LANE = 16;
const GRAPH_LANE = 18;
interface Props {
commits: GitCommit[];
localBranchNames: string[];
activeBranch: string;
repositoryKey: string;
hasRepository: boolean;
isBusy: boolean;
expandedCommitHashes: Set<string>;
onRestoreCommit: (commit: GitCommit) => void;
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
onToggleCommitFiles: (hash: string) => void;
onCreateBranchFromCommit: (commit: GitCommit) => void;
}
let {
commits = [],
localBranchNames = [],
activeBranch = "",
repositoryKey = "",
hasRepository = false,
isBusy = false,
expandedCommitHashes = new Set(),
onRestoreCommit = () => {},
onPreviewCommitFile = () => {},
onToggleCommitFiles = () => {},
onCreateBranchFromCommit = () => {},
}: Props = $props();
let hiddenGraphBranches = $state<Set<string>>(new Set());
let branchDialogOpen = $state(false);
let userAdjustedBranchFilter = $state(false);
let lastDefaultFilterKey = $state("");
let panelElement = $state<HTMLElement | null>(null);
let contextCommit = $state<GitCommit | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
function laneColor(col: number): string {
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
}
@@ -49,13 +73,23 @@
return col * GRAPH_LANE + GRAPH_LANE / 2;
}
function computeGraph(items: GitCommit[]): { rows: GraphRow[]; columns: number } {
function graphPath(seg: GraphSegment, fromY: number, toY: number): string {
const x1 = graphColX(seg.fromCol);
const x2 = graphColX(seg.toCol);
if (x1 === x2) return `M ${x1} ${fromY} L ${x2} ${toY}`;
const midY = (fromY + toY) / 2;
return `M ${x1} ${fromY} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${toY}`;
}
function computeGraph(items: GitCommit[], branchMembership = new Map<string, string[]>()): { rows: GraphRow[]; columns: number } {
const rows: GraphRow[] = [];
let lanes: (string | null)[] = [];
let laneBranches: string[][] = [];
let maxColumns = 1;
for (const commit of items) {
const before = lanes.slice();
const beforeBranches = laneBranches.map((branches) => branches.slice());
let col = before.indexOf(commit.hash);
if (col === -1) {
@@ -65,18 +99,27 @@
const after = before.slice();
while (after.length <= col) after.push(null);
const afterBranches = beforeBranches.map((branches) => branches.slice());
while (afterBranches.length <= col) afterBranches.push([]);
for (let k = 0; k < after.length; k++) {
if (after[k] === commit.hash) after[k] = null;
if (after[k] === commit.hash) {
after[k] = null;
afterBranches[k] = [];
}
}
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 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); }
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
after[slot] = commit.parents[p];
afterBranches[slot] = [];
fromCommit.add(slot);
}
@@ -84,19 +127,28 @@
for (let k = 0; k < before.length; k++) {
const target = before[k];
if (target == null) continue;
top.push({ fromCol: k, toCol: target === commit.hash ? col : k, color: laneColor(k) });
top.push({ fromCol: k, toCol: target === commit.hash ? col : k, color: laneColor(k), branches: beforeBranches[k] ?? [] });
}
const bottom: GraphSegment[] = [];
for (let k = 0; k < after.length; k++) {
if (after[k] == null) continue;
bottom.push({ fromCol: fromCommit.has(k) ? col : k, toCol: k, color: laneColor(k) });
bottom.push({
fromCol: fromCommit.has(k) ? col : k,
toCol: k,
color: laneColor(k),
branches: fromCommit.has(k) ? commitBranches : afterBranches[k] ?? [],
});
}
rows.push({ dotCol: col, dotColor: laneColor(col), top, bottom });
rows.push({ dotCol: col, dotColor: laneColor(col), branchLabels: currentBranches, top, bottom });
lanes = after.slice();
while (lanes.length > 0 && lanes[lanes.length - 1] == null) lanes.pop();
laneBranches = afterBranches.map((branches) => branches.slice());
while (lanes.length > 0 && lanes[lanes.length - 1] == null) {
lanes.pop();
laneBranches.pop();
}
maxColumns = Math.max(maxColumns, before.length, after.length, col + 1);
}
@@ -111,73 +163,421 @@
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
}
function baseName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function commitFileName(file: GitCommitFile): string {
return file.old_path
? `${baseName(file.old_path)} -> ${baseName(file.path)}`
: baseName(file.path);
}
function authorInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
function commitKind(commit: GitCommit): "merge" | "root" | "commit" {
if (commit.parents.length > 1) return "merge";
if (commit.parents.length === 0) return "root";
return "commit";
}
function commitKindLabel(commit: GitCommit): string {
const kind = commitKind(commit);
if (kind === "merge") return "merge";
if (kind === "root") return "root";
return "commit";
}
let localBranchNameSet = $derived(new Set(localBranchNames));
function uniqueStrings(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
function branchIsVisible(branch: string): boolean {
return !hiddenGraphBranches.has(branch);
}
function visibleBranchLabels(labels: string[]): string[] {
return labels.filter(branchIsVisible);
}
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
const directBranches = localBranchRefs(commit);
if (directBranches.length > 0) return directBranches;
const containingBranches = row?.branchLabels ?? [];
if (containingBranches.length <= 3) return containingBranches;
return [...containingBranches.slice(0, 3), `+${containingBranches.length - 3} more`];
}
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
const directBranches = localBranchRefs(commit);
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
const containingBranches = row?.branchLabels ?? [];
if (containingBranches.length === 0) return commit.short_hash;
return `Branches containing this commit: ${containingBranches.join(", ")}`;
}
function segmentIsVisible(segment: GraphSegment): boolean {
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
}
function branchesAreVisible(branches: string[]): boolean {
if (localBranchNames.length === 0) return true;
return branches.some(branchIsVisible);
}
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
return branchesAreVisible(row?.branchLabels ?? []);
}
function nearestVisibleGraphParents(
hash: string,
visibleHashes: Set<string>,
commitByHash: Map<string, GitCommit>,
seen: Set<string>,
): string[] {
if (visibleHashes.has(hash)) return [hash];
if (seen.has(hash)) return [];
seen.add(hash);
const commit = commitByHash.get(hash);
if (!commit) return [];
return uniqueStrings(
commit.parents.flatMap((parentHash) => (
nearestVisibleGraphParents(parentHash, visibleHashes, commitByHash, new Set(seen))
)),
);
}
function branchMembershipByHash(items: GitCommit[]): Map<string, string[]> {
const commitByHash = new Map(items.map((commit) => [commit.hash, commit]));
const membership = new Map<string, Set<string>>();
for (const commit of items) {
for (const branch of localBranchRefs(commit)) {
const stack = [commit.hash];
const seen = new Set<string>();
while (stack.length > 0) {
const hash = stack.pop();
if (!hash || seen.has(hash)) continue;
seen.add(hash);
let branches = membership.get(hash);
if (!branches) {
branches = new Set<string>();
membership.set(hash, branches);
}
branches.add(branch);
const parentCommit = commitByHash.get(hash);
if (parentCommit) stack.push(...parentCommit.parents);
}
}
}
return new Map(
items.map((commit) => [
commit.hash,
localBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
]),
);
}
function visibleCommitEntriesForGraph(items: GitCommit[], branchMembership: Map<string, string[]>): VisibleCommitEntry[] {
const visibleItems = items.filter((commit) => branchesAreVisible(branchMembership.get(commit.hash) ?? []));
const visibleHashes = new Set(visibleItems.map((commit) => commit.hash));
const commitByHash = new Map(items.map((commit) => [commit.hash, commit]));
return visibleItems.map((commit) => {
const parents = uniqueStrings(
commit.parents.flatMap((parentHash) => (
nearestVisibleGraphParents(parentHash, visibleHashes, commitByHash, new Set())
)),
);
return { commit, graphCommit: { ...commit, parents } };
});
}
function toggleGraphBranch(branch: string) {
const next = new Set(hiddenGraphBranches);
if (next.has(branch)) next.delete(branch); else next.add(branch);
hiddenGraphBranches = next;
userAdjustedBranchFilter = true;
}
function showAllGraphBranches() {
hiddenGraphBranches = new Set();
userAdjustedBranchFilter = true;
}
function hideAllGraphBranches() {
hiddenGraphBranches = new Set(localBranchNames);
userAdjustedBranchFilter = true;
}
function openBranchDialog() {
branchDialogOpen = true;
}
function closeBranchDialog() {
branchDialogOpen = false;
}
function handleBranchDialogKeydown(event: KeyboardEvent) {
if (branchDialogOpen && event.key === "Escape") {
closeBranchDialog();
}
}
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
if (contextCommit?.hash === commit.hash) {
closeCommitContextMenu();
return;
}
const panelRect = panelElement?.getBoundingClientRect();
const buttonRect = event.currentTarget instanceof HTMLElement
? event.currentTarget.getBoundingClientRect()
: null;
const rawX = panelRect && buttonRect ? buttonRect.right - panelRect.left - 184 : event.offsetX;
const rawY = panelRect && buttonRect ? buttonRect.bottom - panelRect.top + 4 : event.offsetY;
const maxX = Math.max(8, (panelRect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (panelRect?.height ?? window.innerHeight) - 96);
contextCommit = commit;
contextMenuX = Math.max(8, Math.min(rawX, maxX));
contextMenuY = Math.max(8, Math.min(rawY, maxY));
}
function closeCommitContextMenu() {
contextCommit = null;
}
async function createBranchFromContextCommit() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
await onCreateBranchFromCommit(commit);
}
async function restoreContextCommit() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
await onRestoreCommit(commit);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
closeCommitContextMenu();
handleBranchDialogKeydown(event);
}
function localBranchRefs(commit: GitCommit): string[] {
const seen = new Set<string>();
const labels: string[] = [];
for (const ref of commit.refs) {
const label = refLabel(ref);
if (!localBranchNameSet.has(label) || seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels;
}
function visibleRefs(commit: GitCommit): string[] {
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
}
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 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);
}
let graph = $derived(computeGraph(commits));
$effect(() => {
const available = new Set(localBranchNames);
const nextHidden = new Set([...hiddenGraphBranches].filter((branch) => available.has(branch)));
if (nextHidden.size !== hiddenGraphBranches.size) {
hiddenGraphBranches = nextHidden;
}
});
$effect(() => {
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
? activeBranch
: (localBranchNames[0] ?? "");
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
if (!defaultBranch) {
if (lastDefaultFilterKey !== defaultFilterKey) {
hiddenGraphBranches = new Set();
userAdjustedBranchFilter = false;
lastDefaultFilterKey = defaultFilterKey;
}
return;
}
if (lastDefaultFilterKey !== defaultFilterKey) {
userAdjustedBranchFilter = false;
lastDefaultFilterKey = defaultFilterKey;
}
if (!userAdjustedBranchFilter) {
hiddenGraphBranches = new Set(localBranchNames.filter((branch) => branch !== defaultBranch));
}
});
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 graph = $derived(computeGraph(graphCommits, branchMembership));
let graphRows = $derived(graph.rows);
let graphWidth = $derived(Math.max(graph.columns, 1) * GRAPH_LANE);
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
</script>
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
<svelte:window onclick={closeCommitContextMenu} onkeydown={handleWindowKeydown} on:contextmenu|capture={closeCommitContextMenu} />
<section bind:this={panelElement} class="panel history-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
<div class="section-head">
<div>
<span class="eyebrow">History</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
</div>
<span class="pill pill-count">{commits.length}</span>
<div class="section-head-actions">
{#if localBranchNames.length > 0}
<button
class="graph-branch-dialog-button"
type="button"
onclick={openBranchDialog}
title="Select branches shown in the graph"
>
<GitBranch size={13} aria-hidden="true" />
Branches
<span>{visibleBranchCount}/{localBranchNames.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 !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">
{#each commits as item, rowIndex (item.hash)}
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
{@const item = entry.commit}
{@const row = graphRows[rowIndex]}
<article class="commit-row graph-row">
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
{@const otherRefs = visibleRefs(item)}
<article
class="commit-row graph-row"
class:merge-row={item.parents.length > 1}
class:root-row={item.parents.length === 0}
class:tip-row={item.refs.length > 0}
>
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
{#if row}
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
{#each row.top as seg}
<line
x1={graphColX(seg.fromCol)} y1="0"
x2={graphColX(seg.toCol)} y2="50"
stroke={seg.color} stroke-width="2" vector-effect="non-scaling-stroke"
<path
class:hidden-branch={!segmentIsVisible(seg)}
d={graphPath(seg, 0, 50)}
stroke={seg.color}
stroke-width="2.2"
vector-effect="non-scaling-stroke"
/>
{/each}
{#each row.bottom as seg}
<line
x1={graphColX(seg.fromCol)} y1="50"
x2={graphColX(seg.toCol)} y2="100"
stroke={seg.color} stroke-width="2" vector-effect="non-scaling-stroke"
<path
class:hidden-branch={!segmentIsVisible(seg)}
d={graphPath(seg, 50, 100)}
stroke={seg.color}
stroke-width="2.2"
vector-effect="non-scaling-stroke"
/>
{/each}
</svg>
<span
class="graph-dot"
class:merge={item.parents.length > 1}
class:tip={item.refs.length > 0}
class:hidden-branch={!rowGraphIsVisible(row)}
title={commitHoverTitle(item, row)}
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
></span>
{#if hoverBranchRefs.length > 0}
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
{#each hoverBranchRefs as branch}
<span title={branch}>
<GitBranch size={10} aria-hidden="true" />
{branch}
</span>
{/each}
</div>
{/if}
{/if}
</div>
<div class="commit-body">
<div class="commit-line">
<div>
<strong title={item.summary}>{item.summary}</strong>
<span>{item.short_hash} - {item.author_name}</span>
<div class="commit-card-head">
<span class="commit-avatar">
{authorInitials(item.author_name)}
</span>
<div class="commit-card-main">
<div class="commit-title-row">
<strong class="commit-summary" title={item.summary}>{item.summary}</strong>
<span class={`commit-kind ${commitKind(item)}`}>
{#if item.parents.length > 1}
<GitMerge size={12} aria-hidden="true" />
{/if}
{commitKindLabel(item)}
</span>
</div>
<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>
</div>
</div>
</div>
{#if item.refs.length > 0}
{#if otherRefs.length > 0}
<div class="ref-list" aria-label="Commit refs">
{#each item.refs as ref}
<span>{ref}</span>
{#each otherRefs as ref}
<span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
{/each}
</div>
{/if}
@@ -206,10 +606,10 @@
type="button"
onclick={() => onPreviewCommitFile(item, file)}
disabled={isBusy}
title="Show differences before restoring"
title={`Show differences before restoring - ${displayCommitFile(file)}`}
>
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
<strong>{displayCommitFile(file)}</strong>
<strong>{commitFileName(file)}</strong>
</button>
{/each}
</div>
@@ -218,11 +618,19 @@
{/if}
<div class="commit-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons">
<button class="btn-sm" type="button" onclick={() => onRestoreCommit(item)} disabled={isBusy} title="Reset current branch to this commit">
<RotateCcw size={15} aria-hidden="true" />
Restore
<button
class="commit-menu-button"
type="button"
onclick={(event) => openCommitActionMenu(event, item)}
disabled={isBusy}
title="Commit actions"
aria-label={`Actions for ${item.short_hash}`}
aria-haspopup="menu"
aria-expanded={contextCommit?.hash === item.hash}
>
<EllipsisVertical size={15} aria-hidden="true" />
</button>
</div>
</div>
@@ -231,4 +639,66 @@
{/each}
</div>
{/if}
{#if contextCommit}
<div
class="history-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextCommit.short_hash}`}
>
<button type="button" role="menuitem" onclick={createBranchFromContextCommit} disabled={isBusy}>
<GitBranch size={14} aria-hidden="true" />
Branch
</button>
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
<RotateCcw size={14} aria-hidden="true" />
Restore
</button>
</div>
{/if}
</section>
{#if branchDialogOpen}
<div class="branch-filter-backdrop" role="presentation">
<div
class="branch-filter-dialog"
role="dialog"
aria-modal="true"
aria-label="Select visible branches"
>
<header class="branch-filter-dialog-head">
<div>
<span class="eyebrow">Git graph</span>
<h3>Visible branches</h3>
</div>
<button class="dialog-icon-button" type="button" onclick={closeBranchDialog} aria-label="Close branch selection">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="branch-filter-summary">
<span>{visibleBranchCount} of {localBranchNames.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={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}
</div>
</div>
</div>
{/if}
+189
View File
@@ -0,0 +1,189 @@
<script lang="ts">
import { LoaderCircle, X } from "@lucide/svelte";
import type { GitFileStatus, PatchApplyAction } from "../types";
type PatchLineKind = "context" | "add" | "delete" | "meta";
interface PatchLine {
id: string;
text: string;
kind: PatchLineKind;
}
interface PatchHunk {
id: string;
header: string;
lines: PatchLine[];
}
interface ParsedPatch {
headerLines: string[];
hunks: PatchHunk[];
binary: boolean;
}
interface Props {
file: GitFileStatus;
staged: boolean;
patch: string;
isBusy: boolean;
isLoading: boolean;
error: string;
onClose: () => void;
onRefresh: () => void | Promise<void>;
onApply: (action: PatchApplyAction, patch: string) => void | Promise<void>;
}
let {
file,
staged = false,
patch = "",
isBusy = false,
isLoading = false,
error = "",
onClose = () => {},
onRefresh = () => {},
onApply = () => {},
}: Props = $props();
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
$effect(() => {
parsed = parsePatch(patch);
});
function parsePatch(input: string): ParsedPatch {
const normalized = input.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
const headerLines: string[] = [];
const hunks: PatchHunk[] = [];
let current: PatchHunk | null = null;
for (const line of lines) {
if (line.startsWith("@@ ")) {
current = { id: `hunk-${hunks.length}`, header: line, lines: [] };
hunks.push(current);
continue;
}
if (!current) {
headerLines.push(line);
continue;
}
const kind = patchLineKind(line);
current.lines.push({
id: `${current.id}-line-${current.lines.length}`,
text: line,
kind,
});
}
return {
headerLines,
hunks,
binary: /(^|\n)(Binary files|GIT binary patch|literal \d+)/.test(normalized),
};
}
function patchLineKind(line: string): PatchLineKind {
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
if (line.startsWith(" ")) return "context";
return "meta";
}
function linePrefix(line: PatchLine): string {
if (line.kind === "add") return "+";
if (line.kind === "delete") return "-";
if (line.kind === "meta") return "\\";
return " ";
}
function lineBody(line: PatchLine): string {
if (line.kind === "meta") return line.text;
return line.text.slice(1);
}
function buildHunkPatch(hunk: PatchHunk): string {
return `${[...parsed.headerLines, hunk.header, ...hunk.lines.map((line) => line.text)].join("\n")}\n`;
}
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
if (isBusy || isLoading) return;
await onApply(action, buildHunkPatch(hunk));
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label="Line patch">
<header class="dialog-header">
<div>
<span class="eyebrow">{scopeLabel}</span>
<p class="dialog-title" title={displayPath}>{displayPath}</p>
</div>
<div class="dialog-header-actions">
<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" />
</button>
</div>
</header>
<div class="line-patch-body">
{#if isLoading}
<div class="blank-state">
<LoaderCircle class="spin" size={18} aria-hidden="true" />
Loading patch...
</div>
{:else if error}
<div class="blank-state">{error}</div>
{:else if !patch.trim()}
<div class="blank-state">No line patch available for this file.</div>
{:else if parsed.binary || parsed.hunks.length === 0}
<div class="blank-state">This change cannot be split into text lines.</div>
{:else}
<div class="line-patch-scroll">
{#each parsed.hunks as hunk (hunk.id)}
<section class="line-patch-hunk">
<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>
{/if}
</div>
</div>
</div>
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import { GitBranch, LoaderCircle, X } from "@lucide/svelte";
import type { GitCommit } from "../types";
interface Props {
commit: GitCommit;
isBusy: boolean;
onCreate: (name: string) => void;
onClose: () => void;
}
let {
commit,
isBusy = false,
onCreate = () => {},
onClose = () => {},
}: Props = $props();
let name = $state("");
function submit(event: SubmitEvent) {
event.preventDefault();
const value = name.trim();
if (!value) return;
onCreate(value);
}
</script>
<div
class="dialog-backdrop"
role="presentation"
>
<div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">New branch</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">From commit</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="new-branch-form" onsubmit={submit}>
<div class="new-branch-target">
<span class="hash">{commit.short_hash}</span>
<span class="new-branch-summary" title={commit.summary}>{commit.summary}</span>
</div>
<label class="new-branch-field">
<span>Branch name</span>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={name}
autocomplete="off"
spellcheck="false"
placeholder="feature/my-branch"
disabled={isBusy}
autofocus
/>
</label>
<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}>
{#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<GitBranch size={16} aria-hidden="true" />
{/if}
Create branch
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import { GitBranch, LoaderCircle, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
interface Props {
branch: GitBranchInfo;
isBusy: boolean;
onRename: (name: string) => void;
onClose: () => void;
}
let {
branch,
isBusy = false,
onRename = () => {},
onClose = () => {},
}: Props = $props();
let name = $state("");
$effect(() => {
name = branch.name;
});
function submit(event: SubmitEvent) {
event.preventDefault();
const value = name.trim();
if (!value || value === branch.name) return;
onRename(value);
}
</script>
<div
class="dialog-backdrop"
role="presentation"
>
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">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">
<X size={18} aria-hidden="true" />
</button>
</header>
<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
/>
</label>
<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}>
{#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<GitBranch size={16} aria-hidden="true" />
{/if}
Rename
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,175 @@
<script lang="ts">
export let label = "Opening repository";
export let repoName = "";
</script>
<div class="repo-loading" role="status" aria-live="polite">
<div class="repo-loading-card">
<!-- Animated git graph: nodes light up in sequence while a branch draws itself -->
<svg class="git-graph" viewBox="0 0 120 120" aria-hidden="true">
<defs>
<linearGradient id="gl-line" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#646cff" />
<stop offset="100%" stop-color="#41d1ff" />
</linearGradient>
</defs>
<!-- rotating dashed ring -->
<circle class="ring" cx="60" cy="60" r="52" />
<!-- main trunk -->
<path class="trunk" d="M42 22 L42 98" />
<!-- branch forking off and merging back -->
<path class="branch" d="M42 44 C42 62, 82 58, 82 76 L82 88" />
<!-- commit nodes -->
<circle class="node n1" cx="42" cy="30" r="6" />
<circle class="node n2" cx="42" cy="60" r="6" />
<circle class="node n3" cx="82" cy="88" r="6" />
<circle class="node n4" cx="42" cy="90" r="6" />
</svg>
<div class="repo-loading-text">
<span class="repo-loading-label">{label}</span>
{#if repoName}
<span class="repo-loading-name" title={repoName}>{repoName}</span>
{/if}
</div>
<div class="repo-loading-bar"><span></span></div>
</div>
</div>
<style>
.repo-loading {
position: fixed;
inset: 0;
z-index: 400;
display: grid;
place-items: center;
background: rgba(6, 6, 14, 0.72);
backdrop-filter: blur(8px);
animation: overlay-in 180ms ease;
}
.repo-loading-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 34px 46px;
border: 1px solid var(--color-border);
border-radius: 18px;
background: var(--color-surface-raised);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
}
.git-graph {
width: 118px;
height: 118px;
overflow: visible;
}
.git-graph .ring {
fill: none;
stroke: rgba(100, 108, 255, 0.22);
stroke-width: 2;
stroke-dasharray: 26 18;
transform-origin: 60px 60px;
animation: ring-spin 5s linear infinite;
}
.git-graph .trunk,
.git-graph .branch {
fill: none;
stroke: url(#gl-line);
stroke-width: 4;
stroke-linecap: round;
}
.git-graph .branch {
stroke-dasharray: 90;
stroke-dashoffset: 90;
animation: branch-draw 2.4s ease-in-out infinite;
}
.git-graph .node {
fill: var(--color-surface-alt);
stroke: url(#gl-line);
stroke-width: 4;
animation: node-pulse 2.4s ease-in-out infinite;
}
.git-graph .n1 { animation-delay: 0s; }
.git-graph .n2 { animation-delay: 0.5s; }
.git-graph .n3 { animation-delay: 1s; }
.git-graph .n4 { animation-delay: 1.5s; }
.repo-loading-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
}
.repo-loading-label {
color: var(--color-ink);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.01em;
}
.repo-loading-name {
max-width: 260px;
overflow: hidden;
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.repo-loading-bar {
position: relative;
width: 180px;
height: 3px;
overflow: hidden;
border-radius: 3px;
background: rgba(100, 108, 255, 0.16);
}
.repo-loading-bar span {
position: absolute;
inset: 0;
width: 40%;
border-radius: 3px;
background: linear-gradient(90deg, transparent, var(--color-accent), transparent);
animation: bar-slide 1.3s ease-in-out infinite;
}
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes ring-spin { to { transform: rotate(360deg); } }
@keyframes bar-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
@keyframes branch-draw {
0% { stroke-dashoffset: 90; opacity: 0.35; }
45% { stroke-dashoffset: 0; opacity: 1; }
100% { stroke-dashoffset: 0; opacity: 1; }
}
@keyframes node-pulse {
0%, 100% { fill: var(--color-surface-alt); filter: none; }
50% {
fill: var(--color-primary);
filter: drop-shadow(0 0 6px rgba(100, 108, 255, 0.8));
}
}
@media (prefers-reduced-motion: reduce) {
.git-graph .ring,
.git-graph .branch,
.git-graph .node,
.repo-loading-bar span { animation: none; }
.git-graph .branch { stroke-dashoffset: 0; }
}
</style>
+3 -4
View File
@@ -254,20 +254,19 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
class="dialog"
style="grid-template-rows: auto minmax(0,1fr) auto;"
role="dialog"
aria-modal="true"
aria-label="Resolve merge conflicts"
aria-label="Resolve conflicts"
tabindex="-1"
>
<header class="dialog-header">
<div>
<span class="eyebrow">Resolve</span>
<h2 class="dialog-title">Merge conflicts</h2>
<h2 class="dialog-title">Conflicts</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
@@ -275,7 +274,7 @@
</header>
{#if conflictedFiles.length === 0}
<div class="blank-state">All conflicts resolved. You can commit the merge now.</div>
<div class="blank-state">All conflicts resolved. Continue the current operation when ready.</div>
{:else}
<div class="dialog-body">
<aside class="dialog-files" aria-label="Conflicted files">
+134
View File
@@ -0,0 +1,134 @@
<script lang="ts">
import { Archive, ChevronDown, ChevronRight, Download, Trash2, Upload } from "@lucide/svelte";
import type { GitStash } from "../types";
interface Props {
stashes: GitStash[];
changedCount: number;
hasRepository: boolean;
isBusy: boolean;
onPush: (message: string, includeUntracked: boolean) => void;
onApply: (stash: GitStash) => void;
onPop: (stash: GitStash) => void;
onDrop: (stash: GitStash) => void;
}
let {
stashes = [],
changedCount = 0,
hasRepository = false,
isBusy = false,
onPush = () => {},
onApply = () => {},
onPop = () => {},
onDrop = () => {},
}: Props = $props();
let message = $state("");
let includeUntracked = $state(true);
let open = $state(false);
function submitPush() {
onPush(message, includeUntracked);
message = "";
}
function stashTitle(stash: GitStash): string {
return stash.message || stash.selector;
}
</script>
<section class="panel stash-panel overflow-hidden" class:collapsed={!open} aria-label="Git stash">
<div class="section-head">
<div>
<span class="eyebrow">Stash</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Shelved changes</h2>
</div>
<div class="stash-head-actions">
<button
class="stash-toggle"
type="button"
onclick={() => { open = !open; }}
aria-expanded={open}
title={open ? "Collapse stash panel" : "Expand stash panel"}
>
{#if open}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
</button>
<span class="pill pill-count">{stashes.length}</span>
</div>
</div>
{#if !open}
<!-- collapsed -->
{:else if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else}
<div class="stash-create">
<input
class="stash-input"
type="text"
bind:value={message}
placeholder="Optional message"
disabled={isBusy || changedCount === 0}
onkeydown={(event) => {
if (event.key === "Enter" && changedCount > 0 && !isBusy) submitPush();
}}
/>
<label class="stash-check">
<input type="checkbox" bind:checked={includeUntracked} disabled={isBusy || changedCount === 0} />
Untracked
</label>
<button
class="btn-sm stash-save-button"
type="button"
onclick={submitPush}
disabled={isBusy || changedCount === 0}
title="Save current working tree changes to a stash"
>
<Archive size={14} aria-hidden="true" />
Stash
</button>
</div>
{#if stashes.length === 0}
<div class="blank-state stash-empty">No stashes saved.</div>
{:else}
<div class="stash-list">
{#each stashes as stash (stash.selector)}
<article class="stash-row">
<div class="stash-row-main">
<strong title={stashTitle(stash)}>{stashTitle(stash)}</strong>
<span>
{stash.selector}
{#if stash.branch}
on {stash.branch}
{/if}
{#if stash.date}
- {stash.date}
{/if}
</span>
</div>
<div class="stash-actions">
<button class="btn-sm" type="button" onclick={() => onApply(stash)} disabled={isBusy} title="Apply stash and keep it">
<Download size={13} aria-hidden="true" />
Apply
</button>
<button class="btn-sm" type="button" onclick={() => onPop(stash)} disabled={isBusy} title="Apply stash and remove it if successful">
<Upload size={13} aria-hidden="true" />
Pop
</button>
<button class="btn-sm danger" type="button" onclick={() => onDrop(stash)} disabled={isBusy} title="Delete stash">
<Trash2 size={13} aria-hidden="true" />
Drop
</button>
</div>
</article>
{/each}
</div>
{/if}
{/if}
</section>
+39 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, RotateCcw, Undo2 } from "@lucide/svelte";
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
interface Props {
@@ -9,9 +9,12 @@
hasRepository: boolean;
isBusy: boolean;
status: GitStatus | null;
selectedFilePath: string;
onSelectFile: (file: GitFileStatus) => void;
onStage: (file: GitFileStatus) => void;
onUnstage: (file: GitFileStatus) => void;
onDiscard: (file: GitFileStatus, staged: boolean) => void;
onPatch: (file: GitFileStatus, staged: boolean) => void;
onStageAll: () => void;
onUnstageAll: () => void;
}
@@ -23,9 +26,12 @@
hasRepository = false,
isBusy = false,
status = null,
selectedFilePath = "",
onSelectFile = () => {},
onStage = () => {},
onUnstage = () => {},
onDiscard = () => {},
onPatch = () => {},
onStageAll = () => {},
onUnstageAll = () => {},
}: Props = $props();
@@ -38,6 +44,18 @@
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
}
function baseName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function fileName(file: GitFileStatus): string {
return file.old_path ? `${baseName(file.old_path)} -> ${baseName(file.path)}` : baseName(file.path);
}
function canPatch(kind: FileStatusKind | null): boolean {
return kind === "modified";
}
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
</script>
@@ -88,9 +106,19 @@
{:else}
<div class="overflow-auto p-2">
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
<article class="file-row">
<article
class="file-row"
class:selected={selectedFilePath === file.path}
>
<div class="file-title">
<strong title={displayPath(file)}>{displayPath(file)}</strong>
<button
class="file-title-button"
type="button"
onclick={() => onSelectFile(file)}
title={`Select ${displayPath(file)} in Explorer`}
>
<strong>{fileName(file)}</strong>
</button>
</div>
<div class="change-lanes">
@@ -105,6 +133,10 @@
<Undo2 size={14} aria-hidden="true" />
Unstage
</button>
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
<FileDiff size={14} aria-hidden="true" />
Details
</button>
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
<RotateCcw size={14} aria-hidden="true" />
Discard
@@ -126,6 +158,10 @@
<Check size={14} aria-hidden="true" />
Stage
</button>
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
<FileDiff size={14} aria-hidden="true" />
Details
</button>
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
<RotateCcw size={14} aria-hidden="true" />
Discard
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts">
import {
AlertCircle,
Download,
LoaderCircle,
PackageCheck,
Sparkles,
X,
} from "@lucide/svelte";
type UpdateToastState = "available" | "downloading" | "installed" | "error";
interface Props {
state: UpdateToastState;
version: string;
currentVersion: string;
progress: number;
error: string;
onInstall: () => void;
onLater: () => void;
onDismiss: () => void;
}
let {
state,
version,
currentVersion = "",
progress = 0,
error = "",
onInstall,
onLater,
onDismiss,
}: Props = $props();
const clampedProgress = $derived(Math.max(0, Math.min(100, Math.round(progress))));
const title = $derived(
state === "installed"
? "Update installed"
: state === "downloading"
? "Installing update"
: state === "error"
? "Update failed"
: "Update available",
);
const description = $derived(
state === "installed"
? "Restart GitLite to use the new version."
: state === "downloading"
? "Download and installation are running in the background."
: state === "error"
? (error || "The update could not be installed.")
: "Do you want to install this version now?",
);
const versionLabel = $derived(
version && currentVersion
? `${currentVersion} -> ${version}`
: version
? `Version ${version}`
: "New version",
);
const showProgress = $derived(state === "downloading" || state === "installed");
const isBusy = $derived(state === "downloading");
</script>
<section
class="update-toast"
class:error={state === "error"}
class:installed={state === "installed"}
role={state === "error" ? "alert" : "status"}
aria-live={state === "error" ? "assertive" : "polite"}
>
<div class="update-toast-glow" aria-hidden="true"></div>
<div class="update-toast-icon" class:busy={isBusy}>
{#if state === "downloading"}
<LoaderCircle class="spin" size={21} aria-hidden="true" />
{:else if state === "installed"}
<PackageCheck size={21} aria-hidden="true" />
{:else if state === "error"}
<AlertCircle size={21} aria-hidden="true" />
{:else}
<Sparkles size={21} aria-hidden="true" />
{/if}
</div>
<div class="update-toast-content">
<div class="update-toast-top">
<div class="update-toast-copy">
<span class="update-toast-kicker">{versionLabel}</span>
<h2>{title}</h2>
</div>
{#if !isBusy}
<button class="update-toast-close" type="button" onclick={onDismiss} title="Close" aria-label="Close">
<X size={15} aria-hidden="true" />
</button>
{/if}
</div>
<p>{description}</p>
{#if showProgress}
<div
class="update-progress"
class:indeterminate={state === "downloading" && clampedProgress === 0}
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={clampedProgress}
>
<span style={`width: ${clampedProgress}%`}></span>
</div>
<div class="update-progress-label">
{state === "installed" ? "100% complete" : clampedProgress > 0 ? `${clampedProgress}%` : "Waiting for download size"}
</div>
{/if}
<div class="update-toast-actions">
{#if state === "installed"}
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
Got it
</button>
{:else if state === "error"}
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
Close
</button>
<button class="update-toast-primary" type="button" onclick={onInstall}>
<Download size={14} aria-hidden="true" />
Try again
</button>
{:else}
<button class="update-toast-secondary" type="button" onclick={onLater} disabled={isBusy}>
Later
</button>
<button class="update-toast-primary" type="button" onclick={onInstall} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={14} aria-hidden="true" />
Installing
{:else}
<Download size={14} aria-hidden="true" />
Install
{/if}
</button>
{/if}
</div>
</div>
</section>
+164 -2
View File
@@ -1,13 +1,20 @@
import { invoke } from "@tauri-apps/api/core";
import type {
CommitAiLocalProfile,
CommitAiProvider,
CommitAiStatus,
ConflictFile,
GitBranch,
GitCommit,
GitCommitComparison,
GitRepositoryFile,
GitSearchHit,
GitStash,
GitStatus,
LocalModelOption,
PatchApplyAction,
RepositoryBundle,
StoredCredential,
} from "./types";
@@ -15,18 +22,78 @@ export function openRepository(path: string): Promise<GitStatus> {
return invoke<GitStatus>("open_repository", { path });
}
export function openRepoInExplorer(path: string): Promise<void> {
return invoke<void>("open_repo_in_explorer", { path });
}
export function openRepositoryFile(path: string, file: string): Promise<void> {
return invoke<void>("open_repository_file", { path, file });
}
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
}
export function cloneRepository(
remoteUrl: string,
parentPath: string,
directoryName?: string,
username?: string,
password?: string,
commitLimit = 100,
): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("clone_repository", {
remoteUrl,
parentPath,
directoryName: directoryName?.trim() ? directoryName.trim() : null,
username: username ?? null,
password: password ?? null,
commitLimit,
});
}
export function getStatus(path: string): Promise<GitStatus> {
return invoke<GitStatus>("get_status", { path });
}
// Sets the taskbar icon badge to ahead + behind + changed status files (0 clears it). Windows only — a no-op on
// other platforms, since Windows has no native numeric badge to fall back to.
export function setSyncBadge(ahead: number, behind: number, changes: number): Promise<void> {
return invoke<void>("set_sync_badge", { ahead, behind, changes });
}
export function listBranches(path: string): Promise<GitBranch[]> {
return invoke<GitBranch[]>("list_branches", { path });
}
export function listStashes(path: string): Promise<GitStash[]> {
return invoke<GitStash[]>("list_stashes", { path });
}
export function checkoutBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("checkout_branch", { path, branch });
}
export function createBranch(
path: string,
branch: string,
startPoint?: string,
): Promise<GitStatus> {
return invoke<GitStatus>("create_branch", { path, branch, startPoint: startPoint ?? null });
}
export function renameBranch(
path: string,
oldBranch: string,
newBranch: string,
): Promise<GitStatus> {
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
}
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch, force });
}
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
return invoke<GitStatus>("stage_files", { path, files });
}
@@ -43,14 +110,88 @@ export function restoreFiles(
return invoke<GitStatus>("restore_files", { path, files, staged });
}
export function getFilePatch(path: string, file: string, staged: boolean): Promise<string> {
return invoke<string>("get_file_patch", { path, file, staged });
}
export function applyFilePatch(
path: string,
file: string,
patch: string,
action: PatchApplyAction,
): Promise<GitStatus> {
return invoke<GitStatus>("apply_file_patch", { path, file, patch, action });
}
export function commit(path: string, message: string): Promise<GitStatus> {
return invoke<GitStatus>("commit", { path, message });
}
export function stashPush(
path: string,
message?: string,
includeUntracked = true,
): Promise<GitStatus> {
return invoke<GitStatus>("stash_push", {
path,
message: message?.trim() ? message.trim() : null,
includeUntracked,
});
}
export function stashApply(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_apply", { path, selector });
}
export function stashPop(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_pop", { path, selector });
}
export function stashDrop(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_drop", { path, selector });
}
export function commitAiStatus(): Promise<CommitAiStatus> {
return invoke<CommitAiStatus>("commit_ai_status");
}
export function commitAiLoad(modelId: string): Promise<void> {
return invoke<void>("commit_ai_load", { modelId });
}
export function commitAiLocalModels(): Promise<LocalModelOption[]> {
return invoke<LocalModelOption[]>("commit_ai_local_models");
}
export interface CommitAiGenerateOptions {
notes?: string;
provider: CommitAiProvider;
localProfile?: CommitAiLocalProfile;
model?: string;
apiKey?: string;
baseUrl?: string;
}
export function commitAiGenerate(path: string, options: CommitAiGenerateOptions): Promise<string> {
return invoke<string>("commit_ai_generate", {
path,
notes: options.notes,
provider: options.provider,
localProfile: options.localProfile,
model: options.model,
apiKey: options.apiKey,
baseUrl: options.baseUrl,
});
}
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
}
export function fetchRemote(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null });
}
export function push(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null });
}
@@ -96,12 +237,33 @@ export function mergeBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("merge_branch", { path, branch });
}
export function rebaseBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_branch", { path, branch });
}
export function rebaseContinue(path: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_continue", { path });
}
export function rebaseAbort(path: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_abort", { path });
}
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
}
export function listFileHistory(path: string, file: string, limit = 100): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_file_history", { path, file, limit });
export function listFileHistory(
path: string,
file: string,
limit = 100,
requestId?: string,
): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_file_history", { path, file, limit, requestId: requestId ?? null });
}
export function cancelFileHistory(requestId: string): Promise<void> {
return invoke<void>("cancel_file_history", { requestId });
}
export function compareCommits(
+46
View File
@@ -7,6 +7,32 @@ export type FileStatusKind =
| "conflicted"
| "unknown";
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
export interface CommitAiStatus {
phase: CommitAiPhase;
model_id: string | null;
error: string | null;
}
export interface LocalModelOption {
id: string;
label: string;
approx_size_mb: number;
}
export interface AiSettings {
provider: CommitAiProvider;
localModelId: string;
localProfile: CommitAiLocalProfile;
openaiModel: string;
anthropicModel: string;
customBaseUrl: string;
customModel: string;
}
export interface GitStatus {
repo_path: string;
current_branch: string | null;
@@ -15,6 +41,7 @@ export interface GitStatus {
behind: number;
files: GitFileStatus[];
clean: boolean;
rebase_in_progress: boolean;
}
export interface GitFileStatus {
@@ -24,12 +51,23 @@ export interface GitFileStatus {
unstaged: FileStatusKind | null;
}
export type PatchApplyAction = "stage" | "unstage" | "discard-unstaged" | "discard-staged";
export interface GitBranch {
name: string;
current: boolean;
remote: boolean;
}
export interface GitStash {
selector: string;
index: number;
hash: string;
branch: string | null;
message: string;
date: string;
}
export interface GitCommit {
hash: string;
short_hash: string;
@@ -54,6 +92,14 @@ export interface GitRepositoryFile {
status: FileStatusKind | null;
}
export interface RepositoryBundle {
status: GitStatus;
branches: GitBranch[];
stashes: GitStash[];
commits: GitCommit[];
files: GitRepositoryFile[];
}
export interface GitDiffFile {
path: string;
old_path: string | null;