Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4936ba790 | ||
|
|
b5d15f9190 | ||
|
|
e7fbaadf5e | ||
|
|
8b559fa15c | ||
|
|
fac22ff173 | ||
|
|
cd01df0108 | ||
|
|
6f2d4dd8c9 | ||
|
|
33e85fe6be | ||
|
|
993179c25e | ||
|
|
25d7f27d7a | ||
|
|
c5f058beb1 | ||
|
|
4181925047 | ||
|
|
927e0c3e92 | ||
|
|
0dac3e9f09 | ||
|
|
4f1131e855 | ||
|
|
f24c39c573 | ||
|
|
f487090b4b | ||
|
|
c8247f2b2a | ||
|
|
a7558e457d | ||
|
|
412e8d19b5 | ||
|
|
32832f5db7 | ||
|
|
37d2152fcd | ||
|
|
2bc74dc4a7 | ||
|
|
4db488415f | ||
|
|
be695d78a9 | ||
|
|
40311275b0 | ||
|
|
fe577d78a8 | ||
|
|
a27a8666ee | ||
|
|
c442b3735f | ||
|
|
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 | ||
|
|
8079ac5f64 | ||
|
|
df26ecf9fb | ||
|
|
af7b993550 | ||
|
|
e07bc96127 | ||
|
|
f0e87d67d5 | ||
|
|
3246dfdcfd |
@@ -5,7 +5,8 @@ on:
|
|||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-tauri:
|
publish-windows:
|
||||||
|
name: Build and publish Windows installer
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
environment: production
|
environment: production
|
||||||
@@ -30,25 +31,7 @@ jobs:
|
|||||||
run:
|
run:
|
||||||
working-directory: GitLite
|
working-directory: GitLite
|
||||||
|
|
||||||
strategy:
|
runs-on: windows-latest
|
||||||
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 }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# cicd_tool/main.py expects the repo at <workspace>/GitLite, so check out there.
|
# cicd_tool/main.py expects the repo at <workspace>/GitLite, so check out there.
|
||||||
@@ -56,25 +39,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: GitLite
|
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
|
- name: setup node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
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
|
- name: install frontend dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
@@ -87,19 +56,14 @@ jobs:
|
|||||||
run: |
|
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');"
|
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
|
- name: Commit and Push Changes
|
||||||
if: matrix.platform == 'windows-latest'
|
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
git config user.name '${{ vars.USERNAME_GIT }}'
|
git config user.name '${{ vars.USERNAME_GIT }}'
|
||||||
git config user.email '${{ vars.EMAIL_GIT }}'
|
git config user.email '${{ vars.EMAIL_GIT }}'
|
||||||
# npm version also bumps the version inside package-lock.json, so stage it
|
# 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.
|
# 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 }}'
|
git commit -m 'Update version to ${{ github.ref_name }}'
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
@@ -143,28 +107,33 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
|
||||||
NO_STRIP: ${{ matrix.no_strip }}
|
NO_STRIP: ""
|
||||||
run: npm run tauri -- build --bundles ${{ matrix.bundles }}
|
run: npm run tauri -- build --bundles nsis
|
||||||
# ----------------------
|
# ----------------------
|
||||||
# Upload to MinIO (via mc) and create latest.json
|
# Upload to MinIO (via mc) and create latest.json
|
||||||
# ----------------------
|
# ----------------------
|
||||||
- name: Upload artifacts to MinIO with cicd_tool
|
- name: Upload artifacts to MinIO with cicd_tool
|
||||||
working-directory: GitLite/cicd_tool
|
working-directory: GitLite/cicd_tool
|
||||||
env:
|
env:
|
||||||
PUBLISH_PLATFORM: ${{ matrix.updater_platform }}
|
PUBLISH_PLATFORM: windows-x86_64
|
||||||
run: |
|
run: |
|
||||||
uv sync
|
uv sync
|
||||||
uv run main.py
|
uv run main.py
|
||||||
|
|
||||||
publish-arch:
|
publish-arch:
|
||||||
name: Build and publish Arch package
|
name: Build and publish AUR packages
|
||||||
|
needs: publish-ubuntu
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
runs-on: archlinux
|
runs-on: archlinux
|
||||||
environment: production
|
environment: production
|
||||||
env:
|
env:
|
||||||
MINIO_ENDPOINT: ${{ vars.MINIO_ENDPOINT }}
|
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
GITEA_API: https://git.cbsk-tech.de/api/v1
|
||||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
OWNER: Christoph
|
||||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
REPO: GitLite
|
||||||
|
GITEA_TOKEN: ${{ secrets.ACTIONS_TOKEN }}
|
||||||
|
GITEA_FALLBACK_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -174,8 +143,66 @@ jobs:
|
|||||||
- name: Prepare Arch job container
|
- name: Prepare Arch job container
|
||||||
working-directory: /
|
working-directory: /
|
||||||
run: |
|
run: |
|
||||||
pacman -Syu --noconfirm
|
set -euo pipefail
|
||||||
pacman -S --needed --noconfirm nodejs npm git
|
|
||||||
|
# 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 git-lfs 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
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
@@ -186,46 +213,315 @@ jobs:
|
|||||||
RELEASE_VERSION: ${{ github.ref_name }}
|
RELEASE_VERSION: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
npm version "$RELEASE_VERSION" --no-git-tag-version --allow-same-version
|
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")"
|
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"
|
||||||
|
|
||||||
|
ARCH_PACKAGE_PATH="$(find "$AUR_SOURCE_DIR" -maxdepth 1 -type f -name 'gitty-desktop-*.pkg.tar.zst' -print -quit)"
|
||||||
|
if [ -z "$ARCH_PACKAGE_PATH" ]; then
|
||||||
|
echo "The native Arch package was not created" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "AUR_SOURCE_DIR=$AUR_SOURCE_DIR" >> "$GITHUB_ENV"
|
||||||
|
echo "ARCH_PACKAGE_PATH=$ARCH_PACKAGE_PATH" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Attach native Arch package to Gitea release
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TOKEN="${GITEA_TOKEN:-${GITEA_FALLBACK_TOKEN:-}}"
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "A Gitea token is required to upload the native Arch package" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_JSON="$(curl --fail --location --silent --show-error \
|
||||||
|
--header "Authorization: token $TOKEN" \
|
||||||
|
"$GITEA_API/repos/$OWNER/$REPO/releases/tags/$RELEASE_TAG")"
|
||||||
|
export RELEASE_JSON
|
||||||
|
RELEASE_ID="$(node -e "const r=JSON.parse(process.env.RELEASE_JSON); if (!r.id) process.exit(1); process.stdout.write(String(r.id))")"
|
||||||
|
PACKAGE_NAME="${ARCH_PACKAGE_PATH##*/}"
|
||||||
|
export PACKAGE_NAME
|
||||||
|
|
||||||
|
if node -e "const r=JSON.parse(process.env.RELEASE_JSON); process.exit((r.assets || []).some(a => a.name === process.env.PACKAGE_NAME) ? 0 : 1)"; then
|
||||||
|
echo "Release asset $PACKAGE_NAME already exists; skipping upload."
|
||||||
|
else
|
||||||
|
curl --fail --location --silent --show-error \
|
||||||
|
--request POST \
|
||||||
|
--header "Authorization: token $TOKEN" \
|
||||||
|
--form "attachment=@$ARCH_PACKAGE_PATH;type=application/octet-stream" \
|
||||||
|
"$GITEA_API/repos/$OWNER/$REPO/releases/$RELEASE_ID/assets?name=$PACKAGE_NAME"
|
||||||
|
echo
|
||||||
|
echo "Attached $PACKAGE_NAME to Gitea release $RELEASE_TAG."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Generate and build binary AUR package
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TOKEN="${GITEA_TOKEN:-${GITEA_FALLBACK_TOKEN:-}}"
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "A Gitea token is required to locate the native Arch package" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_JSON="$(curl --fail --location --silent --show-error \
|
||||||
|
--header "Authorization: token $TOKEN" \
|
||||||
|
"$GITEA_API/repos/$OWNER/$REPO/releases/tags/$RELEASE_TAG")"
|
||||||
|
export RELEASE_JSON
|
||||||
|
PACKAGE_NAME="${ARCH_PACKAGE_PATH##*/}"
|
||||||
|
export PACKAGE_NAME
|
||||||
|
PACKAGE_URL="$(node -e "const r=JSON.parse(process.env.RELEASE_JSON); const a=(r.assets || []).find(a => a.name === process.env.PACKAGE_NAME); if (!a?.browser_download_url) process.exit(1); process.stdout.write(a.browser_download_url)")"
|
||||||
|
|
||||||
|
AUR_BIN_DIR="$(mktemp -d)"
|
||||||
|
cp PKGBUILD-bin "$AUR_BIN_DIR/PKGBUILD"
|
||||||
|
PACKAGE_PATH="$AUR_BIN_DIR/$PACKAGE_NAME"
|
||||||
|
curl --fail --location --silent --show-error \
|
||||||
|
--header "Authorization: token $TOKEN" \
|
||||||
|
--output "$PACKAGE_PATH" "$PACKAGE_URL"
|
||||||
|
PACKAGE_CHECKSUM="$(sha256sum "$PACKAGE_PATH" | cut -d ' ' -f 1)"
|
||||||
|
|
||||||
|
sed -i \
|
||||||
|
-e "s/^pkgver=.*/pkgver=$PACKAGE_VERSION/" \
|
||||||
|
-e "s/^pkgrel=.*/pkgrel=1/" \
|
||||||
|
-e "s|^_package=.*|_package=\"$PACKAGE_NAME\"|" \
|
||||||
|
-e "s|^_artifact_url=.*|_artifact_url=\"$PACKAGE_URL\"|" \
|
||||||
|
-e "s/^sha256sums=.*/sha256sums=('$PACKAGE_CHECKSUM')/" \
|
||||||
|
"$AUR_BIN_DIR/PKGBUILD"
|
||||||
|
|
||||||
|
chown -R builder:builder "$AUR_BIN_DIR"
|
||||||
|
runuser -u builder -- \
|
||||||
|
bash -lc "cd '$AUR_BIN_DIR' && makepkg --cleanbuild --noconfirm && makepkg --printsrcinfo > .SRCINFO"
|
||||||
|
|
||||||
|
ARCH_BIN_PACKAGE_PATH="$(find "$AUR_BIN_DIR" -maxdepth 1 -type f -name 'gitty-desktop-bin-*.pkg.tar.zst' -print -quit)"
|
||||||
|
if [ -z "$ARCH_BIN_PACKAGE_PATH" ]; then
|
||||||
|
echo "The binary Arch package was not created" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "AUR_BIN_DIR=$AUR_BIN_DIR" >> "$GITHUB_ENV"
|
||||||
|
echo "ARCH_BIN_PACKAGE_PATH=$ARCH_BIN_PACKAGE_PATH" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Attach binary Arch package to Gitea release
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TOKEN="${GITEA_TOKEN:-${GITEA_FALLBACK_TOKEN:-}}"
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "A Gitea token is required to upload the Arch package" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_JSON="$(curl --fail --location --silent --show-error \
|
||||||
|
--header "Authorization: token $TOKEN" \
|
||||||
|
"$GITEA_API/repos/$OWNER/$REPO/releases/tags/$RELEASE_TAG")"
|
||||||
|
export RELEASE_JSON
|
||||||
|
RELEASE_ID="$(node -e "const r=JSON.parse(process.env.RELEASE_JSON); if (!r.id) process.exit(1); process.stdout.write(String(r.id))")"
|
||||||
|
PACKAGE_NAME="${ARCH_BIN_PACKAGE_PATH##*/}"
|
||||||
|
export PACKAGE_NAME
|
||||||
|
|
||||||
|
if node -e "const r=JSON.parse(process.env.RELEASE_JSON); process.exit((r.assets || []).some(a => a.name === process.env.PACKAGE_NAME) ? 0 : 1)"; then
|
||||||
|
echo "Release asset $PACKAGE_NAME already exists; skipping upload."
|
||||||
|
else
|
||||||
|
curl --fail --location --silent --show-error \
|
||||||
|
--request POST \
|
||||||
|
--header "Authorization: token $TOKEN" \
|
||||||
|
--form "attachment=@$ARCH_BIN_PACKAGE_PATH;type=application/octet-stream" \
|
||||||
|
"$GITEA_API/repos/$OWNER/$REPO/releases/$RELEASE_ID/assets?name=$PACKAGE_NAME"
|
||||||
|
echo
|
||||||
|
echo "Attached $PACKAGE_NAME to Gitea release $RELEASE_TAG."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- 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"
|
||||||
|
|
||||||
|
publish_aur_package() {
|
||||||
|
local package_name="$1"
|
||||||
|
local package_source_dir="$2"
|
||||||
|
local checkout_root checkout clone_candidate pushed
|
||||||
|
|
||||||
|
checkout_root="$(mktemp -d)"
|
||||||
|
checkout=""
|
||||||
|
for AUR_ATTEMPT in 1 2 3 4 5; do
|
||||||
|
clone_candidate="$checkout_root/attempt-$AUR_ATTEMPT"
|
||||||
|
echo "$package_name clone attempt $AUR_ATTEMPT of 5"
|
||||||
|
if timeout 3m git -c init.defaultBranch=master clone \
|
||||||
|
"ssh://aur@aur.archlinux.org/$package_name.git" "$clone_candidate"; then
|
||||||
|
checkout="$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 "$checkout" ]; then
|
||||||
|
echo "Could not clone $package_name after 5 attempts" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "$package_source_dir/PKGBUILD" "$package_source_dir/.SRCINFO" "$checkout/"
|
||||||
|
git -C "$checkout" config user.name "${AUR_GIT_NAME:-Gitty Release Bot}"
|
||||||
|
git -C "$checkout" config user.email "${AUR_GIT_EMAIL:-aur@localhost}"
|
||||||
|
git -C "$checkout" add PKGBUILD .SRCINFO
|
||||||
|
|
||||||
|
if git -C "$checkout" diff --cached --quiet; then
|
||||||
|
echo "$package_name already matches release $PACKAGE_VERSION"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "$checkout" commit -m "Update to $PACKAGE_VERSION"
|
||||||
|
pushed=0
|
||||||
|
for AUR_ATTEMPT in 1 2 3 4 5; do
|
||||||
|
echo "$package_name push attempt $AUR_ATTEMPT of 5"
|
||||||
|
if timeout 3m git -C "$checkout" push origin HEAD:master; then
|
||||||
|
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 [ "$pushed" -ne 1 ]; then
|
||||||
|
echo "Could not publish $package_name after 5 attempts" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_aur_package gitty-desktop "$AUR_SOURCE_DIR"
|
||||||
|
publish_aur_package gitty-desktop-bin "$AUR_BIN_DIR"
|
||||||
|
|
||||||
|
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 git-lfs 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
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
|
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
|
working-directory: GitLite/cicd_tool
|
||||||
|
env:
|
||||||
|
PUBLISH_PLATFORM: linux-x86_64
|
||||||
run: |
|
run: |
|
||||||
uv sync
|
uv sync
|
||||||
uv run arch_repo.py download-db --output ../arch-repo/gitty.db.tar.gz
|
uv run main.py
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/dist
|
/dist
|
||||||
/src-tauri/target
|
/src-tauri/target
|
||||||
|
/src-tauri/binaries/git-lfs
|
||||||
|
/src-tauri/binaries/git-lfs.exe
|
||||||
|
/src-tauri/binaries/git-lfs-*-*-*
|
||||||
*.log
|
*.log
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+160
-3
@@ -2,9 +2,160 @@
|
|||||||
|
|
||||||
All notable user-facing changes to Gitty are documented in this file.
|
All notable user-facing changes to Gitty are documented in this file.
|
||||||
|
|
||||||
The project uses calendar versions. Displayed release names use `YYYY.MM.DD`;
|
The project uses calendar-style versions in the form `YYYY.M.PATCH`.
|
||||||
package metadata uses the equivalent numeric form without leading zeroes where
|
|
||||||
required by the package manager.
|
## [2026.8.5] - 2026-08-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Integrated Git LFS management now detects the available extension and the
|
||||||
|
repository setup, shows tracked patterns and checkout objects, and provides
|
||||||
|
actions for local activation, tracking and untracking patterns, downloading
|
||||||
|
objects, and pruning the local cache. Desktop installers bundle Git LFS,
|
||||||
|
while Arch packages declare it as a dependency.
|
||||||
|
- The Changes panel now offers a List/Tree switch. Tree view groups staged and
|
||||||
|
unstaged files into independently collapsible folders while retaining all
|
||||||
|
existing file actions.
|
||||||
|
- Files and folders in the Changes panel now have context-menu actions for
|
||||||
|
staging or unstaging their scope and for creating a stash containing only
|
||||||
|
the selected file or folder.
|
||||||
|
- Gitty can open a repository directly at startup through the `--repo PATH` or
|
||||||
|
`--repo=PATH` command-line argument.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A successful Git pull now detects repositories that use LFS and downloads
|
||||||
|
the required LFS objects automatically with the same remote and credentials.
|
||||||
|
- Unstaged and staged changes use an equal-width side-by-side layout with
|
||||||
|
independent scrolling, directional stage/unstage actions, and a responsive
|
||||||
|
vertical fallback. The List/Tree switch is centered above both areas.
|
||||||
|
- File and folder context menus now separate the selected name from its parent
|
||||||
|
path, show the affected file count, and present stage, unstage, and stash
|
||||||
|
actions with clearer icons and descriptions.
|
||||||
|
- Controls and surfaces use a more consistent square visual language while
|
||||||
|
circular status markers, avatars, and branch shapes remain recognizable.
|
||||||
|
- The built-in German and English help now includes Git LFS setup, tracking,
|
||||||
|
troubleshooting, and everyday workflow guidance.
|
||||||
|
|
||||||
|
## [2026.8.4] - 2026-08-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Branch folders now have a context-menu action for deleting all contained
|
||||||
|
local or remote branches at once. The top-level remote folder such as
|
||||||
|
`origin` is protected, while its nested folders remain manageable. The
|
||||||
|
currently checked-out branch is kept,
|
||||||
|
and individual failures are reported after the remaining branches have been
|
||||||
|
processed.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The Compare selector is fully localized in German and uses the same visual
|
||||||
|
language as the external-tool selectors for its fields, groups, typography,
|
||||||
|
and dialog surfaces.
|
||||||
|
- Repository-tab close buttons are square and have clearer spacing, hover
|
||||||
|
behavior, and keyboard-focus feedback.
|
||||||
|
|
||||||
|
## [2026.8.3] - 2026-08-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Configurable external tools for editors, diff viewers, merge tools,
|
||||||
|
terminals, and file managers, including automatic cross-platform discovery
|
||||||
|
and presets for VS Code, JetBrains IDEs, Beyond Compare, and other common
|
||||||
|
applications.
|
||||||
|
- Repository and file actions for opening content in the configured external
|
||||||
|
application. Supported tools open in a separate window.
|
||||||
|
- A choice between Gitty's internal diff/merge views and the configured
|
||||||
|
external applications.
|
||||||
|
- Git Notes support for attaching editable notes to commits without rewriting
|
||||||
|
commit history, including fetch and push synchronization.
|
||||||
|
- A command palette for quickly opening repository actions, files, and commits.
|
||||||
|
- Complete branch-to-branch comparisons for local and remote branches. The
|
||||||
|
comparison dialog shows every changed file and its side-by-side diff.
|
||||||
|
- Safe remote branch renaming from the branch context menu.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Redesigned the settings window with tool categories, detected applications,
|
||||||
|
preset dropdowns, and clearer explanations of where each tool is used.
|
||||||
|
- Redesigned the history graph's branch presentation with compact labels,
|
||||||
|
hover details, cleaner flag connectors, and branch visibility controls.
|
||||||
|
- Reduced the minimum width of the commit history panel so the workspace can
|
||||||
|
be resized more freely.
|
||||||
|
- Local-only branches are now identified consistently in the toolbar,
|
||||||
|
repository summary, status bar, and commit graph. Their first push is labeled
|
||||||
|
Publish and configures the remote tracking branch automatically.
|
||||||
|
- Git operations now run asynchronously to keep the application responsive
|
||||||
|
during slower repository commands.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Closing supported external tools no longer reports their documented
|
||||||
|
comparison result codes as application errors.
|
||||||
|
- External tools that otherwise reuse an existing process are explicitly
|
||||||
|
opened in a new window where supported.
|
||||||
|
- Remote branch renaming uses an atomic push with lease checks, preventing an
|
||||||
|
existing destination branch or a newly changed remote branch from being
|
||||||
|
overwritten.
|
||||||
|
|
||||||
|
## [2026.8.2] - 2026-08-10
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- History graph colors remain stable across parent lanes, making longer and
|
||||||
|
branching histories easier to follow.
|
||||||
|
- Release artifacts are published to the matching Gitea release automatically
|
||||||
|
without creating duplicate assets.
|
||||||
|
- Application shutdown now completes telemetry cleanup more reliably.
|
||||||
|
|
||||||
|
## [2026.8.1] - 2026-08-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Paginated commit history that loads older commits on demand instead of
|
||||||
|
limiting the visible repository history to the initial page.
|
||||||
|
- A dedicated file-history dialog opened from the explorer context menu.
|
||||||
|
- Windows and Ubuntu release publishing plus improved AUR packaging workflows.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- File history moved out of the permanent workspace panel into a focused,
|
||||||
|
larger dialog.
|
||||||
|
- Dialogs close more consistently with the Escape key.
|
||||||
|
- Arch Linux installation documentation now uses the `gitty-desktop` AUR
|
||||||
|
package.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- AUR SSH setup, package installation timeouts, and clone/push retries are more
|
||||||
|
robust in the release workflow.
|
||||||
|
|
||||||
|
## [2026.07.22] - 2026-07-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- AI-assisted commit splitting for staged changes:
|
||||||
|
- analyze the staged diff and propose an ordered set of logical commits;
|
||||||
|
- generate an editable Conventional Commit message for every group;
|
||||||
|
- move files between proposed commits before applying the plan;
|
||||||
|
- create all accepted commits sequentially with **Commit all**.
|
||||||
|
- Support for commit-splitting suggestions through OpenAI, Anthropic, and
|
||||||
|
custom OpenAI-compatible endpoints.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The commit-splitting dialog now explains why a plan cannot be applied and
|
||||||
|
clearly marks AI-generated commit messages as editable.
|
||||||
|
- Safety checks prevent applying a stale plan after the staged files change
|
||||||
|
and reject files that contain both staged and unstaged changes.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Commit all** now passes a plain state snapshot to the commit workflow
|
||||||
|
instead of failing silently when cloning a reactive UI proxy.
|
||||||
|
- Closing the active repository tab no longer allows an in-flight status or
|
||||||
|
fetch response to reopen and reactivate the closed repository.
|
||||||
|
|
||||||
## [2026.07.21] - 2026-07-21
|
## [2026.07.21] - 2026-07-21
|
||||||
|
|
||||||
@@ -59,5 +210,11 @@ required by the package manager.
|
|||||||
- Improved repository tabs, diff styling, spacing, and panel structure.
|
- Improved repository tabs, diff styling, spacing, and panel structure.
|
||||||
- Improved file-history state handling.
|
- Improved file-history state handling.
|
||||||
|
|
||||||
|
[2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22
|
||||||
[2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21
|
[2026.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.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20
|
||||||
|
[2026.8.5]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.5
|
||||||
|
[2026.8.4]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.4
|
||||||
|
[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,35 +1,33 @@
|
|||||||
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
|
# 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.20
|
pkgver=2026.7.22
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="A lightweight, modern Git client built with Tauri"
|
pkgdesc="A lightweight, modern Git client built with Tauri"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://git.cbsk-tech.de/Christoph/GitLite"
|
url="https://git.cbsk-tech.de/Christoph/GitLite"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'git-lfs' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
||||||
makedepends=('rust' 'nodejs' 'npm')
|
makedepends=('rust' 'nodejs' 'npm')
|
||||||
options=('!lto' '!debug')
|
options=('!lto' '!debug')
|
||||||
|
|
||||||
source=()
|
_tag=2026.7.22
|
||||||
sha256sums=()
|
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).
|
prepare() {
|
||||||
pkgver() {
|
cd "$srcdir/gitlite"
|
||||||
cd "$startdir"
|
|
||||||
node -p "require('./package.json').version"
|
# 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() {
|
build() {
|
||||||
cd "$startdir"
|
cd "$srcdir/gitlite"
|
||||||
|
|
||||||
# mistralrs-core is large enough that the regular release profile (thin LTO,
|
# Keep the large Rust release build within the Arch runner's memory limit.
|
||||||
# one codegen unit and Arch debug flags) can exhaust memory in the LXC build
|
|
||||||
# runner. Keep the package optimized while bounding peak compiler memory.
|
|
||||||
export CARGO_BUILD_JOBS=1
|
export CARGO_BUILD_JOBS=1
|
||||||
export CARGO_PROFILE_RELEASE_OPT_LEVEL=2
|
export CARGO_PROFILE_RELEASE_OPT_LEVEL=2
|
||||||
export CARGO_PROFILE_RELEASE_LTO=false
|
export CARGO_PROFILE_RELEASE_LTO=false
|
||||||
@@ -37,40 +35,35 @@ build() {
|
|||||||
export CARGO_PROFILE_RELEASE_DEBUG=0
|
export CARGO_PROFILE_RELEASE_DEBUG=0
|
||||||
|
|
||||||
npm ci
|
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
|
npm run tauri -- build --no-bundle
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
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" \
|
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" \
|
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" \
|
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" \
|
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"
|
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]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=Gitty
|
Name=Gitty
|
||||||
Comment=$pkgdesc
|
Comment=$pkgdesc
|
||||||
Exec=gitty
|
Exec=gitty-desktop
|
||||||
Icon=gitty
|
Icon=gitty-desktop
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=Development;RevisionControl;
|
Categories=Development;RevisionControl;
|
||||||
StartupWMClass=gitty
|
StartupWMClass=gitty
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
install -Dm644 "README.md" "$pkgdir/usr/share/doc/$pkgname/README.md"
|
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
|
||||||
|
|
||||||
|
pkgname=gitty-desktop-bin
|
||||||
|
pkgver=2026.8.5
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="A lightweight, modern Git client built with Tauri (prebuilt Arch package)"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://git.cbsk-tech.de/Christoph/GitLite"
|
||||||
|
license=('MIT')
|
||||||
|
depends=('git' 'git-lfs' 'webkit2gtk-4.1' 'gtk3' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
||||||
|
provides=('gitty-desktop')
|
||||||
|
conflicts=('gitty-desktop')
|
||||||
|
options=('!strip')
|
||||||
|
|
||||||
|
_package="gitty-desktop-${pkgver}-1-x86_64.pkg.tar.zst"
|
||||||
|
_artifact_url="https://git.cbsk-tech.de/Christoph/GitLite/releases/download/${pkgver}/${_package}"
|
||||||
|
source=("${_package}::${_artifact_url}")
|
||||||
|
noextract=("${_package}")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
package() {
|
||||||
|
# Extract only the native package payload, without carrying its package
|
||||||
|
# metadata (.PKGINFO, .BUILDINFO and .MTREE) across.
|
||||||
|
bsdtar -xf "$srcdir/$_package" -C "$pkgdir" usr
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ Fast, simple, and designed for developers who want a clean Git experience withou
|
|||||||
- 🔄 Pull, Push & Fetch
|
- 🔄 Pull, Push & Fetch
|
||||||
- 🔀 Merge & Rebase
|
- 🔀 Merge & Rebase
|
||||||
- 📦 Repository management
|
- 📦 Repository management
|
||||||
|
- 🗄️ Git LFS detection, tracking and object management
|
||||||
- 🎨 Modern and intuitive UI
|
- 🎨 Modern and intuitive UI
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -54,23 +55,44 @@ MIT License
|
|||||||
|
|
||||||
## Arch Linux
|
## Arch Linux
|
||||||
|
|
||||||
Release builds are published as an `x86_64` pacman repository. Add this block to
|
Install Gitty from the AUR with an AUR helper:
|
||||||
`/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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo pacman -Syu gitty
|
yay -S gitty-desktop
|
||||||
```
|
```
|
||||||
|
|
||||||
The repository is currently distributed over HTTPS but is not GPG-signed. A
|
To install the prebuilt native Arch package instead of compiling from source:
|
||||||
repository signing key should be added before recommending it to third parties.
|
|
||||||
|
```bash
|
||||||
|
yay -S gitty-desktop-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Or build the AUR package manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://aur.archlinux.org/gitty-desktop.git
|
||||||
|
cd gitty-desktop
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
The source recipe downloads the public Gitea release archive and builds Gitty.
|
||||||
|
The `-bin` recipe repackages the native `.pkg.tar.zst` release artifact. The
|
||||||
|
release pipeline updates both packages' versions, checksums, and `.SRCINFO`
|
||||||
|
files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git LFS
|
||||||
|
|
||||||
|
Gitty bundles the `git-lfs` executable in its desktop installers and checks it
|
||||||
|
at runtime before offering LFS actions. Arch packages also declare `git-lfs` as
|
||||||
|
a dependency so Git hooks and command-line workflows outside Gitty use the same
|
||||||
|
extension. The repository toolbar exposes LFS setup, tracked patterns, object
|
||||||
|
downloads, and safe cache pruning. Normal clone, pull, checkout, and push
|
||||||
|
operations continue to use Git's standard LFS filters and pre-push hook. After
|
||||||
|
every successful pull, Gitty detects LFS usage and automatically downloads the
|
||||||
|
required LFS objects with the same remote and credentials, so no second pull is
|
||||||
|
needed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 argparse
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable, List, NamedTuple, Optional
|
from typing import Any, Iterable, List, NamedTuple, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
from urllib.parse import quote, urlencode
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from minio import Minio
|
from minio import Minio
|
||||||
from minio.error import S3Error
|
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:
|
def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
||||||
resolved_version = _resolve_version(version)
|
resolved_version = _resolve_version(version)
|
||||||
artifact_base_url = os.environ.get("ARTIFACT_BASE_URL", "")
|
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,
|
artifact_base_url=artifact_base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_upload_gitea_release_asset(resolved_version, primary)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
|||||||
+38
-1
@@ -73,6 +73,34 @@ interface GitRepositoryFile {
|
|||||||
tracked: boolean;
|
tracked: boolean;
|
||||||
status: FileStatusKind | null;
|
status: FileStatusKind | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GitLfsStatus {
|
||||||
|
available: boolean;
|
||||||
|
bundled: boolean;
|
||||||
|
version: string | null;
|
||||||
|
filters_configured: boolean;
|
||||||
|
hook_installed: boolean;
|
||||||
|
repository_uses_lfs: boolean;
|
||||||
|
patterns: GitLfsPattern[];
|
||||||
|
files: GitLfsFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitLfsPattern {
|
||||||
|
pattern: string;
|
||||||
|
source: string;
|
||||||
|
lockable: boolean;
|
||||||
|
tracked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitLfsFile {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
checkout: boolean;
|
||||||
|
downloaded: boolean;
|
||||||
|
oid_type: string;
|
||||||
|
oid: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
@@ -83,6 +111,14 @@ The command list below includes the repository-management and synchronization AP
|
|||||||
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
|
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
|
||||||
- `clone_repository(...): Promise<RepositoryBundle>`
|
- `clone_repository(...): Promise<RepositoryBundle>`
|
||||||
- `get_status(path: string): Promise<GitStatus>`
|
- `get_status(path: string): Promise<GitStatus>`
|
||||||
|
- `git_lfs_status(path: string): Promise<GitLfsStatus>`
|
||||||
|
- `git_lfs_install(path: string): Promise<GitLfsStatus>`
|
||||||
|
- `git_lfs_track(path: string, pattern: string, lockable?: boolean): Promise<GitLfsStatus>`
|
||||||
|
- `git_lfs_untrack(path: string, pattern: string): Promise<GitLfsStatus>`
|
||||||
|
- `git_lfs_pull(path: string, remote?: string, username?: string, password?: string): Promise<GitLfsStatus>`
|
||||||
|
- `git_lfs_prune(path: string): Promise<GitLfsStatus>`
|
||||||
|
- `pull(...)` automatically runs `git lfs pull` after a successful Git pull when
|
||||||
|
the repository contains LFS attributes or tracked LFS objects.
|
||||||
- `list_branches(path: string): Promise<GitBranch[]>`
|
- `list_branches(path: string): Promise<GitBranch[]>`
|
||||||
- `list_remotes(path: string): Promise<GitRemote[]>`
|
- `list_remotes(path: string): Promise<GitRemote[]>`
|
||||||
- `add_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
|
- `add_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
|
||||||
@@ -102,11 +138,12 @@ The command list below includes the repository-management and synchronization AP
|
|||||||
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
|
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||||
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
|
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||||
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
|
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
|
||||||
|
- `stash_push(path: string, message?: string, includeUntracked?: boolean, paths?: string[]): Promise<GitStatus>`; when `paths` is provided, only matching files are stashed.
|
||||||
- `commit(path: string, message: string): Promise<GitStatus>`
|
- `commit(path: string, message: string): Promise<GitStatus>`
|
||||||
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
|
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
|
||||||
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: 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>`
|
- `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_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)
|
- `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>`
|
- `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise<GitStatus>`
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.7.20",
|
"version": "2026.8.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.7.20",
|
"version": "2026.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^1.21.0",
|
"@lucide/svelte": "^1.21.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
|||||||
+2
-1
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.7.20",
|
"version": "2026.8.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 127.0.0.1",
|
"dev": "vite --host 127.0.0.1",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"prepare:lfs": "node scripts/prepare-git-lfs-sidecar.mjs",
|
||||||
"icons": "node scripts/generate-app-icons.mjs",
|
"icons": "node scripts/generate-app-icons.mjs",
|
||||||
"preview": "vite preview --host 127.0.0.1",
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { copyFileSync, existsSync, mkdirSync, chmodSync, statSync } from "node:fs";
|
||||||
|
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const projectRoot = resolve(scriptDirectory, "..");
|
||||||
|
const binariesDirectory = join(projectRoot, "src-tauri", "binaries");
|
||||||
|
const extension = process.platform === "win32" ? ".exe" : "";
|
||||||
|
|
||||||
|
function defaultTargetTriple() {
|
||||||
|
const triples = {
|
||||||
|
"win32:x64": "x86_64-pc-windows-msvc",
|
||||||
|
"win32:arm64": "aarch64-pc-windows-msvc",
|
||||||
|
"linux:x64": "x86_64-unknown-linux-gnu",
|
||||||
|
"linux:arm64": "aarch64-unknown-linux-gnu",
|
||||||
|
"darwin:x64": "x86_64-apple-darwin",
|
||||||
|
"darwin:arm64": "aarch64-apple-darwin",
|
||||||
|
};
|
||||||
|
return triples[`${process.platform}:${process.arch}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function executableCandidates() {
|
||||||
|
const configured = process.env.GIT_LFS_BINARY?.trim();
|
||||||
|
if (configured) return [configured];
|
||||||
|
|
||||||
|
const pathEntries = (process.env.PATH ?? "")
|
||||||
|
.split(delimiter)
|
||||||
|
.filter(Boolean);
|
||||||
|
const candidates = pathEntries.map((entry) => join(entry, `git-lfs${extension}`));
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
for (const entry of pathEntries) {
|
||||||
|
if (basename(entry).toLowerCase() === "cmd") {
|
||||||
|
candidates.push(resolve(entry, "..", "mingw64", "bin", "git-lfs.exe"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = executableCandidates()
|
||||||
|
.filter((candidate) => existsSync(candidate))
|
||||||
|
// Git for Windows exposes a small launcher in cmd/ and the standalone
|
||||||
|
// executable in mingw64/bin/. The standalone file is the portable sidecar.
|
||||||
|
.sort((left, right) => statSync(right).size - statSync(left).size)[0];
|
||||||
|
if (!source) {
|
||||||
|
throw new Error(
|
||||||
|
"Git LFS was not found. Install git-lfs or set GIT_LFS_BINARY before building Gitty.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTriple = (
|
||||||
|
process.env.GITTY_TARGET_TRIPLE
|
||||||
|
?? process.env.TAURI_ENV_TARGET_TRIPLE
|
||||||
|
?? process.env.TARGET
|
||||||
|
?? defaultTargetTriple()
|
||||||
|
)?.trim();
|
||||||
|
if (!targetTriple) throw new Error("Rust did not report a target triple.");
|
||||||
|
|
||||||
|
mkdirSync(binariesDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const developmentTarget = join(binariesDirectory, `git-lfs${extension}`);
|
||||||
|
const bundleTarget = join(
|
||||||
|
binariesDirectory,
|
||||||
|
`git-lfs-${targetTriple}${extension}`,
|
||||||
|
);
|
||||||
|
for (const target of [developmentTarget, bundleTarget]) {
|
||||||
|
if (resolve(source) !== resolve(target)) copyFileSync(source, target);
|
||||||
|
if (process.platform !== "win32") chmodSync(target, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Prepared bundled git-lfs from ${source} for ${targetTriple}.`);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2014- GitHub, Inc. and Git LFS contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
Portions of the subprocess and tools directories are copied from Go and are
|
||||||
|
under the following license:
|
||||||
|
|
||||||
|
Copyright (c) 2009,2010 The Go Authors. All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
* Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
* Neither the name of Google Inc. nor the names of its contributors may be
|
||||||
|
used to endorse or promote products derived from this software without
|
||||||
|
specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||||
|
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||||
|
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||||
|
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||||
|
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-unminimize",
|
"core:window:allow-unminimize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-destroy",
|
||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
|
|||||||
@@ -215,6 +215,89 @@ pub async fn review_custom(
|
|||||||
openai_compatible_review_request(url, api_key, model, diff).await
|
openai_compatible_review_request(url, api_key, model, diff).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn openai_compatible_split_request(
|
||||||
|
url: String,
|
||||||
|
bearer: Option<&str>,
|
||||||
|
model: &str,
|
||||||
|
diff: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.";
|
||||||
|
let user =
|
||||||
|
format!("Analyze these staged changes and propose an ordered commit plan:\n\n{diff}");
|
||||||
|
let body = OpenAiRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
messages: vec![
|
||||||
|
OpenAiMessage {
|
||||||
|
role: "system",
|
||||||
|
content: system.to_string(),
|
||||||
|
},
|
||||||
|
OpenAiMessage {
|
||||||
|
role: "user",
|
||||||
|
content: user,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.1,
|
||||||
|
};
|
||||||
|
let client = http_client()?;
|
||||||
|
let mut request = client.post(url).json(&body);
|
||||||
|
if let Some(key) = bearer.filter(|key| !key.trim().is_empty()) {
|
||||||
|
request = request.bearer_auth(key);
|
||||||
|
}
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Request to the AI model failed: {err}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("API error ({status}): {text}"));
|
||||||
|
}
|
||||||
|
let parsed: OpenAiResponse =
|
||||||
|
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
parsed
|
||||||
|
.choices
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.and_then(|choice| choice.message.content)
|
||||||
|
.map(|content| sanitize_message(&content))
|
||||||
|
.filter(|content| !content.is_empty())
|
||||||
|
.ok_or_else(|| "The model did not return a commit plan.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn split_openai(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return Err("OpenAI API key is missing.".to_string());
|
||||||
|
}
|
||||||
|
openai_compatible_split_request(
|
||||||
|
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||||
|
Some(api_key),
|
||||||
|
model,
|
||||||
|
diff,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn split_custom(
|
||||||
|
base_url: &str,
|
||||||
|
api_key: Option<&str>,
|
||||||
|
model: &str,
|
||||||
|
diff: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if base_url.trim().is_empty() {
|
||||||
|
return Err("Endpoint URL is missing.".to_string());
|
||||||
|
}
|
||||||
|
openai_compatible_split_request(
|
||||||
|
format!("{}/chat/completions", base_url.trim_end_matches('/')),
|
||||||
|
api_key,
|
||||||
|
model,
|
||||||
|
diff,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct AnthropicMessage {
|
struct AnthropicMessage {
|
||||||
role: &'static str,
|
role: &'static str,
|
||||||
@@ -338,3 +421,47 @@ pub async fn review_anthropic(api_key: &str, model: &str, diff: &str) -> Result<
|
|||||||
.filter(|text| !text.is_empty())
|
.filter(|text| !text.is_empty())
|
||||||
.ok_or_else(|| "The model did not return a review.".to_string())
|
.ok_or_else(|| "The model did not return a review.".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn split_anthropic(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return Err("Anthropic API key is missing.".to_string());
|
||||||
|
}
|
||||||
|
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.".to_string();
|
||||||
|
let body = AnthropicRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
max_tokens: 2400,
|
||||||
|
system,
|
||||||
|
messages: vec![AnthropicMessage {
|
||||||
|
role: "user",
|
||||||
|
content: format!(
|
||||||
|
"Analyze these staged changes and propose an ordered commit plan:\n\n{diff}"
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let client = http_client()?;
|
||||||
|
let response = client
|
||||||
|
.post("https://api.anthropic.com/v1/messages")
|
||||||
|
.header("x-api-key", api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("API error ({status}): {text}"));
|
||||||
|
}
|
||||||
|
let parsed: AnthropicResponse =
|
||||||
|
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
parsed
|
||||||
|
.content
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|block| block.text)
|
||||||
|
.map(|text| sanitize_message(&text))
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.ok_or_else(|| "The model did not return a commit plan.".to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ mod cloud;
|
|||||||
|
|
||||||
pub use cloud::{
|
pub use cloud::{
|
||||||
generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom,
|
generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom,
|
||||||
review_openai,
|
review_openai, split_anthropic, split_custom, split_openai,
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+2011
-146
File diff suppressed because it is too large
Load Diff
+124
-16
@@ -1,33 +1,110 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod badge;
|
mod badge;
|
||||||
|
mod external_tools;
|
||||||
mod git;
|
mod git;
|
||||||
mod telemetry;
|
mod telemetry;
|
||||||
|
|
||||||
use badge::set_sync_badge;
|
use badge::set_sync_badge;
|
||||||
|
use external_tools::{
|
||||||
|
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
|
||||||
|
};
|
||||||
use git::{
|
use git::{
|
||||||
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
|
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
|
||||||
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||||
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
|
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
|
||||||
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits,
|
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
|
||||||
compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete,
|
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
|
||||||
cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
|
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
|
||||||
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
|
delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes,
|
||||||
get_status, init_repository, last_commit_message, list_branches, list_commits,
|
get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install,
|
||||||
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
|
git_lfs_prune, git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository,
|
||||||
list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort,
|
last_commit_message, list_branches, list_commits, list_file_history,
|
||||||
merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository,
|
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
|
||||||
open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag,
|
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
|
||||||
read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree,
|
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
|
||||||
rename_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
|
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,
|
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
|
||||||
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
||||||
set_branch_upstream, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop,
|
set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
|
||||||
stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote,
|
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
|
||||||
|
update_remote,
|
||||||
};
|
};
|
||||||
use tauri::Manager;
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
||||||
|
|
||||||
|
struct StartupRepository(Mutex<Option<String>>);
|
||||||
|
|
||||||
|
fn repository_path_from_args(args: impl IntoIterator<Item = String>, cwd: &Path) -> Option<String> {
|
||||||
|
let mut args = args.into_iter();
|
||||||
|
let mut repository = None;
|
||||||
|
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
if arg == "--repo" {
|
||||||
|
repository = args.next();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(path) = arg.strip_prefix("--repo=") {
|
||||||
|
repository = Some(path.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !arg.starts_with('-') {
|
||||||
|
repository = Some(arg);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.filter(|path| !path.trim().is_empty()).map(|path| {
|
||||||
|
let path = PathBuf::from(path);
|
||||||
|
if path.is_absolute() {
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
cwd.join(path)
|
||||||
|
}
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod startup_repository_tests {
|
||||||
|
use super::repository_path_from_args;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_direct_relative_repository_path() {
|
||||||
|
let path = repository_path_from_args(["projects/repo".to_string()], Path::new("/home/user"));
|
||||||
|
assert_eq!(path.as_deref(), Some("/home/user/projects/repo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_repo_option() {
|
||||||
|
let path = repository_path_from_args(
|
||||||
|
["--repo".to_string(), "/projects/repo".to_string()],
|
||||||
|
Path::new("/home/user"),
|
||||||
|
);
|
||||||
|
assert_eq!(path.as_deref(), Some("/projects/repo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_repo_equals_option() {
|
||||||
|
let path = repository_path_from_args(
|
||||||
|
["--repo=projects/repo".to_string()],
|
||||||
|
Path::new("/home/user"),
|
||||||
|
);
|
||||||
|
assert_eq!(path.as_deref(), Some("/home/user/projects/repo"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn take_startup_repository(state: tauri::State<'_, StartupRepository>) -> Option<String> {
|
||||||
|
state.0.lock().ok()?.take()
|
||||||
|
}
|
||||||
|
|
||||||
struct ConsoleLogger;
|
struct ConsoleLogger;
|
||||||
|
|
||||||
impl log::Log for ConsoleLogger {
|
impl log::Log for ConsoleLogger {
|
||||||
@@ -90,8 +167,19 @@ async fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let startup_repository = repository_path_from_args(
|
||||||
|
std::env::args().skip(1),
|
||||||
|
&std::env::current_dir().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
|
||||||
let builder = tauri::Builder::default()
|
let builder = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
|
||||||
|
if let Some(path) = repository_path_from_args(args.into_iter().skip(1), Path::new(&cwd)) {
|
||||||
|
if let Ok(mut pending) = app.state::<StartupRepository>().0.lock() {
|
||||||
|
*pending = Some(path);
|
||||||
|
}
|
||||||
|
let _ = app.emit("open-startup-repository", ());
|
||||||
|
}
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
let _ = app
|
let _ = app
|
||||||
.get_webview_window("main")
|
.get_webview_window("main")
|
||||||
@@ -106,6 +194,7 @@ async fn main() {
|
|||||||
})
|
})
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
|
.manage(StartupRepository(Mutex::new(startup_repository)))
|
||||||
.manage(SearchCancellationState::default())
|
.manage(SearchCancellationState::default())
|
||||||
.manage(commit_ai::CommitAiEngine::new())
|
.manage(commit_ai::CommitAiEngine::new())
|
||||||
.plugin(tauri_plugin_dialog::init());
|
.plugin(tauri_plugin_dialog::init());
|
||||||
@@ -123,7 +212,17 @@ async fn main() {
|
|||||||
clone_repository,
|
clone_repository,
|
||||||
open_repo_in_explorer,
|
open_repo_in_explorer,
|
||||||
open_repository_file,
|
open_repository_file,
|
||||||
|
detect_external_tools,
|
||||||
|
launch_external_tool,
|
||||||
|
launch_external_diff,
|
||||||
|
launch_external_merge,
|
||||||
get_status,
|
get_status,
|
||||||
|
git_lfs_status,
|
||||||
|
git_lfs_install,
|
||||||
|
git_lfs_track,
|
||||||
|
git_lfs_untrack,
|
||||||
|
git_lfs_pull,
|
||||||
|
git_lfs_prune,
|
||||||
list_branches,
|
list_branches,
|
||||||
list_remotes,
|
list_remotes,
|
||||||
add_remote,
|
add_remote,
|
||||||
@@ -131,10 +230,12 @@ async fn main() {
|
|||||||
remove_remote,
|
remove_remote,
|
||||||
set_branch_upstream,
|
set_branch_upstream,
|
||||||
delete_remote_branch,
|
delete_remote_branch,
|
||||||
|
delete_remote_branches,
|
||||||
list_stashes,
|
list_stashes,
|
||||||
checkout_branch,
|
checkout_branch,
|
||||||
create_branch,
|
create_branch,
|
||||||
rename_branch,
|
rename_branch,
|
||||||
|
rename_remote_branch,
|
||||||
delete_branch,
|
delete_branch,
|
||||||
list_worktrees,
|
list_worktrees,
|
||||||
add_worktree,
|
add_worktree,
|
||||||
@@ -169,10 +270,16 @@ async fn main() {
|
|||||||
commit_ai_local_models,
|
commit_ai_local_models,
|
||||||
commit_ai_generate,
|
commit_ai_generate,
|
||||||
commit_ai_review,
|
commit_ai_review,
|
||||||
|
commit_ai_split,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
fetch,
|
fetch,
|
||||||
list_commits,
|
list_commits,
|
||||||
|
get_commit_note,
|
||||||
|
set_commit_note,
|
||||||
|
delete_commit_note,
|
||||||
|
fetch_commit_notes,
|
||||||
|
push_commit_notes,
|
||||||
restore_to_commit,
|
restore_to_commit,
|
||||||
restore_file_from_commit,
|
restore_file_from_commit,
|
||||||
merge_branch,
|
merge_branch,
|
||||||
@@ -208,7 +315,8 @@ async fn main() {
|
|||||||
close_splashscreen,
|
close_splashscreen,
|
||||||
set_telemetry_enabled,
|
set_telemetry_enabled,
|
||||||
emit_frontend_log,
|
emit_frontend_log,
|
||||||
emit_frontend_span
|
emit_frontend_span,
|
||||||
|
take_startup_repository
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Gitty",
|
"productName": "Gitty",
|
||||||
"version": "2026.7.20",
|
"version": "2026.8.5",
|
||||||
"identifier": "com.gitty",
|
"identifier": "com.gitty",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
|
||||||
"devUrl": "http://127.0.0.1:1420",
|
"devUrl": "http://127.0.0.1:1420",
|
||||||
"beforeBuildCommand": "npm run build",
|
"beforeBuildCommand": "npm run prepare:lfs && npm run build",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
@@ -41,6 +41,12 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/git-lfs"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
"binaries/git-lfs-LICENSE.txt"
|
||||||
|
],
|
||||||
"targets": [
|
"targets": [
|
||||||
"nsis"
|
"nsis"
|
||||||
],
|
],
|
||||||
|
|||||||
+1496
-400
File diff suppressed because it is too large
Load Diff
+1508
-410
File diff suppressed because it is too large
Load Diff
@@ -58,7 +58,7 @@
|
|||||||
aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
|
aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
|
||||||
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
|
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
|
||||||
>
|
>
|
||||||
<X size={13} aria-hidden="true" />
|
<X size={11} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
|
Box,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Code2,
|
||||||
CloudDownload,
|
CloudDownload,
|
||||||
|
CloudOff,
|
||||||
Download,
|
Download,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
GitCompare,
|
GitCompare,
|
||||||
@@ -12,6 +15,7 @@
|
|||||||
Search,
|
Search,
|
||||||
Upload,
|
Upload,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
Terminal,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
export let hasRepository: boolean = false;
|
export let hasRepository: boolean = false;
|
||||||
@@ -19,7 +23,11 @@
|
|||||||
export let operation: string = "";
|
export let operation: string = "";
|
||||||
export let ahead: number = 0;
|
export let ahead: number = 0;
|
||||||
export let behind: number = 0;
|
export let behind: number = 0;
|
||||||
|
export let localOnly: boolean = false;
|
||||||
export let language: "en" | "de" = "en";
|
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 onFetch: () => void = () => {};
|
||||||
export let onPull: () => void = () => {};
|
export let onPull: () => void = () => {};
|
||||||
export let onPush: () => void = () => {};
|
export let onPush: () => void = () => {};
|
||||||
@@ -29,15 +37,24 @@
|
|||||||
export let onInteractiveRebase: () => void = () => {};
|
export let onInteractiveRebase: () => void = () => {};
|
||||||
export let onReflog: () => void = () => {};
|
export let onReflog: () => void = () => {};
|
||||||
export let onOpenInExplorer: () => void = () => {};
|
export let onOpenInExplorer: () => void = () => {};
|
||||||
|
export let onOpenInEditor: () => void = () => {};
|
||||||
|
export let onOpenTerminal: () => void = () => {};
|
||||||
export let onFetchPrune: () => void = () => {};
|
export let onFetchPrune: () => void = () => {};
|
||||||
export let onForcePush: () => void = () => {};
|
export let onForcePush: () => void = () => {};
|
||||||
export let onSyncOptions: () => void = () => {};
|
export let onSyncOptions: () => void = () => {};
|
||||||
|
export let onOpenLfs: () => void = () => {};
|
||||||
|
|
||||||
let historyOpen = false;
|
let historyOpen = false;
|
||||||
let syncOpen = false;
|
let syncOpen = false;
|
||||||
let toolbarElement: HTMLDivElement;
|
let toolbarElement: HTMLDivElement;
|
||||||
|
|
||||||
$: isGerman = language === "de";
|
$: 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) {
|
function runHistoryAction(action: () => void) {
|
||||||
historyOpen = false;
|
historyOpen = false;
|
||||||
@@ -96,10 +113,13 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="repo-action sync-primary"
|
class="repo-action sync-primary"
|
||||||
|
class:publish-local={localOnly}
|
||||||
onclick={onPush}
|
onclick={onPush}
|
||||||
disabled={!hasRepository || isBusy}
|
disabled={!hasRepository || isBusy}
|
||||||
title="Push"
|
title={pushTitle}
|
||||||
aria-label={ahead > 0
|
aria-label={localOnly
|
||||||
|
? pushTitle
|
||||||
|
: ahead > 0
|
||||||
? `Push, ${ahead} ${isGerman ? (ahead === 1 ? "lokaler Commit voraus" : "lokale Commits voraus") : (ahead === 1 ? "commit ahead" : "commits ahead")}`
|
? `Push, ${ahead} ${isGerman ? (ahead === 1 ? "lokaler Commit voraus" : "lokale Commits voraus") : (ahead === 1 ? "commit ahead" : "commits ahead")}`
|
||||||
: "Push"}
|
: "Push"}
|
||||||
>
|
>
|
||||||
@@ -108,8 +128,12 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Upload size={15} aria-hidden="true" />
|
<Upload size={15} aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="repo-action-label">Push</span>
|
<span class="repo-action-label">{pushLabel}</span>
|
||||||
{#if ahead > 0}<span class="repo-action-count ahead">↑{ahead}</span>{/if}
|
{#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>
|
</button>
|
||||||
<div class="repo-history-wrap">
|
<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">
|
<button class="repo-action" type="button" onclick={() => { syncOpen = !syncOpen; historyOpen = false; }} disabled={!hasRepository || isBusy} aria-label={isGerman ? "Sync-Optionen" : "Sync options"} aria-haspopup="menu">
|
||||||
@@ -120,6 +144,7 @@
|
|||||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onFetchPrune(); }}><CloudDownload size={15} /><span><strong>Fetch + Prune</strong><small>{isGerman ? "Veraltete Remote-Branches entfernen" : "Remove stale remote branches"}</small></span></button>
|
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onFetchPrune(); }}><CloudDownload size={15} /><span><strong>Fetch + Prune</strong><small>{isGerman ? "Veraltete Remote-Branches entfernen" : "Remove stale remote branches"}</small></span></button>
|
||||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onForcePush(); }}><Upload size={15} /><span><strong>Force with lease</strong><small>{isGerman ? "Sicheres Pushen nach Rebase" : "Safe push after rebase"}</small></span></button>
|
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onForcePush(); }}><Upload size={15} /><span><strong>Force with lease</strong><small>{isGerman ? "Sicheres Pushen nach Rebase" : "Safe push after rebase"}</small></span></button>
|
||||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onSyncOptions(); }}><Settings2 size={15} /><span><strong>{isGerman ? "Remotes & Strategien" : "Remotes & strategies"}</strong><small>{isGerman ? "Upstream, Pull und Remote verwalten" : "Manage upstream, pull and remotes"}</small></span></button>
|
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onSyncOptions(); }}><Settings2 size={15} /><span><strong>{isGerman ? "Remotes & Strategien" : "Remotes & strategies"}</strong><small>{isGerman ? "Upstream, Pull und Remote verwalten" : "Manage upstream, pull and remotes"}</small></span></button>
|
||||||
|
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onOpenLfs(); }}><Box size={15} /><span><strong>Git LFS</strong><small>{isGerman ? "Große Dateien und LFS-Installation verwalten" : "Manage large files and LFS installation"}</small></span></button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -144,8 +169,8 @@
|
|||||||
class="repo-action"
|
class="repo-action"
|
||||||
onclick={onCompare}
|
onclick={onCompare}
|
||||||
disabled={!hasRepository || isBusy}
|
disabled={!hasRepository || isBusy}
|
||||||
title={isGerman ? "Commits vergleichen" : "Compare commits"}
|
title={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
|
||||||
aria-label={isGerman ? "Commits vergleichen" : "Compare commits"}
|
aria-label={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
|
||||||
>
|
>
|
||||||
<GitCompare size={15} aria-hidden="true" />
|
<GitCompare size={15} aria-hidden="true" />
|
||||||
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
|
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
|
||||||
@@ -192,15 +217,23 @@
|
|||||||
<div class="repo-toolbar-divider utility" aria-hidden="true"></div>
|
<div class="repo-toolbar-divider utility" aria-hidden="true"></div>
|
||||||
|
|
||||||
<div class="repo-action-group repo-utility-actions">
|
<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
|
<button
|
||||||
class="repo-action"
|
class="repo-action"
|
||||||
onclick={onOpenInExplorer}
|
onclick={onOpenInExplorer}
|
||||||
disabled={!hasRepository || isBusy}
|
disabled={!hasRepository || isBusy}
|
||||||
title={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
|
title={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
|
||||||
aria-label={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
|
aria-label={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
|
||||||
>
|
>
|
||||||
<FolderOpen size={15} aria-hidden="true" />
|
<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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { GitCommitHorizontal, LoaderCircle, Sparkles, X } from "@lucide/svelte";
|
||||||
|
import type { AiCommitPlan } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
plan: AiCommitPlan;
|
||||||
|
isApplying: boolean;
|
||||||
|
onApply: (plan: AiCommitPlan) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { plan, isApplying = false, onApply, onClose }: Props = $props();
|
||||||
|
let draft = $state<AiCommitPlan>({ summary: "", groups: [] });
|
||||||
|
$effect.pre(() => {
|
||||||
|
if (draft.groups.length === 0) draft = structuredClone(plan);
|
||||||
|
});
|
||||||
|
let valid = $derived(draft.groups.length > 1 && draft.groups.every((group) => group.message.trim() && group.files.length));
|
||||||
|
let validationIssue = $derived(
|
||||||
|
draft.groups.findIndex((group) => group.files.length === 0) >= 0
|
||||||
|
? "Every commit needs at least one file."
|
||||||
|
: draft.groups.findIndex((group) => !group.message.trim()) >= 0
|
||||||
|
? "Every commit needs a message."
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
|
||||||
|
function setMessage(index: number, message: string) {
|
||||||
|
draft.groups[index].message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveFile(file: string, from: number, to: number) {
|
||||||
|
if (from === to) return;
|
||||||
|
draft.groups[from].files = draft.groups[from].files.filter((path) => path !== file);
|
||||||
|
draft.groups[to].files = [...draft.groups[to].files, file];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDraft() {
|
||||||
|
// `draft` is a deeply reactive Svelte proxy. `structuredClone(draft)`
|
||||||
|
// throws a DataCloneError before the callback runs, which made the button
|
||||||
|
// appear to do nothing. A state snapshot is a plain, cloneable object.
|
||||||
|
onApply($state.snapshot(draft));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={(event) => { if (event.key === "Escape" && !isApplying) onClose(); }} />
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog split-dialog" role="dialog" aria-modal="true" aria-label="AI commit split">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div><span class="eyebrow">Staged changes</span><h2>Split into logical commits</h2></div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isApplying} aria-label="Close"><X size={18} /></button>
|
||||||
|
</header>
|
||||||
|
<div class="split-intro"><Sparkles size={18} /><p>{draft.summary}</p></div>
|
||||||
|
<div class="split-groups">
|
||||||
|
{#each draft.groups as group, groupIndex}
|
||||||
|
<article class:empty={group.files.length === 0}>
|
||||||
|
<header><GitCommitHorizontal size={17} /><strong>Commit {groupIndex + 1}</strong><span>{group.files.length} files</span></header>
|
||||||
|
<label>
|
||||||
|
<span>Commit message <em>AI-generated · editable</em></span>
|
||||||
|
<input value={group.message} oninput={(event) => setMessage(groupIndex, event.currentTarget.value)} disabled={isApplying} />
|
||||||
|
</label>
|
||||||
|
{#if group.reason}<p>{group.reason}</p>{/if}
|
||||||
|
<div class="split-files">
|
||||||
|
{#each group.files as file}
|
||||||
|
<div><code>{file}</code>
|
||||||
|
<SelectMenu class="split-file-target" value={String(groupIndex)} options={draft.groups.map((_, target) => ({ value: String(target), label: `Commit ${target + 1}` }))} disabled={isApplying} onChange={(value) => moveFile(file, groupIndex, Number(value))} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<p class:invalid={!!validationIssue}>{validationIssue || "Messages are generated automatically. All commits are created in this order."}</p>
|
||||||
|
<div><button class="btn-secondary" type="button" onclick={onClose} disabled={isApplying}>Cancel</button>
|
||||||
|
<button class="btn-primary" type="button" onclick={applyDraft} disabled={!valid || isApplying}>
|
||||||
|
{#if isApplying}<LoaderCircle class="spin" size={14} />{/if}Commit all ({draft.groups.length})
|
||||||
|
</button></div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.split-dialog{width:min(820px,calc(100vw - 32px));max-height:min(820px,calc(100vh - 32px));display:flex;flex-direction:column}
|
||||||
|
.split-intro{display:flex;gap:10px;align-items:flex-start;padding:14px 18px;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
|
||||||
|
.split-intro p{margin:0;line-height:1.5}
|
||||||
|
.split-groups{display:grid;gap:10px;padding:14px 18px;overflow:auto}
|
||||||
|
article{display:grid;gap:10px;padding:13px;border:1px solid var(--color-border-subtle);border-radius:9px;background:var(--color-surface-raised)}
|
||||||
|
article.empty{border-color:#d88a45}
|
||||||
|
article>header{display:flex;align-items:center;gap:8px;color:var(--color-ink)}
|
||||||
|
article>header span{margin-left:auto;color:var(--color-ink-faint);font-size:11px}
|
||||||
|
label{display:grid;gap:5px;color:var(--color-ink-faint);font-size:10px;font-weight:800;text-transform:uppercase}
|
||||||
|
label span{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||||
|
label em{color:var(--color-accent);font-size:9px;font-style:normal;font-weight:700;text-transform:none}
|
||||||
|
input{height:34px;padding:0 10px;border:1px solid var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-ink);font-family:var(--font-mono)}
|
||||||
|
article>p{margin:0;color:var(--color-ink-muted);font-size:12px;line-height:1.45}
|
||||||
|
.split-files{display:grid;gap:5px}
|
||||||
|
.split-files>div{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;background:var(--color-surface)}
|
||||||
|
code{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;color:var(--color-ink-muted);font-size:11px;white-space:nowrap}
|
||||||
|
:global(.split-file-target){width:110px;flex:0 0 110px}
|
||||||
|
:global(.split-file-target .select-menu-trigger){height:28px;min-height:28px;font-size:11px}
|
||||||
|
.dialog-footer p.invalid{color:#e0a040}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
|
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
|
||||||
import { credDelete, credLoad, credSave } from "../git";
|
import { credDelete, credLoad, credSave } from "../git";
|
||||||
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
|
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: AiSettings;
|
settings: AiSettings;
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
const key = CRED_KEYS[target];
|
const key = CRED_KEYS[target];
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
await credSave(key, "api-key", trimmed, null);
|
await credSave(key, "api-key", trimmed);
|
||||||
} else {
|
} else {
|
||||||
await credDelete(key);
|
await credDelete(key);
|
||||||
}
|
}
|
||||||
@@ -200,11 +201,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="cred-field">
|
<label class="cred-field">
|
||||||
<span class="cred-field-label">Model</span>
|
<span class="cred-field-label">Model</span>
|
||||||
<select bind:value={localModelId}>
|
<SelectMenu value={localModelId} options={localModels.map((option) => ({ value: option.id, label: `${option.label} - ${formatSize(option.approx_size_mb)}` }))} onChange={(value) => { localModelId = value; }} />
|
||||||
{#each localModels as option (option.id)}
|
|
||||||
<option value={option.id}>{option.label} — {formatSize(option.approx_size_mb)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<div class="cred-token-hint">
|
<div class="cred-token-hint">
|
||||||
<AlertCircle size={13} aria-hidden="true" />
|
<AlertCircle size={13} aria-hidden="true" />
|
||||||
|
|||||||
@@ -1,22 +1,84 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, Languages, RefreshCw, Settings, X } from "@lucide/svelte";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types";
|
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";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
|
type SettingsPage = "general" | "tools";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
analytics: AnalyticsSettings;
|
analytics: AnalyticsSettings;
|
||||||
theme: AppTheme;
|
theme: AppTheme;
|
||||||
language: AppLanguage;
|
language: AppLanguage;
|
||||||
autoRefresh: boolean;
|
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;
|
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 analyticsEnabled = $state(true);
|
||||||
let selectedTheme = $state<AppTheme>("system");
|
let selectedTheme = $state<AppTheme>("system");
|
||||||
let selectedLanguage = $state<AppLanguage>("en");
|
let selectedLanguage = $state<AppLanguage>("en");
|
||||||
let autoRefreshEnabled = $state(true);
|
let autoRefreshEnabled = $state(true);
|
||||||
|
let tools = $state<ExternalToolsSettings>(defaultExternalToolsSettings());
|
||||||
const isGerman = $derived(selectedLanguage === "de");
|
const isGerman = $derived(selectedLanguage === "de");
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -24,6 +86,7 @@
|
|||||||
selectedTheme = theme;
|
selectedTheme = theme;
|
||||||
selectedLanguage = language;
|
selectedLanguage = language;
|
||||||
autoRefreshEnabled = autoRefresh;
|
autoRefreshEnabled = autoRefresh;
|
||||||
|
tools = structuredClone(externalTools);
|
||||||
});
|
});
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
@@ -31,112 +94,482 @@
|
|||||||
...analytics,
|
...analytics,
|
||||||
enabled: analyticsEnabled,
|
enabled: analyticsEnabled,
|
||||||
noticeSeen: true,
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation">
|
<div class="dialog-backdrop" role="presentation">
|
||||||
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
|
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="app-settings-head">
|
||||||
|
<div class="app-settings-title">
|
||||||
|
<span class="app-settings-mark"><Settings2 size={18} aria-hidden="true" /></span>
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Gitty</span>
|
<h2>{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||||
<h2 class="dialog-title">{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"}>
|
</div>
|
||||||
|
<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" />
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
<form class="app-settings-shell" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
||||||
<section class="settings-section">
|
<div class="app-settings-body">
|
||||||
<header>
|
<nav class="settings-nav" aria-label={isGerman ? "Einstellungsbereiche" : "Settings sections"}>
|
||||||
<Settings size={16} aria-hidden="true" />
|
<button type="button" class:active={activePage === "general"} onclick={() => { activePage = "general"; }}>
|
||||||
<div>
|
<SlidersHorizontal size={16} aria-hidden="true" />
|
||||||
<span class="eyebrow">{isGerman ? "Darstellung" : "Appearance"}</span>
|
<span>
|
||||||
<h3>{isGerman ? "Farbschema" : "Theme"}</h3>
|
<strong>{isGerman ? "Allgemein" : "General"}</strong>
|
||||||
</div>
|
<small>{isGerman ? "Darstellung & Verhalten" : "Appearance & behavior"}</small>
|
||||||
</header>
|
</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-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"}>
|
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}>
|
||||||
<label class:active={selectedTheme === "system"}>
|
<label class:active={selectedTheme === "system"}><input type="radio" bind:group={selectedTheme} value="system" /><span>System</span></label>
|
||||||
<input type="radio" bind:group={selectedTheme} value="system" />
|
<label class:active={selectedTheme === "light"}><input type="radio" bind:group={selectedTheme} value="light" /><span>{isGerman ? "Hell" : "Light"}</span></label>
|
||||||
<span>System</span>
|
<label class:active={selectedTheme === "dark"}><input type="radio" bind:group={selectedTheme} value="dark" /><span>{isGerman ? "Dunkel" : "Dark"}</span></label>
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-section">
|
<section class="general-setting-panel">
|
||||||
<header>
|
<header><Languages size={16} /><div><h4>{isGerman ? "Sprache" : "Language"}</h4><p>{isGerman ? "Sprache der Oberfläche." : "Language used by the interface."}</p></div></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"}>
|
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
|
||||||
<label class:active={selectedLanguage === "en"}>
|
<label class:active={selectedLanguage === "en"}><input type="radio" bind:group={selectedLanguage} value="en" /><span>EN · English</span></label>
|
||||||
<input type="radio" bind:group={selectedLanguage} value="en" />
|
<label class:active={selectedLanguage === "de"}><input type="radio" bind:group={selectedLanguage} value="de" /><span>DE · Deutsch</span></label>
|
||||||
<span>EN · English</span>
|
|
||||||
</label>
|
|
||||||
<label class:active={selectedLanguage === "de"}>
|
|
||||||
<input type="radio" bind:group={selectedLanguage} value="de" />
|
|
||||||
<span>DE · Deutsch</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-section">
|
<section class="general-setting-panel general-setting-wide">
|
||||||
<header>
|
<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>
|
||||||
<Settings size={16} aria-hidden="true" />
|
<label class="settings-switch-row">
|
||||||
<div>
|
<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>
|
||||||
<span class="eyebrow">Analytics</span>
|
<input type="checkbox" bind:checked={autoRefreshEnabled} />
|
||||||
<h3>{isGerman ? "Anonyme Nutzungsanalyse" : "Anonymous usage analytics"}</h3>
|
</label>
|
||||||
</div>
|
</section>
|
||||||
</header>
|
|
||||||
|
|
||||||
<label class="settings-toggle-row">
|
<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} />
|
<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>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
<div class="new-branch-actions">
|
{:else}
|
||||||
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
<div class="settings-page-head tools-page-head">
|
||||||
<button class="btn-primary" type="submit">
|
<div>
|
||||||
<Check size={16} aria-hidden="true" />
|
<h3>{isGerman ? "Externe Tools" : "External tools"}</h3>
|
||||||
{isGerman ? "Speichern" : "Save"}
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
<SelectMenu
|
||||||
|
class="tool-preset-select"
|
||||||
|
value={tools[activeToolKind].preset}
|
||||||
|
options={[
|
||||||
|
...availablePresets(activeToolKind).map((preset) => ({ value: preset.id, label: `Installed - ${preset.label}`, group: isGerman ? "Installiert" : "Installed" })),
|
||||||
|
...otherPresets(activeToolKind).map((preset) => ({ value: preset.id, label: preset.label, group: isGerman ? "Weitere unterstützte Programme" : "Other supported applications" })),
|
||||||
|
{ value: "custom", label: isGerman ? "Eigenes Programm auswählen..." : "Choose a custom application..." },
|
||||||
|
]}
|
||||||
|
ariaLabel={isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}
|
||||||
|
onChange={(value) => changePreset(activeToolKind, value)}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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-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; }
|
||||||
|
:global(.tool-preset-select .select-menu-trigger) { height: 38px; min-height: 38px; padding: 0 11px; border-color: var(--color-border); 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 { FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
|
||||||
import type { GitBlameLine } from "../types";
|
import type { GitBlameLine } from "../types";
|
||||||
|
|
||||||
|
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
interface BlameGroup {
|
interface BlameGroup {
|
||||||
id: string;
|
id: string;
|
||||||
hash: string;
|
hash: string;
|
||||||
@@ -66,7 +71,7 @@
|
|||||||
if (!seconds) return "";
|
if (!seconds) return "";
|
||||||
const date = new Date(seconds * 1000);
|
const date = new Date(seconds * 1000);
|
||||||
if (Number.isNaN(date.getTime())) return "";
|
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 {
|
function groupTooltip(group: BlameGroup): string {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<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 { tick } from "svelte";
|
||||||
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
|
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
|
||||||
|
|
||||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
children: BranchTreeNode[];
|
children: BranchTreeNode[];
|
||||||
branchCount: number;
|
branchCount: number;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
|
branches: GitBranchInfo[];
|
||||||
folders: Map<string, BranchFolderNode>;
|
folders: Map<string, BranchFolderNode>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
depth: number;
|
depth: number;
|
||||||
branchCount: number;
|
branchCount: number;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
|
branches: GitBranchInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BranchLeafRow {
|
interface BranchLeafRow {
|
||||||
@@ -49,12 +52,14 @@
|
|||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onCheckout: (branch: GitBranchInfo) => void;
|
onCheckout: (branch: GitBranchInfo) => void;
|
||||||
|
onCompareBranch: (branch: GitBranchInfo) => void;
|
||||||
onMerge: (branch: GitBranchInfo) => void;
|
onMerge: (branch: GitBranchInfo) => void;
|
||||||
onRebase: (branch: GitBranchInfo) => void;
|
onRebase: (branch: GitBranchInfo) => void;
|
||||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||||
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
|
onDeleteBranchFolder: (folderName: string, branches: GitBranchInfo[], depth: number) => void | Promise<void>;
|
||||||
onCreateTag: (name: string, message: string) => void | Promise<void>;
|
onCreateTag: (name: string, message: string) => void | Promise<void>;
|
||||||
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
||||||
onPushTag: (tag: GitTag) => void | Promise<void>;
|
onPushTag: (tag: GitTag) => void | Promise<void>;
|
||||||
@@ -72,12 +77,14 @@
|
|||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
onCheckout = () => {},
|
onCheckout = () => {},
|
||||||
|
onCompareBranch = () => {},
|
||||||
onMerge = () => {},
|
onMerge = () => {},
|
||||||
onRebase = () => {},
|
onRebase = () => {},
|
||||||
onCreateBranch = () => {},
|
onCreateBranch = () => {},
|
||||||
onRenameBranch = () => {},
|
onRenameBranch = () => {},
|
||||||
onDeleteBranch = () => {},
|
onDeleteBranch = () => {},
|
||||||
onDeleteRemoteBranch = () => {},
|
onDeleteRemoteBranch = () => {},
|
||||||
|
onDeleteBranchFolder = () => {},
|
||||||
onCreateTag = () => {},
|
onCreateTag = () => {},
|
||||||
onDeleteTag = () => {},
|
onDeleteTag = () => {},
|
||||||
onPushTag = () => {},
|
onPushTag = () => {},
|
||||||
@@ -97,11 +104,13 @@
|
|||||||
let newTagName = $state("");
|
let newTagName = $state("");
|
||||||
let newTagMessage = $state("");
|
let newTagMessage = $state("");
|
||||||
let tagCreateInput = $state<HTMLInputElement | null>(null);
|
let tagCreateInput = $state<HTMLInputElement | null>(null);
|
||||||
let panelElement = $state<HTMLElement | null>(null);
|
|
||||||
let contextBranch = $state<GitBranchInfo | null>(null);
|
let contextBranch = $state<GitBranchInfo | null>(null);
|
||||||
|
let contextFolder = $state<BranchFolderRow | null>(null);
|
||||||
|
let branchContextMenuElement = $state<HTMLElement | null>(null);
|
||||||
let contextMenuX = $state(0);
|
let contextMenuX = $state(0);
|
||||||
let contextMenuY = $state(0);
|
let contextMenuY = $state(0);
|
||||||
let contextTag = $state<GitTag | null>(null);
|
let contextTag = $state<GitTag | null>(null);
|
||||||
|
let tagContextMenuElement = $state<HTMLElement | null>(null);
|
||||||
let tagContextMenuX = $state(0);
|
let tagContextMenuX = $state(0);
|
||||||
let tagContextMenuY = $state(0);
|
let tagContextMenuY = $state(0);
|
||||||
let collapsedBranchFolders = $state<Set<string>>(new Set());
|
let collapsedBranchFolders = $state<Set<string>>(new Set());
|
||||||
@@ -117,6 +126,7 @@
|
|||||||
children: [],
|
children: [],
|
||||||
branchCount: 0,
|
branchCount: 0,
|
||||||
current: false,
|
current: false,
|
||||||
|
branches: [],
|
||||||
folders: new Map(),
|
folders: new Map(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -143,6 +153,7 @@
|
|||||||
|
|
||||||
folder.branchCount += 1;
|
folder.branchCount += 1;
|
||||||
folder.current ||= branch.current;
|
folder.current ||= branch.current;
|
||||||
|
folder.branches.push(branch);
|
||||||
parent = folder;
|
parent = folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +195,7 @@
|
|||||||
depth,
|
depth,
|
||||||
branchCount: node.branchCount,
|
branchCount: node.branchCount,
|
||||||
current: node.current,
|
current: node.current,
|
||||||
|
branches: node.branches,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isBranchFolderOpen(node.id)) {
|
if (isBranchFolderOpen(node.id)) {
|
||||||
@@ -241,24 +253,52 @@
|
|||||||
onCheckout(branch);
|
onCheckout(branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
|
function fitContextMenuToViewport(element: HTMLElement | null, x: number, y: number) {
|
||||||
|
const rect = element?.getBoundingClientRect();
|
||||||
|
const width = rect?.width ?? 184;
|
||||||
|
const height = rect?.height ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.max(8, Math.min(x + 2, window.innerWidth - width - 8)),
|
||||||
|
y: Math.max(8, Math.min(y + 2, window.innerHeight - height - 8)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
|
|
||||||
const rect = panelElement?.getBoundingClientRect();
|
|
||||||
const rawX = rect ? event.clientX - rect.left : event.offsetX;
|
|
||||||
const rawY = rect ? event.clientY - rect.top : event.offsetY;
|
|
||||||
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
|
|
||||||
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 190);
|
|
||||||
|
|
||||||
contextBranch = branch;
|
contextBranch = branch;
|
||||||
contextMenuX = Math.max(8, Math.min(rawX, maxX));
|
contextMenuX = event.clientX + 2;
|
||||||
contextMenuY = Math.max(8, Math.min(rawY, maxY));
|
contextMenuY = event.clientY + 2;
|
||||||
|
await tick();
|
||||||
|
if (contextBranch !== branch) return;
|
||||||
|
|
||||||
|
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY);
|
||||||
|
contextMenuX = position.x;
|
||||||
|
contextMenuY = position.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFolderContextMenu(event: MouseEvent, folder: BranchFolderRow) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (isBusy || (folder.depth === 0 && folder.branches.every((branch) => branch.remote))) return;
|
||||||
|
|
||||||
|
contextFolder = folder;
|
||||||
|
contextMenuX = event.clientX + 2;
|
||||||
|
contextMenuY = event.clientY + 2;
|
||||||
|
await tick();
|
||||||
|
if (contextFolder !== folder) return;
|
||||||
|
|
||||||
|
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY);
|
||||||
|
contextMenuX = position.x;
|
||||||
|
contextMenuY = position.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeBranchContextMenu() {
|
function closeBranchContextMenu() {
|
||||||
contextBranch = null;
|
contextBranch = null;
|
||||||
|
contextFolder = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renameContextBranch() {
|
async function renameContextBranch() {
|
||||||
@@ -268,6 +308,13 @@
|
|||||||
await onRenameBranch(branch);
|
await onRenameBranch(branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareContextBranch() {
|
||||||
|
const branch = contextBranch;
|
||||||
|
if (!branch || isBusy) return;
|
||||||
|
closeBranchContextMenu();
|
||||||
|
onCompareBranch(branch);
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteContextBranch() {
|
async function deleteContextBranch() {
|
||||||
const branch = contextBranch;
|
const branch = contextBranch;
|
||||||
if (!branch || branch.current || isBusy) return;
|
if (!branch || branch.current || isBusy) return;
|
||||||
@@ -275,6 +322,13 @@
|
|||||||
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
|
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteContextFolder() {
|
||||||
|
const folder = contextFolder;
|
||||||
|
if (!folder || isBusy || (folder.depth === 0 && folder.branches.every((branch) => branch.remote))) return;
|
||||||
|
closeBranchContextMenu();
|
||||||
|
await onDeleteBranchFolder(folder.name, folder.branches, folder.depth);
|
||||||
|
}
|
||||||
|
|
||||||
async function createContextWorktree() {
|
async function createContextWorktree() {
|
||||||
const branch = contextBranch;
|
const branch = contextBranch;
|
||||||
closeBranchContextMenu();
|
closeBranchContextMenu();
|
||||||
@@ -326,20 +380,20 @@
|
|||||||
tagsOpen = true;
|
tagsOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTagContextMenu(event: MouseEvent, tag: GitTag) {
|
async function openTagContextMenu(event: MouseEvent, tag: GitTag) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
|
|
||||||
const rect = panelElement?.getBoundingClientRect();
|
|
||||||
const rawX = rect ? event.clientX - rect.left : event.offsetX;
|
|
||||||
const rawY = rect ? event.clientY - rect.top : event.offsetY;
|
|
||||||
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
|
|
||||||
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 130);
|
|
||||||
|
|
||||||
contextTag = tag;
|
contextTag = tag;
|
||||||
tagContextMenuX = Math.max(8, Math.min(rawX, maxX));
|
tagContextMenuX = event.clientX + 2;
|
||||||
tagContextMenuY = Math.max(8, Math.min(rawY, maxY));
|
tagContextMenuY = event.clientY + 2;
|
||||||
|
await tick();
|
||||||
|
if (contextTag !== tag) return;
|
||||||
|
|
||||||
|
const position = fitContextMenuToViewport(tagContextMenuElement, event.clientX, event.clientY);
|
||||||
|
tagContextMenuX = position.x;
|
||||||
|
tagContextMenuY = position.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTagContextMenu() {
|
function closeTagContextMenu() {
|
||||||
@@ -372,7 +426,7 @@
|
|||||||
|
|
||||||
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
|
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
|
||||||
|
|
||||||
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
|
<section class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Branches</span>
|
<span class="eyebrow">Branches</span>
|
||||||
@@ -462,6 +516,7 @@
|
|||||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => toggleBranchFolder(row.id)}
|
onclick={() => toggleBranchFolder(row.id)}
|
||||||
|
oncontextmenu={(event) => openFolderContextMenu(event, row)}
|
||||||
aria-expanded={isBranchFolderOpen(row.id)}
|
aria-expanded={isBranchFolderOpen(row.id)}
|
||||||
title={`${row.name} (${row.branchCount})`}
|
title={`${row.name} (${row.branchCount})`}
|
||||||
>
|
>
|
||||||
@@ -529,6 +584,7 @@
|
|||||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => toggleBranchFolder(row.id)}
|
onclick={() => toggleBranchFolder(row.id)}
|
||||||
|
oncontextmenu={(event) => openFolderContextMenu(event, row)}
|
||||||
aria-expanded={isBranchFolderOpen(row.id)}
|
aria-expanded={isBranchFolderOpen(row.id)}
|
||||||
title={`${row.name} (${row.branchCount})`}
|
title={`${row.name} (${row.branchCount})`}
|
||||||
>
|
>
|
||||||
@@ -667,6 +723,7 @@
|
|||||||
|
|
||||||
{#if contextBranch}
|
{#if contextBranch}
|
||||||
<div
|
<div
|
||||||
|
bind:this={branchContextMenuElement}
|
||||||
class="branch-context-menu"
|
class="branch-context-menu"
|
||||||
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||||
role="menu"
|
role="menu"
|
||||||
@@ -677,6 +734,10 @@
|
|||||||
<GitBranch size={14} aria-hidden="true" />
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
Checkout
|
Checkout
|
||||||
</button>
|
</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}>
|
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
|
||||||
<GitMerge size={14} aria-hidden="true" />
|
<GitMerge size={14} aria-hidden="true" />
|
||||||
Merge into current
|
Merge into current
|
||||||
@@ -690,9 +751,9 @@
|
|||||||
Open in new worktree
|
Open in new worktree
|
||||||
</button>
|
</button>
|
||||||
<div class="menu-separator" role="separator"></div>
|
<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" />
|
<Pencil size={14} aria-hidden="true" />
|
||||||
Rename
|
{contextBranch.remote ? "Rename remote..." : "Rename"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="danger"
|
class="danger"
|
||||||
@@ -708,8 +769,32 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if contextFolder}
|
||||||
|
<div
|
||||||
|
bind:this={branchContextMenuElement}
|
||||||
|
class="branch-context-menu"
|
||||||
|
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label={`Actions for branch folder ${contextFolder.name}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="danger"
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onclick={deleteContextFolder}
|
||||||
|
disabled={isBusy || contextFolder.branches.every((branch) => branch.current)}
|
||||||
|
title={contextFolder.current ? "The current branch will be kept" : "Delete all branches in this folder"}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
|
Delete {contextFolder.branches.filter((branch) => !branch.current).length} branches
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if contextTag}
|
{#if contextTag}
|
||||||
<div
|
<div
|
||||||
|
bind:this={tagContextMenuElement}
|
||||||
class="branch-context-menu"
|
class="branch-context-menu"
|
||||||
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
|
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
|
||||||
role="menu"
|
role="menu"
|
||||||
|
|||||||
@@ -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,300 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
GitCommitHorizontal,
|
||||||
|
Info,
|
||||||
|
LoaderCircle,
|
||||||
|
Save,
|
||||||
|
StickyNote,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import type { AppLanguage, GitCommit, GitRemote } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
|
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>
|
||||||
|
<SelectMenu value={selectedRemote} options={remotes.map((remote) => ({ value: remote.name, label: remote.name }))} disabled={isBusy} onChange={(value) => { selectedRemote = value; }} />
|
||||||
|
</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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
|
import { Check, GitCommitHorizontal, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
|
||||||
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,12 +14,14 @@
|
|||||||
commitAiPhase: CommitAiPhase;
|
commitAiPhase: CommitAiPhase;
|
||||||
commitAiGenerating: boolean;
|
commitAiGenerating: boolean;
|
||||||
commitAiReviewing: boolean;
|
commitAiReviewing: boolean;
|
||||||
|
commitAiSplitting: boolean;
|
||||||
canAmend: boolean;
|
canAmend: boolean;
|
||||||
amendMode: boolean;
|
amendMode: boolean;
|
||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
onCommitMessageChange: (msg: string) => void;
|
onCommitMessageChange: (msg: string) => void;
|
||||||
onGenerateCommitMessage: () => void;
|
onGenerateCommitMessage: () => void;
|
||||||
onReviewStaged: () => void;
|
onReviewStaged: () => void;
|
||||||
|
onSplitStaged: () => void;
|
||||||
onOpenAiSettings: () => void;
|
onOpenAiSettings: () => void;
|
||||||
onToggleAmend: (checked: boolean) => void;
|
onToggleAmend: (checked: boolean) => void;
|
||||||
onUndoLastCommit: () => void;
|
onUndoLastCommit: () => void;
|
||||||
@@ -37,12 +39,14 @@
|
|||||||
commitAiPhase = "idle",
|
commitAiPhase = "idle",
|
||||||
commitAiGenerating = false,
|
commitAiGenerating = false,
|
||||||
commitAiReviewing = false,
|
commitAiReviewing = false,
|
||||||
|
commitAiSplitting = false,
|
||||||
canAmend = false,
|
canAmend = false,
|
||||||
amendMode = false,
|
amendMode = false,
|
||||||
onCommit = () => {},
|
onCommit = () => {},
|
||||||
onCommitMessageChange = () => {},
|
onCommitMessageChange = () => {},
|
||||||
onGenerateCommitMessage = () => {},
|
onGenerateCommitMessage = () => {},
|
||||||
onReviewStaged = () => {},
|
onReviewStaged = () => {},
|
||||||
|
onSplitStaged = () => {},
|
||||||
onOpenAiSettings = () => {},
|
onOpenAiSettings = () => {},
|
||||||
onToggleAmend = () => {},
|
onToggleAmend = () => {},
|
||||||
onUndoLastCommit = () => {},
|
onUndoLastCommit = () => {},
|
||||||
@@ -70,6 +74,7 @@
|
|||||||
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
||||||
);
|
);
|
||||||
let canReview = $derived(canGenerate && commitAiProvider !== "local");
|
let canReview = $derived(canGenerate && commitAiProvider !== "local");
|
||||||
|
let canSplit = $derived(canReview && stagedCount > 1 && !commitAiSplitting);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel commit-panel" aria-label="Commit">
|
<section class="panel commit-panel" aria-label="Commit">
|
||||||
@@ -80,6 +85,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="commit-head-actions">
|
<div class="commit-head-actions">
|
||||||
<span class="pill pill-count">{stagedCount} staged</span>
|
<span class="pill pill-count">{stagedCount} staged</span>
|
||||||
|
<button
|
||||||
|
class="commit-review-button"
|
||||||
|
type="button"
|
||||||
|
onclick={onSplitStaged}
|
||||||
|
disabled={!canSplit}
|
||||||
|
title={commitAiProvider === "local" ? "Commit splitting currently requires an API provider" : "Suggest logical commits for the staged files"}
|
||||||
|
>
|
||||||
|
{#if commitAiSplitting}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<GitCommitHorizontal size={14} aria-hidden="true" />{/if}
|
||||||
|
Split
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="commit-review-button"
|
class="commit-review-button"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowRight, FileCode, RotateCcw, X } from "@lucide/svelte";
|
import { ArrowRight, FileCode, GitCompare, RotateCcw, X } from "@lucide/svelte";
|
||||||
import type { GitCommitComparison, GitDiffFile, FileStatusKind } from "../types";
|
import type { GitCommitComparison, GitDiffFile, FileStatusKind } from "../types";
|
||||||
|
|
||||||
type SplitRow =
|
type SplitRow =
|
||||||
@@ -20,9 +20,12 @@
|
|||||||
comparison: GitCommitComparison;
|
comparison: GitCommitComparison;
|
||||||
selectedDiffPath: string;
|
selectedDiffPath: string;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
fromLabel?: string;
|
||||||
|
toLabel?: string;
|
||||||
restoreLabel?: string;
|
restoreLabel?: string;
|
||||||
/** When opened from a search hit, the term to highlight on matching lines. */
|
/** When opened from a search hit, the term to highlight on matching lines. */
|
||||||
highlightQuery?: string;
|
highlightQuery?: string;
|
||||||
|
language?: "en" | "de";
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRestore?: () => void;
|
onRestore?: () => void;
|
||||||
onSelectFile: (file: GitDiffFile) => void;
|
onSelectFile: (file: GitDiffFile) => void;
|
||||||
@@ -32,13 +35,18 @@
|
|||||||
comparison,
|
comparison,
|
||||||
selectedDiffPath = "",
|
selectedDiffPath = "",
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
fromLabel = "",
|
||||||
|
toLabel = "",
|
||||||
restoreLabel = "",
|
restoreLabel = "",
|
||||||
highlightQuery = "",
|
highlightQuery = "",
|
||||||
|
language = "en",
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
onRestore = undefined,
|
onRestore = undefined,
|
||||||
onSelectFile = () => {},
|
onSelectFile = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let isGerman = $derived(language === "de");
|
||||||
|
|
||||||
// Needle = first non-empty line of the search query, lowercased for matching.
|
// Needle = first non-empty line of the search query, lowercased for matching.
|
||||||
let highlightNeedle = $derived(
|
let highlightNeedle = $derived(
|
||||||
highlightQuery
|
highlightQuery
|
||||||
@@ -216,19 +224,22 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop compare-dialog-backdrop"
|
||||||
role="presentation"
|
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">
|
<header class="compare-dialog-head">
|
||||||
|
<div class="compare-dialog-title">
|
||||||
|
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Compare</span>
|
<h2>{isGerman ? "Änderungen vergleichen" : "Compare changes"}</h2>
|
||||||
<h2 class="dialog-range">
|
<p 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" />
|
<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>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-header-actions">
|
<div class="dialog-header-actions">
|
||||||
{#if restoreLabel && onRestore}
|
{#if restoreLabel && onRestore}
|
||||||
@@ -237,19 +248,23 @@
|
|||||||
<span>{restoreLabel}</span>
|
<span>{restoreLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Vergleich schließen" : "Close comparison"}>
|
||||||
<X size={18} aria-hidden="true" />
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if comparison.files.length === 0}
|
{#if comparison.files.length === 0}
|
||||||
<div class="blank-state">No differences — these versions are identical.</div>
|
<div class="blank-state">{isGerman ? "Keine Unterschiede - diese Versionen sind identisch." : "No differences - these versions are identical."}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
|
|
||||||
<!-- File list -->
|
<!-- File list -->
|
||||||
<aside class="dialog-files" aria-label="Changed files">
|
<aside class="dialog-files" aria-label={isGerman ? "Geänderte Dateien" : "Changed files"}>
|
||||||
|
<div class="compare-files-head">
|
||||||
|
<span>{isGerman ? "Geänderte Dateien" : "Changed files"}</span>
|
||||||
|
<strong>{comparison.files.length}</strong>
|
||||||
|
</div>
|
||||||
{#each comparison.files as file (`${file.old_path ?? ""}:${file.path}`)}
|
{#each comparison.files as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||||
<button
|
<button
|
||||||
class="dialog-file-row"
|
class="dialog-file-row"
|
||||||
@@ -271,9 +286,9 @@
|
|||||||
<!-- Diff pane -->
|
<!-- Diff pane -->
|
||||||
<div class="dialog-diff">
|
<div class="dialog-diff">
|
||||||
{#if !selectedFile}
|
{#if !selectedFile}
|
||||||
<div class="blank-state">Select a file to see its changes.</div>
|
<div class="blank-state">{isGerman ? "Wähle eine Datei aus, um ihre Änderungen zu sehen." : "Select a file to see its changes."}</div>
|
||||||
{:else if splitRows.length === 0}
|
{:else if splitRows.length === 0}
|
||||||
<div class="blank-state">No textual changes for this file.</div>
|
<div class="blank-state">{isGerman ? "Keine textuellen Änderungen für diese Datei." : "No textual changes for this file."}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Path bar -->
|
<!-- Path bar -->
|
||||||
<div class="diff-header">
|
<div class="diff-header">
|
||||||
@@ -288,12 +303,12 @@
|
|||||||
<!-- Column headers -->
|
<!-- Column headers -->
|
||||||
<div class="split-col-headers">
|
<div class="split-col-headers">
|
||||||
<div class="split-col-label">
|
<div class="split-col-label">
|
||||||
<span>Before</span>
|
<span>{isGerman ? "Vorher" : "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>
|
||||||
<div class="split-col-label">
|
<div class="split-col-label">
|
||||||
<span>After</span>
|
<span>{isGerman ? "Nachher" : "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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowRight, GitCompare, LoaderCircle } from "@lucide/svelte";
|
import { ArrowRight, GitCompare, LoaderCircle } from "@lucide/svelte";
|
||||||
import type { GitCommit, GitCommitComparison } from "../types";
|
import type { GitCommit, GitCommitComparison } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
@@ -40,6 +41,8 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onCompare();
|
onCompare();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let commitOptions = $derived(commits.map((item) => ({ value: item.hash, label: commitOptionLabel(item), group: "Commits" })));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="compare-panel panel" aria-label="Compare commits">
|
<section class="compare-panel panel" aria-label="Compare commits">
|
||||||
@@ -61,32 +64,14 @@
|
|||||||
<form class="compare-form" onsubmit={handleSubmit}>
|
<form class="compare-form" onsubmit={handleSubmit}>
|
||||||
<label class="compare-field">
|
<label class="compare-field">
|
||||||
<span>From (older)</span>
|
<span>From (older)</span>
|
||||||
<select
|
<SelectMenu value={compareFrom} options={commitOptions} placeholder="Select a commit" showSelectedGroup disabled={isBusy} onChange={onCompareFromChange} />
|
||||||
value={compareFrom}
|
|
||||||
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
<option value="" disabled>Select a commit</option>
|
|
||||||
{#each commits as item (item.hash)}
|
|
||||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
|
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
|
||||||
|
|
||||||
<label class="compare-field">
|
<label class="compare-field">
|
||||||
<span>To (newer)</span>
|
<span>To (newer)</span>
|
||||||
<select
|
<SelectMenu value={compareTo} options={commitOptions} placeholder="Select a commit" showSelectedGroup disabled={isBusy} onChange={onCompareToChange} />
|
||||||
value={compareTo}
|
|
||||||
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
<option value="" disabled>Select a commit</option>
|
|
||||||
{#each commits as item (item.hash)}
|
|
||||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
|
import { ArrowRight, GitCompare, Info, LoaderCircle, X } from "@lucide/svelte";
|
||||||
import type { GitCommit } from "../types";
|
import type { GitBranch, GitCommit } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
|
branches: GitBranch[];
|
||||||
compareFrom: string;
|
compareFrom: string;
|
||||||
compareTo: string;
|
compareTo: string;
|
||||||
canCompare: boolean;
|
canCompare: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
operation: string;
|
operation: string;
|
||||||
|
language?: "en" | "de";
|
||||||
onCompareFromChange: (val: string) => void;
|
onCompareFromChange: (val: string) => void;
|
||||||
onCompareToChange: (val: string) => void;
|
onCompareToChange: (val: string) => void;
|
||||||
onCompare: () => void;
|
onCompare: () => void;
|
||||||
@@ -17,21 +20,50 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
commits = [],
|
commits = [],
|
||||||
|
branches = [],
|
||||||
compareFrom = "",
|
compareFrom = "",
|
||||||
compareTo = "",
|
compareTo = "",
|
||||||
canCompare = false,
|
canCompare = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
operation = "",
|
operation = "",
|
||||||
|
language = "en",
|
||||||
onCompareFromChange = () => {},
|
onCompareFromChange = () => {},
|
||||||
onCompareToChange = () => {},
|
onCompareToChange = () => {},
|
||||||
onCompare = () => {},
|
onCompare = () => {},
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let isGerman = $derived(language === "de");
|
||||||
|
|
||||||
function commitOptionLabel(item: GitCommit): string {
|
function commitOptionLabel(item: GitCommit): string {
|
||||||
return `${item.short_hash} - ${item.summary}`;
|
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);
|
||||||
|
let compareOptions = $derived([
|
||||||
|
...localBranches.map((branch) => ({
|
||||||
|
value: branchValue(branch),
|
||||||
|
label: `${branch.name}${branch.current ? (isGerman ? " (aktuell)" : " (current)") : ""}`,
|
||||||
|
group: isGerman ? "Lokale Branches" : "Local branches",
|
||||||
|
})),
|
||||||
|
...remoteBranches.map((branch) => ({
|
||||||
|
value: branchValue(branch),
|
||||||
|
label: branch.name,
|
||||||
|
group: isGerman ? "Remote-Branches" : "Remote branches",
|
||||||
|
})),
|
||||||
|
...commits.map((item) => ({
|
||||||
|
value: item.hash,
|
||||||
|
label: commitOptionLabel(item),
|
||||||
|
group: "Commits",
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
function handleSubmit(event: SubmitEvent) {
|
function handleSubmit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onCompare();
|
onCompare();
|
||||||
@@ -42,66 +74,66 @@
|
|||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
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={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"} tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="compare-dialog-head">
|
||||||
|
<div class="compare-dialog-title">
|
||||||
|
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Compare</span>
|
<h2>{isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}</h2>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Select commits</h2>
|
<p>{isGerman ? "Zwei Repository-Stände direkt gegenüberstellen" : "Review two repository states side by side"}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
</div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Vergleich schließen" : "Close comparison"}>
|
||||||
<X size={18} aria-hidden="true" />
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if commits.length < 2}
|
<form class="compare-select-shell" onsubmit={handleSubmit}>
|
||||||
<div class="blank-state">At least two commits are needed to compare.</div>
|
<div class="compare-select-body">
|
||||||
|
{#if targetCount < 2}
|
||||||
|
<div class="blank-state">{isGerman ? "Für einen Vergleich werden mindestens zwei Branches oder Commits benötigt." : "At least two branches or commits are needed to compare."}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<form class="compare-form" onsubmit={handleSubmit}>
|
<section class="compare-target-panel">
|
||||||
|
<div class="compare-target-heading">
|
||||||
|
<h3>{isGerman ? "Vergleichsbereich" : "Comparison range"}</h3>
|
||||||
|
<p>{isGerman ? "Wähle Ausgangspunkt und Ziel des Vergleichs." : "Choose the starting point and target for the comparison."}</p>
|
||||||
|
</div>
|
||||||
|
<div class="compare-form">
|
||||||
<label class="compare-field">
|
<label class="compare-field">
|
||||||
<span>From (older)</span>
|
<span>{isGerman ? "Ausgangsbasis" : "Base"}</span>
|
||||||
<select
|
<SelectMenu class="compare-target-select" value={compareFrom} options={compareOptions} placeholder={isGerman ? "Branch oder Commit wählen" : "Select a branch or commit"} showSelectedGroup disabled={isBusy} onChange={onCompareFromChange} />
|
||||||
value={compareFrom}
|
|
||||||
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
<option value="" disabled>Select a commit</option>
|
|
||||||
{#each commits as item (item.hash)}
|
|
||||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
|
<span class="compare-arrow-shell"><ArrowRight size={18} aria-hidden="true" /></span>
|
||||||
|
|
||||||
<label class="compare-field">
|
<label class="compare-field">
|
||||||
<span>To (newer)</span>
|
<span>{isGerman ? "Vergleichen mit" : "Compare with"}</span>
|
||||||
<select
|
<SelectMenu class="compare-target-select" value={compareTo} options={compareOptions} placeholder={isGerman ? "Branch oder Commit wählen" : "Select a branch or commit"} showSelectedGroup disabled={isBusy} onChange={onCompareToChange} />
|
||||||
value={compareTo}
|
|
||||||
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
<option value="" disabled>Select a commit</option>
|
|
||||||
{#each commits as item (item.hash)}
|
|
||||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
{#if compareFrom && compareTo && compareFrom === compareTo}
|
||||||
{#if operation === "Comparing commits"}
|
<div class="compare-target-help compare-target-warning"><Info size={15} aria-hidden="true" /><span>{isGerman ? "Wähle zwei unterschiedliche Branches oder Commits." : "Select two different branches or commits to compare."}</span></div>
|
||||||
|
{:else}
|
||||||
|
<div class="compare-target-help"><Info size={15} aria-hidden="true" /><span>{isGerman ? "Verglichen werden die vollständigen Repository-Stände. Nicht committete Änderungen im Arbeitsverzeichnis sind nicht enthalten." : "The complete repository states are compared. Uncommitted working-tree changes are not included."}</span></div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<footer class="compare-select-footer">
|
||||||
|
<span>{isGerman ? `${targetCount} Ziele verfügbar` : `${targetCount} targets available`}</span>
|
||||||
|
<div>
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
||||||
|
<button class="btn-primary" type="submit" disabled={!canCompare || targetCount < 2}>
|
||||||
|
{#if operation === "Comparing revisions"}
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<GitCompare size={16} aria-hidden="true" />
|
<GitCompare size={16} aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
Compare
|
{isGerman ? "Vergleich öffnen" : "Open comparison"}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if compareFrom && compareTo && compareFrom === compareTo}
|
|
||||||
<div class="blank-state">Select two different commits to compare.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="blank-state">Pick two commits and run a comparison.</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Download,
|
Download,
|
||||||
@@ -14,10 +15,12 @@
|
|||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: "push" | "pull" | "fetch" | "clone";
|
action: "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
|
||||||
error: string;
|
error: string;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
|
initialUsername?: string;
|
||||||
|
initialMode?: Mode;
|
||||||
|
onSubmit: (username: string, password: string, save: boolean, mode: Mode) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,35 +28,40 @@
|
|||||||
action,
|
action,
|
||||||
error = "",
|
error = "",
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
initialUsername = "",
|
||||||
|
initialMode = "credentials",
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
type Mode = "credentials" | "token";
|
type Mode = "credentials" | "token";
|
||||||
|
|
||||||
let mode = $state<Mode>("credentials");
|
let mode = $state<Mode>(untrack(() => initialMode));
|
||||||
let username = $state("");
|
let username = $state(untrack(() => initialUsername === "oauth2" ? "" : initialUsername));
|
||||||
let password = $state("");
|
let password = $state("");
|
||||||
let showPassword = $state(false);
|
let showPassword = $state(false);
|
||||||
let saveSession = $state(true);
|
let saveSession = $state(true);
|
||||||
let expiresAt = $state("");
|
|
||||||
|
|
||||||
let canSubmit = $derived(
|
let canSubmit = $derived(
|
||||||
!isBusy &&
|
!isBusy &&
|
||||||
password.trim().length > 0 &&
|
password.trim().length > 0 &&
|
||||||
(mode === "token" || username.trim().length > 0),
|
username.trim().length > 0,
|
||||||
);
|
);
|
||||||
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
|
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : action === "delete" ? "Delete" : "Pull");
|
||||||
let actionTitle = $derived(
|
let actionTitle = $derived(
|
||||||
action === "push"
|
action === "push"
|
||||||
? "Authenticate push"
|
? "Authenticate push"
|
||||||
|
: action === "rename"
|
||||||
|
? "Authenticate remote rename"
|
||||||
|
: action === "delete"
|
||||||
|
? "Authenticate remote deletion"
|
||||||
: action === "fetch"
|
: action === "fetch"
|
||||||
? "Authenticate fetch"
|
? "Authenticate fetch"
|
||||||
: action === "clone"
|
: action === "clone"
|
||||||
? "Authenticate clone"
|
? "Authenticate clone"
|
||||||
: "Authenticate pull",
|
: "Authenticate pull",
|
||||||
);
|
);
|
||||||
let actionHint = $derived(action === "push"
|
let actionHint = $derived(action === "push" || action === "rename" || action === "delete"
|
||||||
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
|
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
|
||||||
: action === "clone"
|
: action === "clone"
|
||||||
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
|
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
|
||||||
@@ -62,12 +70,7 @@
|
|||||||
function handleSubmit(e: SubmitEvent) {
|
function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
onSubmit(
|
onSubmit(username.trim(), password, saveSession, mode);
|
||||||
mode === "token" ? "oauth2" : username,
|
|
||||||
password,
|
|
||||||
saveSession,
|
|
||||||
saveSession && expiresAt ? expiresAt : null,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -79,7 +82,7 @@
|
|||||||
<div class="cred-hero">
|
<div class="cred-hero">
|
||||||
<div class="cred-hero-top">
|
<div class="cred-hero-top">
|
||||||
<div class="cred-hero-icon">
|
<div class="cred-hero-icon">
|
||||||
{#if action === "push"}
|
{#if action === "push" || action === "rename" || action === "delete"}
|
||||||
<Upload size={27} aria-hidden="true" />
|
<Upload size={27} aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<Download size={27} aria-hidden="true" />
|
<Download size={27} aria-hidden="true" />
|
||||||
@@ -98,7 +101,7 @@
|
|||||||
|
|
||||||
<div class="cred-security-note">
|
<div class="cred-security-note">
|
||||||
<ShieldCheck size={14} aria-hidden="true" />
|
<ShieldCheck size={14} aria-hidden="true" />
|
||||||
<span>When saved, the token is stored encrypted in the operating system's keychain — never in plain text.</span>
|
<span>When saved, the credentials are stored encrypted in the operating system's keychain — never in plain text.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,12 +125,11 @@
|
|||||||
aria-pressed={mode === "token"}
|
aria-pressed={mode === "token"}
|
||||||
>
|
>
|
||||||
<Key size={13} aria-hidden="true" />
|
<Key size={13} aria-hidden="true" />
|
||||||
Token
|
Access token
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cred-fields">
|
<div class="cred-fields">
|
||||||
{#if mode === "credentials"}
|
|
||||||
<div class="cred-field">
|
<div class="cred-field">
|
||||||
<label class="cred-field-label" for="cred-username">Username</label>
|
<label class="cred-field-label" for="cred-username">Username</label>
|
||||||
<div class="cred-input">
|
<div class="cred-input">
|
||||||
@@ -136,13 +138,12 @@
|
|||||||
id="cred-username"
|
id="cred-username"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={username}
|
bind:value={username}
|
||||||
placeholder="e.g. my-github-username"
|
placeholder="Your account username"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="cred-field">
|
<div class="cred-field">
|
||||||
<label class="cred-field-label" for="cred-password">
|
<label class="cred-field-label" for="cred-password">
|
||||||
@@ -180,7 +181,7 @@
|
|||||||
{#if mode === "token"}
|
{#if mode === "token"}
|
||||||
<div class="cred-token-hint">
|
<div class="cred-token-hint">
|
||||||
<Key size={13} aria-hidden="true" />
|
<Key size={13} aria-hidden="true" />
|
||||||
<span>Username is automatically set to <code>oauth2</code>. This works with GitHub, GitLab, and Bitbucket.</span>
|
<span>Use your normal account username. The access token is sent as the password, as required by Gitea and most Git providers.</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -191,19 +192,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="cred-footer">
|
||||||
<label class="cred-save">
|
<label class="cred-save">
|
||||||
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
|
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
FileCog,
|
FileCog,
|
||||||
FileImage,
|
FileImage,
|
||||||
FileJson,
|
FileJson,
|
||||||
|
FileSearch,
|
||||||
|
GitCompare,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
FileText,
|
FileText,
|
||||||
FileType,
|
FileType,
|
||||||
@@ -32,11 +34,17 @@
|
|||||||
selectedExplorerKind: ExplorerNodeKind;
|
selectedExplorerKind: ExplorerNodeKind;
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
language?: "en" | "de";
|
||||||
|
editorName?: string;
|
||||||
|
diffName?: string;
|
||||||
onToggleFolder: (node: ExplorerNode) => void;
|
onToggleFolder: (node: ExplorerNode) => void;
|
||||||
onExpandAllFolders: () => void;
|
onExpandAllFolders: () => void;
|
||||||
onCollapseAllFolders: () => void;
|
onCollapseAllFolders: () => void;
|
||||||
onSelectNode: (node: ExplorerNode) => void;
|
onSelectNode: (node: ExplorerNode) => void;
|
||||||
onOpenFile: (node: ExplorerNode) => void;
|
onOpenFile: (node: ExplorerNode) => void;
|
||||||
|
onOpenInEditor: (node: ExplorerNode) => void;
|
||||||
|
onExternalDiff: (node: ExplorerNode) => void;
|
||||||
|
onFileHistory: (node: ExplorerNode) => void;
|
||||||
onBlame: (node: ExplorerNode) => void;
|
onBlame: (node: ExplorerNode) => void;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
onToggleCollapsed?: () => void;
|
onToggleCollapsed?: () => void;
|
||||||
@@ -49,11 +57,17 @@
|
|||||||
selectedExplorerKind = "file",
|
selectedExplorerKind = "file",
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
language = "en",
|
||||||
|
editorName = "Editor",
|
||||||
|
diffName = "diff tool",
|
||||||
onToggleFolder = () => {},
|
onToggleFolder = () => {},
|
||||||
onExpandAllFolders = () => {},
|
onExpandAllFolders = () => {},
|
||||||
onCollapseAllFolders = () => {},
|
onCollapseAllFolders = () => {},
|
||||||
onSelectNode = () => {},
|
onSelectNode = () => {},
|
||||||
onOpenFile = () => {},
|
onOpenFile = () => {},
|
||||||
|
onOpenInEditor = () => {},
|
||||||
|
onExternalDiff = () => {},
|
||||||
|
onFileHistory = () => {},
|
||||||
onBlame = () => {},
|
onBlame = () => {},
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
onToggleCollapsed = () => {},
|
onToggleCollapsed = () => {},
|
||||||
@@ -62,6 +76,7 @@
|
|||||||
let contextNode = $state<ExplorerNode | null>(null);
|
let contextNode = $state<ExplorerNode | null>(null);
|
||||||
let contextMenuX = $state(0);
|
let contextMenuX = $state(0);
|
||||||
let contextMenuY = $state(0);
|
let contextMenuY = $state(0);
|
||||||
|
const isGerman = $derived(language === "de");
|
||||||
|
|
||||||
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
|
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
|
||||||
if (!next) return current;
|
if (!next) return current;
|
||||||
@@ -175,7 +190,7 @@
|
|||||||
|
|
||||||
contextNode = node;
|
contextNode = node;
|
||||||
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
|
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() {
|
function closeFileContextMenu() {
|
||||||
@@ -189,6 +204,20 @@
|
|||||||
onOpenFile(node);
|
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() {
|
function openContextBlame() {
|
||||||
const node = contextNode;
|
const node = contextNode;
|
||||||
if (!node || node.kind !== "file") return;
|
if (!node || node.kind !== "file") return;
|
||||||
@@ -196,6 +225,13 @@
|
|||||||
onBlame(node);
|
onBlame(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openContextFileHistory() {
|
||||||
|
const node = contextNode;
|
||||||
|
if (!node || node.kind !== "file" || !node.tracked) return;
|
||||||
|
closeFileContextMenu();
|
||||||
|
onFileHistory(node);
|
||||||
|
}
|
||||||
|
|
||||||
function handleWindowKeydown(event: KeyboardEvent) {
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape") closeFileContextMenu();
|
if (event.key === "Escape") closeFileContextMenu();
|
||||||
}
|
}
|
||||||
@@ -203,6 +239,11 @@
|
|||||||
let explorerTree = $derived(buildExplorerTree(repoFiles));
|
let explorerTree = $derived(buildExplorerTree(repoFiles));
|
||||||
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
|
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
|
||||||
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
|
<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>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="explorer-head-actions">
|
<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
|
<button
|
||||||
class="explorer-bulk-button"
|
class="explorer-bulk-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -261,12 +331,14 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="explorer-list overflow-auto p-2">
|
<div class="explorer-list overflow-auto p-2">
|
||||||
{#each visibleNodes as node (`${node.kind}:${node.path}`)}
|
{#each visibleNodes as node (`${node.kind}:${node.path}`)}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="explorer-row"
|
class="explorer-row"
|
||||||
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
|
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
|
||||||
class:folder={node.kind === "folder"}
|
class:folder={node.kind === "folder"}
|
||||||
style={`--depth: ${node.depth}`}
|
style={`--depth: ${node.depth}`}
|
||||||
title={node.path}
|
title={node.path}
|
||||||
|
oncontextmenu={(event) => openFileContextMenu(event, node)}
|
||||||
>
|
>
|
||||||
{#if node.kind === "folder"}
|
{#if node.kind === "folder"}
|
||||||
<button
|
<button
|
||||||
@@ -328,9 +400,8 @@
|
|||||||
class="explorer-select"
|
class="explorer-select"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => onSelectNode(node)}
|
onclick={() => onSelectNode(node)}
|
||||||
oncontextmenu={(event) => openFileContextMenu(event, node)}
|
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
title={`Show history for ${node.path}`}
|
title={`Select ${node.path}`}
|
||||||
>
|
>
|
||||||
<span>{node.name}</span>
|
<span>{node.name}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -355,6 +426,24 @@
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-label={`Actions for ${contextNode.path}`}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@@ -372,7 +461,7 @@
|
|||||||
disabled={!contextNode.tracked || contextNode.status === "deleted"}
|
disabled={!contextNode.tracked || contextNode.status === "deleted"}
|
||||||
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
|
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
|
Blame
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArchiveRestore,
|
||||||
|
Box,
|
||||||
|
Check,
|
||||||
|
CircleDashed,
|
||||||
|
Download,
|
||||||
|
HardDriveDownload,
|
||||||
|
Link2,
|
||||||
|
LoaderCircle,
|
||||||
|
PackageCheck,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import type { AppLanguage, GitLfsPattern, GitLfsStatus } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: GitLfsStatus | null;
|
||||||
|
language: AppLanguage;
|
||||||
|
isLoading: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
|
error?: string;
|
||||||
|
onRefresh: () => void | Promise<void>;
|
||||||
|
onInstall: () => boolean | void | Promise<boolean | void>;
|
||||||
|
onTrack: (pattern: string, lockable: boolean) => boolean | Promise<boolean>;
|
||||||
|
onUntrack: (pattern: string) => void | Promise<void>;
|
||||||
|
onPull: () => void | Promise<void>;
|
||||||
|
onPrune: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
status = null,
|
||||||
|
language = "en",
|
||||||
|
isLoading = false,
|
||||||
|
isBusy = false,
|
||||||
|
error = "",
|
||||||
|
onRefresh = () => {},
|
||||||
|
onInstall = () => {},
|
||||||
|
onTrack = () => false,
|
||||||
|
onUntrack = () => {},
|
||||||
|
onPull = () => {},
|
||||||
|
onPrune = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let pattern = $state("");
|
||||||
|
let lockable = $state(false);
|
||||||
|
let isGerman = $derived(language === "de");
|
||||||
|
let setupReady = $derived(Boolean(status?.filters_configured && status?.hook_installed));
|
||||||
|
let downloadedCount = $derived(status?.files.filter((file) => file.downloaded).length ?? 0);
|
||||||
|
let totalSize = $derived(status?.files.reduce((sum, file) => sum + file.size, 0) ?? 0);
|
||||||
|
|
||||||
|
function formatBytes(value: number): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||||
|
const amount = value / 1024 ** index;
|
||||||
|
return `${amount >= 10 || index === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionLabel(value: string | null | undefined): string {
|
||||||
|
return value?.match(/git-lfs\/([^\s]+)/)?.[1] ?? value ?? "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRemove(item: GitLfsPattern): boolean {
|
||||||
|
return item.source === ".gitattributes";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPattern(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const value = pattern.trim();
|
||||||
|
if (!value || !setupReady) return;
|
||||||
|
if (await onTrack(value, lockable)) {
|
||||||
|
pattern = "";
|
||||||
|
lockable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmPrune() {
|
||||||
|
const confirmed = window.confirm(isGerman
|
||||||
|
? "Nicht mehr benötigte lokale LFS-Objekte sicher bereinigen? Nicht gepushte und aktuell verwendete Objekte bleiben erhalten."
|
||||||
|
: "Safely prune unused local LFS objects? Unpushed and currently used objects are retained.");
|
||||||
|
if (confirmed) await onPrune();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog lfs-dialog" role="dialog" aria-modal="true" aria-labelledby="lfs-dialog-title">
|
||||||
|
<header class="dialog-header lfs-dialog-header">
|
||||||
|
<div class="lfs-heading">
|
||||||
|
<span class="lfs-mark" aria-hidden="true"><Box size={19} /></span>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Large file storage</span>
|
||||||
|
<p class="dialog-title" id="lfs-dialog-title">Git LFS</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-header-actions">
|
||||||
|
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>
|
||||||
|
<RefreshCw class={isLoading ? "spin" : undefined} size={15} aria-hidden="true" />
|
||||||
|
{isGerman ? "Prüfen" : "Check"}
|
||||||
|
</button>
|
||||||
|
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label={isGerman ? "Schließen" : "Close"}>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="lfs-main">
|
||||||
|
{#if error}
|
||||||
|
<div class="lfs-error" role="alert"><AlertTriangle size={15} aria-hidden="true" />{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading && !status}
|
||||||
|
<div class="lfs-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>{isGerman ? "Git LFS wird geprüft…" : "Inspecting Git LFS…"}</span></div>
|
||||||
|
{:else if status}
|
||||||
|
<div class="lfs-content">
|
||||||
|
<section class="lfs-diagnostics" aria-label={isGerman ? "LFS-Diagnose" : "LFS diagnostics"}>
|
||||||
|
<article class:ready={status.available} class:error={!status.available}>
|
||||||
|
<span class="diagnostic-icon">{#if status.available}<PackageCheck size={18} />{:else}<AlertTriangle size={18} />{/if}</span>
|
||||||
|
<div><small>01 · {isGerman ? "Erweiterung" : "Extension"}</small><strong>{status.available ? `Git LFS ${versionLabel(status.version)}` : (isGerman ? "Nicht gefunden" : "Not found")}</strong><span>{status.bundled ? (isGerman ? "Mit Gitty gebündelt" : "Bundled with Gitty") : (isGerman ? "Systeminstallation" : "System installation")}</span></div>
|
||||||
|
</article>
|
||||||
|
<span class:ready={status.available} class="diagnostic-link" aria-hidden="true"><Link2 size={14} /></span>
|
||||||
|
<article class:ready={setupReady} class:warning={status.available && !setupReady}>
|
||||||
|
<span class="diagnostic-icon">{#if setupReady}<ShieldCheck size={18} />{:else}<CircleDashed size={18} />{/if}</span>
|
||||||
|
<div><small>02 · Repository</small><strong>{setupReady ? (isGerman ? "Aktiv" : "Active") : (isGerman ? "Einrichtung nötig" : "Setup required")}</strong><span>{status.filters_configured ? "Filter ✓" : "Filter —"} · {status.hook_installed ? "Pre-push ✓" : "Pre-push —"}</span></div>
|
||||||
|
</article>
|
||||||
|
<span class:ready={setupReady} class="diagnostic-link" aria-hidden="true"><Link2 size={14} /></span>
|
||||||
|
<article class:ready={status.files.length > 0}>
|
||||||
|
<span class="diagnostic-icon"><ArchiveRestore size={18} /></span>
|
||||||
|
<div><small>03 · {isGerman ? "Objekte" : "Objects"}</small><strong>{status.files.length} {isGerman ? "Dateien" : "files"}</strong><span>{downloadedCount} {isGerman ? "lokal" : "local"} · {formatBytes(totalSize)}</span></div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if !status.available}
|
||||||
|
<section class="lfs-missing">
|
||||||
|
<span class="lfs-missing-icon"><AlertTriangle size={22} aria-hidden="true" /></span>
|
||||||
|
<div>
|
||||||
|
<strong>{isGerman ? "Git LFS ist nicht ausführbar" : "Git LFS cannot be started"}</strong>
|
||||||
|
<p>{isGerman ? "Gitty liefert Git LFS normalerweise mit. Installiere Gitty erneut oder installiere git-lfs systemweit und starte die App neu." : "Gitty normally includes Git LFS. Reinstall Gitty or install git-lfs system-wide, then restart the app."}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
{#if !setupReady}
|
||||||
|
<section class="lfs-setup-card">
|
||||||
|
<div><strong>{isGerman ? "Für dieses Repository aktivieren" : "Activate for this repository"}</strong><p>{isGerman ? "Richtet Clean-/Smudge-Filter lokal ein und installiert den Pre-push-Hook. Globale Git-Einstellungen bleiben unverändert." : "Configures local clean/smudge filters and installs the pre-push hook. Global Git settings remain unchanged."}</p></div>
|
||||||
|
<button class="btn-primary" type="button" onclick={onInstall} disabled={isBusy}><ShieldCheck size={15} aria-hidden="true" />{isGerman ? "LFS aktivieren" : "Activate LFS"}</button>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="lfs-grid">
|
||||||
|
<section class="lfs-panel patterns-panel">
|
||||||
|
<header><div><span class="eyebrow">Tracking rules</span><h3>{isGerman ? "Muster" : "Patterns"}</h3></div><span>{status.patterns.length}</span></header>
|
||||||
|
<form class="lfs-track-form" onsubmit={submitPattern}>
|
||||||
|
<label><span>{isGerman ? "Neues Muster" : "New pattern"}</span><input type="text" bind:value={pattern} disabled={isBusy || !setupReady} autocomplete="off" spellcheck="false" placeholder="*.psd, Assets/**, video.mp4" /></label>
|
||||||
|
<label class="lfs-lockable"><input type="checkbox" bind:checked={lockable} disabled={isBusy || !setupReady} /><span><strong>Lockable</strong><small>{isGerman ? "Schreibgeschützt, solange nicht gesperrt" : "Read-only until locked"}</small></span></label>
|
||||||
|
<button class="btn-primary" type="submit" disabled={isBusy || !setupReady || !pattern.trim()}><Plus size={15} aria-hidden="true" />{isGerman ? "Hinzufügen" : "Add"}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="lfs-pattern-list">
|
||||||
|
{#if status.patterns.length === 0}
|
||||||
|
<div class="lfs-empty"><Box size={20} aria-hidden="true" /><span>{isGerman ? "Noch keine Dateien werden über LFS verwaltet." : "No files are tracked through LFS yet."}</span></div>
|
||||||
|
{:else}
|
||||||
|
{#each status.patterns as item (`${item.source}:${item.pattern}`)}
|
||||||
|
<article>
|
||||||
|
<div><code>{item.pattern}</code><span>{item.source}{item.lockable ? " · lockable" : ""}</span></div>
|
||||||
|
<button class="btn-sm danger" type="button" onclick={() => onUntrack(item.pattern)} disabled={isBusy || !canRemove(item)} title={canRemove(item) ? (isGerman ? "Muster entfernen" : "Remove pattern") : (isGerman ? "Nur Muster aus der Wurzel-.gitattributes können hier entfernt werden" : "Only patterns from the root .gitattributes can be removed here") }><Trash2 size={14} aria-hidden="true" /></button>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="lfs-panel objects-panel">
|
||||||
|
<header><div><span class="eyebrow">Current checkout</span><h3>{isGerman ? "LFS-Dateien" : "LFS files"}</h3></div><span>{status.files.length}</span></header>
|
||||||
|
<div class="lfs-object-list">
|
||||||
|
{#if status.files.length === 0}
|
||||||
|
<div class="lfs-empty"><HardDriveDownload size={20} aria-hidden="true" /><span>{isGerman ? "Im aktuellen Stand sind keine LFS-Objekte vorhanden." : "The current checkout has no LFS objects."}</span></div>
|
||||||
|
{:else}
|
||||||
|
{#each status.files as file (`${file.oid}:${file.name}`)}
|
||||||
|
<article>
|
||||||
|
<span class:downloaded={file.downloaded} class="object-state" title={file.downloaded ? (isGerman ? "Objekt lokal vorhanden" : "Object available locally") : (isGerman ? "Nur LFS-Zeiger vorhanden" : "LFS pointer only")}>{#if file.downloaded}<Check size={12} />{:else}<Download size={12} />{/if}</span>
|
||||||
|
<div title={file.name}><strong>{file.name}</strong><span>{formatBytes(file.size)} · <code>{file.oid.slice(0, 10)}</code></span></div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="lfs-footer">
|
||||||
|
<div><ShieldCheck size={14} aria-hidden="true" /><span>{isGerman ? ".gitattributes bleibt als normale Änderung sichtbar und muss committed werden." : ".gitattributes remains a normal change and must be committed."}</span></div>
|
||||||
|
<div>
|
||||||
|
<button class="btn-secondary" type="button" onclick={confirmPrune} disabled={isBusy || !status?.available}><Trash2 size={14} aria-hidden="true" />{isGerman ? "Cache bereinigen" : "Prune cache"}</button>
|
||||||
|
<button class="btn-primary" type="button" onclick={onPull} disabled={isBusy || !setupReady}><HardDriveDownload size={15} aria-hidden="true" />{isGerman ? "Objekte laden" : "Pull objects"}</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.lfs-dialog { --lfs-success: #4eca76; --lfs-warning: #f0b648; --lfs-danger: #e86060; width: min(880px, calc(100vw - 32px)); height: auto; max-height: min(660px, calc(100vh - 32px)); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; border-radius: 12px; }
|
||||||
|
.lfs-dialog-header, .lfs-heading, .dialog-header-actions, .lfs-footer, .lfs-footer > div { display: flex; align-items: center; }
|
||||||
|
.lfs-dialog-header { min-height: 58px; justify-content: space-between; padding: 10px 13px; }
|
||||||
|
.lfs-heading { gap: 9px; }
|
||||||
|
.lfs-mark { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid color-mix(in srgb, var(--color-accent) 34%, var(--color-border)); border-radius: 9px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
|
||||||
|
.dialog-header-actions { gap: 7px; }
|
||||||
|
.lfs-main { min-height: 0; overflow: hidden; }
|
||||||
|
.lfs-error { display: flex; align-items: center; gap: 8px; margin: 10px 13px 0; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--lfs-danger) 38%, var(--color-border)); border-radius: 7px; color: var(--lfs-danger); background: color-mix(in srgb, var(--lfs-danger) 8%, transparent); font-size: 11px; }
|
||||||
|
.lfs-loading { display: grid; min-height: 280px; place-items: center; align-content: center; gap: 10px; color: var(--color-ink-dim); font-size: 12px; }
|
||||||
|
.lfs-content { min-height: 0; max-height: 548px; padding: 12px 13px 13px; overflow: auto; }
|
||||||
|
.lfs-diagnostics { display: grid; grid-template-columns: minmax(0, 1fr) 22px minmax(0, 1fr) 22px minmax(0, 1fr); align-items: center; margin-bottom: 11px; }
|
||||||
|
.lfs-diagnostics article { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 9px; min-height: 64px; padding: 8px 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
||||||
|
.lfs-diagnostics article.ready { border-color: color-mix(in srgb, var(--lfs-success) 34%, var(--color-border)); }
|
||||||
|
.lfs-diagnostics article.warning { border-color: color-mix(in srgb, var(--lfs-warning) 38%, var(--color-border)); }
|
||||||
|
.lfs-diagnostics article.error { border-color: color-mix(in srgb, var(--lfs-danger) 38%, var(--color-border)); }
|
||||||
|
.diagnostic-icon { display: grid; width: 29px; height: 29px; place-items: center; border-radius: 8px; color: var(--color-ink-dim); background: var(--color-surface-hover); }
|
||||||
|
article.ready .diagnostic-icon { color: var(--lfs-success); background: color-mix(in srgb, var(--lfs-success) 10%, transparent); }
|
||||||
|
article.warning .diagnostic-icon { color: var(--lfs-warning); }
|
||||||
|
article.error .diagnostic-icon { color: var(--lfs-danger); }
|
||||||
|
.lfs-diagnostics article div { display: grid; min-width: 0; gap: 2px; }
|
||||||
|
.lfs-diagnostics small { color: var(--color-ink-faint); font-size: 9px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.lfs-diagnostics strong { overflow: hidden; color: var(--color-ink); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.lfs-diagnostics article div > span { color: var(--color-ink-dim); font-size: 10px; }
|
||||||
|
.diagnostic-link { display: grid; place-items: center; color: var(--color-border); }
|
||||||
|
.diagnostic-link.ready { color: color-mix(in srgb, var(--lfs-success) 60%, var(--color-border)); }
|
||||||
|
.lfs-missing, .lfs-setup-card { display: flex; align-items: center; gap: 11px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
||||||
|
.lfs-missing-icon { display: grid; flex: 0 0 auto; place-items: center; color: var(--lfs-danger); }
|
||||||
|
.lfs-missing strong, .lfs-setup-card strong { color: var(--color-ink); font-size: 12px; }
|
||||||
|
.lfs-missing p, .lfs-setup-card p { margin: 4px 0 0; color: var(--color-ink-dim); font-size: 10.5px; line-height: 1.5; }
|
||||||
|
.lfs-setup-card { justify-content: space-between; margin-bottom: 11px; border-color: color-mix(in srgb, var(--lfs-warning) 32%, var(--color-border)); }
|
||||||
|
.lfs-setup-card > div { max-width: 650px; }
|
||||||
|
.lfs-setup-card button { flex: 0 0 auto; }
|
||||||
|
.lfs-grid { display: grid; grid-template-columns: minmax(0, .92fr) minmax(0, 1.08fr); gap: 10px; height: clamp(230px, 30vh, 285px); min-height: 0; }
|
||||||
|
.lfs-panel { display: grid; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr); border: 1px solid var(--color-border-subtle); border-radius: 10px; overflow: hidden; background: var(--color-surface-raised); }
|
||||||
|
.objects-panel { grid-template-rows: auto minmax(0, 1fr); }
|
||||||
|
.lfs-panel > header { display: flex; align-items: center; justify-content: space-between; min-height: 48px; padding: 8px 10px; border-bottom: 1px solid var(--color-border-subtle); background: color-mix(in srgb, var(--app-dialog-chrome) 74%, transparent); }
|
||||||
|
.lfs-panel h3 { margin: 2px 0 0; color: var(--color-ink); font-size: 13px; }
|
||||||
|
.lfs-panel > header > span { display: grid; min-width: 25px; height: 22px; place-items: center; border-radius: 11px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); font-size: 10px; font-weight: 800; }
|
||||||
|
.lfs-track-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 7px 9px; padding: 9px 10px; border-bottom: 1px solid var(--color-border-subtle); }
|
||||||
|
.lfs-track-form > label:first-child { display: grid; gap: 5px; grid-column: 1 / -1; }
|
||||||
|
.lfs-track-form label > span:first-child { color: var(--color-ink-dim); font-size: 9.5px; font-weight: 700; }
|
||||||
|
.lfs-track-form input[type="text"] { width: 100%; }
|
||||||
|
.lfs-lockable { display: inline-flex; width: fit-content; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
|
||||||
|
.lfs-lockable input[type="checkbox"] { width: 14px; min-width: 14px; height: 14px; margin: 0; padding: 0; border-radius: 3px; accent-color: var(--color-accent); cursor: pointer; box-shadow: none; }
|
||||||
|
.lfs-lockable > span { display: flex; align-items: baseline; gap: 4px; white-space: nowrap; }
|
||||||
|
.lfs-lockable strong { color: var(--color-ink); font-size: 10px; }
|
||||||
|
.lfs-lockable small { color: var(--color-ink-faint); font-size: 8.5px; }
|
||||||
|
.lfs-pattern-list, .lfs-object-list { min-height: 0; overflow: auto; }
|
||||||
|
.lfs-pattern-list article, .lfs-object-list article { display: flex; align-items: center; gap: 9px; min-height: 49px; padding: 8px 10px; border-bottom: 1px solid var(--color-border-subtle); }
|
||||||
|
.lfs-pattern-list article:last-child, .lfs-object-list article:last-child { border-bottom: 0; }
|
||||||
|
.lfs-pattern-list article > div, .lfs-object-list article > div { display: grid; min-width: 0; flex: 1; gap: 3px; }
|
||||||
|
.lfs-pattern-list code, .lfs-object-list strong { overflow: hidden; color: var(--color-ink); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.lfs-pattern-list article span, .lfs-object-list article span { color: var(--color-ink-faint); font-size: 9px; }
|
||||||
|
.object-state { display: grid; width: 24px; height: 24px; flex: 0 0 auto; place-items: center; border-radius: 50%; color: var(--lfs-warning); background: color-mix(in srgb, var(--lfs-warning) 10%, transparent); }
|
||||||
|
.object-state.downloaded { color: var(--lfs-success); background: color-mix(in srgb, var(--lfs-success) 10%, transparent); }
|
||||||
|
.lfs-empty { display: grid; width: 100%; height: 100%; min-height: 80px; place-items: center; align-content: center; gap: 7px; padding: 14px; color: var(--color-ink-faint); text-align: center; }
|
||||||
|
.lfs-empty span { max-width: 260px; font-size: 10px; line-height: 1.45; }
|
||||||
|
.lfs-footer { min-height: 54px; justify-content: space-between; gap: 14px; padding: 9px 13px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||||
|
.lfs-footer > div { gap: 7px; }
|
||||||
|
.lfs-footer > div:first-child { min-width: 0; color: var(--color-ink-dim); font-size: 9.5px; }
|
||||||
|
.lfs-footer > div:last-child { flex: 0 0 auto; gap: 8px; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.dialog-backdrop { padding: 10px; }
|
||||||
|
.lfs-dialog { width: min(620px, 100%); height: min(760px, 100%); max-height: none; }
|
||||||
|
.lfs-content { height: 100%; max-height: none; }
|
||||||
|
.lfs-diagnostics { grid-template-columns: 1fr; gap: 7px; }
|
||||||
|
.diagnostic-link { display: none; }
|
||||||
|
.lfs-grid { grid-template-columns: 1fr; height: auto; }
|
||||||
|
.lfs-panel { min-height: 210px; }
|
||||||
|
.lfs-footer { align-items: flex-start; flex-direction: column; }
|
||||||
|
.lfs-footer > div:last-child { width: 100%; justify-content: flex-end; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 460px) {
|
||||||
|
.lfs-dialog-header { min-height: 54px; }
|
||||||
|
.lfs-mark { width: 31px; height: 31px; }
|
||||||
|
.lfs-track-form { grid-template-columns: 1fr; }
|
||||||
|
.lfs-track-form > label:first-child { grid-column: 1; }
|
||||||
|
.lfs-track-form button { justify-self: end; }
|
||||||
|
.lfs-lockable > span { white-space: normal; }
|
||||||
|
.lfs-footer > div:first-child { display: none; }
|
||||||
|
.lfs-footer > div:last-child { justify-content: stretch; }
|
||||||
|
.lfs-footer button { flex: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
import {
|
import {
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
FileCode,
|
FileCode,
|
||||||
@@ -15,6 +16,11 @@
|
|||||||
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
|
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
|
||||||
import LanguageIcon from "./LanguageIcon.svelte";
|
import LanguageIcon from "./LanguageIcon.svelte";
|
||||||
|
|
||||||
|
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
type SearchTab = "code" | "files";
|
type SearchTab = "code" | "files";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -82,7 +88,7 @@
|
|||||||
function formatCommitDate(value: string): string {
|
function formatCommitDate(value: string): string {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return 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 {
|
function displayPath(hit: GitSearchHit): string {
|
||||||
@@ -188,12 +194,7 @@
|
|||||||
|
|
||||||
<label class="search-limit">
|
<label class="search-limit">
|
||||||
<span>Results</span>
|
<span>Results</span>
|
||||||
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}>
|
<SelectMenu value={String(limit)} options={[100, 250, 500, 1000].map((value) => ({ value: String(value), label: String(value) }))} disabled={!hasRepository || isBusy || isSearching} onChange={(value) => { limit = Number(value); }} />
|
||||||
<option value={100}>100</option>
|
|
||||||
<option value={250}>250</option>
|
|
||||||
<option value={500}>500</option>
|
|
||||||
<option value={1000}>1000</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
|
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Box,
|
||||||
Check,
|
Check,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
@@ -468,6 +469,126 @@
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
deCategories.splice(deCategories.findIndex((category) => category.id === "remote") + 1, 0, {
|
||||||
|
id: "lfs",
|
||||||
|
label: "Git LFS",
|
||||||
|
description: "Große Binärdateien tracken, Objekte synchronisieren und bestehende Repositories sicher umstellen.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "lfs-overview",
|
||||||
|
title: "Was Git LFS macht",
|
||||||
|
summary: "Git LFS ersetzt große Dateien im Git-Verlauf durch kleine Zeigerdateien. Die eigentlichen Inhalte liegen im LFS-Speicher des Remotes und werden beim Checkout oder Pull passend geladen.",
|
||||||
|
steps: [
|
||||||
|
"Nutze LFS vor allem für große Binärdateien wie PSD-, Video-, Audio-, Modell- oder Archivdateien, die Git nicht sinnvoll als Text-Diff verwalten kann.",
|
||||||
|
"Gitty liefert die Git-LFS-Erweiterung in Desktop-Installern mit und zeigt Version, Filter sowie Pre-push-Hook im LFS-Dialog an.",
|
||||||
|
"Die LFS-Regeln stehen in .gitattributes und gehören deshalb wie normaler Quellcode in das Repository.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lfs-setup",
|
||||||
|
title: "Git LFS in Gitty einrichten",
|
||||||
|
summary: "Die Einrichtung gilt für das aktuell geöffnete Repository und verändert keine globalen Git-Einstellungen.",
|
||||||
|
steps: [
|
||||||
|
"Öffne im Repository das Menü Synchronisieren und wähle Git LFS.",
|
||||||
|
"Klicke auf LFS aktivieren, damit Gitty die lokalen Filter und den Pre-push-Hook einrichtet.",
|
||||||
|
"Füge ein Muster wie *.psd, Assets/** oder video.mp4 hinzu. Lockable markiert Dateien, die über einen kompatiblen LFS-Server gesperrt werden können.",
|
||||||
|
"Stage und committe anschließend .gitattributes zusammen mit den gewünschten Dateien.",
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{ command: "git lfs install --local", description: "LFS nur im aktuellen Repository aktivieren" },
|
||||||
|
{ command: "git lfs track \"*.psd\"", description: "Ein Dateimuster über Git LFS verwalten" },
|
||||||
|
{ command: "git add .gitattributes", description: "Die erzeugten Tracking-Regeln stagen" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lfs-sync",
|
||||||
|
title: "LFS-Objekte synchronisieren",
|
||||||
|
summary: "Ein normaler Pull in Gitty prüft nach erfolgreicher Git-Synchronisierung automatisch auf LFS und lädt benötigte Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter Pull ist nicht nötig.",
|
||||||
|
steps: [
|
||||||
|
"Normales Push nutzt den LFS-Pre-push-Hook und lädt neue LFS-Objekte vor den Git-Referenzen hoch.",
|
||||||
|
"Objekte laden im LFS-Dialog ist ein manueller Reparatur- oder Aktualisierungsschritt, falls lokale Inhalte fehlen.",
|
||||||
|
"Cache bereinigen entfernt sicher nicht mehr benötigte lokale Objekte; aktuell verwendete und noch nicht gepushte Inhalte bleiben erhalten.",
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{ command: "git lfs status", description: "LFS-Zustand und ausstehende Änderungen prüfen" },
|
||||||
|
{ command: "git lfs pull", description: "Benötigte LFS-Objekte manuell laden" },
|
||||||
|
{ command: "git lfs prune", description: "Nicht mehr benötigte lokale LFS-Objekte bereinigen" },
|
||||||
|
{ command: "git lfs lock <datei>", description: "Eine als lockable markierte Datei auf einem kompatiblen Remote sperren" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lfs-migrate",
|
||||||
|
title: "Bestehende Dateien umstellen",
|
||||||
|
summary: "Ein neu hinzugefügtes Tracking-Muster schreibt vorhandene Commits nicht rückwirkend um. Aktuelle Dateien lassen sich neu normalisieren; eine vollständige Migration verändert dagegen die Historie.",
|
||||||
|
commands: [
|
||||||
|
{ command: "git add --renormalize .", description: "Aktuelle Dateien erneut durch die neuen LFS-Regeln führen" },
|
||||||
|
{ command: "git lfs migrate import --include=\"*.psd\" --everything", description: "Passende Dateien in der gesamten Historie nach LFS migrieren" },
|
||||||
|
],
|
||||||
|
note: "Vorsicht: git lfs migrate import schreibt Commit-Hashes um. Stimme die Migration mit allen Beteiligten ab, erstelle vorher ein Backup und rechne bei bereits veröffentlichten Branches mit einem koordinierten Force-Push.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
enCategories.splice(enCategories.findIndex((category) => category.id === "remote") + 1, 0, {
|
||||||
|
id: "lfs",
|
||||||
|
label: "Git LFS",
|
||||||
|
description: "Track large binary files, synchronize objects, and migrate existing repositories safely.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "lfs-overview",
|
||||||
|
title: "What Git LFS does",
|
||||||
|
summary: "Git LFS replaces large files in Git history with small pointer files. The actual content is stored in the remote's LFS storage and downloaded for the relevant checkout or pull.",
|
||||||
|
steps: [
|
||||||
|
"Use LFS mainly for large binary files such as PSDs, videos, audio, models, or archives that Git cannot usefully manage as text diffs.",
|
||||||
|
"Gitty bundles the Git LFS extension in desktop installers and displays its version, filters, and pre-push hook in the LFS dialog.",
|
||||||
|
"LFS rules live in .gitattributes, so commit them to the repository like regular source code.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lfs-setup",
|
||||||
|
title: "Set up Git LFS in Gitty",
|
||||||
|
summary: "Setup applies to the currently open repository and does not change global Git settings.",
|
||||||
|
steps: [
|
||||||
|
"Open the Sync menu in the repository and select Git LFS.",
|
||||||
|
"Select Activate LFS so Gitty configures the local filters and pre-push hook.",
|
||||||
|
"Add a pattern such as *.psd, Assets/**, or video.mp4. Lockable marks files that can be locked through a compatible LFS server.",
|
||||||
|
"Stage and commit .gitattributes together with the files you want to track.",
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{ command: "git lfs install --local", description: "Activate LFS only in the current repository" },
|
||||||
|
{ command: "git lfs track \"*.psd\"", description: "Manage a file pattern through Git LFS" },
|
||||||
|
{ command: "git add .gitattributes", description: "Stage the generated tracking rules" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lfs-sync",
|
||||||
|
title: "Synchronize LFS objects",
|
||||||
|
summary: "After a successful regular pull, Gitty automatically checks for LFS and downloads required objects with the same remote and credentials. A second pull is not necessary.",
|
||||||
|
steps: [
|
||||||
|
"A normal push uses the LFS pre-push hook to upload new LFS objects before Git references are published.",
|
||||||
|
"Pull objects in the LFS dialog is a manual repair or refresh action when local content is missing.",
|
||||||
|
"Prune cache safely removes unused local objects while retaining current and unpushed content.",
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{ command: "git lfs status", description: "Inspect LFS state and pending changes" },
|
||||||
|
{ command: "git lfs pull", description: "Download required LFS objects manually" },
|
||||||
|
{ command: "git lfs prune", description: "Remove unused local LFS objects" },
|
||||||
|
{ command: "git lfs lock <file>", description: "Lock a lockable file on a compatible remote" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lfs-migrate",
|
||||||
|
title: "Migrate existing files",
|
||||||
|
summary: "Adding a tracking pattern does not rewrite existing commits. Current files can be renormalized, while a complete migration changes repository history.",
|
||||||
|
commands: [
|
||||||
|
{ command: "git add --renormalize .", description: "Run current files through the new LFS rules again" },
|
||||||
|
{ command: "git lfs migrate import --include=\"*.psd\" --everything", description: "Move matching files to LFS throughout repository history" },
|
||||||
|
],
|
||||||
|
note: "Caution: git lfs migrate import rewrites commit hashes. Coordinate the migration with every contributor, create a backup first, and expect a coordinated force push for published branches.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// Extended handbook chapters. Keeping these additions next to the shared data makes
|
// Extended handbook chapters. Keeping these additions next to the shared data makes
|
||||||
// it straightforward to compare the German and English coverage section by section.
|
// it straightforward to compare the German and English coverage section by section.
|
||||||
deCategories.find((category) => category.id === "start")?.sections.push(
|
deCategories.find((category) => category.id === "start")?.sections.push(
|
||||||
@@ -547,6 +668,25 @@
|
|||||||
"Restore from commit übernimmt eine ältere Dateiversion ins Arbeitsverzeichnis. Prüfe und committe das Ergebnis anschließend normal.",
|
"Restore from commit übernimmt eine ältere Dateiversion ins Arbeitsverzeichnis. Prüfe und committe das Ergebnis anschließend normal.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "app-git-notes",
|
||||||
|
title: "Commits mit Git Notes ergänzen",
|
||||||
|
summary: "Git Notes speichern zusätzliche Informationen zu einem Commit, ohne dessen Hash oder die Historie zu verändern. Sie eignen sich etwa für Review-Hinweise, Ticket-Kontext, Build-IDs oder Freigabestatus.",
|
||||||
|
steps: [
|
||||||
|
"Öffne im Commit-Verlauf über das Notiz-Symbol oder das Kontextmenü die Commit-Notiz.",
|
||||||
|
"Schreibe oder bearbeite die Notiz und speichere sie. Commits mit einer Notiz sind im Verlauf markiert; beim Überfahren der Markierung erscheint eine Vorschau.",
|
||||||
|
"Löschen entfernt nur die Notiz. Der zugehörige Commit und seine Dateien bleiben unverändert.",
|
||||||
|
"Gitty lädt Git Notes im Hintergrund vom bevorzugten Remote. Nutze im Dialog Vom Remote laden, um sie bei Bedarf gezielt zu aktualisieren.",
|
||||||
|
"Nutze Zum Remote senden, um lokale Notizen zu veröffentlichen. Ein normaler Branch-Push überträgt Git Notes nicht automatisch.",
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{ command: "git notes show <commit>", description: "Notiz eines Commits in der Kommandozeile anzeigen" },
|
||||||
|
{ command: "git notes add <commit>", description: "Notiz zu einem Commit hinzufügen oder im Editor verfassen" },
|
||||||
|
{ command: "git fetch <remote> refs/notes/commits:refs/notes/commits", description: "Commit-Notizen gezielt vom Remote laden" },
|
||||||
|
{ command: "git push <remote> refs/notes/commits", description: "Lokale Commit-Notizen zum Remote senden" },
|
||||||
|
],
|
||||||
|
note: "Git Notes liegen standardmäßig unter refs/notes/commits und werden getrennt von Branches synchronisiert. Prüfe vor einem Push, ob der Ziel-Remote diese Referenz akzeptiert.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "app-search",
|
id: "app-search",
|
||||||
title: "Code-Ursprung mit Global Search finden",
|
title: "Code-Ursprung mit Global Search finden",
|
||||||
@@ -647,20 +787,20 @@
|
|||||||
{
|
{
|
||||||
id: "app-package-updates",
|
id: "app-package-updates",
|
||||||
title: "Gitty unter Arch Linux installieren und aktualisieren",
|
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: [
|
commands: [
|
||||||
{ command: "sudo pacman --config /pfad/gitty-pacman.conf -Syu gitty", description: "Repository-Konfiguration für genau diesen Aufruf verwenden und Gitty aktualisieren" },
|
{ command: "yay -S gitty-desktop", description: "Gitty mit yay aus dem AUR bauen, installieren oder aktualisieren" },
|
||||||
{ command: "sudo pacman -U /pfad/gitty-<version>-x86_64.pkg.tar.zst", description: "Ein einzelnes lokales oder heruntergeladenes Paket installieren" },
|
{ command: "paru -S gitty-desktop", description: "Gitty alternativ mit paru bauen, installieren oder aktualisieren" },
|
||||||
{ command: "pacman -Qi gitty", description: "Installierte Version und Paketinformationen anzeigen" },
|
{ 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 -Si gitty", description: "Im konfigurierten Repository verfügbare Version anzeigen" },
|
{ command: "pacman -Qi gitty-desktop", description: "Installierte Version und Paketinformationen anzeigen" },
|
||||||
],
|
],
|
||||||
steps: [
|
steps: [
|
||||||
"Für normale Systemupdates trägst du das Gitty-Repository einmal dauerhaft in /etc/pacman.conf ein.",
|
"Das PKGBUILD lädt das öffentliche Quellarchiv des jeweiligen Gitea-Tags herunter.",
|
||||||
"Wenn du die Systemdatei nicht ändern möchtest, gib --config bei jedem Pacman-Aufruf erneut an oder verwende ein eigenes Wrapper-Skript.",
|
"Die Pipeline aktualisiert Version, Prüfsumme und .SRCINFO im AUR-Paket gitty-desktop.",
|
||||||
"Die Build-Pipeline erzeugt das Paket aus dem PKGBUILD, aktualisiert die Repository-Datenbank und veröffentlicht beides auf dem CDN.",
|
"AUR-Helfer erkennen neue Versionen und erzeugen daraus lokal ein normales Pacman-Paket.",
|
||||||
"Pacman installiert nur dann eine neue Version, wenn Paketversion und Repository-Datenbank entsprechend aktualisiert wurden.",
|
"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.",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -990,6 +1130,25 @@
|
|||||||
"Restore from commit writes an older file version into the working tree. Review and commit the result normally.",
|
"Restore from commit writes an older file version into the working tree. Review and commit the result normally.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "app-git-notes",
|
||||||
|
title: "Add context to commits with Git Notes",
|
||||||
|
summary: "Git Notes attach additional information to a commit without changing its hash or rewriting history. They are useful for review findings, ticket context, build IDs, or approval status.",
|
||||||
|
steps: [
|
||||||
|
"Open the commit note from the note icon or the commit context menu in History.",
|
||||||
|
"Write or edit the note and save it. Commits with a note are marked in History, and hovering over the marker shows a preview.",
|
||||||
|
"Deleting removes only the note. The associated commit and its files remain unchanged.",
|
||||||
|
"Gitty fetches Git Notes from the preferred remote in the background. Use Fetch from remote in the dialog to refresh them explicitly when needed.",
|
||||||
|
"Use Push to remote to publish local notes. A regular branch push does not transfer Git Notes automatically.",
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{ command: "git notes show <commit>", description: "Show a commit's note on the command line" },
|
||||||
|
{ command: "git notes add <commit>", description: "Add a note to a commit or compose it in an editor" },
|
||||||
|
{ command: "git fetch <remote> refs/notes/commits:refs/notes/commits", description: "Fetch commit notes explicitly from a remote" },
|
||||||
|
{ command: "git push <remote> refs/notes/commits", description: "Push local commit notes to a remote" },
|
||||||
|
],
|
||||||
|
note: "Git Notes are stored under refs/notes/commits by default and synchronize separately from branches. Before pushing, make sure the destination remote accepts this reference.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "app-search",
|
id: "app-search",
|
||||||
title: "Find code origins with Global Search",
|
title: "Find code origins with Global Search",
|
||||||
@@ -1090,20 +1249,20 @@
|
|||||||
{
|
{
|
||||||
id: "app-package-updates",
|
id: "app-package-updates",
|
||||||
title: "Install and update Gitty on Arch Linux",
|
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: [
|
commands: [
|
||||||
{ command: "sudo pacman --config /path/gitty-pacman.conf -Syu gitty", description: "Use the custom repository configuration for this invocation and update Gitty" },
|
{ command: "yay -S gitty-desktop", description: "Build, install, or update Gitty from the AUR with yay" },
|
||||||
{ command: "sudo pacman -U /path/gitty-<version>-x86_64.pkg.tar.zst", description: "Install one local or downloaded package file" },
|
{ command: "paru -S gitty-desktop", description: "Alternatively build, install, or update Gitty with paru" },
|
||||||
{ command: "pacman -Qi gitty", description: "Show the installed version and package information" },
|
{ 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 -Si gitty", description: "Show the version available from configured repositories" },
|
{ command: "pacman -Qi gitty-desktop", description: "Show the installed version and package information" },
|
||||||
],
|
],
|
||||||
steps: [
|
steps: [
|
||||||
"For regular system upgrades, add the Gitty repository to /etc/pacman.conf once.",
|
"PKGBUILD downloads the public source archive for the corresponding Gitea tag.",
|
||||||
"If you do not want to change the system file, pass --config on every Pacman invocation or create a dedicated wrapper script.",
|
"The pipeline updates the version, checksum, and .SRCINFO in the gitty-desktop AUR package.",
|
||||||
"The build pipeline creates the package from PKGBUILD, updates the repository database, and publishes both to the CDN.",
|
"AUR helpers detect new versions and turn the recipe into a regular Pacman package locally.",
|
||||||
"Pacman offers an update only after both the package version and repository database have been updated.",
|
"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 +1520,87 @@
|
|||||||
label: "Neu in Gitty",
|
label: "Neu in Gitty",
|
||||||
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
|
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
|
||||||
sections: [
|
sections: [
|
||||||
|
{
|
||||||
|
id: "changelog-2026-8-5",
|
||||||
|
title: "Version 2026.8.5",
|
||||||
|
summary: "Dieses Release integriert Git LFS direkt in Gitty und macht den Staging-Bereich bei vielen geänderten Dateien deutlich übersichtlicher.",
|
||||||
|
steps: [
|
||||||
|
"Git LFS ist direkt über das Synchronisierungsmenü erreichbar. Gitty prüft die verfügbare Erweiterung, die Repository-Konfiguration und den Pre-Push-Hook und zeigt an, ob Git LFS mit Gitty gebündelt oder systemweit installiert ist.",
|
||||||
|
"LFS-Muster lassen sich hinzufügen, als Lockable markieren und wieder entfernen. Der Dialog zeigt außerdem die LFS-Dateien des aktuellen Checkouts, lädt fehlende Objekte und bereinigt nicht mehr benötigte Cache-Objekte.",
|
||||||
|
"Nach einem erfolgreichen Pull erkennt Gitty LFS-Repositories automatisch und lädt die benötigten LFS-Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter manueller Pull ist nicht erforderlich.",
|
||||||
|
"Unstaged und Staged stehen jetzt gleich breit nebeneinander, scrollen unabhängig voneinander und verwenden eindeutige Pfeile für Stage und Unstage. Bei schmalen Fenstern wechselt die Darstellung automatisch untereinander.",
|
||||||
|
"Der mittig angeordnete List-/Tree-Umschalter zeigt Änderungen entweder als kompakte Liste oder gruppiert sie in beiden Bereichen nach aufklappbaren Ordnern.",
|
||||||
|
"Über das neu gestaltete Kontextmenü einer Datei oder eines Ordners lassen sich gezielt einzelne Dateien oder alle Änderungen im Ordner stagen, unstagen oder in einem eigenen Stash sichern. Dateiname, übergeordneter Pfad und Anzahl der betroffenen Dateien sind dabei klar voneinander getrennt.",
|
||||||
|
"Repositories können beim Start über --repo PATH oder --repo=PATH direkt geöffnet werden. Relative Pfade werden dabei aufgelöst.",
|
||||||
|
"Quadratische Bedienelemente und Flächen vereinheitlichen das Erscheinungsbild; runde Statuspunkte, Avatare und charakteristische Branch-Markierungen bleiben erhalten.",
|
||||||
|
],
|
||||||
|
note: "Von Git LFS erzeugte Änderungen an .gitattributes gehören zum Repository und müssen wie jede andere Änderung committed werden. Bereits vorhandene Git-Historie wird durch neue Tracking-Muster nicht rückwirkend umgeschrieben.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-8-4",
|
||||||
|
title: "Version 2026.8.4",
|
||||||
|
summary: "Dieses Release vereinfacht die Verwaltung zusammengehöriger Branches und sorgt für eine einheitlichere, klarere Oberfläche.",
|
||||||
|
steps: [
|
||||||
|
"Lokale und verschachtelte Remote-Branch-Ordner lassen sich über ihr Kontextmenü gesammelt löschen. Der oberste Remote-Ordner wie origin ist geschützt. Der aktuell ausgecheckte Branch bleibt erhalten und einzelne Fehler werden nach Abschluss verständlich aufgeführt.",
|
||||||
|
"Der Compare-Auswahldialog ist vollständig auf Deutsch verfügbar und orientiert sich bei Feldern, Gruppen, Typografie und Dialogflächen am Styling der externen Tools.",
|
||||||
|
"Die Schließen-Schaltflächen der Repository-Tabs sind quadratisch und haben ausgewogenere Abstände sowie deutlichere Hover- und Tastaturfokus-Zustände.",
|
||||||
|
],
|
||||||
|
note: "Der oberste Remote-Ordner wie origin kann nicht gesammelt gelöscht werden. Seine Unterordner können weiterhin gezielt verwaltet werden.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-8-3",
|
||||||
|
title: "Version 2026.8.3",
|
||||||
|
summary: "Dieses Release verbindet Gitty enger mit deinen Entwicklungswerkzeugen und macht Branches, Historie und Vergleiche deutlich leistungsfähiger.",
|
||||||
|
steps: [
|
||||||
|
"Externe Tools: Editor, Diff-Tool, Merge-Tool, Terminal und Dateimanager lassen sich in den neu gestalteten Einstellungen erkennen, auswählen und individuell konfigurieren.",
|
||||||
|
"VS Code, JetBrains-IDEs, Beyond Compare und weitere unterstützte Programme werden in einem eigenen Fenster geöffnet; dokumentierte Ergebnis-Codes werden beim Schließen korrekt behandelt.",
|
||||||
|
"Git Notes: Commits erhalten lokale Notizen, ohne ihre Historie umzuschreiben. Notizen lassen sich bearbeiten, löschen sowie gezielt vom Remote abrufen oder dorthin übertragen.",
|
||||||
|
"Vollständiger Branch-Vergleich: Lokale Branches, Remote-Branches und Commits können direkt ausgewählt und dateiweise im Side-by-Side-Diff verglichen werden.",
|
||||||
|
"Remote-Branches lassen sich im Kontextmenü sicher umbenennen. Gitty schützt dabei vorhandene Ziel-Branches und zwischenzeitlich geänderte Remote-Stände.",
|
||||||
|
"Der überarbeitete Commit-Graph zeigt Branches kompakter, reduziert überladene Commit-Zeilen und blendet zusätzliche Flag-Details beim Darüberfahren ein.",
|
||||||
|
"Noch nicht veröffentlichte Branches sind als „Nur lokal“ erkennbar – in der Werkzeugleiste, Repository-Übersicht, Statuszeile und direkt an der Branch-Flag. Die erste Push-Aktion heißt passend „Veröffentlichen“.",
|
||||||
|
"Branch-Sichtbarkeit, feinere Graph-Verbindungen und ein kleineres Mindestmaß des Verlaufsbereichs verbessern die Übersicht bei großen Repositories.",
|
||||||
|
"Die neue Befehlspalette öffnet Aktionen, Dateien und Commits schneller; asynchrone Git-Befehle halten Gitty auch bei langsameren Operationen reaktionsfähig.",
|
||||||
|
],
|
||||||
|
note: "Der Branch-Vergleich zeigt die vollständig festgeschriebenen Zustände der beiden Branch-Spitzen. Nicht commitete Änderungen im Arbeitsverzeichnis sind nicht enthalten.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-8-2",
|
||||||
|
title: "Version 2026.8.2",
|
||||||
|
summary: "Dieses Release stabilisiert die Darstellung komplexer Verläufe und verbessert die Veröffentlichung neuer Gitty-Versionen.",
|
||||||
|
steps: [
|
||||||
|
"Branch-Farben bleiben über Eltern-Lanes hinweg stabil, sodass sich Linien in längeren und verzweigten Historien leichter verfolgen lassen.",
|
||||||
|
"Release-Artefakte werden automatisch und ohne doppelte Dateien an das passende Gitea-Release angehängt.",
|
||||||
|
"Beim Beenden der Anwendung wird die Telemetrie zuverlässiger abgeschlossen.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-8-1",
|
||||||
|
title: "Version 2026.8.1",
|
||||||
|
summary: "Dieses Release macht große Commit-Verläufe und die Historie einzelner Dateien leichter zugänglich und verbessert die Paketverteilung.",
|
||||||
|
steps: [
|
||||||
|
"Die Commit-Historie lädt ältere Einträge seitenweise nach und ist nicht mehr auf die erste Ergebnismenge begrenzt.",
|
||||||
|
"Die Dateihistorie öffnet sich aus dem Explorer-Kontextmenü in einem eigenen, größeren Dialog statt in einem dauerhaft belegten Seitenbereich.",
|
||||||
|
"Dialoge reagieren konsistenter auf die Escape-Taste.",
|
||||||
|
"Windows- und Ubuntu-Releases sowie der AUR-Paketablauf wurden erweitert und robuster gemacht.",
|
||||||
|
"Die Arch-Linux-Anleitung verwendet jetzt das AUR-Paket gitty-desktop; SSH-Einrichtung, Zeitlimits und Wiederholungsversuche wurden verbessert.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-07-22",
|
||||||
|
title: "Version 2026.07.22",
|
||||||
|
summary: "Dieses Release erweitert Gitty um eine AI-gestützte Aufteilung gestagter Änderungen in logisch getrennte Commits.",
|
||||||
|
steps: [
|
||||||
|
"AI-Commit-Aufteilung: Der gestagte Diff wird analysiert und als geordneter Plan aus mehreren logisch zusammengehörenden Commits vorgeschlagen.",
|
||||||
|
"Für jede Gruppe wird automatisch eine editierbare Conventional-Commit-Nachricht erzeugt.",
|
||||||
|
"Dateien können vor dem Commit zwischen den vorgeschlagenen Gruppen verschoben werden.",
|
||||||
|
"Mit „Commit all“ werden alle bestätigten Gruppen sicher und der Reihe nach committed.",
|
||||||
|
"Der Dialog erklärt leere Gruppen oder fehlende Nachrichten und schützt vor einem zwischenzeitlich veränderten Staging-Bereich.",
|
||||||
|
"„Commit all“ reagiert wieder zuverlässig und bricht nicht mehr beim Kopieren des reaktiven Dialogzustands ab.",
|
||||||
|
"Ein geschlossenes aktives Repository kann nicht mehr durch einen verspäteten Status- oder Fetch-Request erneut geöffnet werden.",
|
||||||
|
],
|
||||||
|
note: "Die AI-Commit-Aufteilung unterstützt OpenAI, Anthropic und eigene OpenAI-kompatible Endpunkte. In Paketdateien erscheint diese Version als 2026.7.22.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "changelog-2026-07-21",
|
id: "changelog-2026-07-21",
|
||||||
title: "Version 2026.07.21",
|
title: "Version 2026.07.21",
|
||||||
@@ -1396,6 +1636,87 @@
|
|||||||
label: "What's new",
|
label: "What's new",
|
||||||
description: "Changes since the latest published version and notable additions from earlier releases.",
|
description: "Changes since the latest published version and notable additions from earlier releases.",
|
||||||
sections: [
|
sections: [
|
||||||
|
{
|
||||||
|
id: "changelog-2026-8-5",
|
||||||
|
title: "Version 2026.8.5",
|
||||||
|
summary: "This release integrates Git LFS directly into Gitty and makes the staging area much easier to navigate when many files have changed.",
|
||||||
|
steps: [
|
||||||
|
"Git LFS is available directly from the Sync menu. Gitty checks the available extension, repository configuration, and pre-push hook, and reports whether Git LFS is bundled with Gitty or installed system-wide.",
|
||||||
|
"LFS patterns can be added, marked as Lockable, and removed again. The dialog also lists LFS files in the current checkout, downloads missing objects, and prunes unused cache objects.",
|
||||||
|
"After a successful pull, Gitty automatically detects LFS repositories and downloads the required LFS objects with the same remote and credentials. A second manual pull is no longer required.",
|
||||||
|
"Unstaged and Staged now sit side by side at equal width, scroll independently, and use clear arrows for Stage and Unstage. Narrow windows automatically fall back to a vertical layout.",
|
||||||
|
"The centered List/Tree switch presents changes either as a compact list or groups them into collapsible folders in both areas.",
|
||||||
|
"The redesigned file and folder context menu can stage, unstage, or save only that file or the folder's complete set of changes in a dedicated stash. The selected name, parent path, and affected file count are now clearly separated.",
|
||||||
|
"Repositories can be opened directly at startup with --repo PATH or --repo=PATH. Relative paths are resolved automatically.",
|
||||||
|
"Square controls and surfaces make the interface more consistent while circular status markers, avatars, and characteristic branch shapes remain intact.",
|
||||||
|
],
|
||||||
|
note: "Changes to .gitattributes created by Git LFS belong to the repository and must be committed like any other change. New tracking patterns do not rewrite existing Git history retroactively.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-8-4",
|
||||||
|
title: "Version 2026.8.4",
|
||||||
|
summary: "This release simplifies managing related branches and makes the interface more consistent and easier to read.",
|
||||||
|
steps: [
|
||||||
|
"Local and nested remote branch folders can be deleted in one action from their context menu. The top-level remote folder such as origin is protected. The currently checked-out branch is kept, and individual failures are summarized after processing.",
|
||||||
|
"The Compare selector is fully localized in German and now follows the external-tool selectors for fields, groups, typography, and dialog surfaces.",
|
||||||
|
"Repository-tab close buttons are square and have more balanced spacing and clearer hover and keyboard-focus states.",
|
||||||
|
],
|
||||||
|
note: "The top-level remote folder such as origin cannot be deleted in bulk. Its nested folders can still be managed selectively.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-8-3",
|
||||||
|
title: "Version 2026.8.3",
|
||||||
|
summary: "This release connects Gitty more closely with your development tools and makes branches, history, and comparisons substantially more capable.",
|
||||||
|
steps: [
|
||||||
|
"External tools: editors, diff tools, merge tools, terminals, and file managers can be detected, selected, and customized in the redesigned settings.",
|
||||||
|
"VS Code, JetBrains IDEs, Beyond Compare, and other supported applications open in a separate window; documented result codes are handled correctly when they close.",
|
||||||
|
"Git Notes: attach local notes to commits without rewriting history. Notes can be edited, deleted, fetched from a remote, or pushed explicitly.",
|
||||||
|
"Complete branch comparison: choose local branches, remote branches, or commits and inspect every changed file in a side-by-side diff.",
|
||||||
|
"Remote branches can be renamed safely from the context menu. Gitty protects existing destination branches and remote branches that changed after the last fetch.",
|
||||||
|
"The redesigned commit graph presents branches more compactly, reduces crowded commit rows, and reveals additional flag details on hover.",
|
||||||
|
"Unpublished branches are clearly marked as Local only in the toolbar, repository summary, status bar, and on the graph flag. Their first push is labeled Publish.",
|
||||||
|
"Branch visibility controls, refined graph connectors, and a smaller minimum history width improve navigation in large repositories.",
|
||||||
|
"The new command palette opens actions, files, and commits faster; asynchronous Git commands keep Gitty responsive during slower operations.",
|
||||||
|
],
|
||||||
|
note: "Branch comparison uses the fully committed state at each branch tip. Uncommitted working-tree changes are not included.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-8-2",
|
||||||
|
title: "Version 2026.8.2",
|
||||||
|
summary: "This release stabilizes complex history rendering and improves publication of new Gitty versions.",
|
||||||
|
steps: [
|
||||||
|
"Branch colors remain stable across parent lanes, making longer and branching histories easier to follow.",
|
||||||
|
"Release artifacts are attached to the matching Gitea release automatically without uploading duplicates.",
|
||||||
|
"Telemetry cleanup completes more reliably while the application is shutting down.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-8-1",
|
||||||
|
title: "Version 2026.8.1",
|
||||||
|
summary: "This release makes large commit histories and individual file histories easier to access and improves package distribution.",
|
||||||
|
steps: [
|
||||||
|
"Commit history loads older entries page by page instead of stopping after the initial result set.",
|
||||||
|
"File history opens from the explorer context menu in a dedicated larger dialog instead of occupying a permanent workspace panel.",
|
||||||
|
"Dialogs respond more consistently to the Escape key.",
|
||||||
|
"Windows and Ubuntu publishing plus the AUR package workflow were expanded and made more robust.",
|
||||||
|
"The Arch Linux guide now uses the gitty-desktop AUR package; SSH setup, timeouts, and retry handling were improved.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-07-22",
|
||||||
|
title: "Version 2026.07.22",
|
||||||
|
summary: "This release adds AI-assisted splitting of staged changes into separate logical commits.",
|
||||||
|
steps: [
|
||||||
|
"AI commit splitting analyzes the staged diff and proposes an ordered plan of logically related commits.",
|
||||||
|
"Every group receives an automatically generated, editable Conventional Commit message.",
|
||||||
|
"Files can be moved between proposed groups before committing.",
|
||||||
|
"Commit all safely creates every accepted group in sequence.",
|
||||||
|
"The dialog explains empty groups or missing messages and protects against a staging area that changed after planning.",
|
||||||
|
"Commit all now responds reliably instead of failing while copying reactive dialog state.",
|
||||||
|
"Closing the active repository no longer lets a delayed status or fetch request reopen the closed tab.",
|
||||||
|
],
|
||||||
|
note: "AI commit splitting supports OpenAI, Anthropic, and custom OpenAI-compatible endpoints. Package metadata represents this version as 2026.7.22.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "changelog-2026-07-21",
|
id: "changelog-2026-07-21",
|
||||||
title: "Version 2026.07.21",
|
title: "Version 2026.07.21",
|
||||||
@@ -1535,6 +1856,7 @@
|
|||||||
{:else if category.id === "basics"}<GitCommitHorizontal size={17} aria-hidden="true" />
|
{:else if category.id === "basics"}<GitCommitHorizontal size={17} aria-hidden="true" />
|
||||||
{:else if category.id === "branches"}<GitBranch size={17} aria-hidden="true" />
|
{:else if category.id === "branches"}<GitBranch size={17} aria-hidden="true" />
|
||||||
{:else if category.id === "remote"}<Cloud size={17} aria-hidden="true" />
|
{:else if category.id === "remote"}<Cloud size={17} aria-hidden="true" />
|
||||||
|
{:else if category.id === "lfs"}<Box size={17} aria-hidden="true" />
|
||||||
{:else if category.id === "troubleshooting"}<Wrench size={17} aria-hidden="true" />
|
{:else if category.id === "troubleshooting"}<Wrench size={17} aria-hidden="true" />
|
||||||
{:else if category.id === "workflows"}<ListChecks size={17} aria-hidden="true" />
|
{:else if category.id === "workflows"}<ListChecks size={17} aria-hidden="true" />
|
||||||
{:else if category.id === "reference"}<Library size={17} aria-hidden="true" />
|
{:else if category.id === "reference"}<Library size={17} aria-hidden="true" />
|
||||||
@@ -1547,7 +1869,7 @@
|
|||||||
|
|
||||||
<div class="help-nav-tip">
|
<div class="help-nav-tip">
|
||||||
<span class="help-tip-icon"><Lightbulb size={16} aria-hidden="true" /></span>
|
<span class="help-tip-icon"><Lightbulb size={16} aria-hidden="true" /></span>
|
||||||
<span>{isGerman ? "Suche auch nach Befehlen wie" : "Try commands such as"} <code>rebase</code>, <code>stash</code> {isGerman ? "oder" : "or"} <code>reflog</code>.</span>
|
<span>{isGerman ? "Suche auch nach Befehlen wie" : "Try commands such as"} <code>rebase</code>, <code>lfs</code> {isGerman ? "oder" : "or"} <code>reflog</code>.</span>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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";
|
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||||
|
|
||||||
interface GraphSegment {
|
interface GraphSegment {
|
||||||
@@ -22,54 +22,127 @@
|
|||||||
graphCommit: GitCommit;
|
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 = [
|
const GRAPH_COLORS = [
|
||||||
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
|
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
|
||||||
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
||||||
];
|
];
|
||||||
const GRAPH_LANE = 18;
|
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 {
|
interface Props {
|
||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
localBranchNames: string[];
|
localBranchNames: string[];
|
||||||
|
localBranchUpstreams: Record<string, string>;
|
||||||
|
remoteBranchNames: string[];
|
||||||
activeBranch: string;
|
activeBranch: string;
|
||||||
activeUpstream: string;
|
activeUpstream: string;
|
||||||
|
activeAhead: number;
|
||||||
|
activeBehind: number;
|
||||||
repositoryKey: string;
|
repositoryKey: string;
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
isLoadingMore: boolean;
|
||||||
|
loadMoreError: string;
|
||||||
expandedCommitHashes: Set<string>;
|
expandedCommitHashes: Set<string>;
|
||||||
|
selectedCommitHash: string;
|
||||||
|
onLoadMore: () => void | Promise<void>;
|
||||||
onRestoreCommit: (commit: GitCommit) => void;
|
onRestoreCommit: (commit: GitCommit) => void;
|
||||||
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||||
onToggleCommitFiles: (hash: string) => void;
|
onToggleCommitFiles: (hash: string) => void;
|
||||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||||
onCherryPickCommit: (commit: GitCommit) => void;
|
onCherryPickCommit: (commit: GitCommit) => void;
|
||||||
onRevertCommit: (commit: GitCommit) => void;
|
onRevertCommit: (commit: GitCommit) => void;
|
||||||
|
onOpenCommitNote: (commit: GitCommit) => void;
|
||||||
|
onLoadCommitNote: (commit: GitCommit) => Promise<string | null>;
|
||||||
|
onSelectCommit: (commit: GitCommit) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
commits = [],
|
commits = [],
|
||||||
localBranchNames = [],
|
localBranchNames = [],
|
||||||
|
localBranchUpstreams = {},
|
||||||
|
remoteBranchNames = [],
|
||||||
activeBranch = "",
|
activeBranch = "",
|
||||||
activeUpstream = "",
|
activeUpstream = "",
|
||||||
|
activeAhead = 0,
|
||||||
|
activeBehind = 0,
|
||||||
repositoryKey = "",
|
repositoryKey = "",
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
hasMore = false,
|
||||||
|
isLoadingMore = false,
|
||||||
|
loadMoreError = "",
|
||||||
expandedCommitHashes = new Set(),
|
expandedCommitHashes = new Set(),
|
||||||
|
selectedCommitHash = "",
|
||||||
|
onLoadMore = () => {},
|
||||||
onRestoreCommit = () => {},
|
onRestoreCommit = () => {},
|
||||||
onPreviewCommitFile = () => {},
|
onPreviewCommitFile = () => {},
|
||||||
onToggleCommitFiles = () => {},
|
onToggleCommitFiles = () => {},
|
||||||
onCreateBranchFromCommit = () => {},
|
onCreateBranchFromCommit = () => {},
|
||||||
onCherryPickCommit = () => {},
|
onCherryPickCommit = () => {},
|
||||||
onRevertCommit = () => {},
|
onRevertCommit = () => {},
|
||||||
|
onOpenCommitNote = () => {},
|
||||||
|
onLoadCommitNote = async () => null,
|
||||||
|
onSelectCommit = () => {},
|
||||||
}: Props = $props();
|
}: 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 branchDialogOpen = $state(false);
|
||||||
let userAdjustedBranchFilter = $state(false);
|
let expandedRefsCommitHash = $state("");
|
||||||
let lastDefaultFilterKey = $state("");
|
|
||||||
let panelElement = $state<HTMLElement | null>(null);
|
let panelElement = $state<HTMLElement | null>(null);
|
||||||
let contextCommit = $state<GitCommit | null>(null);
|
let contextCommit = $state<GitCommit | null>(null);
|
||||||
let contextMenuX = $state(0);
|
let contextMenuX = $state(0);
|
||||||
let contextMenuY = $state(0);
|
let contextMenuY = $state(0);
|
||||||
|
let notePreviews = $state<Record<string, string>>({});
|
||||||
|
let notePreviewLoading = $state<Set<string>>(new Set());
|
||||||
|
let notePreviewErrors = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
repositoryKey;
|
||||||
|
notePreviews = {};
|
||||||
|
notePreviewLoading = new Set();
|
||||||
|
notePreviewErrors = new Set();
|
||||||
|
});
|
||||||
|
|
||||||
|
function observeHistoryEnd(node: HTMLElement) {
|
||||||
|
const root = node.closest<HTMLElement>(".history-list");
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting) && hasMore && !isLoadingMore && !loadMoreError && !isBusy) {
|
||||||
|
void onLoadMore();
|
||||||
|
}
|
||||||
|
}, { root, rootMargin: "240px 0px" });
|
||||||
|
observer.observe(node);
|
||||||
|
return { destroy: () => observer.disconnect() };
|
||||||
|
}
|
||||||
|
|
||||||
function laneColor(col: number): string {
|
function laneColor(col: number): string {
|
||||||
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
|
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
|
||||||
@@ -117,15 +190,18 @@
|
|||||||
|
|
||||||
const currentBranches = branchMembership.get(commit.hash) ?? localBranchRefs(commit);
|
const currentBranches = branchMembership.get(commit.hash) ?? localBranchRefs(commit);
|
||||||
const commitBranches = uniqueStrings([...(beforeBranches[col] ?? []), ...currentBranches]);
|
const commitBranches = uniqueStrings([...(beforeBranches[col] ?? []), ...currentBranches]);
|
||||||
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
|
const firstParent = commit.parents[0] ?? null;
|
||||||
afterBranches[col] = after[col] ? commitBranches.slice() : [];
|
after[col] = firstParent;
|
||||||
|
afterBranches[col] = firstParent
|
||||||
|
? (branchMembership.get(firstParent) ?? commitBranches).slice()
|
||||||
|
: [];
|
||||||
|
|
||||||
const fromCommit = new Set<number>([col]);
|
const fromCommit = new Set<number>([col]);
|
||||||
for (let p = 1; p < commit.parents.length; p++) {
|
for (let p = 1; p < commit.parents.length; p++) {
|
||||||
let slot = after.indexOf(null);
|
let slot = after.indexOf(null);
|
||||||
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
|
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
|
||||||
after[slot] = commit.parents[p];
|
after[slot] = commit.parents[p];
|
||||||
afterBranches[slot] = [];
|
afterBranches[slot] = (branchMembership.get(commit.parents[p]) ?? commitBranches).slice();
|
||||||
fromCommit.add(slot);
|
fromCommit.add(slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,39 +276,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let localBranchNameSet = $derived(new Set(localBranchNames));
|
let localBranchNameSet = $derived(new Set(localBranchNames));
|
||||||
|
let remoteBranchNameSet = $derived(new Set(remoteBranchNames));
|
||||||
|
|
||||||
function uniqueStrings(values: string[]): string[] {
|
function uniqueStrings(values: string[]): string[] {
|
||||||
return [...new Set(values.filter(Boolean))];
|
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));
|
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 {
|
function branchIsVisible(branch: string): boolean {
|
||||||
if (branch === activeUpstream && activeUpstream) {
|
return visibleGraphBranchNameSet.has(branch);
|
||||||
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`];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
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(", ")}`;
|
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;
|
if (containingBranches.length === 0) return commit.short_hash;
|
||||||
return `Branches containing this commit: ${containingBranches.join(", ")}`;
|
return `Branches containing this commit: ${containingBranches.join(", ")}`;
|
||||||
}
|
}
|
||||||
@@ -252,7 +329,7 @@
|
|||||||
|
|
||||||
function branchesAreVisible(branches: string[]): boolean {
|
function branchesAreVisible(branches: string[]): boolean {
|
||||||
if (graphBranchNames.length === 0) return true;
|
if (graphBranchNames.length === 0) return true;
|
||||||
return branches.some(branchIsVisible);
|
return branches.some((branch) => visibleGraphBranchNameSet.has(branch));
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
|
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
|
||||||
@@ -329,20 +406,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleGraphBranch(branch: string) {
|
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);
|
if (next.has(branch)) next.delete(branch); else next.add(branch);
|
||||||
hiddenGraphBranches = next;
|
customVisibleBranches = next;
|
||||||
userAdjustedBranchFilter = true;
|
branchVisibilityMode = "custom";
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAllGraphBranches() {
|
function showAllGraphBranches() {
|
||||||
hiddenGraphBranches = new Set();
|
branchVisibilityMode = "all";
|
||||||
userAdjustedBranchFilter = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideAllGraphBranches() {
|
function hideAllGraphBranches() {
|
||||||
hiddenGraphBranches = new Set(localBranchNames);
|
customVisibleBranches = new Set();
|
||||||
userAdjustedBranchFilter = true;
|
branchVisibilityMode = "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFocusGraphBranches() {
|
||||||
|
branchVisibilityMode = "focus";
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBranchDialog() {
|
function openBranchDialog() {
|
||||||
@@ -359,6 +441,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCommitRefs(commit: GitCommit) {
|
||||||
|
expandedRefsCommitHash = expandedRefsCommitHash === commit.hash ? "" : commit.hash;
|
||||||
|
}
|
||||||
|
|
||||||
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
|
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -415,9 +501,52 @@
|
|||||||
await onRevertCommit(commit);
|
await onRevertCommit(commit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openContextCommitNote() {
|
||||||
|
const commit = contextCommit;
|
||||||
|
if (!commit || isBusy) return;
|
||||||
|
closeCommitContextMenu();
|
||||||
|
onSelectCommit(commit);
|
||||||
|
await onOpenCommitNote(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCommitNote(commit: GitCommit) {
|
||||||
|
if (isBusy) return;
|
||||||
|
onSelectCommit(commit);
|
||||||
|
await onOpenCommitNote(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCommitNotePreview(commit: GitCommit) {
|
||||||
|
if (!commit.has_note || notePreviewLoading.has(commit.hash)) return;
|
||||||
|
|
||||||
|
const loading = new Set(notePreviewLoading);
|
||||||
|
loading.add(commit.hash);
|
||||||
|
notePreviewLoading = loading;
|
||||||
|
|
||||||
|
const errors = new Set(notePreviewErrors);
|
||||||
|
errors.delete(commit.hash);
|
||||||
|
notePreviewErrors = errors;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const note = await onLoadCommitNote(commit);
|
||||||
|
notePreviews = {
|
||||||
|
...notePreviews,
|
||||||
|
[commit.hash]: note?.trim() || "This Git note is empty.",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
const nextErrors = new Set(notePreviewErrors);
|
||||||
|
nextErrors.add(commit.hash);
|
||||||
|
notePreviewErrors = nextErrors;
|
||||||
|
} finally {
|
||||||
|
const nextLoading = new Set(notePreviewLoading);
|
||||||
|
nextLoading.delete(commit.hash);
|
||||||
|
notePreviewLoading = nextLoading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleWindowKeydown(event: KeyboardEvent) {
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
if (event.key !== "Escape") return;
|
if (event.key !== "Escape") return;
|
||||||
closeCommitContextMenu();
|
closeCommitContextMenu();
|
||||||
|
expandedRefsCommitHash = "";
|
||||||
handleBranchDialogKeydown(event);
|
handleBranchDialogKeydown(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,66 +574,217 @@
|
|||||||
return labels;
|
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 {
|
function refLabel(ref: string): string {
|
||||||
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
|
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 {
|
function formatCommitDate(value: string): string {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
return commitDateFormatter.format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const available = new Set(localBranchNames);
|
const currentRepository = repositoryKey;
|
||||||
const nextHidden = new Set([...hiddenGraphBranches].filter((branch) => available.has(branch)));
|
if (loadedVisibilityRepository === currentRepository) return;
|
||||||
if (nextHidden.size !== hiddenGraphBranches.size) {
|
loadedVisibilityRepository = currentRepository;
|
||||||
hiddenGraphBranches = nextHidden;
|
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(() => {
|
$effect(() => {
|
||||||
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
|
const currentRepository = repositoryKey;
|
||||||
? activeBranch
|
const mode = branchVisibilityMode;
|
||||||
: (graphBranchNames[0] ?? "");
|
const branches = [...customVisibleBranches];
|
||||||
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
|
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 (!defaultBranch) {
|
$effect(() => {
|
||||||
if (lastDefaultFilterKey !== defaultFilterKey) {
|
if (graphBranchNames.length === 0 || customVisibleBranches.size === 0) return;
|
||||||
hiddenGraphBranches = new Set();
|
const available = new Set(graphBranchNames);
|
||||||
userAdjustedBranchFilter = false;
|
const next = new Set([...customVisibleBranches].filter((branch) => available.has(branch)));
|
||||||
lastDefaultFilterKey = defaultFilterKey;
|
if (next.size !== customVisibleBranches.size) customVisibleBranches = next;
|
||||||
}
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastDefaultFilterKey !== defaultFilterKey) {
|
$effect(() => {
|
||||||
userAdjustedBranchFilter = false;
|
if (!selectedCommitHash) return;
|
||||||
lastDefaultFilterKey = defaultFilterKey;
|
queueMicrotask(() => {
|
||||||
}
|
panelElement
|
||||||
|
?.querySelector<HTMLElement>(`[data-commit-hash="${CSS.escape(selectedCommitHash)}"]`)
|
||||||
if (!userAdjustedBranchFilter) {
|
?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||||
hiddenGraphBranches = new Set(localBranchNames.filter((branch) => branch !== defaultBranch));
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let branchMembership = $derived(branchMembershipByHash(commits));
|
let branchMembership = $derived(branchMembershipByHash(commits));
|
||||||
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
|
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
|
||||||
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
|
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
|
||||||
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
|
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 graph = $derived(computeGraph(graphCommits, branchMembership));
|
||||||
let graphRows = $derived(graph.rows);
|
let graphRows = $derived(graph.rows);
|
||||||
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
||||||
@@ -518,41 +798,42 @@
|
|||||||
<span class="eyebrow">History</span>
|
<span class="eyebrow">History</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
||||||
</div>
|
</div>
|
||||||
|
{#if graphBranchNames.length > 0}
|
||||||
<div class="section-head-actions">
|
<div class="section-head-actions">
|
||||||
{#if localBranchNames.length > 0}
|
|
||||||
<button
|
<button
|
||||||
class="graph-branch-dialog-button"
|
class="graph-branch-dialog-button"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={openBranchDialog}
|
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" />
|
<GitBranch size={13} aria-hidden="true" />
|
||||||
Branches
|
Branches
|
||||||
<span>{visibleBranchCount}/{localBranchNames.length}</span>
|
<span>{visibleBranchCount}/{graphBranchNames.length}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
<div class="blank-state">No repository loaded.</div>
|
<div class="blank-state">No repository loaded.</div>
|
||||||
{:else if commits.length === 0}
|
{:else if commits.length === 0}
|
||||||
<div class="blank-state">No commits returned.</div>
|
<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}
|
{:else}
|
||||||
<div class="history-list graph-list overflow-auto">
|
<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)}
|
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
||||||
{@const item = entry.commit}
|
{@const item = entry.commit}
|
||||||
{@const row = graphRows[rowIndex]}
|
{@const row = graphRows[rowIndex]}
|
||||||
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
|
{@const refSummary = commitRefSummary(item)}
|
||||||
{@const otherRefs = visibleRefs(item)}
|
|
||||||
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
||||||
<article
|
<article
|
||||||
class="commit-row graph-row"
|
class="commit-row graph-row"
|
||||||
|
class:selected={selectedCommitHash === item.hash}
|
||||||
|
class:has-note={item.has_note}
|
||||||
|
data-commit-hash={item.hash}
|
||||||
class:graph-ahead-row={rowSyncClass === "ahead"}
|
class:graph-ahead-row={rowSyncClass === "ahead"}
|
||||||
class:graph-behind-row={rowSyncClass === "behind"}
|
class:graph-behind-row={rowSyncClass === "behind"}
|
||||||
class:merge-row={item.parents.length > 1}
|
class:merge-row={item.parents.length > 1}
|
||||||
@@ -584,6 +865,15 @@
|
|||||||
vector-effect="non-scaling-stroke"
|
vector-effect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/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>
|
</svg>
|
||||||
<span
|
<span
|
||||||
class="graph-dot"
|
class="graph-dot"
|
||||||
@@ -595,25 +885,114 @@
|
|||||||
title={commitHoverTitle(item, row)}
|
title={commitHoverTitle(item, row)}
|
||||||
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
||||||
></span>
|
></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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="commit-body">
|
<div
|
||||||
|
class="commit-body"
|
||||||
|
class:has-branch-ref={Boolean(refSummary.primaryBranch)}
|
||||||
|
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`}
|
||||||
|
>
|
||||||
|
{#if item.has_note}
|
||||||
|
<span class="commit-note-rail" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
|
||||||
|
<div class="commit-ref-area">
|
||||||
|
<div class="commit-ref-strip" aria-label="Commit references">
|
||||||
|
{#if refSummary.primaryBranch}
|
||||||
|
<span class="branch-ref-cluster" class:local-only={refSummary.primaryBranch.localOnly}>
|
||||||
|
<span
|
||||||
|
class="compact-ref-chip branch"
|
||||||
|
class:current={refSummary.primaryBranch.current}
|
||||||
|
class:remote={refSummary.primaryBranch.kind === "remote"}
|
||||||
|
title={branchDecorationTitle(refSummary.primaryBranch)}
|
||||||
|
>
|
||||||
|
<GitBranch class="compact-ref-branch-icon" size={10} aria-hidden="true" />
|
||||||
|
<span>{refSummary.primaryBranch.label}</span>
|
||||||
|
{#if !refSummary.primaryBranch.localOnly && branchStatusLabel(refSummary.primaryBranch)}
|
||||||
|
<small>{branchStatusLabel(refSummary.primaryBranch)}</small>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if refSummary.primaryBranch.localOnly}
|
||||||
|
<span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet">
|
||||||
|
LOCAL
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.primaryTag}
|
||||||
|
<span class="compact-ref-chip tag" title={`Tag ${refSummary.primaryTag}`}>
|
||||||
|
<Tag size={10} aria-hidden="true" />
|
||||||
|
<span>{refSummary.primaryTag}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.overflowCount > 0}
|
||||||
|
<button
|
||||||
|
class="compact-ref-overflow"
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggleCommitRefs(item)}
|
||||||
|
aria-expanded={expandedRefsCommitHash === item.hash}
|
||||||
|
aria-controls={`commit-refs-${item.hash}`}
|
||||||
|
title={`Show ${refSummary.overflowCount} more ${refSummary.overflowCount === 1 ? "reference" : "references"}`}
|
||||||
|
>
|
||||||
|
+{refSummary.overflowCount}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if refSummary.overflowCount > 0 && expandedRefsCommitHash === item.hash}
|
||||||
|
<div class="commit-ref-details" id={`commit-refs-${item.hash}`}>
|
||||||
|
<strong>References on this commit</strong>
|
||||||
|
{#if refSummary.branches.some((branch) => branch.kind !== "remote")}
|
||||||
|
<section>
|
||||||
|
<span>Local</span>
|
||||||
|
<div>
|
||||||
|
{#each refSummary.branches.filter((branch) => branch.kind !== "remote") as branch}
|
||||||
|
<span class="commit-ref-detail-item local" title={branchDecorationTitle(branch)}>
|
||||||
|
<i aria-hidden="true"></i>{branch.label}
|
||||||
|
{#if branch.current}<small>Current</small>{/if}
|
||||||
|
{#if branch.trackedRemote}<small>{branch.trackedRemote}</small>{/if}
|
||||||
|
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />Local only</small>{/if}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.branches.some((branch) => branch.kind === "remote")}
|
||||||
|
<section>
|
||||||
|
<span>Remote</span>
|
||||||
|
<div>
|
||||||
|
{#each refSummary.branches.filter((branch) => branch.kind === "remote") as branch}
|
||||||
|
<span class="commit-ref-detail-item remote"><i aria-hidden="true"></i>{branch.label}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.tags.length > 0}
|
||||||
|
<section>
|
||||||
|
<span>Tags</span>
|
||||||
|
<div>
|
||||||
|
{#each refSummary.tags as tag}
|
||||||
|
<span class="commit-ref-detail-item tag"><Tag size={10} aria-hidden="true" />{tag}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.other.length > 0}
|
||||||
|
<section>
|
||||||
|
<span>Other</span>
|
||||||
|
<div>
|
||||||
|
{#each refSummary.other as ref}
|
||||||
|
<span class="commit-ref-detail-item">{ref}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="commit-card-head">
|
<div class="commit-card-head">
|
||||||
<span class="commit-avatar">
|
<span class="commit-avatar">
|
||||||
{authorInitials(item.author_name)}
|
{authorInitials(item.author_name)}
|
||||||
@@ -631,17 +1010,46 @@
|
|||||||
<div class="commit-meta-line">
|
<div class="commit-meta-line">
|
||||||
<span class="commit-hash">{item.short_hash}</span>
|
<span class="commit-hash">{item.short_hash}</span>
|
||||||
<span class="commit-author" title={item.author_email}>{item.author_name}</span>
|
<span class="commit-author" title={item.author_email}>{item.author_name}</span>
|
||||||
</div>
|
{#if item.has_note}
|
||||||
</div>
|
<span class="commit-note-indicator">
|
||||||
</div>
|
<button
|
||||||
|
class="commit-note-presence"
|
||||||
{#if otherRefs.length > 0}
|
type="button"
|
||||||
<div class="ref-list" aria-label="Commit refs">
|
onpointerenter={() => void loadCommitNotePreview(item)}
|
||||||
{#each otherRefs as ref}
|
onfocus={() => void loadCommitNotePreview(item)}
|
||||||
<span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
|
onclick={() => openCommitNote(item)}
|
||||||
{/each}
|
disabled={isBusy}
|
||||||
</div>
|
aria-label={`Open Git note for ${item.short_hash}`}
|
||||||
|
aria-describedby={`commit-note-preview-${item.hash}`}
|
||||||
|
>
|
||||||
|
<StickyNote size={11} aria-hidden="true" />
|
||||||
|
<span>Note</span>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class="commit-note-tooltip"
|
||||||
|
id={`commit-note-preview-${item.hash}`}
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
<span class="commit-note-tooltip-head">
|
||||||
|
<StickyNote size={12} aria-hidden="true" />
|
||||||
|
Git Note
|
||||||
|
<small>Click to open</small>
|
||||||
|
</span>
|
||||||
|
<span class="commit-note-tooltip-body">
|
||||||
|
{#if notePreviewLoading.has(item.hash)}
|
||||||
|
Loading note…
|
||||||
|
{:else if notePreviewErrors.has(item.hash)}
|
||||||
|
Note could not be loaded.
|
||||||
|
{:else}
|
||||||
|
{notePreviews[item.hash] ?? "Hover to load the note."}
|
||||||
{/if}
|
{/if}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if item.files.length > 0}
|
{#if item.files.length > 0}
|
||||||
<div class="commit-files">
|
<div class="commit-files">
|
||||||
@@ -681,6 +1089,18 @@
|
|||||||
<div class="commit-actions">
|
<div class="commit-actions">
|
||||||
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
|
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||||
<div class="commit-action-buttons">
|
<div class="commit-action-buttons">
|
||||||
|
{#if !item.has_note}
|
||||||
|
<button
|
||||||
|
class="commit-menu-button commit-note-button"
|
||||||
|
type="button"
|
||||||
|
onclick={() => openCommitNote(item)}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={`Add a Git note to ${item.short_hash}`}
|
||||||
|
aria-label={`Add a Git note to ${item.short_hash}`}
|
||||||
|
>
|
||||||
|
<StickyNote size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="commit-menu-button"
|
class="commit-menu-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -698,6 +1118,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -713,6 +1148,10 @@
|
|||||||
<GitBranch size={14} aria-hidden="true" />
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
Branch
|
Branch
|
||||||
</button>
|
</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}>
|
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
Restore
|
Restore
|
||||||
@@ -753,14 +1192,17 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="branch-filter-summary">
|
<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">
|
<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>
|
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="branch-filter-dialog-list">
|
<div class="branch-filter-dialog-list">
|
||||||
|
{#if localBranchNames.length > 0}
|
||||||
|
<span class="branch-filter-group-label">Local</span>
|
||||||
{#each localBranchNames as branch}
|
{#each localBranchNames as branch}
|
||||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
||||||
<input
|
<input
|
||||||
@@ -772,6 +1214,21 @@
|
|||||||
<span>{branch}</span>
|
<span>{branch}</span>
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
|
import { AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
|
||||||
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
|
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
interface PlanRow extends RebaseCommit {
|
interface PlanRow extends RebaseCommit {
|
||||||
action: RebaseAction;
|
action: RebaseAction;
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
));
|
));
|
||||||
let invalidReword = $derived(rows.some((row) => row.action === "reword" && !row.message.trim()));
|
let invalidReword = $derived(rows.some((row) => row.action === "reword" && !row.message.trim()));
|
||||||
let canStart = $derived(Boolean(base) && rows.length > 0 && keptCount > 0 && !invalidSquash && !invalidReword && !isLoading && !isBusy);
|
let canStart = $derived(Boolean(base) && rows.length > 0 && keptCount > 0 && !invalidSquash && !invalidReword && !isLoading && !isBusy);
|
||||||
|
const rebaseActions: RebaseAction[] = ["pick", "reword", "squash", "fixup", "drop"];
|
||||||
|
|
||||||
function updateAction(index: number, action: RebaseAction) {
|
function updateAction(index: number, action: RebaseAction) {
|
||||||
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, action } : row);
|
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, action } : row);
|
||||||
@@ -82,12 +84,7 @@
|
|||||||
<section class="rebase-base-bar">
|
<section class="rebase-base-bar">
|
||||||
<label>
|
<label>
|
||||||
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
|
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
|
||||||
<select value={base} onchange={(event) => onBaseChange((event.target as HTMLSelectElement).value)} disabled={isBusy || isLoading}>
|
<SelectMenu value={base} options={availableBases.map((branch) => ({ value: branch.name, label: `${branch.remote ? "Remote - " : "Local - "}${branch.name}` }))} placeholder="Select a base branch" disabled={isBusy || isLoading} onChange={onBaseChange} />
|
||||||
<option value="" disabled>Select a base branch</option>
|
|
||||||
{#each availableBases as branch (branch.name)}
|
|
||||||
<option value={branch.name}>{branch.remote ? "Remote · " : "Local · "}{branch.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
|
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -110,9 +107,7 @@
|
|||||||
<button type="button" onclick={() => move(index, -1)} disabled={isBusy || index === 0} title="Move up"><ArrowUp size={14} aria-hidden="true" /></button>
|
<button type="button" onclick={() => move(index, -1)} disabled={isBusy || index === 0} title="Move up"><ArrowUp size={14} aria-hidden="true" /></button>
|
||||||
<button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title="Move down"><ArrowDown size={14} aria-hidden="true" /></button>
|
<button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title="Move down"><ArrowDown size={14} aria-hidden="true" /></button>
|
||||||
</div>
|
</div>
|
||||||
<select class={`rebase-action ${row.action}`} value={row.action} onchange={(event) => updateAction(index, (event.target as HTMLSelectElement).value as RebaseAction)} disabled={isBusy} aria-label={`Action for ${row.short_hash}`}>
|
<SelectMenu class={`rebase-action ${row.action}`} value={row.action} options={rebaseActions.map((action) => ({ value: action, label: action }))} disabled={isBusy} ariaLabel={`Action for ${row.short_hash}`} onChange={(value) => updateAction(index, value as RebaseAction)} />
|
||||||
<option value="pick">pick</option><option value="reword">reword</option><option value="squash">squash</option><option value="fixup">fixup</option><option value="drop">drop</option>
|
|
||||||
</select>
|
|
||||||
<code>{row.short_hash}</code>
|
<code>{row.short_hash}</code>
|
||||||
<div class="rebase-commit-copy">
|
<div class="rebase-commit-copy">
|
||||||
{#if row.action === "reword"}
|
{#if row.action === "reword"}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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";
|
import type { GitFileStatus, PatchApplyAction } from "../types";
|
||||||
|
|
||||||
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
||||||
@@ -34,9 +34,12 @@
|
|||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
|
language?: "en" | "de";
|
||||||
|
diffName?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRefresh: () => void | Promise<void>;
|
onRefresh: () => void | Promise<void>;
|
||||||
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
|
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
|
||||||
|
onExternalDiff: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -46,11 +49,16 @@
|
|||||||
isBusy = false,
|
isBusy = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = "",
|
error = "",
|
||||||
|
language = "en",
|
||||||
|
diffName = "diff tool",
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
onRefresh = () => {},
|
onRefresh = () => {},
|
||||||
onApply = () => {},
|
onApply = () => {},
|
||||||
|
onExternalDiff = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isGerman = $derived(language === "de");
|
||||||
|
|
||||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||||
let patchScroll = $state<HTMLDivElement | null>(null);
|
let patchScroll = $state<HTMLDivElement | null>(null);
|
||||||
let selectedLineIds = $state<Set<string>>(new Set());
|
let selectedLineIds = $state<Set<string>>(new Set());
|
||||||
@@ -270,6 +278,15 @@
|
|||||||
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-header-actions">
|
<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" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
|
||||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||||
<X size={16} aria-hidden="true" />
|
<X size={16} aria-hidden="true" />
|
||||||
|
|||||||
@@ -18,14 +18,29 @@
|
|||||||
|
|
||||||
let name = $state("");
|
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(() => {
|
$effect(() => {
|
||||||
name = branch.name;
|
name = originalName;
|
||||||
});
|
});
|
||||||
|
|
||||||
function submit(event: SubmitEvent) {
|
function submit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const value = name.trim();
|
const value = name.trim();
|
||||||
if (!value || value === branch.name) return;
|
if (!value || value === originalName) return;
|
||||||
onRename(value);
|
onRename(value);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -34,10 +49,10 @@
|
|||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
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">
|
<header class="dialog-header">
|
||||||
<div>
|
<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>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||||
@@ -48,6 +63,8 @@
|
|||||||
<form class="rename-branch-form" onsubmit={submit}>
|
<form class="rename-branch-form" onsubmit={submit}>
|
||||||
<label class="new-branch-field">
|
<label class="new-branch-field">
|
||||||
<span>Branch name</span>
|
<span>Branch name</span>
|
||||||
|
<div class:remote-branch-name-field={branch.remote}>
|
||||||
|
{#if branch.remote}<strong>{remote}/</strong>{/if}
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
@@ -56,19 +73,27 @@
|
|||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</label>
|
</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">
|
<div class="new-branch-actions">
|
||||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</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}
|
{#if isBusy}
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<GitBranch size={16} aria-hidden="true" />
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
Rename
|
{branch.remote ? "Rename on remote" : "Rename"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
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";
|
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
|
||||||
|
|
||||||
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
|
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
|
||||||
@@ -24,6 +24,9 @@
|
|||||||
onSelectFile: (path: string) => void;
|
onSelectFile: (path: string) => void;
|
||||||
onMarkResolved: (path: string, resolution: PreparedResolution) => void;
|
onMarkResolved: (path: string, resolution: PreparedResolution) => void;
|
||||||
onApply: () => void;
|
onApply: () => void;
|
||||||
|
onExternalMerge: (path: string) => void | Promise<void>;
|
||||||
|
language?: "en" | "de";
|
||||||
|
mergeName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -37,7 +40,11 @@
|
|||||||
onSelectFile = () => {},
|
onSelectFile = () => {},
|
||||||
onMarkResolved = () => {},
|
onMarkResolved = () => {},
|
||||||
onApply = () => {},
|
onApply = () => {},
|
||||||
|
onExternalMerge = () => {},
|
||||||
|
language = "en",
|
||||||
|
mergeName = "merge tool",
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
const isGerman = $derived(language === "de");
|
||||||
|
|
||||||
let conflictParts = $derived<ConflictPart[]>(
|
let conflictParts = $derived<ConflictPart[]>(
|
||||||
conflict && !conflict.binary ? parseConflicts(conflict.content) : [],
|
conflict && !conflict.binary ? parseConflicts(conflict.content) : [],
|
||||||
@@ -268,9 +275,22 @@
|
|||||||
<span class="eyebrow">Resolve</span>
|
<span class="eyebrow">Resolve</span>
|
||||||
<h2 class="dialog-title">Conflicts</h2>
|
<h2 class="dialog-title">Conflicts</h2>
|
||||||
</div>
|
</div>
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
<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" />
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if conflictedFiles.length === 0}
|
{#if conflictedFiles.length === 0}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import { Check, ChevronDown } from "@lucide/svelte";
|
||||||
|
|
||||||
|
export interface SelectMenuOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
group?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
options: SelectMenuOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
class?: string;
|
||||||
|
showSelectedGroup?: boolean;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value,
|
||||||
|
options = [],
|
||||||
|
placeholder = "Select an option",
|
||||||
|
disabled = false,
|
||||||
|
ariaLabel = "",
|
||||||
|
class: className = "",
|
||||||
|
showSelectedGroup = false,
|
||||||
|
onChange,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let root = $state<HTMLDivElement>();
|
||||||
|
let trigger = $state<HTMLButtonElement>();
|
||||||
|
let open = $state(false);
|
||||||
|
let activeIndex = $state(-1);
|
||||||
|
let menuStyle = $state("");
|
||||||
|
const menuId = `select-menu-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
let selectedOption = $derived(options.find((option) => option.value === value));
|
||||||
|
let enabledIndices = $derived(options.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0));
|
||||||
|
|
||||||
|
function groupCount(group: string): number {
|
||||||
|
return options.filter((option) => option.group === group).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionMenu() {
|
||||||
|
if (!trigger) return;
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
const viewportGap = 8;
|
||||||
|
const menuGap = 5;
|
||||||
|
const desiredHeight = Math.min(300, options.length * 34 + 24);
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
|
||||||
|
const spaceAbove = rect.top - viewportGap;
|
||||||
|
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
|
||||||
|
const maxHeight = Math.max(96, Math.min(desiredHeight, openAbove ? spaceAbove - menuGap : spaceBelow - menuGap));
|
||||||
|
const width = Math.max(rect.width, 180);
|
||||||
|
const left = Math.min(rect.left, window.innerWidth - width - viewportGap);
|
||||||
|
const top = openAbove ? rect.top - menuGap : rect.bottom + menuGap;
|
||||||
|
menuStyle = `left:${Math.max(viewportGap, left)}px;${openAbove ? `bottom:${window.innerHeight - top}px;` : `top:${top}px;`}min-width:${width}px;max-width:${Math.max(180, window.innerWidth - viewportGap * 2)}px;max-height:${maxHeight}px;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show() {
|
||||||
|
if (disabled || enabledIndices.length === 0) return;
|
||||||
|
const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled);
|
||||||
|
activeIndex = selectedIndex >= 0 ? selectedIndex : enabledIndices[0];
|
||||||
|
open = true;
|
||||||
|
await tick();
|
||||||
|
positionMenu();
|
||||||
|
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(index: number) {
|
||||||
|
const option = options[index];
|
||||||
|
if (!option || option.disabled) return;
|
||||||
|
onChange(option.value);
|
||||||
|
close();
|
||||||
|
trigger?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveActive(direction: -1 | 1) {
|
||||||
|
if (enabledIndices.length === 0) return;
|
||||||
|
const current = enabledIndices.indexOf(activeIndex);
|
||||||
|
const next = current < 0
|
||||||
|
? (direction > 0 ? 0 : enabledIndices.length - 1)
|
||||||
|
: (current + direction + enabledIndices.length) % enabledIndices.length;
|
||||||
|
activeIndex = enabledIndices[next];
|
||||||
|
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (disabled) return;
|
||||||
|
if (!open && ["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
void show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) return;
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
moveActive(event.key === "ArrowDown" ? 1 : -1);
|
||||||
|
} else if (event.key === "Home" || event.key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex = event.key === "Home" ? enabledIndices[0] : enabledIndices[enabledIndices.length - 1];
|
||||||
|
} else if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
choose(activeIndex);
|
||||||
|
} else if (event.key === "Escape" || event.key === "Tab") {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowPointerDown(event: PointerEvent) {
|
||||||
|
if (open && root && !root.contains(event.target as Node)) close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onpointerdown={handleWindowPointerDown} onresize={positionMenu} onscroll={positionMenu} />
|
||||||
|
|
||||||
|
<div class={`select-menu ${className}`} class:open bind:this={root}>
|
||||||
|
<button
|
||||||
|
bind:this={trigger}
|
||||||
|
class="select-menu-trigger"
|
||||||
|
type="button"
|
||||||
|
{disabled}
|
||||||
|
aria-label={ariaLabel || undefined}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={open ? menuId : undefined}
|
||||||
|
onclick={() => open ? close() : void show()}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
>
|
||||||
|
<span class="select-menu-value" class:placeholder={!selectedOption} title={selectedOption?.label}>
|
||||||
|
{#if showSelectedGroup && selectedOption?.group}<small>{selectedOption.group}</small>{/if}
|
||||||
|
<span>{selectedOption?.label ?? placeholder}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div id={menuId} class="select-menu-popup" style={menuStyle} role="listbox" aria-label={ariaLabel || undefined}>
|
||||||
|
{#each options as option, index (`${option.value}:${index}`)}
|
||||||
|
{#if option.group && (index === 0 || options[index - 1]?.group !== option.group)}
|
||||||
|
<div class="select-menu-group" role="presentation">
|
||||||
|
<span>{option.group}</span>
|
||||||
|
<small>{groupCount(option.group)}</small>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
id={`${menuId}-option-${index}`}
|
||||||
|
class="select-menu-option"
|
||||||
|
class:active={index === activeIndex}
|
||||||
|
class:selected={option.value === value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={option.value === value}
|
||||||
|
disabled={option.disabled}
|
||||||
|
onmouseenter={() => { if (!option.disabled) activeIndex = index; }}
|
||||||
|
onclick={() => choose(index)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{#if option.value === value}<Check size={14} aria-hidden="true" />{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
|
import {
|
||||||
|
Archive,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
FileDiff,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
FolderTree,
|
||||||
|
RotateCcw,
|
||||||
|
} from "@lucide/svelte";
|
||||||
import iconUrl from "../../../src-tauri/icons/icon.png";
|
import iconUrl from "../../../src-tauri/icons/icon.png";
|
||||||
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
||||||
|
|
||||||
@@ -17,11 +26,41 @@
|
|||||||
onUnstage: (files: GitFileStatus[]) => void;
|
onUnstage: (files: GitFileStatus[]) => void;
|
||||||
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
|
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
|
||||||
onDiscardMany: (files: GitFileStatus[]) => void;
|
onDiscardMany: (files: GitFileStatus[]) => void;
|
||||||
|
onStash: (files: GitFileStatus[], label: string) => void;
|
||||||
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
||||||
onStageAll: () => void;
|
onStageAll: () => void;
|
||||||
onUnstageAll: () => void;
|
onUnstageAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StatusViewMode = "list" | "tree";
|
||||||
|
type StatusLaneKind = "unstaged" | "staged";
|
||||||
|
|
||||||
|
interface StatusFolderNode {
|
||||||
|
kind: "folder";
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
depth: number;
|
||||||
|
fileCount: number;
|
||||||
|
children: StatusTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusFileNode {
|
||||||
|
kind: "file";
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
depth: number;
|
||||||
|
file: GitFileStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusTreeNode = StatusFolderNode | StatusFileNode;
|
||||||
|
|
||||||
|
interface StatusContextTarget {
|
||||||
|
lane: StatusLaneKind;
|
||||||
|
kind: "file" | "folder";
|
||||||
|
label: string;
|
||||||
|
files: GitFileStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
changedFiles = [],
|
changedFiles = [],
|
||||||
stagedCount = 0,
|
stagedCount = 0,
|
||||||
@@ -36,6 +75,7 @@
|
|||||||
onUnstage = () => {},
|
onUnstage = () => {},
|
||||||
onDiscard = () => {},
|
onDiscard = () => {},
|
||||||
onDiscardMany = () => {},
|
onDiscardMany = () => {},
|
||||||
|
onStash = () => {},
|
||||||
onPatch = () => {},
|
onPatch = () => {},
|
||||||
onStageAll = () => {},
|
onStageAll = () => {},
|
||||||
onUnstageAll = () => {},
|
onUnstageAll = () => {},
|
||||||
@@ -65,8 +105,156 @@
|
|||||||
return `${file.old_path ?? ""}:${file.path}`;
|
return `${file.old_path ?? ""}:${file.path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortStatusTree(nodes: StatusTreeNode[]) {
|
||||||
|
nodes.sort((a, b) => {
|
||||||
|
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.kind === "folder") sortStatusTree(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStatusTree(files: GitFileStatus[]): StatusTreeNode[] {
|
||||||
|
const roots: StatusTreeNode[] = [];
|
||||||
|
const folders = new Map<string, StatusFolderNode>();
|
||||||
|
|
||||||
|
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
||||||
|
const parts = file.path.split(/[\\/]+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) continue;
|
||||||
|
|
||||||
|
let currentPath = "";
|
||||||
|
let siblings = roots;
|
||||||
|
const ancestors: StatusFolderNode[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < parts.length - 1; index++) {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${parts[index]}` : parts[index];
|
||||||
|
let folder = folders.get(currentPath);
|
||||||
|
if (!folder) {
|
||||||
|
folder = {
|
||||||
|
kind: "folder",
|
||||||
|
name: parts[index],
|
||||||
|
path: currentPath,
|
||||||
|
depth: index,
|
||||||
|
fileCount: 0,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
folders.set(currentPath, folder);
|
||||||
|
siblings.push(folder);
|
||||||
|
}
|
||||||
|
ancestors.push(folder);
|
||||||
|
siblings = folder.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
siblings.push({
|
||||||
|
kind: "file",
|
||||||
|
name: parts[parts.length - 1] ?? file.path,
|
||||||
|
path: file.path,
|
||||||
|
depth: Math.max(parts.length - 1, 0),
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
for (const folder of ancestors) folder.fileCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
sortStatusTree(roots);
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenStatusTree(nodes: StatusTreeNode[], lane: StatusLaneKind): StatusTreeNode[] {
|
||||||
|
const visible: StatusTreeNode[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
visible.push(node);
|
||||||
|
if (node.kind === "folder" && !collapsedStatusFolders.has(`${lane}:${node.path}`)) {
|
||||||
|
visible.push(...flattenStatusTree(node.children, lane));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listStatusRows(files: GitFileStatus[]): StatusFileNode[] {
|
||||||
|
return files.map((file) => ({
|
||||||
|
kind: "file",
|
||||||
|
name: fileName(file),
|
||||||
|
path: file.path,
|
||||||
|
depth: 0,
|
||||||
|
file,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
let selectedStatusPaths = $state<Set<string>>(new Set());
|
let selectedStatusPaths = $state<Set<string>>(new Set());
|
||||||
let selectionAnchorKey = $state("");
|
let selectionAnchorKey = $state("");
|
||||||
|
let statusView = $state<StatusViewMode>("list");
|
||||||
|
let collapsedStatusFolders = $state<Set<string>>(new Set());
|
||||||
|
let statusContextTarget = $state<StatusContextTarget | null>(null);
|
||||||
|
let statusContextMenuX = $state(0);
|
||||||
|
let statusContextMenuY = $state(0);
|
||||||
|
|
||||||
|
function toggleStatusFolder(lane: StatusLaneKind, path: string) {
|
||||||
|
const next = new Set(collapsedStatusFolders);
|
||||||
|
const key = `${lane}:${path}`;
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
collapsedStatusFolders = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filesInStatusFolder(lane: StatusLaneKind, path: string): GitFileStatus[] {
|
||||||
|
const prefix = `${path.replace(/\\/g, "/").replace(/\/$/, "")}/`;
|
||||||
|
const files = lane === "unstaged" ? unstagedFiles : stagedFiles;
|
||||||
|
return files.filter((file) => {
|
||||||
|
const currentPath = file.path.replace(/\\/g, "/");
|
||||||
|
const oldPath = file.old_path?.replace(/\\/g, "/") ?? "";
|
||||||
|
return currentPath.startsWith(prefix) || oldPath.startsWith(prefix);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStatusContextMenu(
|
||||||
|
event: MouseEvent,
|
||||||
|
lane: StatusLaneKind,
|
||||||
|
kind: "file" | "folder",
|
||||||
|
label: string,
|
||||||
|
files: GitFileStatus[],
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (files.length === 0) return;
|
||||||
|
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288));
|
||||||
|
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174));
|
||||||
|
statusContextTarget = { lane, kind, label, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusContextName(label: string): string {
|
||||||
|
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
|
return normalized.split("/").pop() || label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusContextParent(label: string): string {
|
||||||
|
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
|
const separator = normalized.lastIndexOf("/");
|
||||||
|
return separator > 0 ? normalized.slice(0, separator) : "Repository root";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeStatusContextMenu() {
|
||||||
|
statusContextTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runStatusContextStageAction() {
|
||||||
|
const target = statusContextTarget;
|
||||||
|
if (!target) return;
|
||||||
|
closeStatusContextMenu();
|
||||||
|
if (target.lane === "unstaged") onStage(target.files);
|
||||||
|
else onUnstage(target.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runStatusContextStashAction() {
|
||||||
|
const target = statusContextTarget;
|
||||||
|
if (!target) return;
|
||||||
|
closeStatusContextMenu();
|
||||||
|
onStash(target.files, target.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") closeStatusContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
function isStatusSelected(file: GitFileStatus): boolean {
|
function isStatusSelected(file: GitFileStatus): boolean {
|
||||||
return selectedStatusPaths.has(fileKey(file));
|
return selectedStatusPaths.has(fileKey(file));
|
||||||
@@ -144,6 +332,12 @@
|
|||||||
|
|
||||||
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
||||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== 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 unstagedTree = $derived(buildStatusTree(unstagedFiles));
|
||||||
|
let stagedTree = $derived(buildStatusTree(stagedFiles));
|
||||||
|
let visibleUnstagedRows = $derived(statusView === "tree" ? flattenStatusTree(unstagedTree, "unstaged") : listStatusRows(unstagedFiles));
|
||||||
|
let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles));
|
||||||
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
|
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);
|
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
|
||||||
|
|
||||||
@@ -155,12 +349,22 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={closeStatusContextMenu} onkeydown={handleStatusWindowKeydown} />
|
||||||
|
|
||||||
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
|
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Workspace</span>
|
<span class="eyebrow">Workspace</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="status-view-switch" role="group" aria-label="Changes view">
|
||||||
|
<button class:active={statusView === "list"} type="button" onclick={() => statusView = "list"} title="List view" aria-label="List view" aria-pressed={statusView === "list"}>
|
||||||
|
<i class="status-view-icon list-icon" aria-hidden="true"></i><span>List</span>
|
||||||
|
</button>
|
||||||
|
<button class:active={statusView === "tree"} type="button" onclick={() => statusView = "tree"} title="Tree view" aria-label="Tree view" aria-pressed={statusView === "tree"}>
|
||||||
|
<FolderTree size={13} aria-hidden="true" /><span>Tree</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="status-head-actions">
|
<div class="status-head-actions">
|
||||||
<span class="pill pill-count">{stagedCount} staged</span>
|
<span class="pill pill-count">{stagedCount} staged</span>
|
||||||
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
||||||
@@ -179,19 +383,22 @@
|
|||||||
{:else if changedFiles.length === 0}
|
{:else if changedFiles.length === 0}
|
||||||
<div class="blank-state">No file changes returned.</div>
|
<div class="blank-state">No file changes returned.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="status-lanes overflow-auto">
|
<div class="status-lanes">
|
||||||
<section class="status-lane" aria-label="Unstaged changes">
|
<section class="status-lane unstaged-lane" aria-label="Unstaged changes">
|
||||||
<header class="status-lane-head">
|
<header class="status-lane-head">
|
||||||
<div class="status-lane-title"><strong>Unstaged</strong><span>{unstagedCount}</span></div>
|
<div class="status-lane-title">
|
||||||
|
<div class="status-lane-copy"><strong>Unstaged</strong><small>Working tree</small></div>
|
||||||
|
<span class="status-lane-count">{unstagedCount}</span>
|
||||||
|
</div>
|
||||||
<div class="status-lane-actions">
|
<div class="status-lane-actions">
|
||||||
{#if selectedUnstagedCount > 1}
|
{#if selectedUnstagedCount > 1}
|
||||||
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
|
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
|
||||||
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={`Stage ${selectedUnstagedCount} selected files`}>
|
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={`Stage ${selectedUnstagedCount} selected files`}>
|
||||||
<Check size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
|
<ArrowRight size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
|
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
|
||||||
<Check size={13} aria-hidden="true" /> Stage all
|
<ArrowRight size={13} aria-hidden="true" /> Stage all
|
||||||
</button>
|
</button>
|
||||||
{#if selectedUnstagedCount > 1}
|
{#if selectedUnstagedCount > 1}
|
||||||
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={`Discard unstaged changes in ${selectedUnstagedCount} selected files`}>
|
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={`Discard unstaged changes in ${selectedUnstagedCount} selected files`}>
|
||||||
@@ -201,37 +408,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="status-file-list">
|
<div class="status-file-list">
|
||||||
{#each changedFiles.filter((file) => file.unstaged !== null) as file (`unstaged:${fileKey(file)}`)}
|
{#each visibleUnstagedRows as row (`unstaged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
|
||||||
|
{#if row.kind === "folder"}
|
||||||
|
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("unstaged", row.path)} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "folder", row.path, filesInStatusFolder("unstaged", row.path))} title={row.path} aria-expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)}>
|
||||||
|
<i class:expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
|
||||||
|
{#if collapsedStatusFolders.has(`unstaged:${row.path}`)}<Folder class="status-folder-lucide" size={15} aria-hidden="true" />{:else}<FolderOpen class="status-folder-lucide" size={15} aria-hidden="true" />{/if}
|
||||||
|
<strong>{row.name}</strong>
|
||||||
|
<span class="status-folder-count">{row.fileCount}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
{@const file = row.file}
|
||||||
{@const stageTargets = selectedStageTargets(file)}
|
{@const stageTargets = selectedStageTargets(file)}
|
||||||
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
|
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "file", file.path, [file])}>
|
||||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||||
<strong>{fileName(file)}</strong>
|
<strong>{fileName(file)}</strong>
|
||||||
<span>{displayPath(file)}</span>
|
<span>{displayPath(file)}</span>
|
||||||
</button>
|
</button>
|
||||||
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
||||||
<div class="status-file-actions">
|
<div class="status-file-actions">
|
||||||
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><Check size={14} aria-hidden="true" /></button>
|
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><ArrowRight size={14} aria-hidden="true" /></button>
|
||||||
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
|
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||||
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
|
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="status-lane" aria-label="Staged changes">
|
<div class="status-flow-divider" aria-hidden="true">
|
||||||
|
<span><ArrowRight size={12} /></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="status-lane staged-lane" aria-label="Staged changes">
|
||||||
<header class="status-lane-head">
|
<header class="status-lane-head">
|
||||||
<div class="status-lane-title"><strong>Staged</strong><span>{stagedCount}</span></div>
|
<div class="status-lane-title">
|
||||||
|
<div class="status-lane-copy"><strong>Staged</strong><small>Next commit</small></div>
|
||||||
|
<span class="status-lane-count">{stagedCount}</span>
|
||||||
|
</div>
|
||||||
<div class="status-lane-actions">
|
<div class="status-lane-actions">
|
||||||
{#if selectedStagedCount > 1}
|
{#if selectedStagedCount > 1}
|
||||||
<span class="status-selection-count">{selectedStagedCount} selected</span>
|
<span class="status-selection-count">{selectedStagedCount} selected</span>
|
||||||
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={`Unstage ${selectedStagedCount} selected files`}>
|
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={`Unstage ${selectedStagedCount} selected files`}>
|
||||||
<Undo2 size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
|
<ArrowLeft size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
|
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
|
||||||
<Undo2 size={13} aria-hidden="true" /> Unstage all
|
<ArrowLeft size={13} aria-hidden="true" /> Unstage all
|
||||||
</button>
|
</button>
|
||||||
{#if selectedStagedCount > 1}
|
{#if selectedStagedCount > 1}
|
||||||
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={`Discard staged changes in ${selectedStagedCount} selected files`}>
|
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={`Discard staged changes in ${selectedStagedCount} selected files`}>
|
||||||
@@ -241,20 +465,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="status-file-list">
|
<div class="status-file-list">
|
||||||
{#each changedFiles.filter((file) => file.staged !== null) as file (`staged:${fileKey(file)}`)}
|
{#each visibleStagedRows as row (`staged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
|
||||||
|
{#if row.kind === "folder"}
|
||||||
|
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("staged", row.path)} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "folder", row.path, filesInStatusFolder("staged", row.path))} title={row.path} aria-expanded={!collapsedStatusFolders.has(`staged:${row.path}`)}>
|
||||||
|
<i class:expanded={!collapsedStatusFolders.has(`staged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
|
||||||
|
{#if collapsedStatusFolders.has(`staged:${row.path}`)}<Folder class="status-folder-lucide" size={15} aria-hidden="true" />{:else}<FolderOpen class="status-folder-lucide" size={15} aria-hidden="true" />{/if}
|
||||||
|
<strong>{row.name}</strong>
|
||||||
|
<span class="status-folder-count">{row.fileCount}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
{@const file = row.file}
|
||||||
{@const unstageTargets = selectedUnstageTargets(file)}
|
{@const unstageTargets = selectedUnstageTargets(file)}
|
||||||
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
|
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "file", file.path, [file])}>
|
||||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||||
<strong>{fileName(file)}</strong>
|
<strong>{fileName(file)}</strong>
|
||||||
<span>{displayPath(file)}</span>
|
<span>{displayPath(file)}</span>
|
||||||
</button>
|
</button>
|
||||||
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
||||||
<div class="status-file-actions">
|
<div class="status-file-actions">
|
||||||
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><Undo2 size={14} aria-hidden="true" /></button>
|
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><ArrowLeft size={14} aria-hidden="true" /></button>
|
||||||
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
|
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||||
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
|
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -282,6 +516,40 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if statusContextTarget}
|
||||||
|
<div class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
|
||||||
|
<div class="status-context-label">
|
||||||
|
<span class="status-context-object-icon" aria-hidden="true">
|
||||||
|
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
|
||||||
|
</span>
|
||||||
|
<span class="status-context-object-copy">
|
||||||
|
<span class="status-context-kind">{statusContextTarget.lane === "unstaged" ? "Unstaged" : "Staged"} {statusContextTarget.kind}</span>
|
||||||
|
<strong title={statusContextTarget.label}>{statusContextName(statusContextTarget.label)}</strong>
|
||||||
|
<span class="status-context-path" title={statusContextTarget.label}><Folder size={10} aria-hidden="true" />{statusContextParent(statusContextTarget.label)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="status-context-count" title={`${statusContextTarget.files.length} ${statusContextTarget.files.length === 1 ? "file" : "files"}`}>
|
||||||
|
{statusContextTarget.files.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" role="menuitem" onclick={runStatusContextStageAction} disabled={isBusy}>
|
||||||
|
<span class="status-context-action-icon" aria-hidden="true">
|
||||||
|
{#if statusContextTarget.lane === "unstaged"}<ArrowRight size={15} />{:else}<ArrowLeft size={15} />{/if}
|
||||||
|
</span>
|
||||||
|
<span class="status-context-action-copy">
|
||||||
|
<strong>{statusContextTarget.lane === "unstaged" ? "Stage" : "Unstage"} {statusContextTarget.kind}</strong>
|
||||||
|
<span>{statusContextTarget.lane === "unstaged" ? "Add to the next commit" : "Move back to working changes"}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={runStatusContextStashAction} disabled={isBusy}>
|
||||||
|
<span class="status-context-action-icon" aria-hidden="true"><Archive size={15} /></span>
|
||||||
|
<span class="status-context-action-copy">
|
||||||
|
<strong>Stash {statusContextTarget.kind}</strong>
|
||||||
|
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.status-panel-overlay {
|
.status-panel-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Cloud, GitBranch, Plus, Save, Trash2, X } from "@lucide/svelte";
|
import { Cloud, GitBranch, Plus, Save, Trash2, X } from "@lucide/svelte";
|
||||||
import type { GitRemote, PullStrategy } from "../types";
|
import type { GitRemote, PullStrategy } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
export let remotes: GitRemote[] = [];
|
export let remotes: GitRemote[] = [];
|
||||||
export let remoteBranches: string[] = [];
|
export let remoteBranches: string[] = [];
|
||||||
@@ -76,8 +77,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sync-fields">
|
<div class="sync-fields">
|
||||||
<label><span>{de ? "Remote für Sync" : "Remote used for sync"}</span><select bind:value={draftRemote}><option value="">{de ? "Automatisch wählen" : "Choose automatically"}</option>{#each remotes as remote}<option value={remote.name}>{remote.name}</option>{/each}</select><small>{de ? "Ein Remote ist die gespeicherte Verbindung zu einem Server-Repository." : "A remote is a saved connection to a repository on a server."}</small></label>
|
<label><span>{de ? "Remote für Sync" : "Remote used for sync"}</span><SelectMenu value={draftRemote} options={[{ value: "", label: de ? "Automatisch wählen" : "Choose automatically" }, ...remotes.map((remote) => ({ value: remote.name, label: remote.name }))]} onChange={(value) => { draftRemote = value; }} /><small>{de ? "Ein Remote ist die gespeicherte Verbindung zu einem Server-Repository." : "A remote is a saved connection to a repository on a server."}</small></label>
|
||||||
<label><span>{de ? `Upstream für ${currentBranch || "aktuellen Branch"}` : `Upstream for ${currentBranch || "current branch"}`}</span><select bind:value={draftUpstream}><option value="">{de ? "Kein Upstream" : "No upstream"}</option>{#each remoteBranches as branch}<option value={branch}>{branch}</option>{/each}</select><small>{de ? "Der Upstream ist der Remote-Branch, mit dem Pull, Push und Ahead/Behind verglichen werden." : "The upstream is the remote branch used by Pull, Push, and Ahead/Behind."}</small></label>
|
<label><span>{de ? `Upstream für ${currentBranch || "aktuellen Branch"}` : `Upstream for ${currentBranch || "current branch"}`}</span><SelectMenu value={draftUpstream} options={[{ value: "", label: de ? "Kein Upstream" : "No upstream" }, ...remoteBranches.map((branch) => ({ value: branch, label: branch }))]} onChange={(value) => { draftUpstream = value; }} /><small>{de ? "Der Upstream ist der Remote-Branch, mit dem Pull, Push und Ahead/Behind verglichen werden." : "The upstream is the remote branch used by Pull, Push, and Ahead/Behind."}</small></label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
X,
|
X,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import type { GitBranch as GitBranchInfo, GitWorktree } from "../types";
|
import type { GitBranch as GitBranchInfo, GitWorktree } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
type CreateMode = "existing" | "new" | "detached";
|
type CreateMode = "existing" | "new" | "detached";
|
||||||
|
|
||||||
@@ -260,12 +261,17 @@
|
|||||||
{#if createMode === "existing"}
|
{#if createMode === "existing"}
|
||||||
<label>
|
<label>
|
||||||
<span>Branch</span>
|
<span>Branch</span>
|
||||||
<select bind:value={selectedBranch} disabled={isBusy}>
|
<SelectMenu
|
||||||
<option value="" disabled>Select a local branch</option>
|
value={selectedBranch}
|
||||||
{#each localBranches as branch (branch.name)}
|
placeholder="Select a local branch"
|
||||||
<option value={branch.name} disabled={!branchAvailable(branch.name)}>{branch.name}{!branchAvailable(branch.name) ? " (already checked out)" : ""}</option>
|
options={localBranches.map((branch) => ({
|
||||||
{/each}
|
value: branch.name,
|
||||||
</select>
|
label: `${branch.name}${!branchAvailable(branch.name) ? " (already checked out)" : ""}`,
|
||||||
|
disabled: !branchAvailable(branch.name),
|
||||||
|
}))}
|
||||||
|
disabled={isBusy}
|
||||||
|
onChange={(value) => { selectedBranch = value; }}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
{:else if createMode === "new"}
|
{:else if createMode === "new"}
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { StoredCredential } from "./types";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives a credential key from a remote URL, scoped to host + organisation —
|
* Derives a credential key from a remote URL, scoped to host + organisation —
|
||||||
* the same granularity Azure DevOps / GitHub use. Examples:
|
* the same granularity Azure DevOps / GitHub use. Examples:
|
||||||
@@ -37,13 +35,6 @@ export function orgKeyFromUrl(raw: string): string | null {
|
|||||||
return org ? `${host}/${org}` : host;
|
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:";
|
const AUTH_PREFIX = "AUTH_FAILED:";
|
||||||
|
|
||||||
export function isAuthError(message: string): boolean {
|
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;
|
||||||
|
}
|
||||||
+122
-12
@@ -2,14 +2,19 @@ import { tracedInvoke as invoke } from "./telemetry";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
AiReviewResult,
|
AiReviewResult,
|
||||||
|
AiCommitPlan,
|
||||||
CommitAiLocalProfile,
|
CommitAiLocalProfile,
|
||||||
CommitAiProvider,
|
CommitAiProvider,
|
||||||
CommitAiStatus,
|
CommitAiStatus,
|
||||||
ConflictFile,
|
ConflictFile,
|
||||||
|
DetectedExternalTool,
|
||||||
|
ExternalToolCommand,
|
||||||
|
ExternalDiffScope,
|
||||||
GitBlameResult,
|
GitBlameResult,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
GitCommitComparison,
|
GitCommitComparison,
|
||||||
|
GitLfsStatus,
|
||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitRemote,
|
GitRemote,
|
||||||
MergeStrategy,
|
MergeStrategy,
|
||||||
@@ -44,6 +49,22 @@ export function openRepositoryFile(path: string, file: string): Promise<void> {
|
|||||||
return invoke<void>("open_repository_file", { path, file });
|
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> {
|
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
|
||||||
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
||||||
}
|
}
|
||||||
@@ -70,6 +91,35 @@ export function getStatus(path: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("get_status", { path });
|
return invoke<GitStatus>("get_status", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGitLfsStatus(path: string): Promise<GitLfsStatus> {
|
||||||
|
return invoke<GitLfsStatus>("git_lfs_status", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installGitLfs(path: string): Promise<GitLfsStatus> {
|
||||||
|
return invoke<GitLfsStatus>("git_lfs_install", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackGitLfsPattern(path: string, pattern: string, lockable = false): Promise<GitLfsStatus> {
|
||||||
|
return invoke<GitLfsStatus>("git_lfs_track", { path, pattern, lockable });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function untrackGitLfsPattern(path: string, pattern: string): Promise<GitLfsStatus> {
|
||||||
|
return invoke<GitLfsStatus>("git_lfs_untrack", { path, pattern });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pullGitLfsObjects(path: string, remote?: string, username?: string, password?: string): Promise<GitLfsStatus> {
|
||||||
|
return invoke<GitLfsStatus>("git_lfs_pull", {
|
||||||
|
path,
|
||||||
|
remote: remote || null,
|
||||||
|
username: username ?? null,
|
||||||
|
password: password ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneGitLfsObjects(path: string): Promise<GitLfsStatus> {
|
||||||
|
return invoke<GitLfsStatus>("git_lfs_prune", { path });
|
||||||
|
}
|
||||||
|
|
||||||
// Sets the taskbar icon badge to ahead + behind + changed status files (0 clears it). Windows only — a no-op on
|
// Sets the taskbar icon badge to ahead + behind + changed status files (0 clears it). Windows only — a no-op on
|
||||||
// other platforms, since Windows has no native numeric badge to fall back to.
|
// other platforms, since Windows has no native numeric badge to fall back to.
|
||||||
export function setSyncBadge(ahead: number, behind: number, changes: number): Promise<void> {
|
export function setSyncBadge(ahead: number, behind: number, changes: number): Promise<void> {
|
||||||
@@ -87,7 +137,12 @@ export function removeRemote(path: string, name: string): Promise<GitRemote[]> {
|
|||||||
console.log("remove_remote")
|
console.log("remove_remote")
|
||||||
return invoke("remove_remote", { path, name }); }
|
return invoke("remove_remote", { path, name }); }
|
||||||
export function setBranchUpstream(path: string, branch: string, upstream?: string): Promise<GitStatus> { return invoke("set_branch_upstream", { path, branch, upstream: upstream || null }); }
|
export function setBranchUpstream(path: string, branch: string, upstream?: string): Promise<GitStatus> { return invoke("set_branch_upstream", { path, branch, upstream: upstream || null }); }
|
||||||
export function deleteRemoteBranch(path: string, remote: string, branch: string): Promise<GitStatus> { return invoke("delete_remote_branch", { path, remote, branch }); }
|
export function deleteRemoteBranch(path: string, remote: string, branch: string, username?: string, password?: string): Promise<GitStatus> {
|
||||||
|
return invoke("delete_remote_branch", { path, remote, branch, username: username ?? null, password: password ?? null });
|
||||||
|
}
|
||||||
|
export function deleteRemoteBranches(path: string, remote: string, branches: string[], username?: string, password?: string): Promise<GitStatus> {
|
||||||
|
return invoke("delete_remote_branches", { path, remote, branches, username: username ?? null, password: password ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
export function listStashes(path: string): Promise<GitStash[]> {
|
export function listStashes(path: string): Promise<GitStash[]> {
|
||||||
return invoke<GitStash[]>("list_stashes", { path });
|
return invoke<GitStash[]>("list_stashes", { path });
|
||||||
@@ -113,6 +168,24 @@ export function renameBranch(
|
|||||||
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
|
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renameRemoteBranch(
|
||||||
|
path: string,
|
||||||
|
remote: string,
|
||||||
|
oldBranch: string,
|
||||||
|
newBranch: string,
|
||||||
|
username?: string,
|
||||||
|
password?: string,
|
||||||
|
): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("rename_remote_branch", {
|
||||||
|
path,
|
||||||
|
remote,
|
||||||
|
oldBranch,
|
||||||
|
newBranch,
|
||||||
|
username: username ?? null,
|
||||||
|
password: password ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
|
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
||||||
}
|
}
|
||||||
@@ -259,11 +332,13 @@ export function stashPush(
|
|||||||
path: string,
|
path: string,
|
||||||
message?: string,
|
message?: string,
|
||||||
includeUntracked = true,
|
includeUntracked = true,
|
||||||
|
paths?: string[],
|
||||||
): Promise<GitStatus> {
|
): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("stash_push", {
|
return invoke<GitStatus>("stash_push", {
|
||||||
path,
|
path,
|
||||||
message: message?.trim() ? message.trim() : null,
|
message: message?.trim() ? message.trim() : null,
|
||||||
includeUntracked,
|
includeUntracked,
|
||||||
|
paths: paths?.length ? paths : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +397,16 @@ export function commitAiReview(path: string, options: CommitAiGenerateOptions):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function commitAiSplit(path: string, options: CommitAiGenerateOptions): Promise<AiCommitPlan> {
|
||||||
|
return invoke<AiCommitPlan>("commit_ai_split", {
|
||||||
|
path,
|
||||||
|
provider: options.provider,
|
||||||
|
model: options.model,
|
||||||
|
apiKey: options.apiKey,
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise<GitStatus> {
|
export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null });
|
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null });
|
||||||
}
|
}
|
||||||
@@ -334,29 +419,54 @@ export function push(path: string, username?: string, password?: string, forceWi
|
|||||||
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null });
|
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRemoteUrl(path: string): Promise<string | null> {
|
export function getRemoteUrl(path: string, remote?: string, push = false): Promise<string | null> {
|
||||||
return invoke<string | null>("get_remote_url", { path });
|
return invoke<string | null>("get_remote_url", { path, remote: remote || null, push });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function credLoad(key: string): Promise<StoredCredential | null> {
|
export function credLoad(key: string): Promise<StoredCredential | null> {
|
||||||
return invoke<StoredCredential | null>("cred_load", { key });
|
return invoke<StoredCredential | null>("cred_load", { key });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function credSave(
|
export function credSave(key: string, username: string, password: string, mode: "credentials" | "token" = "credentials"): Promise<void> {
|
||||||
key: string,
|
return invoke<void>("cred_save", { key, username, password, mode });
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
expiresAt: string | null,
|
|
||||||
): Promise<void> {
|
|
||||||
return invoke<void>("cred_save", { key, username, password, expiresAt });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function credDelete(key: string): Promise<void> {
|
export function credDelete(key: string): Promise<void> {
|
||||||
return invoke<void>("cred_delete", { key });
|
return invoke<void>("cred_delete", { key });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listCommits(path: string, limit = 100): Promise<GitCommit[]> {
|
export function listCommits(path: string, limit = 100, skip = 0): Promise<GitCommit[]> {
|
||||||
return invoke<GitCommit[]>("list_commits", { path, limit });
|
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> {
|
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ type TelemetryLevel = "info" | "warn" | "error";
|
|||||||
const MAX_MESSAGE_LENGTH = 2_048;
|
const MAX_MESSAGE_LENGTH = 2_048;
|
||||||
let telemetryEnabled = false;
|
let telemetryEnabled = false;
|
||||||
let configuration: Promise<unknown> = Promise.resolve();
|
let configuration: Promise<unknown> = Promise.resolve();
|
||||||
|
let frontendShuttingDown = false;
|
||||||
|
|
||||||
|
export function beginFrontendShutdown() {
|
||||||
|
frontendShuttingDown = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Telemetry must never contain repository contents or identifying local data.
|
// Telemetry must never contain repository contents or identifying local data.
|
||||||
function sanitize(message: string): string {
|
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> {
|
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);
|
if (!telemetryEnabled) return invoke<T>(command, args);
|
||||||
const startedAtMs = Date.now();
|
const startedAtMs = Date.now();
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
|
|||||||
+72
-1
@@ -37,6 +37,17 @@ export interface AiReviewResult {
|
|||||||
findings: AiReviewFinding[];
|
findings: AiReviewFinding[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AiCommitGroup {
|
||||||
|
message: string;
|
||||||
|
reason: string;
|
||||||
|
files: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiCommitPlan {
|
||||||
|
summary: string;
|
||||||
|
groups: AiCommitGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalModelOption {
|
export interface LocalModelOption {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -58,6 +69,36 @@ export interface AnalyticsSettings {
|
|||||||
noticeSeen: boolean;
|
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 {
|
export interface GitStatus {
|
||||||
repo_path: string;
|
repo_path: string;
|
||||||
current_branch: string | null;
|
current_branch: string | null;
|
||||||
@@ -88,6 +129,7 @@ export interface GitBranch {
|
|||||||
name: string;
|
name: string;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
remote: boolean;
|
remote: boolean;
|
||||||
|
upstream: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitWorktree {
|
export interface GitWorktree {
|
||||||
@@ -136,6 +178,7 @@ export interface GitCommit {
|
|||||||
refs: string[];
|
refs: string[];
|
||||||
parents: string[];
|
parents: string[];
|
||||||
files: GitCommitFile[];
|
files: GitCommitFile[];
|
||||||
|
has_note: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitCommitFile {
|
export interface GitCommitFile {
|
||||||
@@ -150,6 +193,34 @@ export interface GitRepositoryFile {
|
|||||||
status: FileStatusKind | null;
|
status: FileStatusKind | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitLfsPattern {
|
||||||
|
pattern: string;
|
||||||
|
source: string;
|
||||||
|
lockable: boolean;
|
||||||
|
tracked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitLfsFile {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
checkout: boolean;
|
||||||
|
downloaded: boolean;
|
||||||
|
oid_type: string;
|
||||||
|
oid: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitLfsStatus {
|
||||||
|
available: boolean;
|
||||||
|
bundled: boolean;
|
||||||
|
version: string | null;
|
||||||
|
filters_configured: boolean;
|
||||||
|
hook_installed: boolean;
|
||||||
|
repository_uses_lfs: boolean;
|
||||||
|
patterns: GitLfsPattern[];
|
||||||
|
files: GitLfsFile[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RepositoryBundle {
|
export interface RepositoryBundle {
|
||||||
status: GitStatus;
|
status: GitStatus;
|
||||||
branches: GitBranch[];
|
branches: GitBranch[];
|
||||||
@@ -268,5 +339,5 @@ export interface ReflogEntry {
|
|||||||
export interface StoredCredential {
|
export interface StoredCredential {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
expiresAt?: string | null;
|
mode?: "credentials" | "token";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user