Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
bc3d3e88e0 | ||
|
|
1721341e62 | ||
|
|
eb99165931 | ||
|
|
24201c1302 | ||
|
|
38b7f1a536 | ||
|
|
afa85b97aa | ||
|
|
7869efeab5 | ||
|
|
2b88e5a189 | ||
|
|
f7793bc908 | ||
|
|
a1325d85e8 | ||
|
|
e7e313b7d7 | ||
|
|
482d7857b5 | ||
|
|
8220e36629 | ||
|
|
27c8b40dc6 | ||
|
|
6aed0b933e | ||
|
|
94083a7106 | ||
|
|
647f2b2076 | ||
|
|
cae8390b54 | ||
|
|
5c9f67700b | ||
|
|
2bd60f0e5b | ||
|
|
95c8b01ed1 | ||
|
|
7800f0fb24 | ||
|
|
35310bec6d | ||
|
|
7f85eb7d98 | ||
|
|
c2f55d96db | ||
|
|
0dd5441754 | ||
|
|
82c47c8d03 | ||
|
|
bad1263dcf | ||
|
|
cc805e04bf | ||
|
|
735acd2551 | ||
|
|
f9c0c00618 | ||
|
|
ff00925b13 | ||
|
|
982dbf136d | ||
|
|
646dcc341e | ||
|
|
5f3e55dcd7 | ||
|
|
fdbff8175e | ||
|
|
a6e7e991dd | ||
|
|
3491b8efb3 | ||
|
|
59a8f34e0e | ||
|
|
286b106baa | ||
|
|
670e7e24fe | ||
|
|
6729d61dca | ||
|
|
b181b384e7 | ||
|
|
c174fb3a80 | ||
|
|
c747e02f29 | ||
|
|
791275f341 | ||
|
|
f7b8beaad4 | ||
|
|
d0bca62362 | ||
|
|
d3cde83518 | ||
|
|
3fdd6d3467 | ||
|
|
900a6fa230 | ||
|
|
2d7946f6fd | ||
|
|
0af57f1818 | ||
|
|
8e6811b786 | ||
|
|
de8832a919 | ||
|
|
25b98d7c4d | ||
|
|
7ef1a2dac8 | ||
|
|
44a5b776d7 | ||
|
|
55b4c9fc50 |
@@ -100,7 +100,40 @@
|
|||||||
"Bash(pkg-config --exists openssl)",
|
"Bash(pkg-config --exists openssl)",
|
||||||
"Bash(sudo apt-get install -y libssl-dev pkg-config)",
|
"Bash(sudo apt-get install -y libssl-dev pkg-config)",
|
||||||
"Bash(dpkg -L libssl3t64)",
|
"Bash(dpkg -L libssl3t64)",
|
||||||
"Bash(grep *)"
|
"Bash(grep *)",
|
||||||
|
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/**)",
|
||||||
|
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-runtime-wry-2.11.4/src/**)",
|
||||||
|
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/window/**)",
|
||||||
|
"WebSearch",
|
||||||
|
"Bash(node -p \"require\\('./package.json'\\).version\")",
|
||||||
|
"Bash(bash -n PKGBUILD)",
|
||||||
|
"Bash(makepkg --printsrcinfo)",
|
||||||
|
"Bash(npx tauri *)",
|
||||||
|
"Bash(echo \"---exit code:$?---\")",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite status --short)",
|
||||||
|
"Bash(makepkg -f --noconfirm)",
|
||||||
|
"Bash(tar -tf gitty-2026.7.19-1-x86_64.pkg.tar.zst)",
|
||||||
|
"Bash(cp /mnt/data/Development/GitLite/PKGBUILD .)",
|
||||||
|
"Bash(sed -i 's/^pkgrel=1/pkgrel=3/' PKGBUILD)",
|
||||||
|
"Bash(node -e \"const fs=require\\('fs'\\); const version='2026.8.1'; const pkgbuild=fs.readFileSync\\('PKGBUILD','utf8'\\).replace\\(/^pkgver=.*\\\\$/m, 'pkgver='+version\\).replace\\(/^pkgrel=.*\\\\$/m, 'pkgrel=1'\\); fs.writeFileSync\\('PKGBUILD', pkgbuild\\);\")",
|
||||||
|
"Bash(rm PKGBUILD)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite stash)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite stash pop)",
|
||||||
|
"Bash(command -v docker)",
|
||||||
|
"Read(//home/christoph/.docker/**)",
|
||||||
|
"Bash(docker info *)",
|
||||||
|
"Bash(sudo -n test -f /root/.docker/config.json)",
|
||||||
|
"Bash(systemctl list-units *)",
|
||||||
|
"Bash(find / -maxdepth 4 -iname \"act_runner*\")",
|
||||||
|
"Bash(find / -maxdepth 6 -iname \"config.yaml\" -path \"*runner*\")",
|
||||||
|
"Read(//home/**)",
|
||||||
|
"Bash(getent passwd *)",
|
||||||
|
"Bash(docker context *)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite log --oneline -1 -- PKGBUILD)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite show --stat HEAD -- PKGBUILD)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite log --oneline -10)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite status)",
|
||||||
|
"Bash(git *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -88,7 +57,6 @@ jobs:
|
|||||||
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: 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 }}'
|
||||||
@@ -139,15 +107,421 @@ 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: |
|
||||||
|
uv sync
|
||||||
|
uv run main.py
|
||||||
|
|
||||||
|
publish-arch:
|
||||||
|
name: Build and publish AUR packages
|
||||||
|
needs: publish-ubuntu
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
runs-on: archlinux
|
||||||
|
environment: production
|
||||||
|
env:
|
||||||
|
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||||
|
GITEA_API: https://git.cbsk-tech.de/api/v1
|
||||||
|
OWNER: Christoph
|
||||||
|
REPO: GitLite
|
||||||
|
GITEA_TOKEN: ${{ secrets.ACTIONS_TOKEN }}
|
||||||
|
GITEA_FALLBACK_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: GitLite
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Prepare Arch job container
|
||||||
|
working-directory: /
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# The runner image may contain only a single geo mirror. Seed several
|
||||||
|
# currently active HTTPS mirrors and retain the image's list as a
|
||||||
|
# fallback so one slow CDN cannot fail the complete release.
|
||||||
|
ARCH_MIRROR_BACKUP="$(mktemp)"
|
||||||
|
cp /etc/pacman.d/mirrorlist "$ARCH_MIRROR_BACKUP"
|
||||||
|
printf '%s\n' \
|
||||||
|
'Server = https://frankfurt.mirror.pkgbuild.com/$repo/os/$arch' \
|
||||||
|
'Server = https://mirror.osbeck.com/archlinux/$repo/os/$arch' \
|
||||||
|
'Server = https://mirror.ubrco.de/archlinux/$repo/os/$arch' \
|
||||||
|
'Server = https://ftp.halifax.rwth-aachen.de/archlinux/$repo/os/$arch' \
|
||||||
|
'Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch' \
|
||||||
|
> /etc/pacman.d/mirrorlist
|
||||||
|
sed -n '/^[[:space:]]*Server[[:space:]]*=/p' "$ARCH_MIRROR_BACKUP" \
|
||||||
|
>> /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
|
ARCH_PACKAGES=(
|
||||||
|
base-devel curl git nodejs npm openssh rust
|
||||||
|
webkit2gtk-4.1 gtk3 hicolor-icon-theme
|
||||||
|
libappindicator-gtk3 librsvg xdotool
|
||||||
|
)
|
||||||
|
|
||||||
|
for ARCH_INSTALL_ATTEMPT in 1 2 3; do
|
||||||
|
echo "pacman attempt $ARCH_INSTALL_ATTEMPT of 3"
|
||||||
|
timeout --signal=TERM 30m \
|
||||||
|
pacman -Syu --needed --noconfirm --noprogressbar \
|
||||||
|
"${ARCH_PACKAGES[@]}" \
|
||||||
|
</dev/null &
|
||||||
|
ARCH_PACMAN_PID=$!
|
||||||
|
(
|
||||||
|
ARCH_WAIT_SECONDS=0
|
||||||
|
while true; do
|
||||||
|
sleep 30
|
||||||
|
((ARCH_WAIT_SECONDS += 30))
|
||||||
|
echo "pacman is still running (${ARCH_WAIT_SECONDS}s elapsed)"
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
ARCH_HEARTBEAT_PID=$!
|
||||||
|
|
||||||
|
ARCH_PACMAN_STATUS=0
|
||||||
|
wait "$ARCH_PACMAN_PID" || ARCH_PACMAN_STATUS=$?
|
||||||
|
kill "$ARCH_HEARTBEAT_PID" 2>/dev/null || true
|
||||||
|
wait "$ARCH_HEARTBEAT_PID" 2>/dev/null || true
|
||||||
|
|
||||||
|
if [ "$ARCH_PACMAN_STATUS" -eq 0 ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ARCH_PACMAN_STATUS" -eq 124 ]; then
|
||||||
|
echo "pacman attempt timed out after 30 minutes" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ARCH_INSTALL_ATTEMPT" -eq 3 ]; then
|
||||||
|
echo "pacman failed after 3 attempts" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep "$((ARCH_INSTALL_ATTEMPT * 5))"
|
||||||
|
done
|
||||||
|
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
path: GitLite
|
||||||
|
|
||||||
|
- name: Set release version
|
||||||
|
env:
|
||||||
|
RELEASE_VERSION: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
npm version "$RELEASE_VERSION" --no-git-tag-version --allow-same-version
|
||||||
|
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
|
||||||
|
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 libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Update package.json version
|
||||||
|
run: npm version ${{ github.ref_name }} --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Update tauri.conf.json version
|
||||||
|
env:
|
||||||
|
RELEASE_VERSION: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
|
||||||
|
|
||||||
|
- name: Build Tauri App
|
||||||
|
env:
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
|
||||||
|
NO_STRIP: "1"
|
||||||
|
run: npm run tauri -- build --bundles appimage
|
||||||
|
|
||||||
|
- name: Upload artifacts to MinIO with cicd_tool
|
||||||
|
working-directory: GitLite/cicd_tool
|
||||||
|
env:
|
||||||
|
PUBLISH_PLATFORM: linux-x86_64
|
||||||
run: |
|
run: |
|
||||||
uv sync
|
uv sync
|
||||||
uv run main.py
|
uv run main.py
|
||||||
|
|||||||
@@ -7,3 +7,6 @@
|
|||||||
~
|
~
|
||||||
.codex*
|
.codex*
|
||||||
target
|
target
|
||||||
|
|
||||||
|
*.pkg.tar.zst
|
||||||
|
pkg
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable user-facing changes to Gitty are documented in this file.
|
||||||
|
|
||||||
|
The project uses calendar-style versions in the form `YYYY.M.PATCH`.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Full Git worktree management:
|
||||||
|
- create a worktree from an existing branch;
|
||||||
|
- create a new branch and worktree together;
|
||||||
|
- create a detached worktree at a commit;
|
||||||
|
- open a worktree as a repository tab;
|
||||||
|
- move, lock, unlock, repair, remove, and prune worktrees.
|
||||||
|
- A dedicated **Worktrees** entry below **Tags** in the branch panel.
|
||||||
|
- An **Open in new worktree** action in the local branch context menu.
|
||||||
|
- Line-level patch selection with Shift-click range selection.
|
||||||
|
- Stage, unstage, and discard actions for selected lines while retaining the
|
||||||
|
existing whole-file and whole-hunk workflows.
|
||||||
|
- Arch Linux package automation that builds from `PKGBUILD`, publishes the
|
||||||
|
`.pkg.tar.zst` artifact, and updates the Pacman repository database on the
|
||||||
|
CDN.
|
||||||
|
- Detailed German and English help for worktrees, line-level staging, Pacman
|
||||||
|
installation, and release changes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Branch deletion now uses a clearer confirmation dialog with more explicit
|
||||||
|
branch and upstream information.
|
||||||
|
- Dialog backdrops blur the application instead of covering it with a nearly
|
||||||
|
black background.
|
||||||
|
- Remote operations and authentication errors provide more actionable
|
||||||
|
feedback.
|
||||||
|
- Build diagnostics and resource handling for the Arch package pipeline are
|
||||||
|
more robust.
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- Added centralized error notifications.
|
||||||
|
- Added structured, privacy-conscious telemetry.
|
||||||
|
- Stabilized the Arch Linux container build and `makepkg` workflow.
|
||||||
|
|
||||||
|
## [2026.7.20] - 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- AI-assisted review of staged changes before committing.
|
||||||
|
- Optional automatic repository refresh.
|
||||||
|
- Comprehensive German Git documentation in the built-in help.
|
||||||
|
- Initial `PKGBUILD` and automated package version synchronization.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Redesigned the repository action toolbar and workspace status presentation.
|
||||||
|
- Improved repository tabs, diff styling, spacing, and panel structure.
|
||||||
|
- Improved file-history state handling.
|
||||||
|
|
||||||
|
[2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22
|
||||||
|
[2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21
|
||||||
|
[2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20
|
||||||
|
[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
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
|
||||||
|
|
||||||
|
pkgname=gitty-desktop
|
||||||
|
pkgver=2026.7.22
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="A lightweight, modern Git client built with Tauri"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://git.cbsk-tech.de/Christoph/GitLite"
|
||||||
|
license=('MIT')
|
||||||
|
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
||||||
|
makedepends=('rust' 'nodejs' 'npm')
|
||||||
|
options=('!lto' '!debug')
|
||||||
|
|
||||||
|
_tag=2026.7.22
|
||||||
|
source=("gitty-desktop-${pkgver}.tar.gz::${url}/archive/${_tag}.tar.gz")
|
||||||
|
sha256sums=('bd6c7d9da54917cbea98ce31172934b4657cc2fe2020f49d27ea2acb51348e9c')
|
||||||
|
|
||||||
|
prepare() {
|
||||||
|
cd "$srcdir/gitlite"
|
||||||
|
|
||||||
|
# Keep application metadata aligned even when a release tag contains
|
||||||
|
# leading zeroes that npm normalizes (for example 2026.7.01 -> 2026.7.1).
|
||||||
|
npm version "$pkgver" --no-git-tag-version --allow-same-version
|
||||||
|
RELEASE_VERSION="$pkgver" node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path,JSON.stringify(config,null,2)+'\n');"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$srcdir/gitlite"
|
||||||
|
|
||||||
|
# Keep the large Rust release build within the Arch runner's memory limit.
|
||||||
|
export CARGO_BUILD_JOBS=1
|
||||||
|
export CARGO_PROFILE_RELEASE_OPT_LEVEL=2
|
||||||
|
export CARGO_PROFILE_RELEASE_LTO=false
|
||||||
|
export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8
|
||||||
|
export CARGO_PROFILE_RELEASE_DEBUG=0
|
||||||
|
|
||||||
|
npm ci
|
||||||
|
npm run tauri -- build --no-bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$srcdir/gitlite"
|
||||||
|
|
||||||
|
install -Dm755 "src-tauri/target/release/gitty" "$pkgdir/usr/bin/gitty-desktop"
|
||||||
|
|
||||||
|
install -Dm644 "src-tauri/icons/32x32.png" \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/32x32/apps/gitty-desktop.png"
|
||||||
|
install -Dm644 "src-tauri/icons/128x128.png" \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/128x128/apps/gitty-desktop.png"
|
||||||
|
install -Dm644 "src-tauri/icons/128x128@2x.png" \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/256x256@2/apps/gitty-desktop.png"
|
||||||
|
install -Dm644 "src-tauri/icons/icon.png" \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty-desktop.png"
|
||||||
|
|
||||||
|
install -d "$pkgdir/usr/share/applications"
|
||||||
|
cat > "$pkgdir/usr/share/applications/gitty-desktop.desktop" <<-EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Gitty
|
||||||
|
Comment=$pkgdesc
|
||||||
|
Exec=gitty-desktop
|
||||||
|
Icon=gitty-desktop
|
||||||
|
Terminal=false
|
||||||
|
Categories=Development;RevisionControl;
|
||||||
|
StartupWMClass=gitty
|
||||||
|
EOF
|
||||||
|
|
||||||
|
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
|
||||||
|
|
||||||
|
pkgname=gitty-desktop-bin
|
||||||
|
pkgver=2026.8.4
|
||||||
|
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' '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
|
||||||
|
}
|
||||||
@@ -49,3 +49,42 @@ Contributions, feedback and feature requests are always welcome!
|
|||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arch Linux
|
||||||
|
|
||||||
|
Install Gitty from the AUR with an AUR helper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yay -S gitty-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
To install the prebuilt native Arch package instead of compiling from source:
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SigNoz telemetry
|
||||||
|
|
||||||
|
After the user opts in, Gitty sends privacy-filtered product events and technical errors as OTLP/HTTP JSON logs. Every Git/Tauri command is also captured as a trace span with its command name, duration and success state, but without command arguments or results. CPU utilization and resident memory usage of the Gitty process are sampled every 30 seconds and exported as OpenTelemetry gauges. The default endpoints are `https://telemetry.cbsk-tech.de/v1/logs`, `https://telemetry.cbsk-tech.de/v1/traces`, and `https://telemetry.cbsk-tech.de/v1/metrics`.
|
||||||
|
|
||||||
|
The SigNoz reverse proxy must forward all three paths to the Collector's OTLP/HTTP receiver (normally port `4318`). For development or a separate collector hostname, set `GITTY_OTLP_LOGS_ENDPOINT`, `GITTY_OTLP_TRACES_ENDPOINT`, and `GITTY_OTLP_METRICS_ENDPOINT` to the full signal URLs before starting Gitty.
|
||||||
|
|
||||||
|
Telemetry is non-blocking, bounded, disabled until consent is stored, and never includes repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, source code, URLs, or email addresses.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+47
-4
@@ -15,6 +15,9 @@ interface GitStatus {
|
|||||||
behind: number;
|
behind: number;
|
||||||
files: GitFileStatus[];
|
files: GitFileStatus[];
|
||||||
clean: boolean;
|
clean: boolean;
|
||||||
|
rebase_in_progress: boolean;
|
||||||
|
cherry_pick_in_progress: boolean;
|
||||||
|
merge_in_progress: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GitFileStatus {
|
interface GitFileStatus {
|
||||||
@@ -30,6 +33,24 @@ interface GitBranch {
|
|||||||
remote: boolean;
|
remote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GitWorktree {
|
||||||
|
path: string;
|
||||||
|
head: string | null;
|
||||||
|
short_head: string | null;
|
||||||
|
branch: string | null;
|
||||||
|
bare: boolean;
|
||||||
|
detached: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
lock_reason: string | null;
|
||||||
|
prunable: boolean;
|
||||||
|
prune_reason: string | null;
|
||||||
|
missing: boolean;
|
||||||
|
is_main: boolean;
|
||||||
|
is_current: boolean;
|
||||||
|
clean: boolean;
|
||||||
|
changed_files: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface GitCommit {
|
interface GitCommit {
|
||||||
hash: string;
|
hash: string;
|
||||||
short_hash: string;
|
short_hash: string;
|
||||||
@@ -56,19 +77,41 @@ interface GitRepositoryFile {
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
The command list below includes the repository-management and synchronization API. The TypeScript wrappers in `src/lib/git.ts` are the authoritative full list.
|
||||||
|
|
||||||
- `open_repository(path: string): Promise<GitStatus>`
|
- `open_repository(path: string): Promise<GitStatus>`
|
||||||
|
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
|
||||||
|
- `clone_repository(...): Promise<RepositoryBundle>`
|
||||||
- `get_status(path: string): Promise<GitStatus>`
|
- `get_status(path: string): Promise<GitStatus>`
|
||||||
- `list_branches(path: string): Promise<GitBranch[]>`
|
- `list_branches(path: string): Promise<GitBranch[]>`
|
||||||
|
- `list_remotes(path: string): Promise<GitRemote[]>`
|
||||||
|
- `add_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
|
||||||
|
- `update_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
|
||||||
|
- `remove_remote(path: string, name: string): Promise<GitRemote[]>`
|
||||||
|
- `set_branch_upstream(path: string, branch: string, upstream?: string): Promise<GitStatus>`
|
||||||
|
- `delete_remote_branch(path: string, remote: string, branch: string): Promise<GitStatus>`
|
||||||
- `checkout_branch(path: string, branch: string): Promise<GitStatus>`
|
- `checkout_branch(path: string, branch: string): Promise<GitStatus>`
|
||||||
|
- `list_worktrees(path: string): Promise<GitWorktree[]>`
|
||||||
|
- `add_worktree(path: string, worktreePath: string, ...): Promise<GitWorktree[]>`
|
||||||
|
- `remove_worktree(path: string, worktreePath: string, force?: boolean): Promise<GitWorktree[]>`
|
||||||
|
- `move_worktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]>`
|
||||||
|
- `lock_worktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]>`
|
||||||
|
- `unlock_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
|
||||||
|
- `prune_worktrees(path: string): Promise<GitWorktree[]>`
|
||||||
|
- `repair_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
|
||||||
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
|
- `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>`
|
||||||
- `commit(path: string, message: string): Promise<GitStatus>`
|
- `commit(path: string, message: string): Promise<GitStatus>`
|
||||||
- `pull(path: string): Promise<GitStatus>`
|
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
|
||||||
- `push(path: string): Promise<GitStatus>`
|
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
|
||||||
- `list_commits(path: string, limit?: number): Promise<GitCommit[]>`
|
- `push(path: string, forceWithLease?: boolean, remote?: string): Promise<GitStatus>`
|
||||||
|
- `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): Promise<GitStatus>`
|
- `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise<GitStatus>`
|
||||||
|
- `merge_continue(path: string): Promise<GitStatus>`
|
||||||
|
- `merge_abort(path: string): Promise<GitStatus>`
|
||||||
|
- `revert_commit(path: string, commit: string): Promise<GitStatus>`
|
||||||
- `list_repository_files(path: string): Promise<GitRepositoryFile[]>`
|
- `list_repository_files(path: string): Promise<GitRepositoryFile[]>`
|
||||||
- `list_file_history(path: string, file: string, limit?: number): Promise<GitCommit[]>` (the `file` argument can also be a folder path)
|
- `list_file_history(path: string, file: string, limit?: number): Promise<GitCommit[]>` (the `file` argument can also be a folder path)
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.7.16",
|
"version": "2026.8.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.7.16",
|
"version": "2026.8.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^1.21.0",
|
"@lucide/svelte": "^1.21.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.7.16",
|
"version": "2026.8.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+253
-123
@@ -574,13 +574,22 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block2"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
|
||||||
|
dependencies = [
|
||||||
|
"objc2 0.5.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1119,6 +1128,19 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-graphics"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-graphics-types",
|
||||||
|
"foreign-types 0.5.0",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-graphics"
|
name = "core-graphics"
|
||||||
version = "0.25.0"
|
version = "0.25.0"
|
||||||
@@ -1592,9 +1614,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2 0.6.2",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2537,8 +2559,11 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"commit_ai",
|
"commit_ai",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
"log",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sysinfo 0.38.3",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-aptabase",
|
"tauri-plugin-aptabase",
|
||||||
@@ -3883,7 +3908,7 @@ dependencies = [
|
|||||||
"statrs",
|
"statrs",
|
||||||
"strum 0.27.2",
|
"strum 0.27.2",
|
||||||
"symphonia",
|
"symphonia",
|
||||||
"sysinfo",
|
"sysinfo 0.36.1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokenizers 0.21.4",
|
"tokenizers 0.21.4",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -4013,10 +4038,10 @@ dependencies = [
|
|||||||
"dpi",
|
"dpi",
|
||||||
"gtk",
|
"gtk",
|
||||||
"keyboard-types",
|
"keyboard-types",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -4310,6 +4335,22 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc-sys"
|
||||||
|
version = "0.3.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
|
||||||
|
dependencies = [
|
||||||
|
"objc-sys",
|
||||||
|
"objc2-encode",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -4322,92 +4363,80 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-app-kit"
|
name = "objc2-app-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2 0.6.2",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-cloud-kit"
|
name = "objc2-cloud-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-data"
|
name = "objc2-core-data"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-foundation"
|
name = "objc2-core-foundation"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-graphics"
|
name = "objc2-core-graphics"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-io-surface",
|
"objc2-io-surface",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-image"
|
name = "objc2-core-image"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
|
checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-location"
|
name = "objc2-core-location"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
|
checksum = "ac0f75792558aa9d618443bbb5db7426a7a0b6fddf96903f86ef9ad02e135740"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc2-core-text"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.0",
|
|
||||||
"objc2",
|
|
||||||
"objc2-core-foundation",
|
|
||||||
"objc2-core-graphics",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4427,22 +4456,34 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-foundation"
|
name = "objc2-foundation"
|
||||||
version = "0.3.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2 0.5.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-foundation"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"block2 0.6.2",
|
||||||
|
"libc",
|
||||||
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-io-kit"
|
name = "objc2-io-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
@@ -4450,82 +4491,106 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-io-surface"
|
name = "objc2-io-surface"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-metal"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"block2 0.5.1",
|
||||||
|
"objc2 0.5.2",
|
||||||
|
"objc2-foundation 0.2.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-osa-kit"
|
name = "objc2-osa-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
checksum = "26bb88504b5a050dbba515d2414607bf5e57dd56b107bc5f0351197a3e7bdc5d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.3.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"objc2",
|
"block2 0.5.1",
|
||||||
|
"objc2 0.5.2",
|
||||||
|
"objc2-foundation 0.2.2",
|
||||||
|
"objc2-metal",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-quartz-core"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-ui-kit"
|
name = "objc2-ui-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2 0.6.2",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-cloud-kit",
|
"objc2-cloud-kit",
|
||||||
"objc2-core-data",
|
"objc2-core-data",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-core-image",
|
"objc2-core-image",
|
||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-core-text",
|
"objc2-foundation 0.3.1",
|
||||||
"objc2-foundation",
|
"objc2-quartz-core 0.3.1",
|
||||||
"objc2-quartz-core",
|
|
||||||
"objc2-user-notifications",
|
"objc2-user-notifications",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-user-notifications"
|
name = "objc2-user-notifications"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
|
checksum = "2a3f5ec77a81d9e0c5a0b32159b0cb143d7086165e79708351e02bf37dfc65cd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-web-kit"
|
name = "objc2-web-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
|
checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2 0.6.2",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4663,8 +4728,8 @@ dependencies = [
|
|||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"log",
|
"log",
|
||||||
"nix 0.31.3",
|
"nix 0.31.3",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"serde",
|
"serde",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -4676,8 +4741,8 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"objc2-osa-kit",
|
"objc2-osa-kit",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -5539,17 +5604,17 @@ version = "0.16.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.6.2",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"glib-sys",
|
"glib-sys",
|
||||||
"gobject-sys",
|
"gobject-sys",
|
||||||
"gtk-sys",
|
"gtk-sys",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
@@ -6357,24 +6422,24 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "softbuffer"
|
name = "softbuffer"
|
||||||
version = "0.4.8"
|
version = "0.4.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
|
checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
|
"cfg_aliases",
|
||||||
|
"core-graphics 0.24.0",
|
||||||
|
"foreign-types 0.5.0",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"ndk",
|
"log",
|
||||||
"objc2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-foundation",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-core-graphics",
|
"objc2-quartz-core 0.2.2",
|
||||||
"objc2-foundation",
|
|
||||||
"objc2-quartz-core",
|
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"redox_syscall",
|
"redox_syscall",
|
||||||
"tracing",
|
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6771,7 +6836,21 @@ dependencies = [
|
|||||||
"ntapi",
|
"ntapi",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-io-kit",
|
"objc2-io-kit",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sysinfo"
|
||||||
|
version = "0.38.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d03c61d2a49c649a15c407338afe7accafde9dac869995dccb73e5f7ef7d9034"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"ntapi",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-io-kit",
|
||||||
|
"windows 0.62.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6815,9 +6894,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2 0.6.2",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics 0.25.0",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
@@ -6831,9 +6910,9 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -6842,7 +6921,7 @@ dependencies = [
|
|||||||
"tao-macros",
|
"tao-macros",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
@@ -6898,9 +6977,9 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
"muda",
|
"muda",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -6924,7 +7003,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"window-vibrancy",
|
"window-vibrancy",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7053,7 +7132,7 @@ dependencies = [
|
|||||||
"dunce",
|
"dunce",
|
||||||
"glob",
|
"glob",
|
||||||
"log",
|
"log",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"schemars 0.8.22",
|
"schemars 0.8.22",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -7126,7 +7205,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
@@ -7137,7 +7216,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7150,7 +7229,7 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -7162,7 +7241,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7810,11 +7889,11 @@ dependencies = [
|
|||||||
"dirs",
|
"dirs",
|
||||||
"libappindicator",
|
"libappindicator",
|
||||||
"muda",
|
"muda",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -8388,7 +8467,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"webview2-com-macros",
|
"webview2-com-macros",
|
||||||
"webview2-com-sys",
|
"webview2-com-sys",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
@@ -8412,7 +8491,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8475,10 +8554,10 @@ version = "0.6.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
|
checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
@@ -8490,11 +8569,23 @@ version = "0.61.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-collections",
|
"windows-collections 0.2.0",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-future",
|
"windows-future 0.2.1",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-numerics",
|
"windows-numerics 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||||
|
dependencies = [
|
||||||
|
"windows-collections 0.3.2",
|
||||||
|
"windows-core 0.62.2",
|
||||||
|
"windows-future 0.3.2",
|
||||||
|
"windows-numerics 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8506,6 +8597,15 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-collections"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.62.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -8540,7 +8640,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-threading",
|
"windows-threading 0.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-future"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.62.2",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
"windows-threading 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8587,6 +8698,16 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-numerics"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.62.2",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-registry"
|
name = "windows-registry"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -8760,6 +8881,15 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-threading"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-version"
|
name = "windows-version"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -9002,7 +9132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
|
checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"block2",
|
"block2 0.6.2",
|
||||||
"cookie",
|
"cookie",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -9016,10 +9146,10 @@ dependencies = [
|
|||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -9033,7 +9163,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
|
|||||||
@@ -21,12 +21,21 @@ tauri-plugin-aptabase = "1.0"
|
|||||||
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
||||||
commit_ai = { path = "crates/commit_ai" }
|
commit_ai = { path = "crates/commit_ai" }
|
||||||
tokio = "1.52.3"
|
tokio = "1.52.3"
|
||||||
|
log = "0.4"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
|
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|
||||||
|
# Self-updating only makes sense for install methods this app ships itself
|
||||||
|
# (NSIS on Windows, the .app bundle on macOS). On Linux the app is meant to be
|
||||||
|
# installed via the system package manager (e.g. the PKGBUILD), which owns
|
||||||
|
# updates instead — so the updater plugin isn't even compiled in there.
|
||||||
|
[target.'cfg(not(any(target_os = "android", target_os = "ios", target_os = "linux")))'.dependencies]
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
|
|
||||||
# ── Build profiles ───────────────────────────────────────────────────────────
|
# ── Build profiles ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
"identifier": "desktop-capability",
|
"identifier": "desktop-capability",
|
||||||
"platforms": [
|
"platforms": [
|
||||||
"macOS",
|
"macOS",
|
||||||
"windows",
|
"windows"
|
||||||
"linux"
|
|
||||||
],
|
],
|
||||||
"windows": [
|
"windows": [
|
||||||
"main"
|
"main"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{build_messages, looks_like_diff_echo, sanitize_message};
|
use crate::{build_messages, build_review_messages, looks_like_diff_echo, sanitize_message};
|
||||||
|
|
||||||
// Generous sizing so a detailed body with bullet points isn't cut off.
|
// Generous sizing so a detailed body with bullet points isn't cut off.
|
||||||
const DEFAULT_MAX_TOKENS: u32 = 1500;
|
const DEFAULT_MAX_TOKENS: u32 = 1500;
|
||||||
@@ -139,6 +139,165 @@ pub async fn generate_custom(
|
|||||||
openai_compatible_request(url, api_key, model, diff, notes).await
|
openai_compatible_request(url, api_key, model, diff, notes).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn openai_compatible_review_request(
|
||||||
|
url: String,
|
||||||
|
bearer: Option<&str>,
|
||||||
|
model: &str,
|
||||||
|
diff: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let (system, user) = build_review_messages(diff)?;
|
||||||
|
let body = OpenAiRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
messages: vec![
|
||||||
|
OpenAiMessage {
|
||||||
|
role: "system",
|
||||||
|
content: system,
|
||||||
|
},
|
||||||
|
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 review.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn review_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_review_request(
|
||||||
|
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||||
|
Some(api_key),
|
||||||
|
model,
|
||||||
|
diff,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn review_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());
|
||||||
|
}
|
||||||
|
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
|
||||||
|
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,
|
||||||
@@ -220,3 +379,89 @@ pub async fn generate_anthropic(
|
|||||||
}
|
}
|
||||||
Ok(message)
|
Ok(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn review_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, user) = build_review_messages(diff)?;
|
||||||
|
let body = AnthropicRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
max_tokens: 2400,
|
||||||
|
system,
|
||||||
|
messages: vec![AnthropicMessage {
|
||||||
|
role: "user",
|
||||||
|
content: user,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let client = http_client()?;
|
||||||
|
let response = client
|
||||||
|
.post("https://api.anthropic.com/v1/messages")
|
||||||
|
.header("x-api-key", api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("API error ({status}): {text}"));
|
||||||
|
}
|
||||||
|
let parsed: AnthropicResponse =
|
||||||
|
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
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 review.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn split_anthropic(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return Err("Anthropic API key is missing.".to_string());
|
||||||
|
}
|
||||||
|
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.".to_string();
|
||||||
|
let body = AnthropicRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
max_tokens: 2400,
|
||||||
|
system,
|
||||||
|
messages: vec![AnthropicMessage {
|
||||||
|
role: "user",
|
||||||
|
content: format!(
|
||||||
|
"Analyze these staged changes and propose an ordered commit plan:\n\n{diff}"
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let client = http_client()?;
|
||||||
|
let response = client
|
||||||
|
.post("https://api.anthropic.com/v1/messages")
|
||||||
|
.header("x-api-key", api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("API error ({status}): {text}"));
|
||||||
|
}
|
||||||
|
let parsed: AnthropicResponse =
|
||||||
|
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
parsed
|
||||||
|
.content
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|block| block.text)
|
||||||
|
.map(|text| sanitize_message(&text))
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.ok_or_else(|| "The model did not return a commit plan.".to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
mod cloud;
|
mod cloud;
|
||||||
|
|
||||||
pub use cloud::{generate_anthropic, generate_custom, generate_openai};
|
pub use cloud::{
|
||||||
|
generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom,
|
||||||
|
review_openai, split_anthropic, split_custom, split_openai,
|
||||||
|
};
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::hash_map::DefaultHasher,
|
collections::hash_map::DefaultHasher,
|
||||||
@@ -443,3 +446,27 @@ instead of a direct string comparison.\n\n\
|
|||||||
user.push_str(&format!("Staged diff:\n{diff}"));
|
user.push_str(&format!("Staged diff:\n{diff}"));
|
||||||
Ok((system, user))
|
Ok((system, user))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_review_messages(diff: &str) -> Result<(String, String), String> {
|
||||||
|
if diff.trim().is_empty() {
|
||||||
|
return Err("No staged changes available for review.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CHARS: usize = 36_000;
|
||||||
|
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
|
||||||
|
let system = r#"You are a senior software engineer performing a focused pre-commit review.
|
||||||
|
Review only the supplied staged Git diff. Look for concrete correctness bugs, security issues,
|
||||||
|
data loss, regressions, broken edge cases, unsafe error handling, and meaningful performance or
|
||||||
|
maintainability risks. Do not report formatting preferences or speculative nitpicks.
|
||||||
|
|
||||||
|
Return ONLY valid JSON with this exact shape:
|
||||||
|
{"summary":"one concise overall assessment","risk":"low|medium|high","findings":[{"severity":"critical|warning|info","title":"short title","description":"clear evidence and impact","file":"path or null","line":123,"suggestion":"specific safe next step"}]}
|
||||||
|
|
||||||
|
Use the new-file line number from the diff when it is known; otherwise use null. Use null for file
|
||||||
|
when the issue is repository-wide. Maximum 12 findings, ordered critical then warning then info.
|
||||||
|
If no actionable issue exists, return an empty findings array and risk low. Never use markdown,
|
||||||
|
code fences, commentary outside the JSON, or claim that tests were executed."#
|
||||||
|
.to_string();
|
||||||
|
let user = format!("Staged diff to review:\n{diff}");
|
||||||
|
Ok((system, user))
|
||||||
|
}
|
||||||
|
|||||||
+15
-1
@@ -177,7 +177,21 @@ pub fn set_sync_badge(
|
|||||||
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
|
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
// Native badge count: works on Linux (via the desktop's launcher API, e.g. Unity's
|
||||||
|
// LauncherEntry, also honored by GNOME/KDE) and macOS (dock badge). Matched against the
|
||||||
|
// app's `<product-name>.desktop` file on Linux, so it's a silent no-op on launchers that
|
||||||
|
// don't implement the protocol.
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
{
|
||||||
|
let Some(window) = app.get_webview_window("main") else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
window
|
||||||
|
.set_badge_count(if count > 0 { Some(count as i64) } else { None })
|
||||||
|
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
|
||||||
{
|
{
|
||||||
let _ = (app, count);
|
let _ = (app, count);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+2775
-118
File diff suppressed because it is too large
Load Diff
+120
-20
@@ -1,25 +1,68 @@
|
|||||||
#![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;
|
||||||
|
|
||||||
use badge::set_sync_badge;
|
use badge::set_sync_badge;
|
||||||
use git::{
|
use external_tools::{
|
||||||
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
|
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
|
||||||
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
|
|
||||||
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
|
||||||
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
|
|
||||||
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
|
|
||||||
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame,
|
|
||||||
get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits,
|
|
||||||
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
|
|
||||||
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
|
|
||||||
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
|
|
||||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
|
||||||
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
|
|
||||||
stash_push, undo_last_commit, unstage_files,
|
|
||||||
};
|
};
|
||||||
use tauri::{Manager, AppHandle};
|
use git::{
|
||||||
|
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
|
||||||
|
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||||
|
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
|
||||||
|
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
|
||||||
|
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
|
||||||
|
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
|
||||||
|
delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes,
|
||||||
|
get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, init_repository,
|
||||||
|
last_commit_message, list_branches, list_commits, list_file_history,
|
||||||
|
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
|
||||||
|
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
|
||||||
|
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
|
||||||
|
open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
|
||||||
|
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
|
||||||
|
rename_remote_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
|
||||||
|
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
|
||||||
|
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
||||||
|
set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
|
||||||
|
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
|
||||||
|
update_remote,
|
||||||
|
};
|
||||||
|
use tauri::Manager;
|
||||||
|
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
||||||
|
|
||||||
|
struct ConsoleLogger;
|
||||||
|
|
||||||
|
impl log::Log for ConsoleLogger {
|
||||||
|
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||||
|
metadata.level() <= log::Level::Info
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &log::Record<'_>) {
|
||||||
|
if self.enabled(record.metadata()) {
|
||||||
|
eprintln!(
|
||||||
|
"[{}] [{}] {}",
|
||||||
|
record.level(),
|
||||||
|
record.target(),
|
||||||
|
record.args()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static CONSOLE_LOGGER: ConsoleLogger = ConsoleLogger;
|
||||||
|
|
||||||
|
fn init_console_logging() {
|
||||||
|
if log::set_logger(&CONSOLE_LOGGER).is_ok() {
|
||||||
|
log::set_max_level(log::LevelFilter::Info);
|
||||||
|
log::info!(target: "gitty", "Rust console logging initialized");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
@@ -31,6 +74,7 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.maximize();
|
||||||
window
|
window
|
||||||
.show()
|
.show()
|
||||||
.map_err(|error| format!("failed to show main window: {error}"))?;
|
.map_err(|error| format!("failed to show main window: {error}"))?;
|
||||||
@@ -42,14 +86,24 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tauri::Builder::default()
|
init_console_logging();
|
||||||
|
telemetry::init();
|
||||||
|
if let Some(result) = run_sequence_editor_if_requested() {
|
||||||
|
if let Err(error) = result {
|
||||||
|
eprintln!("{error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let builder = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
let _ = app.get_webview_window("main")
|
let _ = app
|
||||||
|
.get_webview_window("main")
|
||||||
.expect("no main window")
|
.expect("no main window")
|
||||||
.set_focus();
|
.set_focus();
|
||||||
}))
|
}))
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
|
tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
|
||||||
.with_options(tauri_plugin_aptabase::InitOptions {
|
.with_options(tauri_plugin_aptabase::InitOptions {
|
||||||
@@ -60,19 +114,48 @@ async fn main() {
|
|||||||
)
|
)
|
||||||
.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());
|
||||||
|
|
||||||
|
// Linux installs are expected to come from the system package manager (see the
|
||||||
|
// PKGBUILD), which owns updates itself — the self-updater is only wired up for
|
||||||
|
// the platforms whose install method this app ships (NSIS/Windows, .app/macOS).
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||||
|
|
||||||
|
builder
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
open_repository,
|
open_repository,
|
||||||
|
init_repository,
|
||||||
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,
|
||||||
list_branches,
|
list_branches,
|
||||||
|
list_remotes,
|
||||||
|
add_remote,
|
||||||
|
update_remote,
|
||||||
|
remove_remote,
|
||||||
|
set_branch_upstream,
|
||||||
|
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,
|
||||||
|
add_worktree,
|
||||||
|
remove_worktree,
|
||||||
|
move_worktree,
|
||||||
|
lock_worktree,
|
||||||
|
unlock_worktree,
|
||||||
|
prune_worktrees,
|
||||||
|
repair_worktree,
|
||||||
list_tags,
|
list_tags,
|
||||||
create_tag,
|
create_tag,
|
||||||
delete_tag,
|
delete_tag,
|
||||||
@@ -97,16 +180,30 @@ async fn main() {
|
|||||||
commit_ai_load,
|
commit_ai_load,
|
||||||
commit_ai_local_models,
|
commit_ai_local_models,
|
||||||
commit_ai_generate,
|
commit_ai_generate,
|
||||||
|
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,
|
||||||
|
merge_continue,
|
||||||
|
merge_abort,
|
||||||
|
revert_commit,
|
||||||
rebase_branch,
|
rebase_branch,
|
||||||
rebase_continue,
|
rebase_continue,
|
||||||
rebase_abort,
|
rebase_abort,
|
||||||
|
list_interactive_rebase_commits,
|
||||||
|
start_interactive_rebase,
|
||||||
|
list_reflog,
|
||||||
|
restore_reflog_entry,
|
||||||
list_repository_files,
|
list_repository_files,
|
||||||
open_repository_bundle,
|
open_repository_bundle,
|
||||||
list_file_history,
|
list_file_history,
|
||||||
@@ -126,7 +223,10 @@ async fn main() {
|
|||||||
cred_save,
|
cred_save,
|
||||||
cred_delete,
|
cred_delete,
|
||||||
set_sync_badge,
|
set_sync_badge,
|
||||||
close_splashscreen
|
close_splashscreen,
|
||||||
|
set_telemetry_enabled,
|
||||||
|
emit_frontend_log,
|
||||||
|
emit_frontend_span
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
use std::{
|
||||||
|
sync::{
|
||||||
|
OnceLock,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
mpsc::{SyncSender, sync_channel},
|
||||||
|
},
|
||||||
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
const DEFAULT_OTLP_LOGS_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/logs";
|
||||||
|
const DEFAULT_OTLP_TRACES_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/traces";
|
||||||
|
const DEFAULT_OTLP_METRICS_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/metrics";
|
||||||
|
const METRICS_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
|
const MAX_BODY_BYTES: usize = 2_048;
|
||||||
|
|
||||||
|
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||||
|
static SENDER: OnceLock<SyncSender<TelemetrySignal>> = OnceLock::new();
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum TelemetrySignal {
|
||||||
|
Log(TelemetryRecord),
|
||||||
|
Span(FrontendSpan),
|
||||||
|
Metrics(ProcessMetrics),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ProcessMetrics {
|
||||||
|
cpu_utilization: f64,
|
||||||
|
memory_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TelemetryRecord {
|
||||||
|
severity: &'static str,
|
||||||
|
severity_number: u8,
|
||||||
|
body: String,
|
||||||
|
event_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FrontendLog {
|
||||||
|
level: String,
|
||||||
|
message: String,
|
||||||
|
event_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FrontendSpan {
|
||||||
|
name: String,
|
||||||
|
trace_id: String,
|
||||||
|
span_id: String,
|
||||||
|
started_at_ms: u64,
|
||||||
|
duration_ms: f64,
|
||||||
|
success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init() {
|
||||||
|
let endpoint = std::env::var("GITTY_OTLP_LOGS_ENDPOINT")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_OTLP_LOGS_ENDPOINT.to_string());
|
||||||
|
let traces_endpoint = std::env::var("GITTY_OTLP_TRACES_ENDPOINT")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_OTLP_TRACES_ENDPOINT.to_string());
|
||||||
|
let metrics_endpoint = std::env::var("GITTY_OTLP_METRICS_ENDPOINT")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_OTLP_METRICS_ENDPOINT.to_string());
|
||||||
|
let (sender, receiver) = sync_channel::<TelemetrySignal>(256);
|
||||||
|
if SENDER.set(sender.clone()).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
start_process_metrics(sender);
|
||||||
|
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("gitty-telemetry".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let client = match reqwest::blocking::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("[WARN] [gitty::telemetry] could not create OTLP client: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut export_warning_shown = false;
|
||||||
|
while let Ok(signal) = receiver.recv() {
|
||||||
|
let (target_endpoint, payload) = match signal {
|
||||||
|
TelemetrySignal::Log(record) => (&endpoint, log_payload(record)),
|
||||||
|
TelemetrySignal::Span(span) => (&traces_endpoint, span_payload(span)),
|
||||||
|
TelemetrySignal::Metrics(metrics) => {
|
||||||
|
(&metrics_endpoint, metrics_payload(metrics))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export(
|
||||||
|
&client,
|
||||||
|
target_endpoint,
|
||||||
|
&payload,
|
||||||
|
&mut export_warning_shown,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("failed to start telemetry worker");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_process_metrics(sender: SyncSender<TelemetrySignal>) {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("gitty-process-metrics".into())
|
||||||
|
.spawn(move || {
|
||||||
|
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
|
||||||
|
|
||||||
|
let Ok(pid) = sysinfo::get_current_pid() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let refresh_kind = ProcessRefreshKind::nothing().with_cpu().with_memory();
|
||||||
|
let cpu_count = std::thread::available_parallelism()
|
||||||
|
.map(|count| count.get())
|
||||||
|
.unwrap_or(1) as f64;
|
||||||
|
let mut system = System::new();
|
||||||
|
let mut primed = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(METRICS_INTERVAL);
|
||||||
|
if !ENABLED.load(Ordering::Relaxed) {
|
||||||
|
primed = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
system.refresh_processes_specifics(
|
||||||
|
ProcessesToUpdate::Some(&[pid]),
|
||||||
|
true,
|
||||||
|
refresh_kind,
|
||||||
|
);
|
||||||
|
let Some(process) = system.process(pid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !primed {
|
||||||
|
primed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let metrics = ProcessMetrics {
|
||||||
|
cpu_utilization: (f64::from(process.cpu_usage()) / (100.0 * cpu_count))
|
||||||
|
.clamp(0.0, 1.0),
|
||||||
|
memory_bytes: process.memory(),
|
||||||
|
};
|
||||||
|
let _ = sender.try_send(TelemetrySignal::Metrics(metrics));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("failed to start process metrics worker");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resource_attributes() -> serde_json::Value {
|
||||||
|
json!([
|
||||||
|
{ "key": "service.name", "value": { "stringValue": "gitty-desktop" } },
|
||||||
|
{ "key": "service.version", "value": { "stringValue": env!("CARGO_PKG_VERSION") } },
|
||||||
|
{ "key": "deployment.environment.name", "value": { "stringValue": if cfg!(debug_assertions) { "development" } else { "production" } } },
|
||||||
|
{ "key": "os.type", "value": { "stringValue": std::env::consts::OS } },
|
||||||
|
{ "key": "host.arch", "value": { "stringValue": std::env::consts::ARCH } }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_payload(record: TelemetryRecord) -> serde_json::Value {
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
.to_string();
|
||||||
|
let mut attributes = vec![json!({
|
||||||
|
"key": "telemetry.sdk.language",
|
||||||
|
"value": { "stringValue": "rust" }
|
||||||
|
})];
|
||||||
|
if let Some(event_name) = record.event_name {
|
||||||
|
attributes.push(json!({
|
||||||
|
"key": "event.name",
|
||||||
|
"value": { "stringValue": event_name }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let payload = json!({
|
||||||
|
"resourceLogs": [{
|
||||||
|
"resource": { "attributes": resource_attributes() },
|
||||||
|
"scopeLogs": [{
|
||||||
|
"scope": { "name": "gitty.telemetry" },
|
||||||
|
"logRecords": [{
|
||||||
|
"timeUnixNano": timestamp,
|
||||||
|
"observedTimeUnixNano": timestamp,
|
||||||
|
"severityNumber": record.severity_number,
|
||||||
|
"severityText": record.severity,
|
||||||
|
"body": { "stringValue": record.body },
|
||||||
|
"attributes": attributes
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fn span_payload(mut span: FrontendSpan) -> serde_json::Value {
|
||||||
|
if span.name.len() > 128 {
|
||||||
|
span.name.truncate(128);
|
||||||
|
}
|
||||||
|
let start = u128::from(span.started_at_ms) * 1_000_000;
|
||||||
|
let duration = (span.duration_ms.max(0.0) * 1_000_000.0) as u128;
|
||||||
|
let attributes = vec![
|
||||||
|
json!({ "key": "rpc.system", "value": { "stringValue": "tauri" } }),
|
||||||
|
json!({ "key": "rpc.method", "value": { "stringValue": span.name } }),
|
||||||
|
];
|
||||||
|
json!({
|
||||||
|
"resourceSpans": [{
|
||||||
|
"resource": { "attributes": resource_attributes() },
|
||||||
|
"scopeSpans": [{
|
||||||
|
"scope": { "name": "gitty.tauri", "version": env!("CARGO_PKG_VERSION") },
|
||||||
|
"spans": [{
|
||||||
|
"traceId": span.trace_id,
|
||||||
|
"spanId": span.span_id,
|
||||||
|
"name": format!("tauri.{}", span.name),
|
||||||
|
"kind": 1,
|
||||||
|
"startTimeUnixNano": start.to_string(),
|
||||||
|
"endTimeUnixNano": (start + duration).to_string(),
|
||||||
|
"attributes": attributes,
|
||||||
|
"status": {
|
||||||
|
"code": if span.success { 1 } else { 2 },
|
||||||
|
"message": if span.success { "" } else { "Tauri command failed" }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metrics_payload(metrics: ProcessMetrics) -> serde_json::Value {
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
.to_string();
|
||||||
|
json!({
|
||||||
|
"resourceMetrics": [{
|
||||||
|
"resource": { "attributes": resource_attributes() },
|
||||||
|
"scopeMetrics": [{
|
||||||
|
"scope": { "name": "gitty.process", "version": env!("CARGO_PKG_VERSION") },
|
||||||
|
"metrics": [
|
||||||
|
{
|
||||||
|
"name": "process.cpu.utilization",
|
||||||
|
"description": "Normalized CPU utilization of the Gitty process",
|
||||||
|
"unit": "1",
|
||||||
|
"gauge": { "dataPoints": [{
|
||||||
|
"timeUnixNano": timestamp,
|
||||||
|
"asDouble": metrics.cpu_utilization,
|
||||||
|
"attributes": []
|
||||||
|
}] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "process.memory.usage",
|
||||||
|
"description": "Resident memory used by the Gitty process",
|
||||||
|
"unit": "By",
|
||||||
|
"gauge": { "dataPoints": [{
|
||||||
|
"timeUnixNano": timestamp,
|
||||||
|
"asInt": metrics.memory_bytes.to_string(),
|
||||||
|
"attributes": []
|
||||||
|
}] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export(
|
||||||
|
client: &reqwest::blocking::Client,
|
||||||
|
endpoint: &str,
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
export_warning_shown: &mut bool,
|
||||||
|
) {
|
||||||
|
match client.post(endpoint).json(payload).send() {
|
||||||
|
Ok(response) if response.status().is_success() => {
|
||||||
|
if response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| value.starts_with("text/html"))
|
||||||
|
&& !*export_warning_shown
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[WARN] [gitty::telemetry] OTLP endpoint returned HTML; configure the collector endpoint with GITTY_OTLP_LOGS_ENDPOINT"
|
||||||
|
);
|
||||||
|
*export_warning_shown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response) if !*export_warning_shown => {
|
||||||
|
eprintln!(
|
||||||
|
"[WARN] [gitty::telemetry] OTLP export failed with status {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
*export_warning_shown = true;
|
||||||
|
}
|
||||||
|
Err(error) if !*export_warning_shown => {
|
||||||
|
eprintln!("[WARN] [gitty::telemetry] OTLP export failed: {error}");
|
||||||
|
*export_warning_shown = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(record: TelemetryRecord) {
|
||||||
|
if !ENABLED.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(sender) = SENDER.get() {
|
||||||
|
let _ = sender.try_send(TelemetrySignal::Log(record));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_telemetry_enabled(enabled: bool) {
|
||||||
|
ENABLED.store(enabled, Ordering::Relaxed);
|
||||||
|
if enabled {
|
||||||
|
emit(TelemetryRecord {
|
||||||
|
severity: "INFO",
|
||||||
|
severity_number: 9,
|
||||||
|
body: "Telemetry enabled".into(),
|
||||||
|
event_name: Some("telemetry.enabled".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn emit_frontend_log(log: FrontendLog) {
|
||||||
|
let (severity, severity_number) = match log.level.as_str() {
|
||||||
|
"error" => ("ERROR", 17),
|
||||||
|
"warn" => ("WARN", 13),
|
||||||
|
_ => ("INFO", 9),
|
||||||
|
};
|
||||||
|
let mut body = log.message;
|
||||||
|
if body.len() > MAX_BODY_BYTES {
|
||||||
|
body.truncate(MAX_BODY_BYTES);
|
||||||
|
}
|
||||||
|
emit(TelemetryRecord {
|
||||||
|
severity,
|
||||||
|
severity_number,
|
||||||
|
body,
|
||||||
|
event_name: log.event_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn emit_frontend_span(span: FrontendSpan) {
|
||||||
|
if !ENABLED.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if span.trace_id.len() != 32
|
||||||
|
|| span.span_id.len() != 16
|
||||||
|
|| !span.trace_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
|| !span.span_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(sender) = SENDER.get() {
|
||||||
|
let _ = sender.try_send(TelemetrySignal::Span(span));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Gitty",
|
"productName": "Gitty",
|
||||||
"version": "2026.7.16",
|
"version": "2026.8.4",
|
||||||
"identifier": "com.gitty",
|
"identifier": "com.gitty",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+2163
-493
File diff suppressed because it is too large
Load Diff
+3471
-579
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { BookOpen, Database, Plus, X } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface RepositoryTabItem {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
branch: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export let activeView: "management" | "repository" = "management";
|
||||||
|
export let repoTabs: RepositoryTabItem[] = [];
|
||||||
|
export let isBusy: boolean = false;
|
||||||
|
export let language: "en" | "de" = "en";
|
||||||
|
export let onOpenManagement: () => void = () => {};
|
||||||
|
export let isActive: (path: string) => boolean = () => false;
|
||||||
|
export let onSelect: (path: string) => void | Promise<void> = () => {};
|
||||||
|
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
|
||||||
|
export let onContextMenu: (path: string, event: MouseEvent) => void = () => {};
|
||||||
|
export let onAdd: () => void | Promise<void> = () => {};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="repo-tabbar" aria-label={language === "de" ? "Repository-Reiter" : "Repository tabs"}>
|
||||||
|
<button
|
||||||
|
class="repo-tab management"
|
||||||
|
class:active={activeView === "management"}
|
||||||
|
type="button"
|
||||||
|
onclick={onOpenManagement}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
||||||
|
>
|
||||||
|
<BookOpen size={14} aria-hidden="true" />
|
||||||
|
<span>{language === "de" ? "Repository-Verwaltung" : "Repository Management"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="repo-tabs-scroll">
|
||||||
|
{#each repoTabs as repo (repo.path)}
|
||||||
|
<div
|
||||||
|
class="repo-tab-wrap"
|
||||||
|
class:active={isActive(repo.path)}
|
||||||
|
role="presentation"
|
||||||
|
oncontextmenu={(event) => onContextMenu(repo.path, event)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="repo-tab"
|
||||||
|
type="button"
|
||||||
|
onclick={() => onSelect(repo.path)}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={repo.path}
|
||||||
|
>
|
||||||
|
<Database size={15} aria-hidden="true" />
|
||||||
|
<span>{repo.name}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="repo-tab-close"
|
||||||
|
type="button"
|
||||||
|
onclick={(event) => onClose(repo.path, event)}
|
||||||
|
disabled={isBusy}
|
||||||
|
aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
|
||||||
|
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
|
||||||
|
>
|
||||||
|
<X size={11} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="repo-tab-add"
|
||||||
|
type="button"
|
||||||
|
onclick={onAdd}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
|
||||||
|
aria-label={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
|
||||||
|
>
|
||||||
|
<Plus size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
Code2,
|
||||||
|
CloudDownload,
|
||||||
|
CloudOff,
|
||||||
|
Download,
|
||||||
|
FolderOpen,
|
||||||
|
GitCompare,
|
||||||
|
History,
|
||||||
|
ListRestart,
|
||||||
|
LoaderCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Upload,
|
||||||
|
Settings2,
|
||||||
|
Terminal,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
|
export let hasRepository: boolean = false;
|
||||||
|
export let isBusy: boolean = false;
|
||||||
|
export let operation: string = "";
|
||||||
|
export let ahead: number = 0;
|
||||||
|
export let behind: number = 0;
|
||||||
|
export let localOnly: boolean = false;
|
||||||
|
export let language: "en" | "de" = "en";
|
||||||
|
export let editorName: string = "Editor";
|
||||||
|
export let terminalName: string = "Terminal";
|
||||||
|
export let fileManagerName: string = "Explorer";
|
||||||
|
export let onFetch: () => void = () => {};
|
||||||
|
export let onPull: () => void = () => {};
|
||||||
|
export let onPush: () => void = () => {};
|
||||||
|
export let onRefresh: () => void = () => {};
|
||||||
|
export let onSearch: () => void = () => {};
|
||||||
|
export let onCompare: () => void = () => {};
|
||||||
|
export let onInteractiveRebase: () => void = () => {};
|
||||||
|
export let onReflog: () => void = () => {};
|
||||||
|
export let onOpenInExplorer: () => void = () => {};
|
||||||
|
export let onOpenInEditor: () => void = () => {};
|
||||||
|
export let onOpenTerminal: () => void = () => {};
|
||||||
|
export let onFetchPrune: () => void = () => {};
|
||||||
|
export let onForcePush: () => void = () => {};
|
||||||
|
export let onSyncOptions: () => void = () => {};
|
||||||
|
|
||||||
|
let historyOpen = false;
|
||||||
|
let syncOpen = false;
|
||||||
|
let toolbarElement: HTMLDivElement;
|
||||||
|
|
||||||
|
$: isGerman = language === "de";
|
||||||
|
$: pushLabel = localOnly ? (isGerman ? "Veröffentlichen" : "Publish") : "Push";
|
||||||
|
$: pushTitle = localOnly
|
||||||
|
? (isGerman
|
||||||
|
? "Dieser Branch existiert nur lokal. Veröffentlichen erstellt den Remote-Branch und richtet das Tracking ein."
|
||||||
|
: "This branch exists only locally. Publish creates the remote branch and configures tracking.")
|
||||||
|
: "Push";
|
||||||
|
|
||||||
|
function runHistoryAction(action: () => void) {
|
||||||
|
historyOpen = false;
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowClick(event: MouseEvent) {
|
||||||
|
if (toolbarElement && !toolbarElement.contains(event.target as Node)) { historyOpen = false; syncOpen = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape" && historyOpen) {
|
||||||
|
event.stopPropagation();
|
||||||
|
historyOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={handleWindowClick} onkeydown={handleWindowKeydown} />
|
||||||
|
|
||||||
|
<div bind:this={toolbarElement} class="repo-toolbar" role="toolbar" aria-label={isGerman ? "Repository-Aktionen" : "Repository actions"}>
|
||||||
|
<div class="repo-toolbar-sync">
|
||||||
|
<div class="repo-action-group repo-sync-actions">
|
||||||
|
<button
|
||||||
|
class="repo-action fetch"
|
||||||
|
onclick={onFetch}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title="Fetch"
|
||||||
|
aria-label="Fetch"
|
||||||
|
>
|
||||||
|
{#if operation === "Fetching"}
|
||||||
|
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<CloudDownload size={15} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
<span class="repo-action-label">Fetch</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="repo-action sync-primary"
|
||||||
|
onclick={onPull}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title="Pull"
|
||||||
|
aria-label={behind > 0
|
||||||
|
? `Pull, ${behind} ${isGerman ? (behind === 1 ? "Remote-Commit voraus" : "Remote-Commits voraus") : (behind === 1 ? "commit behind" : "commits behind")}`
|
||||||
|
: "Pull"}
|
||||||
|
>
|
||||||
|
{#if operation === "Pulling"}
|
||||||
|
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<Download size={15} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
<span class="repo-action-label">Pull</span>
|
||||||
|
{#if behind > 0}<span class="repo-action-count behind">↓{behind}</span>{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="repo-action sync-primary"
|
||||||
|
class:publish-local={localOnly}
|
||||||
|
onclick={onPush}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title={pushTitle}
|
||||||
|
aria-label={localOnly
|
||||||
|
? pushTitle
|
||||||
|
: ahead > 0
|
||||||
|
? `Push, ${ahead} ${isGerman ? (ahead === 1 ? "lokaler Commit voraus" : "lokale Commits voraus") : (ahead === 1 ? "commit ahead" : "commits ahead")}`
|
||||||
|
: "Push"}
|
||||||
|
>
|
||||||
|
{#if operation === "Pushing"}
|
||||||
|
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<Upload size={15} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
<span class="repo-action-label">{pushLabel}</span>
|
||||||
|
{#if localOnly}
|
||||||
|
<span class="repo-action-local-marker"><CloudOff size={9} aria-hidden="true" />{isGerman ? "NUR LOKAL" : "LOCAL"}</span>
|
||||||
|
{:else if ahead > 0}
|
||||||
|
<span class="repo-action-count ahead">↑{ahead}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<div class="repo-history-wrap">
|
||||||
|
<button class="repo-action" type="button" onclick={() => { syncOpen = !syncOpen; historyOpen = false; }} disabled={!hasRepository || isBusy} aria-label={isGerman ? "Sync-Optionen" : "Sync options"} aria-haspopup="menu">
|
||||||
|
<ChevronDown size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{#if syncOpen}
|
||||||
|
<div class="repo-history-menu" role="menu">
|
||||||
|
<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; 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>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="repo-toolbar-divider" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="repo-action-group repo-inspect-actions">
|
||||||
|
<button
|
||||||
|
class="repo-action"
|
||||||
|
onclick={onSearch}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title={isGerman ? "Globale Suche" : "Global search"}
|
||||||
|
aria-label={isGerman ? "Globale Suche" : "Global search"}
|
||||||
|
>
|
||||||
|
<Search size={15} aria-hidden="true" />
|
||||||
|
<span class="repo-action-label">{isGerman ? "Suchen" : "Search"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="repo-action"
|
||||||
|
onclick={onCompare}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
|
||||||
|
aria-label={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
|
||||||
|
>
|
||||||
|
<GitCompare size={15} aria-hidden="true" />
|
||||||
|
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="repo-history-wrap">
|
||||||
|
<button
|
||||||
|
class="repo-action history-trigger"
|
||||||
|
class:active={historyOpen}
|
||||||
|
type="button"
|
||||||
|
onclick={() => { historyOpen = !historyOpen; }}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={historyOpen}
|
||||||
|
title={isGerman ? "Verlauf und Rebase" : "History and rebase"}
|
||||||
|
>
|
||||||
|
<History size={15} aria-hidden="true" />
|
||||||
|
<span class="repo-action-label">{isGerman ? "Verlauf" : "History"}</span>
|
||||||
|
<ChevronDown class="history-chevron" size={13} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if historyOpen}
|
||||||
|
<div class="repo-history-menu" role="menu">
|
||||||
|
<button type="button" role="menuitem" onclick={() => runHistoryAction(onReflog)}>
|
||||||
|
<History size={15} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>Reflog</strong>
|
||||||
|
<small>{isGerman ? "Lokale Referenzbewegungen" : "Local reference movements"}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={() => runHistoryAction(onInteractiveRebase)}>
|
||||||
|
<ListRestart size={15} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>{isGerman ? "Interaktiver Rebase" : "Interactive rebase"}</strong>
|
||||||
|
<small>{isGerman ? "Commits ordnen und zusammenfassen" : "Reorder and combine commits"}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="repo-toolbar-spacer"></div>
|
||||||
|
<div class="repo-toolbar-divider utility" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="repo-action-group repo-utility-actions">
|
||||||
|
<button class="repo-action" onclick={onOpenInEditor} disabled={!hasRepository || isBusy} title={isGerman ? `Repository in ${editorName} öffnen` : `Open repository in ${editorName}`}>
|
||||||
|
<Code2 size={15} aria-hidden="true" />
|
||||||
|
<span class="repo-action-label utility-label">{editorName}</span>
|
||||||
|
</button>
|
||||||
|
<button class="repo-action" onclick={onOpenTerminal} disabled={!hasRepository || isBusy} title={isGerman ? `${terminalName} im Repository öffnen` : `Open ${terminalName} in repository`}>
|
||||||
|
<Terminal size={15} aria-hidden="true" />
|
||||||
|
<span class="repo-action-label utility-label">{terminalName}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="repo-action"
|
||||||
|
onclick={onOpenInExplorer}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
|
||||||
|
aria-label={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
|
||||||
|
>
|
||||||
|
<FolderOpen size={15} aria-hidden="true" />
|
||||||
|
<span class="repo-action-label utility-label">{fileManagerName}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="repo-action"
|
||||||
|
onclick={onRefresh}
|
||||||
|
disabled={isBusy || !hasRepository}
|
||||||
|
title={isGerman ? "Manuell aktualisieren" : "Refresh now"}
|
||||||
|
aria-label={isGerman ? "Manuell aktualisieren" : "Refresh now"}
|
||||||
|
>
|
||||||
|
<RefreshCw class={operation === "Refreshing" ? "spin" : ""} size={16} aria-hidden="true" />
|
||||||
|
<span class="repo-action-label utility-label">{isGerman ? "Aktualisieren" : "Refresh"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+34
-162
@@ -1,45 +1,44 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte";
|
import { CircleHelp, Minus, Settings, X } from "@lucide/svelte";
|
||||||
import iconUrl from "../../src-tauri/icons/icon.png";
|
import iconUrl from "../../src-tauri/icons/icon.png";
|
||||||
|
|
||||||
export let branch: string = "";
|
export let onOpenHelp: () => void = () => {};
|
||||||
export let ahead: number = 0;
|
|
||||||
export let behind: number = 0;
|
|
||||||
export let repoName: string = "";
|
|
||||||
export let hasRepository: boolean = false;
|
|
||||||
export let isBusy: boolean = false;
|
|
||||||
export let operation: string = "";
|
|
||||||
export let autoRefreshEnabled: boolean = true;
|
|
||||||
export let autoRefreshInFlight: boolean = false;
|
|
||||||
export let onFetch: () => void = () => {};
|
|
||||||
export let onPull: () => void = () => {};
|
|
||||||
export let onPush: () => void = () => {};
|
|
||||||
export let onRefresh: () => void = () => {};
|
|
||||||
export let onSearch: () => void = () => {};
|
|
||||||
export let onCompare: () => void = () => {};
|
|
||||||
export let onOpenInExplorer: () => void = () => {};
|
|
||||||
export let onToggleAutoRefresh: () => void = () => {};
|
|
||||||
export let onOpenSettings: () => void = () => {};
|
export let onOpenSettings: () => void = () => {};
|
||||||
|
export let language: "en" | "de" = "en";
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
let win: ReturnType<typeof getCurrentWindow> | null = null;
|
||||||
let isMaximized = false;
|
let isMaximized = false;
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
let appVersion = "";
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
win = getCurrentWindow();
|
||||||
isMaximized = await win.isMaximized();
|
isMaximized = await win.isMaximized();
|
||||||
unlisten = await win.onResized(async () => {
|
unlisten = await win.onResized(async () => {
|
||||||
isMaximized = await win.isMaximized();
|
isMaximized = await win?.isMaximized() ?? false;
|
||||||
});
|
});
|
||||||
appVersion = await getVersion();
|
} catch {
|
||||||
|
win = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unlisten?.();
|
unlisten?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function minimizeWindow() {
|
||||||
|
void win?.minimize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMaximizeWindow() {
|
||||||
|
void win?.toggleMaximize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWindow() {
|
||||||
|
void win?.close();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="titlebar" data-tauri-drag-region>
|
<header class="titlebar" data-tauri-drag-region>
|
||||||
@@ -49,163 +48,37 @@
|
|||||||
<img src={iconUrl} alt="" data-tauri-drag-region />
|
<img src={iconUrl} alt="" data-tauri-drag-region />
|
||||||
</span>
|
</span>
|
||||||
<span data-tauri-drag-region>Gitty</span>
|
<span data-tauri-drag-region>Gitty</span>
|
||||||
{#if appVersion}
|
|
||||||
<span class="tb-version" data-tauri-drag-region title="Version {appVersion}">v{appVersion}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: repo + branch info -->
|
<div class="titlebar-drag" data-tauri-drag-region aria-hidden="true"></div>
|
||||||
<div class="titlebar-info" data-tauri-drag-region>
|
|
||||||
{#if hasRepository}
|
|
||||||
{#if repoName}
|
|
||||||
<span class="tb-repo" data-tauri-drag-region>{repoName}</span>
|
|
||||||
<span class="tb-sep" data-tauri-drag-region aria-hidden="true">/</span>
|
|
||||||
{/if}
|
|
||||||
<GitBranch size={12} aria-hidden="true" />
|
|
||||||
<span class="tb-branch" data-tauri-drag-region>{branch}</span>
|
|
||||||
{#if ahead > 0}
|
|
||||||
<span class="tb-sync ahead" title="{ahead} commits ahead">↑{ahead}</span>
|
|
||||||
{/if}
|
|
||||||
{#if behind > 0}
|
|
||||||
<span class="tb-sync behind" title="{behind} commits behind">↓{behind}</span>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<span class="tb-no-repo" data-tauri-drag-region>No repository open</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: actions + window controls -->
|
<!-- Right: app-global actions + window controls -->
|
||||||
<div class="titlebar-right">
|
<div class="titlebar-globals">
|
||||||
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
|
|
||||||
<button
|
<button
|
||||||
class="tb-action"
|
class="tb-action"
|
||||||
onclick={onOpenInExplorer}
|
onclick={onOpenHelp}
|
||||||
disabled={!hasRepository || isBusy}
|
title={language === "de" ? "Hilfe (Ctrl+/)" : "Help (Ctrl+/)"}
|
||||||
title="Open repository in Explorer"
|
aria-label={language === "de" ? "Hilfe öffnen" : "Open help"}
|
||||||
aria-label="Open repository in Explorer"
|
|
||||||
>
|
>
|
||||||
<FolderOpen size={14} aria-hidden="true" />
|
<CircleHelp size={14} aria-hidden="true" />
|
||||||
<span class="tb-action-label">Explorer</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onSearch}
|
|
||||||
disabled={!hasRepository || isBusy}
|
|
||||||
title="Global search"
|
|
||||||
aria-label="Global search"
|
|
||||||
>
|
|
||||||
<Search size={14} aria-hidden="true" />
|
|
||||||
<span class="tb-action-label">Search</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onCompare}
|
|
||||||
disabled={!hasRepository || isBusy}
|
|
||||||
title="Compare commits"
|
|
||||||
aria-label="Compare commits"
|
|
||||||
>
|
|
||||||
<GitCompare size={14} aria-hidden="true" />
|
|
||||||
<span class="tb-action-label">Compare</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onFetch}
|
|
||||||
disabled={!hasRepository || isBusy}
|
|
||||||
title="Fetch"
|
|
||||||
aria-label="Fetch"
|
|
||||||
>
|
|
||||||
{#if operation === "Fetching"}
|
|
||||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
|
||||||
{:else}
|
|
||||||
<CloudDownload size={14} aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
<span class="tb-action-label">Fetch</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onPull}
|
|
||||||
disabled={!hasRepository || isBusy}
|
|
||||||
title="Pull"
|
|
||||||
aria-label="Pull"
|
|
||||||
>
|
|
||||||
{#if operation === "Pulling"}
|
|
||||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
|
||||||
{:else}
|
|
||||||
<Download size={14} aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
<span class="tb-action-label">Pull</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onPush}
|
|
||||||
disabled={!hasRepository || isBusy}
|
|
||||||
title="Push"
|
|
||||||
aria-label="Push"
|
|
||||||
>
|
|
||||||
{#if operation === "Pushing"}
|
|
||||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
|
||||||
{:else}
|
|
||||||
<Upload size={14} aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
<span class="tb-action-label">Push</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onRefresh}
|
|
||||||
disabled={isBusy || !hasRepository}
|
|
||||||
title="Refresh"
|
|
||||||
aria-label="Refresh"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
class={operation === "Refreshing" ? "spin" : ""}
|
|
||||||
size={14}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span class="tb-action-label">Refresh</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action auto-toggle"
|
|
||||||
class:active={autoRefreshEnabled}
|
|
||||||
onclick={onToggleAutoRefresh}
|
|
||||||
aria-pressed={autoRefreshEnabled}
|
|
||||||
title={autoRefreshEnabled ? "Auto-refresh on — click to disable" : "Auto-refresh off — click to enable"}
|
|
||||||
aria-label="Toggle auto-refresh"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
class={autoRefreshInFlight ? "spin" : ""}
|
|
||||||
size={14}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span class="tb-action-label">Auto</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="tb-action"
|
class="tb-action"
|
||||||
onclick={onOpenSettings}
|
onclick={onOpenSettings}
|
||||||
title="Settings"
|
title={language === "de" ? "Einstellungen" : "Settings"}
|
||||||
aria-label="Settings"
|
aria-label={language === "de" ? "Einstellungen" : "Settings"}
|
||||||
>
|
>
|
||||||
<Settings size={14} aria-hidden="true" />
|
<Settings size={14} aria-hidden="true" />
|
||||||
<span class="tb-action-label">Settings</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="titlebar-divider" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<div class="titlebar-controls">
|
<div class="titlebar-controls">
|
||||||
<button class="tb-btn" onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
<button class="tb-btn" onclick={minimizeWindow} title="Minimize" aria-label="Minimize">
|
||||||
<Minus size={12} aria-hidden="true" />
|
<Minus size={12} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="tb-btn"
|
class="tb-btn"
|
||||||
onclick={() => win.toggleMaximize()}
|
onclick={toggleMaximizeWindow}
|
||||||
title={isMaximized ? "Restore" : "Maximize"}
|
title={isMaximized ? "Restore" : "Maximize"}
|
||||||
aria-label={isMaximized ? "Restore" : "Maximize"}
|
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||||
>
|
>
|
||||||
@@ -220,9 +93,8 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="tb-btn close" onclick={() => win.close()} title="Close" aria-label="Close">
|
<button class="tb-btn close" onclick={closeWindow} title="Close" aria-label="Close">
|
||||||
<X size={12} aria-hidden="true" />
|
<X size={12} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { telemetryLog } from "./telemetry";
|
||||||
|
|
||||||
export type AnalyticsEventProperties = Record<string, string | number>;
|
export type AnalyticsEventProperties = Record<string, string | number>;
|
||||||
|
|
||||||
export function trackAnalyticsEvent(name: string, props?: AnalyticsEventProperties) {
|
export function trackAnalyticsEvent(name: string, props?: AnalyticsEventProperties) {
|
||||||
|
const details = props && Object.keys(props).length > 0 ? ` ${JSON.stringify(props)}` : "";
|
||||||
|
telemetryLog("info", `${name}${details}`, `product.${name}`);
|
||||||
void invoke("plugin:aptabase|track_event", {
|
void invoke("plugin:aptabase|track_event", {
|
||||||
name,
|
name,
|
||||||
props: props && Object.keys(props).length > 0 ? props : undefined,
|
props: props && Object.keys(props).length > 0 ? props : undefined,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertTriangle, CircleAlert, FileCode, Info, LoaderCircle, RotateCw, ShieldCheck, X } from "@lucide/svelte";
|
||||||
|
import type { AiReviewFinding, AiReviewResult, CommitAiProvider } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
result: AiReviewResult;
|
||||||
|
provider: CommitAiProvider;
|
||||||
|
isReviewing: boolean;
|
||||||
|
onRerun: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { result, provider, isReviewing = false, onRerun, onClose }: Props = $props();
|
||||||
|
|
||||||
|
function providerLabel(value: CommitAiProvider): string {
|
||||||
|
if (value === "openai") return "OpenAI";
|
||||||
|
if (value === "anthropic") return "Anthropic";
|
||||||
|
if (value === "custom") return "Custom endpoint";
|
||||||
|
return "Local AI";
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationLabel(finding: AiReviewFinding): string {
|
||||||
|
if (!finding.file) return "Repository-wide";
|
||||||
|
return finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={(event) => { if (event.key === "Escape" && !isReviewing) onClose(); }} />
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog ai-review-dialog" role="dialog" aria-modal="true" aria-label="AI pre-commit review" tabindex="-1">
|
||||||
|
<header class="dialog-header ai-review-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Staged changes</span>
|
||||||
|
<h2>AI pre-commit review</h2>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-header-actions">
|
||||||
|
<span class="ai-review-provider">{providerLabel(provider)}</span>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isReviewing} aria-label="Close review"><X size={18} aria-hidden="true" /></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="ai-review-summary">
|
||||||
|
<div class="ai-review-summary-icon" class:clean={result.findings.length === 0}>
|
||||||
|
{#if result.findings.length === 0}<ShieldCheck size={22} aria-hidden="true" />{:else}<AlertTriangle size={22} aria-hidden="true" />{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="ai-review-summary-line">
|
||||||
|
<strong>{result.findings.length === 0 ? "No actionable issues found" : `${result.findings.length} review ${result.findings.length === 1 ? "finding" : "findings"}`}</strong>
|
||||||
|
<span class="ai-review-risk {result.risk}">{result.risk} risk</span>
|
||||||
|
</div>
|
||||||
|
<p>{result.summary}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-review-findings">
|
||||||
|
{#if result.findings.length === 0}
|
||||||
|
<div class="ai-review-clean-state">
|
||||||
|
<ShieldCheck size={30} aria-hidden="true" />
|
||||||
|
<strong>The staged diff looks ready for human verification.</strong>
|
||||||
|
<span>AI reviews can miss issues. Run the relevant tests before committing.</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each result.findings as finding, index (`${finding.file ?? "repo"}:${finding.line ?? 0}:${finding.title}:${index}`)}
|
||||||
|
<article class="ai-review-finding {finding.severity}">
|
||||||
|
<div class="ai-review-finding-icon">
|
||||||
|
{#if finding.severity === "critical"}<CircleAlert size={17} aria-hidden="true" />{:else if finding.severity === "warning"}<AlertTriangle size={17} aria-hidden="true" />{:else}<Info size={17} aria-hidden="true" />{/if}
|
||||||
|
</div>
|
||||||
|
<div class="ai-review-finding-body">
|
||||||
|
<div class="ai-review-finding-title">
|
||||||
|
<span>{finding.severity}</span>
|
||||||
|
<strong>{finding.title}</strong>
|
||||||
|
</div>
|
||||||
|
<p>{finding.description}</p>
|
||||||
|
<div class="ai-review-location"><FileCode size={13} aria-hidden="true" /><code>{locationLabel(finding)}</code></div>
|
||||||
|
{#if finding.suggestion}<div class="ai-review-suggestion"><strong>Suggested next step</strong><span>{finding.suggestion}</span></div>{/if}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="dialog-footer ai-review-footer">
|
||||||
|
<p>Review suggestions are advisory and never modify files automatically.</p>
|
||||||
|
<div>
|
||||||
|
<button class="btn-secondary" type="button" onclick={onRerun} disabled={isReviewing}>
|
||||||
|
{#if isReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<RotateCw size={14} aria-hidden="true" />{/if}
|
||||||
|
Review again
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" type="button" onclick={onClose} disabled={isReviewing}>Done</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -30,10 +30,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="analytics-notice-copy">
|
<div class="analytics-notice-copy">
|
||||||
<p>
|
<p>
|
||||||
Gitty can send anonymous usage events to Aptabase so crashes, rough edges, and commonly used workflows are easier to improve.
|
Gitty can send anonymous usage events and technical error logs to Aptabase and the self-hosted SigNoz service so crashes, rough edges, and commonly used workflows are easier to improve.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Events do not include repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, or source code.
|
Telemetry does not include repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, or source code.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="dialog-footer analytics-notice-footer">
|
<footer class="dialog-footer analytics-notice-footer">
|
||||||
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage events.</span>
|
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage and technical errors.</span>
|
||||||
<button class="btn-primary" type="button" onclick={() => onContinue(allowAnalytics)}>
|
<button class="btn-primary" type="button" onclick={() => onContinue(allowAnalytics)}>
|
||||||
<Check size={16} aria-hidden="true" />
|
<Check size={16} aria-hidden="true" />
|
||||||
Continue
|
Continue
|
||||||
|
|||||||
@@ -1,19 +1,92 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, Settings, X } from "@lucide/svelte";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import type { AnalyticsSettings } 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;
|
||||||
onSave: (settings: AnalyticsSettings) => void;
|
theme: AppTheme;
|
||||||
|
language: AppLanguage;
|
||||||
|
autoRefresh: boolean;
|
||||||
|
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, 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 selectedLanguage = $state<AppLanguage>("en");
|
||||||
|
let autoRefreshEnabled = $state(true);
|
||||||
|
let tools = $state<ExternalToolsSettings>(defaultExternalToolsSettings());
|
||||||
|
const isGerman = $derived(selectedLanguage === "de");
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
analyticsEnabled = analytics.enabled;
|
analyticsEnabled = analytics.enabled;
|
||||||
|
selectedTheme = theme;
|
||||||
|
selectedLanguage = language;
|
||||||
|
autoRefreshEnabled = autoRefresh;
|
||||||
|
tools = structuredClone(externalTools);
|
||||||
});
|
});
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
@@ -21,48 +94,482 @@
|
|||||||
...analytics,
|
...analytics,
|
||||||
enabled: analyticsEnabled,
|
enabled: analyticsEnabled,
|
||||||
noticeSeen: true,
|
noticeSeen: true,
|
||||||
|
}, 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="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">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="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">Analytics</span>
|
|
||||||
<h3>Anonymous usage analytics</h3>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<label class="settings-toggle-row">
|
|
||||||
<input type="checkbox" bind:checked={analyticsEnabled} />
|
|
||||||
<span>
|
<span>
|
||||||
<strong>Allow anonymous Aptabase events</strong>
|
<strong>{isGerman ? "Allgemein" : "General"}</strong>
|
||||||
<small>No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent.</small>
|
<small>{isGerman ? "Darstellung & Verhalten" : "Appearance & behavior"}</small>
|
||||||
</span>
|
</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"}>
|
||||||
|
<label class:active={selectedTheme === "system"}><input type="radio" bind:group={selectedTheme} value="system" /><span>System</span></label>
|
||||||
|
<label class:active={selectedTheme === "light"}><input type="radio" bind:group={selectedTheme} value="light" /><span>{isGerman ? "Hell" : "Light"}</span></label>
|
||||||
|
<label class:active={selectedTheme === "dark"}><input type="radio" bind:group={selectedTheme} value="dark" /><span>{isGerman ? "Dunkel" : "Dark"}</span></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="general-setting-panel">
|
||||||
|
<header><Languages size={16} /><div><h4>{isGerman ? "Sprache" : "Language"}</h4><p>{isGerman ? "Sprache der Oberfläche." : "Language used by the interface."}</p></div></header>
|
||||||
|
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
|
||||||
|
<label class:active={selectedLanguage === "en"}><input type="radio" bind:group={selectedLanguage} value="en" /><span>EN · English</span></label>
|
||||||
|
<label class:active={selectedLanguage === "de"}><input type="radio" bind:group={selectedLanguage} value="de" /><span>DE · Deutsch</span></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="general-setting-panel general-setting-wide">
|
||||||
|
<header><RefreshCw size={16} /><div><h4>{isGerman ? "Repository-Aktualisierung" : "Repository refresh"}</h4><p>{isGerman ? "Arbeitsbereich und Remotes aktuell halten." : "Keep the working tree and remotes current."}</p></div></header>
|
||||||
|
<label class="settings-switch-row">
|
||||||
|
<span><strong>{isGerman ? "Automatisch aktualisieren" : "Refresh automatically"}</strong><small>{isGerman ? "Branch-Status und Änderungen regelmäßig im Hintergrund prüfen." : "Periodically check branch state and working-tree changes."}</small></span>
|
||||||
|
<input type="checkbox" bind:checked={autoRefreshEnabled} />
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="new-branch-actions">
|
<section class="general-setting-panel general-setting-wide">
|
||||||
<button class="btn-secondary" type="button" onclick={onClose}>Cancel</button>
|
<header><ShieldCheck size={16} /><div><h4>{isGerman ? "Datenschutz" : "Privacy"}</h4><p>{isGerman ? "Anonyme Produkt- und Fehlerdiagnose." : "Anonymous product and error diagnostics."}</p></div></header>
|
||||||
<button class="btn-primary" type="submit">
|
<label class="settings-switch-row">
|
||||||
<Check size={16} aria-hidden="true" />
|
<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>
|
||||||
Save
|
<input type="checkbox" bind:checked={analyticsEnabled} />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="settings-page-head tools-page-head">
|
||||||
|
<div>
|
||||||
|
<h3>{isGerman ? "Externe Tools" : "External tools"}</h3>
|
||||||
|
<p>
|
||||||
|
{detectionUnavailable
|
||||||
|
? (isGerman ? "Automatische Erkennung ist in dieser Umgebung nicht verfügbar." : "Automatic detection is unavailable in this environment.")
|
||||||
|
: (isGerman ? `${detectedTools.length} installierte Programme erkannt.` : `${detectedTools.length} installed applications detected.`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="tool-rescan-button" type="button" onclick={onRefreshDetectedTools} disabled={detectionPending}>
|
||||||
|
<RotateCw class={detectionPending ? "spin" : ""} size={14} aria-hidden="true" />
|
||||||
|
{detectionPending ? (isGerman ? "Erkennung läuft…" : "Detecting…") : (isGerman ? "Neu erkennen" : "Detect again")}
|
||||||
</button>
|
</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 {
|
||||||
|
|||||||
@@ -18,16 +18,24 @@
|
|||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
|
let title = $derived(branch.remote ? "Delete remote branch?" : force ? "Force delete branch?" : "Delete branch?");
|
||||||
|
let remoteParts = $derived(branch.remote ? branch.name.split(/\/(.+)/) : []);
|
||||||
|
let branchName = $derived(branch.remote ? remoteParts[1] || branch.name : branch.name);
|
||||||
|
let branchLocation = $derived(branch.remote ? remoteParts[0] || "Remote" : "Local repository");
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation">
|
<div class="dialog-backdrop" role="presentation">
|
||||||
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="branch-delete-title">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header branch-delete-header">
|
||||||
|
<div class="branch-delete-heading">
|
||||||
|
<span class:force class="branch-delete-heading-icon" aria-hidden="true">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">{force ? "Force delete" : "Delete branch"}</span>
|
<span class="eyebrow">{branch.remote ? "Remote branch" : force ? "Force delete" : "Delete branch"}</span>
|
||||||
<p class="dialog-title">{title}</p>
|
<p class="dialog-title" id="branch-delete-title">{title}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
||||||
@@ -35,27 +43,35 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="branch-delete-body">
|
<div class="branch-delete-body">
|
||||||
<div class="discard-warning-icon" aria-hidden="true">
|
<div class:force class="discard-warning-icon branch-delete-warning-icon" aria-hidden="true">
|
||||||
<AlertTriangle size={22} />
|
<AlertTriangle size={22} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="discard-confirm-copy">
|
<div class="discard-confirm-copy">
|
||||||
<p>
|
<p class="branch-delete-lead">
|
||||||
{#if force}
|
{#if branch.remote}
|
||||||
This branch is not fully merged. Force deleting removes the branch pointer even if some commits are only reachable from this branch.
|
This branch will be removed from the shared remote repository.
|
||||||
|
{:else if force}
|
||||||
|
This branch is not fully merged. Some commits may only exist here.
|
||||||
{:else}
|
{:else}
|
||||||
Delete this local branch from the repository?
|
This branch will be removed from your local repository.
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
<code class="discard-target" title={branch.name}>
|
<div class="branch-delete-target" title={branch.name}>
|
||||||
<GitBranch size={13} aria-hidden="true" />
|
<span class="branch-delete-target-icon" aria-hidden="true"><GitBranch size={16} /></span>
|
||||||
{branch.name}
|
<span class="branch-delete-target-copy">
|
||||||
</code>
|
<code>{branchName}</code>
|
||||||
|
<span>{branchLocation}</span>
|
||||||
|
</span>
|
||||||
|
<span class:remote={branch.remote} class="branch-delete-scope">{branch.remote ? "Remote" : "Local"}</span>
|
||||||
|
</div>
|
||||||
<p class="discard-warning-text">
|
<p class="discard-warning-text">
|
||||||
{#if force}
|
{#if branch.remote}
|
||||||
Make sure you no longer need the unique commits on this branch.
|
This affects everyone using <strong>{remoteParts[0] || "the remote"}</strong>. Your local branch is kept.
|
||||||
|
{:else if force}
|
||||||
|
Force deletion can make unmerged commits difficult to recover.
|
||||||
{:else}
|
{:else}
|
||||||
Git will refuse if the branch is not fully merged.
|
Git will stop the deletion if the branch contains unmerged commits.
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,13 +79,13 @@
|
|||||||
|
|
||||||
<footer class="discard-confirm-actions">
|
<footer class="discard-confirm-actions">
|
||||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
|
||||||
<button class="btn-danger" type="button" onclick={onConfirm} disabled={isBusy}>
|
<button class="btn-danger branch-delete-confirm" type="button" onclick={onConfirm} disabled={isBusy}>
|
||||||
{#if isBusy}
|
{#if isBusy}
|
||||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<Trash2 size={15} aria-hidden="true" />
|
<Trash2 size={15} aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
{force ? "Force delete" : "Delete"}
|
{branch.remote ? "Delete from remote" : force ? "Force delete" : "Delete"}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, 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,14 +52,19 @@
|
|||||||
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>;
|
||||||
|
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>;
|
||||||
|
onManageWorktrees: () => void;
|
||||||
|
onCreateWorktree: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
onToggleCollapsed?: () => void;
|
onToggleCollapsed?: () => void;
|
||||||
}
|
}
|
||||||
@@ -69,14 +77,19 @@
|
|||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
onCheckout = () => {},
|
onCheckout = () => {},
|
||||||
|
onCompareBranch = () => {},
|
||||||
onMerge = () => {},
|
onMerge = () => {},
|
||||||
onRebase = () => {},
|
onRebase = () => {},
|
||||||
onCreateBranch = () => {},
|
onCreateBranch = () => {},
|
||||||
onRenameBranch = () => {},
|
onRenameBranch = () => {},
|
||||||
onDeleteBranch = () => {},
|
onDeleteBranch = () => {},
|
||||||
|
onDeleteRemoteBranch = () => {},
|
||||||
|
onDeleteBranchFolder = () => {},
|
||||||
onCreateTag = () => {},
|
onCreateTag = () => {},
|
||||||
onDeleteTag = () => {},
|
onDeleteTag = () => {},
|
||||||
onPushTag = () => {},
|
onPushTag = () => {},
|
||||||
|
onManageWorktrees = () => {},
|
||||||
|
onCreateWorktree = () => {},
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
onToggleCollapsed = () => {},
|
onToggleCollapsed = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -91,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());
|
||||||
@@ -111,6 +126,7 @@
|
|||||||
children: [],
|
children: [],
|
||||||
branchCount: 0,
|
branchCount: 0,
|
||||||
current: false,
|
current: false,
|
||||||
|
branches: [],
|
||||||
folders: new Map(),
|
folders: new Map(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -137,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,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)) {
|
||||||
@@ -235,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() {
|
||||||
@@ -262,11 +308,32 @@
|
|||||||
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 || branch.remote || isBusy) return;
|
if (!branch || branch.current || isBusy) return;
|
||||||
closeBranchContextMenu();
|
closeBranchContextMenu();
|
||||||
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() {
|
||||||
|
const branch = contextBranch;
|
||||||
|
closeBranchContextMenu();
|
||||||
|
if (!branch) return;
|
||||||
|
await onCreateWorktree(branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkoutContextBranch() {
|
async function checkoutContextBranch() {
|
||||||
@@ -313,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() {
|
||||||
@@ -359,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>
|
||||||
@@ -398,8 +465,6 @@
|
|||||||
<!-- collapsed -->
|
<!-- collapsed -->
|
||||||
{:else if !hasRepository}
|
{:else if !hasRepository}
|
||||||
<p class="blank-state">Open a repository to list branches.</p>
|
<p class="blank-state">Open a repository to list branches.</p>
|
||||||
{:else if branches.length === 0 && tags.length === 0}
|
|
||||||
<p class="blank-state">No branches returned.</p>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
||||||
{#if createOpen}
|
{#if createOpen}
|
||||||
@@ -451,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})`}
|
||||||
>
|
>
|
||||||
@@ -518,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})`}
|
||||||
>
|
>
|
||||||
@@ -636,11 +703,27 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="branch-group">
|
||||||
|
<button
|
||||||
|
class="branch-group-toggle worktree-group-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={onManageWorktrees}
|
||||||
|
disabled={isBusy}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
title="Manage repository worktrees"
|
||||||
|
>
|
||||||
|
<HardDrive size={14} aria-hidden="true" />
|
||||||
|
<span>Worktrees</span>
|
||||||
|
<span class="branch-group-count">Manage</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#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"
|
||||||
@@ -651,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
|
||||||
@@ -659,27 +746,55 @@
|
|||||||
<GitBranch size={14} aria-hidden="true" />
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
Rebase current onto this
|
Rebase current onto this
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={createContextWorktree} disabled={isBusy || contextBranch.remote || contextBranch.current}>
|
||||||
|
<HardDrive size={14} aria-hidden="true" />
|
||||||
|
Open in new worktree
|
||||||
|
</button>
|
||||||
<div class="menu-separator" role="separator"></div>
|
<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"
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onclick={deleteContextBranch}
|
onclick={deleteContextBranch}
|
||||||
disabled={isBusy || contextBranch.current || contextBranch.remote}
|
disabled={isBusy || contextBranch.current}
|
||||||
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Remote branch cannot be deleted here" : "Delete local branch"}
|
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Delete remote branch" : "Delete local branch"}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} aria-hidden="true" />
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
Delete
|
{contextBranch.remote ? "Delete remote" : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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, 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 {
|
||||||
@@ -13,11 +13,15 @@
|
|||||||
commitAiProvider: CommitAiProvider;
|
commitAiProvider: CommitAiProvider;
|
||||||
commitAiPhase: CommitAiPhase;
|
commitAiPhase: CommitAiPhase;
|
||||||
commitAiGenerating: boolean;
|
commitAiGenerating: 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;
|
||||||
|
onSplitStaged: () => void;
|
||||||
onOpenAiSettings: () => void;
|
onOpenAiSettings: () => void;
|
||||||
onToggleAmend: (checked: boolean) => void;
|
onToggleAmend: (checked: boolean) => void;
|
||||||
onUndoLastCommit: () => void;
|
onUndoLastCommit: () => void;
|
||||||
@@ -34,11 +38,15 @@
|
|||||||
commitAiProvider = "local",
|
commitAiProvider = "local",
|
||||||
commitAiPhase = "idle",
|
commitAiPhase = "idle",
|
||||||
commitAiGenerating = false,
|
commitAiGenerating = false,
|
||||||
|
commitAiReviewing = false,
|
||||||
|
commitAiSplitting = false,
|
||||||
canAmend = false,
|
canAmend = false,
|
||||||
amendMode = false,
|
amendMode = false,
|
||||||
onCommit = () => {},
|
onCommit = () => {},
|
||||||
onCommitMessageChange = () => {},
|
onCommitMessageChange = () => {},
|
||||||
onGenerateCommitMessage = () => {},
|
onGenerateCommitMessage = () => {},
|
||||||
|
onReviewStaged = () => {},
|
||||||
|
onSplitStaged = () => {},
|
||||||
onOpenAiSettings = () => {},
|
onOpenAiSettings = () => {},
|
||||||
onToggleAmend = () => {},
|
onToggleAmend = () => {},
|
||||||
onUndoLastCommit = () => {},
|
onUndoLastCommit = () => {},
|
||||||
@@ -61,25 +69,63 @@
|
|||||||
hasRepository &&
|
hasRepository &&
|
||||||
!isBusy &&
|
!isBusy &&
|
||||||
!commitAiGenerating &&
|
!commitAiGenerating &&
|
||||||
|
!commitAiReviewing &&
|
||||||
stagedCount > 0 &&
|
stagedCount > 0 &&
|
||||||
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
||||||
);
|
);
|
||||||
|
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">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Commit</span>
|
<span class="eyebrow">Create revision</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Message</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commit</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<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
|
||||||
|
class="commit-review-button"
|
||||||
|
type="button"
|
||||||
|
onclick={onReviewStaged}
|
||||||
|
disabled={!canReview}
|
||||||
|
title={commitAiProvider === "local" ? "Pre-commit review currently requires an API provider" : stagedCount === 0 ? "Stage changes first" : "Review staged changes for bugs and risks"}
|
||||||
|
>
|
||||||
|
{#if commitAiReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<ShieldCheck size={14} aria-hidden="true" />{/if}
|
||||||
|
Review
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="commit-generate-button"
|
||||||
|
type="button"
|
||||||
|
onclick={onGenerateCommitMessage}
|
||||||
|
disabled={!canGenerate}
|
||||||
|
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
|
||||||
|
>
|
||||||
|
{#if commitAiGenerating || localModelLoading}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<Sparkles size={14} aria-hidden="true" />{/if}
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
<button class="commit-settings-button" type="button" onclick={onOpenAiSettings} disabled={isBusy} title="AI settings" aria-label="AI settings">
|
||||||
|
<Settings size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="commit-form" onsubmit={handleSubmit}>
|
<form class="commit-form" onsubmit={handleSubmit}>
|
||||||
<textarea
|
<textarea
|
||||||
value={commitMessage}
|
value={commitMessage}
|
||||||
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
|
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
|
||||||
placeholder="Commit message..."
|
placeholder="Commit message"
|
||||||
disabled={!hasRepository || isBusy}
|
disabled={!hasRepository || isBusy}
|
||||||
></textarea>
|
></textarea>
|
||||||
{#if commitBlockReason}
|
{#if commitBlockReason}
|
||||||
@@ -117,30 +163,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{amendMode ? "Amend" : "Commit"}
|
{amendMode ? "Amend" : "Commit"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
class="btn-secondary commit-ai-button"
|
|
||||||
type="button"
|
|
||||||
onclick={onGenerateCommitMessage}
|
|
||||||
disabled={!canGenerate}
|
|
||||||
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
|
|
||||||
>
|
|
||||||
{#if commitAiGenerating || localModelLoading}
|
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
|
||||||
{:else}
|
|
||||||
<Sparkles size={16} aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
AI
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-secondary commit-ai-settings-button"
|
|
||||||
type="button"
|
|
||||||
onclick={onOpenAiSettings}
|
|
||||||
disabled={isBusy}
|
|
||||||
title="AI settings"
|
|
||||||
aria-label="AI settings"
|
|
||||||
>
|
|
||||||
<Settings size={16} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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 =
|
||||||
@@ -10,13 +10,22 @@
|
|||||||
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
|
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface DiffMarker {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
kind: "add" | "delete" | "mixed";
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
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;
|
||||||
@@ -26,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
|
||||||
@@ -173,26 +187,59 @@
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDiffMarkers(rows: SplitRow[]): DiffMarker[] {
|
||||||
|
const markers: DiffMarker[] = [];
|
||||||
|
let current: DiffMarker | null = null;
|
||||||
|
for (let index = 0; index < rows.length; index++) {
|
||||||
|
const row = rows[index];
|
||||||
|
if (row.type !== "pair" || (row.leftKind === "context" && row.rightKind === "context")) {
|
||||||
|
current = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const kind = row.leftKind === "del" && row.rightKind === "add"
|
||||||
|
? "mixed"
|
||||||
|
: row.rightKind === "add" ? "add" : "delete";
|
||||||
|
if (current && current.end === index - 1 && current.kind === kind) {
|
||||||
|
current.end = index;
|
||||||
|
} else {
|
||||||
|
current = { start: index, end: index, kind };
|
||||||
|
markers.push(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToDiffMarker(rowIndex: number) {
|
||||||
|
const ratio = rowIndex / Math.max(splitRows.length - 1, 1);
|
||||||
|
for (const pane of [beforePane, afterPane]) {
|
||||||
|
if (pane) pane.scrollTop = ratio * Math.max(pane.scrollHeight - pane.clientHeight, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let diffByPath = $derived(buildDiffByPath(comparison.patch));
|
let diffByPath = $derived(buildDiffByPath(comparison.patch));
|
||||||
let selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
|
let selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
|
||||||
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
|
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
|
||||||
let splitRows = $derived(buildSplitRows(selectedPatch));
|
let splitRows = $derived(buildSplitRows(selectedPatch));
|
||||||
|
let diffMarkers = $derived(buildDiffMarkers(splitRows));
|
||||||
</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}
|
||||||
@@ -201,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"
|
||||||
@@ -235,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">
|
||||||
@@ -252,16 +303,17 @@
|
|||||||
<!-- 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>
|
||||||
|
|
||||||
<!-- Split diff grid -->
|
<!-- Split diff grid -->
|
||||||
|
<div class="split-diff-shell">
|
||||||
<div class="split-diff" role="table" aria-label="Side-by-side diff">
|
<div class="split-diff" role="table" aria-label="Side-by-side diff">
|
||||||
<div
|
<div
|
||||||
class="split-pane"
|
class="split-pane"
|
||||||
@@ -298,6 +350,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="diff-overview" aria-label="Change overview">
|
||||||
|
{#each diffMarkers as marker, index (`${marker.start}-${marker.end}-${marker.kind}`)}
|
||||||
|
<button
|
||||||
|
class="diff-overview-marker {marker.kind}"
|
||||||
|
type="button"
|
||||||
|
style={`--marker-position: ${(marker.start / Math.max(splitRows.length - 1, 1)) * 100}%`}
|
||||||
|
onclick={() => scrollToDiffMarker(marker.start)}
|
||||||
|
title={`Jump to change ${index + 1} of ${diffMarkers.length}`}
|
||||||
|
aria-label={`Jump to change ${index + 1} of ${diffMarkers.length}`}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</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} />
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
import type { GitFileStatus } from "../types";
|
import type { GitFileStatus } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
file: GitFileStatus;
|
files: GitFileStatus[];
|
||||||
staged: boolean;
|
staged: boolean | null;
|
||||||
scope: "file" | "hunk";
|
scope: "file" | "hunk" | "lines";
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onConfirm: () => void | Promise<void>;
|
onConfirm: () => void | Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
file,
|
files,
|
||||||
staged = false,
|
staged = false,
|
||||||
scope = "file",
|
scope = "file",
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
@@ -20,10 +20,16 @@
|
|||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let targetPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
function targetPath(file: GitFileStatus): string {
|
||||||
let title = $derived(scope === "hunk" ? "Discard hunk?" : "Discard file changes?");
|
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
||||||
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
}
|
||||||
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
|
||||||
|
let count = $derived(files.length);
|
||||||
|
let title = $derived(
|
||||||
|
scope === "hunk" ? "Discard hunk?" : scope === "lines" ? "Discard selected lines?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
|
||||||
|
);
|
||||||
|
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : scope === "lines" ? "selected lines" : count > 1 ? `${count} files` : "file");
|
||||||
|
let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -46,11 +52,22 @@
|
|||||||
|
|
||||||
<div class="discard-confirm-copy">
|
<div class="discard-confirm-copy">
|
||||||
<p>
|
<p>
|
||||||
This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below.
|
This will reset the {sourceLabel} for the {scopeLabel} below.
|
||||||
</p>
|
</p>
|
||||||
<code class="discard-target" title={targetPath}>{targetPath}</code>
|
{#if count > 1}
|
||||||
|
<ul class="discard-target-list">
|
||||||
|
{#each files.slice(0, 8) as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||||
|
<li><code class="discard-target" title={targetPath(file)}>{targetPath(file)}</code></li>
|
||||||
|
{/each}
|
||||||
|
{#if files.length > 8}
|
||||||
|
<li class="discard-target-more">+{files.length - 8} more</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{:else if count === 1}
|
||||||
|
<code class="discard-target" title={targetPath(files[0])}>{targetPath(files[0])}</code>
|
||||||
|
{/if}
|
||||||
<p class="discard-warning-text">
|
<p class="discard-warning-text">
|
||||||
This cannot be undone. If the file only exists in your working tree, it can be deleted entirely.
|
This cannot be undone. If a file only exists in your working tree, it can be deleted entirely.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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}>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,52 +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;
|
||||||
|
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 = () => {},
|
||||||
|
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];
|
||||||
@@ -115,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,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(", ")}`;
|
||||||
}
|
}
|
||||||
@@ -250,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 {
|
||||||
@@ -327,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() {
|
||||||
@@ -357,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();
|
||||||
@@ -406,9 +494,59 @@
|
|||||||
await onCherryPickCommit(commit);
|
await onCherryPickCommit(commit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function revertContextCommit() {
|
||||||
|
const commit = contextCommit;
|
||||||
|
if (!commit || isBusy) return;
|
||||||
|
closeCommitContextMenu();
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,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));
|
||||||
@@ -509,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}
|
||||||
@@ -575,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"
|
||||||
@@ -586,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)}
|
||||||
@@ -622,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">
|
||||||
@@ -672,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"
|
||||||
@@ -689,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}
|
||||||
|
|
||||||
@@ -704,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
|
||||||
@@ -718,6 +1166,9 @@
|
|||||||
<Cherry size={14} aria-hidden="true" />
|
<Cherry size={14} aria-hidden="true" />
|
||||||
Cherry-pick
|
Cherry-pick
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title="Create a new commit that reverses this commit">
|
||||||
|
<RotateCcw size={14} aria-hidden="true" /> Revert
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
@@ -741,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
|
||||||
@@ -760,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>
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
|
||||||
|
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
|
interface PlanRow extends RebaseCommit {
|
||||||
|
action: RebaseAction;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
branches: GitBranchInfo[];
|
||||||
|
currentBranch: string;
|
||||||
|
base: string;
|
||||||
|
commits: RebaseCommit[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
|
operation: string;
|
||||||
|
error: string;
|
||||||
|
onBaseChange: (base: string) => void;
|
||||||
|
onStart: (plan: RebasePlanItem[]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
branches = [], currentBranch = "", base = "", commits = [], isLoading = false,
|
||||||
|
isBusy = false, operation = "", error = "", onBaseChange = () => {},
|
||||||
|
onStart = () => {}, onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let rows = $state<PlanRow[]>([]);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
rows = commits.map((commit) => ({ ...commit, action: "pick", message: commit.summary }));
|
||||||
|
});
|
||||||
|
|
||||||
|
let availableBases = $derived(branches.filter((branch) => !branch.current));
|
||||||
|
let keptCount = $derived(rows.filter((row) => row.action !== "drop").length);
|
||||||
|
let invalidSquash = $derived(rows.some((row, index) =>
|
||||||
|
(row.action === "squash" || row.action === "fixup")
|
||||||
|
&& rows.slice(0, index).every((previous) => previous.action === "drop")
|
||||||
|
));
|
||||||
|
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);
|
||||||
|
const rebaseActions: RebaseAction[] = ["pick", "reword", "squash", "fixup", "drop"];
|
||||||
|
|
||||||
|
function updateAction(index: number, action: RebaseAction) {
|
||||||
|
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, action } : row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMessage(index: number, message: string) {
|
||||||
|
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, message } : row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(index: number, direction: -1 | 1) {
|
||||||
|
const target = index + direction;
|
||||||
|
if (target < 0 || target >= rows.length) return;
|
||||||
|
const next = [...rows];
|
||||||
|
[next[index], next[target]] = [next[target], next[index]];
|
||||||
|
rows = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (!canStart) return;
|
||||||
|
onStart(rows.map((row) => ({
|
||||||
|
hash: row.hash,
|
||||||
|
action: row.action,
|
||||||
|
message: row.action === "reword" ? row.message.trim() : null,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||||
|
<div class="dialog interactive-rebase-dialog" role="dialog" aria-modal="true" aria-label="Interactive rebase" tabindex="-1">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Rewrite local history</span>
|
||||||
|
<h2 class="dialog-title">Interactive rebase</h2>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="interactive-rebase-body">
|
||||||
|
<section class="rebase-base-bar">
|
||||||
|
<label>
|
||||||
|
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
|
||||||
|
<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} />
|
||||||
|
</label>
|
||||||
|
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rebase-warning error"><AlertTriangle size={16} aria-hidden="true" /><span>{error}</span></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading rebase range…</div>
|
||||||
|
{:else if !base}
|
||||||
|
<div class="blank-state">Select the branch or commit that should become the new base.</div>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<div class="blank-state">No linear commits are available above this base.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rebase-plan" role="list" aria-label="Interactive rebase plan">
|
||||||
|
{#each rows as row, index (row.hash)}
|
||||||
|
<article class:drop={row.action === "drop"} class="rebase-plan-row" role="listitem">
|
||||||
|
<div class="rebase-order-actions">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<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)} />
|
||||||
|
<code>{row.short_hash}</code>
|
||||||
|
<div class="rebase-commit-copy">
|
||||||
|
{#if row.action === "reword"}
|
||||||
|
<input value={row.message} oninput={(event) => updateMessage(index, (event.target as HTMLInputElement).value)} disabled={isBusy} aria-label={`New message for ${row.short_hash}`} maxlength="240" />
|
||||||
|
{:else}
|
||||||
|
<strong>{row.summary}</strong>
|
||||||
|
{/if}
|
||||||
|
<span>{row.author_name} · {new Date(row.date).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if invalidSquash}
|
||||||
|
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Squash and fixup need an earlier commit that is not dropped.</div>
|
||||||
|
{:else if invalidReword}
|
||||||
|
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Reword messages cannot be empty.</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<span class="dialog-footer-info">{keptCount} of {rows.length} commits kept</span>
|
||||||
|
<div class="rebase-footer-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
|
||||||
|
<button class="btn-primary" type="button" onclick={start} disabled={!canStart}>
|
||||||
|
{#if operation === "Starting interactive rebase"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Play size={16} aria-hidden="true" />{/if}
|
||||||
|
Start rebase
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { LoaderCircle, 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";
|
||||||
@@ -8,12 +8,17 @@
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
kind: PatchLineKind;
|
kind: PatchLineKind;
|
||||||
|
oldLine: number | null;
|
||||||
|
newLine: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PatchHunk {
|
interface PatchHunk {
|
||||||
id: string;
|
id: string;
|
||||||
header: string;
|
header: string;
|
||||||
lines: PatchLine[];
|
lines: PatchLine[];
|
||||||
|
oldStart: number;
|
||||||
|
newStart: number;
|
||||||
|
suffix: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedPatch {
|
interface ParsedPatch {
|
||||||
@@ -29,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) => void | Promise<void>;
|
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
|
||||||
|
onExternalDiff: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -41,18 +49,32 @@
|
|||||||
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 selectedLineIds = $state<Set<string>>(new Set());
|
||||||
|
let lastSelectedLineId = $state("");
|
||||||
|
|
||||||
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
||||||
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||||
|
let selectableLines = $derived(
|
||||||
|
parsed.hunks.flatMap((hunk) => hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete")),
|
||||||
|
);
|
||||||
|
let selectedCount = $derived(selectableLines.filter((line) => selectedLineIds.has(line.id)).length);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
parsed = parsePatch(patch);
|
parsed = parsePatch(patch);
|
||||||
|
selectedLineIds = new Set();
|
||||||
|
lastSelectedLineId = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
function parsePatch(input: string): ParsedPatch {
|
function parsePatch(input: string): ParsedPatch {
|
||||||
@@ -63,10 +85,22 @@
|
|||||||
const headerLines: string[] = [];
|
const headerLines: string[] = [];
|
||||||
const hunks: PatchHunk[] = [];
|
const hunks: PatchHunk[] = [];
|
||||||
let current: PatchHunk | null = null;
|
let current: PatchHunk | null = null;
|
||||||
|
let oldCursor = 0;
|
||||||
|
let newCursor = 0;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith("@@ ")) {
|
if (line.startsWith("@@ ")) {
|
||||||
current = { id: `hunk-${hunks.length}`, header: line, lines: [] };
|
const range = parseHunkHeader(line);
|
||||||
|
current = {
|
||||||
|
id: `hunk-${hunks.length}`,
|
||||||
|
header: line,
|
||||||
|
lines: [],
|
||||||
|
oldStart: range.oldStart,
|
||||||
|
newStart: range.newStart,
|
||||||
|
suffix: range.suffix,
|
||||||
|
};
|
||||||
|
oldCursor = range.oldStart;
|
||||||
|
newCursor = range.newStart;
|
||||||
hunks.push(current);
|
hunks.push(current);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -77,11 +111,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const kind = patchLineKind(line);
|
const kind = patchLineKind(line);
|
||||||
|
const oldLine = kind === "context" || kind === "delete" ? oldCursor : null;
|
||||||
|
const newLine = kind === "context" || kind === "add" ? newCursor : null;
|
||||||
current.lines.push({
|
current.lines.push({
|
||||||
id: `${current.id}-line-${current.lines.length}`,
|
id: `${current.id}-line-${current.lines.length}`,
|
||||||
text: line,
|
text: line,
|
||||||
kind,
|
kind,
|
||||||
|
oldLine,
|
||||||
|
newLine,
|
||||||
});
|
});
|
||||||
|
if (oldLine !== null) oldCursor += 1;
|
||||||
|
if (newLine !== null) newCursor += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -91,6 +131,15 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseHunkHeader(header: string): { oldStart: number; newStart: number; suffix: string } {
|
||||||
|
const match = header.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
|
||||||
|
return {
|
||||||
|
oldStart: Number(match?.[1] ?? 0),
|
||||||
|
newStart: Number(match?.[2] ?? 0),
|
||||||
|
suffix: match?.[3] ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function patchLineKind(line: string): PatchLineKind {
|
function patchLineKind(line: string): PatchLineKind {
|
||||||
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
|
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
|
||||||
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
|
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
|
||||||
@@ -116,7 +165,108 @@
|
|||||||
|
|
||||||
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
|
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
|
||||||
if (isBusy || isLoading) return;
|
if (isBusy || isLoading) return;
|
||||||
await onApply(action, buildHunkPatch(hunk));
|
await onApply(action, buildHunkPatch(hunk), "hunk");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLine(event: MouseEvent, line: PatchLine) {
|
||||||
|
if (isBusy || isLoading || (line.kind !== "add" && line.kind !== "delete")) return;
|
||||||
|
const next = new Set(selectedLineIds);
|
||||||
|
const selecting = !next.has(line.id);
|
||||||
|
if (event.shiftKey && lastSelectedLineId) {
|
||||||
|
const start = selectableLines.findIndex((candidate) => candidate.id === lastSelectedLineId);
|
||||||
|
const end = selectableLines.findIndex((candidate) => candidate.id === line.id);
|
||||||
|
if (start >= 0 && end >= 0) {
|
||||||
|
for (const candidate of selectableLines.slice(Math.min(start, end), Math.max(start, end) + 1)) {
|
||||||
|
if (selecting) next.add(candidate.id);
|
||||||
|
else next.delete(candidate.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (selecting) {
|
||||||
|
next.add(line.id);
|
||||||
|
} else {
|
||||||
|
next.delete(line.id);
|
||||||
|
}
|
||||||
|
selectedLineIds = next;
|
||||||
|
lastSelectedLineId = line.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHunkSelection(hunk: PatchHunk) {
|
||||||
|
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
|
||||||
|
const allSelected = changed.length > 0 && changed.every((line) => selectedLineIds.has(line.id));
|
||||||
|
const next = new Set(selectedLineIds);
|
||||||
|
for (const line of changed) {
|
||||||
|
if (allSelected) next.delete(line.id);
|
||||||
|
else next.add(line.id);
|
||||||
|
}
|
||||||
|
selectedLineIds = next;
|
||||||
|
lastSelectedLineId = changed[changed.length - 1]?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hunkSelectionState(hunk: PatchHunk): "none" | "some" | "all" {
|
||||||
|
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
|
||||||
|
const count = changed.filter((line) => selectedLineIds.has(line.id)).length;
|
||||||
|
return count === 0 ? "none" : count === changed.length ? "all" : "some";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangePart(start: number, count: number): string {
|
||||||
|
return count === 1 ? `${start}` : `${start},${count}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelectedHunk(hunk: PatchHunk): string | null {
|
||||||
|
if (!hunk.lines.some((line) => selectedLineIds.has(line.id))) return null;
|
||||||
|
const output: string[] = [];
|
||||||
|
let previousIncluded = false;
|
||||||
|
|
||||||
|
for (const line of hunk.lines) {
|
||||||
|
if (line.kind === "context") {
|
||||||
|
output.push(line.text);
|
||||||
|
previousIncluded = true;
|
||||||
|
} else if (line.kind === "delete") {
|
||||||
|
output.push(selectedLineIds.has(line.id) ? line.text : ` ${line.text.slice(1)}`);
|
||||||
|
previousIncluded = true;
|
||||||
|
} else if (line.kind === "add") {
|
||||||
|
if (selectedLineIds.has(line.id)) {
|
||||||
|
output.push(line.text);
|
||||||
|
previousIncluded = true;
|
||||||
|
} else {
|
||||||
|
previousIncluded = false;
|
||||||
|
}
|
||||||
|
} else if (previousIncluded) {
|
||||||
|
output.push(line.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldCount = output.filter((line) => line.startsWith(" ") || line.startsWith("-")).length;
|
||||||
|
const newCount = output.filter((line) => line.startsWith(" ") || line.startsWith("+")).length;
|
||||||
|
const header = `@@ -${rangePart(hunk.oldStart, oldCount)} +${rangePart(hunk.newStart, newCount)} @@${hunk.suffix}`;
|
||||||
|
return [header, ...output].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelectedPatch(): string {
|
||||||
|
const hunks = parsed.hunks.map(buildSelectedHunk).filter((hunk): hunk is string => Boolean(hunk));
|
||||||
|
return `${[...parsed.headerLines, ...hunks].join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySelected(action: PatchApplyAction) {
|
||||||
|
if (isBusy || isLoading || selectedCount === 0) return;
|
||||||
|
await onApply(action, buildSelectedPatch(), "lines");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hunkPosition(index: number): number {
|
||||||
|
const totalLines = parsed.hunks.reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
|
||||||
|
const precedingLines = parsed.hunks.slice(0, index).reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
|
||||||
|
return (precedingLines / Math.max(totalLines - 1, 1)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hunkKind(hunk: PatchHunk): "add" | "delete" | "mixed" {
|
||||||
|
const hasAdd = hunk.lines.some((line) => line.kind === "add");
|
||||||
|
const hasDelete = hunk.lines.some((line) => line.kind === "delete");
|
||||||
|
return hasAdd && hasDelete ? "mixed" : hasAdd ? "add" : "delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToHunk(hunkId: string) {
|
||||||
|
const target = patchScroll?.querySelector<HTMLElement>(`[data-hunk-id="${hunkId}"]`);
|
||||||
|
if (patchScroll && target) patchScroll.scrollTop = Math.max(target.offsetTop - 8, 0);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -128,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" />
|
||||||
@@ -148,10 +307,47 @@
|
|||||||
{:else if parsed.binary || parsed.hunks.length === 0}
|
{:else if parsed.binary || parsed.hunks.length === 0}
|
||||||
<div class="blank-state">This change cannot be split into text lines.</div>
|
<div class="blank-state">This change cannot be split into text lines.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="line-patch-scroll">
|
<div class:active={selectedCount > 0} class="line-patch-selection-bar">
|
||||||
|
<div>
|
||||||
|
<MousePointer2 size={14} aria-hidden="true" />
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<strong>{selectedCount} {selectedCount === 1 ? "line" : "lines"} selected</strong>
|
||||||
|
<span>Shift-click to select a range.</span>
|
||||||
|
{:else}
|
||||||
|
<strong>Select changed lines</strong>
|
||||||
|
<span>Choose individual additions or deletions below.</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<div class="line-patch-selected-actions">
|
||||||
|
<button class="line-patch-hunk-button discard" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy}>
|
||||||
|
Discard selected
|
||||||
|
</button>
|
||||||
|
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy}>
|
||||||
|
{staged ? "Unstage selected" : "Stage selected"}
|
||||||
|
</button>
|
||||||
|
<button class="btn-sm" type="button" onclick={() => { selectedLineIds = new Set(); }} disabled={isBusy}>Clear</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="line-patch-workspace">
|
||||||
|
<div class="line-patch-scroll" bind:this={patchScroll}>
|
||||||
{#each parsed.hunks as hunk (hunk.id)}
|
{#each parsed.hunks as hunk (hunk.id)}
|
||||||
<section class="line-patch-hunk">
|
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
|
||||||
<div class="line-patch-hunk-head">
|
<div class="line-patch-hunk-head">
|
||||||
|
<button
|
||||||
|
class:all={hunkSelectionState(hunk) === "all"}
|
||||||
|
class:some={hunkSelectionState(hunk) === "some"}
|
||||||
|
class="line-patch-select-hunk"
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggleHunkSelection(hunk)}
|
||||||
|
aria-label={`Select changed lines in ${hunk.header}`}
|
||||||
|
aria-pressed={hunkSelectionState(hunk) === "all"}
|
||||||
|
title="Select all changed lines in this hunk"
|
||||||
|
>
|
||||||
|
{#if hunkSelectionState(hunk) !== "none"}<Check size={12} aria-hidden="true" />{/if}
|
||||||
|
</button>
|
||||||
<code>{hunk.header}</code>
|
<code>{hunk.header}</code>
|
||||||
<div class="line-patch-hunk-actions">
|
<div class="line-patch-hunk-actions">
|
||||||
{#if staged}
|
{#if staged}
|
||||||
@@ -174,7 +370,23 @@
|
|||||||
|
|
||||||
<div class="line-patch-lines">
|
<div class="line-patch-lines">
|
||||||
{#each hunk.lines as line (line.id)}
|
{#each hunk.lines as line (line.id)}
|
||||||
<div class={`line-patch-row ${line.kind}`}>
|
<div class:selected={selectedLineIds.has(line.id)} class:selectable={line.kind === "add" || line.kind === "delete"} class={`line-patch-row ${line.kind}`}>
|
||||||
|
{#if line.kind === "add" || line.kind === "delete"}
|
||||||
|
<button
|
||||||
|
class="line-patch-line-select"
|
||||||
|
type="button"
|
||||||
|
onclick={(event) => toggleLine(event, line)}
|
||||||
|
aria-label={`${selectedLineIds.has(line.id) ? "Deselect" : "Select"} ${line.kind === "add" ? "added" : "deleted"} line ${line.newLine ?? line.oldLine ?? ""}`}
|
||||||
|
aria-pressed={selectedLineIds.has(line.id)}
|
||||||
|
title="Select line (Shift-click for range)"
|
||||||
|
>
|
||||||
|
{#if selectedLineIds.has(line.id)}<Check size={11} aria-hidden="true" />{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="line-patch-line-select-placeholder"></span>
|
||||||
|
{/if}
|
||||||
|
<span class="line-patch-line-number">{line.oldLine ?? ""}</span>
|
||||||
|
<span class="line-patch-line-number">{line.newLine ?? ""}</span>
|
||||||
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
||||||
<code>{lineBody(line)}</code>
|
<code>{lineBody(line)}</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,6 +395,19 @@
|
|||||||
</section>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="diff-overview line-patch-overview" aria-label="Change overview">
|
||||||
|
{#each parsed.hunks as hunk, index (hunk.id)}
|
||||||
|
<button
|
||||||
|
class="diff-overview-marker {hunkKind(hunk)}"
|
||||||
|
type="button"
|
||||||
|
style={`--marker-position: ${hunkPosition(index)}%`}
|
||||||
|
onclick={() => scrollToHunk(hunk.id)}
|
||||||
|
title={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
|
||||||
|
aria-label={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { GitBranch, History, LoaderCircle, Search, ShieldCheck, X } from "@lucide/svelte";
|
||||||
|
import type { ReflogEntry } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entries: ReflogEntry[];
|
||||||
|
currentHash: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
|
operation: string;
|
||||||
|
error: string;
|
||||||
|
onPreview: (entry: ReflogEntry) => void;
|
||||||
|
onRestore: (entry: ReflogEntry, branch: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { entries = [], currentHash = "", isLoading = false, isBusy = false, operation = "", error = "", onPreview = () => {}, onRestore = () => {}, onClose = () => {} }: Props = $props();
|
||||||
|
let query = $state("");
|
||||||
|
let selectedHash = $state("");
|
||||||
|
let recoveryBranch = $state("");
|
||||||
|
let filteredEntries = $derived(entries.filter((entry) => `${entry.selector} ${entry.action} ${entry.short_hash} ${entry.author_name}`.toLowerCase().includes(query.trim().toLowerCase())));
|
||||||
|
let selected = $derived(entries.find((entry) => entry.hash === selectedHash) ?? filteredEntries[0] ?? null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!selectedHash && entries.length > 0) select(entries[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function select(entry: ReflogEntry) {
|
||||||
|
selectedHash = entry.hash;
|
||||||
|
recoveryBranch = `recovery/${entry.short_hash}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||||
|
<div class="dialog reflog-dialog" role="dialog" aria-modal="true" aria-label="Reflog" tabindex="-1">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div><span class="eyebrow">Recovery history</span><h2 class="dialog-title">Reflog</h2></div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
|
||||||
|
</header>
|
||||||
|
<div class="reflog-body">
|
||||||
|
<aside class="reflog-list-pane">
|
||||||
|
<label class="reflog-search"><Search size={15} aria-hidden="true" /><input bind:value={query} placeholder="Search actions, hashes or authors" aria-label="Search reflog" /></label>
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading reflog…</div>
|
||||||
|
{:else if filteredEntries.length === 0}
|
||||||
|
<div class="blank-state">No reflog entries match this search.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="reflog-list" role="listbox" aria-label="Reflog entries">
|
||||||
|
{#each filteredEntries as entry (`${entry.selector}:${entry.hash}`)}
|
||||||
|
<button class:active={selected?.selector === entry.selector} type="button" role="option" aria-selected={selected?.selector === entry.selector} onclick={() => select(entry)}>
|
||||||
|
<span class="reflog-row-top"><code>{entry.selector}</code><span>{new Date(entry.date).toLocaleString()}</span></span>
|
||||||
|
<strong>{entry.action}</strong>
|
||||||
|
<span class="reflog-row-bottom"><code>{entry.short_hash}</code><span>{entry.author_name}</span></span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="reflog-detail">
|
||||||
|
{#if error}<div class="rebase-warning error">{error}</div>{/if}
|
||||||
|
{#if selected}
|
||||||
|
<div class="reflog-detail-head"><History size={20} aria-hidden="true" /><div><span class="eyebrow">{selected.selector}</span><h3>{selected.action}</h3></div></div>
|
||||||
|
<dl><div><dt>Commit</dt><dd><code>{selected.hash}</code></dd></div><div><dt>Author</dt><dd>{selected.author_name}</dd></div><div><dt>Date</dt><dd>{new Date(selected.date).toLocaleString()}</dd></div></dl>
|
||||||
|
<button class="btn-secondary reflog-preview" type="button" onclick={() => onPreview(selected)} disabled={isBusy || selected.hash === currentHash}><History size={15} aria-hidden="true" /> Preview changes to current HEAD</button>
|
||||||
|
<div class="reflog-recovery-card">
|
||||||
|
<div class="reflog-recovery-title"><ShieldCheck size={18} aria-hidden="true" /><div><strong>Safe recovery</strong><span>Create a new branch here. The current branch is not reset or deleted.</span></div></div>
|
||||||
|
<label><span>Recovery branch</span><div><GitBranch size={15} aria-hidden="true" /><input bind:value={recoveryBranch} disabled={isBusy} spellcheck="false" /></div></label>
|
||||||
|
<button class="btn-primary" type="button" onclick={() => onRestore(selected, recoveryBranch.trim())} disabled={isBusy || !recoveryBranch.trim()}>
|
||||||
|
{#if operation === "Restoring reflog entry"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<ShieldCheck size={16} aria-hidden="true" />{/if}
|
||||||
|
Create and checkout recovery branch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="blank-state">Select a reflog entry to inspect or recover it.</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -41,9 +41,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background:
|
background: linear-gradient(135deg, #070a10 0%, #0c111a 48%, #090e16 100%);
|
||||||
radial-gradient(circle at 50% 42%, rgba(137, 92, 255, 0.18), transparent 34%),
|
|
||||||
linear-gradient(135deg, #050712 0%, #080b16 46%, #120b18 100%);
|
|
||||||
animation: overlay-in 180ms ease;
|
animation: overlay-in 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +50,8 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
opacity: 0.28;
|
opacity: 0.28;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(151, 118, 255, 0.12) 1px, transparent 1px),
|
linear-gradient(rgba(111, 140, 255, 0.09) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, rgba(255, 109, 38, 0.08) 1px, transparent 1px);
|
linear-gradient(90deg, rgba(77, 182, 214, 0.07) 1px, transparent 1px);
|
||||||
background-size: 42px 42px;
|
background-size: 42px 42px;
|
||||||
mask-image: radial-gradient(circle at center, black 0%, transparent 68%);
|
mask-image: radial-gradient(circle at center, black 0%, transparent 68%);
|
||||||
animation: grid-drift 10s linear infinite;
|
animation: grid-drift 10s linear infinite;
|
||||||
@@ -67,14 +65,14 @@
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
width: min(420px, calc(100vw - 42px));
|
width: min(420px, calc(100vw - 42px));
|
||||||
padding: 30px 34px 28px;
|
padding: 30px 34px 28px;
|
||||||
border: 1px solid rgba(160, 124, 255, 0.24);
|
border: 1px solid rgba(90, 111, 154, 0.28);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(23, 26, 43, 0.9), rgba(12, 15, 27, 0.92)),
|
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)),
|
||||||
var(--color-surface-raised);
|
var(--color-surface-raised);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 26px 90px rgba(0, 0, 0, 0.62),
|
0 26px 78px rgba(0, 0, 0, 0.54),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-loading-mark {
|
.repo-loading-mark {
|
||||||
@@ -89,7 +87,7 @@
|
|||||||
.repo-loading-halo {
|
.repo-loading-halo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 10px;
|
inset: 10px;
|
||||||
border: 1px solid rgba(151, 118, 255, 0.24);
|
border: 1px solid rgba(111, 140, 255, 0.22);
|
||||||
border-radius: 34px;
|
border-radius: 34px;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
@@ -98,7 +96,7 @@
|
|||||||
}
|
}
|
||||||
.repo-loading-halo.halo-two {
|
.repo-loading-halo.halo-two {
|
||||||
inset: 24px;
|
inset: 24px;
|
||||||
border-color: rgba(255, 109, 38, 0.26);
|
border-color: rgba(77, 182, 214, 0.24);
|
||||||
animation: halo-breathe 2.4s ease-in-out infinite reverse;
|
animation: halo-breathe 2.4s ease-in-out infinite reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,18 +114,18 @@
|
|||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-dasharray: 165;
|
stroke-dasharray: 165;
|
||||||
stroke-dashoffset: 165;
|
stroke-dashoffset: 165;
|
||||||
filter: drop-shadow(0 0 8px rgba(151, 118, 255, 0.55));
|
filter: drop-shadow(0 0 8px rgba(77, 182, 214, 0.32));
|
||||||
animation: trace-draw 2.6s ease-in-out infinite;
|
animation: trace-draw 2.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.repo-loading-traces .trace-main {
|
.repo-loading-traces .trace-main {
|
||||||
stroke: #9b70ff;
|
stroke: #6f8cff;
|
||||||
}
|
}
|
||||||
.repo-loading-traces .trace-branch {
|
.repo-loading-traces .trace-branch {
|
||||||
stroke: #ff6d26;
|
stroke: #4db6d6;
|
||||||
animation-delay: 0.28s;
|
animation-delay: 0.28s;
|
||||||
}
|
}
|
||||||
.repo-loading-traces .trace-cut {
|
.repo-loading-traces .trace-cut {
|
||||||
stroke: rgba(255, 255, 255, 0.42);
|
stroke: rgba(177, 186, 208, 0.42);
|
||||||
stroke-dasharray: 128;
|
stroke-dasharray: 128;
|
||||||
stroke-dashoffset: 128;
|
stroke-dashoffset: 128;
|
||||||
animation-delay: 0.55s;
|
animation-delay: 0.55s;
|
||||||
@@ -141,7 +139,7 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
filter:
|
filter:
|
||||||
drop-shadow(0 18px 24px rgba(0, 0, 0, 0.55))
|
drop-shadow(0 18px 24px rgba(0, 0, 0, 0.55))
|
||||||
drop-shadow(0 0 18px rgba(151, 118, 255, 0.28));
|
drop-shadow(0 0 18px rgba(77, 182, 214, 0.2));
|
||||||
animation: icon-float 2.4s ease-in-out infinite;
|
animation: icon-float 2.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,18 +173,59 @@
|
|||||||
height: 4px;
|
height: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(151, 118, 255, 0.14);
|
background: rgba(111, 140, 255, 0.14);
|
||||||
}
|
}
|
||||||
.repo-loading-bar span {
|
.repo-loading-bar span {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 46%;
|
width: 46%;
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
background: linear-gradient(90deg, transparent, #9b70ff 40%, #ff6d26 74%, transparent);
|
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
|
||||||
box-shadow: 0 0 16px rgba(255, 109, 38, 0.32);
|
box-shadow: 0 0 16px rgba(77, 182, 214, 0.26);
|
||||||
animation: bar-slide 1.35s ease-in-out infinite;
|
animation: bar-slide 1.35s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .repo-loading {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 42%, rgba(49, 95, 214, 0.12), transparent 34%),
|
||||||
|
linear-gradient(135deg, #f7f9fd 0%, #eef3f9 48%, #f8fafc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .repo-loading-grid {
|
||||||
|
opacity: 0.38;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(49, 95, 214, 0.1) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(15, 143, 181, 0.08) 1px, transparent 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .repo-loading-card {
|
||||||
|
border-color: rgba(61, 89, 142, 0.2);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,0.96), rgba(244,247,252,0.94)),
|
||||||
|
var(--color-surface-raised);
|
||||||
|
box-shadow:
|
||||||
|
0 26px 72px rgba(28,44,74,0.18),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .repo-loading-traces .trace {
|
||||||
|
filter: drop-shadow(0 0 8px rgba(49,95,214,0.22));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .repo-loading-traces .trace-cut {
|
||||||
|
stroke: rgba(49,95,214,0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .repo-loading-icon {
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 18px 24px rgba(28,44,74,0.2))
|
||||||
|
drop-shadow(0 0 18px rgba(49,95,214,0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .repo-loading-bar {
|
||||||
|
background: rgba(49,95,214,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
@keyframes grid-drift { to { transform: translate3d(42px, 42px, 0); } }
|
@keyframes grid-drift { to { transform: translate3d(42px, 42px, 0); } }
|
||||||
@keyframes icon-float {
|
@keyframes icon-float {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
onSelectFile: (file: GitFileStatus) => void;
|
onSelectFile: (file: GitFileStatus) => void;
|
||||||
onStage: (files: GitFileStatus[]) => void;
|
onStage: (files: GitFileStatus[]) => void;
|
||||||
onUnstage: (files: GitFileStatus[]) => void;
|
onUnstage: (files: GitFileStatus[]) => void;
|
||||||
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
|
||||||
|
onDiscardMany: (files: GitFileStatus[]) => void;
|
||||||
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
||||||
onStageAll: () => void;
|
onStageAll: () => void;
|
||||||
onUnstageAll: () => void;
|
onUnstageAll: () => void;
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
onStage = () => {},
|
onStage = () => {},
|
||||||
onUnstage = () => {},
|
onUnstage = () => {},
|
||||||
onDiscard = () => {},
|
onDiscard = () => {},
|
||||||
|
onDiscardMany = () => {},
|
||||||
onPatch = () => {},
|
onPatch = () => {},
|
||||||
onStageAll = () => {},
|
onStageAll = () => {},
|
||||||
onUnstageAll = () => {},
|
onUnstageAll = () => {},
|
||||||
@@ -125,9 +127,25 @@
|
|||||||
onUnstage(targets);
|
onUnstage(targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discard mirrors the stage/unstage target selection: if the clicked row is
|
||||||
|
// part of the current multi-selection, the whole selection (filtered to the
|
||||||
|
// relevant lane) is discarded; otherwise just that one file.
|
||||||
|
function discardStagedFromFile(file: GitFileStatus) {
|
||||||
|
const targets = selectedUnstageTargets(file);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
onDiscard(targets, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardUnstagedFromFile(file: GitFileStatus) {
|
||||||
|
const targets = selectedStageTargets(file);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
onDiscard(targets, false);
|
||||||
|
}
|
||||||
|
|
||||||
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 selectedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f))).length);
|
let unstagedFiles = $derived(changedFiles.filter((file) => file.unstaged !== null));
|
||||||
|
let stagedFiles = $derived(changedFiles.filter((file) => file.staged !== null));
|
||||||
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
|
let 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);
|
||||||
|
|
||||||
@@ -139,65 +157,22 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel relative grid grid-rows-[auto_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">Working tree</span>
|
<span class="eyebrow">Workspace</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Status</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<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>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if hasRepository && changedFiles.length > 0}
|
{#if hasRepository && changedFiles.length > 0}
|
||||||
<div class="status-toolbar">
|
<button class="btn-sm status-discard-all" type="button" onclick={() => onDiscardMany(changedFiles)} disabled={isBusy} title="Discard all staged and unstaged changes">
|
||||||
<button
|
<RotateCcw size={13} aria-hidden="true" /> Discard all
|
||||||
class="btn-sm"
|
|
||||||
type="button"
|
|
||||||
onclick={onStageAll}
|
|
||||||
disabled={isBusy || !hasUnstaged}
|
|
||||||
title="Stage all unstaged files"
|
|
||||||
>
|
|
||||||
<Check size={14} aria-hidden="true" />
|
|
||||||
Stage all
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-sm"
|
|
||||||
type="button"
|
|
||||||
onclick={onUnstageAll}
|
|
||||||
disabled={isBusy || !hasStaged}
|
|
||||||
title="Unstage all staged files"
|
|
||||||
>
|
|
||||||
<Undo2 size={14} aria-hidden="true" />
|
|
||||||
Unstage all
|
|
||||||
</button>
|
|
||||||
{#if selectedCount > 1}
|
|
||||||
<span class="status-selection-count">{selectedCount} selected</span>
|
|
||||||
<button
|
|
||||||
class="btn-sm"
|
|
||||||
type="button"
|
|
||||||
onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))}
|
|
||||||
disabled={isBusy || selectedUnstagedCount === 0}
|
|
||||||
title="Stage selected unstaged files"
|
|
||||||
>
|
|
||||||
<Check size={14} aria-hidden="true" />
|
|
||||||
Stage selected
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-sm"
|
|
||||||
type="button"
|
|
||||||
onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))}
|
|
||||||
disabled={isBusy || selectedStagedCount === 0}
|
|
||||||
title="Unstage selected staged files"
|
|
||||||
>
|
|
||||||
<Undo2 size={14} aria-hidden="true" />
|
|
||||||
Unstage selected
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
<div class="blank-state">No repository loaded.</div>
|
<div class="blank-state">No repository loaded.</div>
|
||||||
@@ -206,79 +181,86 @@
|
|||||||
{: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="overflow-auto p-2">
|
<div class="status-lanes overflow-auto">
|
||||||
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
<section class="status-lane" aria-label="Unstaged changes">
|
||||||
<article
|
<header class="status-lane-head">
|
||||||
class="file-row"
|
<div class="status-lane-title"><strong>Unstaged</strong><span>{unstagedCount}</span></div>
|
||||||
class:selected={isStatusSelected(file)}
|
<div class="status-lane-actions">
|
||||||
class:active={selectedFilePath === file.path}
|
{#if selectedUnstagedCount > 1}
|
||||||
>
|
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
|
||||||
<div class="file-title">
|
<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
|
<Check size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
|
||||||
class="file-title-button"
|
|
||||||
type="button"
|
|
||||||
onclick={(event) => handleFileSelect(event, file)}
|
|
||||||
title={`Select ${displayPath(file)} in Explorer`}
|
|
||||||
>
|
|
||||||
<strong>{fileName(file)}</strong>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{/if}
|
||||||
|
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
|
||||||
<div class="change-lanes">
|
<Check size={13} aria-hidden="true" /> Stage all
|
||||||
<div class="change-lane" class:inactive={!file.staged}>
|
|
||||||
<div class="lane-header">
|
|
||||||
<span class="lane-name">Staged</span>
|
|
||||||
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="lane-actions">
|
|
||||||
{#if file.staged}
|
|
||||||
{@const unstageTargets = selectedUnstageTargets(file)}
|
|
||||||
<button class="btn-sm" type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={unstageTargets.length > 1 ? `Unstage ${unstageTargets.length} selected files` : "Unstage file"}>
|
|
||||||
<Undo2 size={14} aria-hidden="true" />
|
|
||||||
{unstageTargets.length > 1 ? `Unstage ${unstageTargets.length}` : "Unstage"}
|
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
|
{#if selectedUnstagedCount > 1}
|
||||||
<FileDiff size={14} aria-hidden="true" />
|
<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`}>
|
||||||
Details
|
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedUnstagedCount}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
|
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
|
||||||
Discard
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span class="quiet">No staged change</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
<div class="status-file-list">
|
||||||
<div class="change-lane" class:inactive={!file.unstaged}>
|
{#each unstagedFiles as file (`unstaged:${fileKey(file)}`)}
|
||||||
<div class="lane-header">
|
|
||||||
<span class="lane-name">Unstaged</span>
|
|
||||||
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="lane-actions">
|
|
||||||
{#if file.unstaged}
|
|
||||||
{@const stageTargets = selectedStageTargets(file)}
|
{@const stageTargets = selectedStageTargets(file)}
|
||||||
<button class="btn-sm" type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Stage ${stageTargets.length} selected files` : "Stage file"}>
|
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
|
||||||
<Check size={14} aria-hidden="true" />
|
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||||
{stageTargets.length > 1 ? `Stage ${stageTargets.length}` : "Stage"}
|
<strong>{fileName(file)}</strong>
|
||||||
|
<span>{displayPath(file)}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
|
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
||||||
<FileDiff size={14} aria-hidden="true" />
|
<div class="status-file-actions">
|
||||||
Details
|
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><Check size={14} aria-hidden="true" /></button>
|
||||||
</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="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
|
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
|
||||||
Discard
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span class="quiet">No unstaged change</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="status-lane" aria-label="Staged changes">
|
||||||
|
<header class="status-lane-head">
|
||||||
|
<div class="status-lane-title"><strong>Staged</strong><span>{stagedCount}</span></div>
|
||||||
|
<div class="status-lane-actions">
|
||||||
|
{#if selectedStagedCount > 1}
|
||||||
|
<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`}>
|
||||||
|
<Undo2 size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
|
||||||
|
<Undo2 size={13} aria-hidden="true" /> Unstage all
|
||||||
|
</button>
|
||||||
|
{#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`}>
|
||||||
|
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedStagedCount}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="status-file-list">
|
||||||
|
{#each stagedFiles as file (`staged:${fileKey(file)}`)}
|
||||||
|
{@const unstageTargets = selectedUnstageTargets(file)}
|
||||||
|
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
|
||||||
|
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||||
|
<strong>{fileName(file)}</strong>
|
||||||
|
<span>{displayPath(file)}</span>
|
||||||
|
</button>
|
||||||
|
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
||||||
|
<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={() => 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>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -310,8 +292,8 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 50% 38%, rgba(137, 92, 255, 0.16), transparent 55%),
|
radial-gradient(circle at 50% 38%, rgba(111, 140, 255, 0.1), transparent 55%),
|
||||||
rgba(8, 9, 16, 0.58);
|
rgba(7, 10, 16, 0.62);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
animation: status-panel-overlay-in 120ms ease;
|
animation: status-panel-overlay-in 120ms ease;
|
||||||
}
|
}
|
||||||
@@ -323,13 +305,13 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
width: min(300px, calc(100% - 32px));
|
width: min(300px, calc(100% - 32px));
|
||||||
padding: 26px 28px 26px;
|
padding: 26px 28px 26px;
|
||||||
border: 1px solid rgba(160, 124, 255, 0.24);
|
border: 1px solid rgba(90, 111, 154, 0.28);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(23, 26, 43, 0.9), rgba(12, 15, 27, 0.92)),
|
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)),
|
||||||
var(--color-surface-raised);
|
var(--color-surface-raised);
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-panel-overlay-mark {
|
.status-panel-overlay-mark {
|
||||||
@@ -344,7 +326,7 @@
|
|||||||
.status-panel-overlay-halo {
|
.status-panel-overlay-halo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 6px;
|
inset: 6px;
|
||||||
border: 1px solid rgba(151, 118, 255, 0.24);
|
border: 1px solid rgba(111, 140, 255, 0.22);
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
@@ -353,7 +335,7 @@
|
|||||||
}
|
}
|
||||||
.status-panel-overlay-halo.halo-two {
|
.status-panel-overlay-halo.halo-two {
|
||||||
inset: 16px;
|
inset: 16px;
|
||||||
border-color: rgba(255, 109, 38, 0.26);
|
border-color: rgba(77, 182, 214, 0.24);
|
||||||
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse;
|
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,16 +353,16 @@
|
|||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-dasharray: 165;
|
stroke-dasharray: 165;
|
||||||
stroke-dashoffset: 165;
|
stroke-dashoffset: 165;
|
||||||
filter: drop-shadow(0 0 6px rgba(151, 118, 255, 0.55));
|
filter: drop-shadow(0 0 6px rgba(77, 182, 214, 0.32));
|
||||||
animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite;
|
animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.status-panel-overlay-traces .trace-main { stroke: #9b70ff; }
|
.status-panel-overlay-traces .trace-main { stroke: #6f8cff; }
|
||||||
.status-panel-overlay-traces .trace-branch {
|
.status-panel-overlay-traces .trace-branch {
|
||||||
stroke: #ff6d26;
|
stroke: #4db6d6;
|
||||||
animation-delay: 0.28s;
|
animation-delay: 0.28s;
|
||||||
}
|
}
|
||||||
.status-panel-overlay-traces .trace-cut {
|
.status-panel-overlay-traces .trace-cut {
|
||||||
stroke: rgba(255, 255, 255, 0.42);
|
stroke: rgba(177, 186, 208, 0.42);
|
||||||
stroke-dasharray: 128;
|
stroke-dasharray: 128;
|
||||||
stroke-dashoffset: 128;
|
stroke-dashoffset: 128;
|
||||||
animation-delay: 0.55s;
|
animation-delay: 0.55s;
|
||||||
@@ -394,7 +376,7 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
filter:
|
filter:
|
||||||
drop-shadow(0 8px 12px rgba(0, 0, 0, 0.5))
|
drop-shadow(0 8px 12px rgba(0, 0, 0, 0.5))
|
||||||
drop-shadow(0 0 10px rgba(151, 118, 255, 0.28));
|
drop-shadow(0 0 10px rgba(77, 182, 214, 0.2));
|
||||||
animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite;
|
animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,15 +397,15 @@
|
|||||||
height: 4px;
|
height: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(151, 118, 255, 0.14);
|
background: rgba(111, 140, 255, 0.14);
|
||||||
}
|
}
|
||||||
.status-panel-overlay-bar span {
|
.status-panel-overlay-bar span {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 46%;
|
width: 46%;
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
background: linear-gradient(90deg, transparent, #9b70ff 40%, #ff6d26 74%, transparent);
|
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
|
||||||
box-shadow: 0 0 12px rgba(255, 109, 38, 0.32);
|
box-shadow: 0 0 12px rgba(77, 182, 214, 0.26);
|
||||||
animation: status-panel-overlay-bar-slide 1.35s ease-in-out infinite;
|
animation: status-panel-overlay-bar-slide 1.35s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Cloud, GitBranch, Plus, Save, Trash2, X } from "@lucide/svelte";
|
||||||
|
import type { GitRemote, PullStrategy } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
|
export let remotes: GitRemote[] = [];
|
||||||
|
export let remoteBranches: string[] = [];
|
||||||
|
export let currentBranch = "";
|
||||||
|
export let currentUpstream = "";
|
||||||
|
export let strategy: PullStrategy = "merge";
|
||||||
|
export let selectedRemote = "";
|
||||||
|
export let isBusy = false;
|
||||||
|
export let language: "en" | "de" = "en";
|
||||||
|
export let onSaveSync: (strategy: PullStrategy, remote: string, upstream: string) => void | Promise<void> = () => {};
|
||||||
|
export let onAddRemote: (name: string, url: string) => void | Promise<void> = () => {};
|
||||||
|
export let onUpdateRemote: (name: string, url: string) => void | Promise<void> = () => {};
|
||||||
|
export let onRemoveRemote: (name: string) => void | Promise<void> = () => {};
|
||||||
|
export let onClose: () => void = () => {};
|
||||||
|
|
||||||
|
let draftStrategy = strategy;
|
||||||
|
let draftRemote = selectedRemote;
|
||||||
|
let draftUpstream = currentUpstream;
|
||||||
|
let newName = "origin";
|
||||||
|
let newUrl = "";
|
||||||
|
let editingName = "";
|
||||||
|
let editingUrl = "";
|
||||||
|
let actionError = "";
|
||||||
|
let actionStatus = "";
|
||||||
|
$: de = language === "de";
|
||||||
|
|
||||||
|
function beginEdit(remote: GitRemote) { actionError = ""; editingName = remote.name; editingUrl = remote.fetch_url; }
|
||||||
|
async function requestDelete(event: MouseEvent, name: string) {
|
||||||
|
console.log(name)
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
editingName = "";
|
||||||
|
actionError = "";
|
||||||
|
actionStatus = de ? `Remote „${name}“ wird entfernt …` : `Removing remote “${name}” …`;
|
||||||
|
console.info("[Gitty remote] remove button activated", { name });
|
||||||
|
await remove(name);
|
||||||
|
}
|
||||||
|
function cancelEdit() { editingName = ""; editingUrl = ""; }
|
||||||
|
async function add() { if (!newName.trim() || !newUrl.trim()) return; await onAddRemote(newName.trim(), newUrl.trim()); newName = "origin"; newUrl = ""; }
|
||||||
|
async function update() { if (!editingName || !editingUrl.trim()) return; await onUpdateRemote(editingName, editingUrl.trim()); cancelEdit(); }
|
||||||
|
async function remove(name: string) {
|
||||||
|
actionError = "";
|
||||||
|
console.log("remove")
|
||||||
|
try {
|
||||||
|
await onRemoveRemote(name);
|
||||||
|
if (draftRemote === name) draftRemote = "";
|
||||||
|
if (draftUpstream.startsWith(`${name}/`)) draftUpstream = "";
|
||||||
|
} catch (error) {
|
||||||
|
actionError = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
actionStatus = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="sync-settings-dialog" role="dialog" aria-modal="true" aria-labelledby="sync-settings-title">
|
||||||
|
<header class="sync-settings-head">
|
||||||
|
<div class="sync-settings-title">
|
||||||
|
<span class="sync-settings-icon"><Cloud size={18} aria-hidden="true" /></span>
|
||||||
|
<div><span class="eyebrow">Git sync</span><h2 id="sync-settings-title">{de ? "Synchronisierung & Remotes" : "Sync & remotes"}</h2></div>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-icon-button" type="button" onclick={onClose} disabled={isBusy} aria-label={de ? "Schließen" : "Close"}><X size={17} /></button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="sync-settings-body">
|
||||||
|
<section class="sync-settings-card">
|
||||||
|
<div class="sync-card-heading"><div><h3>{de ? "Pull-Verhalten" : "Pull behavior"}</h3><p>{de ? "Legt fest, wie entfernte Änderungen in deinen aktuellen Branch übernommen werden." : "Controls how remote changes are integrated into your current branch."}</p></div></div>
|
||||||
|
<div class="strategy-options">
|
||||||
|
<label class:active={draftStrategy === "merge"}><input type="radio" bind:group={draftStrategy} value="merge" /><span><strong>Merge</strong><small>{de ? "Erstellt bei getrennten Verläufen einen Merge-Commit. Sicher und leicht nachvollziehbar." : "Creates a merge commit for diverged history. Safe and easy to follow."}</small></span></label>
|
||||||
|
<label class:active={draftStrategy === "rebase"}><input type="radio" bind:group={draftStrategy} value="rebase" /><span><strong>Rebase</strong><small>{de ? "Setzt deine lokalen Commits auf die Remote-Änderungen. Ergibt eine lineare Historie." : "Replays your local commits on remote changes for a linear history."}</small></span></label>
|
||||||
|
<label class:active={draftStrategy === "ff-only"}><input type="radio" bind:group={draftStrategy} value="ff-only" /><span><strong>Fast-forward only</strong><small>{de ? "Pull stoppt, sobald ein Merge nötig wäre. Verändert die Historie nie automatisch." : "Stops when a merge would be required. Never combines diverged history automatically."}</small></span></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sync-fields">
|
||||||
|
<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><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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="sync-settings-card">
|
||||||
|
<div class="sync-card-heading"><div><h3>Remotes</h3><p>{de ? "Server-Verbindungen dieses Repositorys verwalten." : "Manage this repository's server connections."}</p></div><span class="count-pill">{remotes.length}</span></div>
|
||||||
|
<div class="remote-list">
|
||||||
|
{#if actionError}<div class="sync-action-error" role="alert"><strong>{de ? "Remote konnte nicht entfernt werden" : "Remote could not be removed"}</strong><span>{actionError}</span></div>{/if}
|
||||||
|
{#if actionStatus}<div class="sync-action-status" role="status">{actionStatus}</div>{/if}
|
||||||
|
{#each remotes as remote (remote.name)}
|
||||||
|
<div class="remote-row">
|
||||||
|
<span class="remote-mark"><GitBranch size={15} /></span>
|
||||||
|
{#if editingName === remote.name}
|
||||||
|
<div class="remote-edit"><strong>{remote.name}</strong><input bind:value={editingUrl} aria-label={`URL for ${remote.name}`} /></div>
|
||||||
|
<button class="btn-sm" type="button" onclick={update} disabled={isBusy || !editingUrl.trim()}><Save size={13} /> {de ? "Speichern" : "Save"}</button>
|
||||||
|
<button class="btn-sm" type="button" onclick={cancelEdit} disabled={isBusy}>{de ? "Abbrechen" : "Cancel"}</button>
|
||||||
|
{:else}
|
||||||
|
<button class="remote-main" type="button" onclick={() => beginEdit(remote)} disabled={isBusy}><strong>{remote.name}</strong><span>{remote.fetch_url}</span></button>
|
||||||
|
<button class="remote-delete" type="button" onclick={(event) => requestDelete(event, remote.name)} data-remote-name={remote.name} title={de ? "Remote-Verbindung sofort entfernen; lokale Daten bleiben erhalten" : "Remove remote connection now; local data is kept"}><Trash2 size={13} /><span>{de ? "Entfernen" : "Remove"}</span></button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}<p class="remote-empty">{de ? "Noch kein Remote eingerichtet." : "No remote configured yet."}</p>{/each}
|
||||||
|
</div>
|
||||||
|
<div class="remote-add"><input bind:value={newName} placeholder={de ? "Name, z. B. origin" : "Name, e.g. origin"} aria-label="Remote name" /><input bind:value={newUrl} placeholder="https://… or git@…" aria-label="Remote URL" /><button class="btn-secondary" type="button" onclick={add} disabled={isBusy || !newName.trim() || !newUrl.trim()}><Plus size={14} /> {de ? "Hinzufügen" : "Add remote"}</button></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="sync-settings-footer"><p>{de ? "Fetch + Prune entfernt veraltete Remote-Verweise. Force with lease ist ein geschütztes Force-Push nach einem Rebase." : "Fetch + Prune removes stale remote references. Force with lease is a protected force-push after a rebase."}</p><div><button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{de ? "Abbrechen" : "Cancel"}</button><button class="btn-primary" type="button" onclick={() => onSaveSync(draftStrategy, draftRemote, draftUpstream)} disabled={isBusy}><Save size={14} /> {de ? "Einstellungen speichern" : "Save settings"}</button></div></footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
CircleDot,
|
||||||
|
ExternalLink,
|
||||||
|
FolderInput,
|
||||||
|
FolderOpen,
|
||||||
|
GitBranch,
|
||||||
|
HardDrive,
|
||||||
|
LoaderCircle,
|
||||||
|
Lock,
|
||||||
|
MapPin,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
Unlock,
|
||||||
|
Wrench,
|
||||||
|
X,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import type { GitBranch as GitBranchInfo, GitWorktree } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
|
type CreateMode = "existing" | "new" | "detached";
|
||||||
|
|
||||||
|
interface AddRequest {
|
||||||
|
worktreePath: string;
|
||||||
|
branch?: string;
|
||||||
|
newBranch?: string;
|
||||||
|
startPoint?: string;
|
||||||
|
detached?: boolean;
|
||||||
|
lock?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
worktrees: GitWorktree[];
|
||||||
|
branches: GitBranchInfo[];
|
||||||
|
initialBranch?: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
|
error?: string;
|
||||||
|
onRefresh: () => void | Promise<void>;
|
||||||
|
onOpen: (worktree: GitWorktree) => void | Promise<void>;
|
||||||
|
onAdd: (request: AddRequest) => boolean | Promise<boolean>;
|
||||||
|
onRemove: (worktree: GitWorktree, force: boolean) => boolean | Promise<boolean>;
|
||||||
|
onMove: (worktree: GitWorktree, destination: string) => void | Promise<void>;
|
||||||
|
onLock: (worktree: GitWorktree, reason: string) => boolean | Promise<boolean>;
|
||||||
|
onUnlock: (worktree: GitWorktree) => void | Promise<void>;
|
||||||
|
onPrune: () => void | Promise<void>;
|
||||||
|
onRepair: (worktree: GitWorktree, location: string) => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
worktrees = [],
|
||||||
|
branches = [],
|
||||||
|
initialBranch = "",
|
||||||
|
isLoading = false,
|
||||||
|
isBusy = false,
|
||||||
|
error = "",
|
||||||
|
onRefresh = () => {},
|
||||||
|
onOpen = () => {},
|
||||||
|
onAdd = () => false,
|
||||||
|
onRemove = () => false,
|
||||||
|
onMove = () => {},
|
||||||
|
onLock = () => false,
|
||||||
|
onUnlock = () => {},
|
||||||
|
onPrune = () => {},
|
||||||
|
onRepair = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let createOpen = $state(false);
|
||||||
|
let createMode = $state<CreateMode>("new");
|
||||||
|
let selectedBranch = $state("");
|
||||||
|
let newBranch = $state("");
|
||||||
|
let startPoint = $state("HEAD");
|
||||||
|
let destination = $state("");
|
||||||
|
let lockAfterCreate = $state(false);
|
||||||
|
let pendingRemoval = $state<GitWorktree | null>(null);
|
||||||
|
let forceRemoval = $state(false);
|
||||||
|
let pendingLock = $state<GitWorktree | null>(null);
|
||||||
|
let lockReason = $state("");
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (initialized) return;
|
||||||
|
createOpen = initialBranch.length > 0;
|
||||||
|
createMode = initialBranch ? "existing" : "new";
|
||||||
|
selectedBranch = initialBranch;
|
||||||
|
initialized = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
let localBranches = $derived(branches.filter((branch) => !branch.remote));
|
||||||
|
let prunableCount = $derived(worktrees.filter((worktree) => worktree.prunable).length);
|
||||||
|
let linkedCount = $derived(Math.max(0, worktrees.length - 1));
|
||||||
|
let checkedOutBranches = $derived(new Set(worktrees.map((worktree) => worktree.branch).filter((branch): branch is string => Boolean(branch))));
|
||||||
|
|
||||||
|
function displayName(worktree: GitWorktree): string {
|
||||||
|
return worktree.branch || (worktree.detached ? `Detached at ${worktree.short_head || "HEAD"}` : "Bare worktree");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathName(path: string): string {
|
||||||
|
return path.split(/[\\/]/).filter(Boolean).pop() || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchAvailable(branch: string): boolean {
|
||||||
|
return !checkedOutBranches.has(branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPath(parent: string, name: string): string {
|
||||||
|
const separator = parent.includes("\\") ? "\\" : "/";
|
||||||
|
return `${parent.replace(/[\\/]+$/, "")}${separator}${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseDestination(current = "") {
|
||||||
|
const selected = await openDialog({
|
||||||
|
title: current ? "Choose new worktree location" : "Choose worktree folder",
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
defaultPath: current || undefined,
|
||||||
|
});
|
||||||
|
if (typeof selected === "string") destination = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!destination.trim()) return;
|
||||||
|
const request: AddRequest = {
|
||||||
|
worktreePath: destination.trim(),
|
||||||
|
lock: lockAfterCreate,
|
||||||
|
};
|
||||||
|
if (createMode === "existing") request.branch = selectedBranch;
|
||||||
|
if (createMode === "new") {
|
||||||
|
request.newBranch = newBranch.trim();
|
||||||
|
request.startPoint = startPoint.trim() || "HEAD";
|
||||||
|
}
|
||||||
|
if (createMode === "detached") {
|
||||||
|
request.detached = true;
|
||||||
|
request.startPoint = startPoint.trim() || "HEAD";
|
||||||
|
}
|
||||||
|
if (await onAdd(request)) {
|
||||||
|
createOpen = false;
|
||||||
|
destination = "";
|
||||||
|
newBranch = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseMoveDestination(worktree: GitWorktree) {
|
||||||
|
const selected = await openDialog({
|
||||||
|
title: `Choose parent folder for ${displayName(worktree)}`,
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
defaultPath: worktree.path,
|
||||||
|
});
|
||||||
|
if (typeof selected === "string") {
|
||||||
|
const target = joinPath(selected, pathName(worktree.path));
|
||||||
|
if (target !== worktree.path) await onMove(worktree, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseRepairLocation(worktree: GitWorktree) {
|
||||||
|
const selected = await openDialog({
|
||||||
|
title: `Locate ${displayName(worktree)}`,
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
if (typeof selected === "string") await onRepair(worktree, selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRemoval(worktree: GitWorktree) {
|
||||||
|
pendingRemoval = worktree;
|
||||||
|
forceRemoval = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemoval() {
|
||||||
|
if (!pendingRemoval) return;
|
||||||
|
if (await onRemove(pendingRemoval, forceRemoval)) {
|
||||||
|
pendingRemoval = null;
|
||||||
|
forceRemoval = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestLock(worktree: GitWorktree) {
|
||||||
|
pendingLock = worktree;
|
||||||
|
lockReason = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmLock() {
|
||||||
|
if (!pendingLock) return;
|
||||||
|
if (await onLock(pendingLock, lockReason)) {
|
||||||
|
pendingLock = null;
|
||||||
|
lockReason = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog worktree-dialog" role="dialog" aria-modal="true" aria-labelledby="worktree-dialog-title">
|
||||||
|
<header class="dialog-header worktree-dialog-header">
|
||||||
|
<div class="worktree-dialog-heading">
|
||||||
|
<span class="worktree-dialog-mark" aria-hidden="true"><HardDrive size={18} /></span>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Parallel workspaces</span>
|
||||||
|
<p class="dialog-title" id="worktree-dialog-title">Worktrees</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-header-actions">
|
||||||
|
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading} title="Refresh worktrees">
|
||||||
|
<RefreshCw class={isLoading ? "spin" : undefined} size={15} aria-hidden="true" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="worktree-summary">
|
||||||
|
<div><strong>{linkedCount}</strong><span>linked worktrees</span></div>
|
||||||
|
<div><strong>{worktrees.filter((worktree) => !worktree.clean).length}</strong><span>with changes</span></div>
|
||||||
|
<div class:attention={prunableCount > 0}><strong>{prunableCount}</strong><span>stale entries</span></div>
|
||||||
|
<button class="btn-primary" type="button" onclick={() => { createOpen = !createOpen; }} disabled={isBusy}>
|
||||||
|
<Plus size={15} aria-hidden="true" />
|
||||||
|
New worktree
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="worktree-error" role="alert"><AlertTriangle size={15} aria-hidden="true" />{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="worktree-content">
|
||||||
|
{#if createOpen}
|
||||||
|
<form class="worktree-create-card" onsubmit={submitCreate}>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Create</span>
|
||||||
|
<h3>Choose what this workspace should track</h3>
|
||||||
|
</div>
|
||||||
|
<button class="btn-sm dialog-close" type="button" onclick={() => { createOpen = false; }} disabled={isBusy} aria-label="Close create form">
|
||||||
|
<X size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="worktree-mode-tabs" role="tablist" aria-label="Worktree type">
|
||||||
|
<button class:active={createMode === "existing"} type="button" role="tab" aria-selected={createMode === "existing"} onclick={() => { createMode = "existing"; }}>
|
||||||
|
<GitBranch size={14} aria-hidden="true" />Existing branch
|
||||||
|
</button>
|
||||||
|
<button class:active={createMode === "new"} type="button" role="tab" aria-selected={createMode === "new"} onclick={() => { createMode = "new"; }}>
|
||||||
|
<Plus size={14} aria-hidden="true" />New branch
|
||||||
|
</button>
|
||||||
|
<button class:active={createMode === "detached"} type="button" role="tab" aria-selected={createMode === "detached"} onclick={() => { createMode = "detached"; }}>
|
||||||
|
<CircleDot size={14} aria-hidden="true" />Detached
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="worktree-create-fields">
|
||||||
|
{#if createMode === "existing"}
|
||||||
|
<label>
|
||||||
|
<span>Branch</span>
|
||||||
|
<SelectMenu
|
||||||
|
value={selectedBranch}
|
||||||
|
placeholder="Select a local branch"
|
||||||
|
options={localBranches.map((branch) => ({
|
||||||
|
value: branch.name,
|
||||||
|
label: `${branch.name}${!branchAvailable(branch.name) ? " (already checked out)" : ""}`,
|
||||||
|
disabled: !branchAvailable(branch.name),
|
||||||
|
}))}
|
||||||
|
disabled={isBusy}
|
||||||
|
onChange={(value) => { selectedBranch = value; }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{:else if createMode === "new"}
|
||||||
|
<label>
|
||||||
|
<span>New branch name</span>
|
||||||
|
<input bind:value={newBranch} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="feature/my-change" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Start point</span>
|
||||||
|
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD, branch or commit" />
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<label>
|
||||||
|
<span>Commit or ref</span>
|
||||||
|
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD" />
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<label class="worktree-path-field">
|
||||||
|
<span>Folder</span>
|
||||||
|
<div>
|
||||||
|
<input bind:value={destination} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="Choose an empty folder" />
|
||||||
|
<button class="btn-secondary" type="button" onclick={() => chooseDestination()} disabled={isBusy}>
|
||||||
|
<FolderOpen size={15} aria-hidden="true" />Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<label class="worktree-check">
|
||||||
|
<input type="checkbox" bind:checked={lockAfterCreate} disabled={isBusy} />
|
||||||
|
<span><strong>Lock after creation</strong><small>Protects removable or temporary locations from pruning.</small></span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={isBusy || !destination.trim() || (createMode === "existing" && (!selectedBranch || !branchAvailable(selectedBranch))) || (createMode === "new" && !newBranch.trim())}
|
||||||
|
>
|
||||||
|
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Plus size={15} aria-hidden="true" />{/if}
|
||||||
|
Create worktree
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading && worktrees.length === 0}
|
||||||
|
<div class="worktree-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>Reading worktrees…</span></div>
|
||||||
|
{:else if worktrees.length === 0}
|
||||||
|
<div class="worktree-empty"><HardDrive size={24} aria-hidden="true" /><strong>No worktrees found</strong><span>Create one to work on another branch without switching this workspace.</span></div>
|
||||||
|
{:else}
|
||||||
|
<div class="worktree-list">
|
||||||
|
{#each worktrees as worktree (worktree.path)}
|
||||||
|
<article class:current={worktree.is_current} class:stale={worktree.prunable || worktree.missing} class="worktree-card">
|
||||||
|
<div class="worktree-rail" aria-hidden="true">
|
||||||
|
<span></span>
|
||||||
|
{#if !worktree.is_main}<i></i>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="worktree-card-main">
|
||||||
|
<header>
|
||||||
|
<div class="worktree-name">
|
||||||
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{displayName(worktree)}</strong>
|
||||||
|
<span>{pathName(worktree.path)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="worktree-badges">
|
||||||
|
{#if worktree.is_main}<span>Main</span>{/if}
|
||||||
|
{#if worktree.is_current}<span class="active">Open</span>{/if}
|
||||||
|
{#if worktree.detached}<span>Detached</span>{/if}
|
||||||
|
{#if worktree.locked}<span class="locked"><Lock size={10} aria-hidden="true" />Locked</span>{/if}
|
||||||
|
{#if worktree.prunable || worktree.missing}<span class="danger">Stale</span>{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="worktree-path" title={worktree.path}><MapPin size={13} aria-hidden="true" /><code>{worktree.path}</code></div>
|
||||||
|
|
||||||
|
<div class="worktree-meta">
|
||||||
|
<span class:dirty={!worktree.clean}>
|
||||||
|
{#if worktree.clean}<Check size={12} aria-hidden="true" />Clean{:else}<CircleDot size={12} aria-hidden="true" />{worktree.changed_files} changed{/if}
|
||||||
|
</span>
|
||||||
|
{#if worktree.short_head}<span><code>{worktree.short_head}</code></span>{/if}
|
||||||
|
{#if worktree.lock_reason}<span><Lock size={12} aria-hidden="true" />{worktree.lock_reason}</span>{/if}
|
||||||
|
{#if worktree.prune_reason}<span class="danger"><AlertTriangle size={12} aria-hidden="true" />{worktree.prune_reason}</span>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button class="btn-secondary" type="button" onclick={() => onOpen(worktree)} disabled={isBusy || worktree.missing || worktree.bare}>
|
||||||
|
<ExternalLink size={14} aria-hidden="true" />{worktree.is_current ? "Refresh tab" : "Open tab"}
|
||||||
|
</button>
|
||||||
|
<div class="worktree-card-actions">
|
||||||
|
{#if worktree.prunable}
|
||||||
|
<button class="btn-sm" type="button" onclick={() => chooseRepairLocation(worktree)} disabled={isBusy} title="Locate and repair worktree">
|
||||||
|
<Wrench size={14} aria-hidden="true" />Repair
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if !worktree.is_main && !worktree.missing}
|
||||||
|
<button class="btn-sm" type="button" onclick={() => chooseMoveDestination(worktree)} disabled={isBusy || worktree.locked || worktree.is_current} title="Move worktree">
|
||||||
|
<FolderInput size={14} aria-hidden="true" />Move
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if worktree.locked}
|
||||||
|
<button class="btn-sm" type="button" onclick={() => onUnlock(worktree)} disabled={isBusy} title="Unlock worktree">
|
||||||
|
<Unlock size={14} aria-hidden="true" />Unlock
|
||||||
|
</button>
|
||||||
|
{:else if !worktree.is_main}
|
||||||
|
<button class="btn-sm" type="button" onclick={() => requestLock(worktree)} disabled={isBusy} title="Lock worktree">
|
||||||
|
<Lock size={14} aria-hidden="true" />Lock
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if !worktree.is_main}
|
||||||
|
<button class="btn-sm danger" type="button" onclick={() => requestRemoval(worktree)} disabled={isBusy || worktree.is_current || worktree.locked || worktree.missing} title={worktree.missing ? "Use Prune to remove stale metadata" : "Remove worktree"}>
|
||||||
|
<Trash2 size={14} aria-hidden="true" />Remove
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="worktree-dialog-footer">
|
||||||
|
<div><ShieldCheck size={14} aria-hidden="true" /><span>Dirty, active and locked worktrees are protected.</span></div>
|
||||||
|
<button class="btn-secondary" type="button" onclick={onPrune} disabled={isBusy || prunableCount === 0}>
|
||||||
|
<Wrench size={14} aria-hidden="true" />Prune {prunableCount || ""}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pendingRemoval}
|
||||||
|
<div class="dialog-backdrop worktree-nested-backdrop" role="presentation">
|
||||||
|
<div class="dialog worktree-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="worktree-remove-title">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Remove worktree</span>
|
||||||
|
<p class="dialog-title" id="worktree-remove-title">Remove {displayName(pendingRemoval)}?</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="worktree-confirm-body">
|
||||||
|
<span class="discard-warning-icon" aria-hidden="true"><AlertTriangle size={21} /></span>
|
||||||
|
<div>
|
||||||
|
<p>This removes the worktree folder and its Git registration. The branch itself is kept.</p>
|
||||||
|
<code>{pendingRemoval.path}</code>
|
||||||
|
{#if !pendingRemoval.clean}
|
||||||
|
<label class="worktree-check danger">
|
||||||
|
<input type="checkbox" bind:checked={forceRemoval} disabled={isBusy} />
|
||||||
|
<span><strong>Remove despite local changes</strong><small>{pendingRemoval.changed_files} changed files may be permanently deleted.</small></span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="discard-confirm-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={() => { pendingRemoval = null; }} disabled={isBusy}>Cancel</button>
|
||||||
|
<button class="btn-danger" type="button" onclick={confirmRemoval} disabled={isBusy || (!pendingRemoval.clean && !forceRemoval)}>
|
||||||
|
<Trash2 size={15} aria-hidden="true" />Remove worktree
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pendingLock}
|
||||||
|
<div class="dialog-backdrop worktree-nested-backdrop" role="presentation">
|
||||||
|
<div class="dialog worktree-confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="worktree-lock-title">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Protect worktree</span>
|
||||||
|
<p class="dialog-title" id="worktree-lock-title">Lock {displayName(pendingLock)}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="worktree-lock-body">
|
||||||
|
<label>
|
||||||
|
<span>Reason <small>optional</small></span>
|
||||||
|
<input bind:value={lockReason} disabled={isBusy} autocomplete="off" placeholder="External drive, long-running work…" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<footer class="discard-confirm-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy}>Cancel</button>
|
||||||
|
<button class="btn-primary" type="button" onclick={confirmLock} disabled={isBusy}><Lock size={15} aria-hidden="true" />Lock</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
+14
-9
@@ -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 {
|
||||||
@@ -55,3 +46,17 @@ export function stripAuthPrefix(message: string): string {
|
|||||||
? message.slice(AUTH_PREFIX.length).trim()
|
? message.slice(AUTH_PREFIX.length).trim()
|
||||||
: message;
|
: message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condenses raw git stderr to the single most relevant line for the
|
||||||
|
* credential dialog. Prefers the server's own explanation ("remote: ...")
|
||||||
|
* over git's generic wrapper ("fatal: Authentication failed for ...").
|
||||||
|
*/
|
||||||
|
export function summarizeGitError(message: string): string {
|
||||||
|
const lines = message
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const remote = lines.find((line) => line.toLowerCase().startsWith("remote:"));
|
||||||
|
return remote ?? lines[0] ?? "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+202
-20
@@ -1,19 +1,31 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { tracedInvoke as invoke } from "./telemetry";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
AiReviewResult,
|
||||||
|
AiCommitPlan,
|
||||||
CommitAiLocalProfile,
|
CommitAiLocalProfile,
|
||||||
CommitAiProvider,
|
CommitAiProvider,
|
||||||
CommitAiStatus,
|
CommitAiStatus,
|
||||||
ConflictFile,
|
ConflictFile,
|
||||||
|
DetectedExternalTool,
|
||||||
|
ExternalToolCommand,
|
||||||
|
ExternalDiffScope,
|
||||||
GitBlameResult,
|
GitBlameResult,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
GitCommitComparison,
|
GitCommitComparison,
|
||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
|
GitRemote,
|
||||||
|
MergeStrategy,
|
||||||
|
PullStrategy,
|
||||||
|
RebaseCommit,
|
||||||
|
RebasePlanItem,
|
||||||
|
ReflogEntry,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStash,
|
GitStash,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
GitTag,
|
GitTag,
|
||||||
|
GitWorktree,
|
||||||
LocalModelOption,
|
LocalModelOption,
|
||||||
PatchApplyAction,
|
PatchApplyAction,
|
||||||
RepositoryBundle,
|
RepositoryBundle,
|
||||||
@@ -24,6 +36,10 @@ export function openRepository(path: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("open_repository", { path });
|
return invoke<GitStatus>("open_repository", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function initRepository(path: string, initialBranch = "main"): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("init_repository", { path, initialBranch });
|
||||||
|
}
|
||||||
|
|
||||||
export function openRepoInExplorer(path: string): Promise<void> {
|
export function openRepoInExplorer(path: string): Promise<void> {
|
||||||
return invoke<void>("open_repo_in_explorer", { path });
|
return invoke<void>("open_repo_in_explorer", { path });
|
||||||
}
|
}
|
||||||
@@ -32,6 +48,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 });
|
||||||
}
|
}
|
||||||
@@ -68,6 +100,20 @@ export function listBranches(path: string): Promise<GitBranch[]> {
|
|||||||
return invoke<GitBranch[]>("list_branches", { path });
|
return invoke<GitBranch[]>("list_branches", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listRemotes(path: string): Promise<GitRemote[]> { return invoke("list_remotes", { path }); }
|
||||||
|
export function addRemote(path: string, name: string, url: string): Promise<GitRemote[]> { return invoke("add_remote", { path, name, url }); }
|
||||||
|
export function updateRemote(path: string, name: string, url: string): Promise<GitRemote[]> { return invoke("update_remote", { path, name, url }); }
|
||||||
|
export function removeRemote(path: string, name: string): Promise<GitRemote[]> {
|
||||||
|
console.log("remove_remote")
|
||||||
|
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 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 });
|
||||||
}
|
}
|
||||||
@@ -92,10 +138,78 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listWorktrees(path: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("list_worktrees", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addWorktree(
|
||||||
|
path: string,
|
||||||
|
worktreePath: string,
|
||||||
|
options: {
|
||||||
|
branch?: string;
|
||||||
|
newBranch?: string;
|
||||||
|
startPoint?: string;
|
||||||
|
detached?: boolean;
|
||||||
|
lock?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("add_worktree", {
|
||||||
|
path,
|
||||||
|
worktreePath,
|
||||||
|
branch: options.branch ?? null,
|
||||||
|
newBranch: options.newBranch ?? null,
|
||||||
|
startPoint: options.startPoint ?? null,
|
||||||
|
detached: options.detached ?? false,
|
||||||
|
lock: options.lock ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeWorktree(path: string, worktreePath: string, force = false): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("remove_worktree", { path, worktreePath, force });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveWorktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("move_worktree", { path, worktreePath, destination });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockWorktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("lock_worktree", { path, worktreePath, reason: reason?.trim() || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlockWorktree(path: string, worktreePath: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("unlock_worktree", { path, worktreePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneWorktrees(path: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("prune_worktrees", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function repairWorktree(path: string, worktreePath: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("repair_worktree", { path, worktreePath });
|
||||||
|
}
|
||||||
|
|
||||||
export function listTags(path: string): Promise<GitTag[]> {
|
export function listTags(path: string): Promise<GitTag[]> {
|
||||||
return invoke<GitTag[]>("list_tags", { path });
|
return invoke<GitTag[]>("list_tags", { path });
|
||||||
}
|
}
|
||||||
@@ -241,41 +355,86 @@ export function commitAiGenerate(path: string, options: CommitAiGenerateOptions)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
|
export function commitAiReview(path: string, options: CommitAiGenerateOptions): Promise<AiReviewResult> {
|
||||||
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
|
return invoke<AiReviewResult>("commit_ai_review", {
|
||||||
|
path,
|
||||||
|
provider: options.provider,
|
||||||
|
model: options.model,
|
||||||
|
apiKey: options.apiKey,
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchRemote(path: string, username?: string, password?: string): Promise<GitStatus> {
|
export function commitAiSplit(path: string, options: CommitAiGenerateOptions): Promise<AiCommitPlan> {
|
||||||
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null });
|
return invoke<AiCommitPlan>("commit_ai_split", {
|
||||||
|
path,
|
||||||
|
provider: options.provider,
|
||||||
|
model: options.model,
|
||||||
|
apiKey: options.apiKey,
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function push(path: string, username?: string, password?: string): Promise<GitStatus> {
|
export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null });
|
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRemoteUrl(path: string): Promise<string | null> {
|
export function fetchRemote(path: string, username?: string, password?: string, prune = false, remote?: string): Promise<GitStatus> {
|
||||||
return invoke<string | null>("get_remote_url", { path });
|
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null, prune, remote: remote || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function push(path: string, username?: string, password?: string, forceWithLease = false, remote?: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRemoteUrl(path: string, remote?: string, push = false): Promise<string | null> {
|
||||||
|
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> {
|
||||||
@@ -290,9 +449,12 @@ export function restoreFileFromCommit(
|
|||||||
return invoke<GitStatus>("restore_file_from_commit", { path, commit, file });
|
return invoke<GitStatus>("restore_file_from_commit", { path, commit, file });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeBranch(path: string, branch: string): Promise<GitStatus> {
|
export function mergeBranch(path: string, branch: string, strategy: MergeStrategy = "default"): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("merge_branch", { path, branch });
|
return invoke<GitStatus>("merge_branch", { path, branch, strategy });
|
||||||
}
|
}
|
||||||
|
export function mergeContinue(path: string): Promise<GitStatus> { return invoke("merge_continue", { path }); }
|
||||||
|
export function mergeAbort(path: string): Promise<GitStatus> { return invoke("merge_abort", { path }); }
|
||||||
|
export function revertCommit(path: string, commit: string): Promise<GitStatus> { return invoke("revert_commit", { path, commit }); }
|
||||||
|
|
||||||
export function rebaseBranch(path: string, branch: string): Promise<GitStatus> {
|
export function rebaseBranch(path: string, branch: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("rebase_branch", { path, branch });
|
return invoke<GitStatus>("rebase_branch", { path, branch });
|
||||||
@@ -306,6 +468,26 @@ export function rebaseAbort(path: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("rebase_abort", { path });
|
return invoke<GitStatus>("rebase_abort", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listInteractiveRebaseCommits(path: string, base: string): Promise<RebaseCommit[]> {
|
||||||
|
return invoke<RebaseCommit[]>("list_interactive_rebase_commits", { path, base });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startInteractiveRebase(
|
||||||
|
path: string,
|
||||||
|
base: string,
|
||||||
|
plan: RebasePlanItem[],
|
||||||
|
): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("start_interactive_rebase", { path, base, plan });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listReflog(path: string, limit = 250): Promise<ReflogEntry[]> {
|
||||||
|
return invoke<ReflogEntry[]>("list_reflog", { path, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreReflogEntry(path: string, commit: string, branch: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("restore_reflog_entry", { path, commit, branch });
|
||||||
|
}
|
||||||
|
|
||||||
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
|
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
|
||||||
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
|
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
type TelemetryLevel = "info" | "warn" | "error";
|
||||||
|
|
||||||
|
const MAX_MESSAGE_LENGTH = 2_048;
|
||||||
|
let telemetryEnabled = false;
|
||||||
|
let configuration: Promise<unknown> = Promise.resolve();
|
||||||
|
let frontendShuttingDown = false;
|
||||||
|
|
||||||
|
export function beginFrontendShutdown() {
|
||||||
|
frontendShuttingDown = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telemetry must never contain repository contents or identifying local data.
|
||||||
|
function sanitize(message: string): string {
|
||||||
|
return message
|
||||||
|
.replace(/https?:\/\/\S+/gi, "[url]")
|
||||||
|
.replace(/(?:[A-Za-z]:\\|\/)(?:[^\s<>:"|?*]+[\\/])*[^\s<>:"|?*]*/g, "[path]")
|
||||||
|
.replace(/(token|password|secret|authorization|credential)\s*[:=]\s*\S+/gi, "$1=[redacted]")
|
||||||
|
.replace(/[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}/g, "[email]")
|
||||||
|
.slice(0, MAX_MESSAGE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTelemetryEnabled(enabled: boolean) {
|
||||||
|
telemetryEnabled = enabled;
|
||||||
|
configuration = invoke("set_telemetry_enabled", { enabled }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function telemetryLog(level: TelemetryLevel, message: string, eventName?: string) {
|
||||||
|
if (!telemetryEnabled) return;
|
||||||
|
void configuration.then(() => invoke("emit_frontend_log", {
|
||||||
|
log: {
|
||||||
|
level,
|
||||||
|
message: sanitize(message),
|
||||||
|
eventName,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.catch(() => {
|
||||||
|
// Observability must never interrupt the Git workflow.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomHex(bytes: number): string {
|
||||||
|
const values = new Uint8Array(bytes);
|
||||||
|
crypto.getRandomValues(values);
|
||||||
|
return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tracedInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
if (frontendShuttingDown) {
|
||||||
|
// The window is on its way out and nothing calls preventDefault() on the
|
||||||
|
// close event, so it will finish closing regardless. Never resolving
|
||||||
|
// avoids surfacing a spurious "shutting down" error in whatever UI catch
|
||||||
|
// block happens to be awaiting an in-flight operation (e.g. a pull/push
|
||||||
|
// that makes several sequential calls) when the close request lands.
|
||||||
|
return new Promise<T>(() => {});
|
||||||
|
}
|
||||||
|
if (!telemetryEnabled) return invoke<T>(command, args);
|
||||||
|
const startedAtMs = Date.now();
|
||||||
|
const started = performance.now();
|
||||||
|
const traceId = randomHex(16);
|
||||||
|
const spanId = randomHex(8);
|
||||||
|
try {
|
||||||
|
const result = await invoke<T>(command, args);
|
||||||
|
void configuration.then(() => invoke("emit_frontend_span", {
|
||||||
|
span: { name: command, traceId, spanId, startedAtMs, durationMs: performance.now() - started, success: true },
|
||||||
|
})).catch(() => {});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
void configuration.then(() => invoke("emit_frontend_span", {
|
||||||
|
span: { name: command, traceId, spanId, startedAtMs, durationMs: performance.now() - started, success: false },
|
||||||
|
})).catch(() => {});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installGlobalErrorTelemetry() {
|
||||||
|
window.addEventListener("error", (event) => {
|
||||||
|
telemetryLog("error", event.message || "Unhandled frontend error", "frontend.unhandled_error");
|
||||||
|
});
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
const message = event.reason instanceof Error ? event.reason.message : String(event.reason ?? "Unhandled promise rejection");
|
||||||
|
telemetryLog("error", message, "frontend.unhandled_rejection");
|
||||||
|
});
|
||||||
|
}
|
||||||
+112
-1
@@ -10,6 +10,8 @@ export type FileStatusKind =
|
|||||||
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
||||||
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
||||||
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
||||||
|
export type AppTheme = "system" | "light" | "dark";
|
||||||
|
export type AppLanguage = "en" | "de";
|
||||||
|
|
||||||
export interface CommitAiStatus {
|
export interface CommitAiStatus {
|
||||||
phase: CommitAiPhase;
|
phase: CommitAiPhase;
|
||||||
@@ -17,6 +19,35 @@ export interface CommitAiStatus {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AiReviewRisk = "low" | "medium" | "high";
|
||||||
|
export type AiReviewSeverity = "critical" | "warning" | "info";
|
||||||
|
|
||||||
|
export interface AiReviewFinding {
|
||||||
|
severity: AiReviewSeverity;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
file: string | null;
|
||||||
|
line: number | null;
|
||||||
|
suggestion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiReviewResult {
|
||||||
|
summary: string;
|
||||||
|
risk: AiReviewRisk;
|
||||||
|
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;
|
||||||
@@ -38,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;
|
||||||
@@ -48,8 +109,13 @@ export interface GitStatus {
|
|||||||
clean: boolean;
|
clean: boolean;
|
||||||
rebase_in_progress: boolean;
|
rebase_in_progress: boolean;
|
||||||
cherry_pick_in_progress: boolean;
|
cherry_pick_in_progress: boolean;
|
||||||
|
merge_in_progress: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitRemote { name: string; fetch_url: string; push_url: string; }
|
||||||
|
export type PullStrategy = "merge" | "rebase" | "ff-only";
|
||||||
|
export type MergeStrategy = "default" | "squash" | "ff-only" | "no-ff";
|
||||||
|
|
||||||
export interface GitFileStatus {
|
export interface GitFileStatus {
|
||||||
path: string;
|
path: string;
|
||||||
old_path: string | null;
|
old_path: string | null;
|
||||||
@@ -63,6 +129,25 @@ export interface GitBranch {
|
|||||||
name: string;
|
name: string;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
remote: boolean;
|
remote: boolean;
|
||||||
|
upstream: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitWorktree {
|
||||||
|
path: string;
|
||||||
|
head: string | null;
|
||||||
|
short_head: string | null;
|
||||||
|
branch: string | null;
|
||||||
|
bare: boolean;
|
||||||
|
detached: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
lock_reason: string | null;
|
||||||
|
prunable: boolean;
|
||||||
|
prune_reason: string | null;
|
||||||
|
missing: boolean;
|
||||||
|
is_main: boolean;
|
||||||
|
is_current: boolean;
|
||||||
|
clean: boolean;
|
||||||
|
changed_files: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitTag {
|
export interface GitTag {
|
||||||
@@ -93,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 {
|
||||||
@@ -197,8 +283,33 @@ export interface GitBlameResult {
|
|||||||
lines: GitBlameLine[];
|
lines: GitBlameLine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RebaseAction = "pick" | "reword" | "squash" | "fixup" | "drop";
|
||||||
|
|
||||||
|
export interface RebaseCommit {
|
||||||
|
hash: string;
|
||||||
|
short_hash: string;
|
||||||
|
summary: string;
|
||||||
|
author_name: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RebasePlanItem {
|
||||||
|
hash: string;
|
||||||
|
action: RebaseAction;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReflogEntry {
|
||||||
|
hash: string;
|
||||||
|
short_hash: string;
|
||||||
|
selector: string;
|
||||||
|
action: string;
|
||||||
|
author_name: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoredCredential {
|
export interface StoredCredential {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
expiresAt?: string | null;
|
mode?: "credentials" | "token";
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -2,13 +2,33 @@ import { mount } from "svelte";
|
|||||||
|
|
||||||
import App from "./App.svelte";
|
import App from "./App.svelte";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
import { installGlobalErrorTelemetry } from "./lib/telemetry";
|
||||||
|
|
||||||
|
const THEME_KEY = "gitlite.theme.v1";
|
||||||
const target = document.getElementById("app");
|
const target = document.getElementById("app");
|
||||||
|
|
||||||
|
installGlobalErrorTelemetry();
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw new Error("App target element was not found.");
|
throw new Error("App target element was not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedTheme = localStorage.getItem(THEME_KEY);
|
||||||
|
const preference = storedTheme === "light" || storedTheme === "dark" || storedTheme === "system"
|
||||||
|
? storedTheme
|
||||||
|
: "system";
|
||||||
|
const resolved = preference === "system" && window.matchMedia("(prefers-color-scheme: light)").matches
|
||||||
|
? "light"
|
||||||
|
: preference === "system"
|
||||||
|
? "dark"
|
||||||
|
: preference;
|
||||||
|
document.documentElement.dataset.themePreference = preference;
|
||||||
|
document.documentElement.dataset.theme = resolved;
|
||||||
|
} catch {
|
||||||
|
// Storage is best-effort; the CSS system theme fallback still applies.
|
||||||
|
}
|
||||||
|
|
||||||
const app = mount(App, { target });
|
const app = mount(App, { target });
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
Reference in New Issue
Block a user