Compare commits

...
19 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
28 changed files with 1553 additions and 925 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
+27
View File
@@ -6,6 +6,32 @@ 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
@@ -59,5 +85,6 @@ required by the package manager.
- 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
+1 -1
View File
@@ -106,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::{
+185 -3
View File
@@ -1643,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,
@@ -2796,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).
@@ -2822,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(),
],
@@ -6218,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,
@@ -7105,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());
}
}
+4 -3
View File
@@ -9,9 +9,9 @@ use git::{
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_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,
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,
@@ -169,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",
+245 -143
View File
@@ -8,36 +8,27 @@
import TitleBar from "./lib/TitleBar.svelte";
import RepoToolbar from "./lib/RepoToolbar.svelte";
import RepoTabs from "./lib/RepoTabs.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
import BlameDialog from "./lib/components/BlameDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte";
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareDialog from "./lib/components/CompareDialog.svelte";
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte";
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
import HelpOverlay from "./lib/components/HelpOverlay.svelte";
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
import InteractiveRebaseDialog from "./lib/components/InteractiveRebaseDialog.svelte";
import LinePatchDialog from "./lib/components/LinePatchDialog.svelte";
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
import ReflogDialog from "./lib/components/ReflogDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StashPanel from "./lib/components/StashPanel.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte";
import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte";
import UpdateToast from "./lib/components/UpdateToast.svelte";
import WorktreeDialog from "./lib/components/WorktreeDialog.svelte";
import {
amendCommit,
@@ -51,6 +42,7 @@
commit,
commitAiGenerate,
commitAiReview,
commitAiSplit,
commitAiLoad,
commitAiLocalModels,
commitAiStatus,
@@ -129,6 +121,7 @@
import type {
AiReviewResult,
AiCommitPlan,
AiSettings,
AppLanguage,
AppTheme,
@@ -226,7 +219,6 @@
const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2";
const EXPLORER_PANEL_COLLAPSED_KEY = "gitlite.explorerPanelCollapsed.v1";
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
const FILE_HISTORY_WIDTH_KEY = "gitlite.fileHistoryWidth.v1";
const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
const COMMIT_PANEL_MAX_HEIGHT = 640;
@@ -243,10 +235,8 @@
const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
const HISTORY_ASIDE_MIN_WIDTH = 560;
const HISTORY_ASIDE_MAX_WIDTH = 920;
const FILE_HISTORY_DEFAULT_WIDTH = 300;
const FILE_HISTORY_MIN_WIDTH = 240;
const FILE_HISTORY_MAX_WIDTH = 520;
const ERROR_AUTO_HIDE_MS = 6000;
const COMMIT_HISTORY_PAGE_SIZE = 50;
// ── State ──────────────────────────────────────────────────────────────────
@@ -276,6 +266,10 @@
let tags: GitTag[] = [];
let stashes: GitStash[] = [];
let commits: GitCommit[] = [];
let commitHistoryHasMore = false;
let commitHistoryLoadingMore = false;
let commitHistoryLoadError = "";
let commitHistoryRequestId = 0;
let repoFiles: GitRepositoryFile[] = [];
let selectedExplorerPath = "";
let selectedExplorerKind: ExplorerNodeKind = "file";
@@ -283,8 +277,11 @@
let expandedCommitHashes = new Set<string>();
let fileHistory: GitCommit[] = [];
let fileHistoryLoading = false;
let fileHistoryError = "";
let fileHistoryDialogOpen = false;
let fileHistoryRequestId = 0;
let activeFileHistoryRequestId = "";
let repoOpenRequestId = 0;
let lastFileHistoryHeadHash = "";
let commitMessage = "";
let amendMode = false;
@@ -293,6 +290,9 @@
let commitAiPhase: CommitAiPhase = "idle";
let commitAiGenerating = false;
let commitAiReviewing = false;
let commitAiSplitting = false;
let aiCommitPlan: AiCommitPlan | null = null;
let aiCommitSplitOpen = false;
let aiReviewResult: AiReviewResult | null = null;
let aiReviewOpen = false;
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
@@ -411,11 +411,6 @@
let resizingHistoryAside = false;
let historyResizeStartX = 0;
let historyResizeStartWidth = 0;
let fileHistoryWidth = loadFileHistoryWidth();
let resizingFileHistory = false;
let fileHistoryResizeStartX = 0;
let fileHistoryResizeStartWidth = 0;
let fileHistoryCollapsed = true;
let themeMediaQuery: MediaQueryList | undefined;
let appVersion = "";
@@ -747,7 +742,7 @@
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
applyStatus(nextStatus);
// Something changed — reload branches, commits and files in one bundled call.
const bundle = await openRepositoryBundle(path, 100);
const bundle = await openRepositoryBundle(path, Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length) + 1);
if (!sameRepoPath(path, activeRepoPath)) return;
const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(path, bundle.branches);
@@ -1034,6 +1029,75 @@
}
}
async function splitStagedWithAi() {
if (!activeRepoPath || commitAiSplitting || stagedCount < 2) return;
if (aiSettings.provider === "local") {
errorMessage = "Commit splitting currently requires OpenAI, Anthropic, or a custom endpoint.";
return;
}
commitAiSplitting = true;
errorMessage = "";
try {
if (aiSettings.provider === "openai") {
const cred = await credLoad("ai:openai");
aiCommitPlan = await commitAiSplit(activeRepoPath, { provider: "openai", model: aiSettings.openaiModel, apiKey: cred?.password });
} else if (aiSettings.provider === "anthropic") {
const cred = await credLoad("ai:anthropic");
aiCommitPlan = await commitAiSplit(activeRepoPath, { provider: "anthropic", model: aiSettings.anthropicModel, apiKey: cred?.password });
} else {
const cred = await credLoad("ai:custom");
aiCommitPlan = await commitAiSplit(activeRepoPath, {
provider: "custom", model: aiSettings.customModel, baseUrl: aiSettings.customBaseUrl, apiKey: cred?.password,
});
}
aiCommitSplitOpen = true;
trackEvent("ai_commit_split_planned", { provider: aiSettings.provider, groups: aiCommitPlan.groups.length });
} catch (error) {
errorMessage = errorToMessage(error);
} finally {
commitAiSplitting = false;
}
}
async function applyAiCommitPlan(plan: AiCommitPlan) {
if (!activeRepoPath || isBusy || commitAiSplitting) return;
const allFiles = plan.groups.flatMap((group) => group.files);
if (plan.groups.length < 2 || plan.groups.some((group) => !group.message.trim() || group.files.length === 0)) return;
const currentStaged = changedFiles.filter((file) => file.staged !== null).map((file) => file.path).sort();
if (currentStaged.join("\n") !== [...allFiles].sort().join("\n")) {
errorMessage = "The staged files changed after the plan was created. Generate a new split plan.";
return;
}
if (changedFiles.some((file) => file.staged !== null && file.unstaged !== null)) {
errorMessage = "A file now has both staged and unstaged changes. Stage or discard the remaining changes first.";
return;
}
commitAiSplitting = true;
await runOperation("Creating split commits", async () => {
applyStatus(await unstageFiles(activeRepoPath, currentStaged));
let completed = 0;
try {
for (const group of plan.groups) {
applyStatus(await stageFiles(activeRepoPath, group.files));
applyStatus(await commit(activeRepoPath, group.message.trim()));
completed += 1;
}
} catch (error) {
throw new Error(`${completed} of ${plan.groups.length} commits were created. Remaining changes are preserved and can be staged again. ${errorToMessage(error)}`);
}
aiCommitSplitOpen = false;
aiCommitPlan = null;
commitMessage = "";
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("ai_commit_split_applied", { groups: plan.groups.length, files: allFiles.length });
});
commitAiSplitting = false;
}
// ── Updates ────────────────────────────────────────────────────────────────
async function checkForUpdates() {
@@ -1440,28 +1504,6 @@
}
}
function clampFileHistoryWidth(value: number): number {
return Math.min(FILE_HISTORY_MAX_WIDTH, Math.max(FILE_HISTORY_MIN_WIDTH, Math.round(value)));
}
function loadFileHistoryWidth(): number {
try {
const stored = Number(localStorage.getItem(FILE_HISTORY_WIDTH_KEY));
if (Number.isFinite(stored) && stored > 0) return clampFileHistoryWidth(stored);
} catch {
// Fall through to the default below.
}
return FILE_HISTORY_DEFAULT_WIDTH;
}
function persistFileHistoryWidth(value: number) {
try {
localStorage.setItem(FILE_HISTORY_WIDTH_KEY, String(value));
} catch {
// Local storage is best-effort only; resizing must keep working without it.
}
}
function startCommitPanelResize(event: PointerEvent) {
event.preventDefault();
resizingCommitPanel = true;
@@ -1643,50 +1685,14 @@
persistHistoryAsideWidth(historyAsideWidth);
}
function startFileHistoryResize(event: PointerEvent) {
if (fileHistoryCollapsed) return;
event.preventDefault();
resizingFileHistory = true;
fileHistoryResizeStartX = event.clientX;
fileHistoryResizeStartWidth = fileHistoryWidth;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onFileHistoryResizeMove(event: PointerEvent) {
if (!resizingFileHistory) return;
fileHistoryWidth = clampFileHistoryWidth(fileHistoryResizeStartWidth + (fileHistoryResizeStartX - event.clientX));
}
function endFileHistoryResize(event: PointerEvent) {
if (!resizingFileHistory) return;
resizingFileHistory = false;
persistFileHistoryWidth(fileHistoryWidth);
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onFileHistoryResizeKeydown(event: KeyboardEvent) {
if (fileHistoryCollapsed || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) return;
event.preventDefault();
fileHistoryWidth = clampFileHistoryWidth(fileHistoryWidth + (event.key === "ArrowLeft" ? 20 : -20));
persistFileHistoryWidth(fileHistoryWidth);
}
function toggleFileHistoryCollapsed() {
fileHistoryCollapsed = !fileHistoryCollapsed;
}
function revealFileHistory() {
fileHistoryCollapsed = false;
}
function hideAndResetFileHistory() {
fileHistoryRequestId += 1;
cancelActiveFileHistoryLoad();
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
fileHistoryError = "";
fileHistory = [];
fileHistoryCollapsed = true;
fileHistoryDialogOpen = false;
}
function rememberRecentRepo(path: string) {
@@ -1750,6 +1756,10 @@
branches = [];
stashes = [];
commits = [];
commitHistoryHasMore = false;
commitHistoryLoadingMore = false;
commitHistoryLoadError = "";
commitHistoryRequestId += 1;
lastFileHistoryHeadHash = "";
repoFiles = [];
selectedExplorerPath = "";
@@ -1757,6 +1767,9 @@
expandedExplorerPaths = new Set();
expandedCommitHashes = new Set();
fileHistory = [];
fileHistoryLoading = false;
fileHistoryError = "";
fileHistoryDialogOpen = false;
compareFrom = "";
compareTo = "";
comparison = null;
@@ -1869,7 +1882,19 @@
}
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
commits = prefetched ?? (await listCommits(path, 100));
const targetLimit = prefetched
? Math.max(COMMIT_HISTORY_PAGE_SIZE, prefetched.length - 1)
: Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length);
const requestId = ++commitHistoryRequestId;
commitHistoryLoadingMore = false;
commitHistoryLoadError = "";
const history = prefetched ?? (await listCommits(path, targetLimit + 1, 0));
if (requestId !== commitHistoryRequestId || !sameRepoPath(path, activeRepoPath)) return;
commits = history.slice(0, targetLimit);
commitHistoryHasMore = history.length > targetLimit;
commitHistoryLoadingMore = false;
commitHistoryLoadError = "";
lastFileHistoryHeadHash = commits[0]?.hash ?? "";
const hashes = new Set(commits.map((c) => c.hash));
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
@@ -1882,6 +1907,33 @@
}
}
async function loadMoreCommitHistory() {
if (!activeRepoPath || commitHistoryLoadingMore || !commitHistoryHasMore || isBusy) return;
const path = activeRepoPath;
const offset = commits.length;
const requestId = ++commitHistoryRequestId;
commitHistoryLoadingMore = true;
commitHistoryLoadError = "";
try {
const history = await listCommits(path, COMMIT_HISTORY_PAGE_SIZE + 1, offset);
if (requestId !== commitHistoryRequestId || !sameRepoPath(path, activeRepoPath)) return;
const knownHashes = new Set(commits.map((commit) => commit.hash));
const nextPage = history
.slice(0, COMMIT_HISTORY_PAGE_SIZE)
.filter((commit) => !knownHashes.has(commit.hash));
commits = [...commits, ...nextPage];
commitHistoryHasMore = history.length > COMMIT_HISTORY_PAGE_SIZE;
} catch (error) {
if (requestId !== commitHistoryRequestId || !sameRepoPath(path, activeRepoPath)) return;
commitHistoryLoadError = errorToMessage(error);
} finally {
if (requestId === commitHistoryRequestId) commitHistoryLoadingMore = false;
}
}
async function refreshExplorerFiles(path = activeRepoPath, prefetched?: GitRepositoryFile[]) {
repoFiles = prefetched ?? (await listRepositoryFiles(path));
const folderPaths = allExplorerFolderPaths(repoFiles);
@@ -1889,10 +1941,7 @@
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
selectedExplorerPath = "";
selectedExplorerKind = "file";
cancelActiveFileHistoryLoad();
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
fileHistory = [];
hideAndResetFileHistory();
}
}
@@ -1901,7 +1950,7 @@
await refreshBranchList(path);
await refreshTags(path);
await refreshCommitHistory(path);
if (lastFileHistoryHeadHash !== previousHeadHash && !fileHistoryCollapsed) {
if (lastFileHistoryHeadHash !== previousHeadHash && fileHistoryDialogOpen) {
await refreshFileHistory(path);
}
}
@@ -1916,7 +1965,9 @@
void cancelFileHistory(requestId).catch(() => {});
}
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath, force = false) {
if (!force && !fileHistoryDialogOpen && !globalSearchOpen) return;
const requestId = ++fileHistoryRequestId;
cancelActiveFileHistoryLoad();
@@ -1930,6 +1981,7 @@
const historyRequestId = `file-history-${requestId}-${Date.now()}`;
activeFileHistoryRequestId = historyRequestId;
fileHistoryLoading = true;
fileHistoryError = "";
fileHistory = [];
try {
@@ -1941,7 +1993,10 @@
const message = errorToMessage(error);
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
fileHistory = [];
if (!isCancellationMessage(message)) errorMessage = message;
if (!isCancellationMessage(message)) {
fileHistoryError = message;
if (!fileHistoryDialogOpen) errorMessage = message;
}
}
} finally {
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
@@ -1956,6 +2011,7 @@
async function openRepo(pathOverride?: string) {
const path = (pathOverride ?? repoPath).trim();
if (!path) { errorMessage = "Enter a repository path."; return; }
const requestId = ++repoOpenRequestId;
repoPath = path;
await runOperation("Opening repository", async () => {
@@ -1966,7 +2022,8 @@
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
// Single backend round-trip: resolves the repo and reads status, branches,
// commits and files in one pass instead of four sequential git calls.
const bundle = await openRepositoryBundle(path, 100);
const bundle = await openRepositoryBundle(path, COMMIT_HISTORY_PAGE_SIZE + 1);
if (requestId !== repoOpenRequestId) return;
resetRepositoryState(false);
applyStatus(bundle.status);
if (globalSearchBusy) void cancelGlobalSearch();
@@ -2039,7 +2096,7 @@
directoryName || undefined,
username,
password,
100,
COMMIT_HISTORY_PAGE_SIZE + 1,
);
resetRepositoryState(false);
applyStatus(bundle.status);
@@ -2160,8 +2217,18 @@
if (!wasActive) return;
if (next) {
// Switch identity immediately. Otherwise a status/fetch request for the
// just-closed repository can still finish while it remains active and
// reinsert its tab through applyStatus().
repoOpenRequestId += 1;
activeRepoPath = next.path;
repoPath = next.path;
status = null;
lastStatusFingerprint = "";
resetRepositoryState(false);
await openRepo(next.path);
} else {
repoOpenRequestId += 1;
resetRepositoryState(true);
activeView = "management";
}
@@ -3591,19 +3658,37 @@
return folders;
}
// Loads history for a selected explorer node without blocking the rest of the UI
// (isBusy/runOperation would disable every button in the app while this awaits).
// A request id guards against a slower, stale request overwriting a newer selection.
// Loads history without blocking the rest of the UI. A request id guards
// against a slower, stale request overwriting a newer file selection.
async function loadSelectedFileHistory(path: string, repo = activeRepoPath) {
await refreshFileHistory(repo, path);
await refreshFileHistory(repo, path, true);
}
function openFileHistoryDialog(node: ExplorerNode) {
if (!activeRepoPath || node.kind !== "file" || !node.tracked) return;
selectedExplorerPath = node.path;
selectedExplorerKind = "file";
fileHistoryDialogOpen = true;
fileHistoryError = "";
void loadSelectedFileHistory(node.path);
trackEvent("file_history_opened", {
source: "explorer_context_menu",
});
}
function closeFileHistoryDialog() {
fileHistoryRequestId += 1;
cancelActiveFileHistoryLoad();
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
fileHistoryError = "";
fileHistoryDialogOpen = false;
}
async function selectExplorerNode(node: ExplorerNode) {
if (!activeRepoPath) return;
selectedExplorerPath = node.path;
selectedExplorerKind = node.kind;
revealFileHistory();
void loadSelectedFileHistory(node.path);
trackEvent("explorer_node_selected", {
kind: node.kind,
tracked: node.tracked ? 1 : 0,
@@ -3615,7 +3700,6 @@
selectedExplorerPath = file.path;
selectedExplorerKind = "file";
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
revealFileHistory();
void loadSelectedFileHistory(file.path);
trackEvent("explorer_file_selected", {
@@ -3640,8 +3724,6 @@
if (!activeRepoPath || node.kind !== "file") return;
selectedExplorerPath = node.path;
selectedExplorerKind = "file";
revealFileHistory();
void loadSelectedFileHistory(node.path);
try {
await openRepositoryFile(activeRepoPath, node.path);
@@ -3885,9 +3967,10 @@
helpOpen = false;
return;
}
if (event.key === "Escape" && repoTabContextMenu) closeRepoTabContextMenu();
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && fileHistoryDialogOpen && !isBusy) closeFileHistoryDialog();
else if (event.key === "Escape" && repoTabContextMenu) closeRepoTabContextMenu();
else if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm();
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
@@ -4247,7 +4330,7 @@
<section
class="workspace"
aria-label="Git workspace"
style="--left-sidebar-width: {leftSidebarWidth}px; --history-aside-width: {historyAsideWidth}px; --file-history-width: {fileHistoryWidth}px;"
style="--left-sidebar-width: {leftSidebarWidth}px; --history-aside-width: {historyAsideWidth}px;"
>
<!-- Left sidebar: branches + explorer -->
@@ -4350,6 +4433,7 @@
onCollapseAllFolders={collapseAllExplorerFolders}
onSelectNode={selectExplorerNode}
onOpenFile={openFileFromExplorer}
onFileHistory={openFileHistoryDialog}
onBlame={openBlame}
collapsed={explorerPanelCollapsed}
onToggleCollapsed={toggleExplorerPanelCollapsed}
@@ -4440,12 +4524,14 @@
{commitAiPhase}
{commitAiGenerating}
{commitAiReviewing}
{commitAiSplitting}
{canAmend}
{amendMode}
onCommit={commitChanges}
onCommitMessageChange={updateCommitMessage}
onGenerateCommitMessage={generateCommitMessageWithAi}
onReviewStaged={reviewStagedWithAi}
onSplitStaged={splitStagedWithAi}
onOpenAiSettings={() => { aiSettingsOpen = true; }}
onToggleAmend={toggleAmendMode}
onUndoLastCommit={undoLastCommitChange}
@@ -4472,8 +4558,8 @@
onkeydown={onHistoryAsideResizeKeydown}
></div>
<!-- Right sidebar: commit graph + file history -->
<aside class="history-aside" class:file-history-collapsed={fileHistoryCollapsed} aria-label="Commit history">
<!-- Right sidebar: commit graph -->
<aside class="history-aside" aria-label="Commit history">
<HistoryPanel
{commits}
{localBranchNames}
@@ -4482,7 +4568,11 @@
repositoryKey={activeRepoPath}
{hasRepository}
{isBusy}
hasMore={commitHistoryHasMore}
isLoadingMore={commitHistoryLoadingMore}
loadMoreError={commitHistoryLoadError}
{expandedCommitHashes}
onLoadMore={loadMoreCommitHistory}
onRestoreCommit={restoreCommit}
onPreviewCommitFile={previewCommitFileFromHistory}
onCreateBranchFromCommit={openNewBranchDialog}
@@ -4494,36 +4584,6 @@
expandedCommitHashes = next;
}}
/>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="file-history-resize-handle"
class:resizing={resizingFileHistory}
role="separator"
aria-orientation="vertical"
aria-label="Resize file history width"
aria-valuenow={fileHistoryWidth}
aria-valuemin={FILE_HISTORY_MIN_WIDTH}
aria-valuemax={FILE_HISTORY_MAX_WIDTH}
tabindex={fileHistoryCollapsed ? -1 : 0}
onpointerdown={startFileHistoryResize}
onpointermove={onFileHistoryResizeMove}
onpointerup={endFileHistoryResize}
onpointercancel={endFileHistoryResize}
onkeydown={onFileHistoryResizeKeydown}
></div>
<FileHistoryPanel
{fileHistory}
{selectedExplorerPath}
selectedExplorerLabel={selectedExplorerPath ? `${selectedExplorerKind === "folder" ? "Folder" : "File"} history` : "File history"}
{hasRepository}
{isBusy}
isLoading={fileHistoryLoading}
onDiff={diffSelectedFileFromCommit}
onRestore={restoreSelectedFileFromCommit}
collapsed={fileHistoryCollapsed}
onToggleCollapsed={toggleFileHistoryCollapsed}
/>
</aside>
</section>
{/if}
@@ -4575,7 +4635,9 @@
{/if}
{#if helpOpen}
<HelpOverlay language={appLanguage} onClose={() => { helpOpen = false; }} />
{#await import("./lib/components/HelpOverlay.svelte") then module}
<module.default language={appLanguage} onClose={() => { helpOpen = false; }} />
{/await}
{/if}
{#if aiReviewOpen && aiReviewResult}
@@ -4588,8 +4650,18 @@
/>
{/if}
{#if aiCommitSplitOpen && aiCommitPlan}
<AiCommitSplitDialog
plan={aiCommitPlan}
isApplying={commitAiSplitting}
onApply={applyAiCommitPlan}
onClose={() => { if (!commitAiSplitting) aiCommitSplitOpen = false; }}
/>
{/if}
{#if linePatchOpen && linePatchFile}
<LinePatchDialog
{#await import("./lib/components/LinePatchDialog.svelte") then module}
<module.default
file={linePatchFile}
staged={linePatchStaged}
patch={linePatchText}
@@ -4600,10 +4672,12 @@
onRefresh={refreshLinePatch}
onApply={applyLinePatch}
/>
{/await}
{/if}
{#if blameOpen}
<BlameDialog
{#await import("./lib/components/BlameDialog.svelte") then module}
<module.default
filePath={blameFilePath}
lines={blameLines}
{isBusy}
@@ -4611,6 +4685,7 @@
error={blameError}
onClose={closeBlame}
/>
{/await}
{/if}
{#if pendingDiscard}
@@ -4624,8 +4699,24 @@
/>
{/if}
{#if fileHistoryDialogOpen}
{#await import("./lib/components/FileHistoryDialog.svelte") then module}
<module.default
{fileHistory}
filePath={selectedExplorerPath}
{isBusy}
isLoading={fileHistoryLoading}
error={fileHistoryError}
onDiff={diffSelectedFileFromCommit}
onRestore={restoreSelectedFileFromCommit}
onClose={closeFileHistoryDialog}
/>
{/await}
{/if}
{#if globalSearchOpen}
<GlobalSearchDialog
{#await import("./lib/components/GlobalSearchDialog.svelte") then module}
<module.default
{hasRepository}
{isBusy}
isSearching={globalSearchBusy}
@@ -4642,10 +4733,12 @@
onFileHistoryDiff={diffSelectedFileFromCommit}
onFileHistoryRestore={restoreSelectedFileFromCommit}
/>
{/await}
{/if}
{#if worktreeDialogOpen}
<WorktreeDialog
{#await import("./lib/components/WorktreeDialog.svelte") then module}
<module.default
{worktrees}
{branches}
initialBranch={worktreeInitialBranch}
@@ -4663,6 +4756,7 @@
onRepair={repairSelectedWorktree}
onClose={closeWorktreeDialog}
/>
{/await}
{/if}
<!-- Create a branch from a specific commit in the history -->
@@ -4698,17 +4792,20 @@
<!-- Choose the AI provider/model used to generate commit messages -->
{#if aiSettingsOpen}
<AiSettingsDialog
{#await import("./lib/components/AiSettingsDialog.svelte") then module}
<module.default
settings={aiSettings}
localModels={localModelOptions}
onSave={saveAiSettings}
onClose={() => { aiSettingsOpen = false; }}
/>
{/await}
{/if}
<!-- Compare: pick the two commits to diff -->
{#if interactiveRebaseOpen}
<InteractiveRebaseDialog
{#await import("./lib/components/InteractiveRebaseDialog.svelte") then module}
<module.default
{branches}
currentBranch={status?.current_branch ?? ""}
base={interactiveRebaseBase}
@@ -4721,6 +4818,7 @@
onStart={runInteractiveRebase}
onClose={() => { if (!isBusy) interactiveRebaseOpen = false; }}
/>
{/await}
{/if}
{#if reflogOpen}
@@ -4755,7 +4853,8 @@
<!-- Compare diff dialog (rendered last so it overlays the search dialog when opened from a hit) -->
{#if compareDialogOpen && comparison}
<CompareDialog
{#await import("./lib/components/CompareDialog.svelte") then module}
<module.default
{comparison}
{selectedDiffPath}
{isBusy}
@@ -4765,6 +4864,7 @@
onRestore={restorePreviewedCommitFile}
onSelectFile={selectDiffFile}
/>
{/await}
{/if}
<!-- Credential dialog for push/pull -->
@@ -4819,7 +4919,8 @@
<!-- Conflict resolve dialog -->
{#if resolveDialogOpen}
<ResolveDialog
{#await import("./lib/components/ResolveDialog.svelte") then module}
<module.default
{conflictedFiles}
{conflictTarget}
{conflict}
@@ -4831,4 +4932,5 @@
onMarkResolved={handleMarkResolved}
onApply={applyPreparedResolutions}
/>
{/await}
{/if}
+196 -253
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);
@@ -2635,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 {
@@ -2861,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);
@@ -3091,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));
@@ -5672,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 {
@@ -5743,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 {
@@ -5753,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;
@@ -5808,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);
}
@@ -6106,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);
@@ -6147,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;
}
@@ -6209,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);
@@ -6262,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 {
+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">
+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 {
+50 -20
View File
@@ -647,20 +647,20 @@
{
id: "app-package-updates",
title: "Gitty unter Arch Linux installieren und aktualisieren",
summary: "Ein Pacman-Repository auf der CDN-Seite ermöglicht signierte, versionierte Updates. Alternativ lässt sich eine heruntergeladene .pkg.tar.zst-Datei direkt installieren.",
summary: "Das AUR-Paket gitty-desktop lädt das öffentliche Gitea-Release und baut Gitty lokal aus dem Quellcode.",
commands: [
{ command: "sudo pacman --config /pfad/gitty-pacman.conf -Syu gitty", description: "Repository-Konfiguration für genau diesen Aufruf verwenden und Gitty aktualisieren" },
{ command: "sudo pacman -U /pfad/gitty-<version>-x86_64.pkg.tar.zst", description: "Ein einzelnes lokales oder heruntergeladenes Paket installieren" },
{ command: "pacman -Qi gitty", description: "Installierte Version und Paketinformationen anzeigen" },
{ command: "pacman -Si gitty", description: "Im konfigurierten Repository verfügbare Version anzeigen" },
{ 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: [
"Für normale Systemupdates trägst du das Gitty-Repository einmal dauerhaft in /etc/pacman.conf ein.",
"Wenn du die Systemdatei nicht ändern möchtest, gib --config bei jedem Pacman-Aufruf erneut an oder verwende ein eigenes Wrapper-Skript.",
"Die Build-Pipeline erzeugt das Paket aus dem PKGBUILD, aktualisiert die Repository-Datenbank und veröffentlicht beides auf dem CDN.",
"Pacman installiert nur dann eine neue Version, wenn Paketversion und Repository-Datenbank entsprechend aktualisiert wurden.",
"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: "--config gilt nur für den aktuellen Pacman-Prozess. Ein späteres sudo pacman -Syu erinnert sich nicht automatisch an den zuvor angegebenen Pfad.",
note: "AUR-Pakete werden von Nutzern gepflegt. Prüfe PKGBUILD und .SRCINFO vor der Installation, besonders nach größeren Änderungen.",
},
);
@@ -1090,20 +1090,20 @@
{
id: "app-package-updates",
title: "Install and update Gitty on Arch Linux",
summary: "A Pacman repository hosted on the CDN provides versioned updates. Alternatively, install a downloaded .pkg.tar.zst package directly.",
summary: "The gitty-desktop AUR package downloads the public Gitea release and builds Gitty locally from source.",
commands: [
{ command: "sudo pacman --config /path/gitty-pacman.conf -Syu gitty", description: "Use the custom repository configuration for this invocation and update Gitty" },
{ command: "sudo pacman -U /path/gitty-<version>-x86_64.pkg.tar.zst", description: "Install one local or downloaded package file" },
{ command: "pacman -Qi gitty", description: "Show the installed version and package information" },
{ command: "pacman -Si gitty", description: "Show the version available from configured repositories" },
{ 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: [
"For regular system upgrades, add the Gitty repository to /etc/pacman.conf once.",
"If you do not want to change the system file, pass --config on every Pacman invocation or create a dedicated wrapper script.",
"The build pipeline creates the package from PKGBUILD, updates the repository database, and publishes both to the CDN.",
"Pacman offers an update only after both the package version and repository database have been updated.",
"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: "--config applies only to the current Pacman process. A later sudo pacman -Syu does not remember a previously supplied configuration path.",
note: "AUR packages are user-maintained. Review PKGBUILD and .SRCINFO before installing, especially after major changes.",
},
);
@@ -1361,6 +1361,21 @@
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",
@@ -1396,6 +1411,21 @@
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",
+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}
+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`}>
+13 -2
View File
@@ -2,6 +2,7 @@ import { tracedInvoke as invoke } from "./telemetry";
import type {
AiReviewResult,
AiCommitPlan,
CommitAiLocalProfile,
CommitAiProvider,
CommitAiStatus,
@@ -322,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 });
}
@@ -355,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> {
+11
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;