Compare commits

..
24 Commits
Author SHA1 Message Date
Christoph eb65c1def7 fix(workflows): improve AUR SSH key handling and clone/push retries
publish / Build and publish Windows installer (release) Successful in 22m29s
publish / Build and publish Ubuntu AppImage (release) Successful in 37m9s
publish / Build and publish gitty-desktop to AUR (release) Failing after 52m11s
This update enhances the workflow for interacting with the AUR by
adding robust error handling for SSH key retrieval and repository
cloning/pushing. It implements retry logic with exponential backoff
for both SSH host key retrieval and git operations, improving
resilience against transient network issues.

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

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

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

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

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

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

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

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

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

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

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

- Added `GitWorktree` structure definition across API contracts and Rust backend
- Implemented full CRUD operations for worktrees in Tauri commands
- Updated UI components (App.svelte, BranchPanel.svelte) to expose worktree management dialog
2026-07-26 21:47:35 +02:00
32 changed files with 3585 additions and 971 deletions
+252 -85
View File
@@ -5,7 +5,8 @@ on:
types: [published]
jobs:
publish-tauri:
publish-windows:
name: Build and publish Windows installer
permissions:
contents: write
environment: production
@@ -30,25 +31,7 @@ jobs:
run:
working-directory: GitLite
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
# - platform: 'macos-latest'
# args: '--target aarch64-apple-darwin'
# - platform: 'macos-latest'
# args: '--target x86_64-apple-darwin'
- platform: "windows-latest"
bundles: "nsis"
updater_platform: "windows-x86_64"
no_strip: ""
- platform: "ubuntu-22.04"
bundles: "appimage"
updater_platform: "linux-x86_64"
no_strip: "1"
runs-on: ${{ matrix.platform }}
runs-on: windows-latest
steps:
# cicd_tool/main.py expects the repo at <workspace>/GitLite, so check out there.
@@ -56,25 +39,11 @@ jobs:
with:
path: GitLite
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install uv
if: matrix.platform == 'ubuntu-22.04'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
- name: install rust toolchain
if: matrix.platform == 'ubuntu-22.04'
uses: dtolnay/rust-toolchain@stable
- name: install frontend dependencies
run: npm ci
@@ -87,19 +56,14 @@ jobs:
run: |
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
- name: Update PKGBUILD Version
run: |
node -e "const fs=require('fs'); const version=require('./package.json').version; const pkgbuild=fs.readFileSync('PKGBUILD','utf8').replace(/^pkgver=.*$/m, 'pkgver='+version).replace(/^pkgrel=.*$/m, 'pkgrel=1'); fs.writeFileSync('PKGBUILD', pkgbuild);"
- name: Commit and Push Changes
if: matrix.platform == 'windows-latest'
shell: powershell
run: |
git config user.name '${{ vars.USERNAME_GIT }}'
git config user.email '${{ vars.EMAIL_GIT }}'
# npm version also bumps the version inside package-lock.json, so stage it
# too -- otherwise it stays as an unstaged change and blocks the rebase.
git add package.json package-lock.json src-tauri/tauri.conf.json PKGBUILD
git add package.json package-lock.json src-tauri/tauri.conf.json
git commit -m 'Update version to ${{ github.ref_name }}'
if ($LASTEXITCODE -ne 0) {
@@ -143,28 +107,25 @@ jobs:
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
NO_STRIP: ${{ matrix.no_strip }}
run: npm run tauri -- build --bundles ${{ matrix.bundles }}
NO_STRIP: ""
run: npm run tauri -- build --bundles nsis
# ----------------------
# Upload to MinIO (via mc) and create latest.json
# ----------------------
- name: Upload artifacts to MinIO with cicd_tool
working-directory: GitLite/cicd_tool
env:
PUBLISH_PLATFORM: ${{ matrix.updater_platform }}
PUBLISH_PLATFORM: windows-x86_64
run: |
uv sync
uv run main.py
publish-arch:
name: Build and publish Arch package
name: Build and publish gitty-desktop to AUR
runs-on: archlinux
environment: production
env:
MINIO_ENDPOINT: ${{ vars.MINIO_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
defaults:
run:
@@ -174,8 +135,66 @@ jobs:
- name: Prepare Arch job container
working-directory: /
run: |
pacman -Syu --noconfirm
pacman -S --needed --noconfirm nodejs npm git
set -euo pipefail
# The runner image may contain only a single geo mirror. Seed several
# currently active HTTPS mirrors and retain the image's list as a
# fallback so one slow CDN cannot fail the complete release.
ARCH_MIRROR_BACKUP="$(mktemp)"
cp /etc/pacman.d/mirrorlist "$ARCH_MIRROR_BACKUP"
printf '%s\n' \
'Server = https://frankfurt.mirror.pkgbuild.com/$repo/os/$arch' \
'Server = https://mirror.osbeck.com/archlinux/$repo/os/$arch' \
'Server = https://mirror.ubrco.de/archlinux/$repo/os/$arch' \
'Server = https://ftp.halifax.rwth-aachen.de/archlinux/$repo/os/$arch' \
'Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch' \
> /etc/pacman.d/mirrorlist
sed -n '/^[[:space:]]*Server[[:space:]]*=/p' "$ARCH_MIRROR_BACKUP" \
>> /etc/pacman.d/mirrorlist
ARCH_PACKAGES=(
base-devel curl git nodejs npm openssh rust
webkit2gtk-4.1 gtk3 hicolor-icon-theme
libappindicator-gtk3 librsvg xdotool
)
for ARCH_INSTALL_ATTEMPT in 1 2 3; do
echo "pacman attempt $ARCH_INSTALL_ATTEMPT of 3"
timeout --signal=TERM 30m \
pacman -Syu --needed --noconfirm --noprogressbar \
"${ARCH_PACKAGES[@]}" \
</dev/null &
ARCH_PACMAN_PID=$!
(
ARCH_WAIT_SECONDS=0
while true; do
sleep 30
((ARCH_WAIT_SECONDS += 30))
echo "pacman is still running (${ARCH_WAIT_SECONDS}s elapsed)"
done
) &
ARCH_HEARTBEAT_PID=$!
ARCH_PACMAN_STATUS=0
wait "$ARCH_PACMAN_PID" || ARCH_PACMAN_STATUS=$?
kill "$ARCH_HEARTBEAT_PID" 2>/dev/null || true
wait "$ARCH_HEARTBEAT_PID" 2>/dev/null || true
if [ "$ARCH_PACMAN_STATUS" -eq 0 ]; then
break
fi
if [ "$ARCH_PACMAN_STATUS" -eq 124 ]; then
echo "pacman attempt timed out after 30 minutes" >&2
fi
if [ "$ARCH_INSTALL_ATTEMPT" -eq 3 ]; then
echo "pacman failed after 3 attempts" >&2
exit 1
fi
sleep "$((ARCH_INSTALL_ATTEMPT * 5))"
done
- uses: actions/checkout@v5
with:
@@ -186,46 +205,194 @@ jobs:
RELEASE_VERSION: ${{ github.ref_name }}
run: |
npm version "$RELEASE_VERSION" --no-git-tag-version --allow-same-version
node -e "const fs=require('fs'); const version=require('./package.json').version; const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=version; fs.writeFileSync(path,JSON.stringify(config,null,2)+'\n');"
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
sed -i "s/^pkgver=.*/pkgver=$PACKAGE_VERSION/; s/^pkgrel=.*/pkgrel=1/" PKGBUILD
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
echo "RELEASE_TAG=$RELEASE_VERSION" >> "$GITHUB_ENV"
- name: Generate and build AUR package
run: |
if ! id -u builder >/dev/null 2>&1; then
useradd --create-home builder
fi
AUR_SOURCE_DIR="$(mktemp -d)"
cp PKGBUILD "$AUR_SOURCE_DIR/PKGBUILD"
SOURCE_ARCHIVE="$AUR_SOURCE_DIR/gitty-desktop-$PACKAGE_VERSION.tar.gz"
curl --fail --location --silent --show-error \
--output "$SOURCE_ARCHIVE" \
"https://git.cbsk-tech.de/Christoph/GitLite/archive/$RELEASE_TAG.tar.gz"
CHECKSUM="$(sha256sum "$SOURCE_ARCHIVE" | cut -d ' ' -f 1)"
sed -i \
-e "s/^pkgver=.*/pkgver=$PACKAGE_VERSION/" \
-e "s/^pkgrel=.*/pkgrel=1/" \
-e "s/^_tag=.*/_tag=$RELEASE_TAG/" \
-e "s/^sha256sums=.*/sha256sums=('$CHECKSUM')/" \
"$AUR_SOURCE_DIR/PKGBUILD"
chown -R builder:builder "$AUR_SOURCE_DIR"
runuser -u builder -- \
bash -lc "cd '$AUR_SOURCE_DIR' && makepkg --cleanbuild --noconfirm && makepkg --printsrcinfo > .SRCINFO"
echo "AUR_SOURCE_DIR=$AUR_SOURCE_DIR" >> "$GITHUB_ENV"
- name: Publish PKGBUILD to AUR
env:
AUR_GIT_NAME: ${{ vars.USERNAME_GIT }}
AUR_GIT_EMAIL: ${{ vars.EMAIL_GIT }}
run: |
set -euo pipefail
if [ -z "$AUR_SSH_PRIVATE_KEY" ]; then
echo "AUR_SSH_PRIVATE_KEY is required" >&2
exit 1
fi
install -d -m 0700 "$HOME/.ssh"
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$HOME/.ssh/aur"
chmod 0600 "$HOME/.ssh/aur"
AUR_RETRY_DELAYS=(30 60 120 240)
AUR_KNOWN_HOSTS_TEMP="$(mktemp)"
AUR_HOST_KEY_READY=0
for AUR_ATTEMPT in 1 2 3 4 5; do
echo "AUR host-key attempt $AUR_ATTEMPT of 5"
if timeout 30s ssh-keyscan -T 15 -H aur.archlinux.org > "$AUR_KNOWN_HOSTS_TEMP" \
&& [ -s "$AUR_KNOWN_HOSTS_TEMP" ]; then
AUR_HOST_KEY_READY=1
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ "$AUR_HOST_KEY_READY" -ne 1 ]; then
echo "Could not retrieve the AUR SSH host key after 5 attempts" >&2
exit 1
fi
install -m 0600 "$AUR_KNOWN_HOSTS_TEMP" "$HOME/.ssh/known_hosts"
export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=2"
AUR_CHECKOUT_ROOT="$(mktemp -d)"
AUR_CHECKOUT=""
for AUR_ATTEMPT in 1 2 3 4 5; do
AUR_CLONE_CANDIDATE="$AUR_CHECKOUT_ROOT/attempt-$AUR_ATTEMPT"
echo "AUR clone attempt $AUR_ATTEMPT of 5"
if timeout 3m git -c init.defaultBranch=master clone \
ssh://aur@aur.archlinux.org/gitty-desktop.git "$AUR_CLONE_CANDIDATE"; then
AUR_CHECKOUT="$AUR_CLONE_CANDIDATE"
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
echo "AUR is unavailable; retrying after backoff"
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ -z "$AUR_CHECKOUT" ]; then
echo "Could not clone the AUR repository after 5 attempts" >&2
exit 1
fi
cp "$AUR_SOURCE_DIR/PKGBUILD" "$AUR_SOURCE_DIR/.SRCINFO" "$AUR_CHECKOUT/"
cd "$AUR_CHECKOUT"
git config user.name "${AUR_GIT_NAME:-Gitty Release Bot}"
git config user.email "${AUR_GIT_EMAIL:-aur@localhost}"
git add PKGBUILD .SRCINFO
if git diff --cached --quiet; then
echo "AUR metadata already matches release $PACKAGE_VERSION"
exit 0
fi
git commit -m "Update to $PACKAGE_VERSION"
AUR_PUSHED=0
for AUR_ATTEMPT in 1 2 3 4 5; do
echo "AUR push attempt $AUR_ATTEMPT of 5"
if timeout 3m git push origin HEAD:master; then
AUR_PUSHED=1
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
echo "AUR push failed; retrying after backoff"
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ "$AUR_PUSHED" -ne 1 ]; then
echo "Could not publish to AUR after 5 attempts" >&2
exit 1
fi
publish-ubuntu:
name: Build and publish Ubuntu AppImage
permissions:
contents: write
environment: production
env:
GITEA_API: https://git.cbsk-tech.de/api/v1
OWNER: Christoph
REPO: GitLite
GITEA_TOKEN: ${{ secrets.ACTIONS_TOKEN }}
GITEA_FALLBACK_TOKEN: ${{ github.token }}
MINIO_ENDPOINT: ${{ vars.MINIO_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
ARTIFACT_BASE_URL: ${{ vars.ARTIFACT_BASE_URL }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
defaults:
run:
working-directory: GitLite
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
with:
path: GitLite
- name: Install Ubuntu dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
- name: Restore pacman repository database
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install frontend dependencies
run: npm ci
- name: Update package.json version
run: npm version ${{ github.ref_name }} --no-git-tag-version --allow-same-version
- name: Update tauri.conf.json version
env:
RELEASE_VERSION: ${{ github.ref_name }}
run: |
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
- name: Build Tauri App
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
NO_STRIP: "1"
run: npm run tauri -- build --bundles appimage
- name: Upload artifacts to MinIO with cicd_tool
working-directory: GitLite/cicd_tool
env:
PUBLISH_PLATFORM: linux-x86_64
run: |
uv sync
uv run arch_repo.py download-db --output ../arch-repo/gitty.db.tar.gz
- name: Build pkg.tar.zst with PKGBUILD
run: |
pacman -S --needed --noconfirm \
base-devel git nodejs npm rust \
webkit2gtk-4.1 gtk3 hicolor-icon-theme \
libappindicator-gtk3 librsvg xdotool
mkdir -p arch-output
BUILD_ROOT="$(mktemp -d)"
cp -a . "$BUILD_ROOT/source"
mkdir -p "$BUILD_ROOT/makepkg"
useradd --create-home builder
chown -R builder:builder "$BUILD_ROOT"
runuser -u builder -- \
bash -lc "cd '$BUILD_ROOT/source' && BUILDDIR='$BUILD_ROOT/makepkg' makepkg --cleanbuild --noconfirm"
cp "$BUILD_ROOT"/source/*.pkg.tar.zst arch-output/
chmod 0644 arch-output/*.pkg.tar.zst
- name: Update pacman repository database
run: |
mkdir -p arch-repo
repo-add arch-repo/gitty.db.tar.gz arch-output/*.pkg.tar.zst
- name: Upload package and repository database to CDN
working-directory: GitLite/cicd_tool
run: uv run arch_repo.py publish --packages-dir ../arch-output --repo-dir ../arch-repo
uv run main.py
+90
View File
@@ -0,0 +1,90 @@
# Changelog
All notable user-facing changes to Gitty are documented in this file.
The project uses calendar versions. Displayed release names use `YYYY.MM.DD`;
package metadata uses the equivalent numeric form without leading zeroes where
required by the package manager.
## [2026.07.22] - 2026-07-22
### Added
- AI-assisted commit splitting for staged changes:
- analyze the staged diff and propose an ordered set of logical commits;
- generate an editable Conventional Commit message for every group;
- move files between proposed commits before applying the plan;
- create all accepted commits sequentially with **Commit all**.
- Support for commit-splitting suggestions through OpenAI, Anthropic, and
custom OpenAI-compatible endpoints.
### Changed
- The commit-splitting dialog now explains why a plan cannot be applied and
clearly marks AI-generated commit messages as editable.
- Safety checks prevent applying a stale plan after the staged files change
and reject files that contain both staged and unstaged changes.
### Fixed
- **Commit all** now passes a plain state snapshot to the commit workflow
instead of failing silently when cloning a reactive UI proxy.
- Closing the active repository tab no longer allows an in-flight status or
fetch response to reopen and reactivate the closed repository.
## [2026.07.21] - 2026-07-21
### Added
- Full Git worktree management:
- create a worktree from an existing branch;
- create a new branch and worktree together;
- create a detached worktree at a commit;
- open a worktree as a repository tab;
- move, lock, unlock, repair, remove, and prune worktrees.
- A dedicated **Worktrees** entry below **Tags** in the branch panel.
- An **Open in new worktree** action in the local branch context menu.
- Line-level patch selection with Shift-click range selection.
- Stage, unstage, and discard actions for selected lines while retaining the
existing whole-file and whole-hunk workflows.
- Arch Linux package automation that builds from `PKGBUILD`, publishes the
`.pkg.tar.zst` artifact, and updates the Pacman repository database on the
CDN.
- Detailed German and English help for worktrees, line-level staging, Pacman
installation, and release changes.
### Changed
- Branch deletion now uses a clearer confirmation dialog with more explicit
branch and upstream information.
- Dialog backdrops blur the application instead of covering it with a nearly
black background.
- Remote operations and authentication errors provide more actionable
feedback.
- Build diagnostics and resource handling for the Arch package pipeline are
more robust.
### Infrastructure
- Added centralized error notifications.
- Added structured, privacy-conscious telemetry.
- Stabilized the Arch Linux container build and `makepkg` workflow.
## [2026.7.20] - 2026-07-13
### Added
- AI-assisted review of staged changes before committing.
- Optional automatic repository refresh.
- Comprehensive German Git documentation in the built-in help.
- Initial `PKGBUILD` and automated package version synchronization.
### Changed
- Redesigned the repository action toolbar and workspace status presentation.
- Improved repository tabs, diff styling, spacing, and panel structure.
- Improved file-history state handling.
[2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22
[2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21
[2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20
+24 -31
View File
@@ -1,11 +1,7 @@
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
#
# Local/in-tree PKGBUILD: builds straight from this working directory (no
# source download, no tauri-bundler/AppImage step) and installs the raw
# binary + desktop entry. Run `makepkg -si` from the repo root.
pkgname=gitty
pkgver=2026.7.20
pkgname=gitty-desktop
pkgver=2026.7.22
pkgrel=1
pkgdesc="A lightweight, modern Git client built with Tauri"
arch=('x86_64')
@@ -15,21 +11,23 @@ depends=('webkit2gtk-4.1' 'gtk3' 'git' 'hicolor-icon-theme' 'libappindicator-gtk
makedepends=('rust' 'nodejs' 'npm')
options=('!lto' '!debug')
source=()
sha256sums=()
_tag=2026.7.22
source=("gitty-desktop-${pkgver}.tar.gz::${url}/archive/${_tag}.tar.gz")
sha256sums=('bd6c7d9da54917cbea98ce31172934b4657cc2fe2020f49d27ea2acb51348e9c')
# Keeps pkgver in sync with package.json (the release CI bumps that file).
pkgver() {
cd "$startdir"
node -p "require('./package.json').version"
prepare() {
cd "$srcdir/gitlite"
# Keep application metadata aligned even when a release tag contains
# leading zeroes that npm normalizes (for example 2026.7.01 -> 2026.7.1).
npm version "$pkgver" --no-git-tag-version --allow-same-version
RELEASE_VERSION="$pkgver" node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path,JSON.stringify(config,null,2)+'\n');"
}
build() {
cd "$startdir"
cd "$srcdir/gitlite"
# mistralrs-core is large enough that the regular release profile (thin LTO,
# one codegen unit and Arch debug flags) can exhaust memory in the LXC build
# runner. Keep the package optimized while bounding peak compiler memory.
# Keep the large Rust release build within the Arch runner's memory limit.
export CARGO_BUILD_JOBS=1
export CARGO_PROFILE_RELEASE_OPT_LEVEL=2
export CARGO_PROFILE_RELEASE_LTO=false
@@ -37,40 +35,35 @@ build() {
export CARGO_PROFILE_RELEASE_DEBUG=0
npm ci
# Build through the Tauri CLI (not a raw `cargo build --release`): it enables
# the `custom-protocol` cargo feature that makes the binary load the embedded
# `dist/` assets instead of the Vite dev server URL (127.0.0.1:1420), and runs
# `beforeBuildCommand` (`npm run build`) for us. `--no-bundle` skips deb/rpm/
# appimage packaging (and with it linuxdeploy/FUSE) since pacman owns that here.
npm run tauri -- build --no-bundle
}
package() {
cd "$startdir"
cd "$srcdir/gitlite"
install -Dm755 "src-tauri/target/release/gitty" "$pkgdir/usr/bin/gitty"
install -Dm755 "src-tauri/target/release/gitty" "$pkgdir/usr/bin/gitty-desktop"
install -Dm644 "src-tauri/icons/32x32.png" \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/gitty.png"
"$pkgdir/usr/share/icons/hicolor/32x32/apps/gitty-desktop.png"
install -Dm644 "src-tauri/icons/128x128.png" \
"$pkgdir/usr/share/icons/hicolor/128x128/apps/gitty.png"
"$pkgdir/usr/share/icons/hicolor/128x128/apps/gitty-desktop.png"
install -Dm644 "src-tauri/icons/128x128@2x.png" \
"$pkgdir/usr/share/icons/hicolor/256x256@2/apps/gitty.png"
"$pkgdir/usr/share/icons/hicolor/256x256@2/apps/gitty-desktop.png"
install -Dm644 "src-tauri/icons/icon.png" \
"$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty.png"
"$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty-desktop.png"
install -d "$pkgdir/usr/share/applications"
cat > "$pkgdir/usr/share/applications/gitty.desktop" <<-EOF
cat > "$pkgdir/usr/share/applications/gitty-desktop.desktop" <<-EOF
[Desktop Entry]
Type=Application
Name=Gitty
Comment=$pkgdesc
Exec=gitty
Icon=gitty
Exec=gitty-desktop
Icon=gitty-desktop
Terminal=false
Categories=Development;RevisionControl;
StartupWMClass=gitty
EOF
install -Dm644 "README.md" "$pkgdir/usr/share/doc/$pkgname/README.md"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
}
+12 -13
View File
@@ -54,23 +54,22 @@ MIT License
## Arch Linux
Release builds are published as an `x86_64` pacman repository. Add this block to
`/etc/pacman.conf` once:
```ini
[gitty]
SigLevel = Optional TrustAll
Server = https://cdn.cbsk-tech.de/gitty/arch/$arch
```
Then install Gitty and receive future releases through the normal system update:
Install Gitty from the AUR with an AUR helper:
```bash
sudo pacman -Syu gitty
yay -S gitty-desktop
```
The repository is currently distributed over HTTPS but is not GPG-signed. A
repository signing key should be added before recommending it to third parties.
Or build the AUR package manually:
```bash
git clone https://aur.archlinux.org/gitty-desktop.git
cd gitty-desktop
makepkg -si
```
The AUR recipe downloads the public Gitea release archive and builds Gitty from
source. The release pipeline updates its version, checksum, and `.SRCINFO`.
---
-86
View File
@@ -1,86 +0,0 @@
import argparse
import os
from pathlib import Path
from minio import Minio
from minio.error import S3Error
from main import (
ConfigurationError,
_create_minio_client,
_ensure_bucket,
_parse_publish_targets,
)
REPO_PREFIX = "arch/x86_64"
DATABASE_NAME = "gitty.db.tar.gz"
def _targets():
bucket_name = (os.environ.get("S3_BUCKET") or "gitty").lower()
app_dir = (os.environ.get("APP_DIR") or "gitty").strip("/")
return _parse_publish_targets(bucket_name, app_dir)
def download_database(client: Minio, output: Path) -> None:
target = _targets()[0]
object_name = f"{REPO_PREFIX}/{DATABASE_NAME}"
output.parent.mkdir(parents=True, exist_ok=True)
try:
client.fget_object(target.bucket_name, object_name, str(output))
print(f"Downloaded s3://{target.bucket_name}/{object_name} -> {output}")
except S3Error as exc:
if exc.code in {"NoSuchKey", "NoSuchObject"}:
print("No existing Arch repository database found; creating a new one.")
return
raise
def publish_repository(client: Minio, packages_dir: Path, repo_dir: Path) -> None:
packages = sorted(packages_dir.glob("*.pkg.tar.zst"))
if not packages:
raise FileNotFoundError(f"No .pkg.tar.zst package found in {packages_dir}")
repository_files = [
path
for path in repo_dir.iterdir()
if path.is_file() and path.name.startswith("gitty.")
]
if not any(path.name == DATABASE_NAME for path in repository_files):
raise FileNotFoundError(f"Missing repository database: {repo_dir / DATABASE_NAME}")
for target in _targets():
_ensure_bucket(client, target.bucket_name)
for path in [*packages, *repository_files]:
object_name = f"{REPO_PREFIX}/{path.name}"
client.fput_object(target.bucket_name, object_name, str(path))
print(f"Uploaded {path.name} -> s3://{target.bucket_name}/{object_name}")
def main() -> None:
parser = argparse.ArgumentParser(description="Manage the Gitty pacman repository on MinIO.")
subparsers = parser.add_subparsers(dest="command", required=True)
download_parser = subparsers.add_parser("download-db")
download_parser.add_argument("--output", type=Path, required=True)
publish_parser = subparsers.add_parser("publish")
publish_parser.add_argument("--packages-dir", type=Path, required=True)
publish_parser.add_argument("--repo-dir", type=Path, required=True)
args = parser.parse_args()
client = _create_minio_client()
if args.command == "download-db":
download_database(client, args.output)
else:
publish_repository(client, args.packages_dir, args.repo_dir)
if __name__ == "__main__":
try:
main()
except (ConfigurationError, FileNotFoundError, S3Error) as exc:
raise SystemExit(f"Arch repository operation failed: {exc}") from exc
+27 -1
View File
@@ -33,6 +33,24 @@ interface GitBranch {
remote: boolean;
}
interface GitWorktree {
path: string;
head: string | null;
short_head: string | null;
branch: string | null;
bare: boolean;
detached: boolean;
locked: boolean;
lock_reason: string | null;
prunable: boolean;
prune_reason: string | null;
missing: boolean;
is_main: boolean;
is_current: boolean;
clean: boolean;
changed_files: number;
}
interface GitCommit {
hash: string;
short_hash: string;
@@ -73,6 +91,14 @@ The command list below includes the repository-management and synchronization AP
- `set_branch_upstream(path: string, branch: string, upstream?: string): Promise<GitStatus>`
- `delete_remote_branch(path: string, remote: string, branch: string): Promise<GitStatus>`
- `checkout_branch(path: string, branch: string): Promise<GitStatus>`
- `list_worktrees(path: string): Promise<GitWorktree[]>`
- `add_worktree(path: string, worktreePath: string, ...): Promise<GitWorktree[]>`
- `remove_worktree(path: string, worktreePath: string, force?: boolean): Promise<GitWorktree[]>`
- `move_worktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]>`
- `lock_worktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]>`
- `unlock_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
- `prune_worktrees(path: string): Promise<GitWorktree[]>`
- `repair_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
@@ -80,7 +106,7 @@ The command list below includes the repository-management and synchronization AP
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
- `push(path: string, forceWithLease?: boolean, remote?: string): Promise<GitStatus>`
- `list_commits(path: string, limit?: number): Promise<GitCommit[]>`
- `list_commits(path: string, limit?: number, skip?: number): Promise<GitCommit[]>`
- `restore_to_commit(path: string, commit: string): Promise<GitStatus>`
- `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path)
- `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise<GitStatus>`
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitty",
"version": "2026.7.20",
"version": "2026.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "2026.7.20",
"version": "2026.8.1",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitty",
"version": "2026.7.20",
"version": "2026.8.1",
"private": true,
"type": "module",
"scripts": {
+127
View File
@@ -215,6 +215,89 @@ pub async fn review_custom(
openai_compatible_review_request(url, api_key, model, diff).await
}
async fn openai_compatible_split_request(
url: String,
bearer: Option<&str>,
model: &str,
diff: &str,
) -> Result<String, String> {
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.";
let user =
format!("Analyze these staged changes and propose an ordered commit plan:\n\n{diff}");
let body = OpenAiRequest {
model: model.to_string(),
messages: vec![
OpenAiMessage {
role: "system",
content: system.to_string(),
},
OpenAiMessage {
role: "user",
content: user,
},
],
temperature: 0.1,
};
let client = http_client()?;
let mut request = client.post(url).json(&body);
if let Some(key) = bearer.filter(|key| !key.trim().is_empty()) {
request = request.bearer_auth(key);
}
let response = request
.send()
.await
.map_err(|err| format!("Request to the AI model failed: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Could not read response: {err}"))?;
if !status.is_success() {
return Err(format!("API error ({status}): {text}"));
}
let parsed: OpenAiResponse =
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
parsed
.choices
.into_iter()
.next()
.and_then(|choice| choice.message.content)
.map(|content| sanitize_message(&content))
.filter(|content| !content.is_empty())
.ok_or_else(|| "The model did not return a commit plan.".to_string())
}
pub async fn split_openai(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
if api_key.trim().is_empty() {
return Err("OpenAI API key is missing.".to_string());
}
openai_compatible_split_request(
"https://api.openai.com/v1/chat/completions".to_string(),
Some(api_key),
model,
diff,
)
.await
}
pub async fn split_custom(
base_url: &str,
api_key: Option<&str>,
model: &str,
diff: &str,
) -> Result<String, String> {
if base_url.trim().is_empty() {
return Err("Endpoint URL is missing.".to_string());
}
openai_compatible_split_request(
format!("{}/chat/completions", base_url.trim_end_matches('/')),
api_key,
model,
diff,
)
.await
}
#[derive(Serialize)]
struct AnthropicMessage {
role: &'static str,
@@ -338,3 +421,47 @@ pub async fn review_anthropic(api_key: &str, model: &str, diff: &str) -> Result<
.filter(|text| !text.is_empty())
.ok_or_else(|| "The model did not return a review.".to_string())
}
pub async fn split_anthropic(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
if api_key.trim().is_empty() {
return Err("Anthropic API key is missing.".to_string());
}
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.".to_string();
let body = AnthropicRequest {
model: model.to_string(),
max_tokens: 2400,
system,
messages: vec![AnthropicMessage {
role: "user",
content: format!(
"Analyze these staged changes and propose an ordered commit plan:\n\n{diff}"
),
}],
};
let client = http_client()?;
let response = client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.json(&body)
.send()
.await
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Could not read response: {err}"))?;
if !status.is_success() {
return Err(format!("API error ({status}): {text}"));
}
let parsed: AnthropicResponse =
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
parsed
.content
.into_iter()
.find_map(|block| block.text)
.map(|text| sanitize_message(&text))
.filter(|text| !text.is_empty())
.ok_or_else(|| "The model did not return a commit plan.".to_string())
}
+1 -1
View File
@@ -2,7 +2,7 @@ mod cloud;
pub use cloud::{
generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom,
review_openai,
review_openai, split_anthropic, split_custom, split_openai,
};
use std::{
+589 -3
View File
@@ -68,6 +68,25 @@ pub struct GitBranch {
pub remote: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitWorktree {
pub path: String,
pub head: Option<String>,
pub short_head: Option<String>,
pub branch: Option<String>,
pub bare: bool,
pub detached: bool,
pub locked: bool,
pub lock_reason: Option<String>,
pub prunable: bool,
pub prune_reason: Option<String>,
pub missing: bool,
pub is_main: bool,
pub is_current: bool,
pub clean: bool,
pub changed_files: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitTag {
pub name: String,
@@ -927,6 +946,291 @@ pub fn delete_branch(
status_for_repo(&repo)
}
fn worktree_path_matches(left: &Path, right: &Path) -> bool {
match (fs::canonicalize(left), fs::canonicalize(right)) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}
fn parse_worktree_porcelain(output: &[u8], current_repo: &Path) -> Vec<GitWorktree> {
#[derive(Default)]
struct Record {
path: Option<String>,
head: Option<String>,
branch: Option<String>,
bare: bool,
detached: bool,
locked: bool,
lock_reason: Option<String>,
prunable: bool,
prune_reason: Option<String>,
}
fn finish_record(rows: &mut Vec<GitWorktree>, record: &mut Record, current_repo: &Path) {
let Some(path) = record.path.take() else {
*record = Record::default();
return;
};
let path_buf = PathBuf::from(&path);
let missing = !path_buf.exists();
let is_current = worktree_path_matches(&path_buf, current_repo);
let head = record.head.take();
let short_head = head
.as_ref()
.map(|value| value.chars().take(8).collect::<String>());
rows.push(GitWorktree {
path,
head,
short_head,
branch: record.branch.take(),
bare: record.bare,
detached: record.detached,
locked: record.locked,
lock_reason: record.lock_reason.take(),
prunable: record.prunable,
prune_reason: record.prune_reason.take(),
missing,
is_main: rows.is_empty(),
is_current,
clean: true,
changed_files: 0,
});
*record = Record::default();
}
let mut rows = Vec::new();
let mut record = Record::default();
for field in output.split(|byte| *byte == 0) {
if field.is_empty() {
finish_record(&mut rows, &mut record, current_repo);
continue;
}
let value = String::from_utf8_lossy(field);
let (key, detail) = value
.split_once(' ')
.map_or((value.as_ref(), None), |(key, detail)| (key, Some(detail)));
match key {
"worktree" => record.path = detail.map(str::to_string),
"HEAD" => record.head = detail.map(str::to_string),
"branch" => {
record.branch = detail.map(|branch| {
branch
.strip_prefix("refs/heads/")
.unwrap_or(branch)
.to_string()
})
}
"bare" => record.bare = true,
"detached" => record.detached = true,
"locked" => {
record.locked = true;
record.lock_reason = detail.map(str::to_string).filter(|value| !value.is_empty());
}
"prunable" => {
record.prunable = true;
record.prune_reason = detail.map(str::to_string).filter(|value| !value.is_empty());
}
_ => {}
}
}
finish_record(&mut rows, &mut record, current_repo);
rows
}
fn worktrees_for_repo(repo: &Path) -> Result<Vec<GitWorktree>, String> {
let output = run_git(repo, ["worktree", "list", "--porcelain", "-z"])?;
let mut worktrees = parse_worktree_porcelain(&output, repo);
for worktree in &mut worktrees {
if worktree.missing {
worktree.clean = false;
continue;
}
if worktree.bare {
continue;
}
match run_git_at(
Path::new(&worktree.path),
["status", "--porcelain=v1", "-z", "--untracked-files=normal"],
"Could not inspect worktree status",
) {
Ok(status) => {
worktree.changed_files = status
.split(|byte| *byte == 0)
.filter(|entry| entry.len() >= 3 && entry[2] == b' ')
.count() as u32;
worktree.clean = worktree.changed_files == 0;
}
Err(_) => {
worktree.clean = false;
}
}
}
Ok(worktrees)
}
#[tauri::command]
pub fn list_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn add_worktree(
path: String,
worktree_path: String,
branch: Option<String>,
new_branch: Option<String>,
start_point: Option<String>,
detached: Option<bool>,
lock: Option<bool>,
) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
let destination = worktree_path.trim();
if destination.is_empty() {
return Err("Choose a folder for the new worktree.".to_string());
}
let branch = branch
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let new_branch = new_branch
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if branch.is_some() && new_branch.is_some() {
return Err("Choose either an existing branch or a new branch.".to_string());
}
let mut args = vec![OsString::from("worktree"), OsString::from("add")];
if lock.unwrap_or(false) {
args.push(OsString::from("--lock"));
}
if detached.unwrap_or(false) {
args.push(OsString::from("--detach"));
}
let target = if let Some(new_branch) = new_branch {
let new_branch = validate_new_branch_name(&repo, &new_branch)?;
args.push(OsString::from("-b"));
args.push(OsString::from(new_branch));
start_point
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(|value| verify_commit(&repo, value))
.transpose()?
} else if let Some(branch) = branch {
Some(validate_existing_local_branch_name(&repo, &branch)?)
} else {
start_point
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(|value| verify_commit(&repo, value))
.transpose()?
};
args.push(OsString::from(destination));
if let Some(target) = target {
args.push(OsString::from(target));
}
run_git(&repo, args)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn remove_worktree(
path: String,
worktree_path: String,
force: Option<bool>,
) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
let worktrees = worktrees_for_repo(&repo)?;
let target = worktrees
.iter()
.find(|worktree| {
worktree_path_matches(Path::new(&worktree.path), Path::new(&worktree_path))
})
.ok_or_else(|| "The selected worktree is no longer registered.".to_string())?;
if target.is_main {
return Err("The main worktree cannot be removed.".to_string());
}
if target.is_current {
return Err("The currently open worktree cannot be removed.".to_string());
}
if target.locked {
return Err("Unlock this worktree before removing it.".to_string());
}
let mut args = vec![OsString::from("worktree"), OsString::from("remove")];
if force.unwrap_or(false) {
args.push(OsString::from("--force"));
}
args.push(OsString::from(worktree_path));
run_git(&repo, args)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn move_worktree(
path: String,
worktree_path: String,
destination: String,
) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
let destination = destination.trim();
if destination.is_empty() {
return Err("Choose a new location for the worktree.".to_string());
}
run_git(
&repo,
[
OsString::from("worktree"),
OsString::from("move"),
OsString::from(worktree_path),
OsString::from(destination),
],
)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn lock_worktree(
path: String,
worktree_path: String,
reason: Option<String>,
) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
let mut args = vec![OsString::from("worktree"), OsString::from("lock")];
if let Some(reason) = reason
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
args.push(OsString::from("--reason"));
args.push(OsString::from(reason));
}
args.push(OsString::from(worktree_path));
run_git(&repo, args)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn unlock_worktree(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "unlock", "--", worktree_path.as_str()])?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn prune_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "prune"])?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn repair_worktree(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn create_tag(
path: String,
@@ -1339,6 +1643,127 @@ pub struct AiReviewResult {
pub findings: Vec<AiReviewFinding>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiCommitGroup {
pub message: String,
pub reason: String,
pub files: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiCommitPlan {
pub summary: String,
pub groups: Vec<AiCommitGroup>,
}
fn parse_ai_commit_plan(raw: &str, staged_files: &[String]) -> Result<AiCommitPlan, String> {
use std::collections::HashSet;
let trimmed = raw.trim().trim_matches('`').trim();
let json = match (trimmed.find('{'), trimmed.rfind('}')) {
(Some(start), Some(end)) if start <= end => &trimmed[start..=end],
_ => return Err("The AI response did not contain a valid commit plan.".to_string()),
};
let mut plan: AiCommitPlan = serde_json::from_str(json)
.map_err(|error| format!("Could not process the commit plan: {error}"))?;
plan.groups
.retain(|group| !group.message.trim().is_empty() && !group.files.is_empty());
if plan.groups.len() < 2 {
return Err("The staged changes do not appear to benefit from splitting.".to_string());
}
if plan.groups.len() > 12 {
return Err("The AI proposed too many commit groups.".to_string());
}
let expected = staged_files.iter().cloned().collect::<HashSet<_>>();
let mut seen = HashSet::new();
for group in &mut plan.groups {
group.message = group.message.trim().to_string();
group.reason = group.reason.trim().to_string();
group
.files
.retain(|file| expected.contains(file) && seen.insert(file.clone()));
if group.files.is_empty() {
return Err("The AI returned an empty or duplicate commit group.".to_string());
}
}
if seen != expected {
return Err("The AI plan did not assign every staged file exactly once.".to_string());
}
plan.summary = plan.summary.trim().to_string();
Ok(plan)
}
#[tauri::command]
pub async fn commit_ai_split(
path: String,
provider: String,
model: Option<String>,
api_key: Option<String>,
base_url: Option<String>,
) -> Result<AiCommitPlan, String> {
let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?;
let staged_files = status
.files
.iter()
.filter(|file| file.staged.is_some())
.map(|file| file.path.clone())
.collect::<Vec<_>>();
if staged_files.len() < 2 {
return Err("Stage at least two files before creating a split plan.".to_string());
}
if status
.files
.iter()
.any(|file| file.staged.is_some() && file.unstaged.is_some())
{
return Err("Files with both staged and unstaged changes cannot be split safely. Stage or discard the remaining changes first.".to_string());
}
let diff = staged_diff(&repo)?;
let model = model.filter(|value| !value.trim().is_empty());
let api_key = api_key.filter(|value| !value.trim().is_empty());
let base_url = base_url.filter(|value| !value.trim().is_empty());
let raw = match provider.as_str() {
"openai" => {
commit_ai::split_openai(
api_key
.as_deref()
.ok_or_else(|| "OpenAI API key is missing.".to_string())?,
model.as_deref().unwrap_or("gpt-4o-mini"),
&diff,
)
.await?
}
"anthropic" => {
commit_ai::split_anthropic(
api_key
.as_deref()
.ok_or_else(|| "Anthropic API key is missing.".to_string())?,
model.as_deref().unwrap_or("claude-3-5-haiku-latest"),
&diff,
)
.await?
}
"custom" => {
commit_ai::split_custom(
base_url
.as_deref()
.ok_or_else(|| "Endpoint URL is missing.".to_string())?,
api_key.as_deref(),
model
.as_deref()
.ok_or_else(|| "Model name is missing.".to_string())?,
&diff,
)
.await?
}
"local" => return Err("Commit splitting currently requires an API provider.".to_string()),
other => return Err(format!("Unknown AI provider: {other}")),
};
parse_ai_commit_plan(&raw, &staged_files)
}
#[derive(Deserialize)]
struct AiReviewWireFinding {
severity: String,
@@ -2492,17 +2917,31 @@ fn cherry_pick_status_or_error(
}
#[tauri::command]
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
pub fn list_commits(
path: String,
limit: Option<u32>,
skip: Option<u32>,
) -> Result<Vec<GitCommit>, String> {
let repo = resolve_repo(&path)?;
commits_for_repo(&repo, limit)
commit_page_for_repo(&repo, limit, skip)
}
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
let bounded_limit = limit.unwrap_or(100).clamp(1, 500);
commit_page_for_repo(repo, Some(bounded_limit), None)
}
fn commit_page_for_repo(
repo: &Path,
limit: Option<u32>,
skip: Option<u32>,
) -> Result<Vec<GitCommit>, String> {
if verify_commit(repo, "HEAD").is_err() {
return Ok(Vec::new());
}
let limit = limit.unwrap_or(100).clamp(1, 500).to_string();
let limit = limit.unwrap_or(100).clamp(1, 5_000).to_string();
let skip = skip.unwrap_or(0).min(10_000_000).to_string();
// Fetch the per-commit changed files inline via `--name-status` in a single
// `git log` process, instead of spawning one `git diff-tree` per commit
// (which was ~100 extra processes and the main cost of opening a repo).
@@ -2518,6 +2957,8 @@ fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, S
"-z",
"--root",
"--pretty=format:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1f",
"--skip",
skip.as_str(),
"-n",
limit.as_str(),
],
@@ -5914,6 +6355,40 @@ mod tests {
}));
}
#[test]
fn commit_pages_use_stable_non_overlapping_offsets() {
let repo = init_temp_repo("commit_pages");
commit_initial_file(&repo.path);
for index in 1..=3 {
fs::write(repo.path.join("old.txt"), format!("version {index}\n"))
.expect("tracked file should change");
run_git_test(&repo.path, ["add", "old.txt"]);
run_git_test(
&repo.path,
["commit", "-q", "-m", format!("commit {index}").as_str()],
);
}
let all = commits_for_repo(&repo.path, Some(10)).expect("commits should load");
let first = commit_page_for_repo(&repo.path, Some(2), Some(0))
.expect("first commit page should load");
let second = commit_page_for_repo(&repo.path, Some(2), Some(2))
.expect("second commit page should load");
assert_eq!(first.len(), 2);
assert_eq!(second.len(), 2);
assert_eq!(first[0].hash, all[0].hash);
assert_eq!(first[1].hash, all[1].hash);
assert_eq!(second[0].hash, all[2].hash);
assert_eq!(second[1].hash, all[3].hash);
assert!(
first
.iter()
.all(|commit| second.iter().all(|other| other.hash != commit.hash))
);
}
#[tokio::test]
#[cfg_attr(
windows,
@@ -6409,6 +6884,36 @@ mod tests {
"one\nTWO\nthree\nfour\n"
);
let staged_patch = get_file_patch(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
true,
)
.expect("staged patch should load");
let status = apply_file_patch(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
staged_patch,
"unstage".to_string(),
)
.expect("selected staged lines should unstage");
assert_eq!(status.files[0].staged, None);
assert_eq!(status.files[0].unstaged, Some(FileStatusKind::Modified));
assert_eq!(
git_output_test(&repo.path, ["show", ":old.txt"]),
"one\ntwo\nthree"
);
let selected_patch = "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n";
apply_file_patch(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
selected_patch.to_string(),
"stage".to_string(),
)
.expect("selected line should stage again");
let unstaged_patch = get_file_patch(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
@@ -6687,6 +7192,76 @@ mod tests {
assert!(result.lines[0].is_uncommitted);
}
#[test]
fn parse_worktree_porcelain_preserves_flags_and_reasons() {
let current = Path::new("/repos/main");
let output = b"worktree /repos/main\0HEAD 1234567890abcdef\0branch refs/heads/main\0\0worktree /repos/feature\0HEAD abcdef1234567890\0detached\0locked external drive\0prunable gitdir file points to non-existent location\0\0";
let rows = parse_worktree_porcelain(output, current);
assert_eq!(rows.len(), 2);
assert!(rows[0].is_main);
assert_eq!(rows[0].branch.as_deref(), Some("main"));
assert_eq!(rows[0].short_head.as_deref(), Some("12345678"));
assert!(rows[1].detached);
assert!(rows[1].locked);
assert_eq!(rows[1].lock_reason.as_deref(), Some("external drive"));
assert!(rows[1].prunable);
}
#[test]
fn worktree_add_list_and_remove_round_trip() {
let repo = init_temp_repo("worktree_round_trip");
let destination = temp_dir("worktree_round_trip_destination");
commit_initial_file(&repo.path);
run_git_test(&repo.path, ["branch", "feature"]);
let rows = add_worktree(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
Some("feature".to_string()),
None,
None,
Some(false),
Some(false),
)
.expect("worktree should be created");
let linked = rows
.iter()
.find(|row| row.branch.as_deref() == Some("feature"))
.expect("linked worktree should be listed");
assert!(!linked.is_main);
assert!(linked.clean);
let rows = lock_worktree(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
Some("test lock".to_string()),
)
.expect("worktree should lock");
let linked = rows
.iter()
.find(|row| row.branch.as_deref() == Some("feature"))
.expect("linked worktree should remain listed");
assert!(linked.locked);
assert_eq!(linked.lock_reason.as_deref(), Some("test lock"));
unlock_worktree(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
)
.expect("worktree should unlock");
let rows = remove_worktree(
repo.path.to_string_lossy().to_string(),
destination.path.to_string_lossy().to_string(),
Some(false),
)
.expect("worktree should be removed");
assert_eq!(rows.len(), 1);
}
#[test]
fn parse_ai_review_accepts_fenced_json_and_normalizes_findings() {
let raw = r#"```json
@@ -6701,4 +7276,15 @@ mod tests {
assert_eq!(review.findings[0].file.as_deref(), Some("src/main.rs"));
assert_eq!(review.findings[0].line, Some(42));
}
#[test]
fn parse_ai_commit_plan_requires_each_staged_file_exactly_once() {
let files = vec!["src/app.ts".to_string(), "tests/app.test.ts".to_string()];
let raw = r#"{"summary":"Separate behavior and coverage","groups":[{"message":"feat(app): add behavior","reason":"Production code","files":["src/app.ts"]},{"message":"test(app): cover behavior","reason":"Tests","files":["tests/app.test.ts"]}]}"#;
let plan = parse_ai_commit_plan(raw, &files).expect("complete plan should parse");
assert_eq!(plan.groups.len(), 2);
let duplicate = r#"{"summary":"Bad plan","groups":[{"message":"feat: one","reason":"","files":["src/app.ts"]},{"message":"test: two","reason":"","files":["src/app.ts"]}]}"#;
assert!(parse_ai_commit_plan(duplicate, &files).is_err());
}
}
+24 -14
View File
@@ -6,23 +6,24 @@ mod telemetry;
use badge::set_sync_badge;
use git::{
SearchCancellationState, add_remote, amend_commit, apply_file_patch, cancel_code_search,
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits,
compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete,
cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
cred_delete, cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
get_status, init_repository, last_commit_message, list_branches, list_commits,
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
list_repository_files, list_stashes, list_tags, merge_abort, merge_branch, merge_continue,
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote,
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
restore_files, restore_reflog_entry, restore_to_commit, revert_commit,
run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream, stage_files,
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit,
unstage_files, update_remote,
list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort,
merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository,
open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag,
read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree,
rename_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
set_branch_upstream, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop,
stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote,
};
use tauri::Manager;
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
@@ -135,6 +136,14 @@ async fn main() {
create_branch,
rename_branch,
delete_branch,
list_worktrees,
add_worktree,
remove_worktree,
move_worktree,
lock_worktree,
unlock_worktree,
prune_worktrees,
repair_worktree,
list_tags,
create_tag,
delete_tag,
@@ -160,6 +169,7 @@ async fn main() {
commit_ai_local_models,
commit_ai_generate,
commit_ai_review,
commit_ai_split,
pull,
push,
fetch,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "2026.7.20",
"version": "2026.8.1",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run dev",
+442 -155
View File
File diff suppressed because it is too large Load Diff
+536 -256
View File
@@ -1517,20 +1517,15 @@
.history-aside {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 8px minmax(240px, var(--file-history-width, 300px));
grid-template-columns: minmax(0, 1fr);
min-width: 0;
min-height: 0;
column-gap: 0;
row-gap: 8px;
}
.history-aside.file-history-collapsed {
grid-template-columns: minmax(0, 1fr) 0 42px;
}
.left-sidebar-resize-handle,
.history-resize-handle,
.file-history-resize-handle {
.history-resize-handle {
position: relative;
z-index: 10;
display: flex;
@@ -1541,8 +1536,7 @@
touch-action: none;
}
.left-sidebar-resize-handle::before,
.history-resize-handle::before,
.file-history-resize-handle::before {
.history-resize-handle::before {
content: "";
width: 3px;
height: 40px;
@@ -1553,26 +1547,16 @@
.left-sidebar-resize-handle:hover::before,
.left-sidebar-resize-handle.resizing::before,
.history-resize-handle:hover::before,
.history-resize-handle.resizing::before,
.file-history-resize-handle:hover::before,
.file-history-resize-handle.resizing::before {
.history-resize-handle.resizing::before {
background: var(--color-accent);
box-shadow: 0 0 14px rgba(65,209,255,0.3);
}
.left-sidebar-resize-handle:focus-visible,
.history-resize-handle:focus-visible,
.file-history-resize-handle:focus-visible {
.history-resize-handle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.history-aside.file-history-collapsed .file-history-resize-handle {
pointer-events: none;
}
.history-aside.file-history-collapsed .file-history-resize-handle::before {
display: none;
}
.left-sidebar {
display: grid;
grid-template-rows: minmax(180px, var(--branch-panel-height, 260px)) 14px minmax(150px, var(--stash-panel-height, 190px)) 14px minmax(220px, 1fr);
@@ -2064,6 +2048,20 @@
.tag-group-head { display: flex; align-items: center; gap: 6px; }
.tag-group-head .branch-group-toggle { flex: 1; min-width: 0; }
.tag-group-head .branch-create-toggle { flex-shrink: 0; }
.worktree-group-toggle {
border-color: rgba(77, 182, 214, 0.12);
background: linear-gradient(90deg, rgba(77, 182, 214, 0.055), transparent 72%);
}
.worktree-group-toggle:hover:not(:disabled) {
border-color: rgba(77, 182, 214, 0.25);
background: linear-gradient(90deg, rgba(77, 182, 214, 0.1), rgba(77, 182, 214, 0.025));
color: var(--color-ink);
}
.worktree-group-toggle svg { color: #76cde7; }
.worktree-group-toggle .branch-group-count {
color: #9edced;
background: rgba(77, 182, 214, 0.11);
}
.tag-create-form { grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto auto; }
@@ -2621,225 +2619,6 @@
background: rgba(65,209,255,0.08);
color: var(--color-ink);
}
.file-history-panel.collapsed {
display: grid;
grid-template-rows: 1fr;
overflow: hidden;
}
.file-history-rail-toggle {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
justify-items: center;
gap: 8px;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
padding: 12px 0;
border: 0;
border-radius: 0;
color: var(--color-ink-dim);
background: linear-gradient(180deg, rgba(100,108,255,0.12), rgba(65,209,255,0.04));
}
.file-history-rail-toggle:hover:not(:disabled) {
color: var(--color-ink);
background: linear-gradient(180deg, rgba(100,108,255,0.18), rgba(65,209,255,0.08));
}
.file-history-rail-toggle span {
writing-mode: vertical-rl;
text-orientation: mixed;
overflow: hidden;
color: currentColor;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.file-history-rail-toggle small {
display: inline-grid;
place-items: center;
min-width: 22px;
min-height: 22px;
border-radius: 999px;
color: var(--color-ink);
background: rgba(94,110,156,0.18);
font-size: 11px;
font-weight: 800;
}
.file-history-head { align-items: flex-start; }
.file-history-heading { min-width: 0; flex: 1 1 auto; overflow: hidden; }
.file-history-head-actions {
display: flex;
align-items: center;
flex: 0 0 auto;
gap: 6px;
}
.file-history-toggle {
width: 26px;
min-width: 26px;
min-height: 26px;
padding: 0;
border-color: rgba(65,209,255,0.2);
border-radius: 7px;
color: var(--color-ink-dim);
background: rgba(65,209,255,0.06);
}
.file-history-toggle:hover:not(:disabled) {
border-color: rgba(65,209,255,0.45);
color: #ffffff;
background: rgba(65,209,255,0.13);
}
.section-head .file-history-name {
display: -webkit-box;
max-height: 2.4em;
margin: 2px 0 0;
overflow: hidden;
color: var(--color-ink);
font-size: 14px;
font-weight: 700;
line-height: 1.2;
overflow-wrap: anywhere;
word-break: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.file-history-count { flex: 0 0 auto; margin-top: 2px; }
.file-history-row { grid-template-columns: minmax(0, 1fr); min-width: 0; overflow: hidden; }
.file-history-row .commit-line { overflow: hidden; }
.file-history-row .commit-line strong {
display: -webkit-box;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.file-history-actions { display: grid; grid-template-columns: minmax(0, 1fr); width: 100%; align-items: center; gap: 6px; }
.file-history-actions .commit-action-buttons { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; justify-content: stretch; }
.file-history-actions .commit-action-buttons button { min-width: 0; justify-content: center; padding-inline: 6px; }
/* --- File history loading (scoped, non-blocking) --- */
.file-history-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 156px;
padding: 22px;
overflow: hidden;
background:
radial-gradient(circle at center 42%, rgba(111, 140, 255, 0.1), transparent 52%),
linear-gradient(180deg, rgba(15, 18, 31, 0.55), transparent);
}
.file-history-loading-mark {
position: relative;
display: grid;
place-items: center;
width: 86px;
height: 86px;
isolation: isolate;
}
.file-history-loading-halo {
position: absolute;
inset: 10px;
border: 1px solid rgba(111, 140, 255, 0.22);
border-radius: 18px;
transform: rotate(45deg);
animation: fhl-halo-breathe 2.4s ease-in-out infinite;
}
.file-history-loading-traces {
position: absolute;
inset: -12px;
width: 110px;
height: 110px;
overflow: visible;
}
.file-history-loading-traces .fhl-trace {
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
stroke-dasharray: 92;
stroke-dashoffset: 92;
filter: drop-shadow(0 0 6px rgba(77, 182, 214, 0.3));
animation: fhl-trace-draw 2.4s ease-in-out infinite;
}
.file-history-loading-traces .fhl-trace-main {
stroke: #6f8cff;
}
.file-history-loading-traces .fhl-trace-branch {
stroke: #4db6d6;
animation-delay: 0.28s;
}
.file-history-loading-icon {
position: relative;
z-index: 1;
width: 64px;
height: 64px;
object-fit: contain;
filter:
drop-shadow(0 10px 14px rgba(0, 0, 0, 0.45))
drop-shadow(0 0 12px rgba(77, 182, 214, 0.2));
animation: fhl-icon-float 2.4s ease-in-out infinite;
}
.file-history-loading-label {
color: var(--color-ink-faint);
font-size: 12.5px;
font-weight: 800;
}
.file-history-loading-bar {
position: relative;
width: 118px;
height: 3px;
overflow: hidden;
border-radius: 999px;
background: rgba(111, 140, 255, 0.14);
}
.file-history-loading-bar span {
position: absolute;
inset: 0;
width: 46%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, #6f8cff 42%, #4db6d6 74%, transparent);
box-shadow: 0 0 12px rgba(77, 182, 214, 0.24);
animation: fhl-bar-slide 1.35s ease-in-out infinite;
}
@keyframes fhl-halo-breathe {
0%, 100% { opacity: 0.35; transform: rotate(45deg) scale(0.94); }
50% { opacity: 0.8; transform: rotate(45deg) scale(1.04); }
}
@keyframes fhl-icon-float {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-3px) scale(1.02); }
}
@keyframes fhl-trace-draw {
0% { stroke-dashoffset: 92; opacity: 0; }
36% { opacity: 1; }
64%, 100% { stroke-dashoffset: 0; opacity: 0.72; }
}
@keyframes fhl-bar-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
@media (prefers-reduced-motion: reduce) {
.file-history-loading-halo,
.file-history-loading-traces .fhl-trace,
.file-history-loading-icon,
.file-history-loading-bar span { animation: none; }
.file-history-loading-traces .fhl-trace { stroke-dashoffset: 0; opacity: 0.72; }
}
/* --- Git graph --- */
.graph-list {
@@ -2847,6 +2626,31 @@
background: var(--color-surface-dim);
}
.history-load-more {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 54px;
padding: 9px 12px;
border-top: 1px solid var(--color-border-subtle);
color: var(--color-ink-faint);
background: var(--color-surface-dim);
font-size: 11.5px;
}
.history-load-more-button {
min-height: 30px;
border-color: transparent;
color: var(--color-primary);
background: transparent;
}
.history-load-more-button:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--color-primary) 22%, transparent);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.graph-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@@ -3077,6 +2881,101 @@
box-shadow: var(--app-dialog-shadow);
overflow: hidden;
}
.file-history-dialog-backdrop { z-index: 70; }
.compare-dialog-backdrop { z-index: 80; }
.file-history-dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: min(780px, calc(100vw - 32px));
height: min(720px, calc(100vh - 32px));
border: 1px solid var(--color-border);
border-radius: 12px;
outline: 0;
background: var(--app-dialog-bg);
box-shadow: var(--app-dialog-shadow);
overflow: hidden;
}
.file-history-dialog-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 11px;
min-height: 72px;
padding: 12px 14px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-dim);
}
.file-history-dialog-icon {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border: 1px solid color-mix(in srgb, var(--color-primary) 24%, var(--color-border));
border-radius: 9px;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 9%, var(--color-surface-solid));
}
.file-history-dialog-heading { display: grid; min-width: 0; gap: 2px; }
.file-history-dialog-heading h2 {
margin: 0;
overflow: hidden;
color: var(--color-ink);
font-size: 15px;
font-weight: 750;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-history-dialog-path {
overflow: hidden;
color: var(--color-ink-faint);
font: 10.5px/1.35 var(--font-mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.file-history-dialog-body { min-height: 0; overflow: hidden; background: var(--color-surface-solid); }
.file-history-dialog-list { height: 100%; padding: 6px; overflow: auto; }
.file-history-dialog-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 12px;
min-height: 66px;
padding: 9px 10px;
border-bottom: 1px solid var(--color-border-subtle);
}
.file-history-dialog-row:last-child { border-bottom: 0; }
.file-history-dialog-row:hover { background: var(--color-surface-hover); }
.file-history-dialog-commit { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 9px; min-width: 0; }
.file-history-dialog-commit > div { display: grid; min-width: 0; gap: 4px; }
.file-history-dialog-commit strong {
overflow: hidden;
color: var(--color-ink);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-history-dialog-commit span { overflow: hidden; color: var(--color-ink-faint); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
.file-history-dialog-commit code { color: var(--color-primary); font-family: var(--font-mono); }
.file-history-dialog-row time { color: var(--color-ink-faint); font-size: 10.5px; white-space: nowrap; }
.file-history-dialog-actions { display: flex; align-items: center; gap: 5px; }
.file-history-dialog-actions button { min-height: 28px; white-space: nowrap; }
.file-history-dialog-state {
display: grid;
place-items: center;
align-content: center;
gap: 7px;
height: 100%;
min-height: 220px;
padding: 28px;
color: var(--color-ink-faint);
text-align: center;
}
.file-history-dialog-state svg { color: var(--color-primary); }
.file-history-dialog-state strong { color: var(--color-ink); font-size: 13px; }
.file-history-dialog-state span { max-width: 520px; font-size: 11.5px; line-height: 1.5; overflow-wrap: anywhere; }
.file-history-dialog-state.error svg,
.file-history-dialog-state.error strong { color: var(--color-danger); }
.compare-dialog {
width: min(1560px, calc(100vw - 32px));
height: min(940px, calc(100vh - 32px));
@@ -3261,6 +3160,19 @@
box-shadow: var(--app-dialog-shadow), 0 0 0 1px rgba(255, 90, 103, 0.04);
overflow: auto;
}
.worktree-dialog {
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
width: min(920px, calc(100vw - 32px));
height: min(820px, calc(100vh - 32px));
border-color: rgba(77, 182, 214, 0.24);
}
.worktree-confirm-dialog {
grid-template-rows: auto auto auto;
width: min(520px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
}
.worktree-nested-backdrop { z-index: 70; }
.ai-settings-dialog {
display: block;
width: min(560px, calc(100vw - 32px));
@@ -3884,6 +3796,244 @@
font-weight: 600;
}
.branch-delete-confirm { min-width: 116px; }
.worktree-dialog-header {
background:
linear-gradient(90deg, rgba(77, 182, 214, 0.09), transparent 40%),
var(--app-dialog-chrome);
}
.worktree-dialog-heading { display: flex; align-items: center; gap: 11px; }
.worktree-dialog-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid rgba(77, 182, 214, 0.3);
border-radius: 9px;
color: #8ed8ee;
background: rgba(77, 182, 214, 0.1);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
}
.worktree-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
align-items: stretch;
gap: 1px;
padding: 1px 0;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-border-subtle);
}
.worktree-summary > div {
display: flex;
align-items: baseline;
gap: 7px;
min-width: 0;
padding: 11px 14px;
background: var(--app-dialog-bg);
}
.worktree-summary > div strong { color: var(--color-ink); font-family: var(--font-mono); font-size: 14px; }
.worktree-summary > div span { overflow: hidden; color: var(--color-ink-faint); font-size: 10.5px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.worktree-summary > div.attention strong { color: #ffc07a; }
.worktree-summary > .btn-primary { align-self: center; margin: 0 14px; white-space: nowrap; }
.worktree-error {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
border-bottom: 1px solid rgba(255, 90, 103, 0.2);
color: #ffb8bf;
background: rgba(255, 90, 103, 0.08);
font-size: 11.5px;
font-weight: 650;
}
.worktree-content {
min-height: 0;
overflow: auto;
padding: 14px;
background:
radial-gradient(circle at 92% 0%, rgba(77, 182, 214, 0.055), transparent 28%),
var(--app-dialog-bg);
}
.worktree-create-card {
display: grid;
gap: 14px;
margin-bottom: 14px;
padding: 14px;
border: 1px solid rgba(77, 182, 214, 0.26);
border-radius: 10px;
background: var(--color-surface-raised);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.035);
}
.worktree-create-card > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.worktree-create-card h3 { margin: 3px 0 0; color: var(--color-ink); font-size: 13px; font-weight: 700; }
.worktree-mode-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3px;
padding: 3px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: rgba(0, 0, 0, 0.14);
}
.worktree-mode-tabs button {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 32px;
border-color: transparent;
color: var(--color-ink-faint);
background: transparent;
font-size: 10.5px;
font-weight: 750;
}
.worktree-mode-tabs button.active {
border-color: rgba(77, 182, 214, 0.27);
color: #b8e7f6;
background: rgba(77, 182, 214, 0.11);
box-shadow: 0 3px 12px rgba(0,0,0,0.14);
}
.worktree-create-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.worktree-create-fields label,
.worktree-lock-body label { display: grid; gap: 5px; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 750; }
.worktree-create-fields input,
.worktree-create-fields select,
.worktree-lock-body input { width: 100%; min-width: 0; }
.worktree-create-fields .worktree-path-field { grid-column: 1 / -1; }
.worktree-path-field > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }
.worktree-create-card > footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
.worktree-check { display: flex; align-items: flex-start; gap: 8px; color: var(--color-ink-muted); font-size: 11px; cursor: pointer; }
.worktree-check input { width: auto; margin-top: 2px; }
.worktree-check span { display: grid; gap: 1px; }
.worktree-check strong { color: var(--color-ink); font-size: 11px; }
.worktree-check small { color: var(--color-ink-faint); font-size: 10px; font-weight: 500; }
.worktree-check.danger strong,
.worktree-check.danger small { color: #ffb8bf; }
.worktree-list { display: grid; gap: 8px; }
.worktree-card {
position: relative;
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
min-width: 0;
border: 1px solid var(--color-border-subtle);
border-radius: 9px;
background: var(--color-surface-raised);
overflow: hidden;
}
.worktree-card.current { border-color: rgba(77, 182, 214, 0.34); box-shadow: inset 0 0 0 1px rgba(77, 182, 214, 0.06); }
.worktree-card.stale { border-color: rgba(255, 151, 61, 0.28); }
.worktree-rail {
position: relative;
display: grid;
place-items: start center;
padding-top: 18px;
background: rgba(0,0,0,0.1);
}
.worktree-rail::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
background: rgba(77, 182, 214, 0.22);
}
.worktree-rail span {
position: relative;
z-index: 1;
width: 9px;
height: 9px;
border: 2px solid #76cde7;
border-radius: 50%;
background: var(--color-surface-solid);
box-shadow: 0 0 0 3px rgba(77, 182, 214, 0.08);
}
.worktree-rail i {
position: absolute;
top: 31px;
left: 50%;
width: 7px;
height: 14px;
border-bottom: 1px solid rgba(77, 182, 214, 0.26);
border-left: 1px solid rgba(77, 182, 214, 0.26);
}
.worktree-card-main { display: grid; gap: 9px; min-width: 0; padding: 12px; }
.worktree-card-main > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.worktree-name { display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--color-accent); }
.worktree-name > div { display: grid; gap: 1px; min-width: 0; }
.worktree-name strong { overflow: hidden; color: var(--color-ink); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; }
.worktree-name span { color: var(--color-ink-faint); font-size: 10px; }
.worktree-badges { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 4px; }
.worktree-badges span {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 6px;
border: 1px solid var(--color-border-subtle);
border-radius: 999px;
color: var(--color-ink-faint);
background: rgba(255,255,255,0.025);
font-size: 8.5px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.worktree-badges span.active { border-color: rgba(78, 202, 118, 0.25); color: #77d99a; background: rgba(78, 202, 118, 0.08); }
.worktree-badges span.locked { border-color: rgba(111, 140, 255, 0.26); color: #aebcff; background: rgba(111, 140, 255, 0.08); }
.worktree-badges span.danger { border-color: rgba(255, 151, 61, 0.3); color: #ffc07a; background: rgba(255, 151, 61, 0.08); }
.worktree-path { display: flex; align-items: center; gap: 5px; min-width: 0; color: var(--color-ink-faint); }
.worktree-path code { overflow: hidden; font-family: var(--font-mono); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
.worktree-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; color: var(--color-ink-faint); font-size: 9.5px; }
.worktree-meta span { display: inline-flex; align-items: center; gap: 4px; }
.worktree-meta span.dirty,
.worktree-meta span.danger { color: #ffc07a; }
.worktree-card-main > footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 2px; }
.worktree-card-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 5px; }
.worktree-card-actions .danger { border-color: rgba(255, 90, 103, 0.2); color: #ff9aa4; background: rgba(255, 90, 103, 0.06); }
.worktree-dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-top: 1px solid var(--color-border-subtle);
background: var(--app-dialog-chrome);
}
.worktree-dialog-footer > div { display: flex; align-items: center; gap: 6px; color: var(--color-ink-faint); font-size: 10px; }
.worktree-loading,
.worktree-empty {
display: grid;
place-items: center;
align-content: center;
gap: 7px;
min-height: 240px;
color: var(--color-ink-faint);
text-align: center;
font-size: 11px;
}
.worktree-empty strong { color: var(--color-ink); font-size: 13px; }
.worktree-confirm-body {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 13px;
padding: 17px 16px;
}
.worktree-confirm-body > div { display: grid; gap: 10px; min-width: 0; }
.worktree-confirm-body p { margin: 0; color: var(--color-ink-muted); font-size: 12px; line-height: 1.45; }
.worktree-confirm-body code { overflow: auto; padding: 8px; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-ink); background: rgba(0,0,0,0.16); font-size: 10.5px; }
.worktree-lock-body { padding: 18px 16px; }
.worktree-lock-body label > span { display: flex; align-items: baseline; justify-content: space-between; }
.worktree-lock-body small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; }
@media (max-width: 700px) {
.worktree-summary { grid-template-columns: repeat(3, 1fr); }
.worktree-summary > div { display: grid; gap: 2px; }
.worktree-summary > .btn-primary { grid-column: 1 / -1; margin: 9px 14px; }
.worktree-create-fields { grid-template-columns: 1fr; }
.worktree-create-fields .worktree-path-field { grid-column: auto; }
.worktree-create-card > footer,
.worktree-card-main > footer { align-items: stretch; flex-direction: column; }
.worktree-card-actions { justify-content: flex-start; }
.worktree-dialog-footer > div { display: none; }
}
.discard-target-list {
display: grid;
gap: 4px;
@@ -3916,11 +4066,37 @@
.line-patch-body {
display: grid;
grid-template-rows: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.line-patch-selection-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-height: 46px;
padding: 7px 12px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--app-dialog-chrome);
}
.line-patch-selection-bar.active {
background:
linear-gradient(90deg, rgba(77, 182, 214, 0.1), transparent 44%),
var(--app-dialog-chrome);
}
.line-patch-selection-bar > div:first-child {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
color: var(--color-accent);
}
.line-patch-selection-bar strong { color: var(--color-ink); font-size: 11.5px; }
.line-patch-selection-bar span { color: var(--color-ink-faint); font-size: 10px; }
.line-patch-selected-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
.line-patch-scroll {
min-height: 0;
overflow: auto;
@@ -3946,6 +4122,33 @@
border-bottom: 1px solid var(--color-border-subtle);
background: color-mix(in srgb, var(--code-surface-raised) 96%, transparent);
}
.line-patch-select-hunk,
.line-patch-line-select {
display: grid;
place-items: center;
width: 16px;
min-width: 16px;
height: 16px;
min-height: 16px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 4px;
color: #ffffff;
background: var(--code-surface);
box-shadow: none;
}
.line-patch-select-hunk:hover:not(:disabled),
.line-patch-line-select:hover:not(:disabled) {
border-color: rgba(77, 182, 214, 0.65);
background: rgba(77, 182, 214, 0.1);
}
.line-patch-select-hunk.all,
.line-patch-select-hunk.some,
.line-patch-line-select[aria-pressed="true"] {
border-color: rgba(77, 182, 214, 0.78);
background: #238eb4;
}
.line-patch-select-hunk.some { background: rgba(35, 142, 180, 0.55); }
.line-patch-hunk-head code {
color: var(--color-accent);
font-family: var(--font-mono);
@@ -3991,12 +4194,17 @@
.line-patch-row {
display: grid;
grid-template-columns: 22px minmax(max-content, 1fr);
grid-template-columns: 18px 38px 38px 22px minmax(max-content, 1fr);
align-items: start;
min-height: 22px;
padding: 1px 10px 1px 28px;
padding: 1px 10px 1px 8px;
color: var(--color-ink-muted);
}
.line-patch-row.selectable { cursor: default; }
.line-patch-row.selected {
box-shadow: inset 3px 0 0 rgba(77, 182, 214, 0.86);
filter: saturate(1.12) brightness(1.06);
}
.line-patch-row.add {
background: var(--code-add-bg);
color: var(--code-add-text);
@@ -4013,12 +4221,26 @@
text-align: center;
user-select: none;
}
.line-patch-line-select { align-self: center; }
.line-patch-line-select-placeholder { width: 16px; }
.line-patch-line-number {
padding-right: 7px;
color: var(--color-ink-faint);
font-size: 10px;
line-height: 20px;
text-align: right;
user-select: none;
}
.line-patch-row.add .line-patch-prefix { color: var(--code-add-strong); }
.line-patch-row.delete .line-patch-prefix { color: var(--code-delete-strong); }
.line-patch-row code {
white-space: pre;
font-family: var(--font-mono);
}
@media (max-width: 760px) {
.line-patch-selection-bar { align-items: stretch; flex-direction: column; }
.line-patch-selected-actions { justify-content: flex-start; flex-wrap: wrap; }
}
.blame-body {
min-height: 0;
@@ -5335,12 +5557,10 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.left-sidebar-resize-handle,
.history-resize-handle,
.file-history-resize-handle,
.panel-resize-handle,
.left-panel-resize-handle { background: var(--color-border-subtle); }
.left-sidebar-resize-handle::before,
.history-resize-handle::before,
.file-history-resize-handle::before { width: 1px; height: 48px; background: var(--color-border); }
.history-resize-handle::before { width: 1px; height: 48px; background: var(--color-border); }
.panel-resize-handle::before { width: 44px; height: 2px; }
.status-toolbar {
@@ -5406,7 +5626,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.explorer-row .status-badge { min-width: 18px; overflow: hidden; color: #d9891b; background: transparent; text-indent: -999px; }
.explorer-row .status-badge::after { content: "M"; float: right; text-indent: 0; }
.history-panel, .file-history-panel { min-height: 0; }
.history-panel { min-height: 0; }
.graph-list { background: var(--color-surface-solid); }
.graph-gutter { background: var(--color-surface-dim); }
.commit-body {
@@ -5416,10 +5636,12 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
}
.graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); }
.graph-row:hover .commit-body { background: var(--color-surface-hover); }
.graph-row {
content-visibility: auto;
contain-intrinsic-block-size: 108px;
}
.commit-avatar { border-radius: 50%; }
.commit-kind, .commit-branch-chip, .ref-chip { border-radius: 4px !important; }
.file-history-row { margin: 0; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; background: transparent; }
.workspace-statusbar {
display: flex;
align-items: center;
@@ -5471,6 +5693,10 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
@media (hover: none), (pointer: coarse) {
.status-file-actions { opacity: 1; }
}
:root[data-theme="light"] .repo-tab-wrap {
background: rgba(255,255,255,0.5);
}
@@ -5769,12 +5995,53 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
background: #eef3f9;
}
:root[data-theme="light"] .cred-card {
border-color: rgba(49, 95, 214, 0.22);
background: #ffffff;
box-shadow: 0 28px 72px rgba(28, 44, 74, 0.2), 0 0 0 1px rgba(255,255,255,0.82) inset;
}
:root[data-theme="light"] .cred-hero {
border-bottom-color: rgba(34, 49, 78, 0.1);
background:
radial-gradient(circle at 8% 0%, rgba(49,95,214,0.13), transparent 42%),
linear-gradient(135deg, #f8faff, #eef4fc);
}
:root[data-theme="light"] .cred-hero-icon {
border-color: rgba(49,95,214,0.2);
color: #ffffff;
background: linear-gradient(135deg, #4d8dff, #0f8fb5);
box-shadow: 0 12px 26px rgba(49,95,214,0.2), inset 0 1px 0 rgba(255,255,255,0.24);
}
:root[data-theme="light"] .cred-hero-label { color: #315fd6; }
:root[data-theme="light"] .cred-hero-title { color: #172033; }
:root[data-theme="light"] .cred-hero-copy { color: #526078; }
:root[data-theme="light"] .cred-security-note {
border-color: rgba(15,143,181,0.2);
color: #21647a;
background: rgba(15,143,181,0.07);
}
:root[data-theme="light"] .cred-security-note svg { color: #0f8fb5; }
:root[data-theme="light"] .cred-body {
background: #ffffff;
}
:root[data-theme="light"] .cred-seg-btn.active {
border-color: rgba(49,95,214,0.26);
background: linear-gradient(135deg, rgba(49,95,214,0.12), rgba(15,143,181,0.08));
box-shadow: 0 8px 18px rgba(28,44,74,0.1), inset 0 1px 0 rgba(255,255,255,0.9);
}
:root[data-theme="light"] .cred-seg-btn:hover:not(.active) {
color: var(--color-ink);
background: rgba(255,255,255,0.68);
}
:root[data-theme="light"] .cred-close {
border-color: var(--color-border-subtle);
color: var(--color-ink-dim);
@@ -5810,10 +6077,18 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
:root[data-theme="light"] .cred-input input,
:root[data-theme="light"] .cred-expiry input[type="date"] {
border-color: var(--color-border-input);
background: rgba(255,255,255,0.92);
color-scheme: light;
}
:root[data-theme="light"] .cred-input input::placeholder { color: #8995a8; }
:root[data-theme="light"] .cred-reveal:hover {
color: var(--color-ink);
background: var(--color-surface-hover);
}
:root[data-theme="light"] .cred-token-hint code {
color: #0d78a0;
}
@@ -5872,13 +6147,11 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); }
}
/* Compact: stack history panels vertically, narrow sidebars */
/* Compact workspace with narrow sidebars */
@media (max-width: 960px) {
.workspace { grid-template-columns: 180px 8px minmax(0, 1fr) 0 300px; row-gap: 6px; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1.3fr) minmax(0, 0.7fr); row-gap: 6px; }
.history-aside.file-history-collapsed { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) 42px; }
.history-resize-handle,
.file-history-resize-handle { display: none; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr); row-gap: 0; }
.history-resize-handle { display: none; }
.shell-body { gap: 6px; }
.left-sidebar {
grid-template-rows: minmax(180px, var(--branch-panel-height, 230px)) 14px minmax(150px, var(--stash-panel-height, 170px)) 14px minmax(220px, 1fr);
@@ -5925,8 +6198,15 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.tb-btn { width: 34px; }
.workspace { grid-template-columns: 1fr; gap: 6px; }
.left-sidebar-resize-handle { display: none; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(200px, 1fr) minmax(150px, 0.5fr); min-height: 380px; }
.history-aside.file-history-collapsed { grid-template-rows: minmax(200px, 1fr) 42px; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(200px, 1fr); min-height: 240px; }
.file-history-dialog-backdrop { padding: 8px; }
.file-history-dialog { width: 100%; height: 100%; }
.file-history-dialog-header { min-height: 64px; padding: 9px 10px; }
.file-history-dialog-icon { width: 34px; height: 34px; }
.file-history-dialog-row { grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }
.file-history-dialog-row time { display: none; }
.file-history-dialog-actions { display: grid; }
.file-history-dialog-actions button { width: 32px; min-width: 32px; padding: 0; overflow: hidden; color: var(--color-ink-muted); font-size: 0; gap: 0; }
.left-sidebar {
grid-template-rows: minmax(180px, var(--branch-panel-height, 240px)) 14px minmax(150px, var(--stash-panel-height, 180px)) 14px minmax(220px, 1fr);
min-height: 560px;
@@ -0,0 +1,103 @@
<script lang="ts">
import { GitCommitHorizontal, LoaderCircle, Sparkles, X } from "@lucide/svelte";
import type { AiCommitPlan } from "../types";
interface Props {
plan: AiCommitPlan;
isApplying: boolean;
onApply: (plan: AiCommitPlan) => void;
onClose: () => void;
}
let { plan, isApplying = false, onApply, onClose }: Props = $props();
let draft = $state<AiCommitPlan>({ summary: "", groups: [] });
$effect.pre(() => {
if (draft.groups.length === 0) draft = structuredClone(plan);
});
let valid = $derived(draft.groups.length > 1 && draft.groups.every((group) => group.message.trim() && group.files.length));
let validationIssue = $derived(
draft.groups.findIndex((group) => group.files.length === 0) >= 0
? "Every commit needs at least one file."
: draft.groups.findIndex((group) => !group.message.trim()) >= 0
? "Every commit needs a message."
: "",
);
function setMessage(index: number, message: string) {
draft.groups[index].message = message;
}
function moveFile(file: string, from: number, to: number) {
if (from === to) return;
draft.groups[from].files = draft.groups[from].files.filter((path) => path !== file);
draft.groups[to].files = [...draft.groups[to].files, file];
}
function applyDraft() {
// `draft` is a deeply reactive Svelte proxy. `structuredClone(draft)`
// throws a DataCloneError before the callback runs, which made the button
// appear to do nothing. A state snapshot is a plain, cloneable object.
onApply($state.snapshot(draft));
}
</script>
<svelte:window onkeydown={(event) => { if (event.key === "Escape" && !isApplying) onClose(); }} />
<div class="dialog-backdrop" role="presentation">
<div class="dialog split-dialog" role="dialog" aria-modal="true" aria-label="AI commit split">
<header class="dialog-header">
<div><span class="eyebrow">Staged changes</span><h2>Split into logical commits</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isApplying} aria-label="Close"><X size={18} /></button>
</header>
<div class="split-intro"><Sparkles size={18} /><p>{draft.summary}</p></div>
<div class="split-groups">
{#each draft.groups as group, groupIndex}
<article class:empty={group.files.length === 0}>
<header><GitCommitHorizontal size={17} /><strong>Commit {groupIndex + 1}</strong><span>{group.files.length} files</span></header>
<label>
<span>Commit message <em>AI-generated · editable</em></span>
<input value={group.message} oninput={(event) => setMessage(groupIndex, event.currentTarget.value)} disabled={isApplying} />
</label>
{#if group.reason}<p>{group.reason}</p>{/if}
<div class="split-files">
{#each group.files as file}
<div><code>{file}</code>
<select value={groupIndex} onchange={(event) => moveFile(file, groupIndex, Number(event.currentTarget.value))} disabled={isApplying}>
{#each draft.groups as _, target}<option value={target}>Commit {target + 1}</option>{/each}
</select>
</div>
{/each}
</div>
</article>
{/each}
</div>
<footer class="dialog-footer">
<p class:invalid={!!validationIssue}>{validationIssue || "Messages are generated automatically. All commits are created in this order."}</p>
<div><button class="btn-secondary" type="button" onclick={onClose} disabled={isApplying}>Cancel</button>
<button class="btn-primary" type="button" onclick={applyDraft} disabled={!valid || isApplying}>
{#if isApplying}<LoaderCircle class="spin" size={14} />{/if}Commit all ({draft.groups.length})
</button></div>
</footer>
</div>
</div>
<style>
.split-dialog{width:min(820px,calc(100vw - 32px));max-height:min(820px,calc(100vh - 32px));display:flex;flex-direction:column}
.split-intro{display:flex;gap:10px;align-items:flex-start;padding:14px 18px;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
.split-intro p{margin:0;line-height:1.5}
.split-groups{display:grid;gap:10px;padding:14px 18px;overflow:auto}
article{display:grid;gap:10px;padding:13px;border:1px solid var(--color-border-subtle);border-radius:9px;background:var(--color-surface-raised)}
article.empty{border-color:#d88a45}
article>header{display:flex;align-items:center;gap:8px;color:var(--color-ink)}
article>header span{margin-left:auto;color:var(--color-ink-faint);font-size:11px}
label{display:grid;gap:5px;color:var(--color-ink-faint);font-size:10px;font-weight:800;text-transform:uppercase}
label span{display:flex;align-items:center;justify-content:space-between;gap:8px}
label em{color:var(--color-accent);font-size:9px;font-style:normal;font-weight:700;text-transform:none}
input{height:34px;padding:0 10px;border:1px solid var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-ink);font-family:var(--font-mono)}
article>p{margin:0;color:var(--color-ink-muted);font-size:12px;line-height:1.45}
.split-files{display:grid;gap:5px}
.split-files>div{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;background:var(--color-surface)}
code{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;color:var(--color-ink-muted);font-size:11px;white-space:nowrap}
select{height:28px;border:1px solid var(--color-border-subtle);border-radius:5px;background:var(--color-surface-raised);color:var(--color-ink);font-size:11px}
.dialog-footer p.invalid{color:#e0a040}
</style>
+6 -1
View File
@@ -2,6 +2,11 @@
import { FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
import type { GitBlameLine } from "../types";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
interface BlameGroup {
id: string;
hash: string;
@@ -66,7 +71,7 @@
if (!seconds) return "";
const date = new Date(seconds * 1000);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
return commitDateFormatter.format(date);
}
function groupTooltip(group: BlameGroup): string {
+31 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -58,6 +58,8 @@
onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>;
onManageWorktrees: () => void;
onCreateWorktree: (branch: GitBranchInfo) => void | Promise<void>;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
@@ -79,6 +81,8 @@
onCreateTag = () => {},
onDeleteTag = () => {},
onPushTag = () => {},
onManageWorktrees = () => {},
onCreateWorktree = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
@@ -271,6 +275,13 @@
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
}
async function createContextWorktree() {
const branch = contextBranch;
closeBranchContextMenu();
if (!branch) return;
await onCreateWorktree(branch);
}
async function checkoutContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
@@ -400,8 +411,6 @@
<!-- collapsed -->
{:else if !hasRepository}
<p class="blank-state">Open a repository to list branches.</p>
{:else if branches.length === 0 && tags.length === 0}
<p class="blank-state">No branches returned.</p>
{:else}
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
{#if createOpen}
@@ -638,6 +647,21 @@
{/if}
{/if}
</div>
<div class="branch-group">
<button
class="branch-group-toggle worktree-group-toggle"
type="button"
onclick={onManageWorktrees}
disabled={isBusy}
aria-haspopup="dialog"
title="Manage repository worktrees"
>
<HardDrive size={14} aria-hidden="true" />
<span>Worktrees</span>
<span class="branch-group-count">Manage</span>
</button>
</div>
</div>
{/if}
@@ -661,6 +685,10 @@
<GitBranch size={14} aria-hidden="true" />
Rebase current onto this
</button>
<button type="button" role="menuitem" onclick={createContextWorktree} disabled={isBusy || contextBranch.remote || contextBranch.current}>
<HardDrive size={14} aria-hidden="true" />
Open in new worktree
</button>
<div class="menu-separator" role="separator"></div>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
<Pencil size={14} aria-hidden="true" />
+16 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
import { Check, GitCommitHorizontal, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props {
@@ -14,12 +14,14 @@
commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean;
commitAiReviewing: boolean;
commitAiSplitting: boolean;
canAmend: boolean;
amendMode: boolean;
onCommit: () => void;
onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void;
onReviewStaged: () => void;
onSplitStaged: () => void;
onOpenAiSettings: () => void;
onToggleAmend: (checked: boolean) => void;
onUndoLastCommit: () => void;
@@ -37,12 +39,14 @@
commitAiPhase = "idle",
commitAiGenerating = false,
commitAiReviewing = false,
commitAiSplitting = false,
canAmend = false,
amendMode = false,
onCommit = () => {},
onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {},
onReviewStaged = () => {},
onSplitStaged = () => {},
onOpenAiSettings = () => {},
onToggleAmend = () => {},
onUndoLastCommit = () => {},
@@ -70,6 +74,7 @@
(commitAiProvider !== "local" || commitAiPhase === "ready"),
);
let canReview = $derived(canGenerate && commitAiProvider !== "local");
let canSplit = $derived(canReview && stagedCount > 1 && !commitAiSplitting);
</script>
<section class="panel commit-panel" aria-label="Commit">
@@ -80,6 +85,16 @@
</div>
<div class="commit-head-actions">
<span class="pill pill-count">{stagedCount} staged</span>
<button
class="commit-review-button"
type="button"
onclick={onSplitStaged}
disabled={!canSplit}
title={commitAiProvider === "local" ? "Commit splitting currently requires an API provider" : "Suggest logical commits for the staged files"}
>
{#if commitAiSplitting}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<GitCommitHorizontal size={14} aria-hidden="true" />{/if}
Split
</button>
<button
class="commit-review-button"
type="button"
+1 -1
View File
@@ -216,7 +216,7 @@
</script>
<div
class="dialog-backdrop"
class="dialog-backdrop compare-dialog-backdrop"
role="presentation"
>
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
@@ -5,7 +5,7 @@
interface Props {
files: GitFileStatus[];
staged: boolean | null;
scope: "file" | "hunk";
scope: "file" | "hunk" | "lines";
isBusy: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
@@ -26,9 +26,9 @@
let count = $derived(files.length);
let title = $derived(
scope === "hunk" ? "Discard hunk?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
scope === "hunk" ? "Discard hunk?" : scope === "lines" ? "Discard selected lines?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
);
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : count > 1 ? `${count} files` : "file");
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : scope === "lines" ? "selected lines" : count > 1 ? `${count} files` : "file");
let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
</script>
+25 -4
View File
@@ -11,6 +11,7 @@
FileCog,
FileImage,
FileJson,
FileSearch,
FileSpreadsheet,
FileText,
FileType,
@@ -37,6 +38,7 @@
onCollapseAllFolders: () => void;
onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void;
onFileHistory: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
@@ -54,6 +56,7 @@
onCollapseAllFolders = () => {},
onSelectNode = () => {},
onOpenFile = () => {},
onFileHistory = () => {},
onBlame = () => {},
collapsed = false,
onToggleCollapsed = () => {},
@@ -175,7 +178,7 @@
contextNode = node;
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 56));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 132));
}
function closeFileContextMenu() {
@@ -196,6 +199,13 @@
onBlame(node);
}
function openContextFileHistory() {
const node = contextNode;
if (!node || node.kind !== "file" || !node.tracked) return;
closeFileContextMenu();
onFileHistory(node);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeFileContextMenu();
}
@@ -261,12 +271,14 @@
{:else}
<div class="explorer-list overflow-auto p-2">
{#each visibleNodes as node (`${node.kind}:${node.path}`)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="explorer-row"
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
class:folder={node.kind === "folder"}
style={`--depth: ${node.depth}`}
title={node.path}
oncontextmenu={(event) => openFileContextMenu(event, node)}
>
{#if node.kind === "folder"}
<button
@@ -328,9 +340,8 @@
class="explorer-select"
type="button"
onclick={() => onSelectNode(node)}
oncontextmenu={(event) => openFileContextMenu(event, node)}
disabled={isBusy}
title={`Show history for ${node.path}`}
title={`Select ${node.path}`}
>
<span>{node.name}</span>
</button>
@@ -355,6 +366,16 @@
tabindex="-1"
aria-label={`Actions for ${contextNode.path}`}
>
<button
type="button"
role="menuitem"
onclick={openContextFileHistory}
disabled={!contextNode.tracked}
title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}
>
<History size={14} aria-hidden="true" />
File history
</button>
<button
type="button"
role="menuitem"
@@ -372,7 +393,7 @@
disabled={!contextNode.tracked || contextNode.status === "deleted"}
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
>
<History size={14} aria-hidden="true" />
<FileSearch size={14} aria-hidden="true" />
Blame
</button>
</div>
+106
View File
@@ -0,0 +1,106 @@
<script lang="ts">
import { AlertCircle, GitCompare, History, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
import type { GitCommit } from "../types";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
interface Props {
fileHistory: GitCommit[];
filePath: string;
isBusy: boolean;
isLoading: boolean;
error?: string;
onDiff: (commit: GitCommit) => void;
onRestore: (commit: GitCommit) => void;
onClose: () => void;
}
let {
fileHistory = [],
filePath = "",
isBusy = false,
isLoading = false,
error = "",
onDiff = () => {},
onRestore = () => {},
onClose = () => {},
}: Props = $props();
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return commitDateFormatter.format(date);
}
function fileName(path: string): string {
return path.split(/[\\/]/).pop() ?? path;
}
function closeFromBackdrop(event: MouseEvent) {
if (event.target === event.currentTarget && !isBusy) onClose();
}
</script>
<div class="dialog-backdrop app-chrome-backdrop file-history-dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
<div class="file-history-dialog" role="dialog" aria-modal="true" aria-labelledby="file-history-dialog-title" tabindex="-1">
<header class="file-history-dialog-header">
<div class="file-history-dialog-icon" aria-hidden="true"><History size={19} /></div>
<div class="file-history-dialog-heading">
<span class="eyebrow">File history</span>
<h2 id="file-history-dialog-title">{fileName(filePath)}</h2>
<span class="file-history-dialog-path" title={filePath}>{filePath}</span>
</div>
<button class="dialog-icon-button" type="button" onclick={onClose} disabled={isBusy} aria-label="Close file history">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="file-history-dialog-body">
{#if isLoading}
<div class="file-history-dialog-state" role="status" aria-live="polite">
<LoaderCircle class="spin" size={22} aria-hidden="true" />
<strong>Loading file history…</strong>
<span>Reading commits that changed this file.</span>
</div>
{:else if error}
<div class="file-history-dialog-state error" role="alert">
<AlertCircle size={22} aria-hidden="true" />
<strong>File history could not be loaded.</strong>
<span>{error}</span>
</div>
{:else if fileHistory.length === 0}
<div class="file-history-dialog-state">
<History size={22} aria-hidden="true" />
<strong>No history found.</strong>
<span>This file has no committed changes yet.</span>
</div>
{:else}
<div class="file-history-dialog-list">
{#each fileHistory as item (item.hash)}
<article class="file-history-dialog-row">
<div class="file-history-dialog-commit">
<span class="commit-avatar" aria-hidden="true">{item.author_name.trim().slice(0, 2).toUpperCase() || "?"}</span>
<div>
<strong title={item.summary}>{item.summary}</strong>
<span><code>{item.short_hash}</code> · {item.author_name}</span>
</div>
</div>
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="file-history-dialog-actions">
<button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes against the working tree">
<GitCompare size={14} aria-hidden="true" /> Diff
</button>
<button class="btn-sm" type="button" onclick={() => onRestore(item)} disabled={isBusy} title="Restore this file from the selected commit">
<RotateCcw size={14} aria-hidden="true" /> Restore
</button>
</div>
</article>
{/each}
</div>
{/if}
</div>
</div>
</div>
-172
View File
@@ -1,172 +0,0 @@
<script lang="ts">
import { ChevronLeft, ChevronRight, GitCompare, History, RotateCcw } from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png";
import type { GitCommit } from "../types";
interface Props {
fileHistory: GitCommit[];
selectedExplorerPath: string;
selectedExplorerLabel: string;
hasRepository: boolean;
isBusy: boolean;
isLoading?: boolean;
onDiff: (commit: GitCommit) => void;
onRestore: (commit: GitCommit) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
let {
fileHistory = [],
selectedExplorerPath = "",
selectedExplorerLabel = "File history",
hasRepository = false,
isBusy = false,
isLoading = false,
onDiff = () => {},
onRestore = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function fileName(path: string): string {
return path.split("/").pop() ?? path;
}
function pathTooltip(node: HTMLElement, text: string) {
let el: HTMLDivElement | null = null;
function show(e: MouseEvent) {
if (!text || !text.includes("/")) return;
el = document.createElement("div");
el.className = "path-tooltip";
el.textContent = text;
document.body.appendChild(el);
move(e);
}
function move(e: MouseEvent) {
if (!el) return;
const tw = el.offsetWidth;
const left = Math.max(8, Math.min(e.clientX - tw / 2, window.innerWidth - tw - 8));
el.style.left = `${left}px`;
el.style.top = `${e.clientY - 44}px`;
}
function hide() {
el?.remove();
el = null;
}
node.addEventListener("mouseenter", show);
node.addEventListener("mousemove", move);
node.addEventListener("mouseleave", hide);
return {
update(v: string) { text = v; },
destroy() {
hide();
node.removeEventListener("mouseenter", show);
node.removeEventListener("mousemove", move);
node.removeEventListener("mouseleave", hide);
},
};
}
</script>
<section class="panel file-history-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Selected file history">
{#if collapsed}
<button
class="file-history-rail-toggle"
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title="Expand file history"
aria-label="Expand file history panel"
>
<ChevronLeft size={16} aria-hidden="true" />
<span>File history</span>
{#if selectedExplorerPath}
<small>{fileHistory.length}</small>
{/if}
</button>
{:else}
<div class="section-head file-history-head">
<div class="file-history-heading">
<span class="eyebrow">{selectedExplorerLabel}</span>
<h2
class="file-history-name"
use:pathTooltip={selectedExplorerPath}
>{selectedExplorerPath ? fileName(selectedExplorerPath) : "No file"}</h2>
</div>
<div class="file-history-head-actions">
<span class="pill pill-count file-history-count">{fileHistory.length}</span>
<button
class="file-history-toggle panel-collapse-toggle"
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title="Collapse file history"
aria-label="Collapse file history panel"
>
<ChevronRight size={14} aria-hidden="true" />
</button>
</div>
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else if !selectedExplorerPath}
<div class="blank-state">Select a file in Explorer.</div>
{:else if isLoading}
<div class="file-history-loading" role="status" aria-live="polite">
<div class="file-history-loading-mark" aria-hidden="true">
<span class="file-history-loading-halo"></span>
<svg class="file-history-loading-traces" viewBox="0 0 120 120">
<path class="fhl-trace fhl-trace-main" d="M14 82 C38 60, 48 58, 60 60 S84 66, 106 36" />
<path class="fhl-trace fhl-trace-branch" d="M28 36 C52 48, 70 70, 91 94" />
</svg>
<img src={iconUrl} alt="" class="file-history-loading-icon" />
</div>
<span class="file-history-loading-label">Loading history…</span>
<span class="file-history-loading-bar" aria-hidden="true"><span></span></span>
</div>
{:else if fileHistory.length === 0}
<div class="blank-state">No history returned for this selection.</div>
{:else}
<div class="history-list overflow-auto p-2">
{#each fileHistory as item (item.hash)}
<article class="commit-row compact file-history-row">
<div class="commit-line">
<History size={16} aria-hidden="true" />
<div class="commit-line-text">
<strong title={item.summary}>{item.summary}</strong>
<span>{item.short_hash} - {item.author_name}</span>
</div>
</div>
<div class="commit-actions file-history-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons">
<button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes vs working tree">
<GitCompare size={15} aria-hidden="true" />
Diff
</button>
<button class="btn-sm" type="button" onclick={() => onRestore(item)} disabled={isBusy} title="Restore selected file from this commit">
<RotateCcw size={15} aria-hidden="true" />
Restore
</button>
</div>
</div>
</article>
{/each}
</div>
{/if}
{/if}
</section>
+6 -1
View File
@@ -15,6 +15,11 @@
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
import LanguageIcon from "./LanguageIcon.svelte";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
type SearchTab = "code" | "files";
interface Props {
@@ -82,7 +87,7 @@
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
return commitDateFormatter.format(date);
}
function displayPath(hit: GitSearchHit): string {
+264
View File
@@ -17,6 +17,7 @@
Lightbulb,
ListChecks,
Search,
Sparkles,
Wrench,
X,
} from "@lucide/svelte";
@@ -617,6 +618,87 @@
},
);
deCategories.find((category) => category.id === "app")?.sections.push(
{
id: "app-line-staging",
title: "Änderungen zeilenweise stagen",
summary: "Im Patch-Dialog kannst du nicht nur vollständige Dateien oder Hunks, sondern einzelne hinzugefügte und entfernte Zeilen für den nächsten Commit auswählen.",
steps: [
"Öffne im Bereich Unstaged den Patch einer geänderten Textdatei.",
"Klicke auf die Auswahlfläche vor einer grünen oder roten Zeile. Mit Shift-Klick markierst du einen zusammenhängenden Bereich.",
"Die Auswahlleiste zeigt die Anzahl der gewählten Zeilen. Stage selected übernimmt nur diese Auswahl in den Staging-Bereich.",
"Öffne denselben Dialog unter Staged und nutze Unstage selected, wenn einzelne Zeilen wieder zurück ins Arbeitsverzeichnis sollen.",
"Prüfe danach den Staged-Diff. Er ist die verbindliche Vorschau des nächsten Commits.",
],
note: "Bei einer geänderten Zeile stellt Git intern meist eine entfernte und eine hinzugefügte Zeile dar. Wähle beide Seiten, wenn die vollständige Ersetzung in denselben Commit gehört.",
},
{
id: "app-line-discard",
title: "Hunks und einzelne Zeilen verwerfen",
summary: "Discard selected verwirft nur die markierten Zeilen; Discard Hunk verwirft den vollständigen Abschnitt. Gitty zeigt vor dem Löschen eine Sicherheitsabfrage.",
steps: [
"Kontrolliere, ob der Dialog Staged changes oder Unstaged changes anzeigt.",
"Markiere exakt die Zeilen, die nicht behalten werden sollen.",
"Lies in der Sicherheitsabfrage Pfad und Umfang und bestätige erst danach.",
"Aktualisiere den Patch, falls Dateien während des geöffneten Dialogs extern verändert wurden.",
],
note: "Vorsicht: Verwerfen kann nicht zuverlässig rückgängig gemacht werden. Erstelle bei wichtigen Zwischenständen zuerst einen Commit oder Stash.",
},
{
id: "app-package-updates",
title: "Gitty unter Arch Linux installieren und aktualisieren",
summary: "Das AUR-Paket gitty-desktop lädt das öffentliche Gitea-Release und baut Gitty lokal aus dem Quellcode.",
commands: [
{ command: "yay -S gitty-desktop", description: "Gitty mit yay aus dem AUR bauen, installieren oder aktualisieren" },
{ command: "paru -S gitty-desktop", description: "Gitty alternativ mit paru bauen, installieren oder aktualisieren" },
{ command: "git clone https://aur.archlinux.org/gitty-desktop.git && cd gitty-desktop && makepkg -si", description: "AUR-Paket ohne AUR-Helfer prüfen und manuell bauen" },
{ command: "pacman -Qi gitty-desktop", description: "Installierte Version und Paketinformationen anzeigen" },
],
steps: [
"Das PKGBUILD lädt das öffentliche Quellarchiv des jeweiligen Gitea-Tags herunter.",
"Die Pipeline aktualisiert Version, Prüfsumme und .SRCINFO im AUR-Paket gitty-desktop.",
"AUR-Helfer erkennen neue Versionen und erzeugen daraus lokal ein normales Pacman-Paket.",
"Ohne AUR-Helfer kannst du das AUR-Git-Repository klonen, die Dateien prüfen und makepkg -si ausführen.",
],
note: "AUR-Pakete werden von Nutzern gepflegt. Prüfe PKGBUILD und .SRCINFO vor der Installation, besonders nach größeren Änderungen.",
},
);
deCategories.find((category) => category.id === "branches")?.sections.push(
{
id: "branches-worktrees-concept",
title: "Was ein Worktree ist",
summary: "Ein Repository besitzt eine gemeinsame Git-Datenbank, kann aber mehrere Arbeitsordner haben. Jeder Worktree hat eigene ausgecheckte Dateien und normalerweise einen eigenen Branch; Commits und Referenzen sind sofort in allen Worktrees sichtbar.",
steps: [
"Das Haupt-Worktree ist der Ordner, den du ursprünglich als Repository geöffnet hast.",
"Ein zusätzlicher Worktree ist kein vollständiger Clone: Git-Objekte und Historie werden gemeinsam genutzt, nur Arbeitsordner und Index sind getrennt.",
"Änderungen in Dateien bleiben im jeweiligen Worktree. Ein dort erstellter Commit ist dagegen sofort Teil desselben Repositorys.",
"Worktrees eignen sich besonders für parallele Features, dringende Hotfixes, Reviews oder längere Builds auf einem zweiten Branch.",
"Für vollständig unabhängige Remotes, Konfigurationen oder Experimente ist ein eigener Clone weiterhin die passendere Wahl.",
],
note: "Ein Branch kann normalerweise nicht gleichzeitig in zwei Worktrees ausgecheckt werden. Das verhindert, dass zwei Ordner denselben Branch widersprüchlich verändern.",
},
{
id: "branches-worktrees",
title: "Mehrere Branches mit Worktrees parallel öffnen",
summary: "Ein Worktree checkt einen weiteren Branch in einen eigenen Ordner aus. So kannst du an mehreren Aufgaben arbeiten, ohne Branches im Hauptordner umzuschalten oder lokale Änderungen zu stashen.",
steps: [
"Öffne im Branch-Panel unter Tags den Reiter Worktrees.",
"Wähle Existing branch für einen vorhandenen lokalen Branch, New branch für einen neuen Branch oder Detached für einen festen Commit ohne Branch.",
"Lege einen leeren Zielordner fest und erstelle den Worktree. Open tab öffnet ihn anschließend als eigenen Repository-Tab.",
"Move verschiebt einen nicht aktiven Worktree. Lock schützt ihn vor versehentlichem Prune oder Entfernen, etwa auf einem externen Laufwerk.",
"Remove löscht den Worktree-Ordner und seine Registrierung, behält aber den Branch. Prune entfernt nur veraltete Registrierungen nicht mehr vorhandener Ordner.",
],
commands: [
{ command: "git worktree list", description: "Alle registrierten Worktrees anzeigen" },
{ command: "git worktree add ../projekt-fix fix/login", description: "Vorhandenen Branch in neuem Ordner auschecken" },
{ command: "git worktree add -b feature/name ../projekt-feature main", description: "Neuen Branch und Worktree von main erstellen" },
{ command: "git worktree prune --dry-run", description: "Veraltete Registrierungen vor dem Aufräumen prüfen" },
],
note: "Ein Branch kann normalerweise nur in einem Worktree gleichzeitig ausgecheckt sein. Ein Worktree mit lokalen Änderungen sollte vor Remove committed, gestasht oder bewusst erzwungen entfernt werden.",
},
);
deCategories.find((category) => category.id === "branches")?.sections.push(
{
id: "branches-strategy",
@@ -979,6 +1061,87 @@
},
);
enCategories.find((category) => category.id === "app")?.sections.push(
{
id: "app-line-staging",
title: "Stage changes line by line",
summary: "The patch dialog lets you prepare individual added and deleted lines instead of staging an entire file or hunk.",
steps: [
"Open the patch of a changed text file in Unstaged.",
"Click the selector in front of a green or red line. Shift-click selects a continuous range.",
"The selection bar shows the number of selected lines. Stage selected moves only that selection into Staged.",
"Open the same dialog under Staged and use Unstage selected to return individual lines to the working tree.",
"Review the Staged diff afterward. It is the authoritative preview of the next commit.",
],
note: "Git usually represents one edited line as one deletion plus one addition. Select both sides when the complete replacement belongs in the same commit.",
},
{
id: "app-line-discard",
title: "Discard hunks or individual lines",
summary: "Discard selected removes only selected lines; Discard Hunk removes the complete section. Gitty shows a confirmation before deleting changes.",
steps: [
"Check whether the dialog currently shows Staged changes or Unstaged changes.",
"Select exactly the lines that should not be kept.",
"Review the file path and scope in the confirmation, then confirm.",
"Refresh the patch if another application changed the file while the dialog was open.",
],
note: "Caution: discarded work cannot always be recovered. Commit or stash important intermediate work first.",
},
{
id: "app-package-updates",
title: "Install and update Gitty on Arch Linux",
summary: "The gitty-desktop AUR package downloads the public Gitea release and builds Gitty locally from source.",
commands: [
{ command: "yay -S gitty-desktop", description: "Build, install, or update Gitty from the AUR with yay" },
{ command: "paru -S gitty-desktop", description: "Alternatively build, install, or update Gitty with paru" },
{ command: "git clone https://aur.archlinux.org/gitty-desktop.git && cd gitty-desktop && makepkg -si", description: "Inspect and build the AUR package without an AUR helper" },
{ command: "pacman -Qi gitty-desktop", description: "Show the installed version and package information" },
],
steps: [
"PKGBUILD downloads the public source archive for the corresponding Gitea tag.",
"The pipeline updates the version, checksum, and .SRCINFO in the gitty-desktop AUR package.",
"AUR helpers detect new versions and turn the recipe into a regular Pacman package locally.",
"Without an AUR helper, clone the AUR Git repository, inspect its files, and run makepkg -si.",
],
note: "AUR packages are user-maintained. Review PKGBUILD and .SRCINFO before installing, especially after major changes.",
},
);
enCategories.find((category) => category.id === "branches")?.sections.push(
{
id: "branches-worktrees-concept",
title: "What a worktree is",
summary: "One repository has a shared Git database but can have multiple working folders. Each worktree has its own checked-out files and usually its own branch; commits and references are immediately visible from every worktree.",
steps: [
"The main worktree is the folder you originally opened as the repository.",
"An additional worktree is not a full clone: Git objects and history are shared while the working folder and index remain separate.",
"File changes stay inside their worktree. A commit created there immediately becomes part of the shared repository.",
"Worktrees are useful for parallel features, urgent hotfixes, reviews, or long-running builds on another branch.",
"Use a separate clone when you need independent remotes, configuration, or isolated experiments.",
],
note: "A branch normally cannot be checked out in two worktrees at once. This prevents two folders from changing the same branch inconsistently.",
},
{
id: "branches-worktrees",
title: "Open multiple branches in parallel with worktrees",
summary: "A worktree checks out another branch into a separate folder. This lets you work on multiple tasks without switching the main folder or stashing local changes.",
steps: [
"Open Worktrees below Tags in the Branch panel.",
"Choose Existing branch for a local branch, New branch to create one, or Detached for a fixed commit without a branch.",
"Choose an empty destination folder and create the worktree. Open tab then opens it as a separate repository tab.",
"Move relocates an inactive worktree. Lock protects it from accidental pruning or removal, for example while stored on an external drive.",
"Remove deletes the worktree folder and registration but keeps its branch. Prune removes only stale registrations for missing folders.",
],
commands: [
{ command: "git worktree list", description: "Show all registered worktrees" },
{ command: "git worktree add ../project-fix fix/login", description: "Check out an existing branch in a new folder" },
{ command: "git worktree add -b feature/name ../project-feature main", description: "Create a branch and worktree from main" },
{ command: "git worktree prune --dry-run", description: "Preview stale registrations before pruning" },
],
note: "A branch can normally be checked out in only one worktree at a time. Commit, stash, or deliberately force removal of local changes before removing a worktree.",
},
);
enCategories.find((category) => category.id === "branches")?.sections.push(
{
id: "branches-strategy",
@@ -1193,6 +1356,106 @@
},
);
deCategories.splice(1, 0, {
id: "changelog",
label: "Neu in Gitty",
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
sections: [
{
id: "changelog-2026-07-22",
title: "Version 2026.07.22",
summary: "Dieses Release erweitert Gitty um eine AI-gestützte Aufteilung gestagter Änderungen in logisch getrennte Commits.",
steps: [
"AI-Commit-Aufteilung: Der gestagte Diff wird analysiert und als geordneter Plan aus mehreren logisch zusammengehörenden Commits vorgeschlagen.",
"Für jede Gruppe wird automatisch eine editierbare Conventional-Commit-Nachricht erzeugt.",
"Dateien können vor dem Commit zwischen den vorgeschlagenen Gruppen verschoben werden.",
"Mit „Commit all“ werden alle bestätigten Gruppen sicher und der Reihe nach committed.",
"Der Dialog erklärt leere Gruppen oder fehlende Nachrichten und schützt vor einem zwischenzeitlich veränderten Staging-Bereich.",
"„Commit all“ reagiert wieder zuverlässig und bricht nicht mehr beim Kopieren des reaktiven Dialogzustands ab.",
"Ein geschlossenes aktives Repository kann nicht mehr durch einen verspäteten Status- oder Fetch-Request erneut geöffnet werden.",
],
note: "Die AI-Commit-Aufteilung unterstützt OpenAI, Anthropic und eigene OpenAI-kompatible Endpunkte. In Paketdateien erscheint diese Version als 2026.7.22.",
},
{
id: "changelog-2026-07-21",
title: "Version 2026.07.21",
summary: "Dieses Release bündelt paralleles Arbeiten mit Worktrees, präzisere Commits und die neue Arch-Linux-Verteilung.",
steps: [
"Worktree-Verwaltung: zusätzliche Arbeitsordner erstellen, öffnen, verschieben, sperren, entsperren, reparieren, entfernen und veraltete Registrierungen aufräumen.",
"Worktrees sind direkt über den neuen Reiter unter Tags erreichbar; Branches lassen sich außerdem aus ihrem Kontextmenü in einem Worktree öffnen.",
"Zeilenweises Staging: einzelne Ergänzungen und Löschungen auswählen, per Shift-Klick Bereiche markieren sowie ausgewählte Zeilen stagen, unstagen oder verwerfen.",
"Sicherere Dialoge: verständlichere Branch-Löschabfrage und weichgezeichneter Hintergrund bei geöffneten Dialogen.",
"Arch-Linux-Pakete: automatisierter Build aus dem PKGBUILD, Veröffentlichung von .pkg.tar.zst und Repository-Datenbank für Pacman auf dem CDN.",
"Robustere Remote-Aktionen, zentrale Fehlermeldungen und strukturierte, datensparsame Telemetrie.",
"Erweiterte zweisprachige Hilfe mit Worktree-, Pacman- und Line-Staging-Anleitungen.",
],
note: "In Paketdateien kann dieselbe Version als 2026.7.21 erscheinen, weil Paketmanager numerische Versionssegmente ohne führende Null verwenden.",
},
{
id: "changelog-2026-7-20",
title: "Version 2026.7.20",
summary: "Der letzte veröffentlichte Stand konzentrierte sich auf produktiveres Arbeiten, bessere Orientierung und einen stabileren Paket-Build.",
steps: [
"Vor dem Commit kann eine AI-gestützte Codeprüfung den Staged-Diff analysieren.",
"Automatische Aktualisierung hält Repository-Status und Arbeitsbereich auf Wunsch aktuell.",
"Repository-Aktionsleiste, Statusdarstellung, Tabs und Diff-Ansicht wurden übersichtlicher gestaltet.",
"Die integrierte Hilfe wurde um ausführliche deutsche Git-Dokumentation ergänzt.",
"PKGBUILD und Build-Skripte wurden für die Arch-Linux-Verteilung vorbereitet.",
],
},
],
});
enCategories.splice(1, 0, {
id: "changelog",
label: "What's new",
description: "Changes since the latest published version and notable additions from earlier releases.",
sections: [
{
id: "changelog-2026-07-22",
title: "Version 2026.07.22",
summary: "This release adds AI-assisted splitting of staged changes into separate logical commits.",
steps: [
"AI commit splitting analyzes the staged diff and proposes an ordered plan of logically related commits.",
"Every group receives an automatically generated, editable Conventional Commit message.",
"Files can be moved between proposed groups before committing.",
"Commit all safely creates every accepted group in sequence.",
"The dialog explains empty groups or missing messages and protects against a staging area that changed after planning.",
"Commit all now responds reliably instead of failing while copying reactive dialog state.",
"Closing the active repository no longer lets a delayed status or fetch request reopen the closed tab.",
],
note: "AI commit splitting supports OpenAI, Anthropic, and custom OpenAI-compatible endpoints. Package metadata represents this version as 2026.7.22.",
},
{
id: "changelog-2026-07-21",
title: "Version 2026.07.21",
summary: "This release combines parallel worktree workflows, more precise commits, and the new Arch Linux distribution.",
steps: [
"Worktree management: create, open, move, lock, unlock, repair, remove, and prune additional working folders.",
"Worktrees are available from the new entry below Tags; branches can also be opened in a worktree from their context menu.",
"Line-level staging: select additions and deletions, Shift-click ranges, and stage, unstage, or discard selected lines.",
"Safer dialogs: a clearer branch deletion confirmation and a blurred background while dialogs are open.",
"Arch Linux packages: automated PKGBUILD builds plus publication of .pkg.tar.zst and the Pacman repository database to the CDN.",
"More robust remote operations, centralized error messages, and structured privacy-conscious telemetry.",
"Expanded bilingual help for worktrees, Pacman installation, and line-level staging.",
],
note: "Package metadata may represent the same release as 2026.7.21 because package managers use numeric version segments without leading zeroes.",
},
{
id: "changelog-2026-7-20",
title: "Version 2026.7.20",
summary: "The latest published version focused on productivity, clearer navigation, and a more stable package build.",
steps: [
"AI-assisted pre-commit review can analyze the staged diff before committing.",
"Optional automatic refresh keeps repository status and the workspace current.",
"The repository action bar, status presentation, tabs, and diff view became easier to scan.",
"The built-in help gained comprehensive German Git documentation.",
"PKGBUILD and build scripts prepared the Arch Linux distribution workflow.",
],
},
],
});
let { language = "en", onClose = () => {} }: Props = $props();
const isGerman = $derived(language === "de");
const categories = $derived(isGerman ? deCategories : enCategories);
@@ -1297,6 +1560,7 @@
>
<span class="help-nav-icon">
{#if category.id === "start"}<Home size={17} aria-hidden="true" />
{:else if category.id === "changelog"}<Sparkles size={17} aria-hidden="true" />
{:else if category.id === "app"}<BookOpen size={17} aria-hidden="true" />
{:else if category.id === "basics"}<GitCommitHorizontal size={17} aria-hidden="true" />
{:else if category.id === "branches"}<GitBranch size={17} aria-hidden="true" />
+45 -9
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment {
@@ -27,6 +27,10 @@
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
];
const GRAPH_LANE = 18;
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
interface Props {
commits: GitCommit[];
@@ -36,7 +40,11 @@
repositoryKey: string;
hasRepository: boolean;
isBusy: boolean;
hasMore: boolean;
isLoadingMore: boolean;
loadMoreError: string;
expandedCommitHashes: Set<string>;
onLoadMore: () => void | Promise<void>;
onRestoreCommit: (commit: GitCommit) => void;
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
onToggleCommitFiles: (hash: string) => void;
@@ -53,7 +61,11 @@
repositoryKey = "",
hasRepository = false,
isBusy = false,
hasMore = false,
isLoadingMore = false,
loadMoreError = "",
expandedCommitHashes = new Set(),
onLoadMore = () => {},
onRestoreCommit = () => {},
onPreviewCommitFile = () => {},
onToggleCommitFiles = () => {},
@@ -71,6 +83,17 @@
let contextMenuX = $state(0);
let contextMenuY = $state(0);
function observeHistoryEnd(node: HTMLElement) {
const root = node.closest<HTMLElement>(".history-list");
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting) && hasMore && !isLoadingMore && !loadMoreError && !isBusy) {
void onLoadMore();
}
}, { root, rootMargin: "240px 0px" });
observer.observe(node);
return { destroy: () => observer.disconnect() };
}
function laneColor(col: number): string {
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
}
@@ -464,7 +487,7 @@
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
return commitDateFormatter.format(date);
}
$effect(() => {
@@ -518,8 +541,8 @@
<span class="eyebrow">History</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
</div>
<div class="section-head-actions">
{#if localBranchNames.length > 0}
<div class="section-head-actions">
<button
class="graph-branch-dialog-button"
type="button"
@@ -530,21 +553,19 @@
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>
{/if}
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else if commits.length === 0}
<div class="blank-state">No commits returned.</div>
{:else if visibleCommits.length === 0}
<div class="blank-state">No commits match the selected branches.</div>
{:else}
<div class="history-list graph-list overflow-auto">
{#if visibleCommits.length === 0}
<div class="blank-state">No loaded commits match the selected branches.</div>
{/if}
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
{@const item = entry.commit}
{@const row = graphRows[rowIndex]}
@@ -698,6 +719,21 @@
</div>
</article>
{/each}
{#if hasMore || isLoadingMore || loadMoreError}
<div class="history-load-more" use:observeHistoryEnd aria-live="polite">
{#if isLoadingMore}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
<span>Loading older commits…</span>
{:else if loadMoreError}
<span title={loadMoreError}>Older commits could not be loaded.</span>
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>Retry</button>
{:else}
<button type="button" class="history-load-more-button" onclick={() => { void onLoadMore(); }} disabled={isBusy}>
Load older commits
</button>
{/if}
</div>
{/if}
</div>
{/if}
+181 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { LoaderCircle, X } from "@lucide/svelte";
import { Check, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
import type { GitFileStatus, PatchApplyAction } from "../types";
type PatchLineKind = "context" | "add" | "delete" | "meta";
@@ -8,12 +8,17 @@
id: string;
text: string;
kind: PatchLineKind;
oldLine: number | null;
newLine: number | null;
}
interface PatchHunk {
id: string;
header: string;
lines: PatchLine[];
oldStart: number;
newStart: number;
suffix: string;
}
interface ParsedPatch {
@@ -31,7 +36,7 @@
error: string;
onClose: () => void;
onRefresh: () => void | Promise<void>;
onApply: (action: PatchApplyAction, patch: string) => void | Promise<void>;
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
}
let {
@@ -48,12 +53,20 @@
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
let patchScroll = $state<HTMLDivElement | null>(null);
let selectedLineIds = $state<Set<string>>(new Set());
let lastSelectedLineId = $state("");
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
let selectableLines = $derived(
parsed.hunks.flatMap((hunk) => hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete")),
);
let selectedCount = $derived(selectableLines.filter((line) => selectedLineIds.has(line.id)).length);
$effect(() => {
parsed = parsePatch(patch);
selectedLineIds = new Set();
lastSelectedLineId = "";
});
function parsePatch(input: string): ParsedPatch {
@@ -64,10 +77,22 @@
const headerLines: string[] = [];
const hunks: PatchHunk[] = [];
let current: PatchHunk | null = null;
let oldCursor = 0;
let newCursor = 0;
for (const line of lines) {
if (line.startsWith("@@ ")) {
current = { id: `hunk-${hunks.length}`, header: line, lines: [] };
const range = parseHunkHeader(line);
current = {
id: `hunk-${hunks.length}`,
header: line,
lines: [],
oldStart: range.oldStart,
newStart: range.newStart,
suffix: range.suffix,
};
oldCursor = range.oldStart;
newCursor = range.newStart;
hunks.push(current);
continue;
}
@@ -78,11 +103,17 @@
}
const kind = patchLineKind(line);
const oldLine = kind === "context" || kind === "delete" ? oldCursor : null;
const newLine = kind === "context" || kind === "add" ? newCursor : null;
current.lines.push({
id: `${current.id}-line-${current.lines.length}`,
text: line,
kind,
oldLine,
newLine,
});
if (oldLine !== null) oldCursor += 1;
if (newLine !== null) newCursor += 1;
}
return {
@@ -92,6 +123,15 @@
};
}
function parseHunkHeader(header: string): { oldStart: number; newStart: number; suffix: string } {
const match = header.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
return {
oldStart: Number(match?.[1] ?? 0),
newStart: Number(match?.[2] ?? 0),
suffix: match?.[3] ?? "",
};
}
function patchLineKind(line: string): PatchLineKind {
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
@@ -117,7 +157,91 @@
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
if (isBusy || isLoading) return;
await onApply(action, buildHunkPatch(hunk));
await onApply(action, buildHunkPatch(hunk), "hunk");
}
function toggleLine(event: MouseEvent, line: PatchLine) {
if (isBusy || isLoading || (line.kind !== "add" && line.kind !== "delete")) return;
const next = new Set(selectedLineIds);
const selecting = !next.has(line.id);
if (event.shiftKey && lastSelectedLineId) {
const start = selectableLines.findIndex((candidate) => candidate.id === lastSelectedLineId);
const end = selectableLines.findIndex((candidate) => candidate.id === line.id);
if (start >= 0 && end >= 0) {
for (const candidate of selectableLines.slice(Math.min(start, end), Math.max(start, end) + 1)) {
if (selecting) next.add(candidate.id);
else next.delete(candidate.id);
}
}
} else if (selecting) {
next.add(line.id);
} else {
next.delete(line.id);
}
selectedLineIds = next;
lastSelectedLineId = line.id;
}
function toggleHunkSelection(hunk: PatchHunk) {
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
const allSelected = changed.length > 0 && changed.every((line) => selectedLineIds.has(line.id));
const next = new Set(selectedLineIds);
for (const line of changed) {
if (allSelected) next.delete(line.id);
else next.add(line.id);
}
selectedLineIds = next;
lastSelectedLineId = changed[changed.length - 1]?.id ?? "";
}
function hunkSelectionState(hunk: PatchHunk): "none" | "some" | "all" {
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
const count = changed.filter((line) => selectedLineIds.has(line.id)).length;
return count === 0 ? "none" : count === changed.length ? "all" : "some";
}
function rangePart(start: number, count: number): string {
return count === 1 ? `${start}` : `${start},${count}`;
}
function buildSelectedHunk(hunk: PatchHunk): string | null {
if (!hunk.lines.some((line) => selectedLineIds.has(line.id))) return null;
const output: string[] = [];
let previousIncluded = false;
for (const line of hunk.lines) {
if (line.kind === "context") {
output.push(line.text);
previousIncluded = true;
} else if (line.kind === "delete") {
output.push(selectedLineIds.has(line.id) ? line.text : ` ${line.text.slice(1)}`);
previousIncluded = true;
} else if (line.kind === "add") {
if (selectedLineIds.has(line.id)) {
output.push(line.text);
previousIncluded = true;
} else {
previousIncluded = false;
}
} else if (previousIncluded) {
output.push(line.text);
}
}
const oldCount = output.filter((line) => line.startsWith(" ") || line.startsWith("-")).length;
const newCount = output.filter((line) => line.startsWith(" ") || line.startsWith("+")).length;
const header = `@@ -${rangePart(hunk.oldStart, oldCount)} +${rangePart(hunk.newStart, newCount)} @@${hunk.suffix}`;
return [header, ...output].join("\n");
}
function buildSelectedPatch(): string {
const hunks = parsed.hunks.map(buildSelectedHunk).filter((hunk): hunk is string => Boolean(hunk));
return `${[...parsed.headerLines, ...hunks].join("\n")}\n`;
}
async function applySelected(action: PatchApplyAction) {
if (isBusy || isLoading || selectedCount === 0) return;
await onApply(action, buildSelectedPatch(), "lines");
}
function hunkPosition(index: number): number {
@@ -166,11 +290,47 @@
{:else if parsed.binary || parsed.hunks.length === 0}
<div class="blank-state">This change cannot be split into text lines.</div>
{:else}
<div class:active={selectedCount > 0} class="line-patch-selection-bar">
<div>
<MousePointer2 size={14} aria-hidden="true" />
{#if selectedCount > 0}
<strong>{selectedCount} {selectedCount === 1 ? "line" : "lines"} selected</strong>
<span>Shift-click to select a range.</span>
{:else}
<strong>Select changed lines</strong>
<span>Choose individual additions or deletions below.</span>
{/if}
</div>
{#if selectedCount > 0}
<div class="line-patch-selected-actions">
<button class="line-patch-hunk-button discard" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy}>
Discard selected
</button>
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy}>
{staged ? "Unstage selected" : "Stage selected"}
</button>
<button class="btn-sm" type="button" onclick={() => { selectedLineIds = new Set(); }} disabled={isBusy}>Clear</button>
</div>
{/if}
</div>
<div class="line-patch-workspace">
<div class="line-patch-scroll" bind:this={patchScroll}>
{#each parsed.hunks as hunk (hunk.id)}
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
<div class="line-patch-hunk-head">
<button
class:all={hunkSelectionState(hunk) === "all"}
class:some={hunkSelectionState(hunk) === "some"}
class="line-patch-select-hunk"
type="button"
onclick={() => toggleHunkSelection(hunk)}
aria-label={`Select changed lines in ${hunk.header}`}
aria-pressed={hunkSelectionState(hunk) === "all"}
title="Select all changed lines in this hunk"
>
{#if hunkSelectionState(hunk) !== "none"}<Check size={12} aria-hidden="true" />{/if}
</button>
<code>{hunk.header}</code>
<div class="line-patch-hunk-actions">
{#if staged}
@@ -193,7 +353,23 @@
<div class="line-patch-lines">
{#each hunk.lines as line (line.id)}
<div class={`line-patch-row ${line.kind}`}>
<div class:selected={selectedLineIds.has(line.id)} class:selectable={line.kind === "add" || line.kind === "delete"} class={`line-patch-row ${line.kind}`}>
{#if line.kind === "add" || line.kind === "delete"}
<button
class="line-patch-line-select"
type="button"
onclick={(event) => toggleLine(event, line)}
aria-label={`${selectedLineIds.has(line.id) ? "Deselect" : "Select"} ${line.kind === "add" ? "added" : "deleted"} line ${line.newLine ?? line.oldLine ?? ""}`}
aria-pressed={selectedLineIds.has(line.id)}
title="Select line (Shift-click for range)"
>
{#if selectedLineIds.has(line.id)}<Check size={11} aria-hidden="true" />{/if}
</button>
{:else}
<span class="line-patch-line-select-placeholder"></span>
{/if}
<span class="line-patch-line-number">{line.oldLine ?? ""}</span>
<span class="line-patch-line-number">{line.newLine ?? ""}</span>
<span class="line-patch-prefix">{linePrefix(line)}</span>
<code>{lineBody(line)}</code>
</div>
+4 -2
View File
@@ -144,6 +144,8 @@
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
let unstagedFiles = $derived(changedFiles.filter((file) => file.unstaged !== null));
let stagedFiles = $derived(changedFiles.filter((file) => file.staged !== null));
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
@@ -201,7 +203,7 @@
</div>
</header>
<div class="status-file-list">
{#each changedFiles.filter((file) => file.unstaged !== null) as file (`unstaged:${fileKey(file)}`)}
{#each unstagedFiles as file (`unstaged:${fileKey(file)}`)}
{@const stageTargets = selectedStageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
@@ -241,7 +243,7 @@
</div>
</header>
<div class="status-file-list">
{#each changedFiles.filter((file) => file.staged !== null) as file (`staged:${fileKey(file)}`)}
{#each stagedFiles as file (`staged:${fileKey(file)}`)}
{@const unstageTargets = selectedUnstageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
+455
View File
@@ -0,0 +1,455 @@
<script lang="ts">
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import {
AlertTriangle,
Check,
CircleDot,
ExternalLink,
FolderInput,
FolderOpen,
GitBranch,
HardDrive,
LoaderCircle,
Lock,
MapPin,
Plus,
RefreshCw,
ShieldCheck,
Trash2,
Unlock,
Wrench,
X,
} from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitWorktree } from "../types";
type CreateMode = "existing" | "new" | "detached";
interface AddRequest {
worktreePath: string;
branch?: string;
newBranch?: string;
startPoint?: string;
detached?: boolean;
lock?: boolean;
}
interface Props {
worktrees: GitWorktree[];
branches: GitBranchInfo[];
initialBranch?: string;
isLoading: boolean;
isBusy: boolean;
error?: string;
onRefresh: () => void | Promise<void>;
onOpen: (worktree: GitWorktree) => void | Promise<void>;
onAdd: (request: AddRequest) => boolean | Promise<boolean>;
onRemove: (worktree: GitWorktree, force: boolean) => boolean | Promise<boolean>;
onMove: (worktree: GitWorktree, destination: string) => void | Promise<void>;
onLock: (worktree: GitWorktree, reason: string) => boolean | Promise<boolean>;
onUnlock: (worktree: GitWorktree) => void | Promise<void>;
onPrune: () => void | Promise<void>;
onRepair: (worktree: GitWorktree, location: string) => void | Promise<void>;
onClose: () => void;
}
let {
worktrees = [],
branches = [],
initialBranch = "",
isLoading = false,
isBusy = false,
error = "",
onRefresh = () => {},
onOpen = () => {},
onAdd = () => false,
onRemove = () => false,
onMove = () => {},
onLock = () => false,
onUnlock = () => {},
onPrune = () => {},
onRepair = () => {},
onClose = () => {},
}: Props = $props();
let createOpen = $state(false);
let createMode = $state<CreateMode>("new");
let selectedBranch = $state("");
let newBranch = $state("");
let startPoint = $state("HEAD");
let destination = $state("");
let lockAfterCreate = $state(false);
let pendingRemoval = $state<GitWorktree | null>(null);
let forceRemoval = $state(false);
let pendingLock = $state<GitWorktree | null>(null);
let lockReason = $state("");
let initialized = false;
$effect(() => {
if (initialized) return;
createOpen = initialBranch.length > 0;
createMode = initialBranch ? "existing" : "new";
selectedBranch = initialBranch;
initialized = true;
});
let localBranches = $derived(branches.filter((branch) => !branch.remote));
let prunableCount = $derived(worktrees.filter((worktree) => worktree.prunable).length);
let linkedCount = $derived(Math.max(0, worktrees.length - 1));
let checkedOutBranches = $derived(new Set(worktrees.map((worktree) => worktree.branch).filter((branch): branch is string => Boolean(branch))));
function displayName(worktree: GitWorktree): string {
return worktree.branch || (worktree.detached ? `Detached at ${worktree.short_head || "HEAD"}` : "Bare worktree");
}
function pathName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() || path;
}
function branchAvailable(branch: string): boolean {
return !checkedOutBranches.has(branch);
}
function joinPath(parent: string, name: string): string {
const separator = parent.includes("\\") ? "\\" : "/";
return `${parent.replace(/[\\/]+$/, "")}${separator}${name}`;
}
async function chooseDestination(current = "") {
const selected = await openDialog({
title: current ? "Choose new worktree location" : "Choose worktree folder",
directory: true,
multiple: false,
defaultPath: current || undefined,
});
if (typeof selected === "string") destination = selected;
}
async function submitCreate(event: SubmitEvent) {
event.preventDefault();
if (!destination.trim()) return;
const request: AddRequest = {
worktreePath: destination.trim(),
lock: lockAfterCreate,
};
if (createMode === "existing") request.branch = selectedBranch;
if (createMode === "new") {
request.newBranch = newBranch.trim();
request.startPoint = startPoint.trim() || "HEAD";
}
if (createMode === "detached") {
request.detached = true;
request.startPoint = startPoint.trim() || "HEAD";
}
if (await onAdd(request)) {
createOpen = false;
destination = "";
newBranch = "";
}
}
async function chooseMoveDestination(worktree: GitWorktree) {
const selected = await openDialog({
title: `Choose parent folder for ${displayName(worktree)}`,
directory: true,
multiple: false,
defaultPath: worktree.path,
});
if (typeof selected === "string") {
const target = joinPath(selected, pathName(worktree.path));
if (target !== worktree.path) await onMove(worktree, target);
}
}
async function chooseRepairLocation(worktree: GitWorktree) {
const selected = await openDialog({
title: `Locate ${displayName(worktree)}`,
directory: true,
multiple: false,
});
if (typeof selected === "string") await onRepair(worktree, selected);
}
function requestRemoval(worktree: GitWorktree) {
pendingRemoval = worktree;
forceRemoval = false;
}
async function confirmRemoval() {
if (!pendingRemoval) return;
if (await onRemove(pendingRemoval, forceRemoval)) {
pendingRemoval = null;
forceRemoval = false;
}
}
function requestLock(worktree: GitWorktree) {
pendingLock = worktree;
lockReason = "";
}
async function confirmLock() {
if (!pendingLock) return;
if (await onLock(pendingLock, lockReason)) {
pendingLock = null;
lockReason = "";
}
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog worktree-dialog" role="dialog" aria-modal="true" aria-labelledby="worktree-dialog-title">
<header class="dialog-header worktree-dialog-header">
<div class="worktree-dialog-heading">
<span class="worktree-dialog-mark" aria-hidden="true"><HardDrive size={18} /></span>
<div>
<span class="eyebrow">Parallel workspaces</span>
<p class="dialog-title" id="worktree-dialog-title">Worktrees</p>
</div>
</div>
<div class="dialog-header-actions">
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading} title="Refresh worktrees">
<RefreshCw class={isLoading ? "spin" : undefined} size={15} aria-hidden="true" />
Refresh
</button>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</div>
</header>
<div class="worktree-summary">
<div><strong>{linkedCount}</strong><span>linked worktrees</span></div>
<div><strong>{worktrees.filter((worktree) => !worktree.clean).length}</strong><span>with changes</span></div>
<div class:attention={prunableCount > 0}><strong>{prunableCount}</strong><span>stale entries</span></div>
<button class="btn-primary" type="button" onclick={() => { createOpen = !createOpen; }} disabled={isBusy}>
<Plus size={15} aria-hidden="true" />
New worktree
</button>
</div>
{#if error}
<div class="worktree-error" role="alert"><AlertTriangle size={15} aria-hidden="true" />{error}</div>
{/if}
<div class="worktree-content">
{#if createOpen}
<form class="worktree-create-card" onsubmit={submitCreate}>
<header>
<div>
<span class="eyebrow">Create</span>
<h3>Choose what this workspace should track</h3>
</div>
<button class="btn-sm dialog-close" type="button" onclick={() => { createOpen = false; }} disabled={isBusy} aria-label="Close create form">
<X size={15} aria-hidden="true" />
</button>
</header>
<div class="worktree-mode-tabs" role="tablist" aria-label="Worktree type">
<button class:active={createMode === "existing"} type="button" role="tab" aria-selected={createMode === "existing"} onclick={() => { createMode = "existing"; }}>
<GitBranch size={14} aria-hidden="true" />Existing branch
</button>
<button class:active={createMode === "new"} type="button" role="tab" aria-selected={createMode === "new"} onclick={() => { createMode = "new"; }}>
<Plus size={14} aria-hidden="true" />New branch
</button>
<button class:active={createMode === "detached"} type="button" role="tab" aria-selected={createMode === "detached"} onclick={() => { createMode = "detached"; }}>
<CircleDot size={14} aria-hidden="true" />Detached
</button>
</div>
<div class="worktree-create-fields">
{#if createMode === "existing"}
<label>
<span>Branch</span>
<select bind:value={selectedBranch} disabled={isBusy}>
<option value="" disabled>Select a local branch</option>
{#each localBranches as branch (branch.name)}
<option value={branch.name} disabled={!branchAvailable(branch.name)}>{branch.name}{!branchAvailable(branch.name) ? " (already checked out)" : ""}</option>
{/each}
</select>
</label>
{:else if createMode === "new"}
<label>
<span>New branch name</span>
<input bind:value={newBranch} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="feature/my-change" />
</label>
<label>
<span>Start point</span>
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD, branch or commit" />
</label>
{:else}
<label>
<span>Commit or ref</span>
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD" />
</label>
{/if}
<label class="worktree-path-field">
<span>Folder</span>
<div>
<input bind:value={destination} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="Choose an empty folder" />
<button class="btn-secondary" type="button" onclick={() => chooseDestination()} disabled={isBusy}>
<FolderOpen size={15} aria-hidden="true" />Browse
</button>
</div>
</label>
</div>
<footer>
<label class="worktree-check">
<input type="checkbox" bind:checked={lockAfterCreate} disabled={isBusy} />
<span><strong>Lock after creation</strong><small>Protects removable or temporary locations from pruning.</small></span>
</label>
<button
class="btn-primary"
type="submit"
disabled={isBusy || !destination.trim() || (createMode === "existing" && (!selectedBranch || !branchAvailable(selectedBranch))) || (createMode === "new" && !newBranch.trim())}
>
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Plus size={15} aria-hidden="true" />{/if}
Create worktree
</button>
</footer>
</form>
{/if}
{#if isLoading && worktrees.length === 0}
<div class="worktree-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>Reading worktrees…</span></div>
{:else if worktrees.length === 0}
<div class="worktree-empty"><HardDrive size={24} aria-hidden="true" /><strong>No worktrees found</strong><span>Create one to work on another branch without switching this workspace.</span></div>
{:else}
<div class="worktree-list">
{#each worktrees as worktree (worktree.path)}
<article class:current={worktree.is_current} class:stale={worktree.prunable || worktree.missing} class="worktree-card">
<div class="worktree-rail" aria-hidden="true">
<span></span>
{#if !worktree.is_main}<i></i>{/if}
</div>
<div class="worktree-card-main">
<header>
<div class="worktree-name">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{displayName(worktree)}</strong>
<span>{pathName(worktree.path)}</span>
</div>
</div>
<div class="worktree-badges">
{#if worktree.is_main}<span>Main</span>{/if}
{#if worktree.is_current}<span class="active">Open</span>{/if}
{#if worktree.detached}<span>Detached</span>{/if}
{#if worktree.locked}<span class="locked"><Lock size={10} aria-hidden="true" />Locked</span>{/if}
{#if worktree.prunable || worktree.missing}<span class="danger">Stale</span>{/if}
</div>
</header>
<div class="worktree-path" title={worktree.path}><MapPin size={13} aria-hidden="true" /><code>{worktree.path}</code></div>
<div class="worktree-meta">
<span class:dirty={!worktree.clean}>
{#if worktree.clean}<Check size={12} aria-hidden="true" />Clean{:else}<CircleDot size={12} aria-hidden="true" />{worktree.changed_files} changed{/if}
</span>
{#if worktree.short_head}<span><code>{worktree.short_head}</code></span>{/if}
{#if worktree.lock_reason}<span><Lock size={12} aria-hidden="true" />{worktree.lock_reason}</span>{/if}
{#if worktree.prune_reason}<span class="danger"><AlertTriangle size={12} aria-hidden="true" />{worktree.prune_reason}</span>{/if}
</div>
<footer>
<button class="btn-secondary" type="button" onclick={() => onOpen(worktree)} disabled={isBusy || worktree.missing || worktree.bare}>
<ExternalLink size={14} aria-hidden="true" />{worktree.is_current ? "Refresh tab" : "Open tab"}
</button>
<div class="worktree-card-actions">
{#if worktree.prunable}
<button class="btn-sm" type="button" onclick={() => chooseRepairLocation(worktree)} disabled={isBusy} title="Locate and repair worktree">
<Wrench size={14} aria-hidden="true" />Repair
</button>
{/if}
{#if !worktree.is_main && !worktree.missing}
<button class="btn-sm" type="button" onclick={() => chooseMoveDestination(worktree)} disabled={isBusy || worktree.locked || worktree.is_current} title="Move worktree">
<FolderInput size={14} aria-hidden="true" />Move
</button>
{/if}
{#if worktree.locked}
<button class="btn-sm" type="button" onclick={() => onUnlock(worktree)} disabled={isBusy} title="Unlock worktree">
<Unlock size={14} aria-hidden="true" />Unlock
</button>
{:else if !worktree.is_main}
<button class="btn-sm" type="button" onclick={() => requestLock(worktree)} disabled={isBusy} title="Lock worktree">
<Lock size={14} aria-hidden="true" />Lock
</button>
{/if}
{#if !worktree.is_main}
<button class="btn-sm danger" type="button" onclick={() => requestRemoval(worktree)} disabled={isBusy || worktree.is_current || worktree.locked || worktree.missing} title={worktree.missing ? "Use Prune to remove stale metadata" : "Remove worktree"}>
<Trash2 size={14} aria-hidden="true" />Remove
</button>
{/if}
</div>
</footer>
</div>
</article>
{/each}
</div>
{/if}
</div>
<footer class="worktree-dialog-footer">
<div><ShieldCheck size={14} aria-hidden="true" /><span>Dirty, active and locked worktrees are protected.</span></div>
<button class="btn-secondary" type="button" onclick={onPrune} disabled={isBusy || prunableCount === 0}>
<Wrench size={14} aria-hidden="true" />Prune {prunableCount || ""}
</button>
</footer>
</div>
</div>
{#if pendingRemoval}
<div class="dialog-backdrop worktree-nested-backdrop" role="presentation">
<div class="dialog worktree-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="worktree-remove-title">
<header class="dialog-header">
<div>
<span class="eyebrow">Remove worktree</span>
<p class="dialog-title" id="worktree-remove-title">Remove {displayName(pendingRemoval)}?</p>
</div>
</header>
<div class="worktree-confirm-body">
<span class="discard-warning-icon" aria-hidden="true"><AlertTriangle size={21} /></span>
<div>
<p>This removes the worktree folder and its Git registration. The branch itself is kept.</p>
<code>{pendingRemoval.path}</code>
{#if !pendingRemoval.clean}
<label class="worktree-check danger">
<input type="checkbox" bind:checked={forceRemoval} disabled={isBusy} />
<span><strong>Remove despite local changes</strong><small>{pendingRemoval.changed_files} changed files may be permanently deleted.</small></span>
</label>
{/if}
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={() => { pendingRemoval = null; }} disabled={isBusy}>Cancel</button>
<button class="btn-danger" type="button" onclick={confirmRemoval} disabled={isBusy || (!pendingRemoval.clean && !forceRemoval)}>
<Trash2 size={15} aria-hidden="true" />Remove worktree
</button>
</footer>
</div>
</div>
{/if}
{#if pendingLock}
<div class="dialog-backdrop worktree-nested-backdrop" role="presentation">
<div class="dialog worktree-confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="worktree-lock-title">
<header class="dialog-header">
<div>
<span class="eyebrow">Protect worktree</span>
<p class="dialog-title" id="worktree-lock-title">Lock {displayName(pendingLock)}</p>
</div>
</header>
<div class="worktree-lock-body">
<label>
<span>Reason <small>optional</small></span>
<input bind:value={lockReason} disabled={isBusy} autocomplete="off" placeholder="External drive, long-running work…" />
</label>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy}>Cancel</button>
<button class="btn-primary" type="button" onclick={confirmLock} disabled={isBusy}><Lock size={15} aria-hidden="true" />Lock</button>
</footer>
</div>
</div>
{/if}
+64 -2
View File
@@ -2,6 +2,7 @@ import { tracedInvoke as invoke } from "./telemetry";
import type {
AiReviewResult,
AiCommitPlan,
CommitAiLocalProfile,
CommitAiProvider,
CommitAiStatus,
@@ -21,6 +22,7 @@ import type {
GitStash,
GitStatus,
GitTag,
GitWorktree,
LocalModelOption,
PatchApplyAction,
RepositoryBundle,
@@ -116,6 +118,56 @@ export function deleteBranch(path: string, branch: string, force = false): Promi
return invoke<GitStatus>("delete_branch", { path, branch, force });
}
export function listWorktrees(path: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("list_worktrees", { path });
}
export function addWorktree(
path: string,
worktreePath: string,
options: {
branch?: string;
newBranch?: string;
startPoint?: string;
detached?: boolean;
lock?: boolean;
} = {},
): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("add_worktree", {
path,
worktreePath,
branch: options.branch ?? null,
newBranch: options.newBranch ?? null,
startPoint: options.startPoint ?? null,
detached: options.detached ?? false,
lock: options.lock ?? false,
});
}
export function removeWorktree(path: string, worktreePath: string, force = false): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("remove_worktree", { path, worktreePath, force });
}
export function moveWorktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("move_worktree", { path, worktreePath, destination });
}
export function lockWorktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("lock_worktree", { path, worktreePath, reason: reason?.trim() || null });
}
export function unlockWorktree(path: string, worktreePath: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("unlock_worktree", { path, worktreePath });
}
export function pruneWorktrees(path: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("prune_worktrees", { path });
}
export function repairWorktree(path: string, worktreePath: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("repair_worktree", { path, worktreePath });
}
export function listTags(path: string): Promise<GitTag[]> {
return invoke<GitTag[]>("list_tags", { path });
}
@@ -271,6 +323,16 @@ export function commitAiReview(path: string, options: CommitAiGenerateOptions):
});
}
export function commitAiSplit(path: string, options: CommitAiGenerateOptions): Promise<AiCommitPlan> {
return invoke<AiCommitPlan>("commit_ai_split", {
path,
provider: options.provider,
model: options.model,
apiKey: options.apiKey,
baseUrl: options.baseUrl,
});
}
export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise<GitStatus> {
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null });
}
@@ -304,8 +366,8 @@ export function credDelete(key: string): Promise<void> {
return invoke<void>("cred_delete", { key });
}
export function listCommits(path: string, limit = 100): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_commits", { path, limit });
export function listCommits(path: string, limit = 100, skip = 0): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_commits", { path, limit, skip });
}
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {
+29
View File
@@ -37,6 +37,17 @@ export interface AiReviewResult {
findings: AiReviewFinding[];
}
export interface AiCommitGroup {
message: string;
reason: string;
files: string[];
}
export interface AiCommitPlan {
summary: string;
groups: AiCommitGroup[];
}
export interface LocalModelOption {
id: string;
label: string;
@@ -90,6 +101,24 @@ export interface GitBranch {
remote: boolean;
}
export interface GitWorktree {
path: string;
head: string | null;
short_head: string | null;
branch: string | null;
bare: boolean;
detached: boolean;
locked: boolean;
lock_reason: string | null;
prunable: boolean;
prune_reason: string | null;
missing: boolean;
is_main: boolean;
is_current: boolean;
clean: boolean;
changed_files: number;
}
export interface GitTag {
name: string;
hash: string;