Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f621638eb3 | ||
|
|
56467f481d | ||
|
|
6434a9f10c | ||
|
|
cc0f1c076f | ||
|
|
b6b9964a93 | ||
|
|
f43fe00873 | ||
|
|
ca14fac90c | ||
|
|
608b3131d2 | ||
|
|
a823aabbb9 | ||
|
|
15d1f2bfd6 | ||
|
|
3eb554fee7 | ||
|
|
1fa57eea6f | ||
|
|
444a7acadd | ||
|
|
ac7cacd687 | ||
|
|
80dc66366d | ||
|
|
a6c86daf62 | ||
|
|
f88b6961d1 | ||
|
|
eb65c1def7 | ||
|
|
96e3805568 | ||
|
|
2d9a10e85c | ||
|
|
12d1b2657f | ||
|
|
b0b4fb69cb | ||
|
|
f3a3fcc079 | ||
|
|
9d18f3af61 | ||
|
|
c4a922894f | ||
|
|
41f474f1eb | ||
|
|
6dd70ec52a | ||
|
|
90070697dd | ||
|
|
50524c2759 | ||
|
|
0cd97db523 |
@@ -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
|
||||
|
||||
+79
-3
@@ -2,9 +2,82 @@
|
||||
|
||||
All notable user-facing changes to Gitty are documented in this file.
|
||||
|
||||
The project uses calendar versions. Displayed release names use `YYYY.MM.DD`;
|
||||
package metadata uses the equivalent numeric form without leading zeroes where
|
||||
required by the package manager.
|
||||
The project uses calendar-style versions in the form `YYYY.M.PATCH`.
|
||||
|
||||
## [2026.8.3] - 2026-08-13
|
||||
|
||||
### Added
|
||||
|
||||
- Configurable external tools for editors, diff viewers, merge tools,
|
||||
terminals, and file managers, including automatic cross-platform discovery
|
||||
and presets for VS Code, JetBrains IDEs, Beyond Compare, and other common
|
||||
applications.
|
||||
- Repository and file actions for opening content in the configured external
|
||||
application. Supported tools open in a separate window.
|
||||
- A choice between Gitty's internal diff/merge views and the configured
|
||||
external applications.
|
||||
- Git Notes support for attaching editable notes to commits without rewriting
|
||||
commit history, including fetch and push synchronization.
|
||||
- A command palette for quickly opening repository actions, files, and commits.
|
||||
- Complete branch-to-branch comparisons for local and remote branches. The
|
||||
comparison dialog shows every changed file and its side-by-side diff.
|
||||
- Safe remote branch renaming from the branch context menu.
|
||||
|
||||
### Changed
|
||||
|
||||
- Redesigned the settings window with tool categories, detected applications,
|
||||
preset dropdowns, and clearer explanations of where each tool is used.
|
||||
- Redesigned the history graph's branch presentation with compact labels,
|
||||
hover details, cleaner flag connectors, and branch visibility controls.
|
||||
- Reduced the minimum width of the commit history panel so the workspace can
|
||||
be resized more freely.
|
||||
- Local-only branches are now identified consistently in the toolbar,
|
||||
repository summary, status bar, and commit graph. Their first push is labeled
|
||||
Publish and configures the remote tracking branch automatically.
|
||||
- Git operations now run asynchronously to keep the application responsive
|
||||
during slower repository commands.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Closing supported external tools no longer reports their documented
|
||||
comparison result codes as application errors.
|
||||
- External tools that otherwise reuse an existing process are explicitly
|
||||
opened in a new window where supported.
|
||||
- Remote branch renaming uses an atomic push with lease checks, preventing an
|
||||
existing destination branch or a newly changed remote branch from being
|
||||
overwritten.
|
||||
|
||||
## [2026.8.2] - 2026-08-10
|
||||
|
||||
### Changed
|
||||
|
||||
- History graph colors remain stable across parent lanes, making longer and
|
||||
branching histories easier to follow.
|
||||
- Release artifacts are published to the matching Gitea release automatically
|
||||
without creating duplicate assets.
|
||||
- Application shutdown now completes telemetry cleanup more reliably.
|
||||
|
||||
## [2026.8.1] - 2026-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- Paginated commit history that loads older commits on demand instead of
|
||||
limiting the visible repository history to the initial page.
|
||||
- A dedicated file-history dialog opened from the explorer context menu.
|
||||
- Windows and Ubuntu release publishing plus improved AUR packaging workflows.
|
||||
|
||||
### Changed
|
||||
|
||||
- File history moved out of the permanent workspace panel into a focused,
|
||||
larger dialog.
|
||||
- Dialogs close more consistently with the Escape key.
|
||||
- Arch Linux installation documentation now uses the `gitty-desktop` AUR
|
||||
package.
|
||||
|
||||
### Fixed
|
||||
|
||||
- AUR SSH setup, package installation timeouts, and clone/push retries are more
|
||||
robust in the release workflow.
|
||||
|
||||
## [2026.07.22] - 2026-07-22
|
||||
|
||||
@@ -88,3 +161,6 @@ required by the package manager.
|
||||
[2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22
|
||||
[2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21
|
||||
[2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20
|
||||
[2026.8.3]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.3
|
||||
[2026.8.2]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.2
|
||||
[2026.8.1]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.1
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# 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
|
||||
pkgname=gitty-desktop
|
||||
pkgver=2026.7.22
|
||||
pkgrel=1
|
||||
pkgdesc="A lightweight, modern Git client built with Tauri"
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,11 +1,15 @@
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, List, NamedTuple, Optional
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import quote, urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
@@ -329,6 +333,65 @@ def _publish_target(
|
||||
)
|
||||
|
||||
|
||||
def _gitea_request(url: str, token: str, data: Optional[bytes] = None, **headers: str):
|
||||
request = Request(url, data=data, headers={"Authorization": f"token {token}", **headers})
|
||||
return urlopen(request, timeout=120)
|
||||
|
||||
|
||||
def _upload_gitea_release_asset(version: str, artifact: Path) -> None:
|
||||
"""Attach the installer to the Gitea release that triggered this build."""
|
||||
api_url = os.environ.get("GITEA_API", "").rstrip("/")
|
||||
owner = os.environ.get("OWNER", "")
|
||||
repo = os.environ.get("REPO", "")
|
||||
token = os.environ.get("GITEA_TOKEN") or os.environ.get("GITEA_FALLBACK_TOKEN")
|
||||
if not all((api_url, owner, repo, token)):
|
||||
raise ConfigurationError(
|
||||
"GITEA_API, OWNER, REPO and GITEA_TOKEN are required to publish "
|
||||
"the release asset."
|
||||
)
|
||||
|
||||
release_url = (
|
||||
f"{api_url}/repos/{quote(owner, safe='')}/{quote(repo, safe='')}"
|
||||
f"/releases/tags/{quote(version, safe='')}"
|
||||
)
|
||||
with _gitea_request(release_url, token) as response:
|
||||
release = json.load(response)
|
||||
|
||||
existing_names = {
|
||||
asset.get("name") for asset in release.get("assets", []) if isinstance(asset, dict)
|
||||
}
|
||||
if artifact.name in existing_names:
|
||||
print(f"Release asset {artifact.name} already exists; skipping upload.")
|
||||
return
|
||||
|
||||
boundary = f"----gitty-{uuid.uuid4().hex}"
|
||||
content_type = mimetypes.guess_type(artifact.name)[0] or "application/octet-stream"
|
||||
body = b"".join(
|
||||
(
|
||||
f"--{boundary}\r\n".encode(),
|
||||
(
|
||||
f'Content-Disposition: form-data; name="attachment"; '
|
||||
f'filename="{artifact.name}"\r\n'
|
||||
).encode(),
|
||||
f"Content-Type: {content_type}\r\n\r\n".encode(),
|
||||
artifact.read_bytes(),
|
||||
f"\r\n--{boundary}--\r\n".encode(),
|
||||
)
|
||||
)
|
||||
upload_url = (
|
||||
f"{api_url}/repos/{quote(owner, safe='')}/{quote(repo, safe='')}"
|
||||
f"/releases/{release['id']}/assets?{urlencode({'name': artifact.name})}"
|
||||
)
|
||||
with _gitea_request(
|
||||
upload_url,
|
||||
token,
|
||||
data=body,
|
||||
**{"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
||||
):
|
||||
pass
|
||||
print(f"Attached {artifact.name} to Gitea release {version}.")
|
||||
|
||||
|
||||
def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
||||
resolved_version = _resolve_version(version)
|
||||
artifact_base_url = os.environ.get("ARTIFACT_BASE_URL", "")
|
||||
@@ -385,6 +448,8 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
||||
artifact_base_url=artifact_base_url,
|
||||
)
|
||||
|
||||
_upload_gitea_release_asset(resolved_version, primary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
|
||||
@@ -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>`
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.7.22",
|
||||
"version": "2026.8.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitty",
|
||||
"version": "2026.7.22",
|
||||
"version": "2026.8.3",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.7.22",
|
||||
"version": "2026.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-destroy",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:allow-open",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+935
-173
File diff suppressed because it is too large
Load Diff
+27
-11
@@ -1,29 +1,35 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod badge;
|
||||
mod external_tools;
|
||||
mod git;
|
||||
mod telemetry;
|
||||
|
||||
use badge::set_sync_badge;
|
||||
use external_tools::{
|
||||
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
|
||||
};
|
||||
use git::{
|
||||
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
|
||||
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
|
||||
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
|
||||
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
|
||||
cred_delete, cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
|
||||
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
|
||||
get_status, init_repository, last_commit_message, list_branches, list_commits,
|
||||
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
|
||||
list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort,
|
||||
merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository,
|
||||
open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag,
|
||||
read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree,
|
||||
rename_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
|
||||
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
|
||||
delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes, get_commit_note,
|
||||
get_file_blame, get_file_patch, get_remote_url, get_status, init_repository,
|
||||
last_commit_message, list_branches, list_commits, list_file_history,
|
||||
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
|
||||
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
|
||||
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
|
||||
open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
|
||||
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
|
||||
rename_remote_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
|
||||
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
|
||||
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
||||
set_branch_upstream, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop,
|
||||
stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote,
|
||||
set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
|
||||
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
|
||||
update_remote,
|
||||
};
|
||||
use tauri::Manager;
|
||||
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
||||
@@ -123,6 +129,10 @@ async fn main() {
|
||||
clone_repository,
|
||||
open_repo_in_explorer,
|
||||
open_repository_file,
|
||||
detect_external_tools,
|
||||
launch_external_tool,
|
||||
launch_external_diff,
|
||||
launch_external_merge,
|
||||
get_status,
|
||||
list_branches,
|
||||
list_remotes,
|
||||
@@ -135,6 +145,7 @@ async fn main() {
|
||||
checkout_branch,
|
||||
create_branch,
|
||||
rename_branch,
|
||||
rename_remote_branch,
|
||||
delete_branch,
|
||||
list_worktrees,
|
||||
add_worktree,
|
||||
@@ -174,6 +185,11 @@ async fn main() {
|
||||
push,
|
||||
fetch,
|
||||
list_commits,
|
||||
get_commit_note,
|
||||
set_commit_note,
|
||||
delete_commit_note,
|
||||
fetch_commit_notes,
|
||||
push_commit_notes,
|
||||
restore_to_commit,
|
||||
restore_file_from_commit,
|
||||
merge_branch,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gitty",
|
||||
"version": "2026.7.22",
|
||||
"version": "2026.8.3",
|
||||
"identifier": "com.gitty",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+949
-441
File diff suppressed because it is too large
Load Diff
+751
-386
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ChevronDown,
|
||||
Code2,
|
||||
CloudDownload,
|
||||
CloudOff,
|
||||
Download,
|
||||
FolderOpen,
|
||||
GitCompare,
|
||||
@@ -12,6 +14,7 @@
|
||||
Search,
|
||||
Upload,
|
||||
Settings2,
|
||||
Terminal,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
export let hasRepository: boolean = false;
|
||||
@@ -19,7 +22,11 @@
|
||||
export let operation: string = "";
|
||||
export let ahead: number = 0;
|
||||
export let behind: number = 0;
|
||||
export let localOnly: boolean = false;
|
||||
export let language: "en" | "de" = "en";
|
||||
export let editorName: string = "Editor";
|
||||
export let terminalName: string = "Terminal";
|
||||
export let fileManagerName: string = "Explorer";
|
||||
export let onFetch: () => void = () => {};
|
||||
export let onPull: () => void = () => {};
|
||||
export let onPush: () => void = () => {};
|
||||
@@ -29,6 +36,8 @@
|
||||
export let onInteractiveRebase: () => void = () => {};
|
||||
export let onReflog: () => void = () => {};
|
||||
export let onOpenInExplorer: () => void = () => {};
|
||||
export let onOpenInEditor: () => void = () => {};
|
||||
export let onOpenTerminal: () => void = () => {};
|
||||
export let onFetchPrune: () => void = () => {};
|
||||
export let onForcePush: () => void = () => {};
|
||||
export let onSyncOptions: () => void = () => {};
|
||||
@@ -38,6 +47,12 @@
|
||||
let toolbarElement: HTMLDivElement;
|
||||
|
||||
$: isGerman = language === "de";
|
||||
$: pushLabel = localOnly ? (isGerman ? "Veröffentlichen" : "Publish") : "Push";
|
||||
$: pushTitle = localOnly
|
||||
? (isGerman
|
||||
? "Dieser Branch existiert nur lokal. Veröffentlichen erstellt den Remote-Branch und richtet das Tracking ein."
|
||||
: "This branch exists only locally. Publish creates the remote branch and configures tracking.")
|
||||
: "Push";
|
||||
|
||||
function runHistoryAction(action: () => void) {
|
||||
historyOpen = false;
|
||||
@@ -96,10 +111,13 @@
|
||||
|
||||
<button
|
||||
class="repo-action sync-primary"
|
||||
class:publish-local={localOnly}
|
||||
onclick={onPush}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Push"
|
||||
aria-label={ahead > 0
|
||||
title={pushTitle}
|
||||
aria-label={localOnly
|
||||
? pushTitle
|
||||
: ahead > 0
|
||||
? `Push, ${ahead} ${isGerman ? (ahead === 1 ? "lokaler Commit voraus" : "lokale Commits voraus") : (ahead === 1 ? "commit ahead" : "commits ahead")}`
|
||||
: "Push"}
|
||||
>
|
||||
@@ -108,8 +126,12 @@
|
||||
{:else}
|
||||
<Upload size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="repo-action-label">Push</span>
|
||||
{#if ahead > 0}<span class="repo-action-count ahead">↑{ahead}</span>{/if}
|
||||
<span class="repo-action-label">{pushLabel}</span>
|
||||
{#if localOnly}
|
||||
<span class="repo-action-local-marker"><CloudOff size={9} aria-hidden="true" />{isGerman ? "NUR LOKAL" : "LOCAL"}</span>
|
||||
{:else if ahead > 0}
|
||||
<span class="repo-action-count ahead">↑{ahead}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<div class="repo-history-wrap">
|
||||
<button class="repo-action" type="button" onclick={() => { syncOpen = !syncOpen; historyOpen = false; }} disabled={!hasRepository || isBusy} aria-label={isGerman ? "Sync-Optionen" : "Sync options"} aria-haspopup="menu">
|
||||
@@ -144,8 +166,8 @@
|
||||
class="repo-action"
|
||||
onclick={onCompare}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title={isGerman ? "Commits vergleichen" : "Compare commits"}
|
||||
aria-label={isGerman ? "Commits vergleichen" : "Compare commits"}
|
||||
title={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
|
||||
aria-label={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
|
||||
>
|
||||
<GitCompare size={15} aria-hidden="true" />
|
||||
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
|
||||
@@ -192,15 +214,23 @@
|
||||
<div class="repo-toolbar-divider utility" aria-hidden="true"></div>
|
||||
|
||||
<div class="repo-action-group repo-utility-actions">
|
||||
<button class="repo-action" onclick={onOpenInEditor} disabled={!hasRepository || isBusy} title={isGerman ? `Repository in ${editorName} öffnen` : `Open repository in ${editorName}`}>
|
||||
<Code2 size={15} aria-hidden="true" />
|
||||
<span class="repo-action-label utility-label">{editorName}</span>
|
||||
</button>
|
||||
<button class="repo-action" onclick={onOpenTerminal} disabled={!hasRepository || isBusy} title={isGerman ? `${terminalName} im Repository öffnen` : `Open ${terminalName} in repository`}>
|
||||
<Terminal size={15} aria-hidden="true" />
|
||||
<span class="repo-action-label utility-label">{terminalName}</span>
|
||||
</button>
|
||||
<button
|
||||
class="repo-action"
|
||||
onclick={onOpenInExplorer}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
|
||||
aria-label={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
|
||||
title={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
|
||||
aria-label={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
|
||||
>
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
<span class="repo-action-label utility-label">Explorer</span>
|
||||
<span class="repo-action-label utility-label">{fileManagerName}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
const key = CRED_KEYS[target];
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
await credSave(key, "api-key", trimmed, null);
|
||||
await credSave(key, "api-key", trimmed);
|
||||
} else {
|
||||
await credDelete(key);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { Check, Languages, RefreshCw, Settings, X } from "@lucide/svelte";
|
||||
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleDashed,
|
||||
Code2,
|
||||
FolderOpen,
|
||||
GitCompare,
|
||||
GitMerge,
|
||||
Languages,
|
||||
Palette,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Terminal,
|
||||
Wrench,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
import {
|
||||
applyExternalToolPreset,
|
||||
defaultExternalToolsSettings,
|
||||
externalToolPresets,
|
||||
isExternalToolPresetAvailable,
|
||||
type ExternalToolKind,
|
||||
type ExternalToolPreset,
|
||||
} from "../externalTools";
|
||||
import type {
|
||||
AnalyticsSettings,
|
||||
AppLanguage,
|
||||
AppTheme,
|
||||
DetectedExternalTool,
|
||||
ExternalToolsSettings,
|
||||
ToolOpenMode,
|
||||
} from "../types";
|
||||
|
||||
type SettingsPage = "general" | "tools";
|
||||
|
||||
interface Props {
|
||||
analytics: AnalyticsSettings;
|
||||
theme: AppTheme;
|
||||
language: AppLanguage;
|
||||
autoRefresh: boolean;
|
||||
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean) => void;
|
||||
externalTools: ExternalToolsSettings;
|
||||
detectedTools: DetectedExternalTool[];
|
||||
detectionPending: boolean;
|
||||
detectionUnavailable: boolean;
|
||||
onRefreshDetectedTools: () => void | Promise<void>;
|
||||
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean, externalTools: ExternalToolsSettings) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { analytics, theme = "system", language = "en", autoRefresh = true, onSave = () => {}, onClose = () => {} }: Props = $props();
|
||||
let {
|
||||
analytics,
|
||||
theme = "system",
|
||||
language = "en",
|
||||
autoRefresh = true,
|
||||
externalTools,
|
||||
detectedTools = [],
|
||||
detectionPending = false,
|
||||
detectionUnavailable = false,
|
||||
onRefreshDetectedTools = () => {},
|
||||
onSave = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
const toolKinds: ExternalToolKind[] = ["editor", "diff", "merge", "terminal", "fileManager"];
|
||||
|
||||
let activePage = $state<SettingsPage>("tools");
|
||||
let activeToolKind = $state<ExternalToolKind>("editor");
|
||||
let advancedOpen = $state(false);
|
||||
let analyticsEnabled = $state(true);
|
||||
let selectedTheme = $state<AppTheme>("system");
|
||||
let selectedLanguage = $state<AppLanguage>("en");
|
||||
let autoRefreshEnabled = $state(true);
|
||||
let tools = $state<ExternalToolsSettings>(defaultExternalToolsSettings());
|
||||
const isGerman = $derived(selectedLanguage === "de");
|
||||
|
||||
$effect(() => {
|
||||
@@ -24,6 +85,7 @@
|
||||
selectedTheme = theme;
|
||||
selectedLanguage = language;
|
||||
autoRefreshEnabled = autoRefresh;
|
||||
tools = structuredClone(externalTools);
|
||||
});
|
||||
|
||||
function save() {
|
||||
@@ -31,112 +93,482 @@
|
||||
...analytics,
|
||||
enabled: analyticsEnabled,
|
||||
noticeSeen: true,
|
||||
}, selectedTheme, selectedLanguage, autoRefreshEnabled);
|
||||
}, selectedTheme, selectedLanguage, autoRefreshEnabled, $state.snapshot(tools));
|
||||
}
|
||||
|
||||
function toolLabel(kind: ExternalToolKind): string {
|
||||
const labels = {
|
||||
editor: "Editor",
|
||||
diff: isGerman ? "Diff-Tool" : "Diff tool",
|
||||
merge: isGerman ? "Merge-Tool" : "Merge tool",
|
||||
terminal: "Terminal",
|
||||
fileManager: isGerman ? "Dateimanager" : "File manager",
|
||||
};
|
||||
return labels[kind];
|
||||
}
|
||||
|
||||
function toolDescription(kind: ExternalToolKind): string {
|
||||
const descriptions = isGerman
|
||||
? {
|
||||
editor: "Öffnet Repositories und einzelne Dateien zum Bearbeiten.",
|
||||
diff: "Vergleicht eine Arbeitsdatei mit ihrer Version aus HEAD.",
|
||||
merge: "Übergibt Base, Current, Incoming und Ergebnis an einen 3-Wege-Merger.",
|
||||
terminal: "Startet eine Shell direkt im Repository-Verzeichnis.",
|
||||
fileManager: "Öffnet das Repository im bevorzugten Dateimanager.",
|
||||
}
|
||||
: {
|
||||
editor: "Opens repositories and individual files for editing.",
|
||||
diff: "Compares a working file with its version from HEAD.",
|
||||
merge: "Passes base, current, incoming, and result to a three-way merger.",
|
||||
terminal: "Starts a shell directly in the repository directory.",
|
||||
fileManager: "Opens the repository in your preferred file manager.",
|
||||
};
|
||||
return descriptions[kind];
|
||||
}
|
||||
|
||||
function toolUsage(kind: ExternalToolKind): string {
|
||||
const usage = isGerman
|
||||
? {
|
||||
editor: "Oben in der Repository-Leiste oder über das Code-Symbol im Datei-Explorer.",
|
||||
diff: "Datei im Explorer markieren und das Vergleichs-Symbol anklicken – alternativ Rechtsklick auf die Datei.",
|
||||
merge: "Bei einem Konflikt „Konflikte lösen“ öffnen und anschließend dieses Merge-Tool starten.",
|
||||
terminal: "Oben in der Repository-Leiste über den Terminal-Button.",
|
||||
fileManager: "Oben in der Repository-Leiste über den Ordner-Button.",
|
||||
}
|
||||
: {
|
||||
editor: "Use the repository toolbar or the code button in the file explorer.",
|
||||
diff: "Select a file in Explorer and click the compare button, or right-click the file.",
|
||||
merge: "Open Resolve conflicts and start this merge tool from the conflict view.",
|
||||
terminal: "Use the terminal button in the repository toolbar.",
|
||||
fileManager: "Use the folder button in the repository toolbar.",
|
||||
};
|
||||
return usage[kind];
|
||||
}
|
||||
|
||||
function presetAvailable(kind: ExternalToolKind, preset: ExternalToolPreset): boolean {
|
||||
return isExternalToolPresetAvailable(kind, preset, detectedTools);
|
||||
}
|
||||
|
||||
function availablePresets(kind: ExternalToolKind): ExternalToolPreset[] {
|
||||
return externalToolPresets[kind].filter((preset) => presetAvailable(kind, preset));
|
||||
}
|
||||
|
||||
function otherPresets(kind: ExternalToolKind): ExternalToolPreset[] {
|
||||
return externalToolPresets[kind].filter((preset) => !presetAvailable(kind, preset));
|
||||
}
|
||||
|
||||
function selectedPreset(kind: ExternalToolKind): ExternalToolPreset | undefined {
|
||||
return externalToolPresets[kind].find((preset) => preset.id === tools[kind].preset);
|
||||
}
|
||||
|
||||
function selectedToolName(kind: ExternalToolKind): string {
|
||||
return tools[kind].preset === "custom"
|
||||
? tools[kind].program.split(/[\\/]/).pop() || (isGerman ? "Eigenes Programm" : "Custom application")
|
||||
: selectedPreset(kind)?.label ?? tools[kind].program;
|
||||
}
|
||||
|
||||
function openMode(kind: "diff" | "merge"): ToolOpenMode {
|
||||
return kind === "diff" ? tools.diffOpenMode : tools.mergeOpenMode;
|
||||
}
|
||||
|
||||
function setOpenMode(kind: "diff" | "merge", mode: ToolOpenMode) {
|
||||
if (kind === "diff") tools.diffOpenMode = mode;
|
||||
else tools.mergeOpenMode = mode;
|
||||
}
|
||||
|
||||
function selectionAvailable(kind: ExternalToolKind): boolean {
|
||||
if (tools[kind].preset === "custom") return tools[kind].program.trim().length > 0;
|
||||
const preset = selectedPreset(kind);
|
||||
return preset ? presetAvailable(kind, preset) : false;
|
||||
}
|
||||
|
||||
function selectionStatus(kind: ExternalToolKind): string {
|
||||
if (tools[kind].preset === "custom") {
|
||||
return tools[kind].program.trim()
|
||||
? (isGerman ? "Manuell konfiguriert" : "Manually configured")
|
||||
: (isGerman ? "Programmpfad fehlt" : "Application path missing");
|
||||
}
|
||||
return selectionAvailable(kind)
|
||||
? (isGerman ? "Installiert und verfügbar" : "Installed and available")
|
||||
: (isGerman ? "Nicht automatisch erkannt" : "Not automatically detected");
|
||||
}
|
||||
|
||||
function changePreset(kind: ExternalToolKind, id: string) {
|
||||
if (id === "custom") {
|
||||
tools[kind] = { ...tools[kind], preset: "custom" };
|
||||
advancedOpen = true;
|
||||
return;
|
||||
}
|
||||
tools[kind] = applyExternalToolPreset(kind, id, detectedTools);
|
||||
}
|
||||
|
||||
function selectToolKind(kind: ExternalToolKind) {
|
||||
activeToolKind = kind;
|
||||
advancedOpen = tools[kind].preset === "custom";
|
||||
}
|
||||
|
||||
function updateProgram(kind: ExternalToolKind, program: string) {
|
||||
tools[kind] = { ...tools[kind], preset: "custom", program };
|
||||
}
|
||||
|
||||
function updateArgs(kind: ExternalToolKind, value: string) {
|
||||
tools[kind] = {
|
||||
...tools[kind],
|
||||
preset: "custom",
|
||||
args: value.split("\n").map((arg) => arg.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
async function browseProgram(kind: ExternalToolKind) {
|
||||
const selected = await open({
|
||||
title: isGerman ? `${toolLabel(kind)} auswählen` : `Choose ${toolLabel(kind)}`,
|
||||
multiple: false,
|
||||
directory: false,
|
||||
});
|
||||
if (typeof selected === "string") {
|
||||
updateProgram(kind, selected);
|
||||
advancedOpen = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Gitty</span>
|
||||
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||
<header class="app-settings-head">
|
||||
<div class="app-settings-title">
|
||||
<span class="app-settings-mark"><Settings2 size={18} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<h2>{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||
<p>{isGerman ? "Gitty an deinen Workflow anpassen" : "Make Gitty fit your workflow"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"}>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Einstellungen schließen" : "Close settings"}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">{isGerman ? "Darstellung" : "Appearance"}</span>
|
||||
<h3>{isGerman ? "Farbschema" : "Theme"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
<form class="app-settings-shell" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
||||
<div class="app-settings-body">
|
||||
<nav class="settings-nav" aria-label={isGerman ? "Einstellungsbereiche" : "Settings sections"}>
|
||||
<button type="button" class:active={activePage === "general"} onclick={() => { activePage = "general"; }}>
|
||||
<SlidersHorizontal size={16} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{isGerman ? "Allgemein" : "General"}</strong>
|
||||
<small>{isGerman ? "Darstellung & Verhalten" : "Appearance & behavior"}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class:active={activePage === "tools"} onclick={() => { activePage = "tools"; }}>
|
||||
<Wrench size={16} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{isGerman ? "Externe Tools" : "External tools"}</strong>
|
||||
<small>{isGerman ? "Editor, Diff & Terminal" : "Editor, diff & terminal"}</small>
|
||||
</span>
|
||||
{#if !detectionUnavailable}<em>{detectedTools.length}</em>{/if}
|
||||
</button>
|
||||
|
||||
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}>
|
||||
<label class:active={selectedTheme === "system"}>
|
||||
<input type="radio" bind:group={selectedTheme} value="system" />
|
||||
<span>System</span>
|
||||
</label>
|
||||
<label class:active={selectedTheme === "light"}>
|
||||
<input type="radio" bind:group={selectedTheme} value="light" />
|
||||
<span>{isGerman ? "Hell" : "Light"}</span>
|
||||
</label>
|
||||
<label class:active={selectedTheme === "dark"}>
|
||||
<input type="radio" bind:group={selectedTheme} value="dark" />
|
||||
<span>{isGerman ? "Dunkel" : "Dark"}</span>
|
||||
</label>
|
||||
<div class="settings-nav-note">
|
||||
<ShieldCheck size={15} aria-hidden="true" />
|
||||
<p>{isGerman ? "Tool-Argumente werden direkt und ohne zusätzliche Shell übergeben." : "Tool arguments are passed directly without an extra shell."}</p>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="settings-content">
|
||||
{#if activePage === "general"}
|
||||
<div class="settings-page-head">
|
||||
<div>
|
||||
<h3>{isGerman ? "Allgemein" : "General"}</h3>
|
||||
<p>{isGerman ? "Darstellung, Sprache und Hintergrundverhalten." : "Appearance, language, and background behavior."}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="general-settings-grid">
|
||||
<section class="general-setting-panel">
|
||||
<header><Palette size={16} /><div><h4>{isGerman ? "Farbschema" : "Theme"}</h4><p>{isGerman ? "Passend zu deiner Umgebung." : "Match your environment."}</p></div></header>
|
||||
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}>
|
||||
<label class:active={selectedTheme === "system"}><input type="radio" bind:group={selectedTheme} value="system" /><span>System</span></label>
|
||||
<label class:active={selectedTheme === "light"}><input type="radio" bind:group={selectedTheme} value="light" /><span>{isGerman ? "Hell" : "Light"}</span></label>
|
||||
<label class:active={selectedTheme === "dark"}><input type="radio" bind:group={selectedTheme} value="dark" /><span>{isGerman ? "Dunkel" : "Dark"}</span></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="general-setting-panel">
|
||||
<header><Languages size={16} /><div><h4>{isGerman ? "Sprache" : "Language"}</h4><p>{isGerman ? "Sprache der Oberfläche." : "Language used by the interface."}</p></div></header>
|
||||
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
|
||||
<label class:active={selectedLanguage === "en"}><input type="radio" bind:group={selectedLanguage} value="en" /><span>EN · English</span></label>
|
||||
<label class:active={selectedLanguage === "de"}><input type="radio" bind:group={selectedLanguage} value="de" /><span>DE · Deutsch</span></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="general-setting-panel general-setting-wide">
|
||||
<header><RefreshCw size={16} /><div><h4>{isGerman ? "Repository-Aktualisierung" : "Repository refresh"}</h4><p>{isGerman ? "Arbeitsbereich und Remotes aktuell halten." : "Keep the working tree and remotes current."}</p></div></header>
|
||||
<label class="settings-switch-row">
|
||||
<span><strong>{isGerman ? "Automatisch aktualisieren" : "Refresh automatically"}</strong><small>{isGerman ? "Branch-Status und Änderungen regelmäßig im Hintergrund prüfen." : "Periodically check branch state and working-tree changes."}</small></span>
|
||||
<input type="checkbox" bind:checked={autoRefreshEnabled} />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="general-setting-panel general-setting-wide">
|
||||
<header><ShieldCheck size={16} /><div><h4>{isGerman ? "Datenschutz" : "Privacy"}</h4><p>{isGerman ? "Anonyme Produkt- und Fehlerdiagnose." : "Anonymous product and error diagnostics."}</p></div></header>
|
||||
<label class="settings-switch-row">
|
||||
<span><strong>{isGerman ? "Anonyme Analytics erlauben" : "Allow anonymous analytics"}</strong><small>{isGerman ? "Keine Pfade, Remotes, Branches, Diffs, Zugangsdaten oder Quelltexte." : "No paths, remotes, branches, diffs, credentials, or source code."}</small></span>
|
||||
<input type="checkbox" bind:checked={analyticsEnabled} />
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="settings-page-head tools-page-head">
|
||||
<div>
|
||||
<h3>{isGerman ? "Externe Tools" : "External tools"}</h3>
|
||||
<p>
|
||||
{detectionUnavailable
|
||||
? (isGerman ? "Automatische Erkennung ist in dieser Umgebung nicht verfügbar." : "Automatic detection is unavailable in this environment.")
|
||||
: (isGerman ? `${detectedTools.length} installierte Programme erkannt.` : `${detectedTools.length} installed applications detected.`)}
|
||||
</p>
|
||||
</div>
|
||||
<button class="tool-rescan-button" type="button" onclick={onRefreshDetectedTools} disabled={detectionPending}>
|
||||
<RotateCw class={detectionPending ? "spin" : ""} size={14} aria-hidden="true" />
|
||||
{detectionPending ? (isGerman ? "Erkennung läuft…" : "Detecting…") : (isGerman ? "Neu erkennen" : "Detect again")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-kind-tabs" role="tablist" aria-label={isGerman ? "Tool-Kategorie" : "Tool category"}>
|
||||
{#each toolKinds as kind}
|
||||
<button type="button" role="tab" aria-selected={activeToolKind === kind} class:active={activeToolKind === kind} onclick={() => selectToolKind(kind)}>
|
||||
{#if kind === "editor"}<Code2 size={16} />
|
||||
{:else if kind === "diff"}<GitCompare size={16} />
|
||||
{:else if kind === "merge"}<GitMerge size={16} />
|
||||
{:else if kind === "terminal"}<Terminal size={16} />
|
||||
{:else}<FolderOpen size={16} />{/if}
|
||||
<span>{toolLabel(kind)}</span>
|
||||
<small class:available={selectionAvailable(kind)}></small>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<section class="tool-config-panel" aria-label={`${toolLabel(activeToolKind)} ${isGerman ? "konfigurieren" : "configuration"}`}>
|
||||
<div class="tool-config-summary">
|
||||
<span class="tool-config-icon">
|
||||
{#if activeToolKind === "editor"}<Code2 size={22} />
|
||||
{:else if activeToolKind === "diff"}<GitCompare size={22} />
|
||||
{:else if activeToolKind === "merge"}<GitMerge size={22} />
|
||||
{:else if activeToolKind === "terminal"}<Terminal size={22} />
|
||||
{:else}<FolderOpen size={22} />{/if}
|
||||
</span>
|
||||
<div>
|
||||
<h4>{toolLabel(activeToolKind)}</h4>
|
||||
<p>{toolDescription(activeToolKind)}</p>
|
||||
</div>
|
||||
<span class="tool-status" class:available={selectionAvailable(activeToolKind)}>
|
||||
{#if selectionAvailable(activeToolKind)}<CheckCircle2 size={13} />{:else}<CircleDashed size={13} />{/if}
|
||||
{selectionStatus(activeToolKind)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if activeToolKind === "diff" || activeToolKind === "merge"}
|
||||
{@const openModeKind = activeToolKind as "diff" | "merge"}
|
||||
<div class="tool-route" aria-label={isGerman ? "Aktuelle Standardansicht" : "Current default view"}>
|
||||
<span>{isGerman ? "Standard" : "Default"}</span><ChevronRight size={15} aria-hidden="true" />
|
||||
<strong>{openMode(openModeKind) === "gitty" ? (isGerman ? "Gitty · integriert" : "Gitty · built in") : selectedToolName(activeToolKind)}</strong>
|
||||
</div>
|
||||
|
||||
<fieldset class="tool-open-mode">
|
||||
<legend>{isGerman ? "Beim Öffnen verwenden" : "Use when opening"}</legend>
|
||||
<div>
|
||||
<button type="button" class:active={openMode(openModeKind) === "gitty"} aria-pressed={openMode(openModeKind) === "gitty"} onclick={() => setOpenMode(openModeKind, "gitty")}>
|
||||
<span>Gitty</span><small>{isGerman ? "Integrierte Ansicht" : "Built-in view"}</small>
|
||||
</button>
|
||||
<button type="button" class:active={openMode(openModeKind) === "external"} aria-pressed={openMode(openModeKind) === "external"} onclick={() => setOpenMode(openModeKind, "external")}>
|
||||
<span>{selectedToolName(activeToolKind)}</span><small>{isGerman ? "Externes Programm" : "External application"}</small>
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
{:else}
|
||||
<div class="tool-route" aria-label={isGerman ? "Aktuelle Standardzuordnung" : "Current default mapping"}>
|
||||
<span>Gitty</span><ChevronRight size={15} aria-hidden="true" /><strong>{selectedToolName(activeToolKind)}</strong>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="tool-default-field">
|
||||
<span>{isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}</span>
|
||||
<select value={tools[activeToolKind].preset} onchange={(event) => changePreset(activeToolKind, event.currentTarget.value)} aria-label={isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}>
|
||||
{#if availablePresets(activeToolKind).length > 0}
|
||||
<optgroup label={isGerman ? "Installiert" : "Installed"}>
|
||||
{#each availablePresets(activeToolKind) as preset}<option value={preset.id}>✓ {preset.label}</option>{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
<optgroup label={isGerman ? "Weitere unterstützte Programme" : "Other supported applications"}>
|
||||
{#each otherPresets(activeToolKind) as preset}<option value={preset.id}>{preset.label}</option>{/each}
|
||||
</optgroup>
|
||||
<option value="custom">{isGerman ? "Eigenes Programm auswählen…" : "Choose a custom application…"}</option>
|
||||
</select>
|
||||
<small>{isGerman ? "Diese Auswahl wird gespeichert und für alle passenden Aktionen verwendet." : "This selection is saved and used for every matching action."}</small>
|
||||
</label>
|
||||
|
||||
<div class="tool-usage-callout">
|
||||
<span>{isGerman ? "So öffnest du es" : "How to open it"}</span>
|
||||
<p>{toolUsage(activeToolKind)}</p>
|
||||
</div>
|
||||
|
||||
<button class="tool-advanced-toggle" type="button" aria-expanded={advancedOpen} onclick={() => { advancedOpen = !advancedOpen; }}>
|
||||
<span>{isGerman ? "Programmpfad und Argumente" : "Application path and arguments"}</span>
|
||||
{#if advancedOpen}<ChevronDown size={15} />{:else}<ChevronRight size={15} />{/if}
|
||||
</button>
|
||||
|
||||
{#if advancedOpen}
|
||||
<div class="tool-advanced-panel">
|
||||
<label>
|
||||
<span>{isGerman ? "Programmpfad" : "Application path"}</span>
|
||||
<div class="tool-program-row">
|
||||
<input value={tools[activeToolKind].program} oninput={(event) => updateProgram(activeToolKind, event.currentTarget.value)} spellcheck="false" />
|
||||
<button type="button" onclick={() => browseProgram(activeToolKind)} title={isGerman ? "Programm auswählen" : "Choose application"} aria-label={isGerman ? "Programm auswählen" : "Choose application"}><FolderOpen size={15} /></button>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>{isGerman ? "Argumente · eine Zeile pro Argument" : "Arguments · one per line"}</span>
|
||||
<textarea value={tools[activeToolKind].args.join("\n")} oninput={(event) => updateArgs(activeToolKind, event.currentTarget.value)} spellcheck="false"></textarea>
|
||||
</label>
|
||||
<p class="tool-placeholders">
|
||||
<span>{isGerman ? "Verfügbare Platzhalter" : "Available placeholders"}</span>
|
||||
<code>{"{repo}"}</code><code>{"{file}"}</code><code>{"{parent}"}</code><code>{"{left}"}</code><code>{"{right}"}</code><code>{"{base}"}</code><code>{"{ours}"}</code><code>{"{theirs}"}</code><code>{"{result}"}</code>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">Repository</span>
|
||||
<h3>{isGerman ? "Automatische Aktualisierung" : "Auto refresh"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<label class="settings-toggle-row">
|
||||
<input type="checkbox" bind:checked={autoRefreshEnabled} />
|
||||
<span>
|
||||
<strong>{isGerman ? "Repositories automatisch aktualisieren" : "Refresh repositories automatically"}</strong>
|
||||
<small>{isGerman ? "Aktualisiert Arbeitsbereich, Branch-Status und Remotes regelmäßig im Hintergrund." : "Periodically refreshes the working tree, branch status, and remotes in the background."}</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<Languages size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">{isGerman ? "Sprache" : "Language"}</span>
|
||||
<h3>{isGerman ? "App-Sprache" : "App language"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
|
||||
<label class:active={selectedLanguage === "en"}>
|
||||
<input type="radio" bind:group={selectedLanguage} value="en" />
|
||||
<span>EN · English</span>
|
||||
</label>
|
||||
<label class:active={selectedLanguage === "de"}>
|
||||
<input type="radio" bind:group={selectedLanguage} value="de" />
|
||||
<span>DE · Deutsch</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">Analytics</span>
|
||||
<h3>{isGerman ? "Anonyme Nutzungsanalyse" : "Anonymous usage analytics"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<label class="settings-toggle-row">
|
||||
<input type="checkbox" bind:checked={analyticsEnabled} />
|
||||
<span>
|
||||
<strong>{isGerman ? "Anonyme Analytics und Fehlerberichte erlauben" : "Allow anonymous analytics and error reports"}</strong>
|
||||
<small>{isGerman ? "Es werden keine Repository-Pfade, Remotes, Branches, Commit-Nachrichten, Dateinamen, Diffs, Zugangsdaten oder Code übertragen." : "No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent."}</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="new-branch-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
||||
<button class="btn-primary" type="submit">
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{isGerman ? "Speichern" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer class="app-settings-footer">
|
||||
<span>{isGerman ? "Änderungen werden erst beim Speichern übernommen." : "Changes are applied only after saving."}</span>
|
||||
<div>
|
||||
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
||||
<button class="btn-primary" type="submit"><Check size={16} aria-hidden="true" />{isGerman ? "Änderungen speichern" : "Save changes"}</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-settings-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr); width: min(920px, calc(100vw - 32px)); height: min(720px, calc(100vh - 32px)); overflow: hidden; }
|
||||
.app-settings-head { display: flex; align-items: center; justify-content: space-between; min-height: 70px; padding: 14px 18px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||
.app-settings-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.app-settings-mark { display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, transparent); }
|
||||
.app-settings-title h2, .settings-page-head h3, .tool-config-summary h4, .general-setting-panel h4 { margin: 0; color: var(--color-ink); }
|
||||
.app-settings-title h2 { font-size: 18px; line-height: 1.2; }
|
||||
.app-settings-title p, .settings-page-head p, .tool-config-summary p, .general-setting-panel p { margin: 0; color: var(--color-ink-dim); }
|
||||
.app-settings-title p { margin-top: 3px; font-size: 11px; }
|
||||
.app-settings-shell { display: grid; min-height: 0; grid-template-rows: minmax(0, 1fr) auto; }
|
||||
.app-settings-body { display: grid; min-height: 0; grid-template-columns: 205px minmax(0, 1fr); }
|
||||
.settings-nav { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 14px 12px; border-right: 1px solid var(--color-border-subtle); background: color-mix(in srgb, var(--app-dialog-chrome) 72%, var(--app-dialog-bg)); }
|
||||
.settings-nav > button { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; min-height: 50px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
||||
.settings-nav > button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.settings-nav > button.active { border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
||||
.settings-nav button > :global(svg) { color: var(--color-ink-muted); }
|
||||
.settings-nav button.active > :global(svg) { color: var(--color-accent); }
|
||||
.settings-nav button span { display: grid; min-width: 0; gap: 2px; }
|
||||
.settings-nav button strong { font-size: 12px; }
|
||||
.settings-nav button small { overflow: hidden; color: var(--color-ink-faint); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.settings-nav button em { display: grid; place-items: center; min-width: 21px; height: 20px; padding-inline: 5px; border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 12%, transparent); font-size: 9px; font-style: normal; font-weight: 800; }
|
||||
.settings-nav-note { display: flex; align-items: flex-start; gap: 8px; margin-top: auto; padding: 10px; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-faint); }
|
||||
.settings-nav-note :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-success); }
|
||||
.settings-nav-note p { margin: 0; font-size: 9.5px; line-height: 1.45; }
|
||||
.settings-content { min-width: 0; overflow: auto; padding: 18px 20px 22px; }
|
||||
.settings-page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||
.settings-page-head h3 { font-size: 18px; }
|
||||
.settings-page-head p { margin-top: 4px; font-size: 11px; line-height: 1.45; }
|
||||
.tool-rescan-button { display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; min-height: 30px; padding: 0 10px; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); font-size: 10px; font-weight: 750; }
|
||||
.tool-rescan-button:hover:not(:disabled) { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
|
||||
.tool-kind-tabs { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; margin-bottom: 14px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
|
||||
.tool-kind-tabs button { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; min-width: 0; height: 38px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
|
||||
.tool-kind-tabs button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.tool-kind-tabs button.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-raised); box-shadow: 0 2px 8px rgba(0,0,0,.12); }
|
||||
.tool-kind-tabs button.active :global(svg) { color: var(--color-accent); }
|
||||
.tool-kind-tabs button small { position: absolute; top: 5px; right: 6px; width: 5px; height: 5px; border-radius: 50%; background: var(--color-ink-faint); }
|
||||
.tool-kind-tabs button small.available { background: var(--color-success); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-success) 15%, transparent); }
|
||||
.tool-config-panel { display: grid; gap: 14px; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 12px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
|
||||
.tool-config-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; }
|
||||
.tool-config-icon { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid color-mix(in srgb, var(--color-accent) 25%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 8%, transparent); }
|
||||
.tool-config-summary h4 { font-size: 14px; }
|
||||
.tool-config-summary p { margin-top: 3px; font-size: 10.5px; line-height: 1.4; }
|
||||
.tool-status { display: inline-flex; align-items: center; gap: 5px; padding: 5px 7px; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-ink-faint); font-size: 9px; font-weight: 750; }
|
||||
.tool-status.available { border-color: color-mix(in srgb, var(--color-success) 24%, var(--color-border)); color: var(--color-success); background: color-mix(in srgb, var(--color-success) 6%, transparent); }
|
||||
.tool-route { display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 7px 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; color: var(--color-ink-faint); background: var(--color-surface-raised); font-size: 10.5px; }
|
||||
.tool-route strong { color: var(--color-ink); }
|
||||
.tool-open-mode { display: grid; gap: 6px; min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.tool-open-mode legend { margin-bottom: 6px; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
|
||||
.tool-open-mode > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
|
||||
.tool-open-mode button { display: grid; justify-items: start; gap: 2px; min-width: 0; min-height: 46px; padding: 7px 10px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
||||
.tool-open-mode button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.tool-open-mode button.active { border-color: color-mix(in srgb, var(--color-accent) 38%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-hover)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
||||
.tool-open-mode button span { max-width: 100%; overflow: hidden; font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tool-open-mode button small { color: var(--color-ink-faint); font-size: 9px; font-weight: 550; }
|
||||
.tool-default-field, .tool-advanced-panel label { display: grid; gap: 6px; min-width: 0; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
|
||||
.tool-default-field select, .tool-advanced-panel input, .tool-advanced-panel textarea { width: 100%; min-width: 0; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink); background: var(--color-surface-raised); font: inherit; }
|
||||
.tool-default-field select { height: 38px; padding: 0 11px; font-size: 12px; font-weight: 700; }
|
||||
.tool-default-field small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; }
|
||||
.tool-usage-callout { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 10px; padding: 10px 11px; border-left: 2px solid var(--color-accent); border-radius: 0 7px 7px 0; background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
|
||||
.tool-usage-callout span { color: var(--color-accent); font-size: 9.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.tool-usage-callout p { margin: 0; color: var(--color-ink-muted); font-size: 10.5px; line-height: 1.45; }
|
||||
.tool-advanced-toggle { display: flex; align-items: center; justify-content: space-between; min-height: 32px; padding: 0; border: 0; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
|
||||
.tool-advanced-toggle:hover { color: var(--color-ink); }
|
||||
.tool-advanced-panel { display: grid; gap: 11px; padding-top: 2px; }
|
||||
.tool-program-row { display: grid; grid-template-columns: minmax(0, 1fr) 34px; gap: 6px; }
|
||||
.tool-advanced-panel input { height: 34px; padding: 0 9px; font-family: var(--font-mono); font-size: 10.5px; }
|
||||
.tool-advanced-panel textarea { min-height: 80px; padding: 8px 9px; resize: vertical; font-family: var(--font-mono); font-size: 10.5px; line-height: 1.45; }
|
||||
.tool-program-row button { display: grid; place-items: center; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); }
|
||||
.tool-program-row button:hover { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
|
||||
.tool-placeholders { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin: 0; color: var(--color-ink-faint); font-size: 9px; }
|
||||
.tool-placeholders span { margin-right: 3px; }
|
||||
.tool-placeholders code { padding: 2px 4px; border-radius: 4px; color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 9%, transparent); }
|
||||
.general-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.general-setting-panel { display: grid; align-content: start; gap: 14px; min-width: 0; padding: 14px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
|
||||
.general-setting-panel.general-setting-wide { grid-column: 1 / -1; }
|
||||
.general-setting-panel > header { display: flex; align-items: flex-start; gap: 9px; }
|
||||
.general-setting-panel > header > :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-accent); }
|
||||
.general-setting-panel h4 { font-size: 12.5px; }
|
||||
.general-setting-panel p { margin-top: 3px; font-size: 9.5px; }
|
||||
.settings-segmented { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 3px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
||||
.settings-segmented.settings-language { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.settings-segmented label { display: flex; align-items: center; justify-content: center; min-height: 31px; border: 1px solid transparent; border-radius: 6px; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 750; }
|
||||
.settings-segmented label.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.settings-segmented input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.settings-switch-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; }
|
||||
.settings-switch-row span { display: grid; gap: 3px; }
|
||||
.settings-switch-row strong { color: var(--color-ink); font-size: 11px; }
|
||||
.settings-switch-row small { color: var(--color-ink-dim); font-size: 9.5px; line-height: 1.4; }
|
||||
.settings-switch-row input { width: 32px; height: 18px; accent-color: var(--color-accent); }
|
||||
.app-settings-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 62px; padding: 11px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||
.app-settings-footer > span { color: var(--color-ink-faint); font-size: 9.5px; }
|
||||
.app-settings-footer > div { display: flex; gap: 8px; }
|
||||
.app-settings-footer button { min-height: 32px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-settings-dialog { height: min(760px, calc(100vh - 20px)); width: min(660px, calc(100vw - 20px)); }
|
||||
.app-settings-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
||||
.settings-nav { flex-direction: row; padding: 8px 10px; border-right: 0; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.settings-nav > button { width: auto; min-width: 0; flex: 1 1 0; min-height: 42px; }
|
||||
.settings-nav-note { display: none; }
|
||||
.settings-content { padding: 14px; }
|
||||
.tool-kind-tabs { grid-template-columns: repeat(5, minmax(42px, 1fr)); overflow-x: auto; }
|
||||
.tool-kind-tabs button { height: 40px; }
|
||||
.tool-kind-tabs button span { display: none; }
|
||||
.tool-config-summary { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.tool-status { grid-column: 1 / -1; justify-self: start; }
|
||||
.app-settings-footer > span { display: none; }
|
||||
.app-settings-footer { justify-content: flex-end; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.app-settings-head { min-height: 58px; padding: 10px 12px; }
|
||||
.app-settings-mark { width: 34px; height: 34px; }
|
||||
.settings-nav button small, .settings-nav button em { display: none; }
|
||||
.settings-nav > button { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.settings-page-head { align-items: stretch; flex-direction: column; }
|
||||
.tool-rescan-button { align-self: flex-start; }
|
||||
.general-settings-grid { grid-template-columns: 1fr; }
|
||||
.general-setting-panel.general-setting-wide { grid-column: auto; }
|
||||
.tool-config-panel { padding: 13px; }
|
||||
.tool-usage-callout { grid-template-columns: 1fr; gap: 4px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
|
||||
|
||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||
@@ -49,6 +49,7 @@
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
onCheckout: (branch: GitBranchInfo) => void;
|
||||
onCompareBranch: (branch: GitBranchInfo) => void;
|
||||
onMerge: (branch: GitBranchInfo) => void;
|
||||
onRebase: (branch: GitBranchInfo) => void;
|
||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||
@@ -72,6 +73,7 @@
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
onCheckout = () => {},
|
||||
onCompareBranch = () => {},
|
||||
onMerge = () => {},
|
||||
onRebase = () => {},
|
||||
onCreateBranch = () => {},
|
||||
@@ -250,7 +252,7 @@
|
||||
const rawX = rect ? event.clientX - rect.left : event.offsetX;
|
||||
const rawY = rect ? event.clientY - rect.top : event.offsetY;
|
||||
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
|
||||
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 190);
|
||||
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 226);
|
||||
|
||||
contextBranch = branch;
|
||||
contextMenuX = Math.max(8, Math.min(rawX, maxX));
|
||||
@@ -268,6 +270,13 @@
|
||||
await onRenameBranch(branch);
|
||||
}
|
||||
|
||||
function compareContextBranch() {
|
||||
const branch = contextBranch;
|
||||
if (!branch || isBusy) return;
|
||||
closeBranchContextMenu();
|
||||
onCompareBranch(branch);
|
||||
}
|
||||
|
||||
async function deleteContextBranch() {
|
||||
const branch = contextBranch;
|
||||
if (!branch || branch.current || isBusy) return;
|
||||
@@ -677,6 +686,10 @@
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
Checkout
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={compareContextBranch} disabled={isBusy}>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
Compare with...
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
|
||||
<GitMerge size={14} aria-hidden="true" />
|
||||
Merge into current
|
||||
@@ -690,9 +703,9 @@
|
||||
Open in new worktree
|
||||
</button>
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
|
||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
Rename
|
||||
{contextBranch.remote ? "Rename remote..." : "Rename"}
|
||||
</button>
|
||||
<button
|
||||
class="danger"
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
ArrowDownToLine, ArrowUpFromLine, Boxes, CircleHelp, FileCode, GitBranch,
|
||||
GitCompare, History, RefreshCw, Search, Settings, SlidersHorizontal, Sparkles,
|
||||
} from "@lucide/svelte";
|
||||
import type { AppLanguage, GitBranch as GitBranchInfo, GitCommit, GitRepositoryFile } from "../types";
|
||||
|
||||
type ItemKind = "fetch" | "pull" | "push" | "refresh" | "search" | "compare" | "reflog" | "rebase" | "worktrees" | "sync" | "settings" | "ai-settings" | "help" | "branch" | "file" | "commit";
|
||||
interface Item { id: string; group: string; kind: ItemKind; title: string; subtitle: string; search: string; disabled?: boolean; run: () => void | Promise<void>; }
|
||||
interface Props {
|
||||
language: AppLanguage; hasRepository: boolean; isBusy: boolean;
|
||||
branches: GitBranchInfo[]; files: GitRepositoryFile[]; commits: GitCommit[];
|
||||
onClose: () => void;
|
||||
onCheckoutBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
onOpenFile: (file: GitRepositoryFile) => void | Promise<void>;
|
||||
onSelectCommit: (commit: GitCommit) => void | Promise<void>;
|
||||
onFetch: () => void | Promise<void>; onPull: () => void | Promise<void>; onPush: () => void | Promise<void>;
|
||||
onRefresh: () => void | Promise<void>; onOpenSearch: () => void; onOpenCompare: () => void;
|
||||
onOpenReflog: () => void | Promise<void>; onOpenInteractiveRebase: () => void;
|
||||
onOpenWorktrees: () => void | Promise<void>; onOpenSyncSettings: () => void | Promise<void>;
|
||||
onOpenSettings: () => void; onOpenAiSettings: () => void; onOpenHelp: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
language = "en", hasRepository = false, isBusy = false, branches = [], files = [], commits = [], onClose = () => {},
|
||||
onCheckoutBranch = () => {}, onOpenFile = () => {}, onSelectCommit = () => {}, onFetch = () => {}, onPull = () => {},
|
||||
onPush = () => {}, onRefresh = () => {}, onOpenSearch = () => {}, onOpenCompare = () => {}, onOpenReflog = () => {},
|
||||
onOpenInteractiveRebase = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {},
|
||||
onOpenAiSettings = () => {}, onOpenHelp = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let query = $state("");
|
||||
let activeIndex = $state(0);
|
||||
let inputElement = $state<HTMLInputElement | null>(null);
|
||||
let listElement = $state<HTMLElement | null>(null);
|
||||
const isGerman = $derived(language === "de");
|
||||
const repositoryActionDisabled = $derived(!hasRepository || isBusy);
|
||||
|
||||
function action(id: string, kind: ItemKind, title: string, subtitle: string, run: () => void | Promise<void>, requiresRepository = true): Item {
|
||||
return { id, group: isGerman ? "Aktionen" : "Actions", kind, title, subtitle, search: `${title} ${subtitle}`.toLowerCase(), disabled: requiresRepository ? repositoryActionDisabled : false, run };
|
||||
}
|
||||
|
||||
const actionItems = $derived([
|
||||
action("fetch", "fetch", "Fetch", isGerman ? "Remote-Änderungen abrufen" : "Download remote changes", onFetch),
|
||||
action("pull", "pull", "Pull", isGerman ? "Änderungen abrufen und integrieren" : "Download and integrate changes", onPull),
|
||||
action("push", "push", "Push", isGerman ? "Lokale Commits veröffentlichen" : "Publish local commits", onPush),
|
||||
action("refresh", "refresh", isGerman ? "Repository aktualisieren" : "Refresh repository", isGerman ? "Status und Historie neu laden" : "Reload status and history", onRefresh),
|
||||
action("search", "search", isGerman ? "Globale Codesuche" : "Global code search", isGerman ? "Code und Dateihistorie durchsuchen" : "Search code and file history", onOpenSearch),
|
||||
action("compare", "compare", isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits", isGerman ? "Zwei vollständige Revisionen vergleichen" : "Diff two complete revisions", onOpenCompare),
|
||||
action("reflog", "reflog", "Reflog", isGerman ? "Verlorene Commits finden und wiederherstellen" : "Find and recover lost commits", onOpenReflog),
|
||||
action("rebase", "rebase", "Interactive Rebase", isGerman ? "Commit-Historie bearbeiten" : "Edit commit history", onOpenInteractiveRebase),
|
||||
action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees),
|
||||
action("sync", "sync", isGerman ? "Synchronisierung konfigurieren" : "Configure synchronization", isGerman ? "Remote, Upstream und Pull-Strategie" : "Remote, upstream and pull strategy", onOpenSyncSettings),
|
||||
action("settings", "settings", isGerman ? "Einstellungen" : "Settings", isGerman ? "Darstellung, Sprache und Verhalten" : "Appearance, language and behavior", onOpenSettings, false),
|
||||
action("ai-settings", "ai-settings", "AI Settings", isGerman ? "Provider und Modell konfigurieren" : "Configure provider and model", onOpenAiSettings, false),
|
||||
action("help", "help", isGerman ? "Hilfe öffnen" : "Open help", isGerman ? "Git-Dokumentation und Tastenkürzel" : "Git documentation and keyboard shortcuts", onOpenHelp, false),
|
||||
]);
|
||||
|
||||
const dynamicItems = $derived.by(() => {
|
||||
if (!hasRepository) return [];
|
||||
const branchItems: Item[] = branches.map((branch) => ({
|
||||
id: `branch:${branch.remote ? "remote" : "local"}:${branch.name}`, group: "Branches", kind: "branch", title: branch.name,
|
||||
subtitle: branch.current ? (isGerman ? "Aktueller Branch" : "Current branch") : branch.remote ? (isGerman ? "Remote-Branch auschecken" : "Check out remote branch") : (isGerman ? "Branch auschecken" : "Check out branch"),
|
||||
search: `${branch.name} branch ${branch.remote ? "remote" : "local"}`.toLowerCase(), disabled: isBusy || branch.current, run: () => onCheckoutBranch(branch),
|
||||
}));
|
||||
const fileItems: Item[] = files.map((file) => ({
|
||||
id: `file:${file.path}`, group: isGerman ? "Dateien" : "Files", kind: "file", title: file.path.split(/[\\/]/).pop() ?? file.path,
|
||||
subtitle: file.path, search: `${file.path} file datei`.toLowerCase(), disabled: isBusy, run: () => onOpenFile(file),
|
||||
}));
|
||||
const commitItems: Item[] = commits.map((commit) => ({
|
||||
id: `commit:${commit.hash}`, group: isGerman ? "Geladene Commits" : "Loaded commits", kind: "commit", title: commit.summary || (isGerman ? "Ohne Commit-Nachricht" : "No commit message"),
|
||||
subtitle: `${commit.short_hash} · ${commit.author_name}`, search: `${commit.hash} ${commit.short_hash} ${commit.summary} ${commit.author_name} ${commit.author_email} ${commit.refs.join(" ")}`.toLowerCase(), run: () => onSelectCommit(commit),
|
||||
}));
|
||||
return [...branchItems, ...fileItems, ...commitItems];
|
||||
});
|
||||
|
||||
const visibleItems = $derived.by(() => {
|
||||
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const allItems = [...actionItems, ...dynamicItems];
|
||||
if (terms.length === 0) return allItems.slice(0, 35);
|
||||
return allItems.filter((item) => terms.every((term) => item.search.includes(term))).slice(0, 80);
|
||||
});
|
||||
|
||||
$effect(() => { query; activeIndex = 0; });
|
||||
$effect(() => { if (activeIndex >= visibleItems.length) activeIndex = Math.max(0, visibleItems.length - 1); });
|
||||
$effect(() => { activeIndex; queueMicrotask(() => listElement?.querySelector<HTMLElement>("[data-active='true']")?.scrollIntoView({ block: "nearest" })); });
|
||||
onMount(() => inputElement?.focus());
|
||||
|
||||
function execute(item: Item | undefined) { if (!item || item.disabled) return; onClose(); queueMicrotask(() => { void item.run(); }); }
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); return; }
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); activeIndex = Math.min(activeIndex + 1, visibleItems.length - 1); return; }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); return; }
|
||||
if (event.key === "Enter") { event.preventDefault(); execute(visibleItems[activeIndex]); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="command-palette-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<div class="command-palette" role="dialog" aria-modal="true" aria-label={isGerman ? "Befehlspalette" : "Command palette"}>
|
||||
<div class="command-palette-search">
|
||||
<Search size={19} aria-hidden="true" />
|
||||
<input bind:this={inputElement} bind:value={query} onkeydown={handleKeydown} placeholder={isGerman ? "Aktion, Branch, Datei oder Commit suchen…" : "Search actions, branches, files, or commits…"} aria-label={isGerman ? "Befehl suchen" : "Search commands"} autocomplete="off" spellcheck="false" />
|
||||
<kbd>ESC</kbd>
|
||||
</div>
|
||||
<div class="command-palette-results" bind:this={listElement} role="listbox" aria-label={isGerman ? "Ergebnisse" : "Results"}>
|
||||
{#if visibleItems.length === 0}
|
||||
<div class="command-palette-empty"><Search size={24} aria-hidden="true" /><strong>{isGerman ? "Keine Treffer" : "No results"}</strong><span>{isGerman ? "Versuche einen anderen Suchbegriff." : "Try a different search term."}</span></div>
|
||||
{:else}
|
||||
{#each visibleItems as item, index (item.id)}
|
||||
{#if index === 0 || visibleItems[index - 1].group !== item.group}<div class="command-palette-group">{item.group}</div>{/if}
|
||||
<button class="command-palette-item" class:active={index === activeIndex} type="button" role="option" aria-selected={index === activeIndex} data-active={index === activeIndex} disabled={item.disabled} onmouseenter={() => { activeIndex = index; }} onclick={() => execute(item)}>
|
||||
<span class={`command-palette-icon ${item.kind}`}>
|
||||
{#if item.kind === "fetch" || item.kind === "pull"}<ArrowDownToLine size={16} />
|
||||
{:else if item.kind === "push"}<ArrowUpFromLine size={16} />
|
||||
{:else if item.kind === "refresh"}<RefreshCw size={16} />
|
||||
{:else if item.kind === "search"}<Search size={16} />
|
||||
{:else if item.kind === "compare"}<GitCompare size={16} />
|
||||
{:else if item.kind === "reflog" || item.kind === "commit"}<History size={16} />
|
||||
{:else if item.kind === "rebase" || item.kind === "branch"}<GitBranch size={16} />
|
||||
{:else if item.kind === "worktrees"}<Boxes size={16} />
|
||||
{:else if item.kind === "sync"}<SlidersHorizontal size={16} />
|
||||
{:else if item.kind === "settings"}<Settings size={16} />
|
||||
{:else if item.kind === "ai-settings"}<Sparkles size={16} />
|
||||
{:else if item.kind === "help"}<CircleHelp size={16} />
|
||||
{:else}<FileCode size={16} />{/if}
|
||||
</span>
|
||||
<span class="command-palette-copy"><strong>{item.title}</strong><small>{item.subtitle}</small></span>
|
||||
{#if item.kind === "branch" && item.disabled && !isBusy}<span class="command-palette-current">{isGerman ? "AKTUELL" : "CURRENT"}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<footer class="command-palette-footer"><span><kbd>↑</kbd><kbd>↓</kbd>{isGerman ? "Navigieren" : "Navigate"}</span><span><kbd>↵</kbd>{isGerman ? "Öffnen" : "Open"}</span>{#if commits.length > 0}<span class="command-palette-hint">{isGerman ? `${commits.length} geladene Commits` : `${commits.length} loaded commits`}</span>{/if}</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.command-palette-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: start center; padding: min(14vh, 120px) 20px 20px; background: color-mix(in srgb, var(--app-dialog-backdrop) 76%, transparent); backdrop-filter: blur(7px) saturate(.82); }
|
||||
.command-palette { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(720px, 100%); max-height: min(650px, 76vh); overflow: hidden; border: 1px solid color-mix(in srgb, var(--color-primary) 22%, var(--color-border)); border-radius: 14px; background: var(--app-dialog-bg); box-shadow: 0 28px 90px rgba(0,0,0,.42); }
|
||||
.command-palette-search { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 15px 17px; border-bottom: 1px solid var(--color-border); color: var(--color-primary); }
|
||||
.command-palette-search input { min-width: 0; border: 0; outline: 0; color: var(--color-ink); background: transparent; font: inherit; font-size: 15px; }
|
||||
.command-palette-search input::placeholder { color: var(--color-ink-dim); }
|
||||
kbd { display: inline-grid; place-items: center; min-width: 23px; height: 21px; padding: 0 5px; border: 1px solid var(--color-border); border-radius: 5px; color: var(--color-ink-dim); background: var(--color-surface-raised); font-family: var(--font-mono); font-size: 9px; font-weight: 700; }
|
||||
.command-palette-results { min-height: 120px; overflow-y: auto; padding: 7px; }
|
||||
.command-palette-group { padding: 10px 9px 5px; color: var(--color-ink-dim); font-size: 10px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
||||
.command-palette-item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; width: 100%; gap: 10px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; text-align: left; color: var(--color-ink); background: transparent; cursor: pointer; }
|
||||
.command-palette-item.active:not(:disabled) { border-color: color-mix(in srgb, var(--color-primary) 24%, transparent); background: color-mix(in srgb, var(--color-primary) 11%, var(--color-surface-raised)); }
|
||||
.command-palette-item:disabled { cursor: default; opacity: .48; }
|
||||
.command-palette-icon { display: grid; place-items: center; width: 31px; height: 31px; border: 1px solid var(--color-border-subtle); border-radius: 8px; color: var(--color-ink-muted); background: var(--color-surface-raised); }
|
||||
.command-palette-icon.branch { color: #65c98b; } .command-palette-icon.file { color: #69a7ff; } .command-palette-icon.commit { color: #ba82ff; }
|
||||
.command-palette-copy { display: grid; min-width: 0; gap: 2px; } .command-palette-copy strong, .command-palette-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.command-palette-copy strong { font-size: 12.5px; font-weight: 700; } .command-palette-copy small { color: var(--color-ink-dim); font-size: 10.5px; }
|
||||
.command-palette-current { color: var(--color-ink-dim); font-family: var(--font-mono); font-size: 9px; font-weight: 700; }
|
||||
.command-palette-empty { display: grid; place-items: center; gap: 5px; padding: 54px 20px; color: var(--color-ink-dim); } .command-palette-empty strong { margin-top: 5px; color: var(--color-ink); font-size: 13px; } .command-palette-empty span { font-size: 11px; }
|
||||
.command-palette-footer { display: flex; align-items: center; gap: 16px; min-height: 38px; padding: 7px 12px; border-top: 1px solid var(--color-border); color: var(--color-ink-dim); font-size: 10px; }
|
||||
.command-palette-footer span { display: flex; align-items: center; gap: 5px; } .command-palette-footer span kbd + kbd { margin-left: -3px; } .command-palette-hint { margin-left: auto; }
|
||||
@media (max-width: 640px) { .command-palette-backdrop { padding: 60px 10px 10px; } .command-palette { max-height: calc(100vh - 80px); } .command-palette-hint { display: none !important; } }
|
||||
</style>
|
||||
@@ -0,0 +1,303 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Download,
|
||||
GitCommitHorizontal,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
Save,
|
||||
StickyNote,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
import type { AppLanguage, GitCommit, GitRemote } from "../types";
|
||||
|
||||
interface Props {
|
||||
commit: GitCommit;
|
||||
note: string;
|
||||
remotes: GitRemote[];
|
||||
preferredRemote: string;
|
||||
language: AppLanguage;
|
||||
isLoading: boolean;
|
||||
isBusy: boolean;
|
||||
error: string;
|
||||
status: string;
|
||||
onSave: (note: string) => void | Promise<void>;
|
||||
onDelete: () => void | Promise<void>;
|
||||
onFetch: (remote: string) => void | Promise<void>;
|
||||
onPush: (remote: string) => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
commit,
|
||||
note = "",
|
||||
remotes = [],
|
||||
preferredRemote = "",
|
||||
language = "en",
|
||||
isLoading = false,
|
||||
isBusy = false,
|
||||
error = "",
|
||||
status = "",
|
||||
onSave = () => {},
|
||||
onDelete = () => {},
|
||||
onFetch = () => {},
|
||||
onPush = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let draft = $state("");
|
||||
let selectedRemote = $state("");
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let lastLoadedKey = $state("");
|
||||
let hasChanges = $derived(draft !== note);
|
||||
let canSave = $derived(!isLoading && !isBusy && draft.trim().length > 0 && hasChanges);
|
||||
|
||||
const text = $derived(language === "de" ? {
|
||||
eyebrow: "Interne Git-Notiz",
|
||||
title: "Commit-Notiz",
|
||||
commit: "Commit",
|
||||
explanation: "Die Notiz wird separat unter refs/notes/commits gespeichert. Hash und Commit-Historie bleiben unverändert.",
|
||||
label: "Notiz",
|
||||
placeholder: "Zum Beispiel Review-Hinweise, Ticket-Kontext, Build-ID oder Freigabestatus …",
|
||||
loading: "Notiz wird geladen …",
|
||||
syncTitle: "Mit Remote synchronisieren",
|
||||
syncHelp: "Git Notes reisen nicht automatisch mit Branches. Lade sie gezielt vom Remote oder sende deine lokalen Notizen dorthin.",
|
||||
noRemotes: "Für dieses Repository ist kein Remote eingerichtet.",
|
||||
remote: "Remote",
|
||||
fetch: "Vom Remote laden",
|
||||
push: "Zum Remote senden",
|
||||
delete: "Notiz löschen",
|
||||
deleteQuestion: "Diese Notiz wirklich löschen?",
|
||||
deleteConfirm: "Ja, löschen",
|
||||
cancel: "Abbrechen",
|
||||
close: "Schließen",
|
||||
save: "Notiz speichern",
|
||||
characters: "Zeichen",
|
||||
} : {
|
||||
eyebrow: "Internal Git note",
|
||||
title: "Commit note",
|
||||
commit: "Commit",
|
||||
explanation: "The note is stored separately under refs/notes/commits. The commit hash and history stay unchanged.",
|
||||
label: "Note",
|
||||
placeholder: "For example review findings, ticket context, a build ID, or approval status…",
|
||||
loading: "Loading note…",
|
||||
syncTitle: "Sync with a remote",
|
||||
syncHelp: "Git Notes do not travel with branches automatically. Fetch them explicitly from a remote or push your local notes there.",
|
||||
noRemotes: "No remote is configured for this repository.",
|
||||
remote: "Remote",
|
||||
fetch: "Fetch from remote",
|
||||
push: "Push to remote",
|
||||
delete: "Delete note",
|
||||
deleteQuestion: "Delete this note?",
|
||||
deleteConfirm: "Yes, delete",
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
save: "Save note",
|
||||
characters: "characters",
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const loadedKey = `${commit.hash}:${note}`;
|
||||
if (loadedKey === lastLoadedKey) return;
|
||||
draft = note;
|
||||
lastLoadedKey = loadedKey;
|
||||
deleteConfirmOpen = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedRemote && remotes.some((remote) => remote.name === selectedRemote)) return;
|
||||
selectedRemote = remotes.some((remote) => remote.name === preferredRemote)
|
||||
? preferredRemote
|
||||
: (remotes[0]?.name ?? "");
|
||||
});
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!canSave) return;
|
||||
void onSave(draft);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && !isBusy) onClose();
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
if (canSave) void onSave(draft);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="dialog-backdrop app-chrome-backdrop commit-note-backdrop" role="presentation">
|
||||
<div class="commit-note-dialog" role="dialog" aria-modal="true" aria-labelledby="commit-note-title" tabindex="-1">
|
||||
<header class="commit-note-head">
|
||||
<div class="commit-note-heading">
|
||||
<span class="commit-note-icon"><StickyNote size={20} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<span class="eyebrow">{text.eyebrow}</span>
|
||||
<h2 id="commit-note-title">{text.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={text.close} aria-label={text.close}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="commit-note-body">
|
||||
<div class="commit-note-target">
|
||||
<span class="commit-target-icon"><GitCommitHorizontal size={18} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<span>{text.commit} <code>{commit.short_hash}</code></span>
|
||||
<strong title={commit.summary}>{commit.summary}</strong>
|
||||
<small>{commit.author_name} · {new Date(commit.date).toLocaleString()}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="commit-note-info">
|
||||
<Info size={16} aria-hidden="true" />
|
||||
<span>{text.explanation}</span>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="commit-note-message error" role="alert">{error}</div>
|
||||
{:else if status}
|
||||
<div class="commit-note-message success" role="status">{status}</div>
|
||||
{/if}
|
||||
|
||||
<form class="commit-note-form" onsubmit={submit}>
|
||||
<label for="commit-note-editor">
|
||||
<span>{text.label}</span>
|
||||
<small>{draft.length.toLocaleString()} {text.characters}</small>
|
||||
</label>
|
||||
<div class="commit-note-editor-wrap">
|
||||
{#if isLoading}
|
||||
<div class="commit-note-loading"><LoaderCircle class="spin" size={18} aria-hidden="true" />{text.loading}</div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<textarea
|
||||
id="commit-note-editor"
|
||||
bind:value={draft}
|
||||
placeholder={text.placeholder}
|
||||
disabled={isLoading || isBusy}
|
||||
maxlength={262144}
|
||||
spellcheck="true"
|
||||
autofocus
|
||||
></textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="commit-note-sync" aria-labelledby="commit-note-sync-title">
|
||||
<div class="commit-note-sync-copy">
|
||||
<strong id="commit-note-sync-title">{text.syncTitle}</strong>
|
||||
<span>{text.syncHelp}</span>
|
||||
</div>
|
||||
{#if remotes.length > 0}
|
||||
<div class="commit-note-sync-controls">
|
||||
<label>
|
||||
<span>{text.remote}</span>
|
||||
<select bind:value={selectedRemote} disabled={isBusy}>
|
||||
{#each remotes as remote (remote.name)}
|
||||
<option value={remote.name}>{remote.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" onclick={() => onFetch(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
|
||||
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Download size={15} aria-hidden="true" />{/if}
|
||||
{text.fetch}
|
||||
</button>
|
||||
<button type="button" onclick={() => onPush(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
|
||||
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Upload size={15} aria-hidden="true" />{/if}
|
||||
{text.push}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="commit-note-no-remotes">{text.noRemotes}</span>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="commit-note-footer">
|
||||
<div class="commit-note-delete">
|
||||
{#if deleteConfirmOpen}
|
||||
<span>{text.deleteQuestion}</span>
|
||||
<button class="btn-danger" type="button" onclick={() => onDelete()} disabled={isBusy}>{text.deleteConfirm}</button>
|
||||
<button type="button" onclick={() => { deleteConfirmOpen = false; }} disabled={isBusy}>{text.cancel}</button>
|
||||
{:else}
|
||||
<button type="button" class="commit-note-delete-trigger" onclick={() => { deleteConfirmOpen = true; }} disabled={isBusy || isLoading || !note}>
|
||||
<Trash2 size={15} aria-hidden="true" />{text.delete}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="commit-note-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{text.close}</button>
|
||||
<button class="btn-primary" type="button" onclick={() => onSave(draft)} disabled={!canSave}>
|
||||
{#if isBusy}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Save size={16} aria-hidden="true" />{/if}
|
||||
{text.save}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.commit-note-backdrop { z-index: 72; }
|
||||
.commit-note-dialog {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-height: min(780px, calc(100vh - 74px));
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 14px;
|
||||
background: var(--app-dialog-bg);
|
||||
box-shadow: var(--app-dialog-shadow);
|
||||
}
|
||||
.commit-note-head,
|
||||
.commit-note-footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; background: var(--app-dialog-chrome); }
|
||||
.commit-note-head { padding: 16px 18px; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.commit-note-heading { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.commit-note-heading h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 18px; line-height: 1.2; }
|
||||
.commit-note-icon,
|
||||
.commit-target-icon { display: grid; flex: 0 0 auto; place-items: center; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
|
||||
.commit-note-icon { width: 40px; height: 40px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 11px; }
|
||||
.commit-note-body { display: grid; align-content: start; gap: 13px; min-height: 0; padding: 16px 18px; overflow: auto; }
|
||||
.commit-note-target { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 11px; padding: 11px 12px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--color-surface-raised); }
|
||||
.commit-target-icon { width: 34px; height: 34px; border-radius: 8px; }
|
||||
.commit-note-target > div { display: grid; gap: 3px; min-width: 0; }
|
||||
.commit-note-target span,
|
||||
.commit-note-target small { color: var(--color-ink-faint); font-size: 10.5px; }
|
||||
.commit-note-target code { margin-left: 4px; color: var(--color-accent); font: 700 10.5px var(--font-mono); }
|
||||
.commit-note-target strong { overflow: hidden; color: var(--color-ink); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.commit-note-info { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 8px; padding: 10px 11px; border: 1px solid color-mix(in srgb, var(--color-accent) 22%, var(--color-border-subtle)); border-radius: 9px; color: var(--color-ink-muted); background: color-mix(in srgb, var(--color-accent) 6%, transparent); font-size: 11px; line-height: 1.45; }
|
||||
.commit-note-info :global(svg) { margin-top: 1px; color: var(--color-accent); }
|
||||
.commit-note-message { padding: 9px 11px; border: 1px solid; border-radius: 8px; font-size: 11px; font-weight: 700; }
|
||||
.commit-note-message.error { border-color: rgba(232, 96, 96, .32); color: #ef8888; background: rgba(232, 96, 96, .08); }
|
||||
.commit-note-message.success { border-color: color-mix(in srgb, #5bd18a 34%, var(--color-border)); color: #70dc99; background: color-mix(in srgb, #5bd18a 8%, transparent); }
|
||||
.commit-note-form { display: grid; gap: 6px; }
|
||||
.commit-note-form > label { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--color-ink-muted); font-size: 11px; font-weight: 800; }
|
||||
.commit-note-form > label small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 600; }
|
||||
.commit-note-editor-wrap { position: relative; }
|
||||
.commit-note-editor-wrap textarea { min-height: 164px; max-height: 320px; resize: vertical; font-size: 12.5px; line-height: 1.55; }
|
||||
.commit-note-loading { position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; justify-content: center; gap: 8px; border-radius: 8px; color: var(--color-ink-muted); background: color-mix(in srgb, var(--app-input-bg) 92%, transparent); font-size: 11px; font-weight: 700; }
|
||||
.commit-note-sync { display: grid; gap: 11px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--color-surface-raised); }
|
||||
.commit-note-sync-copy { display: grid; gap: 4px; }
|
||||
.commit-note-sync-copy strong { color: var(--color-ink); font-size: 12px; }
|
||||
.commit-note-sync-copy span,
|
||||
.commit-note-no-remotes { color: var(--color-ink-faint); font-size: 10.5px; line-height: 1.45; }
|
||||
.commit-note-sync-controls { display: grid; grid-template-columns: minmax(130px, 1fr) auto auto; align-items: end; gap: 8px; }
|
||||
.commit-note-sync-controls label { display: grid; gap: 5px; color: var(--color-ink-muted); font-size: 10px; font-weight: 800; }
|
||||
.commit-note-sync-controls button { min-height: 34px; font-size: 10.5px; font-weight: 750; }
|
||||
.commit-note-footer { min-height: 66px; padding: 12px 18px; border-top: 1px solid var(--color-border-subtle); }
|
||||
.commit-note-delete,
|
||||
.commit-note-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.commit-note-delete > span { color: var(--color-ink-muted); font-size: 10.5px; font-weight: 700; }
|
||||
.commit-note-delete-trigger { border-color: transparent; color: #e87a7a; background: transparent; }
|
||||
.commit-note-delete-trigger:hover:not(:disabled) { border-color: rgba(232, 96, 96, .24); color: #ff9a9a; background: rgba(232, 96, 96, .08); }
|
||||
@media (max-width: 660px) {
|
||||
.commit-note-sync-controls { grid-template-columns: 1fr; }
|
||||
.commit-note-footer { align-items: stretch; flex-direction: column; }
|
||||
.commit-note-delete,
|
||||
.commit-note-actions { justify-content: flex-end; }
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,8 @@
|
||||
comparison: GitCommitComparison;
|
||||
selectedDiffPath: string;
|
||||
isBusy: boolean;
|
||||
fromLabel?: string;
|
||||
toLabel?: string;
|
||||
restoreLabel?: string;
|
||||
/** When opened from a search hit, the term to highlight on matching lines. */
|
||||
highlightQuery?: string;
|
||||
@@ -32,6 +34,8 @@
|
||||
comparison,
|
||||
selectedDiffPath = "",
|
||||
isBusy = false,
|
||||
fromLabel = "",
|
||||
toLabel = "",
|
||||
restoreLabel = "",
|
||||
highlightQuery = "",
|
||||
onClose = () => {},
|
||||
@@ -216,18 +220,18 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
class="dialog-backdrop compare-dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
|
||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Branch or commit comparison" tabindex="-1">
|
||||
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="dialog-range">
|
||||
<span class="hash">{comparison.from_short}</span>
|
||||
<span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
|
||||
<ArrowRight size={14} aria-hidden="true" />
|
||||
<span class="hash">{comparison.to_short}</span>
|
||||
<span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
@@ -289,11 +293,11 @@
|
||||
<div class="split-col-headers">
|
||||
<div class="split-col-label">
|
||||
<span>Before</span>
|
||||
<span class="split-col-hash">{comparison.from_short}</span>
|
||||
<span class="split-col-hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
|
||||
</div>
|
||||
<div class="split-col-label">
|
||||
<span>After</span>
|
||||
<span class="split-col-hash">{comparison.to_short}</span>
|
||||
<span class="split-col-hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitCommit } from "../types";
|
||||
import type { GitBranch, GitCommit } from "../types";
|
||||
|
||||
interface Props {
|
||||
commits: GitCommit[];
|
||||
branches: GitBranch[];
|
||||
compareFrom: string;
|
||||
compareTo: string;
|
||||
canCompare: boolean;
|
||||
@@ -17,6 +18,7 @@
|
||||
|
||||
let {
|
||||
commits = [],
|
||||
branches = [],
|
||||
compareFrom = "",
|
||||
compareTo = "",
|
||||
canCompare = false,
|
||||
@@ -32,6 +34,14 @@
|
||||
return `${item.short_hash} - ${item.summary}`;
|
||||
}
|
||||
|
||||
function branchValue(branch: GitBranch): string {
|
||||
return branch.remote ? `refs/remotes/${branch.name}` : `refs/heads/${branch.name}`;
|
||||
}
|
||||
|
||||
let localBranches = $derived(branches.filter((branch) => !branch.remote));
|
||||
let remoteBranches = $derived(branches.filter((branch) => branch.remote));
|
||||
let targetCount = $derived(branches.length + commits.length);
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
onCompare();
|
||||
@@ -42,53 +52,89 @@
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
|
||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select branches or commits to compare" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Select commits</h2>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Compare branches or commits</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if commits.length < 2}
|
||||
<div class="blank-state">At least two commits are needed to compare.</div>
|
||||
{#if targetCount < 2}
|
||||
<div class="blank-state">At least two branches or commits are needed to compare.</div>
|
||||
{:else}
|
||||
<form class="compare-form" onsubmit={handleSubmit}>
|
||||
<label class="compare-field">
|
||||
<span>From (older)</span>
|
||||
<span>Base</span>
|
||||
<select
|
||||
value={compareFrom}
|
||||
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
<option value="" disabled>Select a branch or commit</option>
|
||||
{#if localBranches.length > 0}
|
||||
<optgroup label="Local branches">
|
||||
{#each localBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if remoteBranches.length > 0}
|
||||
<optgroup label="Remote branches">
|
||||
{#each remoteBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if commits.length > 0}
|
||||
<optgroup label="Recent commits">
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
|
||||
|
||||
<label class="compare-field">
|
||||
<span>To (newer)</span>
|
||||
<span>Compare with</span>
|
||||
<select
|
||||
value={compareTo}
|
||||
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
<option value="" disabled>Select a branch or commit</option>
|
||||
{#if localBranches.length > 0}
|
||||
<optgroup label="Local branches">
|
||||
{#each localBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if remoteBranches.length > 0}
|
||||
<optgroup label="Remote branches">
|
||||
{#each remoteBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if commits.length > 0}
|
||||
<optgroup label="Recent commits">
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
||||
{#if operation === "Comparing commits"}
|
||||
{#if operation === "Comparing revisions"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitCompare size={16} aria-hidden="true" />
|
||||
@@ -98,9 +144,11 @@
|
||||
</form>
|
||||
|
||||
{#if compareFrom && compareTo && compareFrom === compareTo}
|
||||
<div class="blank-state">Select two different commits to compare.</div>
|
||||
<div class="blank-state">Select two different branches or commits to compare.</div>
|
||||
{:else}
|
||||
<div class="blank-state">Pick two commits and run a comparison.</div>
|
||||
<div class="compare-target-help">
|
||||
The two branch tips are compared across the entire repository. Uncommitted working-tree changes are not included.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
action: "push" | "pull" | "fetch" | "clone";
|
||||
error: string;
|
||||
isBusy: boolean;
|
||||
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
|
||||
onSubmit: (username: string, password: string, save: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
let password = $state("");
|
||||
let showPassword = $state(false);
|
||||
let saveSession = $state(true);
|
||||
let expiresAt = $state("");
|
||||
|
||||
let canSubmit = $derived(
|
||||
!isBusy &&
|
||||
@@ -62,12 +61,7 @@
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
onSubmit(
|
||||
mode === "token" ? "oauth2" : username,
|
||||
password,
|
||||
saveSession,
|
||||
saveSession && expiresAt ? expiresAt : null,
|
||||
);
|
||||
onSubmit(mode === "token" ? "oauth2" : username, password, saveSession);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -191,19 +185,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if saveSession}
|
||||
<div class="cred-expiry">
|
||||
<label class="cred-field-label" for="cred-expiry">Expiration date (optional)</label>
|
||||
<input
|
||||
id="cred-expiry"
|
||||
type="date"
|
||||
bind:value={expiresAt}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<span class="cred-expiry-hint">After this date you'll automatically be asked to log in again.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="cred-footer">
|
||||
<label class="cred-save">
|
||||
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
FileCog,
|
||||
FileImage,
|
||||
FileJson,
|
||||
FileSearch,
|
||||
GitCompare,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType,
|
||||
@@ -32,11 +34,17 @@
|
||||
selectedExplorerKind: ExplorerNodeKind;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
language?: "en" | "de";
|
||||
editorName?: string;
|
||||
diffName?: string;
|
||||
onToggleFolder: (node: ExplorerNode) => void;
|
||||
onExpandAllFolders: () => void;
|
||||
onCollapseAllFolders: () => void;
|
||||
onSelectNode: (node: ExplorerNode) => void;
|
||||
onOpenFile: (node: ExplorerNode) => void;
|
||||
onOpenInEditor: (node: ExplorerNode) => void;
|
||||
onExternalDiff: (node: ExplorerNode) => void;
|
||||
onFileHistory: (node: ExplorerNode) => void;
|
||||
onBlame: (node: ExplorerNode) => void;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
@@ -49,11 +57,17 @@
|
||||
selectedExplorerKind = "file",
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
language = "en",
|
||||
editorName = "Editor",
|
||||
diffName = "diff tool",
|
||||
onToggleFolder = () => {},
|
||||
onExpandAllFolders = () => {},
|
||||
onCollapseAllFolders = () => {},
|
||||
onSelectNode = () => {},
|
||||
onOpenFile = () => {},
|
||||
onOpenInEditor = () => {},
|
||||
onExternalDiff = () => {},
|
||||
onFileHistory = () => {},
|
||||
onBlame = () => {},
|
||||
collapsed = false,
|
||||
onToggleCollapsed = () => {},
|
||||
@@ -62,6 +76,7 @@
|
||||
let contextNode = $state<ExplorerNode | null>(null);
|
||||
let contextMenuX = $state(0);
|
||||
let contextMenuY = $state(0);
|
||||
const isGerman = $derived(language === "de");
|
||||
|
||||
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
|
||||
if (!next) return current;
|
||||
@@ -175,7 +190,7 @@
|
||||
|
||||
contextNode = node;
|
||||
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
|
||||
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 56));
|
||||
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 220));
|
||||
}
|
||||
|
||||
function closeFileContextMenu() {
|
||||
@@ -189,6 +204,20 @@
|
||||
onOpenFile(node);
|
||||
}
|
||||
|
||||
function openContextFileInEditor() {
|
||||
const node = contextNode;
|
||||
if (!node || node.kind !== "file") return;
|
||||
closeFileContextMenu();
|
||||
onOpenInEditor(node);
|
||||
}
|
||||
|
||||
function openContextExternalDiff() {
|
||||
const node = contextNode;
|
||||
if (!node || node.kind !== "file") return;
|
||||
closeFileContextMenu();
|
||||
onExternalDiff(node);
|
||||
}
|
||||
|
||||
function openContextBlame() {
|
||||
const node = contextNode;
|
||||
if (!node || node.kind !== "file") return;
|
||||
@@ -196,6 +225,13 @@
|
||||
onBlame(node);
|
||||
}
|
||||
|
||||
function openContextFileHistory() {
|
||||
const node = contextNode;
|
||||
if (!node || node.kind !== "file" || !node.tracked) return;
|
||||
closeFileContextMenu();
|
||||
onFileHistory(node);
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeFileContextMenu();
|
||||
}
|
||||
@@ -203,6 +239,11 @@
|
||||
let explorerTree = $derived(buildExplorerTree(repoFiles));
|
||||
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
|
||||
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
|
||||
let selectedFileNode = $derived(
|
||||
selectedExplorerKind === "file"
|
||||
? visibleNodes.find((node) => node.kind === "file" && node.path === selectedExplorerPath) ?? null
|
||||
: null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
|
||||
@@ -214,6 +255,35 @@
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2>
|
||||
</div>
|
||||
<div class="explorer-head-actions">
|
||||
<button
|
||||
class="explorer-bulk-button explorer-tool-action"
|
||||
type="button"
|
||||
onclick={() => selectedFileNode && onOpenInEditor(selectedFileNode)}
|
||||
disabled={isBusy || !selectedFileNode || selectedFileNode.status === "deleted"}
|
||||
title={isGerman
|
||||
? `Ausgewählte Datei in ${editorName} öffnen`
|
||||
: `Open selected file in ${editorName}`}
|
||||
aria-label={isGerman
|
||||
? `Ausgewählte Datei in ${editorName} öffnen`
|
||||
: `Open selected file in ${editorName}`}
|
||||
>
|
||||
<FileCode size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="explorer-bulk-button explorer-tool-action"
|
||||
type="button"
|
||||
onclick={() => selectedFileNode && onExternalDiff(selectedFileNode)}
|
||||
disabled={isBusy || !selectedFileNode || !selectedFileNode.tracked || selectedFileNode.status === "deleted"}
|
||||
title={isGerman
|
||||
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
|
||||
: `Compare selected file with HEAD in ${diffName}`}
|
||||
aria-label={isGerman
|
||||
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
|
||||
: `Compare selected file with HEAD in ${diffName}`}
|
||||
>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<span class="explorer-action-divider" aria-hidden="true"></span>
|
||||
<button
|
||||
class="explorer-bulk-button"
|
||||
type="button"
|
||||
@@ -261,12 +331,14 @@
|
||||
{:else}
|
||||
<div class="explorer-list overflow-auto p-2">
|
||||
{#each visibleNodes as node (`${node.kind}:${node.path}`)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="explorer-row"
|
||||
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
|
||||
class:folder={node.kind === "folder"}
|
||||
style={`--depth: ${node.depth}`}
|
||||
title={node.path}
|
||||
oncontextmenu={(event) => openFileContextMenu(event, node)}
|
||||
>
|
||||
{#if node.kind === "folder"}
|
||||
<button
|
||||
@@ -328,9 +400,8 @@
|
||||
class="explorer-select"
|
||||
type="button"
|
||||
onclick={() => onSelectNode(node)}
|
||||
oncontextmenu={(event) => openFileContextMenu(event, node)}
|
||||
disabled={isBusy}
|
||||
title={`Show history for ${node.path}`}
|
||||
title={`Select ${node.path}`}
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
</button>
|
||||
@@ -355,6 +426,24 @@
|
||||
tabindex="-1"
|
||||
aria-label={`Actions for ${contextNode.path}`}
|
||||
>
|
||||
<button type="button" role="menuitem" onclick={openContextFileInEditor} disabled={contextNode.status === "deleted"}>
|
||||
<FileCode size={14} aria-hidden="true" />
|
||||
{isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextExternalDiff} disabled={!contextNode.tracked || contextNode.status === "deleted"}>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
{isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={openContextFileHistory}
|
||||
disabled={!contextNode.tracked}
|
||||
title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}
|
||||
>
|
||||
<History size={14} aria-hidden="true" />
|
||||
File history
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -372,7 +461,7 @@
|
||||
disabled={!contextNode.tracked || contextNode.status === "deleted"}
|
||||
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
|
||||
>
|
||||
<History size={14} aria-hidden="true" />
|
||||
<FileSearch size={14} aria-hidden="true" />
|
||||
Blame
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,45 @@
|
||||
label: "Neu in Gitty",
|
||||
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
|
||||
sections: [
|
||||
{
|
||||
id: "changelog-2026-8-3",
|
||||
title: "Version 2026.8.3",
|
||||
summary: "Dieses Release verbindet Gitty enger mit deinen Entwicklungswerkzeugen und macht Branches, Historie und Vergleiche deutlich leistungsfähiger.",
|
||||
steps: [
|
||||
"Externe Tools: Editor, Diff-Tool, Merge-Tool, Terminal und Dateimanager lassen sich in den neu gestalteten Einstellungen erkennen, auswählen und individuell konfigurieren.",
|
||||
"VS Code, JetBrains-IDEs, Beyond Compare und weitere unterstützte Programme werden in einem eigenen Fenster geöffnet; dokumentierte Ergebnis-Codes werden beim Schließen korrekt behandelt.",
|
||||
"Git Notes: Commits erhalten lokale Notizen, ohne ihre Historie umzuschreiben. Notizen lassen sich bearbeiten, löschen sowie gezielt vom Remote abrufen oder dorthin übertragen.",
|
||||
"Vollständiger Branch-Vergleich: Lokale Branches, Remote-Branches und Commits können direkt ausgewählt und dateiweise im Side-by-Side-Diff verglichen werden.",
|
||||
"Remote-Branches lassen sich im Kontextmenü sicher umbenennen. Gitty schützt dabei vorhandene Ziel-Branches und zwischenzeitlich geänderte Remote-Stände.",
|
||||
"Der überarbeitete Commit-Graph zeigt Branches kompakter, reduziert überladene Commit-Zeilen und blendet zusätzliche Flag-Details beim Darüberfahren ein.",
|
||||
"Noch nicht veröffentlichte Branches sind als „Nur lokal“ erkennbar – in der Werkzeugleiste, Repository-Übersicht, Statuszeile und direkt an der Branch-Flag. Die erste Push-Aktion heißt passend „Veröffentlichen“.",
|
||||
"Branch-Sichtbarkeit, feinere Graph-Verbindungen und ein kleineres Mindestmaß des Verlaufsbereichs verbessern die Übersicht bei großen Repositories.",
|
||||
"Die neue Befehlspalette öffnet Aktionen, Dateien und Commits schneller; asynchrone Git-Befehle halten Gitty auch bei langsameren Operationen reaktionsfähig.",
|
||||
],
|
||||
note: "Der Branch-Vergleich zeigt die vollständig festgeschriebenen Zustände der beiden Branch-Spitzen. Nicht commitete Änderungen im Arbeitsverzeichnis sind nicht enthalten.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-2",
|
||||
title: "Version 2026.8.2",
|
||||
summary: "Dieses Release stabilisiert die Darstellung komplexer Verläufe und verbessert die Veröffentlichung neuer Gitty-Versionen.",
|
||||
steps: [
|
||||
"Branch-Farben bleiben über Eltern-Lanes hinweg stabil, sodass sich Linien in längeren und verzweigten Historien leichter verfolgen lassen.",
|
||||
"Release-Artefakte werden automatisch und ohne doppelte Dateien an das passende Gitea-Release angehängt.",
|
||||
"Beim Beenden der Anwendung wird die Telemetrie zuverlässiger abgeschlossen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-1",
|
||||
title: "Version 2026.8.1",
|
||||
summary: "Dieses Release macht große Commit-Verläufe und die Historie einzelner Dateien leichter zugänglich und verbessert die Paketverteilung.",
|
||||
steps: [
|
||||
"Die Commit-Historie lädt ältere Einträge seitenweise nach und ist nicht mehr auf die erste Ergebnismenge begrenzt.",
|
||||
"Die Dateihistorie öffnet sich aus dem Explorer-Kontextmenü in einem eigenen, größeren Dialog statt in einem dauerhaft belegten Seitenbereich.",
|
||||
"Dialoge reagieren konsistenter auf die Escape-Taste.",
|
||||
"Windows- und Ubuntu-Releases sowie der AUR-Paketablauf wurden erweitert und robuster gemacht.",
|
||||
"Die Arch-Linux-Anleitung verwendet jetzt das AUR-Paket gitty-desktop; SSH-Einrichtung, Zeitlimits und Wiederholungsversuche wurden verbessert.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-07-22",
|
||||
title: "Version 2026.07.22",
|
||||
@@ -1411,6 +1450,45 @@
|
||||
label: "What's new",
|
||||
description: "Changes since the latest published version and notable additions from earlier releases.",
|
||||
sections: [
|
||||
{
|
||||
id: "changelog-2026-8-3",
|
||||
title: "Version 2026.8.3",
|
||||
summary: "This release connects Gitty more closely with your development tools and makes branches, history, and comparisons substantially more capable.",
|
||||
steps: [
|
||||
"External tools: editors, diff tools, merge tools, terminals, and file managers can be detected, selected, and customized in the redesigned settings.",
|
||||
"VS Code, JetBrains IDEs, Beyond Compare, and other supported applications open in a separate window; documented result codes are handled correctly when they close.",
|
||||
"Git Notes: attach local notes to commits without rewriting history. Notes can be edited, deleted, fetched from a remote, or pushed explicitly.",
|
||||
"Complete branch comparison: choose local branches, remote branches, or commits and inspect every changed file in a side-by-side diff.",
|
||||
"Remote branches can be renamed safely from the context menu. Gitty protects existing destination branches and remote branches that changed after the last fetch.",
|
||||
"The redesigned commit graph presents branches more compactly, reduces crowded commit rows, and reveals additional flag details on hover.",
|
||||
"Unpublished branches are clearly marked as Local only in the toolbar, repository summary, status bar, and on the graph flag. Their first push is labeled Publish.",
|
||||
"Branch visibility controls, refined graph connectors, and a smaller minimum history width improve navigation in large repositories.",
|
||||
"The new command palette opens actions, files, and commits faster; asynchronous Git commands keep Gitty responsive during slower operations.",
|
||||
],
|
||||
note: "Branch comparison uses the fully committed state at each branch tip. Uncommitted working-tree changes are not included.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-2",
|
||||
title: "Version 2026.8.2",
|
||||
summary: "This release stabilizes complex history rendering and improves publication of new Gitty versions.",
|
||||
steps: [
|
||||
"Branch colors remain stable across parent lanes, making longer and branching histories easier to follow.",
|
||||
"Release artifacts are attached to the matching Gitea release automatically without uploading duplicates.",
|
||||
"Telemetry cleanup completes more reliably while the application is shutting down.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-1",
|
||||
title: "Version 2026.8.1",
|
||||
summary: "This release makes large commit histories and individual file histories easier to access and improves package distribution.",
|
||||
steps: [
|
||||
"Commit history loads older entries page by page instead of stopping after the initial result set.",
|
||||
"File history opens from the explorer context menu in a dedicated larger dialog instead of occupying a permanent workspace panel.",
|
||||
"Dialogs respond more consistently to the Escape key.",
|
||||
"Windows and Ubuntu publishing plus the AUR package workflow were expanded and made more robust.",
|
||||
"The Arch Linux guide now uses the gitty-desktop AUR package; SSH setup, timeouts, and retry handling were improved.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-07-22",
|
||||
title: "Version 2026.07.22",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
|
||||
import { Cherry, ChevronDown, ChevronRight, CloudOff, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, X } from "@lucide/svelte";
|
||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||
|
||||
interface GraphSegment {
|
||||
@@ -22,55 +22,116 @@
|
||||
graphCommit: GitCommit;
|
||||
}
|
||||
|
||||
type BranchVisibilityMode = "focus" | "local" | "all" | "custom";
|
||||
type CommitRefKind = "local" | "remote" | "head";
|
||||
|
||||
interface CommitBranchDecoration {
|
||||
label: string;
|
||||
kind: CommitRefKind;
|
||||
current: boolean;
|
||||
trackedRemote: string;
|
||||
localOnly: boolean;
|
||||
representedBranches: string[];
|
||||
}
|
||||
|
||||
interface CommitRefSummary {
|
||||
branches: CommitBranchDecoration[];
|
||||
tags: string[];
|
||||
other: string[];
|
||||
primaryBranch: CommitBranchDecoration | null;
|
||||
primaryTag: string;
|
||||
overflowCount: number;
|
||||
}
|
||||
|
||||
const GRAPH_COLORS = [
|
||||
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
|
||||
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
||||
];
|
||||
const GRAPH_LANE = 18;
|
||||
const GRAPH_REF_ARM = 16;
|
||||
const GRAPH_VISIBILITY_STORAGE_PREFIX = "gitlite.graphVisibility.v1:";
|
||||
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
interface Props {
|
||||
commits: GitCommit[];
|
||||
localBranchNames: string[];
|
||||
localBranchUpstreams: Record<string, string>;
|
||||
remoteBranchNames: string[];
|
||||
activeBranch: string;
|
||||
activeUpstream: string;
|
||||
activeAhead: number;
|
||||
activeBehind: number;
|
||||
repositoryKey: string;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
loadMoreError: string;
|
||||
expandedCommitHashes: Set<string>;
|
||||
selectedCommitHash: string;
|
||||
onLoadMore: () => void | Promise<void>;
|
||||
onRestoreCommit: (commit: GitCommit) => void;
|
||||
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||
onToggleCommitFiles: (hash: string) => void;
|
||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||
onCherryPickCommit: (commit: GitCommit) => void;
|
||||
onRevertCommit: (commit: GitCommit) => void;
|
||||
onOpenCommitNote: (commit: GitCommit) => void;
|
||||
onSelectCommit: (commit: GitCommit) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
commits = [],
|
||||
localBranchNames = [],
|
||||
localBranchUpstreams = {},
|
||||
remoteBranchNames = [],
|
||||
activeBranch = "",
|
||||
activeUpstream = "",
|
||||
activeAhead = 0,
|
||||
activeBehind = 0,
|
||||
repositoryKey = "",
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
hasMore = false,
|
||||
isLoadingMore = false,
|
||||
loadMoreError = "",
|
||||
expandedCommitHashes = new Set(),
|
||||
selectedCommitHash = "",
|
||||
onLoadMore = () => {},
|
||||
onRestoreCommit = () => {},
|
||||
onPreviewCommitFile = () => {},
|
||||
onToggleCommitFiles = () => {},
|
||||
onCreateBranchFromCommit = () => {},
|
||||
onCherryPickCommit = () => {},
|
||||
onRevertCommit = () => {},
|
||||
onOpenCommitNote = () => {},
|
||||
onSelectCommit = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
||||
let branchVisibilityMode = $state<BranchVisibilityMode>("focus");
|
||||
let customVisibleBranches = $state<Set<string>>(new Set());
|
||||
let loadedVisibilityRepository = $state("");
|
||||
let branchDialogOpen = $state(false);
|
||||
let userAdjustedBranchFilter = $state(false);
|
||||
let lastDefaultFilterKey = $state("");
|
||||
let expandedRefsCommitHash = $state("");
|
||||
let panelElement = $state<HTMLElement | null>(null);
|
||||
let contextCommit = $state<GitCommit | null>(null);
|
||||
let contextMenuX = $state(0);
|
||||
let contextMenuY = $state(0);
|
||||
|
||||
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];
|
||||
}
|
||||
@@ -117,15 +178,18 @@
|
||||
|
||||
const currentBranches = branchMembership.get(commit.hash) ?? localBranchRefs(commit);
|
||||
const commitBranches = uniqueStrings([...(beforeBranches[col] ?? []), ...currentBranches]);
|
||||
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
|
||||
afterBranches[col] = after[col] ? commitBranches.slice() : [];
|
||||
const firstParent = commit.parents[0] ?? null;
|
||||
after[col] = firstParent;
|
||||
afterBranches[col] = firstParent
|
||||
? (branchMembership.get(firstParent) ?? commitBranches).slice()
|
||||
: [];
|
||||
|
||||
const fromCommit = new Set<number>([col]);
|
||||
for (let p = 1; p < commit.parents.length; p++) {
|
||||
let slot = after.indexOf(null);
|
||||
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
|
||||
after[slot] = commit.parents[p];
|
||||
afterBranches[slot] = [];
|
||||
afterBranches[slot] = (branchMembership.get(commit.parents[p]) ?? commitBranches).slice();
|
||||
fromCommit.add(slot);
|
||||
}
|
||||
|
||||
@@ -200,39 +264,40 @@
|
||||
}
|
||||
|
||||
let localBranchNameSet = $derived(new Set(localBranchNames));
|
||||
let remoteBranchNameSet = $derived(new Set(remoteBranchNames));
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, activeUpstream].filter(Boolean)));
|
||||
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, ...remoteBranchNames, activeUpstream].filter(Boolean)));
|
||||
let graphBranchNameSet = $derived(new Set(graphBranchNames));
|
||||
|
||||
function focusBranchNames(): string[] {
|
||||
const focused = uniqueStrings([activeBranch, activeUpstream].filter((branch) => graphBranchNameSet.has(branch)));
|
||||
if (focused.length > 0) return focused;
|
||||
return localBranchNames[0] ? [localBranchNames[0]] : graphBranchNames.slice(0, 1);
|
||||
}
|
||||
|
||||
function branchNamesForMode(): string[] {
|
||||
if (branchVisibilityMode === "focus") return focusBranchNames();
|
||||
if (branchVisibilityMode === "local") return localBranchNames;
|
||||
if (branchVisibilityMode === "all") return graphBranchNames;
|
||||
return graphBranchNames.filter((branch) => customVisibleBranches.has(branch));
|
||||
}
|
||||
|
||||
let visibleGraphBranchNames = $derived(branchNamesForMode());
|
||||
let visibleGraphBranchNameSet = $derived(new Set(visibleGraphBranchNames));
|
||||
|
||||
function branchIsVisible(branch: string): boolean {
|
||||
if (branch === activeUpstream && activeUpstream) {
|
||||
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
|
||||
}
|
||||
return !hiddenGraphBranches.has(branch);
|
||||
}
|
||||
|
||||
function visibleBranchLabels(labels: string[]): string[] {
|
||||
return labels.filter(branchIsVisible);
|
||||
}
|
||||
|
||||
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
|
||||
const directBranches = graphBranchRefs(commit);
|
||||
if (directBranches.length > 0) return directBranches;
|
||||
|
||||
const containingBranches = row?.branchLabels ?? [];
|
||||
if (containingBranches.length <= 3) return containingBranches;
|
||||
return [...containingBranches.slice(0, 3), `+${containingBranches.length - 3} more`];
|
||||
return visibleGraphBranchNameSet.has(branch);
|
||||
}
|
||||
|
||||
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
||||
const directBranches = graphBranchRefs(commit);
|
||||
const directBranches = graphBranchRefs(commit).filter(branchIsVisible);
|
||||
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
||||
|
||||
const containingBranches = row?.branchLabels ?? [];
|
||||
const containingBranches = (row?.branchLabels ?? []).filter(branchIsVisible);
|
||||
if (containingBranches.length === 0) return commit.short_hash;
|
||||
return `Branches containing this commit: ${containingBranches.join(", ")}`;
|
||||
}
|
||||
@@ -252,7 +317,7 @@
|
||||
|
||||
function branchesAreVisible(branches: string[]): boolean {
|
||||
if (graphBranchNames.length === 0) return true;
|
||||
return branches.some(branchIsVisible);
|
||||
return branches.some((branch) => visibleGraphBranchNameSet.has(branch));
|
||||
}
|
||||
|
||||
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
|
||||
@@ -329,20 +394,25 @@
|
||||
}
|
||||
|
||||
function toggleGraphBranch(branch: string) {
|
||||
const next = new Set(hiddenGraphBranches);
|
||||
const next = branchVisibilityMode === "custom"
|
||||
? new Set(customVisibleBranches)
|
||||
: new Set(visibleGraphBranchNames);
|
||||
if (next.has(branch)) next.delete(branch); else next.add(branch);
|
||||
hiddenGraphBranches = next;
|
||||
userAdjustedBranchFilter = true;
|
||||
customVisibleBranches = next;
|
||||
branchVisibilityMode = "custom";
|
||||
}
|
||||
|
||||
function showAllGraphBranches() {
|
||||
hiddenGraphBranches = new Set();
|
||||
userAdjustedBranchFilter = true;
|
||||
branchVisibilityMode = "all";
|
||||
}
|
||||
|
||||
function hideAllGraphBranches() {
|
||||
hiddenGraphBranches = new Set(localBranchNames);
|
||||
userAdjustedBranchFilter = true;
|
||||
customVisibleBranches = new Set();
|
||||
branchVisibilityMode = "custom";
|
||||
}
|
||||
|
||||
function showFocusGraphBranches() {
|
||||
branchVisibilityMode = "focus";
|
||||
}
|
||||
|
||||
function openBranchDialog() {
|
||||
@@ -359,6 +429,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCommitRefs(commit: GitCommit) {
|
||||
expandedRefsCommitHash = expandedRefsCommitHash === commit.hash ? "" : commit.hash;
|
||||
}
|
||||
|
||||
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -415,9 +489,24 @@
|
||||
await onRevertCommit(commit);
|
||||
}
|
||||
|
||||
async function openContextCommitNote() {
|
||||
const commit = contextCommit;
|
||||
if (!commit || isBusy) return;
|
||||
closeCommitContextMenu();
|
||||
onSelectCommit(commit);
|
||||
await onOpenCommitNote(commit);
|
||||
}
|
||||
|
||||
async function openCommitNote(commit: GitCommit) {
|
||||
if (isBusy) return;
|
||||
onSelectCommit(commit);
|
||||
await onOpenCommitNote(commit);
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape") return;
|
||||
closeCommitContextMenu();
|
||||
expandedRefsCommitHash = "";
|
||||
handleBranchDialogKeydown(event);
|
||||
}
|
||||
|
||||
@@ -445,66 +534,217 @@
|
||||
return labels;
|
||||
}
|
||||
|
||||
function visibleRefs(commit: GitCommit): string[] {
|
||||
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
|
||||
}
|
||||
|
||||
function refClass(ref: string): string {
|
||||
if (ref.startsWith("HEAD")) return "head";
|
||||
if (ref.startsWith("tag:")) return "tag";
|
||||
if (localBranchNameSet.has(refLabel(ref))) return "branch";
|
||||
if (ref.includes("/")) return "remote";
|
||||
return "branch";
|
||||
}
|
||||
|
||||
function refLabel(ref: string): string {
|
||||
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
|
||||
}
|
||||
|
||||
function isSymbolicRemoteHead(ref: string): boolean {
|
||||
if (ref.startsWith("HEAD ->") || ref.startsWith("tag:")) return false;
|
||||
const label = refLabel(ref);
|
||||
return !localBranchNameSet.has(label) && /(?:^|\/)HEAD(?:\s*->|$)/.test(ref);
|
||||
}
|
||||
|
||||
function configuredUpstreamForBranch(local: string): string {
|
||||
return localBranchUpstreams[local] ?? (local === activeBranch ? activeUpstream : "");
|
||||
}
|
||||
|
||||
function matchingRemoteBranch(local: string, remoteBranches: string[]): string {
|
||||
const configuredUpstream = configuredUpstreamForBranch(local);
|
||||
return configuredUpstream && remoteBranches.includes(configuredUpstream) ? configuredUpstream : "";
|
||||
}
|
||||
|
||||
function compareBranchDecorations(left: CommitBranchDecoration, right: CommitBranchDecoration): number {
|
||||
const priority = (item: CommitBranchDecoration) => {
|
||||
if (item.label === activeBranch) return 0;
|
||||
if (item.kind === "head") return 1;
|
||||
if (item.kind === "local") return 2;
|
||||
if (item.label === activeUpstream) return 3;
|
||||
return 4;
|
||||
};
|
||||
return priority(left) - priority(right) || left.label.localeCompare(right.label, undefined, { numeric: true });
|
||||
}
|
||||
|
||||
function commitRefSummary(commit: GitCommit): CommitRefSummary {
|
||||
const local = new Set<string>();
|
||||
const remote = new Set<string>();
|
||||
const tags = new Set<string>();
|
||||
const other = new Set<string>();
|
||||
let detachedHead = false;
|
||||
|
||||
for (const ref of commit.refs) {
|
||||
if (isSymbolicRemoteHead(ref)) continue;
|
||||
const label = refLabel(ref);
|
||||
if (!label) continue;
|
||||
if (ref.startsWith("tag:")) {
|
||||
tags.add(label);
|
||||
} else if (localBranchNameSet.has(label)) {
|
||||
local.add(label);
|
||||
} else if (remoteBranchNameSet.has(label) || label === activeUpstream) {
|
||||
remote.add(label);
|
||||
} else if (label === "HEAD") {
|
||||
detachedHead = true;
|
||||
} else {
|
||||
other.add(label);
|
||||
}
|
||||
}
|
||||
|
||||
const remainingRemote = [...remote];
|
||||
const branches: CommitBranchDecoration[] = [...local].map((label) => {
|
||||
const configuredUpstream = configuredUpstreamForBranch(label);
|
||||
const trackedRemote = matchingRemoteBranch(label, remainingRemote);
|
||||
if (trackedRemote) remainingRemote.splice(remainingRemote.indexOf(trackedRemote), 1);
|
||||
return {
|
||||
label,
|
||||
kind: "local",
|
||||
current: label === activeBranch,
|
||||
trackedRemote,
|
||||
localOnly: !configuredUpstream,
|
||||
representedBranches: trackedRemote ? [label, trackedRemote] : [label],
|
||||
};
|
||||
});
|
||||
|
||||
if (detachedHead) {
|
||||
branches.push({
|
||||
label: "HEAD",
|
||||
kind: "head",
|
||||
current: true,
|
||||
trackedRemote: "",
|
||||
localOnly: false,
|
||||
representedBranches: [],
|
||||
});
|
||||
}
|
||||
|
||||
branches.push(...remainingRemote.map((label) => ({
|
||||
label,
|
||||
kind: "remote" as const,
|
||||
current: false,
|
||||
trackedRemote: "",
|
||||
localOnly: false,
|
||||
representedBranches: [label],
|
||||
})));
|
||||
|
||||
const sortedBranches = branches.sort(compareBranchDecorations);
|
||||
const primaryBranch = sortedBranches.find(
|
||||
(branch) => branch.kind === "head" || branch.representedBranches.some(branchIsVisible),
|
||||
) ?? null;
|
||||
const sortedTags = [...tags].sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
|
||||
const sortedOther = [...other].sort((left, right) => left.localeCompare(right));
|
||||
const primaryTag = sortedTags[0] ?? "";
|
||||
|
||||
return {
|
||||
branches: sortedBranches,
|
||||
tags: sortedTags,
|
||||
other: sortedOther,
|
||||
primaryBranch,
|
||||
primaryTag,
|
||||
overflowCount:
|
||||
Math.max(0, sortedBranches.length - (primaryBranch ? 1 : 0))
|
||||
+ Math.max(0, sortedTags.length - (primaryTag ? 1 : 0))
|
||||
+ sortedOther.length,
|
||||
};
|
||||
}
|
||||
|
||||
function branchStatusLabel(branch: CommitBranchDecoration): string {
|
||||
if (branch.label === activeBranch) {
|
||||
const parts = [];
|
||||
if (activeAhead > 0) parts.push(`↑${activeAhead}`);
|
||||
if (activeBehind > 0) parts.push(`↓${activeBehind}`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
if (branch.label === activeUpstream && activeBehind > 0) return `↓${activeBehind}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function branchDecorationTitle(branch: CommitBranchDecoration): string {
|
||||
if (branch.localOnly) {
|
||||
const status = branchStatusLabel(branch);
|
||||
return `${branch.label} · Local only — not published yet${status ? ` · ${status}` : ""}`;
|
||||
}
|
||||
const status = branchStatusLabel(branch);
|
||||
if (branch.trackedRemote) {
|
||||
return `${branch.label} · Tracks ${branch.trackedRemote}${status ? ` · ${status}` : ""}`;
|
||||
}
|
||||
if (status) return `${branch.label} · ${status}`;
|
||||
return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`;
|
||||
}
|
||||
|
||||
function formatCommitDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
return commitDateFormatter.format(date);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const available = new Set(localBranchNames);
|
||||
const nextHidden = new Set([...hiddenGraphBranches].filter((branch) => available.has(branch)));
|
||||
if (nextHidden.size !== hiddenGraphBranches.size) {
|
||||
hiddenGraphBranches = nextHidden;
|
||||
const currentRepository = repositoryKey;
|
||||
if (loadedVisibilityRepository === currentRepository) return;
|
||||
loadedVisibilityRepository = currentRepository;
|
||||
expandedRefsCommitHash = "";
|
||||
|
||||
if (!currentRepository) {
|
||||
branchVisibilityMode = "focus";
|
||||
customVisibleBranches = new Set();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`);
|
||||
if (!stored) {
|
||||
branchVisibilityMode = "focus";
|
||||
customVisibleBranches = new Set();
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(stored) as { mode?: unknown; branches?: unknown };
|
||||
const mode = parsed.mode;
|
||||
branchVisibilityMode = mode === "focus" || mode === "local" || mode === "all" || mode === "custom"
|
||||
? mode
|
||||
: "focus";
|
||||
customVisibleBranches = new Set(
|
||||
Array.isArray(parsed.branches)
|
||||
? parsed.branches.filter((branch): branch is string => typeof branch === "string")
|
||||
: [],
|
||||
);
|
||||
} catch {
|
||||
branchVisibilityMode = "focus";
|
||||
customVisibleBranches = new Set();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
|
||||
? activeBranch
|
||||
: (graphBranchNames[0] ?? "");
|
||||
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
|
||||
|
||||
if (!defaultBranch) {
|
||||
if (lastDefaultFilterKey !== defaultFilterKey) {
|
||||
hiddenGraphBranches = new Set();
|
||||
userAdjustedBranchFilter = false;
|
||||
lastDefaultFilterKey = defaultFilterKey;
|
||||
}
|
||||
return;
|
||||
const currentRepository = repositoryKey;
|
||||
const mode = branchVisibilityMode;
|
||||
const branches = [...customVisibleBranches];
|
||||
if (!currentRepository || loadedVisibilityRepository !== currentRepository) return;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`,
|
||||
JSON.stringify({ mode, branches }),
|
||||
);
|
||||
} catch {
|
||||
// The graph still works if storage is unavailable.
|
||||
}
|
||||
});
|
||||
|
||||
if (lastDefaultFilterKey !== defaultFilterKey) {
|
||||
userAdjustedBranchFilter = false;
|
||||
lastDefaultFilterKey = defaultFilterKey;
|
||||
}
|
||||
$effect(() => {
|
||||
if (graphBranchNames.length === 0 || customVisibleBranches.size === 0) return;
|
||||
const available = new Set(graphBranchNames);
|
||||
const next = new Set([...customVisibleBranches].filter((branch) => available.has(branch)));
|
||||
if (next.size !== customVisibleBranches.size) customVisibleBranches = next;
|
||||
});
|
||||
|
||||
if (!userAdjustedBranchFilter) {
|
||||
hiddenGraphBranches = new Set(localBranchNames.filter((branch) => branch !== defaultBranch));
|
||||
}
|
||||
$effect(() => {
|
||||
if (!selectedCommitHash) return;
|
||||
queueMicrotask(() => {
|
||||
panelElement
|
||||
?.querySelector<HTMLElement>(`[data-commit-hash="${CSS.escape(selectedCommitHash)}"]`)
|
||||
?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
});
|
||||
});
|
||||
|
||||
let branchMembership = $derived(branchMembershipByHash(commits));
|
||||
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
|
||||
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
|
||||
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
|
||||
let visibleBranchCount = $derived(localBranchNames.filter(branchIsVisible).length);
|
||||
let visibleBranchCount = $derived(visibleGraphBranchNames.length);
|
||||
let graph = $derived(computeGraph(graphCommits, branchMembership));
|
||||
let graphRows = $derived(graph.rows);
|
||||
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
||||
@@ -518,41 +758,41 @@
|
||||
<span class="eyebrow">History</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
||||
</div>
|
||||
<div class="section-head-actions">
|
||||
{#if localBranchNames.length > 0}
|
||||
{#if graphBranchNames.length > 0}
|
||||
<div class="section-head-actions">
|
||||
<button
|
||||
class="graph-branch-dialog-button"
|
||||
type="button"
|
||||
onclick={openBranchDialog}
|
||||
title="Select branches shown in the graph"
|
||||
title="Customize visible branches"
|
||||
aria-label={`${visibleBranchCount} of ${graphBranchNames.length} branches visible. Customize branches.`}
|
||||
>
|
||||
<GitBranch size={13} aria-hidden="true" />
|
||||
Branches
|
||||
<span>{visibleBranchCount}/{localBranchNames.length}</span>
|
||||
<span>{visibleBranchCount}/{graphBranchNames.length}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<span class="pill pill-count" title={visibleCommits.length === commits.length ? "Commits" : `${visibleCommits.length} of ${commits.length} commits shown`}>
|
||||
{visibleCommits.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
{:else if commits.length === 0}
|
||||
<div class="blank-state">No commits returned.</div>
|
||||
{:else if visibleCommits.length === 0}
|
||||
<div class="blank-state">No commits match the selected branches.</div>
|
||||
{:else}
|
||||
<div class="history-list graph-list overflow-auto">
|
||||
{#if visibleCommits.length === 0}
|
||||
<div class="blank-state">No loaded commits match the selected branches.</div>
|
||||
{/if}
|
||||
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
||||
{@const item = entry.commit}
|
||||
{@const row = graphRows[rowIndex]}
|
||||
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
|
||||
{@const otherRefs = visibleRefs(item)}
|
||||
{@const refSummary = commitRefSummary(item)}
|
||||
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
||||
<article
|
||||
class="commit-row graph-row"
|
||||
class:selected={selectedCommitHash === item.hash}
|
||||
data-commit-hash={item.hash}
|
||||
class:graph-ahead-row={rowSyncClass === "ahead"}
|
||||
class:graph-behind-row={rowSyncClass === "behind"}
|
||||
class:merge-row={item.parents.length > 1}
|
||||
@@ -584,6 +824,15 @@
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/each}
|
||||
{#if refSummary.primaryBranch}
|
||||
<path
|
||||
class="graph-ref-connector"
|
||||
d={`M ${graphColX(row.dotCol)} 50 L ${graphWidth - GRAPH_REF_ARM * 2} 50`}
|
||||
stroke={row.dotColor}
|
||||
stroke-width="1.5"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<span
|
||||
class="graph-dot"
|
||||
@@ -595,25 +844,111 @@
|
||||
title={commitHoverTitle(item, row)}
|
||||
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
||||
></span>
|
||||
{#if hoverBranchRefs.length > 0}
|
||||
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
|
||||
{#each hoverBranchRefs as branch}
|
||||
<span
|
||||
class:remote={branch === activeUpstream}
|
||||
class:ahead={activeBranch === branch && rowSyncClass === "ahead"}
|
||||
class:behind={activeUpstream === branch && rowSyncClass === "behind"}
|
||||
title={branch}
|
||||
>
|
||||
<GitBranch size={10} aria-hidden="true" />
|
||||
{branch}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="commit-body">
|
||||
<div
|
||||
class="commit-body"
|
||||
class:has-branch-ref={Boolean(refSummary.primaryBranch)}
|
||||
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`}
|
||||
>
|
||||
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
|
||||
<div class="commit-ref-area">
|
||||
<div class="commit-ref-strip" aria-label="Commit references">
|
||||
{#if refSummary.primaryBranch}
|
||||
<span class="branch-ref-cluster" class:local-only={refSummary.primaryBranch.localOnly}>
|
||||
<span
|
||||
class="compact-ref-chip branch"
|
||||
class:current={refSummary.primaryBranch.current}
|
||||
class:remote={refSummary.primaryBranch.kind === "remote"}
|
||||
title={branchDecorationTitle(refSummary.primaryBranch)}
|
||||
>
|
||||
<GitBranch class="compact-ref-branch-icon" size={10} aria-hidden="true" />
|
||||
<span>{refSummary.primaryBranch.label}</span>
|
||||
{#if !refSummary.primaryBranch.localOnly && branchStatusLabel(refSummary.primaryBranch)}
|
||||
<small>{branchStatusLabel(refSummary.primaryBranch)}</small>
|
||||
{/if}
|
||||
</span>
|
||||
{#if refSummary.primaryBranch.localOnly}
|
||||
<span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet">
|
||||
LOCAL
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if refSummary.primaryTag}
|
||||
<span class="compact-ref-chip tag" title={`Tag ${refSummary.primaryTag}`}>
|
||||
<Tag size={10} aria-hidden="true" />
|
||||
<span>{refSummary.primaryTag}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if refSummary.overflowCount > 0}
|
||||
<button
|
||||
class="compact-ref-overflow"
|
||||
type="button"
|
||||
onclick={() => toggleCommitRefs(item)}
|
||||
aria-expanded={expandedRefsCommitHash === item.hash}
|
||||
aria-controls={`commit-refs-${item.hash}`}
|
||||
title={`Show ${refSummary.overflowCount} more ${refSummary.overflowCount === 1 ? "reference" : "references"}`}
|
||||
>
|
||||
+{refSummary.overflowCount}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if refSummary.overflowCount > 0 && expandedRefsCommitHash === item.hash}
|
||||
<div class="commit-ref-details" id={`commit-refs-${item.hash}`}>
|
||||
<strong>References on this commit</strong>
|
||||
{#if refSummary.branches.some((branch) => branch.kind !== "remote")}
|
||||
<section>
|
||||
<span>Local</span>
|
||||
<div>
|
||||
{#each refSummary.branches.filter((branch) => branch.kind !== "remote") as branch}
|
||||
<span class="commit-ref-detail-item local" title={branchDecorationTitle(branch)}>
|
||||
<i aria-hidden="true"></i>{branch.label}
|
||||
{#if branch.current}<small>Current</small>{/if}
|
||||
{#if branch.trackedRemote}<small>{branch.trackedRemote}</small>{/if}
|
||||
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />Local only</small>{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{#if refSummary.branches.some((branch) => branch.kind === "remote")}
|
||||
<section>
|
||||
<span>Remote</span>
|
||||
<div>
|
||||
{#each refSummary.branches.filter((branch) => branch.kind === "remote") as branch}
|
||||
<span class="commit-ref-detail-item remote"><i aria-hidden="true"></i>{branch.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{#if refSummary.tags.length > 0}
|
||||
<section>
|
||||
<span>Tags</span>
|
||||
<div>
|
||||
{#each refSummary.tags as tag}
|
||||
<span class="commit-ref-detail-item tag"><Tag size={10} aria-hidden="true" />{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{#if refSummary.other.length > 0}
|
||||
<section>
|
||||
<span>Other</span>
|
||||
<div>
|
||||
{#each refSummary.other as ref}
|
||||
<span class="commit-ref-detail-item">{ref}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="commit-card-head">
|
||||
<span class="commit-avatar">
|
||||
{authorInitials(item.author_name)}
|
||||
@@ -635,14 +970,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if otherRefs.length > 0}
|
||||
<div class="ref-list" aria-label="Commit refs">
|
||||
{#each otherRefs as ref}
|
||||
<span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.files.length > 0}
|
||||
<div class="commit-files">
|
||||
<button
|
||||
@@ -681,6 +1008,16 @@
|
||||
<div class="commit-actions">
|
||||
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||
<div class="commit-action-buttons">
|
||||
<button
|
||||
class="commit-menu-button commit-note-button"
|
||||
type="button"
|
||||
onclick={() => openCommitNote(item)}
|
||||
disabled={isBusy}
|
||||
title={`Open internal note for ${item.short_hash}`}
|
||||
aria-label={`Open internal note for ${item.short_hash}`}
|
||||
>
|
||||
<StickyNote size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="commit-menu-button"
|
||||
type="button"
|
||||
@@ -698,6 +1035,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}
|
||||
|
||||
@@ -713,6 +1065,10 @@
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
Branch
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextCommitNote} disabled={isBusy}>
|
||||
<StickyNote size={14} aria-hidden="true" />
|
||||
Note
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Restore
|
||||
@@ -753,25 +1109,43 @@
|
||||
</header>
|
||||
|
||||
<div class="branch-filter-summary">
|
||||
<span>{visibleBranchCount} of {localBranchNames.length} branches selected</span>
|
||||
<span>{visibleBranchCount} of {graphBranchNames.length} branches selected</span>
|
||||
<div class="branch-filter-actions">
|
||||
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === localBranchNames.length}>Show all</button>
|
||||
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>Focus</button>
|
||||
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>Show all</button>
|
||||
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="branch-filter-dialog-list">
|
||||
{#each localBranchNames as branch}
|
||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branchIsVisible(branch)}
|
||||
onchange={() => toggleGraphBranch(branch)}
|
||||
/>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<span>{branch}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{#if localBranchNames.length > 0}
|
||||
<span class="branch-filter-group-label">Local</span>
|
||||
{#each localBranchNames as branch}
|
||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branchIsVisible(branch)}
|
||||
onchange={() => toggleGraphBranch(branch)}
|
||||
/>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<span>{branch}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if remoteBranchNames.length > 0}
|
||||
<span class="branch-filter-group-label">Remote</span>
|
||||
{#each remoteBranchNames as branch}
|
||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option remote" title={branch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branchIsVisible(branch)}
|
||||
onchange={() => toggleGraphBranch(branch)}
|
||||
/>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<span>{branch}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Check, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
|
||||
import { Check, ExternalLink, FileDiff, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
|
||||
import type { GitFileStatus, PatchApplyAction } from "../types";
|
||||
|
||||
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
||||
@@ -34,9 +34,12 @@
|
||||
isBusy: boolean;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
language?: "en" | "de";
|
||||
diffName?: string;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
|
||||
onExternalDiff: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -46,11 +49,16 @@
|
||||
isBusy = false,
|
||||
isLoading = false,
|
||||
error = "",
|
||||
language = "en",
|
||||
diffName = "diff tool",
|
||||
onClose = () => {},
|
||||
onRefresh = () => {},
|
||||
onApply = () => {},
|
||||
onExternalDiff = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
const isGerman = $derived(language === "de");
|
||||
|
||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||
let patchScroll = $state<HTMLDivElement | null>(null);
|
||||
let selectedLineIds = $state<Set<string>>(new Set());
|
||||
@@ -270,6 +278,15 @@
|
||||
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
<div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}>
|
||||
<span>{isGerman ? "Öffnen mit" : "Open with"}</span>
|
||||
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}>
|
||||
<FileDiff size={13} aria-hidden="true" />Gitty
|
||||
</button>
|
||||
<button type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={isGerman ? `In ${diffName} öffnen` : `Open in ${diffName}`}>
|
||||
<ExternalLink size={13} aria-hidden="true" /><span>{diffName}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<X size={16} aria-hidden="true" />
|
||||
|
||||
@@ -18,14 +18,29 @@
|
||||
|
||||
let name = $state("");
|
||||
|
||||
function remoteName(branch: GitBranchInfo): string {
|
||||
if (!branch.remote) return "";
|
||||
const slash = branch.name.indexOf("/");
|
||||
return slash > 0 ? branch.name.slice(0, slash) : branch.name;
|
||||
}
|
||||
|
||||
function editableName(branch: GitBranchInfo): string {
|
||||
if (!branch.remote) return branch.name;
|
||||
const slash = branch.name.indexOf("/");
|
||||
return slash >= 0 ? branch.name.slice(slash + 1) : branch.name;
|
||||
}
|
||||
|
||||
let originalName = $derived(editableName(branch));
|
||||
let remote = $derived(remoteName(branch));
|
||||
|
||||
$effect(() => {
|
||||
name = branch.name;
|
||||
name = originalName;
|
||||
});
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const value = name.trim();
|
||||
if (!value || value === branch.name) return;
|
||||
if (!value || value === originalName) return;
|
||||
onRename(value);
|
||||
}
|
||||
</script>
|
||||
@@ -34,10 +49,10 @@
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label={branch.remote ? "Rename remote branch" : "Rename branch"} tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Rename branch</span>
|
||||
<span class="eyebrow">{branch.remote ? "Rename remote branch" : "Rename branch"}</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
@@ -48,27 +63,37 @@
|
||||
<form class="rename-branch-form" onsubmit={submit}>
|
||||
<label class="new-branch-field">
|
||||
<span>Branch name</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={name}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
/>
|
||||
<div class:remote-branch-name-field={branch.remote}>
|
||||
{#if branch.remote}<strong>{remote}/</strong>{/if}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={name}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{#if branch.remote}
|
||||
<p class="rename-remote-note">
|
||||
Gitty creates <strong>{remote}/{name.trim() || "new-name"}</strong> and removes
|
||||
<strong>{branch.name}</strong> in one atomic push. The operation stops if the remote changed in the meantime.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="new-branch-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === branch.name}>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === originalName}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Rename
|
||||
{branch.remote ? "Rename on remote" : "Rename"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { AlertCircle, Check, LoaderCircle, X } from "@lucide/svelte";
|
||||
import { AlertCircle, Check, ExternalLink, GitMerge, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
|
||||
|
||||
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
|
||||
@@ -24,6 +24,9 @@
|
||||
onSelectFile: (path: string) => void;
|
||||
onMarkResolved: (path: string, resolution: PreparedResolution) => void;
|
||||
onApply: () => void;
|
||||
onExternalMerge: (path: string) => void | Promise<void>;
|
||||
language?: "en" | "de";
|
||||
mergeName?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -37,7 +40,11 @@
|
||||
onSelectFile = () => {},
|
||||
onMarkResolved = () => {},
|
||||
onApply = () => {},
|
||||
onExternalMerge = () => {},
|
||||
language = "en",
|
||||
mergeName = "merge tool",
|
||||
}: Props = $props();
|
||||
const isGerman = $derived(language === "de");
|
||||
|
||||
let conflictParts = $derived<ConflictPart[]>(
|
||||
conflict && !conflict.binary ? parseConflicts(conflict.content) : [],
|
||||
@@ -268,9 +275,22 @@
|
||||
<span class="eyebrow">Resolve</span>
|
||||
<h2 class="dialog-title">Conflicts</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<div class="dialog-header-actions">
|
||||
{#if conflictTarget}
|
||||
<div class="tool-surface-choice" aria-label={isGerman ? "Konflikt bearbeiten mit" : "Edit conflict with"}>
|
||||
<span>{isGerman ? "Bearbeiten mit" : "Edit with"}</span>
|
||||
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty bearbeiten" : "Edit in Gitty"}>
|
||||
<GitMerge size={13} aria-hidden="true" />Gitty
|
||||
</button>
|
||||
<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={isGerman ? `In ${mergeName} öffnen` : `Open in ${mergeName}`}>
|
||||
<ExternalLink size={13} aria-hidden="true" /><span>{mergeName}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Konflikte schließen" : "Close conflicts"}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if conflictedFiles.length === 0}
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { StoredCredential } from "./types";
|
||||
|
||||
/**
|
||||
* Derives a credential key from a remote URL, scoped to host + organisation —
|
||||
* the same granularity Azure DevOps / GitHub use. Examples:
|
||||
@@ -37,13 +35,6 @@ export function orgKeyFromUrl(raw: string): string | null {
|
||||
return org ? `${host}/${org}` : host;
|
||||
}
|
||||
|
||||
/** A stored credential is expired only if it carries a past expiry date. */
|
||||
export function isCredentialExpired(cred: StoredCredential): boolean {
|
||||
if (!cred.expiresAt) return false;
|
||||
const time = new Date(cred.expiresAt).getTime();
|
||||
return !Number.isNaN(time) && time < Date.now();
|
||||
}
|
||||
|
||||
const AUTH_PREFIX = "AUTH_FAILED:";
|
||||
|
||||
export function isAuthError(message: string): boolean {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import type {
|
||||
DetectedExternalTool,
|
||||
ExternalToolKind,
|
||||
ExternalToolSetting,
|
||||
ExternalToolsSettings,
|
||||
ToolOpenMode,
|
||||
} from "./types";
|
||||
|
||||
export type { ExternalToolKind } from "./types";
|
||||
|
||||
export interface ExternalToolPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
program: string;
|
||||
args: string[];
|
||||
builtIn?: boolean;
|
||||
}
|
||||
|
||||
type ToolIdentity = Pick<ExternalToolPreset, "id" | "label" | "program">;
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
export const externalToolsPlatform = userAgent.includes("Windows")
|
||||
? "windows"
|
||||
: userAgent.includes("Mac")
|
||||
? "macos"
|
||||
: "linux";
|
||||
|
||||
const codeFamily: ToolIdentity[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "vscode", label: "Visual Studio Code", program: "Code.exe" },
|
||||
{ id: "vscode-insiders", label: "Visual Studio Code Insiders", program: "Code - Insiders.exe" },
|
||||
{ id: "cursor", label: "Cursor", program: "Cursor.exe" },
|
||||
{ id: "windsurf", label: "Windsurf", program: "Windsurf.exe" },
|
||||
{ id: "vscodium", label: "VSCodium", program: "VSCodium.exe" },
|
||||
]
|
||||
: [
|
||||
{ id: "vscode", label: "Visual Studio Code", program: "code" },
|
||||
{ id: "vscode-insiders", label: "Visual Studio Code Insiders", program: "code-insiders" },
|
||||
{ id: "cursor", label: "Cursor", program: "cursor" },
|
||||
{ id: "windsurf", label: "Windsurf", program: "windsurf" },
|
||||
{ id: "vscodium", label: "VSCodium", program: "codium" },
|
||||
];
|
||||
|
||||
const jetBrains: ToolIdentity[] = [
|
||||
{ id: "intellij-idea", label: "IntelliJ IDEA", program: externalToolsPlatform === "windows" ? "idea64.exe" : "idea" },
|
||||
{ id: "webstorm", label: "WebStorm", program: externalToolsPlatform === "windows" ? "webstorm64.exe" : "webstorm" },
|
||||
{ id: "pycharm", label: "PyCharm", program: externalToolsPlatform === "windows" ? "pycharm64.exe" : "pycharm" },
|
||||
{ id: "phpstorm", label: "PhpStorm", program: externalToolsPlatform === "windows" ? "phpstorm64.exe" : "phpstorm" },
|
||||
{ id: "rider", label: "JetBrains Rider", program: externalToolsPlatform === "windows" ? "rider64.exe" : "rider" },
|
||||
{ id: "clion", label: "CLion", program: externalToolsPlatform === "windows" ? "clion64.exe" : "clion" },
|
||||
{ id: "rustrover", label: "RustRover", program: externalToolsPlatform === "windows" ? "rustrover64.exe" : "rustrover" },
|
||||
{ id: "goland", label: "GoLand", program: externalToolsPlatform === "windows" ? "goland64.exe" : "goland" },
|
||||
];
|
||||
|
||||
const editor = (tool: ToolIdentity, args = ["{file}"]): ExternalToolPreset => ({ ...tool, args });
|
||||
const codeDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--new-window", "--wait", "--diff", "{left}", "{right}"] });
|
||||
const codeMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--new-window", "--wait", "--merge", "{ours}", "{theirs}", "{base}", "{result}"] });
|
||||
const jetBrainsDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["diff", "{left}", "{right}"] });
|
||||
const jetBrainsMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["merge", "{ours}", "{theirs}", "{base}", "{result}"] });
|
||||
|
||||
const commonEditors: ExternalToolPreset[] = [
|
||||
...codeFamily.map((item) => editor(item, ["--new-window", "{file}"])),
|
||||
editor({ id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed" }, ["--new", "{file}"]),
|
||||
editor({ id: "sublime-text", label: "Sublime Text", program: externalToolsPlatform === "windows" ? "subl.exe" : "subl" }, ["--new-window", "{file}"]),
|
||||
...jetBrains.map((item) => editor(item)),
|
||||
editor({ id: "neovim", label: "Neovim", program: externalToolsPlatform === "windows" ? "nvim.exe" : "nvim" }),
|
||||
editor({ id: "vim", label: "Vim", program: externalToolsPlatform === "windows" ? "gvim.exe" : "gvim" }),
|
||||
editor({ id: "emacs", label: "Emacs", program: externalToolsPlatform === "windows" ? "runemacs.exe" : "emacs" }),
|
||||
];
|
||||
|
||||
const platformEditors: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
editor({ id: "notepad-plus-plus", label: "Notepad++", program: "notepad++.exe" }, ["-multiInst", "{file}"]),
|
||||
{ ...editor({ id: "notepad", label: "Windows Notepad", program: "notepad.exe" }), builtIn: true },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
editor({ id: "nova", label: "Nova", program: "nova" }),
|
||||
editor({ id: "textmate", label: "TextMate", program: "mate" }),
|
||||
editor({ id: "bbedit", label: "BBEdit", program: "bbedit" }),
|
||||
editor({ id: "xcode", label: "Xcode", program: "xed" }),
|
||||
]
|
||||
: [
|
||||
editor({ id: "lapce", label: "Lapce", program: "lapce" }),
|
||||
editor({ id: "kate", label: "Kate", program: "kate" }, ["--new", "{file}"]),
|
||||
editor({ id: "gedit", label: "GNOME Text Editor", program: "gnome-text-editor" }),
|
||||
editor({ id: "geany", label: "Geany", program: "geany" }, ["--new-instance", "{file}"]),
|
||||
editor({ id: "helix", label: "Helix", program: "hx" }),
|
||||
];
|
||||
|
||||
const dedicatedDiff: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/solo", "/readonly", "{left}", "{right}"] },
|
||||
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/s-", "/u", "/e", "/wl", "/wr", "{left}", "{right}"] },
|
||||
{ id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "{left}", "{right}"] },
|
||||
{ id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{left}", "{right}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{left}", "{right}"] },
|
||||
{ id: "araxis-merge", label: "Araxis Merge", program: "Compare.exe", args: ["/wait", "{left}", "{right}"] },
|
||||
{ id: "tortoisegitmerge", label: "TortoiseGitMerge", program: "TortoiseGitMerge.exe", args: ["/base:{left}", "/mine:{right}"] },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{left}", "{right}"] },
|
||||
{ id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--wait", "{left}", "{right}"] },
|
||||
{ id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{left}", "{right}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] },
|
||||
{ id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{left}", "{right}"] },
|
||||
]
|
||||
: [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{left}", "{right}"] },
|
||||
{ id: "meld", label: "Meld", program: "meld", args: ["--wait", "{left}", "{right}"] },
|
||||
{ id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{left}", "{right}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] },
|
||||
{ id: "kompare", label: "Kompare", program: "kompare", args: ["{left}", "{right}"] },
|
||||
];
|
||||
|
||||
const dedicatedMerge: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/s-", "/u", "/e", "{ours}", "{theirs}", "{base}", "/o", "{result}"] },
|
||||
{ id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] },
|
||||
{ id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||
{ id: "araxis-merge", label: "Araxis Merge", program: "Compare.exe", args: ["/wait", "{ours}", "{base}", "{theirs}", "{result}"] },
|
||||
{ id: "tortoisegitmerge", label: "TortoiseGitMerge", program: "TortoiseGitMerge.exe", args: ["/base:{base}", "/theirs:{theirs}", "/mine:{ours}", "/merged:{result}"] },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||
{ id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--merge", "--output", "{result}", "{base}", "{ours}", "{theirs}"] },
|
||||
{ id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{ours}", "{base}", "{theirs}", "{result}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||
{ id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{ours}", "{theirs}", "-ancestor", "{base}", "-merge", "{result}"] },
|
||||
]
|
||||
: [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||
{ id: "meld", label: "Meld", program: "meld", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] },
|
||||
{ id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||
];
|
||||
|
||||
const terminalPresets: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "windows-terminal", label: "Windows Terminal", program: "wt.exe", args: ["-w", "new", "-d", "{repo}"] },
|
||||
{ id: "powershell", label: "PowerShell 7", program: "pwsh.exe", args: ["-NoExit", "-WorkingDirectory", "{repo}"] },
|
||||
{ id: "windows-powershell", label: "Windows PowerShell", program: "powershell.exe", args: ["-NoExit"], builtIn: true },
|
||||
{ id: "git-bash", label: "Git Bash", program: "git-bash.exe", args: ["--login", "-i"] },
|
||||
{ id: "cmd", label: "Command Prompt", program: "cmd.exe", args: ["/K"], builtIn: true },
|
||||
{ id: "wezterm", label: "WezTerm", program: "wezterm-gui.exe", args: ["start", "--cwd", "{repo}"] },
|
||||
{ id: "alacritty", label: "Alacritty", program: "alacritty.exe", args: ["--working-directory", "{repo}"] },
|
||||
{ id: "kitty-terminal", label: "kitty", program: "kitty.exe", args: ["--directory", "{repo}"] },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
{ id: "terminal", label: "Terminal", program: "open", args: ["-n", "-a", "Terminal", "{repo}"], builtIn: true },
|
||||
{ id: "iterm2", label: "iTerm2", program: "open", args: ["-n", "-a", "iTerm", "{repo}"] },
|
||||
{ id: "warp", label: "Warp", program: "open", args: ["-n", "-a", "Warp", "{repo}"] },
|
||||
{ id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] },
|
||||
{ id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] },
|
||||
]
|
||||
: [
|
||||
{ id: "x-terminal", label: "System terminal", program: "x-terminal-emulator", args: ["--working-directory={repo}"] },
|
||||
{ id: "gnome-terminal", label: "GNOME Terminal", program: "gnome-terminal", args: ["--working-directory={repo}"] },
|
||||
{ id: "konsole", label: "Konsole", program: "konsole", args: ["--workdir", "{repo}"] },
|
||||
{ id: "kitty-terminal", label: "kitty", program: "kitty", args: ["--directory", "{repo}"] },
|
||||
{ id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] },
|
||||
{ id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] },
|
||||
{ id: "xfce-terminal", label: "Xfce Terminal", program: "xfce4-terminal", args: ["--working-directory={repo}"] },
|
||||
{ id: "tilix", label: "Tilix", program: "tilix", args: ["--working-directory={repo}"] },
|
||||
];
|
||||
|
||||
const fileManagerPresets: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "explorer", label: "Windows Explorer", program: "explorer.exe", args: ["/n,", "{repo}"], builtIn: true },
|
||||
{ id: "total-commander", label: "Total Commander", program: "TOTALCMD64.EXE", args: ["/N", "/T", "{repo}"] },
|
||||
{ id: "directory-opus", label: "Directory Opus", program: "dopus.exe", args: ["{repo}"] },
|
||||
{ id: "double-commander", label: "Double Commander", program: "doublecmd.exe", args: ["{repo}"] },
|
||||
{ id: "freecommander", label: "FreeCommander XE", program: "FreeCommander.exe", args: ["/L={repo}"] },
|
||||
{ id: "xyplorer", label: "XYplorer", program: "XYplorer.exe", args: ["{repo}"] },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
{ id: "finder", label: "Finder", program: "open", args: ["-n", "{repo}"], builtIn: true },
|
||||
{ id: "forklift", label: "ForkLift", program: "forklift", args: ["{repo}"] },
|
||||
{ id: "path-finder", label: "Path Finder", program: "Path Finder", args: ["{repo}"] },
|
||||
]
|
||||
: [
|
||||
{ id: "system-file-manager", label: "System file manager", program: "xdg-open", args: ["{repo}"], builtIn: true },
|
||||
{ id: "nautilus", label: "GNOME Files", program: "nautilus", args: ["{repo}"] },
|
||||
{ id: "dolphin", label: "Dolphin", program: "dolphin", args: ["{repo}"] },
|
||||
{ id: "thunar", label: "Thunar", program: "thunar", args: ["{repo}"] },
|
||||
{ id: "nemo", label: "Nemo", program: "nemo", args: ["{repo}"] },
|
||||
{ id: "pcmanfm", label: "PCManFM", program: "pcmanfm", args: ["{repo}"] },
|
||||
{ id: "double-commander", label: "Double Commander", program: "doublecmd", args: ["{repo}"] },
|
||||
];
|
||||
|
||||
export const externalToolPresets: Record<ExternalToolKind, ExternalToolPreset[]> = {
|
||||
editor: [...commonEditors, ...platformEditors],
|
||||
diff: [...dedicatedDiff, ...codeFamily.map(codeDiff), { id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed", args: ["--new", "--diff", "{left}", "{right}"] }, ...jetBrains.map(jetBrainsDiff)],
|
||||
merge: [...dedicatedMerge, ...codeFamily.map(codeMerge), ...jetBrains.map(jetBrainsMerge)],
|
||||
terminal: terminalPresets,
|
||||
fileManager: fileManagerPresets,
|
||||
};
|
||||
|
||||
function detectedPreset(kind: ExternalToolKind, id: string, detectedTools: DetectedExternalTool[]): DetectedExternalTool | undefined {
|
||||
return detectedTools.find((tool) => tool.id === id && tool.kinds.includes(kind));
|
||||
}
|
||||
|
||||
export function isExternalToolPresetAvailable(kind: ExternalToolKind, preset: ExternalToolPreset, detectedTools: DetectedExternalTool[]): boolean {
|
||||
return preset.builtIn === true || detectedPreset(kind, preset.id, detectedTools) != null;
|
||||
}
|
||||
|
||||
export function applyExternalToolPreset(kind: ExternalToolKind, id: string, detectedTools: DetectedExternalTool[] = []): ExternalToolSetting {
|
||||
const value = externalToolPresets[kind].find((item) => item.id === id) ?? externalToolPresets[kind][0];
|
||||
const detected = detectedPreset(kind, value.id, detectedTools);
|
||||
return { preset: value.id, program: detected?.program ?? value.program, args: [...value.args] };
|
||||
}
|
||||
|
||||
function preferredPreset(kind: ExternalToolKind, detectedTools: DetectedExternalTool[]): ExternalToolPreset {
|
||||
return externalToolPresets[kind].find((item) => isExternalToolPresetAvailable(kind, item, detectedTools))
|
||||
?? externalToolPresets[kind][0];
|
||||
}
|
||||
|
||||
export function defaultExternalToolsSettings(detectedTools: DetectedExternalTool[] = []): ExternalToolsSettings {
|
||||
return {
|
||||
editor: applyExternalToolPreset("editor", preferredPreset("editor", detectedTools).id, detectedTools),
|
||||
diff: applyExternalToolPreset("diff", preferredPreset("diff", detectedTools).id, detectedTools),
|
||||
merge: applyExternalToolPreset("merge", preferredPreset("merge", detectedTools).id, detectedTools),
|
||||
terminal: applyExternalToolPreset("terminal", preferredPreset("terminal", detectedTools).id, detectedTools),
|
||||
fileManager: applyExternalToolPreset("fileManager", preferredPreset("fileManager", detectedTools).id, detectedTools),
|
||||
diffOpenMode: "gitty",
|
||||
mergeOpenMode: "gitty",
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseSetting(kind: ExternalToolKind, value: unknown, fallback: ExternalToolSetting): ExternalToolSetting {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback;
|
||||
const candidate = value as Partial<ExternalToolSetting>;
|
||||
const program = typeof candidate.program === "string" && candidate.program.trim() ? candidate.program : fallback.program;
|
||||
const storedArgs = Array.isArray(candidate.args)
|
||||
? candidate.args.filter((item): item is string => typeof item === "string").slice(0, 64)
|
||||
: fallback.args;
|
||||
const preset = typeof candidate.preset === "string" && candidate.preset ? candidate.preset : fallback.preset;
|
||||
// Preset arguments are application-owned and can be upgraded safely. Editing either
|
||||
// the executable or arguments in Settings changes the preset to "custom", which keeps
|
||||
// genuine user commands untouched.
|
||||
const currentPreset = preset === "custom"
|
||||
? undefined
|
||||
: externalToolPresets[kind].find((item) => item.id === preset);
|
||||
const args = currentPreset ? [...currentPreset.args] : storedArgs;
|
||||
return { preset, program, args };
|
||||
}
|
||||
|
||||
export function normaliseExternalToolsSettings(value: unknown): ExternalToolsSettings {
|
||||
const defaults = defaultExternalToolsSettings();
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return defaults;
|
||||
const candidate = value as Partial<ExternalToolsSettings>;
|
||||
return {
|
||||
editor: normaliseSetting("editor", candidate.editor, defaults.editor),
|
||||
diff: normaliseSetting("diff", candidate.diff, defaults.diff),
|
||||
merge: normaliseSetting("merge", candidate.merge, defaults.merge),
|
||||
terminal: normaliseSetting("terminal", candidate.terminal, defaults.terminal),
|
||||
fileManager: normaliseSetting("fileManager", candidate.fileManager, defaults.fileManager),
|
||||
diffOpenMode: normaliseOpenMode(candidate.diffOpenMode, defaults.diffOpenMode),
|
||||
mergeOpenMode: normaliseOpenMode(candidate.mergeOpenMode, defaults.mergeOpenMode),
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseOpenMode(value: unknown, fallback: ToolOpenMode): ToolOpenMode {
|
||||
return value === "gitty" || value === "external" ? value : fallback;
|
||||
}
|
||||
|
||||
export function resolveDetectedExternalToolPrograms(settings: ExternalToolsSettings, detectedTools: DetectedExternalTool[]): ExternalToolsSettings {
|
||||
const resolved = structuredClone(settings);
|
||||
for (const kind of ["editor", "diff", "merge", "terminal", "fileManager"] as ExternalToolKind[]) {
|
||||
if (resolved[kind].preset === "custom") continue;
|
||||
const detected = detectedPreset(kind, resolved[kind].preset, detectedTools);
|
||||
if (detected) resolved[kind].program = detected.program;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function externalToolDisplayName(kind: ExternalToolKind, setting: ExternalToolSetting, detectedTools: DetectedExternalTool[] = []): string {
|
||||
if (setting.preset === "custom") return setting.program.split(/[\\/]/).pop() || setting.program;
|
||||
return detectedPreset(kind, setting.preset, detectedTools)?.label
|
||||
?? externalToolPresets[kind].find((item) => item.id === setting.preset)?.label
|
||||
?? setting.program;
|
||||
}
|
||||
+62
-9
@@ -7,6 +7,9 @@ import type {
|
||||
CommitAiProvider,
|
||||
CommitAiStatus,
|
||||
ConflictFile,
|
||||
DetectedExternalTool,
|
||||
ExternalToolCommand,
|
||||
ExternalDiffScope,
|
||||
GitBlameResult,
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
@@ -45,6 +48,22 @@ export function openRepositoryFile(path: string, file: string): Promise<void> {
|
||||
return invoke<void>("open_repository_file", { path, file });
|
||||
}
|
||||
|
||||
export function detectExternalTools(): Promise<DetectedExternalTool[]> {
|
||||
return invoke<DetectedExternalTool[]>("detect_external_tools");
|
||||
}
|
||||
|
||||
export function launchExternalTool(path: string, command: ExternalToolCommand, file?: string): Promise<void> {
|
||||
return invoke<void>("launch_external_tool", { path, file: file ?? null, command });
|
||||
}
|
||||
|
||||
export function launchExternalDiff(path: string, file: string, command: ExternalToolCommand, scope: ExternalDiffScope = "head"): Promise<void> {
|
||||
return invoke<void>("launch_external_diff", { path, file, command, scope });
|
||||
}
|
||||
|
||||
export function launchExternalMerge(path: string, file: string, command: ExternalToolCommand): Promise<void> {
|
||||
return invoke<void>("launch_external_merge", { path, file, command });
|
||||
}
|
||||
|
||||
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
|
||||
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
||||
}
|
||||
@@ -114,6 +133,15 @@ export function renameBranch(
|
||||
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
|
||||
}
|
||||
|
||||
export function renameRemoteBranch(
|
||||
path: string,
|
||||
remote: string,
|
||||
oldBranch: string,
|
||||
newBranch: string,
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("rename_remote_branch", { path, remote, oldBranch, newBranch });
|
||||
}
|
||||
|
||||
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
||||
}
|
||||
@@ -353,21 +381,46 @@ export function credLoad(key: string): Promise<StoredCredential | null> {
|
||||
return invoke<StoredCredential | null>("cred_load", { key });
|
||||
}
|
||||
|
||||
export function credSave(
|
||||
key: string,
|
||||
username: string,
|
||||
password: string,
|
||||
expiresAt: string | null,
|
||||
): Promise<void> {
|
||||
return invoke<void>("cred_save", { key, username, password, expiresAt });
|
||||
export function credSave(key: string, username: string, password: string): Promise<void> {
|
||||
return invoke<void>("cred_save", { key, username, password });
|
||||
}
|
||||
|
||||
export function credDelete(key: string): Promise<void> {
|
||||
return invoke<void>("cred_delete", { key });
|
||||
}
|
||||
|
||||
export function listCommits(path: string, limit = 100): Promise<GitCommit[]> {
|
||||
return invoke<GitCommit[]>("list_commits", { path, limit });
|
||||
export function listCommits(path: string, limit = 100, skip = 0): Promise<GitCommit[]> {
|
||||
return invoke<GitCommit[]>("list_commits", { path, limit, skip });
|
||||
}
|
||||
|
||||
export function getCommitNote(path: string, commit: string): Promise<string | null> {
|
||||
return invoke<string | null>("get_commit_note", { path, commit });
|
||||
}
|
||||
|
||||
export function setCommitNote(path: string, commit: string, note: string): Promise<void> {
|
||||
return invoke<void>("set_commit_note", { path, commit, note });
|
||||
}
|
||||
|
||||
export function deleteCommitNote(path: string, commit: string): Promise<void> {
|
||||
return invoke<void>("delete_commit_note", { path, commit });
|
||||
}
|
||||
|
||||
export function fetchCommitNotes(path: string, remote: string, username?: string, password?: string): Promise<void> {
|
||||
return invoke<void>("fetch_commit_notes", {
|
||||
path,
|
||||
remote,
|
||||
username: username ?? null,
|
||||
password: password ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function pushCommitNotes(path: string, remote: string, username?: string, password?: string): Promise<void> {
|
||||
return invoke<void>("push_commit_notes", {
|
||||
path,
|
||||
remote,
|
||||
username: username ?? null,
|
||||
password: password ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {
|
||||
|
||||
@@ -5,6 +5,11 @@ type TelemetryLevel = "info" | "warn" | "error";
|
||||
const MAX_MESSAGE_LENGTH = 2_048;
|
||||
let telemetryEnabled = false;
|
||||
let configuration: Promise<unknown> = Promise.resolve();
|
||||
let frontendShuttingDown = false;
|
||||
|
||||
export function beginFrontendShutdown() {
|
||||
frontendShuttingDown = true;
|
||||
}
|
||||
|
||||
// Telemetry must never contain repository contents or identifying local data.
|
||||
function sanitize(message: string): string {
|
||||
@@ -42,6 +47,14 @@ function randomHex(bytes: number): string {
|
||||
}
|
||||
|
||||
export async function tracedInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
if (frontendShuttingDown) {
|
||||
// The window is on its way out and nothing calls preventDefault() on the
|
||||
// close event, so it will finish closing regardless. Never resolving
|
||||
// avoids surfacing a spurious "shutting down" error in whatever UI catch
|
||||
// block happens to be awaiting an in-flight operation (e.g. a pull/push
|
||||
// that makes several sequential calls) when the close request lands.
|
||||
return new Promise<T>(() => {});
|
||||
}
|
||||
if (!telemetryEnabled) return invoke<T>(command, args);
|
||||
const startedAtMs = Date.now();
|
||||
const started = performance.now();
|
||||
|
||||
+31
-1
@@ -69,6 +69,36 @@ export interface AnalyticsSettings {
|
||||
noticeSeen: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalToolCommand {
|
||||
program: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
export interface ExternalToolSetting extends ExternalToolCommand {
|
||||
preset: string;
|
||||
}
|
||||
|
||||
export type ExternalToolKind = "editor" | "diff" | "merge" | "terminal" | "fileManager";
|
||||
export type ToolOpenMode = "gitty" | "external";
|
||||
export type ExternalDiffScope = "head" | "staged" | "unstaged";
|
||||
|
||||
export interface ExternalToolsSettings {
|
||||
editor: ExternalToolSetting;
|
||||
diff: ExternalToolSetting;
|
||||
merge: ExternalToolSetting;
|
||||
terminal: ExternalToolSetting;
|
||||
fileManager: ExternalToolSetting;
|
||||
diffOpenMode: ToolOpenMode;
|
||||
mergeOpenMode: ToolOpenMode;
|
||||
}
|
||||
|
||||
export interface DetectedExternalTool {
|
||||
id: string;
|
||||
label: string;
|
||||
program: string;
|
||||
kinds: ExternalToolKind[];
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
repo_path: string;
|
||||
current_branch: string | null;
|
||||
@@ -99,6 +129,7 @@ export interface GitBranch {
|
||||
name: string;
|
||||
current: boolean;
|
||||
remote: boolean;
|
||||
upstream: string | null;
|
||||
}
|
||||
|
||||
export interface GitWorktree {
|
||||
@@ -279,5 +310,4 @@ export interface ReflogEntry {
|
||||
export interface StoredCredential {
|
||||
username: string;
|
||||
password: string;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user