Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a97b20bb | ||
|
|
97b5db3031 | ||
|
|
f607c6b5cf | ||
|
|
13660c801e | ||
|
|
c46a875a91 | ||
|
|
25d9d28764 | ||
|
|
da53ba3c0e | ||
|
|
20bc53ebbb | ||
|
|
c6553218a2 | ||
|
|
26f09a3350 | ||
|
|
a55a3ca300 | ||
|
|
865ea410ea | ||
|
|
96d8ec739f | ||
|
|
2b43fb18ac | ||
|
|
5c0c47b5c5 | ||
|
|
ebb5a5fa48 | ||
|
|
2ec493e91f | ||
|
|
7f2b04a506 | ||
|
|
3ceedca0ad | ||
|
|
fcd884ee0b | ||
|
|
4a4a13dcf8 | ||
|
|
106cc98afb | ||
|
|
add98f962e | ||
|
|
90df347e4a | ||
|
|
4c58b14691 | ||
|
|
8bec7dfc9a | ||
|
|
7950edb145 | ||
|
|
e3af6653cd | ||
|
|
b752c0804b | ||
|
|
8f98e79df9 | ||
|
|
f0a1d89152 | ||
|
|
c3762ae7a3 | ||
|
|
4db6f30461 | ||
|
|
9c93d5a978 | ||
|
|
c242a72edd | ||
|
|
91263547db | ||
|
|
823a50ce85 | ||
|
|
22da397e39 | ||
|
|
f160e48777 | ||
|
|
4533f8aa38 | ||
|
|
f0bd74be4e | ||
|
|
79f4ec21e4 | ||
|
|
44547f507a | ||
|
|
58027f5e22 | ||
|
|
3477070ec3 | ||
|
|
7d3288a7ef | ||
|
|
6d806e87ea | ||
|
|
7408371430 | ||
|
|
e4697c74b6 | ||
|
|
a4936ba790 | ||
|
|
b5d15f9190 | ||
|
|
e7fbaadf5e | ||
|
|
8b559fa15c | ||
|
|
fac22ff173 | ||
|
|
cd01df0108 | ||
|
|
6f2d4dd8c9 | ||
|
|
33e85fe6be | ||
|
|
993179c25e | ||
|
|
25d7f27d7a | ||
|
|
c5f058beb1 | ||
|
|
4181925047 | ||
|
|
927e0c3e92 | ||
|
|
0dac3e9f09 | ||
|
|
4f1131e855 | ||
|
|
f24c39c573 | ||
|
|
f487090b4b | ||
|
|
c8247f2b2a | ||
|
|
a7558e457d | ||
|
|
412e8d19b5 | ||
|
|
32832f5db7 | ||
|
|
37d2152fcd | ||
|
|
2bc74dc4a7 | ||
|
|
4db488415f | ||
|
|
be695d78a9 | ||
|
|
40311275b0 | ||
|
|
fe577d78a8 | ||
|
|
a27a8666ee | ||
|
|
c442b3735f | ||
|
|
f621638eb3 | ||
|
|
56467f481d | ||
|
|
6434a9f10c | ||
|
|
cc0f1c076f | ||
|
|
b6b9964a93 | ||
|
|
f43fe00873 | ||
|
|
ca14fac90c | ||
|
|
608b3131d2 | ||
|
|
a823aabbb9 | ||
|
|
15d1f2bfd6 | ||
|
|
3eb554fee7 | ||
|
|
1fa57eea6f | ||
|
|
444a7acadd | ||
|
|
ac7cacd687 | ||
|
|
80dc66366d | ||
|
|
a6c86daf62 | ||
|
|
f88b6961d1 | ||
|
|
eb65c1def7 | ||
|
|
96e3805568 | ||
|
|
2d9a10e85c | ||
|
|
12d1b2657f | ||
|
|
b0b4fb69cb | ||
|
|
f3a3fcc079 | ||
|
|
9d18f3af61 | ||
|
|
c4a922894f | ||
|
|
41f474f1eb | ||
|
|
6dd70ec52a | ||
|
|
90070697dd | ||
|
|
50524c2759 | ||
|
|
0cd97db523 | ||
|
|
8079ac5f64 | ||
|
|
df26ecf9fb | ||
|
|
af7b993550 | ||
|
|
e07bc96127 | ||
|
|
f0e87d67d5 | ||
|
|
3246dfdcfd | ||
|
|
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 | ||
|
|
35d9fc969c | ||
|
|
710c6d4a53 | ||
|
|
0877d5a568 | ||
|
|
f79f3dc1ec | ||
|
|
cf2773b6f4 |
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"frontend-design@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cargo build *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(kill %1)",
|
||||
"Bash(perl -0pi -e 's/\\\\{line \\\\|\\\\| \" \"\\\\}/{displayLine\\(line\\) || \" \"}/g' src/App.svelte)",
|
||||
"Bash(kill 26306)",
|
||||
"Bash(convert --version)",
|
||||
"Bash(magick /mnt/data/Development/GitLite/src-tauri/icons/icon.ico /mnt/data/Development/GitLite/src-tauri/icons/icon.png)",
|
||||
"Bash(magick /mnt/data/Development/GitLite/src-tauri/icons/icon.ico -type TrueColorAlpha -alpha on PNG32:/mnt/data/Development/GitLite/src-tauri/icons/icon.png)",
|
||||
"Bash(magick identify *)",
|
||||
"Bash(magick -size 512x512 xc:\"rgba\\(70,130,180,255\\)\" PNG32:/mnt/data/Development/GitLite/src-tauri/icons/icon.png)",
|
||||
"Bash(pkill -f \"vite --host\")",
|
||||
"Bash(pkill -f \"tauri dev\")",
|
||||
"Bash(pkill -f \"tauri_git_lite\")",
|
||||
"Bash(xargs kill -9)",
|
||||
"Bash(grep -qE \"\\(Running \\\\`target|error\\\\[|panicked|terminated\\)\" /tmp/claude-1000/-mnt-data-Development-GitLite/71a8b49f-ac61-4ad9-aaba-dc0219044038/tasks/b4ujhgmdk.output)",
|
||||
"Bash(grep -qE \"\\(Running \\\\`target|error\\\\[|panicked|terminated\\)\" /tmp/claude-1000/-mnt-data-Development-GitLite/71a8b49f-ac61-4ad9-aaba-dc0219044038/tasks/bt54rikhh.output)",
|
||||
"Bash(grep -qE \"\\(Running \\\\`target|error\\\\[|panicked|terminated\\)\" /tmp/claude-1000/-mnt-data-Development-GitLite/71a8b49f-ac61-4ad9-aaba-dc0219044038/tasks/b7nyub68s.output)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(cargo check *)",
|
||||
"Bash(npx vite *)",
|
||||
"Bash(cargo tree *)",
|
||||
"Bash(jobs)",
|
||||
"Bash(npx svelte-check *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(xxd)",
|
||||
"Bash(python3 -)",
|
||||
"Bash(echo \"exit: $?\")",
|
||||
"Bash(grep -n \"input,\\\\|select,\\\\|input {\\\\|select {\\\\|.repo-form input\\\\|input:focus\\\\|::placeholder\" src/app.css)",
|
||||
"Bash(sudo -n true)",
|
||||
"Bash(rustc --version)",
|
||||
"Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)",
|
||||
"Bash(sudo apt install -y libdbus-1-dev pkg-config)",
|
||||
"Bash(dpkg -l)",
|
||||
"Bash(apt list *)",
|
||||
"Bash(cargo search *)",
|
||||
"Bash(curl -s \"https://crates.io/api/v1/crates/mistralrs\")",
|
||||
"Bash(cargo info *)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"Bash(gh api *)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:ericlbuehler.github.io)",
|
||||
"WebFetch(domain:docs.rs)",
|
||||
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF\")",
|
||||
"Bash(python3 -c ' *)",
|
||||
"Bash(curl -sI \"https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf\")",
|
||||
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF?blobs=true\")",
|
||||
"Bash(grep -n 'from \"\\\\./lib/git\"\\\\|from \"\\\\./lib/types\"\\\\|^ commit,$' src/App.svelte)",
|
||||
"Bash(kill 24343 24363 24375 24376)",
|
||||
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-0.5B-Instruct-GGUF?blobs=true\")",
|
||||
"Bash(curl -s \"https://huggingface.co/api/models/bartowski/Llama-3.2-3B-Instruct-GGUF?blobs=true\")",
|
||||
"Bash(curl -s \"https://huggingface.co/api/models/bartowski/Llama-3.2-3B-Instruct-GGUF\")",
|
||||
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct-GGUF\")",
|
||||
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct-GGUF?blobs=true\")",
|
||||
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct\")",
|
||||
"Bash(: *)",
|
||||
"Bash(exit 0 *)",
|
||||
"Bash(rustc -O sanitize_test.rs -o sanitize_test)",
|
||||
"Bash(./sanitize_test)",
|
||||
"Bash(echo \"exit:$?\")",
|
||||
"Bash(grep -rlP \"[äöüßÄÖÜ]\" src src-tauri/src src-tauri/crates --include=\"*.rs\" --include=\"*.svelte\" --include=\"*.ts\")",
|
||||
"Bash(echo \"---exit $?---\")",
|
||||
"Bash(apt-cache policy *)",
|
||||
"Bash(timeout 5 curl -sI http://archive.ubuntu.com)",
|
||||
"Bash(sudo -n apt-get install -y libdbus-1-dev pkg-config)",
|
||||
"Bash(grep -n '\"Unerwarteter Git-Log-Eintrag: {}\",' src-tauri/src/git.rs)",
|
||||
"Bash(grep -E \"git_lite$|tauri_git_lite$\")",
|
||||
"Bash(rustfmt --edition 2024 --check src-tauri/src/git.rs)",
|
||||
"Bash(echo \"EXIT:$?\")",
|
||||
"Bash(ls target/)",
|
||||
"Bash(rustup target *)",
|
||||
"Bash(echo \"exit code: $?\")",
|
||||
"Read(//home/cbr/.cargo/registry/src/**)",
|
||||
"Bash(find / -maxdepth 6 -iname \"mistralrs-*\" -type d)",
|
||||
"Bash(grep -A2 '^name = \"tauri\"$' \"/mnt/c/Users/cbr/Desktop/Neuer Ordner \\(6\\)/src-tauri/Cargo.lock\")",
|
||||
"Bash(rustfmt --edition 2024 --check src/badge.rs src/main.rs)",
|
||||
"Bash(rustfmt --edition 2024 --check src/git.rs)",
|
||||
"Bash(rustfmt --edition 2024 --check src/badge.rs)",
|
||||
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)",
|
||||
"Bash(pkg-config --list-all)",
|
||||
"Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata src/git.rs)",
|
||||
"Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata /mnt/d/Development/GitLite/src-tauri/src/git.rs)",
|
||||
"Bash(rustc --edition 2021 --crate-type bin -o /dev/null --emit=metadata src/main.rs)",
|
||||
"Bash(grep -B1 \"^error\\\\[E0432\\\\]\\\\|^error$\")",
|
||||
"Bash(grep -v \"^--$\")",
|
||||
"Bash(grep \"^error$\" -A2)",
|
||||
"Bash(cargo run *)",
|
||||
"Bash(node_modules/.bin/svelte-check --version)",
|
||||
"Bash(rustfmt --check --edition 2021 src/git.rs src/main.rs)",
|
||||
"Bash(rustfmt --check --edition 2021 src/git.rs)",
|
||||
"Bash(awk *)",
|
||||
"Bash(rustfmt --edition 2021 /tmp/blame_chunk.rs)",
|
||||
"Read(//tmp/**)",
|
||||
"Bash(identify src-tauri/icons/GitCat.ico)",
|
||||
"Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")",
|
||||
"Bash(python3 *)",
|
||||
"Bash(xxd -l 16 src-tauri/icons/GitCat.ico)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ on:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
publish-tauri:
|
||||
publish-windows:
|
||||
name: Build and publish Windows installer
|
||||
permissions:
|
||||
contents: write
|
||||
environment: production
|
||||
@@ -30,25 +31,7 @@ jobs:
|
||||
run:
|
||||
working-directory: GitLite
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
include:
|
||||
# - platform: 'macos-latest'
|
||||
# args: '--target aarch64-apple-darwin'
|
||||
# - platform: 'macos-latest'
|
||||
# args: '--target x86_64-apple-darwin'
|
||||
- platform: "windows-latest"
|
||||
bundles: "nsis"
|
||||
updater_platform: "windows-x86_64"
|
||||
no_strip: ""
|
||||
- platform: "ubuntu-22.04"
|
||||
bundles: "appimage"
|
||||
updater_platform: "linux-x86_64"
|
||||
no_strip: "1"
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
# cicd_tool/main.py expects the repo at <workspace>/GitLite, so check out there.
|
||||
@@ -56,25 +39,11 @@ jobs:
|
||||
with:
|
||||
path: GitLite
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Install uv
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
|
||||
|
||||
- name: install rust toolchain
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -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');"
|
||||
|
||||
- name: Commit and Push Changes
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: powershell
|
||||
run: |
|
||||
git config user.name '${{ vars.USERNAME_GIT }}'
|
||||
@@ -139,15 +107,421 @@ jobs:
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
|
||||
NO_STRIP: ${{ matrix.no_strip }}
|
||||
run: npm run tauri -- build --bundles ${{ matrix.bundles }}
|
||||
NO_STRIP: ""
|
||||
run: npm run tauri -- build --bundles nsis
|
||||
# ----------------------
|
||||
# Upload to MinIO (via mc) and create latest.json
|
||||
# ----------------------
|
||||
- name: Upload artifacts to MinIO with cicd_tool
|
||||
working-directory: GitLite/cicd_tool
|
||||
env:
|
||||
PUBLISH_PLATFORM: ${{ matrix.updater_platform }}
|
||||
PUBLISH_PLATFORM: windows-x86_64
|
||||
run: |
|
||||
uv sync
|
||||
uv run main.py
|
||||
|
||||
publish-arch:
|
||||
name: Build and publish 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 git-lfs nodejs npm openssh rust
|
||||
webkit2gtk-4.1 gtk3 hicolor-icon-theme
|
||||
libappindicator-gtk3 librsvg xdotool
|
||||
)
|
||||
|
||||
for ARCH_INSTALL_ATTEMPT in 1 2 3; do
|
||||
echo "pacman attempt $ARCH_INSTALL_ATTEMPT of 3"
|
||||
timeout --signal=TERM 30m \
|
||||
pacman -Syu --needed --noconfirm --noprogressbar \
|
||||
"${ARCH_PACKAGES[@]}" \
|
||||
</dev/null &
|
||||
ARCH_PACMAN_PID=$!
|
||||
(
|
||||
ARCH_WAIT_SECONDS=0
|
||||
while true; do
|
||||
sleep 30
|
||||
((ARCH_WAIT_SECONDS += 30))
|
||||
echo "pacman is still running (${ARCH_WAIT_SECONDS}s elapsed)"
|
||||
done
|
||||
) &
|
||||
ARCH_HEARTBEAT_PID=$!
|
||||
|
||||
ARCH_PACMAN_STATUS=0
|
||||
wait "$ARCH_PACMAN_PID" || ARCH_PACMAN_STATUS=$?
|
||||
kill "$ARCH_HEARTBEAT_PID" 2>/dev/null || true
|
||||
wait "$ARCH_HEARTBEAT_PID" 2>/dev/null || true
|
||||
|
||||
if [ "$ARCH_PACMAN_STATUS" -eq 0 ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$ARCH_PACMAN_STATUS" -eq 124 ]; then
|
||||
echo "pacman attempt timed out after 30 minutes" >&2
|
||||
fi
|
||||
|
||||
if [ "$ARCH_INSTALL_ATTEMPT" -eq 3 ]; then
|
||||
echo "pacman failed after 3 attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep "$((ARCH_INSTALL_ATTEMPT * 5))"
|
||||
done
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
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 git-lfs libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Install uv
|
||||
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: |
|
||||
uv sync
|
||||
uv run main.py
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
/node_modules
|
||||
/dist
|
||||
/src-tauri/target
|
||||
/src-tauri/binaries/git-lfs
|
||||
/src-tauri/binaries/git-lfs.exe
|
||||
/src-tauri/binaries/git-lfs-*-*-*
|
||||
*.log
|
||||
.idea
|
||||
.DS_Store
|
||||
~
|
||||
.codex*
|
||||
target
|
||||
|
||||
*.pkg.tar.zst
|
||||
pkg
|
||||
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
# 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.8] - 2026-08-29
|
||||
|
||||
### Added
|
||||
|
||||
- Git hosting integrations for GitHub, GitLab.com, GitLab Self-Managed,
|
||||
Azure DevOps, and Gitea. Personal access tokens are stored separately in
|
||||
the operating system keychain.
|
||||
- Azure DevOps supports multiple independently configurable organizations,
|
||||
each with its own display name, organization URL, username, and token.
|
||||
- The Clone dialog has an Integrations tab that loads all repositories
|
||||
available to the selected account, supports filtering and refresh, sorts
|
||||
repositories alphabetically, and clones the selected repository directly
|
||||
with its stored credentials.
|
||||
|
||||
### Changed
|
||||
|
||||
- Repository tabs use a more compact Git-client-style bar. Close buttons
|
||||
remain visible and turn red only while hovered.
|
||||
- The integration repository list uses a narrow custom scrollbar that grows
|
||||
only slightly on hover and no longer covers repository names or metadata.
|
||||
- Repository loading and status overlays use theme-aware design tokens with
|
||||
improved contrast and a more compact presentation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Clearing sync settings on a branch that never had an upstream is now a safe
|
||||
no-op instead of failing with a fatal Git error.
|
||||
|
||||
## [2026.8.7] - 2026-08-23
|
||||
|
||||
### Added
|
||||
|
||||
- Appearance settings now offer Modern, Classic, and Custom styles. Custom
|
||||
themes can define their own persisted color palette, while a complete light
|
||||
theme is available alongside the refreshed dark appearance.
|
||||
- The branch visibility dialog groups local and remote branches into
|
||||
collapsible sections with selected-branch counts. Local branches open by
|
||||
default, while the remote group starts collapsed for quicker navigation.
|
||||
|
||||
### Changed
|
||||
|
||||
- Dark-theme colors, surfaces, controls, and focus outlines have been refined
|
||||
for clearer interactive boundaries and more consistent contrast throughout
|
||||
the application.
|
||||
- The branch visibility dialog follows the responsive layout and visual
|
||||
language of the rest of Gitty more closely.
|
||||
|
||||
## [2026.8.6] - 2026-08-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Activating LFS or adding a tracking pattern now ensures that a root
|
||||
`.gitattributes` file is not hidden by Git ignore rules. When necessary,
|
||||
Gitty adds the scoped `!/.gitattributes` exception to `.gitignore`.
|
||||
- The standard Tauri development launcher now removes an injected non-routing
|
||||
`127.0.0.1:9` proxy and blocking SSH placeholder from the debug child
|
||||
process, while preserving real user and company proxy settings.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Root `.gitattributes` LFS patterns remain visible while the file is
|
||||
untracked or ignored, instead of disappearing from the LFS dialog.
|
||||
- Git LFS pushes rejected with HTTP 413 are retried once with a command-scoped
|
||||
HTTP/1.1 override, which works around Azure DevOps' large HTTP/2 upload
|
||||
behavior without changing repository or global Git settings.
|
||||
- LFS and other generic push failures no longer trigger the unrelated
|
||||
Pull/Push retry flow intended only for non-fast-forward rejections.
|
||||
|
||||
## [2026.8.5] - 2026-08-17
|
||||
|
||||
### Added
|
||||
|
||||
- Integrated Git LFS management now detects the available extension and the
|
||||
repository setup, shows tracked patterns and checkout objects, and provides
|
||||
actions for local activation, tracking and untracking patterns, downloading
|
||||
objects, and pruning the local cache. Desktop installers bundle Git LFS,
|
||||
while Arch packages declare it as a dependency.
|
||||
- The Changes panel now offers a List/Tree switch. Tree view groups staged and
|
||||
unstaged files into independently collapsible folders while retaining all
|
||||
existing file actions.
|
||||
- Files and folders in the Changes panel now have context-menu actions for
|
||||
staging or unstaging their scope and for creating a stash containing only
|
||||
the selected file or folder. New and untracked items can also be added to
|
||||
the repository `.gitignore` from Changes or the File Explorer as an exact
|
||||
file, a complete folder, or an extension-wide pattern. Folder rules are only
|
||||
offered for folder selections. Tracked files and folders can be removed from
|
||||
the Git index without deleting their working-tree contents.
|
||||
- Gitty can open a repository directly at startup through the `--repo PATH` or
|
||||
`--repo=PATH` command-line argument.
|
||||
- The executable can clone and immediately open a repository with
|
||||
`clone REMOTE TARGET`, `--clone REMOTE TARGET`, or `--clone=REMOTE TARGET`.
|
||||
Relative targets use the caller's working directory, and requests are also
|
||||
forwarded to an already-running Gitty instance.
|
||||
|
||||
### Changed
|
||||
|
||||
- Successful clones and pulls now detect repositories that use LFS and
|
||||
download the required LFS objects automatically with the same remote and
|
||||
credentials. Fresh clones also activate LFS locally before they are opened.
|
||||
- Unstaged and staged changes use an equal-width side-by-side layout with
|
||||
independent scrolling, directional stage/unstage actions, and a responsive
|
||||
vertical fallback. The List/Tree switch is centered above both areas.
|
||||
- File and folder context menus now separate the selected name from its parent
|
||||
path, show the affected file count, and present stage, unstage, and stash
|
||||
actions with clearer icons and descriptions.
|
||||
- Controls and surfaces use a more consistent square visual language while
|
||||
circular status markers, avatars, and branch shapes remain recognizable.
|
||||
- The built-in German and English help now includes Git LFS setup, tracking,
|
||||
troubleshooting, and everyday workflow guidance.
|
||||
|
||||
## [2026.8.4] - 2026-08-15
|
||||
|
||||
### Added
|
||||
|
||||
- Branch folders now have a context-menu action for deleting all contained
|
||||
local or remote branches at once. The top-level remote folder such as
|
||||
`origin` is protected, while its nested folders remain manageable. The
|
||||
currently checked-out branch is kept,
|
||||
and individual failures are reported after the remaining branches have been
|
||||
processed.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Compare selector is fully localized in German and uses the same visual
|
||||
language as the external-tool selectors for its fields, groups, typography,
|
||||
and dialog surfaces.
|
||||
- Repository-tab close buttons are square and have clearer spacing, hover
|
||||
behavior, and keyboard-focus feedback.
|
||||
|
||||
## [2026.8.3] - 2026-08-13
|
||||
|
||||
### Added
|
||||
|
||||
- Configurable external tools for editors, diff viewers, merge tools,
|
||||
terminals, and file managers, including automatic cross-platform discovery
|
||||
and presets for VS Code, JetBrains IDEs, Beyond Compare, and other common
|
||||
applications.
|
||||
- Repository and file actions for opening content in the configured external
|
||||
application. Supported tools open in a separate window.
|
||||
- A choice between Gitty's internal diff/merge views and the configured
|
||||
external applications.
|
||||
- Git Notes support for attaching editable notes to commits without rewriting
|
||||
commit history, including fetch and push synchronization.
|
||||
- A command palette for quickly opening repository actions, files, and commits.
|
||||
- Complete branch-to-branch comparisons for local and remote branches. The
|
||||
comparison dialog shows every changed file and its side-by-side diff.
|
||||
- Safe remote branch renaming from the branch context menu.
|
||||
|
||||
### Changed
|
||||
|
||||
- Redesigned the settings window with tool categories, detected applications,
|
||||
preset dropdowns, and clearer explanations of where each tool is used.
|
||||
- Redesigned the history graph's branch presentation with compact labels,
|
||||
hover details, cleaner flag connectors, and branch visibility controls.
|
||||
- Reduced the minimum width of the commit history panel so the workspace can
|
||||
be resized more freely.
|
||||
- Local-only branches are now identified consistently in the toolbar,
|
||||
repository summary, status bar, and commit graph. Their first push is labeled
|
||||
Publish and configures the remote tracking branch automatically.
|
||||
- Git operations now run asynchronously to keep the application responsive
|
||||
during slower repository commands.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Closing supported external tools no longer reports their documented
|
||||
comparison result codes as application errors.
|
||||
- External tools that otherwise reuse an existing process are explicitly
|
||||
opened in a new window where supported.
|
||||
- Remote branch renaming uses an atomic push with lease checks, preventing an
|
||||
existing destination branch or a newly changed remote branch from being
|
||||
overwritten.
|
||||
|
||||
## [2026.8.2] - 2026-08-10
|
||||
|
||||
### Changed
|
||||
|
||||
- History graph colors remain stable across parent lanes, making longer and
|
||||
branching histories easier to follow.
|
||||
- Release artifacts are published to the matching Gitea release automatically
|
||||
without creating duplicate assets.
|
||||
- Application shutdown now completes telemetry cleanup more reliably.
|
||||
|
||||
## [2026.8.1] - 2026-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- Paginated commit history that loads older commits on demand instead of
|
||||
limiting the visible repository history to the initial page.
|
||||
- A dedicated file-history dialog opened from the explorer context menu.
|
||||
- Windows and Ubuntu release publishing plus improved AUR packaging workflows.
|
||||
|
||||
### Changed
|
||||
|
||||
- File history moved out of the permanent workspace panel into a focused,
|
||||
larger dialog.
|
||||
- Dialogs close more consistently with the Escape key.
|
||||
- Arch Linux installation documentation now uses the `gitty-desktop` AUR
|
||||
package.
|
||||
|
||||
### Fixed
|
||||
|
||||
- AUR SSH setup, package installation timeouts, and clone/push retries are more
|
||||
robust in the release workflow.
|
||||
|
||||
## [2026.07.22] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- AI-assisted commit splitting for staged changes:
|
||||
- analyze the staged diff and propose an ordered set of logical commits;
|
||||
- generate an editable Conventional Commit message for every group;
|
||||
- move files between proposed commits before applying the plan;
|
||||
- create all accepted commits sequentially with **Commit all**.
|
||||
- Support for commit-splitting suggestions through OpenAI, Anthropic, and
|
||||
custom OpenAI-compatible endpoints.
|
||||
|
||||
### Changed
|
||||
|
||||
- The commit-splitting dialog now explains why a plan cannot be applied and
|
||||
clearly marks AI-generated commit messages as editable.
|
||||
- Safety checks prevent applying a stale plan after the staged files change
|
||||
and reject files that contain both staged and unstaged changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Commit all** now passes a plain state snapshot to the commit workflow
|
||||
instead of failing silently when cloning a reactive UI proxy.
|
||||
- Closing the active repository tab no longer allows an in-flight status or
|
||||
fetch response to reopen and reactivate the closed repository.
|
||||
|
||||
## [2026.07.21] - 2026-07-21
|
||||
|
||||
### 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.8]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.8
|
||||
[2026.8.7]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.7
|
||||
[2026.8.6]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.6
|
||||
[2026.8.5]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.5
|
||||
[2026.8.4]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.4
|
||||
[2026.8.3]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.3
|
||||
[2026.8.2]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.2
|
||||
[2026.8.1]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.1
|
||||
@@ -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' 'git-lfs' '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.6
|
||||
pkgrel=1
|
||||
pkgdesc="A lightweight, modern Git client built with Tauri (prebuilt Arch package)"
|
||||
arch=('x86_64')
|
||||
url="https://git.cbsk-tech.de/Christoph/GitLite"
|
||||
license=('MIT')
|
||||
depends=('git' 'git-lfs' 'webkit2gtk-4.1' 'gtk3' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
||||
provides=('gitty-desktop')
|
||||
conflicts=('gitty-desktop')
|
||||
options=('!strip')
|
||||
|
||||
_package="gitty-desktop-${pkgver}-1-x86_64.pkg.tar.zst"
|
||||
_artifact_url="https://git.cbsk-tech.de/Christoph/GitLite/releases/download/${pkgver}/${_package}"
|
||||
source=("${_package}::${_artifact_url}")
|
||||
noextract=("${_package}")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
package() {
|
||||
# Extract only the native package payload, without carrying its package
|
||||
# metadata (.PKGINFO, .BUILDINFO and .MTREE) across.
|
||||
bsdtar -xf "$srcdir/$_package" -C "$pkgdir" usr
|
||||
}
|
||||
@@ -20,13 +20,15 @@ Fast, simple, and designed for developers who want a clean Git experience withou
|
||||
- 🔄 Pull, Push & Fetch
|
||||
- 🔀 Merge & Rebase
|
||||
- 📦 Repository management
|
||||
- ☁️ GitHub, GitLab, Azure DevOps, and Gitea integrations
|
||||
- 🗄️ Git LFS detection, tracking and object management
|
||||
- 🎨 Modern and intuitive UI
|
||||
|
||||
---
|
||||
|
||||
## 📸 Preview
|
||||
|
||||
> Screenshots coming soon.
|
||||
> Screenshots comes later.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,4 +50,84 @@ Contributions, feedback and feature requests are always welcome!
|
||||
|
||||
## 📄 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.
|
||||
|
||||
---
|
||||
|
||||
## Command line
|
||||
|
||||
Open an existing repository when Gitty starts:
|
||||
|
||||
```text
|
||||
gitty.exe --repo "D:\Projects\ExistingRepo"
|
||||
```
|
||||
|
||||
Clone a remote into an exact local target folder and open it immediately:
|
||||
|
||||
```text
|
||||
gitty.exe --clone "https://example.com/team/project.git" "D:\Projects\Project"
|
||||
gitty clone "git@example.com:team/project.git" "D:\Projects\Project"
|
||||
```
|
||||
|
||||
`--clone=<REMOTE>` is also accepted. Relative target paths are resolved from
|
||||
the current working directory. Clone and repository requests are forwarded to
|
||||
the running window when Gitty is already open.
|
||||
|
||||
---
|
||||
|
||||
## Git hosting integrations
|
||||
|
||||
Gitty connects to GitHub, GitLab.com, GitLab Self-Managed, Azure DevOps, and
|
||||
Gitea from **Settings → Integrations**. Each connection uses a personal access
|
||||
token that is stored in the operating system keychain instead of application
|
||||
settings. Azure DevOps can manage multiple organizations with separate URLs,
|
||||
usernames, and tokens.
|
||||
|
||||
After enabling a connection, open **Clone → Integrations** to load the
|
||||
repositories available to that account. Repositories are sorted
|
||||
alphabetically and can be filtered, refreshed, selected, and cloned directly
|
||||
with the stored credentials.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Git LFS
|
||||
|
||||
Gitty bundles the `git-lfs` executable in its desktop installers and checks it
|
||||
at runtime before offering LFS actions. Arch packages also declare `git-lfs` as
|
||||
a dependency so Git hooks and command-line workflows outside Gitty use the same
|
||||
extension. The repository toolbar exposes LFS setup, tracked patterns, object
|
||||
downloads, and safe cache pruning. After every successful clone or pull, Gitty
|
||||
detects LFS usage and automatically downloads the required LFS objects with the
|
||||
same remote and credentials, so no second pull is needed. Fresh clones also get
|
||||
repository-local LFS filters and the pre-push hook before they are opened.
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, List, NamedTuple, Optional
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import quote, urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
@@ -329,6 +333,65 @@ def _publish_target(
|
||||
)
|
||||
|
||||
|
||||
def _gitea_request(url: str, token: str, data: Optional[bytes] = None, **headers: str):
|
||||
request = Request(url, data=data, headers={"Authorization": f"token {token}", **headers})
|
||||
return urlopen(request, timeout=120)
|
||||
|
||||
|
||||
def _upload_gitea_release_asset(version: str, artifact: Path) -> None:
|
||||
"""Attach the installer to the Gitea release that triggered this build."""
|
||||
api_url = os.environ.get("GITEA_API", "").rstrip("/")
|
||||
owner = os.environ.get("OWNER", "")
|
||||
repo = os.environ.get("REPO", "")
|
||||
token = os.environ.get("GITEA_TOKEN") or os.environ.get("GITEA_FALLBACK_TOKEN")
|
||||
if not all((api_url, owner, repo, token)):
|
||||
raise ConfigurationError(
|
||||
"GITEA_API, OWNER, REPO and GITEA_TOKEN are required to publish "
|
||||
"the release asset."
|
||||
)
|
||||
|
||||
release_url = (
|
||||
f"{api_url}/repos/{quote(owner, safe='')}/{quote(repo, safe='')}"
|
||||
f"/releases/tags/{quote(version, safe='')}"
|
||||
)
|
||||
with _gitea_request(release_url, token) as response:
|
||||
release = json.load(response)
|
||||
|
||||
existing_names = {
|
||||
asset.get("name") for asset in release.get("assets", []) if isinstance(asset, dict)
|
||||
}
|
||||
if artifact.name in existing_names:
|
||||
print(f"Release asset {artifact.name} already exists; skipping upload.")
|
||||
return
|
||||
|
||||
boundary = f"----gitty-{uuid.uuid4().hex}"
|
||||
content_type = mimetypes.guess_type(artifact.name)[0] or "application/octet-stream"
|
||||
body = b"".join(
|
||||
(
|
||||
f"--{boundary}\r\n".encode(),
|
||||
(
|
||||
f'Content-Disposition: form-data; name="attachment"; '
|
||||
f'filename="{artifact.name}"\r\n'
|
||||
).encode(),
|
||||
f"Content-Type: {content_type}\r\n\r\n".encode(),
|
||||
artifact.read_bytes(),
|
||||
f"\r\n--{boundary}--\r\n".encode(),
|
||||
)
|
||||
)
|
||||
upload_url = (
|
||||
f"{api_url}/repos/{quote(owner, safe='')}/{quote(repo, safe='')}"
|
||||
f"/releases/{release['id']}/assets?{urlencode({'name': artifact.name})}"
|
||||
)
|
||||
with _gitea_request(
|
||||
upload_url,
|
||||
token,
|
||||
data=body,
|
||||
**{"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
||||
):
|
||||
pass
|
||||
print(f"Attached {artifact.name} to Gitea release {version}.")
|
||||
|
||||
|
||||
def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
||||
resolved_version = _resolve_version(version)
|
||||
artifact_base_url = os.environ.get("ARTIFACT_BASE_URL", "")
|
||||
@@ -385,6 +448,8 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
||||
artifact_base_url=artifact_base_url,
|
||||
)
|
||||
|
||||
_upload_gitea_release_asset(resolved_version, primary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
|
||||
+101
-4
@@ -15,6 +15,9 @@ interface GitStatus {
|
||||
behind: number;
|
||||
files: GitFileStatus[];
|
||||
clean: boolean;
|
||||
rebase_in_progress: boolean;
|
||||
cherry_pick_in_progress: boolean;
|
||||
merge_in_progress: boolean;
|
||||
}
|
||||
|
||||
interface GitFileStatus {
|
||||
@@ -30,6 +33,24 @@ interface GitBranch {
|
||||
remote: boolean;
|
||||
}
|
||||
|
||||
interface GitWorktree {
|
||||
path: string;
|
||||
head: string | null;
|
||||
short_head: string | null;
|
||||
branch: string | null;
|
||||
bare: boolean;
|
||||
detached: boolean;
|
||||
locked: boolean;
|
||||
lock_reason: string | null;
|
||||
prunable: boolean;
|
||||
prune_reason: string | null;
|
||||
missing: boolean;
|
||||
is_main: boolean;
|
||||
is_current: boolean;
|
||||
clean: boolean;
|
||||
changed_files: number;
|
||||
}
|
||||
|
||||
interface GitCommit {
|
||||
hash: string;
|
||||
short_hash: string;
|
||||
@@ -52,23 +73,99 @@ interface GitRepositoryFile {
|
||||
tracked: boolean;
|
||||
status: FileStatusKind | null;
|
||||
}
|
||||
|
||||
interface GitLfsStatus {
|
||||
available: boolean;
|
||||
bundled: boolean;
|
||||
version: string | null;
|
||||
filters_configured: boolean;
|
||||
hook_installed: boolean;
|
||||
repository_uses_lfs: boolean;
|
||||
patterns: GitLfsPattern[];
|
||||
files: GitLfsFile[];
|
||||
}
|
||||
|
||||
interface GitLfsPattern {
|
||||
pattern: string;
|
||||
source: string;
|
||||
lockable: boolean;
|
||||
tracked: boolean;
|
||||
}
|
||||
|
||||
interface GitLfsFile {
|
||||
name: string;
|
||||
size: number;
|
||||
checkout: boolean;
|
||||
downloaded: boolean;
|
||||
oid_type: string;
|
||||
oid: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
type GitIntegrationProvider = "github" | "gitlab" | "gitlab-self-hosted" | "azure-devops" | "gitea";
|
||||
|
||||
interface IntegrationRepository {
|
||||
id: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
description: string;
|
||||
cloneUrl: string;
|
||||
sshUrl: string;
|
||||
webUrl: string;
|
||||
updatedAt: string;
|
||||
private: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
## 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>`
|
||||
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
|
||||
- `clone_repository(...): Promise<RepositoryBundle>`
|
||||
- `list_integration_repositories(provider: GitIntegrationProvider, baseUrl: string, accountId?: string): Promise<IntegrationRepository[]>`; loads credentials from the operating system keychain and returns every repository accessible through the configured Git hosting account.
|
||||
- `get_status(path: string): Promise<GitStatus>`
|
||||
- `git_lfs_status(path: string): Promise<GitLfsStatus>`
|
||||
- `git_lfs_install(path: string): Promise<GitLfsStatus>`
|
||||
- `git_lfs_track(path: string, pattern: string, lockable?: boolean): Promise<GitLfsStatus>`
|
||||
- `git_lfs_untrack(path: string, pattern: string): Promise<GitLfsStatus>`
|
||||
- `git_lfs_pull(path: string, remote?: string, username?: string, password?: string): Promise<GitLfsStatus>`
|
||||
- `git_lfs_prune(path: string): Promise<GitLfsStatus>`
|
||||
- `pull(...)` automatically runs `git lfs pull` after a successful Git pull when
|
||||
the repository contains LFS attributes or tracked LFS objects.
|
||||
- `list_branches(path: string): Promise<GitBranch[]>`
|
||||
- `list_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>`
|
||||
- `list_worktrees(path: string): Promise<GitWorktree[]>`
|
||||
- `add_worktree(path: string, worktreePath: string, ...): Promise<GitWorktree[]>`
|
||||
- `remove_worktree(path: string, worktreePath: string, force?: boolean): Promise<GitWorktree[]>`
|
||||
- `move_worktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]>`
|
||||
- `lock_worktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]>`
|
||||
- `unlock_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
|
||||
- `prune_worktrees(path: string): Promise<GitWorktree[]>`
|
||||
- `repair_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
|
||||
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||
- `add_to_gitignore(path: string, target: string, kind: "file" | "extension" | "folder"): Promise<GitStatus>`; appends a repository-root `.gitignore` rule and unstages newly-added matching files.
|
||||
- `untrack_paths(path: string, targets: string[]): Promise<GitStatus>`; removes files or folders from the Git index while preserving their working-tree contents.
|
||||
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
|
||||
- `stash_push(path: string, message?: string, includeUntracked?: boolean, paths?: string[]): Promise<GitStatus>`; when `paths` is provided, only matching files are stashed.
|
||||
- `commit(path: string, message: string): Promise<GitStatus>`
|
||||
- `pull(path: string): Promise<GitStatus>`
|
||||
- `push(path: string): Promise<GitStatus>`
|
||||
- `list_commits(path: string, limit?: number): Promise<GitCommit[]>`
|
||||
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
|
||||
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
|
||||
- `push(path: string, forceWithLease?: boolean, remote?: string): Promise<GitStatus>`
|
||||
- `list_commits(path: string, limit?: number, skip?: number): Promise<GitCommit[]>`
|
||||
- `restore_to_commit(path: string, commit: string): Promise<GitStatus>`
|
||||
- `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path)
|
||||
- `merge_branch(path: string, branch: string): 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_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",
|
||||
"version": "0.200.5",
|
||||
"version": "2026.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitty",
|
||||
"version": "0.200.5",
|
||||
"version": "2026.9.1",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
|
||||
+3
-2
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "0.200.5",
|
||||
"version": "2026.9.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"prepare:lfs": "node scripts/prepare-git-lfs-sidecar.mjs",
|
||||
"icons": "node scripts/generate-app-icons.mjs",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:dev": "node scripts/tauri-dev.mjs",
|
||||
"tauri:build": "tauri build",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, chmodSync, statSync } from "node:fs";
|
||||
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(scriptDirectory, "..");
|
||||
const binariesDirectory = join(projectRoot, "src-tauri", "binaries");
|
||||
const extension = process.platform === "win32" ? ".exe" : "";
|
||||
|
||||
function defaultTargetTriple() {
|
||||
const triples = {
|
||||
"win32:x64": "x86_64-pc-windows-msvc",
|
||||
"win32:arm64": "aarch64-pc-windows-msvc",
|
||||
"linux:x64": "x86_64-unknown-linux-gnu",
|
||||
"linux:arm64": "aarch64-unknown-linux-gnu",
|
||||
"darwin:x64": "x86_64-apple-darwin",
|
||||
"darwin:arm64": "aarch64-apple-darwin",
|
||||
};
|
||||
return triples[`${process.platform}:${process.arch}`];
|
||||
}
|
||||
|
||||
function executableCandidates() {
|
||||
const configured = process.env.GIT_LFS_BINARY?.trim();
|
||||
if (configured) return [configured];
|
||||
|
||||
const pathEntries = (process.env.PATH ?? "")
|
||||
.split(delimiter)
|
||||
.filter(Boolean);
|
||||
const candidates = pathEntries.map((entry) => join(entry, `git-lfs${extension}`));
|
||||
if (process.platform === "win32") {
|
||||
for (const entry of pathEntries) {
|
||||
if (basename(entry).toLowerCase() === "cmd") {
|
||||
candidates.push(resolve(entry, "..", "mingw64", "bin", "git-lfs.exe"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
const source = executableCandidates()
|
||||
.filter((candidate) => existsSync(candidate))
|
||||
// Git for Windows exposes a small launcher in cmd/ and the standalone
|
||||
// executable in mingw64/bin/. The standalone file is the portable sidecar.
|
||||
.sort((left, right) => statSync(right).size - statSync(left).size)[0];
|
||||
if (!source) {
|
||||
throw new Error(
|
||||
"Git LFS was not found. Install git-lfs or set GIT_LFS_BINARY before building Gitty.",
|
||||
);
|
||||
}
|
||||
|
||||
const targetTriple = (
|
||||
process.env.GITTY_TARGET_TRIPLE
|
||||
?? process.env.TAURI_ENV_TARGET_TRIPLE
|
||||
?? process.env.TARGET
|
||||
?? defaultTargetTriple()
|
||||
)?.trim();
|
||||
if (!targetTriple) throw new Error("Rust did not report a target triple.");
|
||||
|
||||
mkdirSync(binariesDirectory, { recursive: true });
|
||||
|
||||
const developmentTarget = join(binariesDirectory, `git-lfs${extension}`);
|
||||
const bundleTarget = join(
|
||||
binariesDirectory,
|
||||
`git-lfs-${targetTriple}${extension}`,
|
||||
);
|
||||
for (const target of [developmentTarget, bundleTarget]) {
|
||||
if (resolve(source) !== resolve(target)) copyFileSync(source, target);
|
||||
if (process.platform !== "win32") chmodSync(target, 0o755);
|
||||
}
|
||||
|
||||
console.log(`Prepared bundled git-lfs from ${source} for ${targetTriple}.`);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const debugEnvironment = { ...process.env };
|
||||
const clearedVariables = [];
|
||||
const blockedProxy = /^https?:\/\/127\.0\.0\.1:9\/?$/i;
|
||||
const proxyVariables = new Set([
|
||||
"all_proxy",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"git_http_proxy",
|
||||
"git_https_proxy",
|
||||
]);
|
||||
|
||||
for (const [name, value] of Object.entries(debugEnvironment)) {
|
||||
if (proxyVariables.has(name.toLowerCase()) && blockedProxy.test(value ?? "")) {
|
||||
delete debugEnvironment[name];
|
||||
clearedVariables.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(debugEnvironment)) {
|
||||
if (
|
||||
name.toLowerCase() === "git_ssh_command"
|
||||
&& /^cmd(?:\.exe)?\s+\/c\s+exit\s+1$/i.test((value ?? "").trim())
|
||||
) {
|
||||
delete debugEnvironment[name];
|
||||
clearedVariables.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv.includes("--check-environment")) {
|
||||
process.stdout.write(
|
||||
clearedVariables.length > 0
|
||||
? `Debug environment ready; cleared: ${clearedVariables.sort().join(", ")}\n`
|
||||
: "Debug environment ready; no blocked proxy variables found.\n",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (clearedVariables.length > 0) {
|
||||
process.stdout.write(
|
||||
`Starting Gitty without the blocked debug proxy (${clearedVariables.sort().join(", ")}).\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const child = spawn(isWindows ? "tauri.cmd" : "tauri", ["dev"], {
|
||||
cwd: process.cwd(),
|
||||
env: debugEnvironment,
|
||||
stdio: "inherit",
|
||||
shell: isWindows,
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
process.stderr.write(`Could not start Tauri development mode: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
process.exitCode = code ?? 1;
|
||||
});
|
||||
Generated
+274
-3335
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -20,13 +20,23 @@ tauri-plugin-dialog = "=2.7.0"
|
||||
tauri-plugin-aptabase = "1.0"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
||||
commit_ai = { path = "crates/commit_ai" }
|
||||
tokio = "1.52.3"
|
||||
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "time"] }
|
||||
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"] }
|
||||
shlex = "2"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
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"
|
||||
|
||||
# ── Build profiles ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014- GitHub, Inc. and Git LFS contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Portions of the subprocess and tools directories are copied from Go and are
|
||||
under the following license:
|
||||
|
||||
Copyright (c) 2009,2010 The Go Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
* Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of Google Inc. nor the names of its contributors may be
|
||||
used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -9,6 +9,7 @@
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-destroy",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:allow-open",
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"identifier": "desktop-capability",
|
||||
"platforms": [
|
||||
"macOS",
|
||||
"windows",
|
||||
"linux"
|
||||
"windows"
|
||||
],
|
||||
"windows": [
|
||||
"main"
|
||||
|
||||
@@ -5,8 +5,6 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
mistralrs = "0.8"
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
||||
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
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)]
|
||||
struct AnthropicMessage {
|
||||
role: &'static str,
|
||||
@@ -220,3 +379,89 @@ pub async fn generate_anthropic(
|
||||
}
|
||||
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,319 +1,12 @@
|
||||
mod cloud;
|
||||
|
||||
pub use cloud::{generate_anthropic, generate_custom, generate_openai};
|
||||
|
||||
use std::{
|
||||
collections::hash_map::DefaultHasher,
|
||||
hash::{Hash, Hasher},
|
||||
sync::Arc,
|
||||
pub use cloud::{
|
||||
generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom,
|
||||
review_openai, split_anthropic, split_custom, split_openai,
|
||||
};
|
||||
|
||||
use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// One selectable local (on-device) model. Larger models produce better commit messages
|
||||
/// but take longer to download (first run only, then cached) and run slower on CPU.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct LocalModelOption {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub approx_size_mb: u32,
|
||||
repo: &'static str,
|
||||
file: &'static str,
|
||||
tokenizer_repo: &'static str,
|
||||
}
|
||||
|
||||
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-0.5b";
|
||||
|
||||
pub const LOCAL_MODELS: &[LocalModelOption] = &[
|
||||
LocalModelOption {
|
||||
id: "qwen2.5-0.5b",
|
||||
label: "Qwen2.5 0.5B Instruct — fast, lower quality",
|
||||
approx_size_mb: 490,
|
||||
repo: "Qwen/Qwen2.5-0.5B-Instruct-GGUF",
|
||||
file: "qwen2.5-0.5b-instruct-q4_k_m.gguf",
|
||||
tokenizer_repo: "Qwen/Qwen2.5-0.5B-Instruct",
|
||||
},
|
||||
LocalModelOption {
|
||||
id: "qwen2.5-1.5b",
|
||||
label: "Qwen2.5 1.5B Instruct — recommended",
|
||||
approx_size_mb: 1050,
|
||||
repo: "Qwen/Qwen2.5-1.5B-Instruct-GGUF",
|
||||
file: "qwen2.5-1.5b-instruct-q4_k_m.gguf",
|
||||
tokenizer_repo: "Qwen/Qwen2.5-1.5B-Instruct",
|
||||
},
|
||||
LocalModelOption {
|
||||
id: "qwen2.5-3b",
|
||||
label: "Qwen2.5 3B Instruct — best quality, slower",
|
||||
approx_size_mb: 2100,
|
||||
repo: "Qwen/Qwen2.5-3B-Instruct-GGUF",
|
||||
file: "qwen2.5-3b-instruct-q4_k_m.gguf",
|
||||
tokenizer_repo: "Qwen/Qwen2.5-3B-Instruct",
|
||||
},
|
||||
];
|
||||
|
||||
fn find_local_model(model_id: &str) -> Option<&'static LocalModelOption> {
|
||||
LOCAL_MODELS.iter().find(|option| option.id == model_id)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LocalGenerationProfile {
|
||||
Fast,
|
||||
Balanced,
|
||||
Detailed,
|
||||
}
|
||||
|
||||
impl Default for LocalGenerationProfile {
|
||||
fn default() -> Self {
|
||||
Self::Fast
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalGenerationProfile {
|
||||
pub fn from_id(value: Option<&str>) -> Self {
|
||||
match value
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"balanced" => Self::Balanced,
|
||||
"detailed" => Self::Detailed,
|
||||
_ => Self::Fast,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diff_unified_context(self) -> &'static str {
|
||||
match self {
|
||||
Self::Fast => "--unified=1",
|
||||
Self::Balanced => "--unified=2",
|
||||
Self::Detailed => "--unified=3",
|
||||
}
|
||||
}
|
||||
|
||||
fn max_diff_chars(self) -> usize {
|
||||
match self {
|
||||
Self::Fast => 8_000,
|
||||
Self::Balanced => 12_000,
|
||||
Self::Detailed => 24_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn max_output_tokens(self) -> usize {
|
||||
match self {
|
||||
Self::Fast => 160,
|
||||
Self::Balanced => 360,
|
||||
Self::Detailed => 750,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CommitAiPhase {
|
||||
/// Nothing has been requested yet.
|
||||
Idle,
|
||||
/// Downloading (first run only, then cached by hf-hub) and/or loading into memory.
|
||||
Loading,
|
||||
Ready,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct CommitAiStatus {
|
||||
pub phase: CommitAiPhase,
|
||||
pub model_id: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
phase: CommitAiPhase,
|
||||
model_id: Option<String>,
|
||||
error: Option<String>,
|
||||
model: Option<Arc<Model>>,
|
||||
cache: Option<GenerationCache>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct GenerationCacheKey {
|
||||
model_id: String,
|
||||
profile: LocalGenerationProfile,
|
||||
input_hash: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct GenerationCache {
|
||||
key: GenerationCacheKey,
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls
|
||||
/// (see [`cloud`]) and don't need this — there's nothing to download or keep loaded.
|
||||
#[derive(Clone)]
|
||||
pub struct CommitAiEngine {
|
||||
inner: Arc<RwLock<Inner>>,
|
||||
}
|
||||
|
||||
impl Default for CommitAiEngine {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(Inner {
|
||||
phase: CommitAiPhase::Idle,
|
||||
model_id: None,
|
||||
error: None,
|
||||
model: None,
|
||||
cache: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommitAiEngine {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub async fn status(&self) -> CommitAiStatus {
|
||||
let guard = self.inner.read().await;
|
||||
CommitAiStatus {
|
||||
phase: guard.phase,
|
||||
model_id: guard.model_id.clone(),
|
||||
error: guard.error.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads (first run only; hf-hub caches the files afterwards) and loads the given
|
||||
/// local model. Safe to call repeatedly — a call for the model that's already
|
||||
/// ready/loading is a no-op; a call for a *different* model switches to it (the
|
||||
/// previous one is dropped once no generation is still using it).
|
||||
pub async fn ensure_loaded(&self, model_id: &str) {
|
||||
{
|
||||
let guard = self.inner.read().await;
|
||||
let same_model = guard.model_id.as_deref() == Some(model_id);
|
||||
if same_model && matches!(guard.phase, CommitAiPhase::Ready | CommitAiPhase::Loading) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(option) = find_local_model(model_id) else {
|
||||
let mut guard = self.inner.write().await;
|
||||
guard.phase = CommitAiPhase::Error;
|
||||
guard.model_id = Some(model_id.to_string());
|
||||
guard.error = Some(format!("Unknown local model: {model_id}"));
|
||||
guard.cache = None;
|
||||
return;
|
||||
};
|
||||
|
||||
{
|
||||
let mut guard = self.inner.write().await;
|
||||
guard.phase = CommitAiPhase::Loading;
|
||||
guard.model_id = Some(model_id.to_string());
|
||||
guard.error = None;
|
||||
guard.model = None;
|
||||
guard.cache = None;
|
||||
}
|
||||
|
||||
let result = GgufModelBuilder::new(option.repo, vec![option.file])
|
||||
.with_tok_model_id(option.tokenizer_repo)
|
||||
.with_logging()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let mut guard = self.inner.write().await;
|
||||
// If the user switched to yet another model while this one was loading, drop this
|
||||
// (now stale) result instead of overwriting the newer request's state.
|
||||
if guard.model_id.as_deref() != Some(model_id) {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(model) => {
|
||||
guard.model = Some(Arc::new(model));
|
||||
guard.phase = CommitAiPhase::Ready;
|
||||
guard.error = None;
|
||||
guard.cache = None;
|
||||
}
|
||||
Err(err) => {
|
||||
guard.phase = CommitAiPhase::Error;
|
||||
guard.error = Some(err.to_string());
|
||||
guard.cache = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_commit_message(
|
||||
&self,
|
||||
diff: &str,
|
||||
notes: Option<&str>,
|
||||
profile: LocalGenerationProfile,
|
||||
) -> Result<String, String> {
|
||||
let (model, cache_key) = {
|
||||
let guard = self.inner.read().await;
|
||||
match (guard.phase, &guard.model) {
|
||||
(CommitAiPhase::Ready, Some(model)) => {
|
||||
let cache_key = GenerationCacheKey {
|
||||
model_id: guard.model_id.clone().unwrap_or_default(),
|
||||
profile,
|
||||
input_hash: generation_input_hash(diff, notes),
|
||||
};
|
||||
if let Some(cache) = &guard.cache {
|
||||
if cache.key == cache_key {
|
||||
return Ok(cache.message.clone());
|
||||
}
|
||||
}
|
||||
(model.clone(), cache_key)
|
||||
}
|
||||
_ => return Err("The local AI model is not ready yet.".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
let (system, user) = build_local_messages(diff, notes, profile)?;
|
||||
let request = RequestBuilder::new()
|
||||
.set_sampler_max_len(profile.max_output_tokens())
|
||||
.add_message(TextMessageRole::System, system)
|
||||
.add_message(TextMessageRole::User, user);
|
||||
|
||||
let response = model
|
||||
.send_chat_request(request)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let content = response
|
||||
.choices
|
||||
.first()
|
||||
.and_then(|choice| choice.message.content.clone())
|
||||
.ok_or_else(|| "The model did not return a response.".to_string())?;
|
||||
|
||||
let message = sanitize_message(&content);
|
||||
if message.is_empty() {
|
||||
return Err("The model did not return a response.".to_string());
|
||||
}
|
||||
if looks_like_diff_echo(&message) {
|
||||
return Err(
|
||||
"The local model returned the diff instead of a commit message. Try a larger local model (1.5B or 3B) or a cloud provider.".to_string(),
|
||||
);
|
||||
}
|
||||
{
|
||||
let mut guard = self.inner.write().await;
|
||||
guard.cache = Some(GenerationCache {
|
||||
key: cache_key,
|
||||
message: message.clone(),
|
||||
});
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
}
|
||||
|
||||
fn generation_input_hash(diff: &str, notes: Option<&str>) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
diff.hash(&mut hasher);
|
||||
notes.unwrap_or("").hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Models occasionally ignore the "no code fences" instruction (small local models
|
||||
/// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go
|
||||
/// straight into the commit-message box.
|
||||
/// Strip a wrapping code fence and wrapping quotes so the result can go straight into
|
||||
/// the commit-message box.
|
||||
pub(crate) fn sanitize_message(raw: &str) -> String {
|
||||
let mut text = raw.trim().to_string();
|
||||
if text.starts_with("```") {
|
||||
@@ -330,9 +23,9 @@ pub(crate) fn sanitize_message(raw: &str) -> String {
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
/// Weak models (small local ones especially) sometimes just echo the prompt's diff
|
||||
/// sections back instead of writing a commit message. Catch that so the UI can show a
|
||||
/// clear error instead of dumping raw diff text into the commit-message box.
|
||||
/// Some models echo the prompt's diff sections instead of writing a commit message.
|
||||
/// Catch that so the UI can show a clear error instead of dumping raw diff text into
|
||||
/// the commit-message box.
|
||||
pub(crate) fn looks_like_diff_echo(message: &str) -> bool {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
lower.contains("diff --git")
|
||||
@@ -355,55 +48,12 @@ fn truncate_at_char_boundary(input: &str, max_chars: usize) -> String {
|
||||
format!("{}\n\n[... diff truncated ...]", &input[..cut])
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_messages(
|
||||
diff: &str,
|
||||
notes: Option<&str>,
|
||||
profile: LocalGenerationProfile,
|
||||
) -> Result<(String, String), String> {
|
||||
if diff.trim().is_empty() {
|
||||
return Err("No staged changes available for a commit message.".to_string());
|
||||
}
|
||||
|
||||
let diff = truncate_at_char_boundary(diff, profile.max_diff_chars());
|
||||
// Appended to every profile below: small local models occasionally just echo the input
|
||||
// (the "Staged files:" / "Diff stat:" / "Detailed diff:" sections built in git.rs's
|
||||
// `staged_diff_local`) instead of writing a new commit message. Naming those exact
|
||||
// section headers here makes the failure mode explicit enough for weak models to avoid.
|
||||
const ANTI_ECHO: &str = " Never repeat, quote, or paraphrase the diff or its headers — \
|
||||
do not include 'diff --git', '@@', 'Staged files:', 'Diff stat:', or 'Detailed diff:' \
|
||||
anywhere in your answer.";
|
||||
let system = match profile {
|
||||
LocalGenerationProfile::Fast => {
|
||||
format!(
|
||||
"You generate Git commit messages. Respond only with one Conventional Commits subject line: <type>(<scope>): <subject>. Max 72 characters. No body, bullets, preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
|
||||
)
|
||||
}
|
||||
LocalGenerationProfile::Balanced => {
|
||||
format!(
|
||||
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then at most two short bullet points. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
|
||||
)
|
||||
}
|
||||
LocalGenerationProfile::Detailed => {
|
||||
format!(
|
||||
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then a concise body and up to four short bullet points grouped by affected area/file. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let mut user = String::new();
|
||||
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
|
||||
user.push_str(&format!("Developer notes:\n{n}\n\n"));
|
||||
}
|
||||
user.push_str(&format!("Staged changes:\n{diff}"));
|
||||
Ok((system, user))
|
||||
}
|
||||
|
||||
pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> {
|
||||
if diff.trim().is_empty() {
|
||||
return Err("No staged changes available for a commit message.".to_string());
|
||||
}
|
||||
|
||||
// Rough token estimate — small models often have an 8-32k context window.
|
||||
// Rough token estimate to keep requests within common context windows.
|
||||
const MAX_CHARS: usize = 24_000;
|
||||
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
|
||||
|
||||
@@ -443,3 +93,27 @@ instead of a direct string comparison.\n\n\
|
||||
user.push_str(&format!("Staged diff:\n{diff}"));
|
||||
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}"))?;
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+5326
-402
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+348
-25
@@ -1,25 +1,256 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod badge;
|
||||
mod external_tools;
|
||||
mod git;
|
||||
mod integrations;
|
||||
mod telemetry;
|
||||
|
||||
use badge::set_sync_badge;
|
||||
use git::{
|
||||
SearchCancellationState, 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_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 external_tools::{
|
||||
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
|
||||
};
|
||||
use tauri::{Manager, AppHandle};
|
||||
use git::{
|
||||
SearchCancellationState, add_remote, add_to_gitignore, 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_review, commit_ai_split, 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_bisect_state, get_commit_note,
|
||||
get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune,
|
||||
git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, 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, mark_bisect, 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, reset_bisect, 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_bisect, start_interactive_rebase,
|
||||
stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree,
|
||||
unstage_files, untrack_paths, update_remote,
|
||||
};
|
||||
use integrations::{
|
||||
add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
|
||||
run_integration_review_action,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{Emitter, Manager};
|
||||
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
||||
|
||||
struct StartupRepository(Mutex<Option<String>>);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StartupCloneRequest {
|
||||
remote_url: String,
|
||||
parent_path: String,
|
||||
directory_name: String,
|
||||
}
|
||||
|
||||
struct StartupClone(Mutex<Option<StartupCloneRequest>>);
|
||||
|
||||
fn resolve_startup_path(path: &str, cwd: &Path) -> Option<PathBuf> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = PathBuf::from(path);
|
||||
Some(if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
cwd.join(path)
|
||||
})
|
||||
}
|
||||
|
||||
fn clone_request_from_args(
|
||||
args: impl IntoIterator<Item = String>,
|
||||
cwd: &Path,
|
||||
) -> Option<StartupCloneRequest> {
|
||||
let mut args = args.into_iter();
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
let remote_url = if arg == "clone" || arg == "--clone" {
|
||||
args.next()
|
||||
} else {
|
||||
arg.strip_prefix("--clone=").map(ToString::to_string)
|
||||
};
|
||||
let Some(remote_url) = remote_url.filter(|value| !value.trim().is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let target = resolve_startup_path(&args.next()?, cwd)?;
|
||||
let directory_name = target.file_name()?.to_string_lossy().trim().to_string();
|
||||
let parent_path = target.parent()?.to_string_lossy().into_owned();
|
||||
if directory_name.is_empty() || parent_path.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(StartupCloneRequest {
|
||||
remote_url,
|
||||
parent_path,
|
||||
directory_name,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn repository_path_from_args(args: impl IntoIterator<Item = String>, cwd: &Path) -> Option<String> {
|
||||
let mut args = args.into_iter();
|
||||
let mut repository = None;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "clone" || arg == "--clone" || arg.starts_with("--clone=") {
|
||||
return None;
|
||||
}
|
||||
if arg == "--repo" {
|
||||
repository = args.next();
|
||||
break;
|
||||
}
|
||||
if let Some(path) = arg.strip_prefix("--repo=") {
|
||||
repository = Some(path.to_string());
|
||||
break;
|
||||
}
|
||||
if !arg.starts_with('-') {
|
||||
repository = Some(arg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
repository
|
||||
.and_then(|path| resolve_startup_path(&path, cwd))
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_repository_tests {
|
||||
use super::{clone_request_from_args, repository_path_from_args};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn accepts_direct_relative_repository_path() {
|
||||
let path =
|
||||
repository_path_from_args(["projects/repo".to_string()], Path::new("/home/user"));
|
||||
assert_eq!(
|
||||
path.map(|value| value.replace('\\', "/")),
|
||||
Some("/home/user/projects/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_repo_option() {
|
||||
let path = repository_path_from_args(
|
||||
["--repo".to_string(), "/projects/repo".to_string()],
|
||||
Path::new("/home/user"),
|
||||
);
|
||||
assert_eq!(path.as_deref(), Some("/projects/repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_repo_equals_option() {
|
||||
let path = repository_path_from_args(
|
||||
["--repo=projects/repo".to_string()],
|
||||
Path::new("/home/user"),
|
||||
);
|
||||
assert_eq!(
|
||||
path.map(|value| value.replace('\\', "/")),
|
||||
Some("/home/user/projects/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_clone_command_with_exact_relative_target() {
|
||||
let request = clone_request_from_args(
|
||||
[
|
||||
"clone".to_string(),
|
||||
"https://example.com/team/project.git".to_string(),
|
||||
"clones/local-copy".to_string(),
|
||||
],
|
||||
Path::new("/home/user"),
|
||||
)
|
||||
.expect("clone request should be parsed");
|
||||
|
||||
assert_eq!(request.remote_url, "https://example.com/team/project.git");
|
||||
assert_eq!(request.directory_name, "local-copy");
|
||||
assert_eq!(request.parent_path.replace('\\', "/"), "/home/user/clones");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_clone_option_and_clone_equals_option() {
|
||||
for args in [
|
||||
vec![
|
||||
"--clone".to_string(),
|
||||
"git@example.com:team/project.git".to_string(),
|
||||
"/projects/project".to_string(),
|
||||
],
|
||||
vec![
|
||||
"--clone=git@example.com:team/project.git".to_string(),
|
||||
"/projects/project".to_string(),
|
||||
],
|
||||
] {
|
||||
let request = clone_request_from_args(args, Path::new("/home/user"))
|
||||
.expect("clone option should be parsed");
|
||||
assert_eq!(request.directory_name, "project");
|
||||
assert_eq!(request.parent_path.replace('\\', "/"), "/projects");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_parser_does_not_treat_clone_values_as_repository_paths() {
|
||||
let path = repository_path_from_args(
|
||||
[
|
||||
"--clone".to_string(),
|
||||
"https://example.com/team/project.git".to_string(),
|
||||
"clones/project".to_string(),
|
||||
],
|
||||
Path::new("/home/user"),
|
||||
);
|
||||
assert!(path.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn take_startup_repository(state: tauri::State<'_, StartupRepository>) -> Option<String> {
|
||||
state.0.lock().ok()?.take()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn take_startup_clone(state: tauri::State<'_, StartupClone>) -> Option<StartupCloneRequest> {
|
||||
state.0.lock().ok()?.take()
|
||||
}
|
||||
|
||||
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]
|
||||
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
||||
@@ -31,6 +262,7 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
||||
}
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.maximize();
|
||||
window
|
||||
.show()
|
||||
.map_err(|error| format!("failed to show main window: {error}"))?;
|
||||
@@ -42,14 +274,41 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
||||
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 startup_args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let startup_cwd = std::env::current_dir().unwrap_or_default();
|
||||
let startup_clone = clone_request_from_args(startup_args.clone(), &startup_cwd);
|
||||
let startup_repository = repository_path_from_args(startup_args, &startup_cwd);
|
||||
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
|
||||
let args: Vec<String> = args.into_iter().skip(1).collect();
|
||||
if let Some(request) = clone_request_from_args(args.clone(), Path::new(&cwd)) {
|
||||
if let Ok(mut pending) = app.state::<StartupClone>().0.lock() {
|
||||
*pending = Some(request);
|
||||
}
|
||||
let _ = app.emit("open-startup-repository", ());
|
||||
} else if let Some(path) = repository_path_from_args(args, Path::new(&cwd)) {
|
||||
if let Ok(mut pending) = app.state::<StartupRepository>().0.lock() {
|
||||
*pending = Some(path);
|
||||
}
|
||||
let _ = app.emit("open-startup-repository", ());
|
||||
}
|
||||
#[cfg(desktop)]
|
||||
let _ = app.get_webview_window("main")
|
||||
let _ = app
|
||||
.get_webview_window("main")
|
||||
.expect("no main window")
|
||||
.set_focus();
|
||||
}))
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(
|
||||
tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
|
||||
.with_options(tauri_plugin_aptabase::InitOptions {
|
||||
@@ -58,21 +317,57 @@ async fn main() {
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.manage(StartupRepository(Mutex::new(startup_repository)))
|
||||
.manage(StartupClone(Mutex::new(startup_clone)))
|
||||
.manage(SearchCancellationState::default())
|
||||
.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![
|
||||
open_repository,
|
||||
init_repository,
|
||||
clone_repository,
|
||||
open_repo_in_explorer,
|
||||
open_repository_file,
|
||||
detect_external_tools,
|
||||
launch_external_tool,
|
||||
launch_external_diff,
|
||||
launch_external_merge,
|
||||
get_status,
|
||||
git_lfs_status,
|
||||
git_lfs_install,
|
||||
git_lfs_track,
|
||||
git_lfs_untrack,
|
||||
git_lfs_pull,
|
||||
git_lfs_prune,
|
||||
list_branches,
|
||||
list_remotes,
|
||||
add_remote,
|
||||
update_remote,
|
||||
remove_remote,
|
||||
set_branch_upstream,
|
||||
delete_remote_branch,
|
||||
delete_remote_branches,
|
||||
list_stashes,
|
||||
checkout_branch,
|
||||
create_branch,
|
||||
rename_branch,
|
||||
rename_remote_branch,
|
||||
delete_branch,
|
||||
list_worktrees,
|
||||
add_worktree,
|
||||
remove_worktree,
|
||||
move_worktree,
|
||||
lock_worktree,
|
||||
unlock_worktree,
|
||||
prune_worktrees,
|
||||
repair_worktree,
|
||||
list_tags,
|
||||
create_tag,
|
||||
delete_tag,
|
||||
@@ -82,6 +377,8 @@ async fn main() {
|
||||
cherry_pick_abort,
|
||||
stage_files,
|
||||
unstage_files,
|
||||
add_to_gitignore,
|
||||
untrack_paths,
|
||||
stash_push,
|
||||
stash_apply,
|
||||
stash_pop,
|
||||
@@ -93,20 +390,35 @@ async fn main() {
|
||||
amend_commit,
|
||||
undo_last_commit,
|
||||
last_commit_message,
|
||||
commit_ai_status,
|
||||
commit_ai_load,
|
||||
commit_ai_local_models,
|
||||
commit_ai_generate,
|
||||
commit_ai_review,
|
||||
commit_ai_split,
|
||||
pull,
|
||||
push,
|
||||
fetch,
|
||||
list_commits,
|
||||
get_commit_note,
|
||||
set_commit_note,
|
||||
delete_commit_note,
|
||||
fetch_commit_notes,
|
||||
push_commit_notes,
|
||||
restore_to_commit,
|
||||
restore_file_from_commit,
|
||||
merge_branch,
|
||||
merge_continue,
|
||||
merge_abort,
|
||||
revert_commit,
|
||||
rebase_branch,
|
||||
rebase_continue,
|
||||
rebase_abort,
|
||||
list_interactive_rebase_commits,
|
||||
start_interactive_rebase,
|
||||
list_reflog,
|
||||
restore_reflog_entry,
|
||||
get_bisect_state,
|
||||
start_bisect,
|
||||
mark_bisect,
|
||||
reset_bisect,
|
||||
list_repository_files,
|
||||
open_repository_bundle,
|
||||
list_file_history,
|
||||
@@ -125,8 +437,19 @@ async fn main() {
|
||||
cred_load,
|
||||
cred_save,
|
||||
cred_delete,
|
||||
list_integration_repositories,
|
||||
list_integration_review_requests,
|
||||
get_integration_review_details,
|
||||
add_integration_review_comment,
|
||||
run_integration_review_action,
|
||||
open_in_browser,
|
||||
set_sync_badge,
|
||||
close_splashscreen
|
||||
close_splashscreen,
|
||||
set_telemetry_enabled,
|
||||
emit_frontend_log,
|
||||
emit_frontend_span,
|
||||
take_startup_repository,
|
||||
take_startup_clone
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.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,12 +1,12 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gitty",
|
||||
"version": "0.200.5",
|
||||
"version": "2026.9.1",
|
||||
"identifier": "com.gitty",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"beforeBuildCommand": "npm run prepare:lfs && npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
@@ -41,6 +41,12 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"externalBin": [
|
||||
"binaries/git-lfs"
|
||||
],
|
||||
"resources": [
|
||||
"binaries/git-lfs-LICENSE.txt"
|
||||
],
|
||||
"targets": [
|
||||
"nsis"
|
||||
],
|
||||
|
||||
+3146
-832
File diff suppressed because it is too large
Load Diff
+5248
-709
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { Folder, GitBranch, GitPullRequest, Plus, X } from "@lucide/svelte";
|
||||
|
||||
interface RepositoryTabItem {
|
||||
path: string;
|
||||
name: string;
|
||||
branch: string | null;
|
||||
}
|
||||
|
||||
export let activeView: "management" | "review-center" | "repository" = "management";
|
||||
export let repoTabs: RepositoryTabItem[] = [];
|
||||
export let isBusy: boolean = false;
|
||||
export let language: "en" | "de" = "en";
|
||||
export let onOpenManagement: () => void = () => {};
|
||||
export let onOpenReviewCenter: () => 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"}
|
||||
aria-label={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
||||
>
|
||||
<Folder size={15} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="repo-tabs-scroll">
|
||||
<div class="repo-tab-wrap review-center-tab" class:active={activeView === "review-center"} role="presentation">
|
||||
<button class="repo-tab" type="button" onclick={onOpenReviewCenter} disabled={isBusy} title="Review Center" aria-label="Review Center">
|
||||
<GitPullRequest size={13} aria-hidden="true" />
|
||||
<span>PR</span>
|
||||
</button>
|
||||
</div>
|
||||
{#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}
|
||||
>
|
||||
<GitBranch size={13} 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,259 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Box,
|
||||
Bug,
|
||||
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 onBisect: () => 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 = () => {};
|
||||
export let onOpenLfs: () => 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>
|
||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onOpenLfs(); }}><Box size={15} /><span><strong>Git LFS</strong><small>{isGerman ? "Große Dateien und LFS-Installation verwalten" : "Manage large files and LFS installation"}</small></span></button>
|
||||
</div>
|
||||
{/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>
|
||||
<button type="button" role="menuitem" onclick={() => runHistoryAction(onBisect)}>
|
||||
<Bug size={15} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>Git Bisect</strong>
|
||||
<small>{isGerman ? "Fehlerhaften Commit schrittweise finden" : "Find a bad commit step by step"}</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>
|
||||
+68
-196
@@ -1,45 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
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";
|
||||
|
||||
export let branch: string = "";
|
||||
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 onOpenHelp: () => 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 unlisten: (() => void) | undefined;
|
||||
let appVersion = "";
|
||||
|
||||
onMount(async () => {
|
||||
isMaximized = await win.isMaximized();
|
||||
unlisten = await win.onResized(async () => {
|
||||
try {
|
||||
win = getCurrentWindow();
|
||||
isMaximized = await win.isMaximized();
|
||||
});
|
||||
appVersion = await getVersion();
|
||||
unlisten = await win.onResized(async () => {
|
||||
isMaximized = await win?.isMaximized() ?? false;
|
||||
});
|
||||
} catch {
|
||||
win = null;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unlisten?.();
|
||||
});
|
||||
|
||||
function minimizeWindow() {
|
||||
void win?.minimize();
|
||||
}
|
||||
|
||||
function toggleMaximizeWindow() {
|
||||
void win?.toggleMaximize();
|
||||
}
|
||||
|
||||
function closeWindow() {
|
||||
void win?.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="titlebar" data-tauri-drag-region>
|
||||
@@ -49,180 +48,53 @@
|
||||
<img src={iconUrl} alt="" data-tauri-drag-region />
|
||||
</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>
|
||||
|
||||
<!-- Center: repo + branch info -->
|
||||
<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 class="titlebar-drag" data-tauri-drag-region aria-hidden="true"></div>
|
||||
|
||||
<!-- Right: app-global actions + window controls -->
|
||||
<div class="titlebar-globals">
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenHelp}
|
||||
title={language === "de" ? "Hilfe (Ctrl+/)" : "Help (Ctrl+/)"}
|
||||
aria-label={language === "de" ? "Hilfe öffnen" : "Open help"}
|
||||
>
|
||||
<CircleHelp size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenSettings}
|
||||
title={language === "de" ? "Einstellungen" : "Settings"}
|
||||
aria-label={language === "de" ? "Einstellungen" : "Settings"}
|
||||
>
|
||||
<Settings size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right: actions + window controls -->
|
||||
<div class="titlebar-right">
|
||||
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenInExplorer}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Open repository in Explorer"
|
||||
aria-label="Open repository in Explorer"
|
||||
>
|
||||
<FolderOpen size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Explorer</span>
|
||||
</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
|
||||
class="tb-action"
|
||||
onclick={onOpenSettings}
|
||||
title="Settings"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Settings size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="titlebar-divider" aria-hidden="true"></div>
|
||||
|
||||
<div class="titlebar-controls">
|
||||
<button class="tb-btn" onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
||||
<Minus size={12} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="tb-btn"
|
||||
onclick={() => win.toggleMaximize()}
|
||||
title={isMaximized ? "Restore" : "Maximize"}
|
||||
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{#if isMaximized}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="1" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M1 3v7a1 1 0 0 0 1 1h7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="1" width="10" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="tb-btn close" onclick={() => win.close()} title="Close" aria-label="Close">
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="titlebar-controls">
|
||||
<button class="tb-btn" onclick={minimizeWindow} title="Minimize" aria-label="Minimize">
|
||||
<Minus size={12} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="tb-btn"
|
||||
onclick={toggleMaximizeWindow}
|
||||
title={isMaximized ? "Restore" : "Maximize"}
|
||||
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{#if isMaximized}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="1" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M1 3v7a1 1 0 0 0 1 1h7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="1" width="10" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="tb-btn close" onclick={closeWindow} title="Close" aria-label="Close">
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { telemetryLog } from "./telemetry";
|
||||
|
||||
export type AnalyticsEventProperties = Record<string, string | number>;
|
||||
|
||||
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", {
|
||||
name,
|
||||
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,93 @@
|
||||
<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";
|
||||
return "Custom endpoint";
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -1,19 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
|
||||
import { Bot, Check, Eye, EyeOff, Globe, Key, LoaderCircle, X } from "@lucide/svelte";
|
||||
import { credDelete, credLoad, credSave } from "../git";
|
||||
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
|
||||
import type { AiSettings, CommitAiProvider } from "../types";
|
||||
|
||||
interface Props {
|
||||
settings: AiSettings;
|
||||
localModels: LocalModelOption[];
|
||||
onSave: (settings: AiSettings) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { settings, localModels = [], onSave, onClose }: Props = $props();
|
||||
let { settings, onSave, onClose }: Props = $props();
|
||||
|
||||
type CloudProvider = Exclude<CommitAiProvider, "local">;
|
||||
type CloudProvider = CommitAiProvider;
|
||||
|
||||
const CRED_KEYS: Record<CloudProvider, string> = {
|
||||
openai: "ai:openai",
|
||||
@@ -21,9 +20,7 @@
|
||||
custom: "ai:custom",
|
||||
};
|
||||
|
||||
let provider = $state<CommitAiProvider>("local");
|
||||
let localModelId = $state("");
|
||||
let localProfile = $state<CommitAiLocalProfile>("fast");
|
||||
let provider = $state<CommitAiProvider>("openai");
|
||||
let openaiModel = $state("");
|
||||
let anthropicModel = $state("");
|
||||
let customBaseUrl = $state("");
|
||||
@@ -40,8 +37,6 @@
|
||||
|
||||
$effect(() => {
|
||||
provider = settings.provider;
|
||||
localModelId = settings.localModelId;
|
||||
localProfile = settings.localProfile ?? "fast";
|
||||
openaiModel = settings.openaiModel;
|
||||
anthropicModel = settings.anthropicModel;
|
||||
customBaseUrl = settings.customBaseUrl;
|
||||
@@ -85,7 +80,7 @@
|
||||
const key = CRED_KEYS[target];
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
await credSave(key, "api-key", trimmed, null);
|
||||
await credSave(key, "api-key", trimmed);
|
||||
} else {
|
||||
await credDelete(key);
|
||||
}
|
||||
@@ -102,8 +97,6 @@
|
||||
]);
|
||||
onSave({
|
||||
provider,
|
||||
localModelId,
|
||||
localProfile,
|
||||
openaiModel: openaiModel.trim() || "gpt-4o-mini",
|
||||
anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest",
|
||||
customBaseUrl: customBaseUrl.trim(),
|
||||
@@ -116,26 +109,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(mb: number): string {
|
||||
return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`;
|
||||
}
|
||||
|
||||
function recommendedModelForProfile(profile: CommitAiLocalProfile): string {
|
||||
if (profile === "balanced") return "qwen2.5-1.5b";
|
||||
if (profile === "detailed") return "qwen2.5-3b";
|
||||
return "qwen2.5-0.5b";
|
||||
}
|
||||
|
||||
function selectLocalProfile(profile: CommitAiLocalProfile) {
|
||||
const previousRecommended = recommendedModelForProfile(localProfile);
|
||||
localProfile = profile;
|
||||
const nextRecommended = recommendedModelForProfile(profile);
|
||||
if (!localModelId || localModelId === previousRecommended) {
|
||||
localModelId = nextRecommended;
|
||||
}
|
||||
}
|
||||
|
||||
let selectedLocalModel = $derived(localModels.find((option) => option.id === localModelId));
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -155,17 +128,6 @@
|
||||
|
||||
<form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}>
|
||||
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider">
|
||||
<button
|
||||
type="button"
|
||||
class="ai-provider-option ai-provider-option-local"
|
||||
class:active={provider === "local"}
|
||||
disabled
|
||||
title="Local AI is still in development and not yet available"
|
||||
>
|
||||
<Cpu size={16} aria-hidden="true" />
|
||||
Local AI
|
||||
<span class="ai-provider-badge">In development</span>
|
||||
</button>
|
||||
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
|
||||
<Bot size={16} aria-hidden="true" />
|
||||
OpenAI
|
||||
@@ -180,42 +142,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if provider === "local"}
|
||||
<div class="cred-field">
|
||||
<span class="cred-field-label">Local speed</span>
|
||||
<div class="ai-local-profile-options" role="radiogroup" aria-label="Local AI speed">
|
||||
<button type="button" class="ai-local-profile-option" class:active={localProfile === "fast"} onclick={() => selectLocalProfile("fast")}>
|
||||
<Zap size={15} aria-hidden="true" />
|
||||
Fast
|
||||
</button>
|
||||
<button type="button" class="ai-local-profile-option" class:active={localProfile === "balanced"} onclick={() => selectLocalProfile("balanced")}>
|
||||
<Gauge size={15} aria-hidden="true" />
|
||||
Balanced
|
||||
</button>
|
||||
<button type="button" class="ai-local-profile-option" class:active={localProfile === "detailed"} onclick={() => selectLocalProfile("detailed")}>
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
Detailed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="cred-field">
|
||||
<span class="cred-field-label">Model</span>
|
||||
<select bind:value={localModelId}>
|
||||
{#each localModels as option (option.id)}
|
||||
<option value={option.id}>{option.label} — {formatSize(option.approx_size_mb)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="cred-token-hint">
|
||||
<AlertCircle size={13} aria-hidden="true" />
|
||||
<span>
|
||||
Switching downloads the model{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""}
|
||||
in the background — depending on your internet connection this can take several minutes.
|
||||
After that it stays cached locally and loads instantly on the next start.
|
||||
The speed setting only changes Local AI; API providers keep their existing prompt.
|
||||
</span>
|
||||
</div>
|
||||
{:else if provider === "openai"}
|
||||
{#if provider === "openai"}
|
||||
<label class="cred-field">
|
||||
<span class="cred-field-label">Model</span>
|
||||
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
|
||||
|
||||
@@ -30,10 +30,10 @@
|
||||
</div>
|
||||
<div class="analytics-notice-copy">
|
||||
<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>
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
|
||||
<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)}>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
Continue
|
||||
|
||||
@@ -1,68 +1,719 @@
|
||||
<script lang="ts">
|
||||
import { Check, Settings, X } from "@lucide/svelte";
|
||||
import type { AnalyticsSettings } from "../types";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleDashed,
|
||||
CloudCog,
|
||||
Code2,
|
||||
FolderOpen,
|
||||
GitCompare,
|
||||
GitMerge,
|
||||
Languages,
|
||||
KeyRound,
|
||||
Palette,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Terminal,
|
||||
Wrench,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
import {
|
||||
applyExternalToolPreset,
|
||||
defaultExternalToolsSettings,
|
||||
externalToolPresets,
|
||||
isExternalToolPresetAvailable,
|
||||
type ExternalToolKind,
|
||||
type ExternalToolPreset,
|
||||
} from "../externalTools";
|
||||
import { configuredIntegrationCount, defaultGitIntegrationSettings } from "../integrations";
|
||||
import type {
|
||||
AnalyticsSettings,
|
||||
AppAppearance,
|
||||
AppLanguage,
|
||||
AppTheme,
|
||||
CustomThemeColors,
|
||||
DetectedExternalTool,
|
||||
ExternalToolsSettings,
|
||||
GitIntegrationSecretUpdate,
|
||||
GitIntegrationSettings,
|
||||
ToolOpenMode,
|
||||
} from "../types";
|
||||
import IntegrationSettingsPage from "./IntegrationSettingsPage.svelte";
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
|
||||
type SettingsPage = "general" | "integrations" | "tools";
|
||||
|
||||
interface Props {
|
||||
analytics: AnalyticsSettings;
|
||||
onSave: (settings: AnalyticsSettings) => void;
|
||||
theme: AppTheme;
|
||||
appearance: AppAppearance;
|
||||
customTheme: CustomThemeColors;
|
||||
language: AppLanguage;
|
||||
autoRefresh: boolean;
|
||||
externalTools: ExternalToolsSettings;
|
||||
integrations: GitIntegrationSettings;
|
||||
detectedTools: DetectedExternalTool[];
|
||||
detectionPending: boolean;
|
||||
detectionUnavailable: boolean;
|
||||
onRefreshDetectedTools: () => void | Promise<void>;
|
||||
onSave: (settings: AnalyticsSettings, theme: AppTheme, appearance: AppAppearance, customTheme: CustomThemeColors, language: AppLanguage, autoRefresh: boolean, externalTools: ExternalToolsSettings, integrations: GitIntegrationSettings, integrationSecrets: GitIntegrationSecretUpdate[]) => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { analytics, onSave = () => {}, onClose = () => {} }: Props = $props();
|
||||
let {
|
||||
analytics,
|
||||
theme = "system",
|
||||
appearance = "modern",
|
||||
customTheme = { background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" },
|
||||
language = "en",
|
||||
autoRefresh = true,
|
||||
externalTools,
|
||||
integrations,
|
||||
detectedTools = [],
|
||||
detectionPending = false,
|
||||
detectionUnavailable = false,
|
||||
onRefreshDetectedTools = () => {},
|
||||
onSave = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
const toolKinds: ExternalToolKind[] = ["editor", "diff", "merge", "terminal", "fileManager"];
|
||||
|
||||
let activePage = $state<SettingsPage>("integrations");
|
||||
let activeToolKind = $state<ExternalToolKind>("editor");
|
||||
let advancedOpen = $state(false);
|
||||
let analyticsEnabled = $state(true);
|
||||
let selectedTheme = $state<AppTheme>("system");
|
||||
let selectedAppearance = $state<AppAppearance>("modern");
|
||||
let customColors = $state<CustomThemeColors>({ background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" });
|
||||
let selectedLanguage = $state<AppLanguage>("en");
|
||||
let autoRefreshEnabled = $state(true);
|
||||
let tools = $state<ExternalToolsSettings>(defaultExternalToolsSettings());
|
||||
let integrationDraft = $state<GitIntegrationSettings>(defaultGitIntegrationSettings());
|
||||
let integrationSecretUpdates = $state<GitIntegrationSecretUpdate[]>([]);
|
||||
let saving = $state(false);
|
||||
const isGerman = $derived(selectedLanguage === "de");
|
||||
|
||||
$effect(() => {
|
||||
analyticsEnabled = analytics.enabled;
|
||||
selectedTheme = theme;
|
||||
selectedAppearance = appearance;
|
||||
customColors = structuredClone(customTheme);
|
||||
selectedLanguage = language;
|
||||
autoRefreshEnabled = autoRefresh;
|
||||
tools = structuredClone(externalTools);
|
||||
integrationDraft = structuredClone(integrations);
|
||||
});
|
||||
|
||||
function save() {
|
||||
onSave({
|
||||
...analytics,
|
||||
enabled: analyticsEnabled,
|
||||
noticeSeen: true,
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
try {
|
||||
await onSave({
|
||||
...analytics,
|
||||
enabled: analyticsEnabled,
|
||||
noticeSeen: true,
|
||||
}, selectedTheme, selectedAppearance, $state.snapshot(customColors), selectedLanguage, autoRefreshEnabled, $state.snapshot(tools), $state.snapshot(integrationDraft), $state.snapshot(integrationSecretUpdates));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetCustomColors() {
|
||||
customColors = selectedTheme === "dark"
|
||||
? { background: "#222328", surface: "#2b2e34", accent: "#2eb5d1", text: "#f0f1f2" }
|
||||
: { background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" };
|
||||
}
|
||||
|
||||
function cssColorToHex(value: string, fallback: string): string {
|
||||
const color = value.trim();
|
||||
if (/^#[0-9a-f]{6}$/i.test(color)) return color.toLowerCase();
|
||||
if (!color || typeof document === "undefined") return fallback;
|
||||
const probe = document.createElement("span");
|
||||
probe.style.color = color;
|
||||
if (!probe.style.color) return fallback;
|
||||
probe.style.display = "none";
|
||||
document.body.appendChild(probe);
|
||||
const match = getComputedStyle(probe).color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
||||
probe.remove();
|
||||
if (!match) return fallback;
|
||||
return `#${match.slice(1, 4).map((part) => Number(part).toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
function currentThemeColors(): CustomThemeColors {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const fallback = selectedTheme === "dark"
|
||||
? { background: "#222328", surface: "#2b2e34", accent: "#2eb5d1", text: "#f0f1f2" }
|
||||
: { background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" };
|
||||
return {
|
||||
background: cssColorToHex(styles.getPropertyValue("--app-bg"), fallback.background),
|
||||
surface: cssColorToHex(styles.getPropertyValue("--color-surface"), fallback.surface),
|
||||
accent: cssColorToHex(styles.getPropertyValue("--color-accent"), fallback.accent),
|
||||
text: cssColorToHex(styles.getPropertyValue("--color-ink"), fallback.text),
|
||||
};
|
||||
}
|
||||
|
||||
function selectAppearance(next: AppAppearance) {
|
||||
if (next === "custom" && selectedAppearance !== "custom") customColors = currentThemeColors();
|
||||
selectedAppearance = next;
|
||||
}
|
||||
|
||||
function toolLabel(kind: ExternalToolKind): string {
|
||||
const labels = {
|
||||
editor: "Editor",
|
||||
diff: isGerman ? "Diff-Tool" : "Diff tool",
|
||||
merge: isGerman ? "Merge-Tool" : "Merge tool",
|
||||
terminal: "Terminal",
|
||||
fileManager: isGerman ? "Dateimanager" : "File manager",
|
||||
};
|
||||
return labels[kind];
|
||||
}
|
||||
|
||||
function toolDescription(kind: ExternalToolKind): string {
|
||||
const descriptions = isGerman
|
||||
? {
|
||||
editor: "Öffnet Repositories und einzelne Dateien zum Bearbeiten.",
|
||||
diff: "Vergleicht eine Arbeitsdatei mit ihrer Version aus HEAD.",
|
||||
merge: "Übergibt Base, Current, Incoming und Ergebnis an einen 3-Wege-Merger.",
|
||||
terminal: "Startet eine Shell direkt im Repository-Verzeichnis.",
|
||||
fileManager: "Öffnet das Repository im bevorzugten Dateimanager.",
|
||||
}
|
||||
: {
|
||||
editor: "Opens repositories and individual files for editing.",
|
||||
diff: "Compares a working file with its version from HEAD.",
|
||||
merge: "Passes base, current, incoming, and result to a three-way merger.",
|
||||
terminal: "Starts a shell directly in the repository directory.",
|
||||
fileManager: "Opens the repository in your preferred file manager.",
|
||||
};
|
||||
return descriptions[kind];
|
||||
}
|
||||
|
||||
function toolUsage(kind: ExternalToolKind): string {
|
||||
const usage = isGerman
|
||||
? {
|
||||
editor: "Oben in der Repository-Leiste oder über das Code-Symbol im Datei-Explorer.",
|
||||
diff: "Datei im Explorer markieren und das Vergleichs-Symbol anklicken – alternativ Rechtsklick auf die Datei.",
|
||||
merge: "Bei einem Konflikt „Konflikte lösen“ öffnen und anschließend dieses Merge-Tool starten.",
|
||||
terminal: "Oben in der Repository-Leiste über den Terminal-Button.",
|
||||
fileManager: "Oben in der Repository-Leiste über den Ordner-Button.",
|
||||
}
|
||||
: {
|
||||
editor: "Use the repository toolbar or the code button in the file explorer.",
|
||||
diff: "Select a file in Explorer and click the compare button, or right-click the file.",
|
||||
merge: "Open Resolve conflicts and start this merge tool from the conflict view.",
|
||||
terminal: "Use the terminal button in the repository toolbar.",
|
||||
fileManager: "Use the folder button in the repository toolbar.",
|
||||
};
|
||||
return usage[kind];
|
||||
}
|
||||
|
||||
function presetAvailable(kind: ExternalToolKind, preset: ExternalToolPreset): boolean {
|
||||
return isExternalToolPresetAvailable(kind, preset, detectedTools);
|
||||
}
|
||||
|
||||
function availablePresets(kind: ExternalToolKind): ExternalToolPreset[] {
|
||||
return externalToolPresets[kind].filter((preset) => presetAvailable(kind, preset));
|
||||
}
|
||||
|
||||
function otherPresets(kind: ExternalToolKind): ExternalToolPreset[] {
|
||||
return externalToolPresets[kind].filter((preset) => !presetAvailable(kind, preset));
|
||||
}
|
||||
|
||||
function selectedPreset(kind: ExternalToolKind): ExternalToolPreset | undefined {
|
||||
return externalToolPresets[kind].find((preset) => preset.id === tools[kind].preset);
|
||||
}
|
||||
|
||||
function selectedToolName(kind: ExternalToolKind): string {
|
||||
return tools[kind].preset === "custom"
|
||||
? tools[kind].program.split(/[\\/]/).pop() || (isGerman ? "Eigenes Programm" : "Custom application")
|
||||
: selectedPreset(kind)?.label ?? tools[kind].program;
|
||||
}
|
||||
|
||||
function openMode(kind: "diff" | "merge"): ToolOpenMode {
|
||||
return kind === "diff" ? tools.diffOpenMode : tools.mergeOpenMode;
|
||||
}
|
||||
|
||||
function setOpenMode(kind: "diff" | "merge", mode: ToolOpenMode) {
|
||||
if (kind === "diff") tools.diffOpenMode = mode;
|
||||
else tools.mergeOpenMode = mode;
|
||||
}
|
||||
|
||||
function selectionAvailable(kind: ExternalToolKind): boolean {
|
||||
if (tools[kind].preset === "custom") return tools[kind].program.trim().length > 0;
|
||||
const preset = selectedPreset(kind);
|
||||
return preset ? presetAvailable(kind, preset) : false;
|
||||
}
|
||||
|
||||
function selectionStatus(kind: ExternalToolKind): string {
|
||||
if (tools[kind].preset === "custom") {
|
||||
return tools[kind].program.trim()
|
||||
? (isGerman ? "Manuell konfiguriert" : "Manually configured")
|
||||
: (isGerman ? "Programmpfad fehlt" : "Application path missing");
|
||||
}
|
||||
return selectionAvailable(kind)
|
||||
? (isGerman ? "Installiert und verfügbar" : "Installed and available")
|
||||
: (isGerman ? "Nicht automatisch erkannt" : "Not automatically detected");
|
||||
}
|
||||
|
||||
function changePreset(kind: ExternalToolKind, id: string) {
|
||||
if (id === "custom") {
|
||||
tools[kind] = { ...tools[kind], preset: "custom" };
|
||||
advancedOpen = true;
|
||||
return;
|
||||
}
|
||||
tools[kind] = applyExternalToolPreset(kind, id, detectedTools);
|
||||
}
|
||||
|
||||
function selectToolKind(kind: ExternalToolKind) {
|
||||
activeToolKind = kind;
|
||||
advancedOpen = tools[kind].preset === "custom";
|
||||
}
|
||||
|
||||
function updateProgram(kind: ExternalToolKind, program: string) {
|
||||
tools[kind] = { ...tools[kind], preset: "custom", program };
|
||||
}
|
||||
|
||||
function updateArgs(kind: ExternalToolKind, value: string) {
|
||||
tools[kind] = {
|
||||
...tools[kind],
|
||||
preset: "custom",
|
||||
args: value.split("\n").map((arg) => arg.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
async function browseProgram(kind: ExternalToolKind) {
|
||||
const selected = await open({
|
||||
title: isGerman ? `${toolLabel(kind)} auswählen` : `Choose ${toolLabel(kind)}`,
|
||||
multiple: false,
|
||||
directory: false,
|
||||
});
|
||||
if (typeof selected === "string") {
|
||||
updateProgram(kind, selected);
|
||||
advancedOpen = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Gitty</span>
|
||||
<h2 class="dialog-title">Settings</h2>
|
||||
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
|
||||
<header class="app-settings-head">
|
||||
<div class="app-settings-title">
|
||||
<span class="app-settings-mark"><Settings2 size={18} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<h2>{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||
<p>{isGerman ? "Gitty an deinen Workflow anpassen" : "Make Gitty fit your workflow"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Einstellungen schließen" : "Close settings"}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">Analytics</span>
|
||||
<h3>Anonymous usage analytics</h3>
|
||||
<form class="app-settings-shell" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
||||
<div class="app-settings-body">
|
||||
<nav class="settings-nav" aria-label={isGerman ? "Einstellungsbereiche" : "Settings sections"}>
|
||||
<button type="button" class:active={activePage === "general"} onclick={() => { activePage = "general"; }}>
|
||||
<SlidersHorizontal size={16} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{isGerman ? "Allgemein" : "General"}</strong>
|
||||
<small>{isGerman ? "Darstellung & Verhalten" : "Appearance & behavior"}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class:active={activePage === "tools"} onclick={() => { activePage = "tools"; }}>
|
||||
<Wrench size={16} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{isGerman ? "Externe Tools" : "External tools"}</strong>
|
||||
<small>{isGerman ? "Editor, Diff & Terminal" : "Editor, diff & terminal"}</small>
|
||||
</span>
|
||||
{#if !detectionUnavailable}<em>{detectedTools.length}</em>{/if}
|
||||
</button>
|
||||
<button type="button" class:active={activePage === "integrations"} onclick={() => { activePage = "integrations"; }}>
|
||||
<CloudCog size={16} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{isGerman ? "Integrationen" : "Integrations"}</strong>
|
||||
<small>GitHub, GitLab, Azure DevOps & Gitea</small>
|
||||
</span>
|
||||
<em>{configuredIntegrationCount(integrationDraft)}</em>
|
||||
</button>
|
||||
|
||||
<div class="settings-nav-note">
|
||||
{#if activePage === "integrations"}<KeyRound size={15} aria-hidden="true" />{:else}<ShieldCheck size={15} aria-hidden="true" />{/if}
|
||||
<p>
|
||||
{activePage === "integrations"
|
||||
? (isGerman ? "Tokens werden sicher im Schlüsselbund des Betriebssystems gespeichert." : "Tokens are stored securely in the operating system keychain.")
|
||||
: (isGerman ? "Tool-Argumente werden direkt und ohne zusätzliche Shell übergeben." : "Tool arguments are passed directly without an extra shell.")}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
</nav>
|
||||
|
||||
<label class="settings-toggle-row">
|
||||
<input type="checkbox" bind:checked={analyticsEnabled} />
|
||||
<span>
|
||||
<strong>Allow anonymous Aptabase events</strong>
|
||||
<small>No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent.</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
<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="new-branch-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose}>Cancel</button>
|
||||
<button class="btn-primary" type="submit">
|
||||
<Check size={16} aria-hidden="true" />
|
||||
Save
|
||||
</button>
|
||||
<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><SlidersHorizontal size={16} /><div><h4>{isGerman ? "Darstellungsstil" : "Design style"}</h4><p>{isGerman ? "Aktuell, klassisch oder selbst gestaltet." : "Current, classic, or designed by you."}</p></div></header>
|
||||
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Darstellungsstil" : "Design style"}>
|
||||
<label class:active={selectedAppearance === "modern"}><input type="radio" name="appearance" value="modern" checked={selectedAppearance === "modern"} onchange={() => selectAppearance("modern")} /><span>{isGerman ? "Aktuell" : "Modern"}</span></label>
|
||||
<label class:active={selectedAppearance === "classic"}><input type="radio" name="appearance" value="classic" checked={selectedAppearance === "classic"} onchange={() => selectAppearance("classic")} /><span>{isGerman ? "Klassisch" : "Classic"}</span></label>
|
||||
<label class:active={selectedAppearance === "custom"}><input type="radio" name="appearance" value="custom" checked={selectedAppearance === "custom"} onchange={() => selectAppearance("custom")} /><span>{isGerman ? "Eigene" : "Custom"}</span></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if selectedAppearance === "custom"}
|
||||
<section class="general-setting-panel general-setting-wide custom-theme-panel">
|
||||
<header>
|
||||
<Palette size={16} />
|
||||
<div><h4>{isGerman ? "Theme-Generator" : "Theme generator"}</h4><p>{isGerman ? "Erstelle dein eigenes Farbprofil." : "Create your own color profile."}</p></div>
|
||||
<button class="theme-reset-button" type="button" onclick={resetCustomColors}><RotateCw size={13} />{isGerman ? "Zurücksetzen" : "Reset"}</button>
|
||||
</header>
|
||||
<div
|
||||
class="theme-preview"
|
||||
style={`--preview-bg:${customColors.background};--preview-surface:${customColors.surface};--preview-accent:${customColors.accent};--preview-text:${customColors.text};`}
|
||||
aria-label={isGerman ? "Vorschau des eigenen Themes" : "Custom theme preview"}
|
||||
>
|
||||
<span class="theme-preview-sidebar"></span>
|
||||
<span class="theme-preview-content"><i></i><b></b><em></em></span>
|
||||
</div>
|
||||
<div class="theme-color-grid">
|
||||
<label><span>{isGerman ? "Hintergrund" : "Background"}</span><input type="color" bind:value={customColors.background} aria-label={isGerman ? "Hintergrundfarbe" : "Background color"} /><code>{customColors.background}</code></label>
|
||||
<label><span>{isGerman ? "Fläche" : "Surface"}</span><input type="color" bind:value={customColors.surface} aria-label={isGerman ? "Flächenfarbe" : "Surface color"} /><code>{customColors.surface}</code></label>
|
||||
<label><span>{isGerman ? "Akzent" : "Accent"}</span><input type="color" bind:value={customColors.accent} aria-label={isGerman ? "Akzentfarbe" : "Accent color"} /><code>{customColors.accent}</code></label>
|
||||
<label><span>{isGerman ? "Schrift" : "Text"}</span><input type="color" bind:value={customColors.text} aria-label={isGerman ? "Schriftfarbe" : "Text color"} /><code>{customColors.text}</code></label>
|
||||
</div>
|
||||
<p class="theme-generator-note">{isGerman ? "Die Farben werden beim Speichern auf die gesamte Oberfläche angewendet." : "The colors are applied across the interface when you save."}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="general-setting-panel">
|
||||
<header><Languages size={16} /><div><h4>{isGerman ? "Sprache" : "Language"}</h4><p>{isGerman ? "Sprache der Oberfläche." : "Language used by the interface."}</p></div></header>
|
||||
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
|
||||
<label class:active={selectedLanguage === "en"}><input type="radio" bind:group={selectedLanguage} value="en" /><span>EN · English</span></label>
|
||||
<label class:active={selectedLanguage === "de"}><input type="radio" bind:group={selectedLanguage} value="de" /><span>DE · Deutsch</span></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="general-setting-panel general-setting-wide">
|
||||
<header><RefreshCw size={16} /><div><h4>{isGerman ? "Repository-Aktualisierung" : "Repository refresh"}</h4><p>{isGerman ? "Arbeitsbereich und Remotes aktuell halten." : "Keep the working tree and remotes current."}</p></div></header>
|
||||
<label class="settings-switch-row">
|
||||
<span><strong>{isGerman ? "Automatisch aktualisieren" : "Refresh automatically"}</strong><small>{isGerman ? "Branch-Status und Änderungen regelmäßig im Hintergrund prüfen." : "Periodically check branch state and working-tree changes."}</small></span>
|
||||
<input type="checkbox" bind:checked={autoRefreshEnabled} />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="general-setting-panel general-setting-wide">
|
||||
<header><ShieldCheck size={16} /><div><h4>{isGerman ? "Datenschutz" : "Privacy"}</h4><p>{isGerman ? "Anonyme Produkt- und Fehlerdiagnose." : "Anonymous product and error diagnostics."}</p></div></header>
|
||||
<label class="settings-switch-row">
|
||||
<span><strong>{isGerman ? "Anonyme Analytics erlauben" : "Allow anonymous analytics"}</strong><small>{isGerman ? "Keine Pfade, Remotes, Branches, Diffs, Zugangsdaten oder Quelltexte." : "No paths, remotes, branches, diffs, credentials, or source code."}</small></span>
|
||||
<input type="checkbox" bind:checked={analyticsEnabled} />
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
{:else if activePage === "tools"}
|
||||
<div class="settings-page-head tools-page-head">
|
||||
<div>
|
||||
<h3>{isGerman ? "Externe Tools" : "External tools"}</h3>
|
||||
<p>
|
||||
{detectionUnavailable
|
||||
? (isGerman ? "Automatische Erkennung ist in dieser Umgebung nicht verfügbar." : "Automatic detection is unavailable in this environment.")
|
||||
: (isGerman ? `${detectedTools.length} installierte Programme erkannt.` : `${detectedTools.length} installed applications detected.`)}
|
||||
</p>
|
||||
</div>
|
||||
<button class="tool-rescan-button" type="button" onclick={onRefreshDetectedTools} disabled={detectionPending}>
|
||||
<RotateCw class={detectionPending ? "spin" : ""} size={14} aria-hidden="true" />
|
||||
{detectionPending ? (isGerman ? "Erkennung läuft…" : "Detecting…") : (isGerman ? "Neu erkennen" : "Detect again")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-kind-tabs" role="tablist" aria-label={isGerman ? "Tool-Kategorie" : "Tool category"}>
|
||||
{#each toolKinds as kind}
|
||||
<button type="button" role="tab" aria-selected={activeToolKind === kind} class:active={activeToolKind === kind} onclick={() => selectToolKind(kind)}>
|
||||
{#if kind === "editor"}<Code2 size={16} />
|
||||
{:else if kind === "diff"}<GitCompare size={16} />
|
||||
{:else if kind === "merge"}<GitMerge size={16} />
|
||||
{:else if kind === "terminal"}<Terminal size={16} />
|
||||
{:else}<FolderOpen size={16} />{/if}
|
||||
<span>{toolLabel(kind)}</span>
|
||||
<small class:available={selectionAvailable(kind)}></small>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<section class="tool-config-panel" aria-label={`${toolLabel(activeToolKind)} ${isGerman ? "konfigurieren" : "configuration"}`}>
|
||||
<div class="tool-config-summary">
|
||||
<span class="tool-config-icon">
|
||||
{#if activeToolKind === "editor"}<Code2 size={22} />
|
||||
{:else if activeToolKind === "diff"}<GitCompare size={22} />
|
||||
{:else if activeToolKind === "merge"}<GitMerge size={22} />
|
||||
{:else if activeToolKind === "terminal"}<Terminal size={22} />
|
||||
{:else}<FolderOpen size={22} />{/if}
|
||||
</span>
|
||||
<div>
|
||||
<h4>{toolLabel(activeToolKind)}</h4>
|
||||
<p>{toolDescription(activeToolKind)}</p>
|
||||
</div>
|
||||
<span class="tool-status" class:available={selectionAvailable(activeToolKind)}>
|
||||
{#if selectionAvailable(activeToolKind)}<CheckCircle2 size={13} />{:else}<CircleDashed size={13} />{/if}
|
||||
{selectionStatus(activeToolKind)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if activeToolKind === "diff" || activeToolKind === "merge"}
|
||||
{@const openModeKind = activeToolKind as "diff" | "merge"}
|
||||
<div class="tool-route" aria-label={isGerman ? "Aktuelle Standardansicht" : "Current default view"}>
|
||||
<span>{isGerman ? "Standard" : "Default"}</span><ChevronRight size={15} aria-hidden="true" />
|
||||
<strong>{openMode(openModeKind) === "gitty" ? (isGerman ? "Gitty · integriert" : "Gitty · built in") : selectedToolName(activeToolKind)}</strong>
|
||||
</div>
|
||||
|
||||
<fieldset class="tool-open-mode">
|
||||
<legend>{isGerman ? "Beim Öffnen verwenden" : "Use when opening"}</legend>
|
||||
<div>
|
||||
<button type="button" class:active={openMode(openModeKind) === "gitty"} aria-pressed={openMode(openModeKind) === "gitty"} onclick={() => setOpenMode(openModeKind, "gitty")}>
|
||||
<span>Gitty</span><small>{isGerman ? "Integrierte Ansicht" : "Built-in view"}</small>
|
||||
</button>
|
||||
<button type="button" class:active={openMode(openModeKind) === "external"} aria-pressed={openMode(openModeKind) === "external"} onclick={() => setOpenMode(openModeKind, "external")}>
|
||||
<span>{selectedToolName(activeToolKind)}</span><small>{isGerman ? "Externes Programm" : "External application"}</small>
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
{:else}
|
||||
<div class="tool-route" aria-label={isGerman ? "Aktuelle Standardzuordnung" : "Current default mapping"}>
|
||||
<span>Gitty</span><ChevronRight size={15} aria-hidden="true" /><strong>{selectedToolName(activeToolKind)}</strong>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="tool-default-field">
|
||||
<span>{isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}</span>
|
||||
<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>
|
||||
{:else}
|
||||
<div class="settings-page-head">
|
||||
<div>
|
||||
<h3>{isGerman ? "Integrationen" : "Integrations"}</h3>
|
||||
<p>{isGerman ? "Verbinde Gitty mit deinen Git-Hosting-Diensten." : "Connect Gitty to your Git hosting services."}</p>
|
||||
</div>
|
||||
</div>
|
||||
<IntegrationSettingsPage
|
||||
language={selectedLanguage}
|
||||
settings={integrationDraft}
|
||||
onChange={(next) => { integrationDraft = next; }}
|
||||
onSecretsChange={(updates) => { integrationSecretUpdates = updates; }}
|
||||
/>
|
||||
{/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" disabled={saving}><Check size={16} aria-hidden="true" />{saving ? (isGerman ? "Wird gespeichert…" : "Saving…") : (isGerman ? "Änderungen speichern" : "Save changes")}</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-settings-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr); width: min(920px, calc(100vw - 32px)); height: min(720px, calc(100vh - 32px)); overflow: hidden; }
|
||||
.app-settings-head { display: flex; align-items: center; justify-content: space-between; min-height: 70px; padding: 14px 18px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||
.app-settings-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.app-settings-mark { display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, transparent); }
|
||||
.app-settings-title h2, .settings-page-head h3, .tool-config-summary h4, .general-setting-panel h4 { margin: 0; color: var(--color-ink); }
|
||||
.app-settings-title h2 { font-size: 18px; line-height: 1.2; }
|
||||
.app-settings-title p, .settings-page-head p, .tool-config-summary p, .general-setting-panel p { margin: 0; color: var(--color-ink-dim); }
|
||||
.app-settings-title p { margin-top: 3px; font-size: 11px; }
|
||||
.app-settings-shell { display: grid; min-height: 0; grid-template-rows: minmax(0, 1fr) auto; }
|
||||
.app-settings-body { display: grid; min-height: 0; grid-template-columns: 205px minmax(0, 1fr); }
|
||||
.settings-nav { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 14px 12px; border-right: 1px solid var(--color-border-subtle); background: color-mix(in srgb, var(--app-dialog-chrome) 72%, var(--app-dialog-bg)); }
|
||||
.settings-nav > button { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; min-height: 50px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
||||
.settings-nav > button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.settings-nav > button.active { border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
||||
.settings-nav button > :global(svg) { color: var(--color-ink-muted); }
|
||||
.settings-nav button.active > :global(svg) { color: var(--color-accent); }
|
||||
.settings-nav button span { display: grid; min-width: 0; gap: 2px; }
|
||||
.settings-nav button strong { font-size: 12px; }
|
||||
.settings-nav button small { overflow: hidden; color: var(--color-ink-faint); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.settings-nav button em { display: grid; place-items: center; min-width: 21px; height: 20px; padding-inline: 5px; border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 12%, transparent); font-size: 9px; font-style: normal; font-weight: 800; }
|
||||
.settings-nav-note { display: flex; align-items: flex-start; gap: 8px; margin-top: auto; padding: 10px; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-faint); }
|
||||
.settings-nav-note :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-success); }
|
||||
.settings-nav-note p { margin: 0; font-size: 9.5px; line-height: 1.45; }
|
||||
.settings-content { min-width: 0; overflow: auto; padding: 18px 20px 22px; }
|
||||
.settings-page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||
.settings-page-head h3 { font-size: 18px; }
|
||||
.settings-page-head p { margin-top: 4px; font-size: 11px; line-height: 1.45; }
|
||||
.tool-rescan-button { display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; min-height: 30px; padding: 0 10px; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); font-size: 10px; font-weight: 750; }
|
||||
.tool-rescan-button:hover:not(:disabled) { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
|
||||
.tool-kind-tabs { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; margin-bottom: 14px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
|
||||
.tool-kind-tabs button { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; min-width: 0; height: 38px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
|
||||
.tool-kind-tabs button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.tool-kind-tabs button.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-raised); box-shadow: 0 2px 8px rgba(0,0,0,.12); }
|
||||
.tool-kind-tabs button.active :global(svg) { color: var(--color-accent); }
|
||||
.tool-kind-tabs button small { position: absolute; top: 5px; right: 6px; width: 5px; height: 5px; border-radius: 50%; background: var(--color-ink-faint); }
|
||||
.tool-kind-tabs button small.available { background: var(--color-success); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-success) 15%, transparent); }
|
||||
.tool-config-panel { display: grid; gap: 14px; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 12px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
|
||||
.tool-config-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; }
|
||||
.tool-config-icon { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid color-mix(in srgb, var(--color-accent) 25%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 8%, transparent); }
|
||||
.tool-config-summary h4 { font-size: 14px; }
|
||||
.tool-config-summary p { margin-top: 3px; font-size: 10.5px; line-height: 1.4; }
|
||||
.tool-status { display: inline-flex; align-items: center; gap: 5px; padding: 5px 7px; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-ink-faint); font-size: 9px; font-weight: 750; }
|
||||
.tool-status.available { border-color: color-mix(in srgb, var(--color-success) 24%, var(--color-border)); color: var(--color-success); background: color-mix(in srgb, var(--color-success) 6%, transparent); }
|
||||
.tool-route { display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 7px 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; color: var(--color-ink-faint); background: var(--color-surface-raised); font-size: 10.5px; }
|
||||
.tool-route strong { color: var(--color-ink); }
|
||||
.tool-open-mode { display: grid; gap: 6px; min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.tool-open-mode legend { margin-bottom: 6px; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
|
||||
.tool-open-mode > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
|
||||
.tool-open-mode button { display: grid; justify-items: start; gap: 2px; min-width: 0; min-height: 46px; padding: 7px 10px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
||||
.tool-open-mode button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.tool-open-mode button.active { border-color: color-mix(in srgb, var(--color-accent) 38%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-hover)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
||||
.tool-open-mode button span { max-width: 100%; overflow: hidden; font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tool-open-mode button small { color: var(--color-ink-faint); font-size: 9px; font-weight: 550; }
|
||||
.tool-default-field, .tool-advanced-panel label { display: grid; gap: 6px; min-width: 0; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
|
||||
.tool-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; }
|
||||
.custom-theme-panel > header { align-items: center; }
|
||||
.custom-theme-panel > header > div { min-width: 0; }
|
||||
.theme-reset-button { display: inline-flex; align-items: center; gap: 5px; min-height: 27px; margin-left: auto; padding: 0 8px; border: 1px solid var(--color-border); color: var(--color-ink-muted); background: var(--color-surface-raised); font-size: 9.5px; font-weight: 750; }
|
||||
.theme-reset-button:hover:not(:disabled) { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.theme-preview { display: grid; grid-template-columns: 28% 1fr; min-height: 78px; overflow: hidden; border: 1px solid color-mix(in srgb, var(--preview-text) 30%, var(--preview-surface)); background: var(--preview-bg); }
|
||||
.theme-preview-sidebar { border-right: 1px solid color-mix(in srgb, var(--preview-text) 24%, var(--preview-surface)); background: color-mix(in srgb, var(--preview-surface) 86%, var(--preview-bg)); }
|
||||
.theme-preview-content { display: grid; grid-template-columns: 1fr auto; align-content: start; gap: 8px; margin: 10px; padding: 10px; border: 1px solid color-mix(in srgb, var(--preview-text) 22%, var(--preview-surface)); color: var(--preview-text); background: var(--preview-surface); }
|
||||
.theme-preview-content i { display: block; width: 54%; height: 7px; background: var(--preview-text); opacity: .82; }
|
||||
.theme-preview-content b { display: block; width: 34px; height: 18px; grid-row: 1 / 3; grid-column: 2; background: var(--preview-accent); }
|
||||
.theme-preview-content em { display: block; width: 76%; height: 5px; background: var(--preview-text); opacity: .32; }
|
||||
.theme-color-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
|
||||
.theme-color-grid label { display: grid; grid-template-columns: minmax(0, 1fr) 32px auto; align-items: center; gap: 8px; min-width: 0; min-height: 38px; padding: 5px 7px; border: 1px solid var(--color-border-subtle); background: var(--color-surface-raised); }
|
||||
.theme-color-grid label > span { color: var(--color-ink-muted); font-size: 10px; font-weight: 750; }
|
||||
.theme-color-grid input[type="color"] { width: 32px; height: 25px; padding: 2px; border: 1px solid var(--color-border-input); background: transparent; cursor: pointer; }
|
||||
.theme-color-grid code { color: var(--color-ink-faint); font: 9px var(--font-mono); text-transform: uppercase; }
|
||||
.theme-generator-note { margin: -5px 0 0 !important; color: var(--color-ink-faint) !important; }
|
||||
.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); gap: 5px; padding-inline: 6px; }
|
||||
.settings-nav button strong { font-size: 10px; }
|
||||
.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; }
|
||||
.theme-color-grid { grid-template-columns: 1fr; }
|
||||
.tool-config-panel { padding: 13px; }
|
||||
.tool-usage-callout { grid-template-columns: 1fr; gap: 4px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { AlertTriangle, Bug, Check, GitCommitHorizontal, LoaderCircle, Play, RotateCcw, SkipForward, X } from "@lucide/svelte";
|
||||
import type { BisectState } from "../types";
|
||||
|
||||
interface Props {
|
||||
language: "en" | "de";
|
||||
bisectState: BisectState | null;
|
||||
isLoading: boolean;
|
||||
isBusy: boolean;
|
||||
operation: string;
|
||||
error: string;
|
||||
onStart: (good: string, bad: string) => void;
|
||||
onMark: (verdict: "good" | "bad" | "skip") => void;
|
||||
onReset: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { language = "en", bisectState = null, isLoading = false, isBusy = false, operation = "", error = "", onStart = () => {}, onMark = () => {}, onReset = () => {}, onClose = () => {} }: Props = $props();
|
||||
let good = $state("");
|
||||
let bad = $state("HEAD");
|
||||
const de = $derived(language === "de");
|
||||
const canStart = $derived(Boolean(good.trim() && bad.trim()) && !isLoading && !isBusy);
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||
<div class="dialog bisect-dialog" role="dialog" aria-modal="true" aria-label={de ? "Geführtes Git Bisect" : "Guided Git bisect"} tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div><span class="eyebrow">{de ? "Fehlerursache eingrenzen" : "Find the regression"}</span><h2 class="dialog-title">Git Bisect</h2></div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={de ? "Schließen" : "Close"}><X size={18} aria-hidden="true" /></button>
|
||||
</header>
|
||||
|
||||
<div class="bisect-body">
|
||||
{#if error}<div class="bisect-notice error"><AlertTriangle size={16} /><span>{error}</span></div>{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="bisect-loading"><LoaderCircle class="spin" size={18} />{de ? "Bisect-Status wird geladen …" : "Loading bisect status …"}</div>
|
||||
{:else if !bisectState?.active}
|
||||
<section class="bisect-intro">
|
||||
<div class="bisect-intro-icon"><Bug size={24} /></div>
|
||||
<div><h3>{de ? "Welcher Commit hat den Fehler eingeführt?" : "Which commit introduced the bug?"}</h3><p>{de ? "Gitty checkt schrittweise frühere Commits aus. Markiere jede getestete Version als Good, Bad oder Skip." : "Gitty checks out earlier commits step by step. Mark each tested version as Good, Bad, or Skip."}</p></div>
|
||||
</section>
|
||||
<div class="bisect-range">
|
||||
<div class="range-heading">
|
||||
<span>{de ? "Suchbereich" : "Search range"}</span>
|
||||
<small>{de ? "Von funktionierend bis fehlerhaft" : "From working to broken"}</small>
|
||||
</div>
|
||||
<div class="bisect-fields">
|
||||
<label class="commit-field good-field">
|
||||
<span class="field-title"><Check size={14} />{de ? "Funktionierender Commit" : "Known good commit"}<em>Good</em></span>
|
||||
<input bind:value={good} placeholder={de ? "z. B. v1.4.0 oder Commit-Hash" : "e.g. v1.4.0 or commit hash"} disabled={isBusy} spellcheck="false" autocomplete="off" />
|
||||
<small>{de ? "Hier trat der Fehler noch nicht auf." : "The bug did not occur here."}</small>
|
||||
</label>
|
||||
<label class="commit-field bad-field">
|
||||
<span class="field-title"><Bug size={14} />{de ? "Fehlerhafter Commit" : "Known bad commit"}<em>Bad</em></span>
|
||||
<input bind:value={bad} placeholder="HEAD" disabled={isBusy} spellcheck="false" autocomplete="off" />
|
||||
<small>{de ? "Meistens HEAD, der aktuelle Commit." : "Usually HEAD, the current commit."}</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bisect-notice"><AlertTriangle size={15} /><span>{de ? "Der Arbeitsbaum muss sauber sein. Während des Bisects wechselt Gitty vorübergehend zwischen Commits." : "The working tree must be clean. Gitty temporarily switches between commits during the bisect."}</span></div>
|
||||
{:else if bisectState.finished && bisectState.culprit}
|
||||
<section class="bisect-result">
|
||||
<div class="result-icon"><Bug size={22} /></div>
|
||||
<div><span>{de ? "Erster fehlerhafter Commit gefunden" : "First bad commit found"}</span><h3>{bisectState.culprit.summary}</h3><div class="commit-meta"><code>{bisectState.culprit.short_hash}</code><span>{bisectState.culprit.author_name}</span><span>{new Date(bisectState.culprit.date).toLocaleString()}</span></div></div>
|
||||
</section>
|
||||
<p class="result-copy">{de ? "Beende den Bisect, um zum ursprünglichen Branch und Arbeitsstand zurückzukehren." : "Finish the bisect to return to the original branch and working state."}</p>
|
||||
{:else if bisectState.current}
|
||||
<div class="bisect-progress">
|
||||
<span>{de ? "Aktueller Test-Commit" : "Current test commit"}</span>
|
||||
{#if bisectState.remaining_steps !== null}<strong>{de ? `Noch ungefähr ${bisectState.remaining_steps} Schritte` : `About ${bisectState.remaining_steps} steps remaining`}</strong>{/if}
|
||||
</div>
|
||||
<section class="bisect-current">
|
||||
<GitCommitHorizontal size={22} />
|
||||
<div><h3>{bisectState.current.summary}</h3><div class="commit-meta"><code>{bisectState.current.short_hash}</code><span>{bisectState.current.author_name}</span><span>{new Date(bisectState.current.date).toLocaleString()}</span></div></div>
|
||||
</section>
|
||||
<div class="bisect-question"><strong>{de ? "Tritt der Fehler bei diesem Commit auf?" : "Does the bug occur at this commit?"}</strong><span>{de ? "Teste die Anwendung oder führe deine Prüfschritte aus, bevor du entscheidest." : "Test the application or run your checks before choosing a verdict."}</span></div>
|
||||
<div class="verdict-grid">
|
||||
<button class="verdict good" type="button" onclick={() => onMark("good")} disabled={isBusy}><Check size={17} /><span><strong>Good</strong><small>{de ? "Fehler tritt nicht auf" : "Bug is absent"}</small></span></button>
|
||||
<button class="verdict bad" type="button" onclick={() => onMark("bad")} disabled={isBusy}><Bug size={17} /><span><strong>Bad</strong><small>{de ? "Fehler tritt auf" : "Bug occurs"}</small></span></button>
|
||||
<button class="verdict skip" type="button" onclick={() => onMark("skip")} disabled={isBusy}><SkipForward size={17} /><span><strong>Skip</strong><small>{de ? "Nicht testbar" : "Cannot test"}</small></span></button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer bisect-footer">
|
||||
{#if bisectState?.active}
|
||||
<button class="btn-secondary reset" type="button" onclick={onReset} disabled={isBusy}><RotateCcw size={15} />{bisectState.finished ? (de ? "Bisect beenden" : "Finish bisect") : (de ? "Bisect abbrechen" : "Abort bisect")}</button>
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{de ? "Später fortsetzen" : "Continue later"}</button>
|
||||
{:else}
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{de ? "Abbrechen" : "Cancel"}</button>
|
||||
<button class="btn-primary" type="button" onclick={() => onStart(good.trim(), bad.trim())} disabled={!canStart}>{#if operation === "Starting Git bisect"}<LoaderCircle class="spin" size={16} />{:else}<Play size={16} />{/if}{de ? "Bisect starten" : "Start bisect"}</button>
|
||||
{/if}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bisect-dialog{grid-template-rows:auto minmax(0,auto) auto;width:min(640px,calc(100vw - 28px));height:auto;max-height:calc(100vh - 40px);overflow:hidden}
|
||||
.bisect-body{display:grid;align-content:start;gap:14px;padding:16px 18px;overflow:auto}
|
||||
.bisect-loading{display:flex;min-height:150px;align-items:center;justify-content:center;gap:8px;color:var(--color-ink-muted)}
|
||||
.bisect-intro,.bisect-current,.bisect-result{display:flex;align-items:flex-start;gap:12px}
|
||||
.bisect-intro-icon,.result-icon{display:grid;width:38px;height:38px;flex:0 0 38px;place-items:center;border:1px solid color-mix(in srgb,var(--color-primary) 32%,var(--color-border));border-radius:8px;color:var(--color-primary);background:color-mix(in srgb,var(--color-primary) 9%,var(--color-surface))}
|
||||
.bisect-intro h3,.bisect-current h3,.bisect-result h3{margin:0;font-size:13px}
|
||||
.bisect-intro p,.result-copy{max-width:520px;margin:4px 0 0;color:var(--color-ink-muted);font-size:12px;line-height:1.45}
|
||||
.bisect-range{display:grid;gap:9px;padding:12px;border:1px solid var(--color-border-subtle);border-radius:9px;background:color-mix(in srgb,var(--color-surface) 72%,transparent)}
|
||||
.range-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
|
||||
.range-heading>span{color:var(--color-ink);font-size:10px;font-weight:800;letter-spacing:.07em;text-transform:uppercase}
|
||||
.range-heading>small{color:var(--color-ink-faint);font-size:10px}
|
||||
.bisect-fields{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.commit-field{display:grid;gap:6px;padding:10px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--color-surface-raised)}
|
||||
.field-title{display:flex;align-items:center;gap:6px;color:var(--color-ink);font-size:11px;font-weight:700}
|
||||
.field-title em{margin-left:auto;padding:1px 5px;border-radius:4px;font-size:9px;font-style:normal;font-weight:800;text-transform:uppercase}
|
||||
.good-field .field-title :global(svg),.good-field .field-title em{color:#69c986}
|
||||
.good-field .field-title em{background:color-mix(in srgb,#69c986 10%,transparent)}
|
||||
.bad-field .field-title :global(svg),.bad-field .field-title em{color:#ef737b}
|
||||
.bad-field .field-title em{background:color-mix(in srgb,#ef737b 10%,transparent)}
|
||||
.bisect-fields input{height:32px;font-family:var(--font-mono);font-size:11.5px}
|
||||
.bisect-fields small{color:var(--color-ink-faint);font-size:9.5px;line-height:1.35}
|
||||
.bisect-notice{display:flex;align-items:flex-start;gap:8px;padding:9px 11px;border:1px solid color-mix(in srgb,#e2ad4e 28%,var(--color-border));border-radius:7px;color:var(--color-ink-muted);background:color-mix(in srgb,#e2ad4e 6%,var(--color-surface));font-size:11.5px;line-height:1.4}
|
||||
.bisect-notice :global(svg){flex:0 0 auto;color:#e2ad4e}
|
||||
.bisect-notice.error{border-color:color-mix(in srgb,#e45c65 40%,var(--color-border));color:#ef979d;background:color-mix(in srgb,#c92f3a 8%,var(--color-surface))}
|
||||
.bisect-notice.error :global(svg){color:#ef6972}
|
||||
.bisect-progress{display:flex;align-items:center;justify-content:space-between;color:var(--color-ink-muted);font-size:11px}
|
||||
.bisect-progress strong{color:var(--color-primary)}
|
||||
.bisect-current{padding:14px;border:1px solid var(--color-border);border-radius:8px;background:var(--color-surface)}
|
||||
.bisect-current>:global(svg){flex:0 0 auto;color:var(--color-primary)}
|
||||
.commit-meta{display:flex;flex-wrap:wrap;align-items:center;gap:7px 12px;margin-top:7px;color:var(--color-ink-faint);font-size:10px}
|
||||
.commit-meta code{padding:2px 5px;border-radius:4px;color:var(--color-primary);background:color-mix(in srgb,var(--color-primary) 10%,transparent)}
|
||||
.bisect-question{display:grid;gap:4px}
|
||||
.bisect-question span{color:var(--color-ink-muted);font-size:11px}
|
||||
.verdict-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}
|
||||
.verdict{min-height:58px;justify-content:flex-start;padding:8px 11px;text-align:left}
|
||||
.verdict>span{display:grid;gap:2px}
|
||||
.verdict small{color:var(--color-ink-faint);font-size:9.5px}
|
||||
.verdict.good{color:#69c986}.verdict.bad{color:#ef737b}.verdict.skip{color:#ddb45c}
|
||||
.bisect-result{padding:16px;border:1px solid color-mix(in srgb,#ef737b 36%,var(--color-border));border-radius:8px;background:color-mix(in srgb,#c92f3a 7%,var(--color-surface))}
|
||||
.bisect-result>div:last-child>span{color:#ef838b;font-size:10px;font-weight:800;text-transform:uppercase}
|
||||
.result-copy{margin:0}
|
||||
.bisect-footer{justify-content:flex-end;padding-block:9px}
|
||||
.bisect-footer .reset{margin-right:auto}
|
||||
@media(max-width:620px){.bisect-fields,.verdict-grid{grid-template-columns:1fr}.bisect-body{padding:14px}.range-heading{align-items:flex-start;flex-direction:column;gap:2px}.bisect-progress{align-items:flex-start;flex-direction:column;gap:4px}}
|
||||
</style>
|
||||
@@ -2,6 +2,11 @@
|
||||
import { FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
|
||||
import type { GitBlameLine } from "../types";
|
||||
|
||||
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
interface BlameGroup {
|
||||
id: string;
|
||||
hash: string;
|
||||
@@ -66,7 +71,7 @@
|
||||
if (!seconds) return "";
|
||||
const date = new Date(seconds * 1000);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
return commitDateFormatter.format(date);
|
||||
}
|
||||
|
||||
function groupTooltip(group: BlameGroup): string {
|
||||
|
||||
@@ -18,16 +18,24 @@
|
||||
onClose = () => {},
|
||||
}: 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>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">{force ? "Force delete" : "Delete branch"}</span>
|
||||
<p class="dialog-title">{title}</p>
|
||||
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="branch-delete-title">
|
||||
<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>
|
||||
<span class="eyebrow">{branch.remote ? "Remote branch" : force ? "Force delete" : "Delete branch"}</span>
|
||||
<p class="dialog-title" id="branch-delete-title">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<X size={16} aria-hidden="true" />
|
||||
@@ -35,27 +43,35 @@
|
||||
</header>
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<div class="discard-confirm-copy">
|
||||
<p>
|
||||
{#if force}
|
||||
This branch is not fully merged. Force deleting removes the branch pointer even if some commits are only reachable from this branch.
|
||||
<p class="branch-delete-lead">
|
||||
{#if branch.remote}
|
||||
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}
|
||||
Delete this local branch from the repository?
|
||||
This branch will be removed from your local repository.
|
||||
{/if}
|
||||
</p>
|
||||
<code class="discard-target" title={branch.name}>
|
||||
<GitBranch size={13} aria-hidden="true" />
|
||||
{branch.name}
|
||||
</code>
|
||||
<div class="branch-delete-target" title={branch.name}>
|
||||
<span class="branch-delete-target-icon" aria-hidden="true"><GitBranch size={16} /></span>
|
||||
<span class="branch-delete-target-copy">
|
||||
<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">
|
||||
{#if force}
|
||||
Make sure you no longer need the unique commits on this branch.
|
||||
{#if branch.remote}
|
||||
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}
|
||||
Git will refuse if the branch is not fully merged.
|
||||
Git will stop the deletion if the branch contains unmerged commits.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -63,13 +79,13 @@
|
||||
|
||||
<footer class="discard-confirm-actions">
|
||||
<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}
|
||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
{force ? "Force delete" : "Delete"}
|
||||
{branch.remote ? "Delete from remote" : force ? "Force delete" : "Delete"}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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";
|
||||
|
||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||
@@ -11,6 +12,7 @@
|
||||
children: BranchTreeNode[];
|
||||
branchCount: number;
|
||||
current: boolean;
|
||||
branches: GitBranchInfo[];
|
||||
folders: Map<string, BranchFolderNode>;
|
||||
}
|
||||
|
||||
@@ -30,6 +32,7 @@
|
||||
depth: number;
|
||||
branchCount: number;
|
||||
current: boolean;
|
||||
branches: GitBranchInfo[];
|
||||
}
|
||||
|
||||
interface BranchLeafRow {
|
||||
@@ -49,14 +52,19 @@
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
onCheckout: (branch: GitBranchInfo) => void;
|
||||
onCompareBranch: (branch: GitBranchInfo) => void;
|
||||
onMerge: (branch: GitBranchInfo) => void;
|
||||
onRebase: (branch: GitBranchInfo) => void;
|
||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||
onRenameBranch: (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>;
|
||||
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
||||
onPushTag: (tag: GitTag) => void | Promise<void>;
|
||||
onManageWorktrees: () => void;
|
||||
onCreateWorktree: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
}
|
||||
@@ -69,14 +77,19 @@
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
onCheckout = () => {},
|
||||
onCompareBranch = () => {},
|
||||
onMerge = () => {},
|
||||
onRebase = () => {},
|
||||
onCreateBranch = () => {},
|
||||
onRenameBranch = () => {},
|
||||
onDeleteBranch = () => {},
|
||||
onDeleteRemoteBranch = () => {},
|
||||
onDeleteBranchFolder = () => {},
|
||||
onCreateTag = () => {},
|
||||
onDeleteTag = () => {},
|
||||
onPushTag = () => {},
|
||||
onManageWorktrees = () => {},
|
||||
onCreateWorktree = () => {},
|
||||
collapsed = false,
|
||||
onToggleCollapsed = () => {},
|
||||
}: Props = $props();
|
||||
@@ -91,11 +104,13 @@
|
||||
let newTagName = $state("");
|
||||
let newTagMessage = $state("");
|
||||
let tagCreateInput = $state<HTMLInputElement | null>(null);
|
||||
let panelElement = $state<HTMLElement | 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 contextMenuY = $state(0);
|
||||
let contextTag = $state<GitTag | null>(null);
|
||||
let tagContextMenuElement = $state<HTMLElement | null>(null);
|
||||
let tagContextMenuX = $state(0);
|
||||
let tagContextMenuY = $state(0);
|
||||
let collapsedBranchFolders = $state<Set<string>>(new Set());
|
||||
@@ -111,6 +126,7 @@
|
||||
children: [],
|
||||
branchCount: 0,
|
||||
current: false,
|
||||
branches: [],
|
||||
folders: new Map(),
|
||||
};
|
||||
}
|
||||
@@ -137,6 +153,7 @@
|
||||
|
||||
folder.branchCount += 1;
|
||||
folder.current ||= branch.current;
|
||||
folder.branches.push(branch);
|
||||
parent = folder;
|
||||
}
|
||||
|
||||
@@ -178,6 +195,7 @@
|
||||
depth,
|
||||
branchCount: node.branchCount,
|
||||
current: node.current,
|
||||
branches: node.branches,
|
||||
});
|
||||
|
||||
if (isBranchFolderOpen(node.id)) {
|
||||
@@ -235,24 +253,52 @@
|
||||
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.stopPropagation();
|
||||
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;
|
||||
contextMenuX = Math.max(8, Math.min(rawX, maxX));
|
||||
contextMenuY = Math.max(8, Math.min(rawY, maxY));
|
||||
contextMenuX = event.clientX + 2;
|
||||
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() {
|
||||
contextBranch = null;
|
||||
contextFolder = null;
|
||||
}
|
||||
|
||||
async function renameContextBranch() {
|
||||
@@ -262,11 +308,32 @@
|
||||
await onRenameBranch(branch);
|
||||
}
|
||||
|
||||
function compareContextBranch() {
|
||||
const branch = contextBranch;
|
||||
if (!branch || isBusy) return;
|
||||
closeBranchContextMenu();
|
||||
onCompareBranch(branch);
|
||||
}
|
||||
|
||||
async function deleteContextBranch() {
|
||||
const branch = contextBranch;
|
||||
if (!branch || branch.current || branch.remote || isBusy) return;
|
||||
if (!branch || branch.current || isBusy) return;
|
||||
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() {
|
||||
@@ -313,20 +380,20 @@
|
||||
tagsOpen = true;
|
||||
}
|
||||
|
||||
function openTagContextMenu(event: MouseEvent, tag: GitTag) {
|
||||
async function openTagContextMenu(event: MouseEvent, tag: GitTag) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
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;
|
||||
tagContextMenuX = Math.max(8, Math.min(rawX, maxX));
|
||||
tagContextMenuY = Math.max(8, Math.min(rawY, maxY));
|
||||
tagContextMenuX = event.clientX + 2;
|
||||
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() {
|
||||
@@ -359,7 +426,7 @@
|
||||
|
||||
<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>
|
||||
<span class="eyebrow">Branches</span>
|
||||
@@ -398,8 +465,6 @@
|
||||
<!-- collapsed -->
|
||||
{:else if !hasRepository}
|
||||
<p class="blank-state">Open a repository to list branches.</p>
|
||||
{:else if branches.length === 0 && tags.length === 0}
|
||||
<p class="blank-state">No branches returned.</p>
|
||||
{:else}
|
||||
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
||||
{#if createOpen}
|
||||
@@ -451,6 +516,7 @@
|
||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||
type="button"
|
||||
onclick={() => toggleBranchFolder(row.id)}
|
||||
oncontextmenu={(event) => openFolderContextMenu(event, row)}
|
||||
aria-expanded={isBranchFolderOpen(row.id)}
|
||||
title={`${row.name} (${row.branchCount})`}
|
||||
>
|
||||
@@ -518,6 +584,7 @@
|
||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||
type="button"
|
||||
onclick={() => toggleBranchFolder(row.id)}
|
||||
oncontextmenu={(event) => openFolderContextMenu(event, row)}
|
||||
aria-expanded={isBranchFolderOpen(row.id)}
|
||||
title={`${row.name} (${row.branchCount})`}
|
||||
>
|
||||
@@ -636,11 +703,27 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="branch-group">
|
||||
<button
|
||||
class="branch-group-toggle worktree-group-toggle"
|
||||
type="button"
|
||||
onclick={onManageWorktrees}
|
||||
disabled={isBusy}
|
||||
aria-haspopup="dialog"
|
||||
title="Manage repository worktrees"
|
||||
>
|
||||
<HardDrive size={14} aria-hidden="true" />
|
||||
<span>Worktrees</span>
|
||||
<span class="branch-group-count">Manage</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if contextBranch}
|
||||
<div
|
||||
bind:this={branchContextMenuElement}
|
||||
class="branch-context-menu"
|
||||
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||
role="menu"
|
||||
@@ -651,6 +734,10 @@
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
Checkout
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={compareContextBranch} disabled={isBusy}>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
Compare with...
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
|
||||
<GitMerge size={14} aria-hidden="true" />
|
||||
Merge into current
|
||||
@@ -659,27 +746,55 @@
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
Rebase current onto this
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={createContextWorktree} disabled={isBusy || contextBranch.remote || contextBranch.current}>
|
||||
<HardDrive size={14} aria-hidden="true" />
|
||||
Open in new worktree
|
||||
</button>
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
|
||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
Rename
|
||||
{contextBranch.remote ? "Rename remote..." : "Rename"}
|
||||
</button>
|
||||
<button
|
||||
class="danger"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={deleteContextBranch}
|
||||
disabled={isBusy || contextBranch.current || contextBranch.remote}
|
||||
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Remote branch cannot be deleted here" : "Delete local branch"}
|
||||
disabled={isBusy || contextBranch.current}
|
||||
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Delete remote branch" : "Delete local branch"}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if contextTag}
|
||||
<div
|
||||
bind:this={tagContextMenuElement}
|
||||
class="branch-context-menu"
|
||||
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
|
||||
role="menu"
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { Download, FolderOpen, LoaderCircle, X } from "@lucide/svelte";
|
||||
import { Cloud, Download, FolderOpen, GitBranch, Globe2, LoaderCircle, LockKeyhole, RefreshCw, Search, X } from "@lucide/svelte";
|
||||
import { listIntegrationRepositories } from "../git";
|
||||
import { configuredIntegrationSources } from "../integrations";
|
||||
import type { AppLanguage, CloneOptions, GitIntegrationProvider, GitIntegrationRepository, GitIntegrationSettings, GitIntegrationSource } from "../types";
|
||||
|
||||
type CloneSource = "url" | "integrations";
|
||||
|
||||
interface Props {
|
||||
isBusy: boolean;
|
||||
error: string;
|
||||
onClone: (remoteUrl: string, parentPath: string, directoryName: string) => void;
|
||||
language: AppLanguage;
|
||||
integrations: GitIntegrationSettings;
|
||||
onClone: (remoteUrl: string, parentPath: string, directoryName: string, options: CloneOptions, provider?: GitIntegrationProvider, accountId?: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isBusy = false,
|
||||
error = "",
|
||||
onClone = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let { isBusy = false, error = "", language = "en", integrations, onClone = () => {}, onClose = () => {} }: Props = $props();
|
||||
let source = $state<CloneSource>("url");
|
||||
let remoteUrl = $state("");
|
||||
let parentPath = $state("");
|
||||
let directoryName = $state("");
|
||||
@@ -25,29 +27,164 @@
|
||||
let browseError = $state("");
|
||||
let visibleError = $state("");
|
||||
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let selectedSourceId = $state("");
|
||||
let selectedRepositoryId = $state("");
|
||||
let repositorySearch = $state("");
|
||||
let repositoriesBySource = $state<Record<string, GitIntegrationRepository[]>>({});
|
||||
let loadingSourceId = $state("");
|
||||
let repositoryError = $state("");
|
||||
let repositoryListElement = $state<HTMLDivElement>();
|
||||
let repositoryScrollbarElement = $state<HTMLDivElement>();
|
||||
let repositoryScrollbarVisible = $state(false);
|
||||
let repositoryScrollbarTop = $state(0);
|
||||
let repositoryScrollbarHeight = $state(28);
|
||||
let repositoryScrollTop = $state(0);
|
||||
let repositoryScrollMax = $state(0);
|
||||
let repositoryScrollbarPointerId = $state<number>();
|
||||
let repositoryScrollbarDragY = 0;
|
||||
let repositoryScrollbarDragScrollTop = 0;
|
||||
let repositoryScrollbarFrame: number | undefined;
|
||||
let repositoryRequestId = 0;
|
||||
let shallowClone = $state(false);
|
||||
let branchToClone = $state("");
|
||||
let shallowLimitMode = $state<"depth" | "since">("depth");
|
||||
let shallowDepth = $state(1);
|
||||
let shallowSince = $state("");
|
||||
let customFlags = $state("");
|
||||
let sparseCheckout = $state(false);
|
||||
let bloblessClone = $state(false);
|
||||
let sparsePathInput = $state("");
|
||||
|
||||
let directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
|
||||
let canSubmit = $derived(
|
||||
!isBusy &&
|
||||
remoteUrl.trim().length > 0 &&
|
||||
parentPath.trim().length > 0,
|
||||
);
|
||||
const isGerman = $derived(language === "de");
|
||||
const configuredSources = $derived(configuredIntegrationSources(integrations));
|
||||
const activeSource = $derived(configuredSources.find((candidate) => candidate.id === selectedSourceId));
|
||||
const activeRepositories = $derived(activeSource ? repositoriesBySource[activeSource.id] ?? [] : []);
|
||||
const filteredRepositories = $derived.by(() => {
|
||||
const query = repositorySearch.trim().toLocaleLowerCase();
|
||||
if (!query) return activeRepositories;
|
||||
return activeRepositories.filter((repository) => `${repository.fullName} ${repository.description}`.toLocaleLowerCase().includes(query));
|
||||
});
|
||||
const azureRepositoryGroups = $derived.by(() => {
|
||||
if (activeSource?.provider !== "azure-devops") return [];
|
||||
const groups = new Map<string, GitIntegrationRepository[]>();
|
||||
for (const repository of filteredRepositories) {
|
||||
const separator = repository.fullName.indexOf("/");
|
||||
const project = separator > 0 ? repository.fullName.slice(0, separator) : (isGerman ? "Weitere Repositories" : "Other repositories");
|
||||
const repositories = groups.get(project) ?? [];
|
||||
repositories.push(repository);
|
||||
groups.set(project, repositories);
|
||||
}
|
||||
return [...groups.entries()].map(([project, repositories]) => ({ project, repositories }));
|
||||
});
|
||||
const selectedRepository = $derived(activeRepositories.find((repository) => repository.id === selectedRepositoryId));
|
||||
const directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
|
||||
const sparsePaths = $derived.by(() => [...new Set(sparsePathInput.split(/\r?\n/).map((path) => path.trim()).filter(Boolean))]);
|
||||
const shallowLimitValid = $derived(!shallowClone || (shallowLimitMode === "depth" ? Number.isInteger(shallowDepth) && shallowDepth > 0 : shallowSince.trim().length > 0));
|
||||
const cloneOptionsValid = $derived(shallowLimitValid);
|
||||
const canSubmit = $derived(!isBusy && remoteUrl.trim().length > 0 && parentPath.trim().length > 0 && cloneOptionsValid);
|
||||
|
||||
$effect(() => {
|
||||
const nextError = error || browseError;
|
||||
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||
visibleError = nextError;
|
||||
if (nextError) {
|
||||
errorHideTimer = setTimeout(() => {
|
||||
visibleError = "";
|
||||
}, 6000);
|
||||
}
|
||||
if (nextError) errorHideTimer = setTimeout(() => { visibleError = ""; }, 6000);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
filteredRepositories.length;
|
||||
loadingSourceId;
|
||||
repositoryError;
|
||||
scheduleRepositoryScrollbarUpdate();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("resize", scheduleRepositoryScrollbarUpdate);
|
||||
return () => window.removeEventListener("resize", scheduleRepositoryScrollbarUpdate);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||
if (repositoryScrollbarFrame !== undefined) cancelAnimationFrame(repositoryScrollbarFrame);
|
||||
});
|
||||
|
||||
function scheduleRepositoryScrollbarUpdate() {
|
||||
if (repositoryScrollbarFrame !== undefined) cancelAnimationFrame(repositoryScrollbarFrame);
|
||||
repositoryScrollbarFrame = requestAnimationFrame(() => {
|
||||
repositoryScrollbarFrame = undefined;
|
||||
updateRepositoryScrollbar();
|
||||
});
|
||||
}
|
||||
|
||||
function updateRepositoryScrollbar() {
|
||||
const list = repositoryListElement;
|
||||
const track = repositoryScrollbarElement;
|
||||
if (!list || !track) {
|
||||
repositoryScrollbarVisible = false;
|
||||
return;
|
||||
}
|
||||
const scrollMax = Math.max(0, list.scrollHeight - list.clientHeight);
|
||||
const trackHeight = track.clientHeight;
|
||||
const thumbHeight = scrollMax > 0
|
||||
? Math.max(28, trackHeight * (list.clientHeight / list.scrollHeight))
|
||||
: trackHeight;
|
||||
const thumbTravel = Math.max(0, trackHeight - thumbHeight);
|
||||
repositoryScrollTop = list.scrollTop;
|
||||
repositoryScrollMax = scrollMax;
|
||||
repositoryScrollbarHeight = thumbHeight;
|
||||
repositoryScrollbarTop = scrollMax > 0 ? (list.scrollTop / scrollMax) * thumbTravel : 0;
|
||||
repositoryScrollbarVisible = scrollMax > 1;
|
||||
}
|
||||
|
||||
function startRepositoryScrollbarDrag(event: PointerEvent) {
|
||||
if (!repositoryListElement) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
repositoryScrollbarPointerId = event.pointerId;
|
||||
repositoryScrollbarDragY = event.clientY;
|
||||
repositoryScrollbarDragScrollTop = repositoryListElement.scrollTop;
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function dragRepositoryScrollbar(event: PointerEvent) {
|
||||
if (repositoryScrollbarPointerId !== event.pointerId || !repositoryListElement || !repositoryScrollbarElement) return;
|
||||
const thumbTravel = repositoryScrollbarElement.clientHeight - repositoryScrollbarHeight;
|
||||
if (thumbTravel <= 0) return;
|
||||
repositoryListElement.scrollTop = repositoryScrollbarDragScrollTop
|
||||
+ ((event.clientY - repositoryScrollbarDragY) / thumbTravel) * repositoryScrollMax;
|
||||
}
|
||||
|
||||
function stopRepositoryScrollbarDrag(event: PointerEvent) {
|
||||
if (repositoryScrollbarPointerId !== event.pointerId) return;
|
||||
repositoryScrollbarPointerId = undefined;
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function jumpRepositoryScrollbar(event: MouseEvent) {
|
||||
if (event.target !== event.currentTarget || !repositoryListElement || !repositoryScrollbarElement) return;
|
||||
const track = repositoryScrollbarElement.getBoundingClientRect();
|
||||
const thumbTravel = track.height - repositoryScrollbarHeight;
|
||||
if (thumbTravel <= 0) return;
|
||||
const targetTop = Math.max(0, Math.min(thumbTravel, event.clientY - track.top - repositoryScrollbarHeight / 2));
|
||||
repositoryListElement.scrollTop = (targetTop / thumbTravel) * repositoryScrollMax;
|
||||
}
|
||||
|
||||
function handleRepositoryScrollbarKey(event: KeyboardEvent) {
|
||||
if (!repositoryListElement) return;
|
||||
const page = repositoryListElement.clientHeight * 0.85;
|
||||
const changes: Record<string, number> = {
|
||||
ArrowUp: repositoryListElement.scrollTop - 40,
|
||||
ArrowDown: repositoryListElement.scrollTop + 40,
|
||||
PageUp: repositoryListElement.scrollTop - page,
|
||||
PageDown: repositoryListElement.scrollTop + page,
|
||||
Home: 0,
|
||||
End: repositoryScrollMax,
|
||||
};
|
||||
if (!(event.key in changes)) return;
|
||||
event.preventDefault();
|
||||
repositoryListElement.scrollTop = changes[event.key];
|
||||
}
|
||||
|
||||
function directoryNameFromRemoteUrl(url: string): string {
|
||||
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
|
||||
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
|
||||
@@ -64,112 +201,368 @@
|
||||
if (isBusy) return;
|
||||
browseError = "";
|
||||
try {
|
||||
const selected = await openDialog({
|
||||
title: "Select clone destination",
|
||||
directory: true,
|
||||
multiple: false,
|
||||
defaultPath: parentPath.trim() || undefined,
|
||||
});
|
||||
if (typeof selected !== "string") return;
|
||||
parentPath = selected;
|
||||
} catch (error) {
|
||||
browseError = errorToMessage(error);
|
||||
}
|
||||
const selected = await openDialog({ title: isGerman ? "Zielordner zum Klonen auswählen" : "Select clone destination", directory: true, multiple: false, defaultPath: parentPath.trim() || undefined });
|
||||
if (typeof selected === "string") parentPath = selected;
|
||||
} catch (error) { browseError = errorToMessage(error); }
|
||||
}
|
||||
|
||||
function handleRemoteInput(event: Event) {
|
||||
const nextRemoteUrl = (event.currentTarget as HTMLInputElement).value;
|
||||
function setRemoteUrl(nextRemoteUrl: string) {
|
||||
remoteUrl = nextRemoteUrl;
|
||||
if (directoryNameEdited) return;
|
||||
directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl);
|
||||
directoryName = directoryAutoName;
|
||||
}
|
||||
|
||||
function handleRemoteInput(event: Event) {
|
||||
setRemoteUrl((event.currentTarget as HTMLInputElement).value);
|
||||
selectedRepositoryId = "";
|
||||
}
|
||||
|
||||
function handleDirectoryInput(event: Event) {
|
||||
const nextDirectoryName = (event.currentTarget as HTMLInputElement).value;
|
||||
directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName;
|
||||
}
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim());
|
||||
function selectRepository(repository: GitIntegrationRepository) {
|
||||
selectedRepositoryId = repository.id;
|
||||
setRemoteUrl(repository.cloneUrl);
|
||||
}
|
||||
|
||||
function sortRepositories(repositories: GitIntegrationRepository[]): GitIntegrationRepository[] {
|
||||
return [...repositories].sort((left, right) => left.fullName.localeCompare(
|
||||
right.fullName,
|
||||
isGerman ? "de" : "en",
|
||||
{ numeric: true, sensitivity: "base" },
|
||||
));
|
||||
}
|
||||
|
||||
async function loadRepositories(integrationSource: GitIntegrationSource, force = false) {
|
||||
selectedSourceId = integrationSource.id;
|
||||
selectedRepositoryId = "";
|
||||
repositorySearch = "";
|
||||
repositoryError = "";
|
||||
if (!force && repositoriesBySource[integrationSource.id]) return;
|
||||
const requestId = ++repositoryRequestId;
|
||||
loadingSourceId = integrationSource.id;
|
||||
try {
|
||||
const repositories = await listIntegrationRepositories(integrationSource.provider, integrationSource.baseUrl, integrationSource.accountId);
|
||||
if (requestId === repositoryRequestId) repositoriesBySource = { ...repositoriesBySource, [integrationSource.id]: sortRepositories(repositories) };
|
||||
} catch (error) {
|
||||
if (requestId === repositoryRequestId) repositoryError = errorToMessage(error);
|
||||
} finally {
|
||||
if (requestId === repositoryRequestId) loadingSourceId = "";
|
||||
}
|
||||
}
|
||||
|
||||
function selectIntegrationSource(integrationSource: GitIntegrationSource) {
|
||||
source = "integrations";
|
||||
void loadRepositories(integrationSource);
|
||||
}
|
||||
|
||||
function showUrlInput() {
|
||||
source = "url";
|
||||
selectedRepositoryId = "";
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value: string): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "" : new Intl.DateTimeFormat(isGerman ? "de-DE" : "en-US", { dateStyle: "medium" }).format(date);
|
||||
}
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (canSubmit) onClone(
|
||||
remoteUrl.trim(),
|
||||
parentPath.trim(),
|
||||
directoryName.trim(),
|
||||
{
|
||||
branch: shallowClone && branchToClone.trim() ? branchToClone.trim() : null,
|
||||
blobless: sparseCheckout && bloblessClone,
|
||||
customFlags: shallowClone ? customFlags.trim() : "",
|
||||
shallowDepth: shallowClone && shallowLimitMode === "depth" ? shallowDepth : null,
|
||||
shallowSince: shallowClone && shallowLimitMode === "since" ? shallowSince : null,
|
||||
sparse: sparseCheckout,
|
||||
sparsePaths: sparseCheckout ? sparsePaths : [],
|
||||
},
|
||||
source === "integrations" ? activeSource?.provider : undefined,
|
||||
source === "integrations" ? activeSource?.accountId : undefined,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label="Clone repository" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Repository Management</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Clone repository</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Repository klonen" : "Clone repository"} tabindex="-1">
|
||||
<header class="dialog-header clone-dialog-header">
|
||||
<div><h2>{isGerman ? "Repository klonen" : "Clone a Repository"}</h2></div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Klonen schließen" : "Close clone dialog"}><X size={18} /></button>
|
||||
</header>
|
||||
|
||||
<form class="clone-dialog-form" onsubmit={submit}>
|
||||
<label class="clone-dialog-field">
|
||||
<span>Remote URL</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={remoteUrl}
|
||||
oninput={handleRemoteInput}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="https://github.com/org/project.git"
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
/>
|
||||
</label>
|
||||
<div class="clone-dialog-layout">
|
||||
<aside class="clone-source-nav" aria-label={isGerman ? "Repository-Quellen" : "Repository sources"}>
|
||||
<div class="clone-source-heading">{isGerman ? "Quelle" : "Source"}</div>
|
||||
<div class="clone-source-list" role="tablist" aria-label={isGerman ? "Repository-Quelle" : "Repository source"}>
|
||||
<button type="button" role="tab" aria-selected={source === "url"} class:active={source === "url"} onclick={showUrlInput}><Globe2 size={15} /><span>{isGerman ? "Mit URL klonen" : "Clone with URL"}</span></button>
|
||||
{#each configuredSources as integrationSource}
|
||||
<button type="button" role="tab" aria-selected={source === "integrations" && selectedSourceId === integrationSource.id} class:active={source === "integrations" && selectedSourceId === integrationSource.id} onclick={() => selectIntegrationSource(integrationSource)}>
|
||||
{#if integrationSource.provider === "azure-devops"}<Cloud size={15} />{:else}<GitBranch size={15} />{/if}
|
||||
<span>{integrationSource.provider === "azure-devops" ? `Azure · ${integrationSource.label}` : integrationSource.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if configuredSources.length === 0}<p>{isGerman ? "Integrationen kannst du in den Einstellungen einrichten." : "Set up integrations in Settings."}</p>{/if}
|
||||
</aside>
|
||||
|
||||
<label class="clone-dialog-field">
|
||||
<span>Destination</span>
|
||||
<div class="clone-dialog-path-field">
|
||||
<input
|
||||
bind:value={parentPath}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="Choose parent folder"
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}>
|
||||
<FolderOpen size={14} aria-hidden="true" />
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<section class="clone-dialog-content">
|
||||
<div class="clone-dialog-title">
|
||||
<span>{source === "integrations" ? (activeSource?.label ?? "Integration") : "URL"}</span>
|
||||
<h3>{isGerman ? "Repository klonen" : "Clone a Repo"}</h3>
|
||||
</div>
|
||||
|
||||
<label class="clone-dialog-field">
|
||||
<span>Folder name</span>
|
||||
<input
|
||||
bind:value={directoryName}
|
||||
oninput={handleDirectoryInput}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder={directorySuggestion || "Optional"}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
</label>
|
||||
<div class="clone-target-grid">
|
||||
<label class="clone-dialog-field"><span>{isGerman ? "Klonen nach" : "Where to clone to"}</span><div class="clone-dialog-path-field"><input bind:value={parentPath} autocomplete="off" spellcheck="false" placeholder={isGerman ? "Übergeordneten Ordner auswählen" : "Choose parent folder"} disabled={isBusy} /><button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}><FolderOpen size={14} />{isGerman ? "Durchsuchen" : "Browse"}</button></div></label>
|
||||
<label class="clone-dialog-field"><span>{isGerman ? "Ordnername" : "Folder name"}</span><input bind:value={directoryName} oninput={handleDirectoryInput} autocomplete="off" spellcheck="false" placeholder={directorySuggestion || "Optional"} disabled={isBusy} /></label>
|
||||
</div>
|
||||
|
||||
{#if visibleError}
|
||||
<div class="clone-dialog-error" role="alert">{visibleError}</div>
|
||||
{/if}
|
||||
<section class="clone-options" aria-label={isGerman ? "Clone-Optionen" : "Clone options"}>
|
||||
<div class="clone-option-card" class:expanded={shallowClone}>
|
||||
<label class="clone-option-toggle">
|
||||
<input type="checkbox" bind:checked={shallowClone} disabled={isBusy} />
|
||||
<span><strong>Shallow Clone</strong><small>{isGerman ? "Nur die neuesten Commits laden" : "Download only the latest commits"}</small></span>
|
||||
</label>
|
||||
{#if shallowClone}
|
||||
<div class="clone-option-body">
|
||||
<label class="clone-option-detail"><span>{isGerman ? "Zu klonender Branch" : "Branch to clone"}</span><input bind:value={branchToClone} autocomplete="off" spellcheck="false" placeholder={isGerman ? "Standard-Branch" : "Default branch"} disabled={isBusy} /></label>
|
||||
<fieldset class="clone-history-mode">
|
||||
<legend>{isGerman ? "Historie begrenzen nach" : "Limit history by"}</legend>
|
||||
<label><input type="radio" bind:group={shallowLimitMode} value="depth" disabled={isBusy} />{isGerman ? "Commit-Tiefe" : "Commit depth"}</label>
|
||||
<label><input type="radio" bind:group={shallowLimitMode} value="since" disabled={isBusy} />{isGerman ? "Seit Datum" : "Since date"}</label>
|
||||
</fieldset>
|
||||
{#if shallowLimitMode === "depth"}
|
||||
<label class="clone-option-detail"><span>{isGerman ? "Tiefe" : "Depth"}</span><input type="number" min="1" step="1" bind:value={shallowDepth} disabled={isBusy} aria-invalid={!Number.isInteger(shallowDepth) || shallowDepth < 1} /></label>
|
||||
{:else}
|
||||
<label class="clone-option-detail"><span>{isGerman ? "Seit" : "Since"}</span><input type="date" bind:value={shallowSince} disabled={isBusy} aria-invalid={!shallowSince.trim()} /></label>
|
||||
{/if}
|
||||
<label class="clone-option-detail"><span>{isGerman ? "Zusätzliche Flags" : "Custom flags"}</span><input bind:value={customFlags} autocomplete="off" spellcheck="false" placeholder="--recurse-submodules --single-branch" disabled={isBusy} /></label>
|
||||
<small class="clone-option-help">{isGerman ? "Flags wie in der Git-Kommandozeile; verwaltete oder unsichere Flags werden abgewiesen." : "Enter flags as on the Git command line; managed or unsafe flags are rejected."}</small>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="clone-option-card" class:expanded={sparseCheckout}>
|
||||
<label class="clone-option-toggle">
|
||||
<input type="checkbox" bind:checked={sparseCheckout} disabled={isBusy} />
|
||||
<span><strong>Sparse Checkout</strong><small>{isGerman ? "Nur ausgewählte Pfade auschecken" : "Check out only selected paths"}</small></span>
|
||||
</label>
|
||||
{#if sparseCheckout}
|
||||
<div class="clone-option-body">
|
||||
<label class="clone-blobless-toggle"><input type="checkbox" bind:checked={bloblessClone} disabled={isBusy} /><span><strong>Blobless Clone</strong><small>{isGerman ? "Lädt zunächst Bäume und Commits ohne Dateiinhalte. Blobs werden bei Bedarf nachgeladen." : "Fetch trees and commits without file contents initially. Blobs are downloaded on demand."}</small></span></label>
|
||||
<label class="clone-option-detail clone-sparse-paths">
|
||||
<span>{isGerman ? "Pfade einschließen" : "Paths to include"}</span>
|
||||
<textarea bind:value={sparsePathInput} rows="3" spellcheck="false" placeholder={"src/\ndocs/\nREADME.md"} disabled={isBusy}></textarea>
|
||||
<small class="clone-option-help">{isGerman ? "Ein Pfad pro Zeile. Nur diese Pfade werden im Arbeitsverzeichnis ausgecheckt. Ohne Pfade werden nur Dateien im Repository-Root ausgecheckt." : "Enter one path per line. Only these paths will be checked out in the working directory. With no paths, only files in the repository root are checked out."}</small>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="clone-dialog-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-primary" type="submit" disabled={!canSubmit}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{#if source === "url"}
|
||||
<label class="clone-dialog-field clone-url-field">
|
||||
<span>{isGerman ? "Repository-URL" : "Repository URL"}</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input value={remoteUrl} oninput={handleRemoteInput} autocomplete="off" spellcheck="false" placeholder="https://gitlab.com/org/project.git" disabled={isBusy} autofocus />
|
||||
</label>
|
||||
{:else}
|
||||
<Download size={16} aria-hidden="true" />
|
||||
<section class="integration-browser" aria-label={isGerman ? "Repositories aus Integrationen" : "Repositories from integrations"}>
|
||||
{#if configuredSources.length === 0}
|
||||
<div class="integration-empty"><Cloud size={26} /><strong>{isGerman ? "Keine aktive Integration" : "No active integration"}</strong><p>{isGerman ? "Richte unter Einstellungen → Integrationen zuerst GitHub, GitLab, Azure DevOps oder Gitea ein." : "Set up GitHub, GitLab, Azure DevOps, or Gitea under Settings → Integrations first."}</p></div>
|
||||
{:else}
|
||||
<div class="repository-toolbar">
|
||||
<label><Search size={14} /><input bind:value={repositorySearch} placeholder={isGerman ? "Repositories durchsuchen…" : "Search repositories…"} aria-label={isGerman ? "Repositories durchsuchen" : "Search repositories"} /></label>
|
||||
<button type="button" onclick={() => activeSource && loadRepositories(activeSource, true)} disabled={!activeSource || loadingSourceId.length > 0} title={isGerman ? "Neu laden" : "Refresh"} aria-label={isGerman ? "Repository-Liste neu laden" : "Refresh repository list"}><RefreshCw class={loadingSourceId ? "spin" : ""} size={14} /></button>
|
||||
</div>
|
||||
<div class="repository-list-shell">
|
||||
<div id="integration-repository-list" class="repository-list" bind:this={repositoryListElement} onscroll={updateRepositoryScrollbar} aria-live="polite">
|
||||
{#if loadingSourceId}
|
||||
<div class="repository-state"><LoaderCircle class="spin" size={20} /><span>{isGerman ? "Repositories werden geladen…" : "Loading repositories…"}</span></div>
|
||||
{:else if repositoryError}
|
||||
<div class="repository-state repository-state-error"><strong>{isGerman ? "Repositories konnten nicht geladen werden" : "Could not load repositories"}</strong><span>{repositoryError}</span></div>
|
||||
{:else if filteredRepositories.length === 0}
|
||||
<div class="repository-state"><GitBranch size={20} /><span>{repositorySearch ? (isGerman ? "Keine passenden Repositories." : "No matching repositories.") : (isGerman ? "Keine Repositories gefunden." : "No repositories found.")}</span></div>
|
||||
{:else if activeSource?.provider === "azure-devops"}
|
||||
{#each azureRepositoryGroups as group (group.project)}
|
||||
<section class="repository-project-group" aria-label={group.project}>
|
||||
<div class="repository-project-header"><span>{group.project}</span><em>{group.repositories.length}</em></div>
|
||||
{#each group.repositories as repository (repository.id)}
|
||||
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
|
||||
<span class="repository-option-icon"><GitBranch size={15} /></span>
|
||||
<span class="repository-option-copy"><strong>{repository.name}</strong><small>{repository.description || repository.cloneUrl}</small></span>
|
||||
<span class="repository-option-meta">{#if repository.private}<LockKeyhole size={12} aria-label={isGerman ? "Privat" : "Private"} />{/if}{formatUpdatedAt(repository.updatedAt)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each filteredRepositories as repository (repository.id)}
|
||||
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
|
||||
<span class="repository-option-icon"><GitBranch size={16} /></span>
|
||||
<span class="repository-option-copy"><strong>{repository.fullName}</strong><small>{repository.description || repository.cloneUrl}</small></span>
|
||||
<span class="repository-option-meta">{#if repository.private}<LockKeyhole size={12} aria-label={isGerman ? "Privat" : "Private"} />{/if}{formatUpdatedAt(repository.updatedAt)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="repository-scrollbar"
|
||||
class:visible={repositoryScrollbarVisible}
|
||||
class:dragging={repositoryScrollbarPointerId !== undefined}
|
||||
bind:this={repositoryScrollbarElement}
|
||||
role="scrollbar"
|
||||
tabindex={repositoryScrollbarVisible ? 0 : -1}
|
||||
aria-controls="integration-repository-list"
|
||||
aria-label={isGerman ? "Repository-Liste scrollen" : "Scroll repository list"}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={repositoryScrollMax}
|
||||
aria-valuenow={repositoryScrollTop}
|
||||
onclick={jumpRepositoryScrollbar}
|
||||
onkeydown={handleRepositoryScrollbarKey}
|
||||
>
|
||||
<div
|
||||
class="repository-scrollbar-thumb"
|
||||
role="presentation"
|
||||
style={`height:${repositoryScrollbarHeight}px;transform:translateY(${repositoryScrollbarTop}px)`}
|
||||
onpointerdown={startRepositoryScrollbarDrag}
|
||||
onpointermove={dragRepositoryScrollbar}
|
||||
onpointerup={stopRepositoryScrollbarDrag}
|
||||
onpointercancel={stopRepositoryScrollbarDrag}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedRepository}<div class="selected-repository"><span>{isGerman ? "Ausgewählt" : "Selected"}</span><strong>{selectedRepository.fullName}</strong><code>{selectedRepository.cloneUrl}</code></div>{/if}
|
||||
{/if}
|
||||
Clone
|
||||
</button>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if visibleError}<div class="clone-dialog-error" role="alert">{visibleError}</div>{/if}
|
||||
</section>
|
||||
</div>
|
||||
<div class="clone-dialog-actions"><button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{isGerman ? "Abbrechen" : "Cancel"}</button><button class="btn-primary" type="submit" disabled={!canSubmit}>{#if isBusy}<LoaderCircle class="spin" size={16} />{:else}<Download size={16} />{/if}{isGerman ? "Klonen" : "Clone"}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clone-repository-dialog { width: min(900px, calc(100vw - 32px)); height: min(660px, calc(100vh - 32px)); }
|
||||
.clone-dialog-header { min-height: 52px; padding: 0 16px 0 20px; }
|
||||
.clone-dialog-header h2 { margin: 0; color: var(--color-ink); font-size: 15px; font-weight: 650; }
|
||||
.clone-dialog-form { grid-template-rows: minmax(0, 1fr) auto; gap: 0; height: calc(100% - 53px); padding: 0; }
|
||||
.clone-dialog-layout { display: grid; grid-template-columns: 205px minmax(0, 1fr); min-height: 0; }
|
||||
.clone-source-nav { min-width: 0; padding: 12px 0; border-right: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||
.clone-source-heading { padding: 2px 14px 8px; color: var(--color-ink-faint); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.clone-source-list { display: grid; gap: 2px; }
|
||||
.clone-source-list button { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 8px; width: 100%; min-height: 38px; padding: 0 14px; border: 0; border-radius: 0; color: var(--color-ink-dim); background: transparent; box-shadow: none; font-size: 10.5px; font-weight: 650; text-align: left; }
|
||||
.clone-source-list button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.clone-source-list button :global(svg) { color: var(--color-ink-faint); }
|
||||
.clone-source-list button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.clone-source-list button.active { color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 18%, var(--color-surface-hover)); box-shadow: inset 3px 0 0 var(--color-accent); }
|
||||
.clone-source-list button.active :global(svg) { color: var(--color-accent); }
|
||||
.clone-source-nav > p { margin: 12px 14px 0; color: var(--color-ink-faint); font-size: 9px; line-height: 1.45; }
|
||||
.clone-dialog-content { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); grid-auto-rows: auto; align-content: stretch; gap: 12px; min-width: 0; min-height: 0; padding: 16px 18px; overflow: hidden; }
|
||||
.clone-dialog-title { display: grid; gap: 3px; }
|
||||
.clone-dialog-title > span { color: var(--color-accent); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .07em; }
|
||||
.clone-dialog-title h3 { margin: 0; color: var(--color-ink); font-size: 16px; font-weight: 650; }
|
||||
.clone-url-field { align-self: start; margin-top: 2px; }
|
||||
.clone-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 8px; }
|
||||
.clone-option-card { min-width: 0; border: 1px solid var(--color-border-subtle); border-radius: 6px; background: color-mix(in srgb, var(--color-surface-raised) 70%, transparent); }
|
||||
.clone-option-card.expanded { border-color: color-mix(in srgb, var(--color-accent) 34%, var(--color-border)); }
|
||||
.clone-option-toggle { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8px; min-height: 42px; padding: 6px 9px; cursor: pointer; }
|
||||
.clone-option-toggle > input { width: 14px; height: 14px; margin: 0; accent-color: var(--color-accent); }
|
||||
.clone-option-toggle > span { display: grid; min-width: 0; gap: 2px; }
|
||||
.clone-option-toggle strong { color: var(--color-ink); font-size: 10.5px; }
|
||||
.clone-option-toggle small { overflow: hidden; color: var(--color-ink-faint); font-size: 8.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.clone-option-body { display: grid; gap: 8px; padding: 1px 9px 9px 31px; }
|
||||
.clone-option-detail { display: grid; grid-template-columns: minmax(82px, auto) minmax(64px, 1fr); align-items: center; gap: 8px; color: var(--color-ink-faint); font-size: 8.5px; font-weight: 750; text-transform: uppercase; letter-spacing: .035em; }
|
||||
.clone-option-detail input { height: 28px; min-width: 0; font-size: 10px; }
|
||||
.clone-history-mode { display: flex; flex-wrap: wrap; align-items: center; gap: 7px 12px; min-width: 0; margin: 0; padding: 0; border: 0; color: var(--color-ink-dim); font-size: 9.5px; }
|
||||
.clone-history-mode legend { float: left; min-width: 100%; margin-bottom: 1px; color: var(--color-ink-faint); font-size: 8.5px; font-weight: 750; text-transform: uppercase; letter-spacing: .035em; }
|
||||
.clone-history-mode label, .clone-blobless-toggle { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }
|
||||
.clone-history-mode input, .clone-blobless-toggle > input { width: 13px; height: 13px; margin: 0; accent-color: var(--color-accent); }
|
||||
.clone-option-help { color: var(--color-ink-faint); font-size: 8px; line-height: 1.35; }
|
||||
.clone-blobless-toggle { align-items: start; }
|
||||
.clone-blobless-toggle > span { display: grid; gap: 2px; }
|
||||
.clone-blobless-toggle strong { color: var(--color-ink); font-size: 9.5px; }
|
||||
.clone-blobless-toggle small { color: var(--color-ink-faint); font-size: 8px; }
|
||||
.clone-sparse-paths { grid-template-columns: 1fr; gap: 5px; }
|
||||
.clone-sparse-paths textarea { min-height: 54px; max-height: 72px; resize: vertical; font: 9.5px/1.35 var(--font-mono); }
|
||||
.integration-browser { display: grid; grid-template-rows: auto minmax(0, 1fr); grid-auto-rows: auto; gap: 8px; min-height: 0; }
|
||||
.repository-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) 32px; gap: 6px; }
|
||||
.repository-toolbar label { position: relative; min-width: 0; }
|
||||
.repository-toolbar label > :global(svg) { position: absolute; z-index: 1; top: 10px; left: 10px; color: var(--color-ink-faint); }
|
||||
.repository-toolbar input { height: 34px; padding-left: 31px; font-size: 11px; }
|
||||
.repository-toolbar button { min-height: 32px; padding: 0; }
|
||||
.repository-list-shell { position: relative; min-height: 0; height: 100%; overflow: hidden; border: 1px solid var(--color-border-input); border-radius: 7px; background: var(--color-surface-raised); box-shadow: 0 8px 18px rgba(0,0,0,.13); }
|
||||
.repository-list { min-height: 0; height: 100%; padding-right: 8px; overflow: auto; scrollbar-width: none; border-radius: inherit; background: transparent; }
|
||||
.repository-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||
.repository-scrollbar { position: absolute; z-index: 2; top: 3px; right: 1px; bottom: 3px; width: 7px; border-radius: 4px; opacity: 0; pointer-events: none; touch-action: none; transition: opacity 120ms ease; }
|
||||
.repository-scrollbar.visible { opacity: 1; pointer-events: auto; }
|
||||
.repository-scrollbar-thumb { position: absolute; top: 0; right: 2px; width: 3px; min-height: 28px; border-radius: 3px; background: var(--app-scrollbar-thumb); cursor: pointer; transition: width 100ms ease, background 100ms ease; }
|
||||
.repository-scrollbar:hover .repository-scrollbar-thumb,
|
||||
.repository-scrollbar:focus-visible .repository-scrollbar-thumb,
|
||||
.repository-scrollbar.dragging .repository-scrollbar-thumb { right: 1px; width: 4px; background: var(--app-scrollbar-thumb-hover); }
|
||||
.repository-scrollbar:focus-visible { outline: 1px solid color-mix(in srgb, var(--color-accent) 70%, transparent); outline-offset: 1px; }
|
||||
.repository-project-group + .repository-project-group { border-top: 1px solid var(--color-border-subtle); }
|
||||
.repository-project-header { position: sticky; z-index: 1; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 34px; padding: 7px 10px 6px 12px; color: var(--color-accent); background: color-mix(in srgb, var(--color-surface-raised) 96%, transparent); font-size: 9.5px; font-weight: 900; text-transform: uppercase; letter-spacing: .045em; }
|
||||
.repository-project-header span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.repository-project-header em { display: grid; flex: 0 0 auto; place-items: center; min-width: 19px; height: 16px; padding: 0 5px; color: var(--color-ink); background: color-mix(in srgb, var(--color-ink) 12%, transparent); font-size: 8px; font-style: normal; line-height: 1; letter-spacing: 0; }
|
||||
.repository-option { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 6px 10px 6px 12px; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
||||
.repository-option:last-child { border-bottom: 0; }
|
||||
.repository-option:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.repository-option.selected { color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 15%, var(--color-surface-hover)); box-shadow: inset 3px 0 0 var(--color-accent); }
|
||||
.repository-option-icon { display: grid; place-items: center; width: 22px; height: 22px; color: var(--color-ink-faint); }
|
||||
.repository-option.selected .repository-option-icon { color: var(--color-accent); }
|
||||
.repository-option-copy { display: grid; min-width: 0; gap: 3px; }
|
||||
.repository-option-copy strong, .repository-option-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.repository-option-copy strong { color: inherit; font-size: 10.5px; }
|
||||
.repository-option-copy small { color: var(--color-ink-faint); font-size: 9px; }
|
||||
.repository-project-group .repository-option { min-height: 38px; padding-block: 5px; }
|
||||
.repository-project-group .repository-option-copy { gap: 0; }
|
||||
.repository-project-group .repository-option-copy strong { color: var(--color-ink); font-size: 11.5px; font-weight: 800; }
|
||||
.repository-project-group .repository-option-copy small { display: none; }
|
||||
.repository-option-meta { display: flex; align-items: center; gap: 5px; color: var(--color-ink-faint); font-size: 8.5px; }
|
||||
.repository-state, .integration-empty { display: grid; place-items: center; align-content: center; min-height: 188px; padding: 20px; color: var(--color-ink-faint); text-align: center; }
|
||||
.repository-state { gap: 7px; font-size: 10.5px; }
|
||||
.repository-state strong, .integration-empty strong { color: var(--color-ink); font-size: 11px; }
|
||||
.repository-state-error strong { color: #e86060; }
|
||||
.repository-state-error span { max-width: 520px; line-height: 1.45; }
|
||||
.integration-empty { min-height: 260px; gap: 8px; }
|
||||
.integration-empty :global(svg) { color: var(--color-accent); }
|
||||
.integration-empty p { max-width: 400px; margin: 0; color: var(--color-ink-faint); font-size: 10px; line-height: 1.5; }
|
||||
.selected-repository { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 3px 8px; min-width: 0; padding: 8px 9px; border-left: 2px solid var(--color-accent); background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
|
||||
.selected-repository span { color: var(--color-accent); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.selected-repository strong { overflow: hidden; color: var(--color-ink); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.selected-repository code { grid-column: 2; overflow: hidden; color: var(--color-ink-faint); font: 8.5px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.clone-target-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(150px, .72fr); gap: 10px; }
|
||||
.clone-dialog-actions { min-height: 54px; align-items: center; padding: 9px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||
@media (max-width: 700px) {
|
||||
.clone-repository-dialog { width: min(660px, calc(100vw - 20px)); }
|
||||
.clone-dialog-layout { grid-template-columns: 155px minmax(0, 1fr); }
|
||||
.clone-source-list button { padding-inline: 10px; }
|
||||
.clone-target-grid { grid-template-columns: 1fr; }
|
||||
.repository-option-meta { display: none; }
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.dialog-backdrop { padding: 10px; }
|
||||
.clone-repository-dialog { width: calc(100vw - 20px); height: min(660px, calc(100vh - 20px)); }
|
||||
.clone-dialog-layout { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
||||
.clone-source-nav { padding: 6px 0; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.clone-source-heading, .clone-source-nav > p { display: none; }
|
||||
.clone-source-list { display: flex; width: max-content; min-width: 100%; padding: 0 6px; }
|
||||
.clone-source-list button { width: auto; min-height: 34px; padding-inline: 9px; border-radius: 5px; }
|
||||
.clone-source-list button.active { box-shadow: inset 0 -2px 0 var(--color-accent); }
|
||||
.clone-options { grid-template-columns: 1fr; }
|
||||
.clone-dialog-content { grid-template-rows: auto; grid-auto-rows: auto; align-content: start; padding: 13px 12px; overflow: auto; }
|
||||
.integration-browser { min-height: 260px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
ArrowDownToLine, ArrowUpFromLine, Boxes, Bug, 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" | "bisect" | "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; onOpenBisect: () => void | Promise<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 = () => {}, onOpenBisect = () => {}, 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("bisect", "bisect", "Git Bisect", isGerman ? "Fehlerhaften Commit schrittweise finden" : "Find a bad commit step by step", onOpenBisect),
|
||||
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 === "bisect"}<Bug 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,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Check, LoaderCircle, RotateCcw, Settings, Sparkles } from "@lucide/svelte";
|
||||
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
||||
import { Check, GitCommitHorizontal, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
|
||||
|
||||
interface Props {
|
||||
commitMessage: string;
|
||||
@@ -10,14 +9,16 @@
|
||||
isBusy: boolean;
|
||||
operation: string;
|
||||
stagedCount: number;
|
||||
commitAiProvider: CommitAiProvider;
|
||||
commitAiPhase: CommitAiPhase;
|
||||
commitAiGenerating: boolean;
|
||||
commitAiReviewing: boolean;
|
||||
commitAiSplitting: boolean;
|
||||
canAmend: boolean;
|
||||
amendMode: boolean;
|
||||
onCommit: () => void;
|
||||
onCommitMessageChange: (msg: string) => void;
|
||||
onGenerateCommitMessage: () => void;
|
||||
onReviewStaged: () => void;
|
||||
onSplitStaged: () => void;
|
||||
onOpenAiSettings: () => void;
|
||||
onToggleAmend: (checked: boolean) => void;
|
||||
onUndoLastCommit: () => void;
|
||||
@@ -31,14 +32,16 @@
|
||||
isBusy = false,
|
||||
operation = "",
|
||||
stagedCount = 0,
|
||||
commitAiProvider = "local",
|
||||
commitAiPhase = "idle",
|
||||
commitAiGenerating = false,
|
||||
commitAiReviewing = false,
|
||||
commitAiSplitting = false,
|
||||
canAmend = false,
|
||||
amendMode = false,
|
||||
onCommit = () => {},
|
||||
onCommitMessageChange = () => {},
|
||||
onGenerateCommitMessage = () => {},
|
||||
onReviewStaged = () => {},
|
||||
onSplitStaged = () => {},
|
||||
onOpenAiSettings = () => {},
|
||||
onToggleAmend = () => {},
|
||||
onUndoLastCommit = () => {},
|
||||
@@ -49,37 +52,71 @@
|
||||
onCommit();
|
||||
}
|
||||
|
||||
function aiButtonTitle(provider: CommitAiProvider, phase: CommitAiPhase, staged: number): string {
|
||||
function aiButtonTitle(staged: number): string {
|
||||
if (staged === 0) return "Stage changes first";
|
||||
if (provider === "local" && phase === "loading") return "AI model is downloading/loading — this happens once";
|
||||
if (provider === "local" && phase === "error") return "AI model failed to load — check AI settings";
|
||||
return "Generate commit message with AI from the staged diff";
|
||||
}
|
||||
|
||||
let localModelLoading = $derived(commitAiProvider === "local" && commitAiPhase === "loading");
|
||||
let canGenerate = $derived(
|
||||
hasRepository &&
|
||||
!isBusy &&
|
||||
!commitAiGenerating &&
|
||||
stagedCount > 0 &&
|
||||
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
||||
!commitAiReviewing &&
|
||||
stagedCount > 0,
|
||||
);
|
||||
let canReview = $derived(canGenerate);
|
||||
let canSplit = $derived(canReview && stagedCount > 1 && !commitAiSplitting);
|
||||
</script>
|
||||
|
||||
<section class="panel commit-panel" aria-label="Commit">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Commit</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Message</h2>
|
||||
<span class="eyebrow">Create revision</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commit</h2>
|
||||
</div>
|
||||
<div class="commit-head-actions">
|
||||
<span class="pill pill-count">{stagedCount} staged</span>
|
||||
<button
|
||||
class="commit-review-button"
|
||||
type="button"
|
||||
onclick={onSplitStaged}
|
||||
disabled={!canSplit}
|
||||
title="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={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(stagedCount)}
|
||||
>
|
||||
{#if commitAiGenerating}<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>
|
||||
<span class="pill pill-count">{stagedCount} staged</span>
|
||||
</div>
|
||||
|
||||
<form class="commit-form" onsubmit={handleSubmit}>
|
||||
<textarea
|
||||
value={commitMessage}
|
||||
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder="Commit message..."
|
||||
placeholder="Commit message"
|
||||
disabled={!hasRepository || isBusy}
|
||||
></textarea>
|
||||
{#if commitBlockReason}
|
||||
@@ -117,30 +154,6 @@
|
||||
{/if}
|
||||
{amendMode ? "Amend" : "Commit"}
|
||||
</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>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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";
|
||||
|
||||
type SplitRow =
|
||||
@@ -10,13 +10,22 @@
|
||||
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
|
||||
};
|
||||
|
||||
interface DiffMarker {
|
||||
start: number;
|
||||
end: number;
|
||||
kind: "add" | "delete" | "mixed";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
comparison: GitCommitComparison;
|
||||
selectedDiffPath: string;
|
||||
isBusy: boolean;
|
||||
fromLabel?: string;
|
||||
toLabel?: string;
|
||||
restoreLabel?: string;
|
||||
/** When opened from a search hit, the term to highlight on matching lines. */
|
||||
highlightQuery?: string;
|
||||
language?: "en" | "de";
|
||||
onClose: () => void;
|
||||
onRestore?: () => void;
|
||||
onSelectFile: (file: GitDiffFile) => void;
|
||||
@@ -26,13 +35,18 @@
|
||||
comparison,
|
||||
selectedDiffPath = "",
|
||||
isBusy = false,
|
||||
fromLabel = "",
|
||||
toLabel = "",
|
||||
restoreLabel = "",
|
||||
highlightQuery = "",
|
||||
language = "en",
|
||||
onClose = () => {},
|
||||
onRestore = undefined,
|
||||
onSelectFile = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let isGerman = $derived(language === "de");
|
||||
|
||||
// Needle = first non-empty line of the search query, lowercased for matching.
|
||||
let highlightNeedle = $derived(
|
||||
highlightQuery
|
||||
@@ -173,26 +187,59 @@
|
||||
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 selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
|
||||
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
|
||||
let splitRows = $derived(buildSplitRows(selectedPatch));
|
||||
let diffMarkers = $derived(buildDiffMarkers(splitRows));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
class="dialog-backdrop compare-dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
|
||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Branch or commit comparison" tabindex="-1">
|
||||
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="dialog-range">
|
||||
<span class="hash">{comparison.from_short}</span>
|
||||
<ArrowRight size={14} aria-hidden="true" />
|
||||
<span class="hash">{comparison.to_short}</span>
|
||||
</h2>
|
||||
<header class="compare-dialog-head">
|
||||
<div class="compare-dialog-title">
|
||||
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<h2>{isGerman ? "Änderungen vergleichen" : "Compare changes"}</h2>
|
||||
<p class="dialog-range">
|
||||
<span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
|
||||
<ArrowRight size={14} aria-hidden="true" />
|
||||
<span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
{#if restoreLabel && onRestore}
|
||||
@@ -201,19 +248,23 @@
|
||||
<span>{restoreLabel}</span>
|
||||
</button>
|
||||
{/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" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#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}
|
||||
<div class="dialog-body">
|
||||
|
||||
<!-- 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}`)}
|
||||
<button
|
||||
class="dialog-file-row"
|
||||
@@ -235,9 +286,9 @@
|
||||
<!-- Diff pane -->
|
||||
<div class="dialog-diff">
|
||||
{#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}
|
||||
<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}
|
||||
<!-- Path bar -->
|
||||
<div class="diff-header">
|
||||
@@ -252,16 +303,17 @@
|
||||
<!-- Column headers -->
|
||||
<div class="split-col-headers">
|
||||
<div class="split-col-label">
|
||||
<span>Before</span>
|
||||
<span class="split-col-hash">{comparison.from_short}</span>
|
||||
<span>{isGerman ? "Vorher" : "Before"}</span>
|
||||
<span class="split-col-hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
|
||||
</div>
|
||||
<div class="split-col-label">
|
||||
<span>After</span>
|
||||
<span class="split-col-hash">{comparison.to_short}</span>
|
||||
<span>{isGerman ? "Nachher" : "After"}</span>
|
||||
<span class="split-col-hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Split diff grid -->
|
||||
<div class="split-diff-shell">
|
||||
<div class="split-diff" role="table" aria-label="Side-by-side diff">
|
||||
<div
|
||||
class="split-pane"
|
||||
@@ -298,6 +350,19 @@
|
||||
</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}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, GitCompare, LoaderCircle } from "@lucide/svelte";
|
||||
import type { GitCommit, GitCommitComparison } from "../types";
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
|
||||
interface Props {
|
||||
commits: GitCommit[];
|
||||
@@ -40,6 +41,8 @@
|
||||
event.preventDefault();
|
||||
onCompare();
|
||||
}
|
||||
|
||||
let commitOptions = $derived(commits.map((item) => ({ value: item.hash, label: commitOptionLabel(item), group: "Commits" })));
|
||||
</script>
|
||||
|
||||
<section class="compare-panel panel" aria-label="Compare commits">
|
||||
@@ -61,32 +64,14 @@
|
||||
<form class="compare-form" onsubmit={handleSubmit}>
|
||||
<label class="compare-field">
|
||||
<span>From (older)</span>
|
||||
<select
|
||||
value={compareFrom}
|
||||
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<SelectMenu value={compareFrom} options={commitOptions} placeholder="Select a commit" showSelectedGroup disabled={isBusy} onChange={onCompareFromChange} />
|
||||
</label>
|
||||
|
||||
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
|
||||
|
||||
<label class="compare-field">
|
||||
<span>To (newer)</span>
|
||||
<select
|
||||
value={compareTo}
|
||||
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<SelectMenu value={compareTo} options={commitOptions} placeholder="Select a commit" showSelectedGroup disabled={isBusy} onChange={onCompareToChange} />
|
||||
</label>
|
||||
|
||||
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitCommit } from "../types";
|
||||
import { ArrowRight, GitCompare, Info, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitBranch, GitCommit } from "../types";
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
|
||||
interface Props {
|
||||
commits: GitCommit[];
|
||||
branches: GitBranch[];
|
||||
compareFrom: string;
|
||||
compareTo: string;
|
||||
canCompare: boolean;
|
||||
isBusy: boolean;
|
||||
operation: string;
|
||||
language?: "en" | "de";
|
||||
onCompareFromChange: (val: string) => void;
|
||||
onCompareToChange: (val: string) => void;
|
||||
onCompare: () => void;
|
||||
@@ -17,21 +20,50 @@
|
||||
|
||||
let {
|
||||
commits = [],
|
||||
branches = [],
|
||||
compareFrom = "",
|
||||
compareTo = "",
|
||||
canCompare = false,
|
||||
isBusy = false,
|
||||
operation = "",
|
||||
language = "en",
|
||||
onCompareFromChange = () => {},
|
||||
onCompareToChange = () => {},
|
||||
onCompare = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let isGerman = $derived(language === "de");
|
||||
|
||||
function commitOptionLabel(item: GitCommit): string {
|
||||
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) {
|
||||
event.preventDefault();
|
||||
onCompare();
|
||||
@@ -42,66 +74,66 @@
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Select commits</h2>
|
||||
<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="compare-dialog-head">
|
||||
<div class="compare-dialog-title">
|
||||
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<h2>{isGerman ? "Branches oder Commits vergleichen" : "Compare branches or 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">
|
||||
<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" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if commits.length < 2}
|
||||
<div class="blank-state">At least two commits are needed to compare.</div>
|
||||
{:else}
|
||||
<form class="compare-form" onsubmit={handleSubmit}>
|
||||
<label class="compare-field">
|
||||
<span>From (older)</span>
|
||||
<select
|
||||
value={compareFrom}
|
||||
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<form class="compare-select-shell" onsubmit={handleSubmit}>
|
||||
<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}
|
||||
<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">
|
||||
<span>{isGerman ? "Ausgangsbasis" : "Base"}</span>
|
||||
<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} />
|
||||
</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">
|
||||
<span>To (newer)</span>
|
||||
<select
|
||||
value={compareTo}
|
||||
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="compare-field">
|
||||
<span>{isGerman ? "Vergleichen mit" : "Compare with"}</span>
|
||||
<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} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
||||
{#if operation === "Comparing commits"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{#if compareFrom && compareTo && compareFrom === compareTo}
|
||||
<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}
|
||||
<GitCompare size={16} aria-hidden="true" />
|
||||
<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}
|
||||
Compare
|
||||
</button>
|
||||
</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}
|
||||
{/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" />
|
||||
{:else}
|
||||
<GitCompare size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
{isGerman ? "Vergleich öffnen" : "Open comparison"}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import {
|
||||
AlertCircle,
|
||||
Download,
|
||||
@@ -14,10 +15,12 @@
|
||||
} from "@lucide/svelte";
|
||||
|
||||
interface Props {
|
||||
action: "push" | "pull" | "fetch" | "clone";
|
||||
action: "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
|
||||
error: string;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -25,35 +28,40 @@
|
||||
action,
|
||||
error = "",
|
||||
isBusy = false,
|
||||
initialUsername = "",
|
||||
initialMode = "credentials",
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: Props = $props();
|
||||
|
||||
type Mode = "credentials" | "token";
|
||||
|
||||
let mode = $state<Mode>("credentials");
|
||||
let username = $state("");
|
||||
let mode = $state<Mode>(untrack(() => initialMode));
|
||||
let username = $state(untrack(() => initialUsername === "oauth2" ? "" : initialUsername));
|
||||
let password = $state("");
|
||||
let showPassword = $state(false);
|
||||
let saveSession = $state(true);
|
||||
let expiresAt = $state("");
|
||||
|
||||
let canSubmit = $derived(
|
||||
!isBusy &&
|
||||
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(
|
||||
action === "push"
|
||||
? "Authenticate push"
|
||||
: action === "fetch"
|
||||
? "Authenticate fetch"
|
||||
: action === "clone"
|
||||
? "Authenticate clone"
|
||||
: "Authenticate pull",
|
||||
: action === "rename"
|
||||
? "Authenticate remote rename"
|
||||
: action === "delete"
|
||||
? "Authenticate remote deletion"
|
||||
: action === "fetch"
|
||||
? "Authenticate fetch"
|
||||
: action === "clone"
|
||||
? "Authenticate clone"
|
||||
: "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."
|
||||
: action === "clone"
|
||||
? "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) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
onSubmit(
|
||||
mode === "token" ? "oauth2" : username,
|
||||
password,
|
||||
saveSession,
|
||||
saveSession && expiresAt ? expiresAt : null,
|
||||
);
|
||||
onSubmit(username.trim(), password, saveSession, mode);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -79,7 +82,7 @@
|
||||
<div class="cred-hero">
|
||||
<div class="cred-hero-top">
|
||||
<div class="cred-hero-icon">
|
||||
{#if action === "push"}
|
||||
{#if action === "push" || action === "rename" || action === "delete"}
|
||||
<Upload size={27} aria-hidden="true" />
|
||||
{:else}
|
||||
<Download size={27} aria-hidden="true" />
|
||||
@@ -98,7 +101,7 @@
|
||||
|
||||
<div class="cred-security-note">
|
||||
<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>
|
||||
|
||||
@@ -122,27 +125,25 @@
|
||||
aria-pressed={mode === "token"}
|
||||
>
|
||||
<Key size={13} aria-hidden="true" />
|
||||
Token
|
||||
Access token
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cred-fields">
|
||||
{#if mode === "credentials"}
|
||||
<div class="cred-field">
|
||||
<label class="cred-field-label" for="cred-username">Username</label>
|
||||
<div class="cred-input">
|
||||
<User size={15} class="cred-field-icon" aria-hidden="true" />
|
||||
<input
|
||||
id="cred-username"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
placeholder="e.g. my-github-username"
|
||||
autocomplete="username"
|
||||
disabled={isBusy}
|
||||
/>
|
||||
</div>
|
||||
<div class="cred-field">
|
||||
<label class="cred-field-label" for="cred-username">Username</label>
|
||||
<div class="cred-input">
|
||||
<User size={15} class="cred-field-icon" aria-hidden="true" />
|
||||
<input
|
||||
id="cred-username"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
placeholder="Your account username"
|
||||
autocomplete="username"
|
||||
disabled={isBusy}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="cred-field">
|
||||
<label class="cred-field-label" for="cred-password">
|
||||
@@ -180,7 +181,7 @@
|
||||
{#if mode === "token"}
|
||||
<div class="cred-token-hint">
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
@@ -191,19 +192,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if saveSession}
|
||||
<div class="cred-expiry">
|
||||
<label class="cred-field-label" for="cred-expiry">Expiration date (optional)</label>
|
||||
<input
|
||||
id="cred-expiry"
|
||||
type="date"
|
||||
bind:value={expiresAt}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<span class="cred-expiry-hint">After this date you'll automatically be asked to log in again.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="cred-footer">
|
||||
<label class="cred-save">
|
||||
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
import type { GitFileStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
file: GitFileStatus;
|
||||
staged: boolean;
|
||||
scope: "file" | "hunk";
|
||||
files: GitFileStatus[];
|
||||
staged: boolean | null;
|
||||
scope: "file" | "hunk" | "lines";
|
||||
isBusy: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
file,
|
||||
files,
|
||||
staged = false,
|
||||
scope = "file",
|
||||
isBusy = false,
|
||||
@@ -20,10 +20,16 @@
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let targetPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||
let title = $derived(scope === "hunk" ? "Discard hunk?" : "Discard file changes?");
|
||||
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
||||
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
||||
function targetPath(file: GitFileStatus): string {
|
||||
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -46,11 +52,22 @@
|
||||
|
||||
<div class="discard-confirm-copy">
|
||||
<p>
|
||||
This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below.
|
||||
This will reset the {sourceLabel} for the {scopeLabel} below.
|
||||
</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">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,18 +11,24 @@
|
||||
FileCog,
|
||||
FileImage,
|
||||
FileJson,
|
||||
FileMinus2,
|
||||
FileSearch,
|
||||
GitCompare,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType,
|
||||
FileVideo,
|
||||
FileX,
|
||||
Folder,
|
||||
FolderMinus,
|
||||
FolderOpen,
|
||||
FolderX,
|
||||
ExternalLink,
|
||||
History,
|
||||
Terminal,
|
||||
} from "@lucide/svelte";
|
||||
import { languageIconForPath } from "../languageIcons";
|
||||
import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitRepositoryFile } from "../types";
|
||||
import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitIgnoreKind, GitRepositoryFile } from "../types";
|
||||
import LanguageIcon from "./LanguageIcon.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -32,12 +38,20 @@
|
||||
selectedExplorerKind: ExplorerNodeKind;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
language?: "en" | "de";
|
||||
editorName?: string;
|
||||
diffName?: string;
|
||||
onToggleFolder: (node: ExplorerNode) => void;
|
||||
onExpandAllFolders: () => void;
|
||||
onCollapseAllFolders: () => void;
|
||||
onSelectNode: (node: ExplorerNode) => void;
|
||||
onOpenFile: (node: ExplorerNode) => void;
|
||||
onOpenInEditor: (node: ExplorerNode) => void;
|
||||
onExternalDiff: (node: ExplorerNode) => void;
|
||||
onFileHistory: (node: ExplorerNode) => void;
|
||||
onBlame: (node: ExplorerNode) => void;
|
||||
onIgnore: (target: string, kind: GitIgnoreKind) => void;
|
||||
onStopTracking: (target: string, kind: "file" | "folder") => void;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
}
|
||||
@@ -49,12 +63,20 @@
|
||||
selectedExplorerKind = "file",
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
language = "en",
|
||||
editorName = "Editor",
|
||||
diffName = "diff tool",
|
||||
onToggleFolder = () => {},
|
||||
onExpandAllFolders = () => {},
|
||||
onCollapseAllFolders = () => {},
|
||||
onSelectNode = () => {},
|
||||
onOpenFile = () => {},
|
||||
onOpenInEditor = () => {},
|
||||
onExternalDiff = () => {},
|
||||
onFileHistory = () => {},
|
||||
onBlame = () => {},
|
||||
onIgnore = () => {},
|
||||
onStopTracking = () => {},
|
||||
collapsed = false,
|
||||
onToggleCollapsed = () => {},
|
||||
}: Props = $props();
|
||||
@@ -62,6 +84,8 @@
|
||||
let contextNode = $state<ExplorerNode | null>(null);
|
||||
let contextMenuX = $state(0);
|
||||
let contextMenuY = $state(0);
|
||||
let contextMenuElement = $state<HTMLDivElement | null>(null);
|
||||
const isGerman = $derived(language === "de");
|
||||
|
||||
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
|
||||
if (!next) return current;
|
||||
@@ -168,14 +192,19 @@
|
||||
return "text";
|
||||
}
|
||||
|
||||
function openFileContextMenu(event: MouseEvent, node: ExplorerNode) {
|
||||
if (node.kind !== "file") return;
|
||||
function openNodeContextMenu(event: MouseEvent, node: ExplorerNode) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
contextNode = node;
|
||||
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
|
||||
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 56));
|
||||
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 248));
|
||||
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 320));
|
||||
requestAnimationFrame(() => {
|
||||
if (!contextMenuElement) return;
|
||||
const bounds = contextMenuElement.getBoundingClientRect();
|
||||
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - bounds.width - 8));
|
||||
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - bounds.height - 8));
|
||||
});
|
||||
}
|
||||
|
||||
function closeFileContextMenu() {
|
||||
@@ -189,6 +218,20 @@
|
||||
onOpenFile(node);
|
||||
}
|
||||
|
||||
function openContextFileInEditor() {
|
||||
const node = contextNode;
|
||||
if (!node || node.kind !== "file") return;
|
||||
closeFileContextMenu();
|
||||
onOpenInEditor(node);
|
||||
}
|
||||
|
||||
function openContextExternalDiff() {
|
||||
const node = contextNode;
|
||||
if (!node || node.kind !== "file") return;
|
||||
closeFileContextMenu();
|
||||
onExternalDiff(node);
|
||||
}
|
||||
|
||||
function openContextBlame() {
|
||||
const node = contextNode;
|
||||
if (!node || node.kind !== "file") return;
|
||||
@@ -196,6 +239,38 @@
|
||||
onBlame(node);
|
||||
}
|
||||
|
||||
function openContextFileHistory() {
|
||||
const node = contextNode;
|
||||
if (!node || node.kind !== "file" || !node.tracked) return;
|
||||
closeFileContextMenu();
|
||||
onFileHistory(node);
|
||||
}
|
||||
|
||||
function explorerFileNodes(node: ExplorerNode): ExplorerNode[] {
|
||||
if (node.kind === "file") return [node];
|
||||
return node.children.flatMap(explorerFileNodes);
|
||||
}
|
||||
|
||||
function isIgnoreableExplorerFile(node: ExplorerNode): boolean {
|
||||
return node.kind === "file" && !node.tracked && node.path.replace(/\\/g, "/").toLowerCase() !== ".gitignore";
|
||||
}
|
||||
|
||||
function runContextIgnore(kind: GitIgnoreKind) {
|
||||
const node = contextNode;
|
||||
if (!node) return;
|
||||
if (kind === "folder" && node.kind !== "folder") return;
|
||||
if ((kind === "file" || kind === "extension") && node.kind !== "file") return;
|
||||
closeFileContextMenu();
|
||||
onIgnore(node.path, kind);
|
||||
}
|
||||
|
||||
function runContextStopTracking() {
|
||||
const node = contextNode;
|
||||
if (!node) return;
|
||||
closeFileContextMenu();
|
||||
onStopTracking(node.path, node.kind);
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeFileContextMenu();
|
||||
}
|
||||
@@ -203,6 +278,15 @@
|
||||
let explorerTree = $derived(buildExplorerTree(repoFiles));
|
||||
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
|
||||
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
|
||||
let contextFiles = $derived(contextNode ? explorerFileNodes(contextNode) : []);
|
||||
let contextCanIgnore = $derived(contextFiles.some(isIgnoreableExplorerFile));
|
||||
let contextCanStopTracking = $derived(contextFiles.some((node) => node.tracked));
|
||||
let contextIgnoreExtension = $derived(contextNode?.kind === "file" && contextCanIgnore ? extensionFor(contextNode.path) : "");
|
||||
let selectedFileNode = $derived(
|
||||
selectedExplorerKind === "file"
|
||||
? visibleNodes.find((node) => node.kind === "file" && node.path === selectedExplorerPath) ?? null
|
||||
: null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
|
||||
@@ -214,6 +298,35 @@
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2>
|
||||
</div>
|
||||
<div class="explorer-head-actions">
|
||||
<button
|
||||
class="explorer-bulk-button explorer-tool-action"
|
||||
type="button"
|
||||
onclick={() => selectedFileNode && onOpenInEditor(selectedFileNode)}
|
||||
disabled={isBusy || !selectedFileNode || selectedFileNode.status === "deleted"}
|
||||
title={isGerman
|
||||
? `Ausgewählte Datei in ${editorName} öffnen`
|
||||
: `Open selected file in ${editorName}`}
|
||||
aria-label={isGerman
|
||||
? `Ausgewählte Datei in ${editorName} öffnen`
|
||||
: `Open selected file in ${editorName}`}
|
||||
>
|
||||
<FileCode size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="explorer-bulk-button explorer-tool-action"
|
||||
type="button"
|
||||
onclick={() => selectedFileNode && onExternalDiff(selectedFileNode)}
|
||||
disabled={isBusy || !selectedFileNode || !selectedFileNode.tracked || selectedFileNode.status === "deleted"}
|
||||
title={isGerman
|
||||
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
|
||||
: `Compare selected file with HEAD in ${diffName}`}
|
||||
aria-label={isGerman
|
||||
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
|
||||
: `Compare selected file with HEAD in ${diffName}`}
|
||||
>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<span class="explorer-action-divider" aria-hidden="true"></span>
|
||||
<button
|
||||
class="explorer-bulk-button"
|
||||
type="button"
|
||||
@@ -261,12 +374,14 @@
|
||||
{:else}
|
||||
<div class="explorer-list overflow-auto p-2">
|
||||
{#each visibleNodes as node (`${node.kind}:${node.path}`)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="explorer-row"
|
||||
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
|
||||
class:folder={node.kind === "folder"}
|
||||
style={`--depth: ${node.depth}`}
|
||||
title={node.path}
|
||||
oncontextmenu={(event) => openNodeContextMenu(event, node)}
|
||||
>
|
||||
{#if node.kind === "folder"}
|
||||
<button
|
||||
@@ -328,9 +443,8 @@
|
||||
class="explorer-select"
|
||||
type="button"
|
||||
onclick={() => onSelectNode(node)}
|
||||
oncontextmenu={(event) => openFileContextMenu(event, node)}
|
||||
disabled={isBusy}
|
||||
title={`Show history for ${node.path}`}
|
||||
title={`Select ${node.path}`}
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
</button>
|
||||
@@ -349,31 +463,60 @@
|
||||
|
||||
{#if contextNode}
|
||||
<div
|
||||
bind:this={contextMenuElement}
|
||||
class="explorer-context-menu"
|
||||
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
aria-label={`Actions for ${contextNode.path}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={openContextFile}
|
||||
disabled={contextNode.status === "deleted"}
|
||||
title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
Open in Explorer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={openContextBlame}
|
||||
disabled={!contextNode.tracked || contextNode.status === "deleted"}
|
||||
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
|
||||
>
|
||||
<History size={14} aria-hidden="true" />
|
||||
Blame
|
||||
</button>
|
||||
{#if contextNode.kind === "file"}
|
||||
<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" />
|
||||
{isGerman ? "Dateiverlauf" : "File history"}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextFile} disabled={contextNode.status === "deleted"} title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}>
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
{isGerman ? "Im Explorer öffnen" : "Open in Explorer"}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextBlame} disabled={!contextNode.tracked || contextNode.status === "deleted"} title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}>
|
||||
<FileSearch size={14} aria-hidden="true" />
|
||||
Blame
|
||||
</button>
|
||||
{/if}
|
||||
{#if contextCanStopTracking || contextCanIgnore}
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
{/if}
|
||||
{#if contextCanStopTracking}
|
||||
<button class="untrack" type="button" role="menuitem" onclick={runContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
|
||||
{#if contextNode.kind === "folder"}<FolderMinus size={14} aria-hidden="true" />{:else}<FileMinus2 size={14} aria-hidden="true" />{/if}
|
||||
{isGerman ? `${contextNode.kind === "folder" ? "Ordner" : "Datei"} nicht mehr tracken` : `Stop tracking ${contextNode.kind}`}
|
||||
</button>
|
||||
{/if}
|
||||
{#if contextCanIgnore && contextNode.kind === "file"}
|
||||
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("file")} disabled={isBusy} title={`Add /${contextNode.path.replace(/\\/g, "/")} to .gitignore`}>
|
||||
<FileX size={14} aria-hidden="true" />
|
||||
{isGerman ? "Datei ignorieren" : "Ignore file"}
|
||||
</button>
|
||||
{#if contextIgnoreExtension}
|
||||
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("extension")} disabled={isBusy} title={`Add *.${contextIgnoreExtension} to .gitignore`}>
|
||||
<FileType size={14} aria-hidden="true" />
|
||||
{isGerman ? `Alle *.${contextIgnoreExtension}-Dateien ignorieren` : `Ignore all *.${contextIgnoreExtension} files`}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if contextCanIgnore && contextNode.kind === "folder"}
|
||||
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("folder")} disabled={isBusy} title={`Add /${contextNode.path.replace(/\\/g, "/").replace(/\/+$/, "")}/ to .gitignore`}>
|
||||
<FolderX size={14} aria-hidden="true" />
|
||||
{isGerman ? "Ordner ignorieren" : "Ignore folder"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { AlertCircle, GitCompare, History, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
|
||||
import type { GitCommit } from "../types";
|
||||
|
||||
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
interface Props {
|
||||
fileHistory: GitCommit[];
|
||||
filePath: string;
|
||||
isBusy: boolean;
|
||||
isLoading: boolean;
|
||||
error?: string;
|
||||
onDiff: (commit: GitCommit) => void;
|
||||
onRestore: (commit: GitCommit) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
fileHistory = [],
|
||||
filePath = "",
|
||||
isBusy = false,
|
||||
isLoading = false,
|
||||
error = "",
|
||||
onDiff = () => {},
|
||||
onRestore = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function formatCommitDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return commitDateFormatter.format(date);
|
||||
}
|
||||
|
||||
function fileName(path: string): string {
|
||||
return path.split(/[\\/]/).pop() ?? path;
|
||||
}
|
||||
|
||||
function closeFromBackdrop(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget && !isBusy) onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop app-chrome-backdrop file-history-dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
|
||||
<div class="file-history-dialog" role="dialog" aria-modal="true" aria-labelledby="file-history-dialog-title" tabindex="-1">
|
||||
<header class="file-history-dialog-header">
|
||||
<div class="file-history-dialog-icon" aria-hidden="true"><History size={19} /></div>
|
||||
<div class="file-history-dialog-heading">
|
||||
<span class="eyebrow">File history</span>
|
||||
<h2 id="file-history-dialog-title">{fileName(filePath)}</h2>
|
||||
<span class="file-history-dialog-path" title={filePath}>{filePath}</span>
|
||||
</div>
|
||||
<button class="dialog-icon-button" type="button" onclick={onClose} disabled={isBusy} aria-label="Close file history">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="file-history-dialog-body">
|
||||
{#if isLoading}
|
||||
<div class="file-history-dialog-state" role="status" aria-live="polite">
|
||||
<LoaderCircle class="spin" size={22} aria-hidden="true" />
|
||||
<strong>Loading file history…</strong>
|
||||
<span>Reading commits that changed this file.</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="file-history-dialog-state error" role="alert">
|
||||
<AlertCircle size={22} aria-hidden="true" />
|
||||
<strong>File history could not be loaded.</strong>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{:else if fileHistory.length === 0}
|
||||
<div class="file-history-dialog-state">
|
||||
<History size={22} aria-hidden="true" />
|
||||
<strong>No history found.</strong>
|
||||
<span>This file has no committed changes yet.</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="file-history-dialog-list">
|
||||
{#each fileHistory as item (item.hash)}
|
||||
<article class="file-history-dialog-row">
|
||||
<div class="file-history-dialog-commit">
|
||||
<span class="commit-avatar" aria-hidden="true">{item.author_name.trim().slice(0, 2).toUpperCase() || "?"}</span>
|
||||
<div>
|
||||
<strong title={item.summary}>{item.summary}</strong>
|
||||
<span><code>{item.short_hash}</code> · {item.author_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||
<div class="file-history-dialog-actions">
|
||||
<button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes against the working tree">
|
||||
<GitCompare size={14} aria-hidden="true" /> Diff
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onRestore(item)} disabled={isBusy} title="Restore this file from the selected commit">
|
||||
<RotateCcw size={14} aria-hidden="true" /> Restore
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,172 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ChevronLeft, ChevronRight, GitCompare, History, RotateCcw } from "@lucide/svelte";
|
||||
import iconUrl from "../../../src-tauri/icons/icon.png";
|
||||
import type { GitCommit } from "../types";
|
||||
|
||||
interface Props {
|
||||
fileHistory: GitCommit[];
|
||||
selectedExplorerPath: string;
|
||||
selectedExplorerLabel: string;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
isLoading?: boolean;
|
||||
onDiff: (commit: GitCommit) => void;
|
||||
onRestore: (commit: GitCommit) => void;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
fileHistory = [],
|
||||
selectedExplorerPath = "",
|
||||
selectedExplorerLabel = "File history",
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
isLoading = false,
|
||||
onDiff = () => {},
|
||||
onRestore = () => {},
|
||||
collapsed = false,
|
||||
onToggleCollapsed = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function formatCommitDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
}
|
||||
|
||||
function fileName(path: string): string {
|
||||
return path.split("/").pop() ?? path;
|
||||
}
|
||||
|
||||
function pathTooltip(node: HTMLElement, text: string) {
|
||||
let el: HTMLDivElement | null = null;
|
||||
|
||||
function show(e: MouseEvent) {
|
||||
if (!text || !text.includes("/")) return;
|
||||
el = document.createElement("div");
|
||||
el.className = "path-tooltip";
|
||||
el.textContent = text;
|
||||
document.body.appendChild(el);
|
||||
move(e);
|
||||
}
|
||||
|
||||
function move(e: MouseEvent) {
|
||||
if (!el) return;
|
||||
const tw = el.offsetWidth;
|
||||
const left = Math.max(8, Math.min(e.clientX - tw / 2, window.innerWidth - tw - 8));
|
||||
el.style.left = `${left}px`;
|
||||
el.style.top = `${e.clientY - 44}px`;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
el?.remove();
|
||||
el = null;
|
||||
}
|
||||
|
||||
node.addEventListener("mouseenter", show);
|
||||
node.addEventListener("mousemove", move);
|
||||
node.addEventListener("mouseleave", hide);
|
||||
|
||||
return {
|
||||
update(v: string) { text = v; },
|
||||
destroy() {
|
||||
hide();
|
||||
node.removeEventListener("mouseenter", show);
|
||||
node.removeEventListener("mousemove", move);
|
||||
node.removeEventListener("mouseleave", hide);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel file-history-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Selected file history">
|
||||
{#if collapsed}
|
||||
<button
|
||||
class="file-history-rail-toggle"
|
||||
type="button"
|
||||
onclick={onToggleCollapsed}
|
||||
aria-expanded={!collapsed}
|
||||
title="Expand file history"
|
||||
aria-label="Expand file history panel"
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
<span>File history</span>
|
||||
{#if selectedExplorerPath}
|
||||
<small>{fileHistory.length}</small>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="section-head file-history-head">
|
||||
<div class="file-history-heading">
|
||||
<span class="eyebrow">{selectedExplorerLabel}</span>
|
||||
<h2
|
||||
class="file-history-name"
|
||||
use:pathTooltip={selectedExplorerPath}
|
||||
>{selectedExplorerPath ? fileName(selectedExplorerPath) : "No file"}</h2>
|
||||
</div>
|
||||
<div class="file-history-head-actions">
|
||||
<span class="pill pill-count file-history-count">{fileHistory.length}</span>
|
||||
<button
|
||||
class="file-history-toggle panel-collapse-toggle"
|
||||
type="button"
|
||||
onclick={onToggleCollapsed}
|
||||
aria-expanded={!collapsed}
|
||||
title="Collapse file history"
|
||||
aria-label="Collapse file history panel"
|
||||
>
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
{:else if !selectedExplorerPath}
|
||||
<div class="blank-state">Select a file in Explorer.</div>
|
||||
{:else if isLoading}
|
||||
<div class="file-history-loading" role="status" aria-live="polite">
|
||||
<div class="file-history-loading-mark" aria-hidden="true">
|
||||
<span class="file-history-loading-halo"></span>
|
||||
<svg class="file-history-loading-traces" viewBox="0 0 120 120">
|
||||
<path class="fhl-trace fhl-trace-main" d="M14 82 C38 60, 48 58, 60 60 S84 66, 106 36" />
|
||||
<path class="fhl-trace fhl-trace-branch" d="M28 36 C52 48, 70 70, 91 94" />
|
||||
</svg>
|
||||
<img src={iconUrl} alt="" class="file-history-loading-icon" />
|
||||
</div>
|
||||
<span class="file-history-loading-label">Loading history…</span>
|
||||
<span class="file-history-loading-bar" aria-hidden="true"><span></span></span>
|
||||
</div>
|
||||
{:else if fileHistory.length === 0}
|
||||
<div class="blank-state">No history returned for this selection.</div>
|
||||
{:else}
|
||||
<div class="history-list overflow-auto p-2">
|
||||
{#each fileHistory as item (item.hash)}
|
||||
<article class="commit-row compact file-history-row">
|
||||
<div class="commit-line">
|
||||
<History size={16} aria-hidden="true" />
|
||||
<div class="commit-line-text">
|
||||
<strong title={item.summary}>{item.summary}</strong>
|
||||
<span>{item.short_hash} - {item.author_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="commit-actions file-history-actions">
|
||||
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||
<div class="commit-action-buttons">
|
||||
<button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes vs working tree">
|
||||
<GitCompare size={15} aria-hidden="true" />
|
||||
Diff
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onRestore(item)} disabled={isBusy} title="Restore selected file from this commit">
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,295 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArchiveRestore,
|
||||
Box,
|
||||
Check,
|
||||
CircleDashed,
|
||||
Download,
|
||||
HardDriveDownload,
|
||||
Link2,
|
||||
LoaderCircle,
|
||||
PackageCheck,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
import type { AppLanguage, GitLfsPattern, GitLfsStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
status: GitLfsStatus | null;
|
||||
language: AppLanguage;
|
||||
isLoading: boolean;
|
||||
isBusy: boolean;
|
||||
error?: string;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
onInstall: () => boolean | void | Promise<boolean | void>;
|
||||
onTrack: (pattern: string, lockable: boolean) => boolean | Promise<boolean>;
|
||||
onUntrack: (pattern: string) => void | Promise<void>;
|
||||
onPull: () => void | Promise<void>;
|
||||
onPrune: () => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
status = null,
|
||||
language = "en",
|
||||
isLoading = false,
|
||||
isBusy = false,
|
||||
error = "",
|
||||
onRefresh = () => {},
|
||||
onInstall = () => {},
|
||||
onTrack = () => false,
|
||||
onUntrack = () => {},
|
||||
onPull = () => {},
|
||||
onPrune = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let pattern = $state("");
|
||||
let lockable = $state(false);
|
||||
let isGerman = $derived(language === "de");
|
||||
let setupReady = $derived(Boolean(status?.filters_configured && status?.hook_installed));
|
||||
let downloadedCount = $derived(status?.files.filter((file) => file.downloaded).length ?? 0);
|
||||
let totalSize = $derived(status?.files.reduce((sum, file) => sum + file.size, 0) ?? 0);
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (!value) return "—";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||
const amount = value / 1024 ** index;
|
||||
return `${amount >= 10 || index === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function versionLabel(value: string | null | undefined): string {
|
||||
return value?.match(/git-lfs\/([^\s]+)/)?.[1] ?? value ?? "—";
|
||||
}
|
||||
|
||||
function canRemove(item: GitLfsPattern): boolean {
|
||||
return item.source === ".gitattributes";
|
||||
}
|
||||
|
||||
async function submitPattern(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const value = pattern.trim();
|
||||
if (!value || !setupReady) return;
|
||||
if (await onTrack(value, lockable)) {
|
||||
pattern = "";
|
||||
lockable = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPrune() {
|
||||
const confirmed = window.confirm(isGerman
|
||||
? "Nicht mehr benötigte lokale LFS-Objekte sicher bereinigen? Nicht gepushte und aktuell verwendete Objekte bleiben erhalten."
|
||||
: "Safely prune unused local LFS objects? Unpushed and currently used objects are retained.");
|
||||
if (confirmed) await onPrune();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog lfs-dialog" role="dialog" aria-modal="true" aria-labelledby="lfs-dialog-title">
|
||||
<header class="dialog-header lfs-dialog-header">
|
||||
<div class="lfs-heading">
|
||||
<span class="lfs-mark" aria-hidden="true"><Box size={19} /></span>
|
||||
<div>
|
||||
<span class="eyebrow">Large file storage</span>
|
||||
<p class="dialog-title" id="lfs-dialog-title">Git LFS</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>
|
||||
<RefreshCw class={isLoading ? "spin" : undefined} size={15} aria-hidden="true" />
|
||||
{isGerman ? "Prüfen" : "Check"}
|
||||
</button>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label={isGerman ? "Schließen" : "Close"}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="lfs-main">
|
||||
{#if error}
|
||||
<div class="lfs-error" role="alert"><AlertTriangle size={15} aria-hidden="true" />{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading && !status}
|
||||
<div class="lfs-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>{isGerman ? "Git LFS wird geprüft…" : "Inspecting Git LFS…"}</span></div>
|
||||
{:else if status}
|
||||
<div class="lfs-content">
|
||||
<section class="lfs-diagnostics" aria-label={isGerman ? "LFS-Diagnose" : "LFS diagnostics"}>
|
||||
<article class:ready={status.available} class:error={!status.available}>
|
||||
<span class="diagnostic-icon">{#if status.available}<PackageCheck size={18} />{:else}<AlertTriangle size={18} />{/if}</span>
|
||||
<div><small>01 · {isGerman ? "Erweiterung" : "Extension"}</small><strong>{status.available ? `Git LFS ${versionLabel(status.version)}` : (isGerman ? "Nicht gefunden" : "Not found")}</strong><span>{status.bundled ? (isGerman ? "Mit Gitty gebündelt" : "Bundled with Gitty") : (isGerman ? "Systeminstallation" : "System installation")}</span></div>
|
||||
</article>
|
||||
<span class:ready={status.available} class="diagnostic-link" aria-hidden="true"><Link2 size={14} /></span>
|
||||
<article class:ready={setupReady} class:warning={status.available && !setupReady}>
|
||||
<span class="diagnostic-icon">{#if setupReady}<ShieldCheck size={18} />{:else}<CircleDashed size={18} />{/if}</span>
|
||||
<div><small>02 · Repository</small><strong>{setupReady ? (isGerman ? "Aktiv" : "Active") : (isGerman ? "Einrichtung nötig" : "Setup required")}</strong><span>{status.filters_configured ? "Filter ✓" : "Filter —"} · {status.hook_installed ? "Pre-push ✓" : "Pre-push —"}</span></div>
|
||||
</article>
|
||||
<span class:ready={setupReady} class="diagnostic-link" aria-hidden="true"><Link2 size={14} /></span>
|
||||
<article class:ready={status.files.length > 0}>
|
||||
<span class="diagnostic-icon"><ArchiveRestore size={18} /></span>
|
||||
<div><small>03 · {isGerman ? "Objekte" : "Objects"}</small><strong>{status.files.length} {isGerman ? "Dateien" : "files"}</strong><span>{downloadedCount} {isGerman ? "lokal" : "local"} · {formatBytes(totalSize)}</span></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{#if !status.available}
|
||||
<section class="lfs-missing">
|
||||
<span class="lfs-missing-icon"><AlertTriangle size={22} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<strong>{isGerman ? "Git LFS ist nicht ausführbar" : "Git LFS cannot be started"}</strong>
|
||||
<p>{isGerman ? "Gitty liefert Git LFS normalerweise mit. Installiere Gitty erneut oder installiere git-lfs systemweit und starte die App neu." : "Gitty normally includes Git LFS. Reinstall Gitty or install git-lfs system-wide, then restart the app."}</p>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
{#if !setupReady}
|
||||
<section class="lfs-setup-card">
|
||||
<div><strong>{isGerman ? "Für dieses Repository aktivieren" : "Activate for this repository"}</strong><p>{isGerman ? "Richtet Clean-/Smudge-Filter lokal ein und installiert den Pre-push-Hook. Globale Git-Einstellungen bleiben unverändert." : "Configures local clean/smudge filters and installs the pre-push hook. Global Git settings remain unchanged."}</p></div>
|
||||
<button class="btn-primary" type="button" onclick={onInstall} disabled={isBusy}><ShieldCheck size={15} aria-hidden="true" />{isGerman ? "LFS aktivieren" : "Activate LFS"}</button>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="lfs-grid">
|
||||
<section class="lfs-panel patterns-panel">
|
||||
<header><div><span class="eyebrow">Tracking rules</span><h3>{isGerman ? "Muster" : "Patterns"}</h3></div><span>{status.patterns.length}</span></header>
|
||||
<form class="lfs-track-form" onsubmit={submitPattern}>
|
||||
<label><span>{isGerman ? "Neues Muster" : "New pattern"}</span><input type="text" bind:value={pattern} disabled={isBusy || !setupReady} autocomplete="off" spellcheck="false" placeholder="*.psd, Assets/**, video.mp4" /></label>
|
||||
<label class="lfs-lockable"><input type="checkbox" bind:checked={lockable} disabled={isBusy || !setupReady} /><span><strong>Lockable</strong><small>{isGerman ? "Schreibgeschützt, solange nicht gesperrt" : "Read-only until locked"}</small></span></label>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || !setupReady || !pattern.trim()}><Plus size={15} aria-hidden="true" />{isGerman ? "Hinzufügen" : "Add"}</button>
|
||||
</form>
|
||||
|
||||
<div class="lfs-pattern-list">
|
||||
{#if status.patterns.length === 0}
|
||||
<div class="lfs-empty"><Box size={20} aria-hidden="true" /><span>{isGerman ? "Noch keine Dateien werden über LFS verwaltet." : "No files are tracked through LFS yet."}</span></div>
|
||||
{:else}
|
||||
{#each status.patterns as item (`${item.source}:${item.pattern}`)}
|
||||
<article>
|
||||
<div><code>{item.pattern}</code><span>{item.source}{item.lockable ? " · lockable" : ""}</span></div>
|
||||
<button class="btn-sm danger" type="button" onclick={() => onUntrack(item.pattern)} disabled={isBusy || !canRemove(item)} title={canRemove(item) ? (isGerman ? "Muster entfernen" : "Remove pattern") : (isGerman ? "Nur Muster aus der Wurzel-.gitattributes können hier entfernt werden" : "Only patterns from the root .gitattributes can be removed here") }><Trash2 size={14} aria-hidden="true" /></button>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lfs-panel objects-panel">
|
||||
<header><div><span class="eyebrow">Current checkout</span><h3>{isGerman ? "LFS-Dateien" : "LFS files"}</h3></div><span>{status.files.length}</span></header>
|
||||
<div class="lfs-object-list">
|
||||
{#if status.files.length === 0}
|
||||
<div class="lfs-empty"><HardDriveDownload size={20} aria-hidden="true" /><span>{isGerman ? "Im aktuellen Stand sind keine LFS-Objekte vorhanden." : "The current checkout has no LFS objects."}</span></div>
|
||||
{:else}
|
||||
{#each status.files as file (`${file.oid}:${file.name}`)}
|
||||
<article>
|
||||
<span class:downloaded={file.downloaded} class="object-state" title={file.downloaded ? (isGerman ? "Objekt lokal vorhanden" : "Object available locally") : (isGerman ? "Nur LFS-Zeiger vorhanden" : "LFS pointer only")}>{#if file.downloaded}<Check size={12} />{:else}<Download size={12} />{/if}</span>
|
||||
<div title={file.name}><strong>{file.name}</strong><span>{formatBytes(file.size)} · <code>{file.oid.slice(0, 10)}</code></span></div>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="lfs-footer">
|
||||
<div><ShieldCheck size={14} aria-hidden="true" /><span>{isGerman ? ".gitattributes bleibt als normale Änderung sichtbar und muss committed werden." : ".gitattributes remains a normal change and must be committed."}</span></div>
|
||||
<div>
|
||||
<button class="btn-secondary" type="button" onclick={confirmPrune} disabled={isBusy || !status?.available}><Trash2 size={14} aria-hidden="true" />{isGerman ? "Cache bereinigen" : "Prune cache"}</button>
|
||||
<button class="btn-primary" type="button" onclick={onPull} disabled={isBusy || !setupReady}><HardDriveDownload size={15} aria-hidden="true" />{isGerman ? "Objekte laden" : "Pull objects"}</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.lfs-dialog { --lfs-success: #4eca76; --lfs-warning: #f0b648; --lfs-danger: #e86060; width: min(880px, calc(100vw - 32px)); height: auto; max-height: min(660px, calc(100vh - 32px)); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; border-radius: 12px; }
|
||||
.lfs-dialog-header, .lfs-heading, .dialog-header-actions, .lfs-footer, .lfs-footer > div { display: flex; align-items: center; }
|
||||
.lfs-dialog-header { min-height: 58px; justify-content: space-between; padding: 10px 13px; }
|
||||
.lfs-heading { gap: 9px; }
|
||||
.lfs-mark { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid color-mix(in srgb, var(--color-accent) 34%, var(--color-border)); border-radius: 9px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
|
||||
.dialog-header-actions { gap: 7px; }
|
||||
.lfs-main { min-height: 0; overflow: hidden; }
|
||||
.lfs-error { display: flex; align-items: center; gap: 8px; margin: 10px 13px 0; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--lfs-danger) 38%, var(--color-border)); border-radius: 7px; color: var(--lfs-danger); background: color-mix(in srgb, var(--lfs-danger) 8%, transparent); font-size: 11px; }
|
||||
.lfs-loading { display: grid; min-height: 280px; place-items: center; align-content: center; gap: 10px; color: var(--color-ink-dim); font-size: 12px; }
|
||||
.lfs-content { min-height: 0; max-height: 548px; padding: 12px 13px 13px; overflow: auto; }
|
||||
.lfs-diagnostics { display: grid; grid-template-columns: minmax(0, 1fr) 22px minmax(0, 1fr) 22px minmax(0, 1fr); align-items: center; margin-bottom: 11px; }
|
||||
.lfs-diagnostics article { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 9px; min-height: 64px; padding: 8px 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
||||
.lfs-diagnostics article.ready { border-color: color-mix(in srgb, var(--lfs-success) 34%, var(--color-border)); }
|
||||
.lfs-diagnostics article.warning { border-color: color-mix(in srgb, var(--lfs-warning) 38%, var(--color-border)); }
|
||||
.lfs-diagnostics article.error { border-color: color-mix(in srgb, var(--lfs-danger) 38%, var(--color-border)); }
|
||||
.diagnostic-icon { display: grid; width: 29px; height: 29px; place-items: center; border-radius: 8px; color: var(--color-ink-dim); background: var(--color-surface-hover); }
|
||||
article.ready .diagnostic-icon { color: var(--lfs-success); background: color-mix(in srgb, var(--lfs-success) 10%, transparent); }
|
||||
article.warning .diagnostic-icon { color: var(--lfs-warning); }
|
||||
article.error .diagnostic-icon { color: var(--lfs-danger); }
|
||||
.lfs-diagnostics article div { display: grid; min-width: 0; gap: 2px; }
|
||||
.lfs-diagnostics small { color: var(--color-ink-faint); font-size: 9px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.lfs-diagnostics strong { overflow: hidden; color: var(--color-ink); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lfs-diagnostics article div > span { color: var(--color-ink-dim); font-size: 10px; }
|
||||
.diagnostic-link { display: grid; place-items: center; color: var(--color-border); }
|
||||
.diagnostic-link.ready { color: color-mix(in srgb, var(--lfs-success) 60%, var(--color-border)); }
|
||||
.lfs-missing, .lfs-setup-card { display: flex; align-items: center; gap: 11px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
||||
.lfs-missing-icon { display: grid; flex: 0 0 auto; place-items: center; color: var(--lfs-danger); }
|
||||
.lfs-missing strong, .lfs-setup-card strong { color: var(--color-ink); font-size: 12px; }
|
||||
.lfs-missing p, .lfs-setup-card p { margin: 4px 0 0; color: var(--color-ink-dim); font-size: 10.5px; line-height: 1.5; }
|
||||
.lfs-setup-card { justify-content: space-between; margin-bottom: 11px; border-color: color-mix(in srgb, var(--lfs-warning) 32%, var(--color-border)); }
|
||||
.lfs-setup-card > div { max-width: 650px; }
|
||||
.lfs-setup-card button { flex: 0 0 auto; }
|
||||
.lfs-grid { display: grid; grid-template-columns: minmax(0, .92fr) minmax(0, 1.08fr); gap: 10px; height: clamp(230px, 30vh, 285px); min-height: 0; }
|
||||
.lfs-panel { display: grid; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr); border: 1px solid var(--color-border-subtle); border-radius: 10px; overflow: hidden; background: var(--color-surface-raised); }
|
||||
.objects-panel { grid-template-rows: auto minmax(0, 1fr); }
|
||||
.lfs-panel > header { display: flex; align-items: center; justify-content: space-between; min-height: 48px; padding: 8px 10px; border-bottom: 1px solid var(--color-border-subtle); background: color-mix(in srgb, var(--app-dialog-chrome) 74%, transparent); }
|
||||
.lfs-panel h3 { margin: 2px 0 0; color: var(--color-ink); font-size: 13px; }
|
||||
.lfs-panel > header > span { display: grid; min-width: 25px; height: 22px; place-items: center; border-radius: 11px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); font-size: 10px; font-weight: 800; }
|
||||
.lfs-track-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 7px 9px; padding: 9px 10px; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.lfs-track-form > label:first-child { display: grid; gap: 5px; grid-column: 1 / -1; }
|
||||
.lfs-track-form label > span:first-child { color: var(--color-ink-dim); font-size: 9.5px; font-weight: 700; }
|
||||
.lfs-track-form input[type="text"] { width: 100%; }
|
||||
.lfs-lockable { display: inline-flex; width: fit-content; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
|
||||
.lfs-lockable input[type="checkbox"] { width: 14px; min-width: 14px; height: 14px; margin: 0; padding: 0; border-radius: 3px; accent-color: var(--color-accent); cursor: pointer; box-shadow: none; }
|
||||
.lfs-lockable > span { display: flex; align-items: baseline; gap: 4px; white-space: nowrap; }
|
||||
.lfs-lockable strong { color: var(--color-ink); font-size: 10px; }
|
||||
.lfs-lockable small { color: var(--color-ink-faint); font-size: 8.5px; }
|
||||
.lfs-pattern-list, .lfs-object-list { min-height: 0; overflow: auto; }
|
||||
.lfs-pattern-list article, .lfs-object-list article { display: flex; align-items: center; gap: 9px; min-height: 49px; padding: 8px 10px; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.lfs-pattern-list article:last-child, .lfs-object-list article:last-child { border-bottom: 0; }
|
||||
.lfs-pattern-list article > div, .lfs-object-list article > div { display: grid; min-width: 0; flex: 1; gap: 3px; }
|
||||
.lfs-pattern-list code, .lfs-object-list strong { overflow: hidden; color: var(--color-ink); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lfs-pattern-list article span, .lfs-object-list article span { color: var(--color-ink-faint); font-size: 9px; }
|
||||
.object-state { display: grid; width: 24px; height: 24px; flex: 0 0 auto; place-items: center; border-radius: 50%; color: var(--lfs-warning); background: color-mix(in srgb, var(--lfs-warning) 10%, transparent); }
|
||||
.object-state.downloaded { color: var(--lfs-success); background: color-mix(in srgb, var(--lfs-success) 10%, transparent); }
|
||||
.lfs-empty { display: grid; width: 100%; height: 100%; min-height: 80px; place-items: center; align-content: center; gap: 7px; padding: 14px; color: var(--color-ink-faint); text-align: center; }
|
||||
.lfs-empty span { max-width: 260px; font-size: 10px; line-height: 1.45; }
|
||||
.lfs-footer { min-height: 54px; justify-content: space-between; gap: 14px; padding: 9px 13px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||
.lfs-footer > div { gap: 7px; }
|
||||
.lfs-footer > div:first-child { min-width: 0; color: var(--color-ink-dim); font-size: 9.5px; }
|
||||
.lfs-footer > div:last-child { flex: 0 0 auto; gap: 8px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.dialog-backdrop { padding: 10px; }
|
||||
.lfs-dialog { width: min(620px, 100%); height: min(760px, 100%); max-height: none; }
|
||||
.lfs-content { height: 100%; max-height: none; }
|
||||
.lfs-diagnostics { grid-template-columns: 1fr; gap: 7px; }
|
||||
.diagnostic-link { display: none; }
|
||||
.lfs-grid { grid-template-columns: 1fr; height: auto; }
|
||||
.lfs-panel { min-height: 210px; }
|
||||
.lfs-footer { align-items: flex-start; flex-direction: column; }
|
||||
.lfs-footer > div:last-child { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.lfs-dialog-header { min-height: 54px; }
|
||||
.lfs-mark { width: 31px; height: 31px; }
|
||||
.lfs-track-form { grid-template-columns: 1fr; }
|
||||
.lfs-track-form > label:first-child { grid-column: 1; }
|
||||
.lfs-track-form button { justify-self: end; }
|
||||
.lfs-lockable > span { white-space: normal; }
|
||||
.lfs-footer > div:first-child { display: none; }
|
||||
.lfs-footer > div:last-child { justify-content: stretch; }
|
||||
.lfs-footer button { flex: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
import {
|
||||
CalendarDays,
|
||||
FileCode,
|
||||
@@ -15,6 +16,11 @@
|
||||
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
|
||||
import LanguageIcon from "./LanguageIcon.svelte";
|
||||
|
||||
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
type SearchTab = "code" | "files";
|
||||
|
||||
interface Props {
|
||||
@@ -82,7 +88,7 @@
|
||||
function formatCommitDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
return commitDateFormatter.format(date);
|
||||
}
|
||||
|
||||
function displayPath(hit: GitSearchHit): string {
|
||||
@@ -188,12 +194,7 @@
|
||||
|
||||
<label class="search-limit">
|
||||
<span>Results</span>
|
||||
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}>
|
||||
<option value={100}>100</option>
|
||||
<option value={250}>250</option>
|
||||
<option value={500}>500</option>
|
||||
<option value={1000}>1000</option>
|
||||
</select>
|
||||
<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); }} />
|
||||
</label>
|
||||
|
||||
<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">
|
||||
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
|
||||
import { Cherry, ChevronDown, ChevronRight, CloudOff, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, X } from "@lucide/svelte";
|
||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||
|
||||
interface GraphSegment {
|
||||
@@ -22,52 +22,129 @@
|
||||
graphCommit: GitCommit;
|
||||
}
|
||||
|
||||
type BranchVisibilityMode = "focus" | "local" | "all" | "custom";
|
||||
type CommitRefKind = "local" | "remote" | "head";
|
||||
|
||||
interface CommitBranchDecoration {
|
||||
label: string;
|
||||
kind: CommitRefKind;
|
||||
current: boolean;
|
||||
trackedRemote: string;
|
||||
localOnly: boolean;
|
||||
representedBranches: string[];
|
||||
}
|
||||
|
||||
interface CommitRefSummary {
|
||||
branches: CommitBranchDecoration[];
|
||||
tags: string[];
|
||||
other: string[];
|
||||
primaryBranch: CommitBranchDecoration | null;
|
||||
primaryTag: string;
|
||||
overflowCount: number;
|
||||
}
|
||||
|
||||
const GRAPH_COLORS = [
|
||||
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
|
||||
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
||||
];
|
||||
const GRAPH_LANE = 18;
|
||||
const GRAPH_REF_ARM = 16;
|
||||
const GRAPH_VISIBILITY_STORAGE_PREFIX = "gitlite.graphVisibility.v1:";
|
||||
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
interface Props {
|
||||
commits: GitCommit[];
|
||||
localBranchNames: string[];
|
||||
localBranchUpstreams: Record<string, string>;
|
||||
remoteBranchNames: string[];
|
||||
activeBranch: string;
|
||||
activeUpstream: string;
|
||||
activeAhead: number;
|
||||
activeBehind: number;
|
||||
repositoryKey: string;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
loadMoreError: string;
|
||||
expandedCommitHashes: Set<string>;
|
||||
selectedCommitHash: string;
|
||||
onLoadMore: () => void | Promise<void>;
|
||||
onRestoreCommit: (commit: GitCommit) => void;
|
||||
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||
onToggleCommitFiles: (hash: string) => void;
|
||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||
onCherryPickCommit: (commit: GitCommit) => void;
|
||||
onRevertCommit: (commit: GitCommit) => void;
|
||||
onOpenCommitNote: (commit: GitCommit) => void;
|
||||
onLoadCommitNote: (commit: GitCommit) => Promise<string | null>;
|
||||
onSelectCommit: (commit: GitCommit) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
commits = [],
|
||||
localBranchNames = [],
|
||||
localBranchUpstreams = {},
|
||||
remoteBranchNames = [],
|
||||
activeBranch = "",
|
||||
activeUpstream = "",
|
||||
activeAhead = 0,
|
||||
activeBehind = 0,
|
||||
repositoryKey = "",
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
hasMore = false,
|
||||
isLoadingMore = false,
|
||||
loadMoreError = "",
|
||||
expandedCommitHashes = new Set(),
|
||||
selectedCommitHash = "",
|
||||
onLoadMore = () => {},
|
||||
onRestoreCommit = () => {},
|
||||
onPreviewCommitFile = () => {},
|
||||
onToggleCommitFiles = () => {},
|
||||
onCreateBranchFromCommit = () => {},
|
||||
onCherryPickCommit = () => {},
|
||||
onRevertCommit = () => {},
|
||||
onOpenCommitNote = () => {},
|
||||
onLoadCommitNote = async () => null,
|
||||
onSelectCommit = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
||||
let branchVisibilityMode = $state<BranchVisibilityMode>("focus");
|
||||
let customVisibleBranches = $state<Set<string>>(new Set());
|
||||
let loadedVisibilityRepository = $state("");
|
||||
let branchDialogOpen = $state(false);
|
||||
let userAdjustedBranchFilter = $state(false);
|
||||
let lastDefaultFilterKey = $state("");
|
||||
let localBranchGroupOpen = $state(true);
|
||||
let remoteBranchGroupOpen = $state(false);
|
||||
let expandedRefsCommitHash = $state("");
|
||||
let panelElement = $state<HTMLElement | null>(null);
|
||||
let contextCommit = $state<GitCommit | null>(null);
|
||||
let contextMenuX = $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 {
|
||||
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
|
||||
@@ -115,15 +192,18 @@
|
||||
|
||||
const currentBranches = branchMembership.get(commit.hash) ?? localBranchRefs(commit);
|
||||
const commitBranches = uniqueStrings([...(beforeBranches[col] ?? []), ...currentBranches]);
|
||||
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
|
||||
afterBranches[col] = after[col] ? commitBranches.slice() : [];
|
||||
const firstParent = commit.parents[0] ?? null;
|
||||
after[col] = firstParent;
|
||||
afterBranches[col] = firstParent
|
||||
? (branchMembership.get(firstParent) ?? commitBranches).slice()
|
||||
: [];
|
||||
|
||||
const fromCommit = new Set<number>([col]);
|
||||
for (let p = 1; p < commit.parents.length; p++) {
|
||||
let slot = after.indexOf(null);
|
||||
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
|
||||
after[slot] = commit.parents[p];
|
||||
afterBranches[slot] = [];
|
||||
afterBranches[slot] = (branchMembership.get(commit.parents[p]) ?? commitBranches).slice();
|
||||
fromCommit.add(slot);
|
||||
}
|
||||
|
||||
@@ -198,39 +278,40 @@
|
||||
}
|
||||
|
||||
let localBranchNameSet = $derived(new Set(localBranchNames));
|
||||
let remoteBranchNameSet = $derived(new Set(remoteBranchNames));
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, activeUpstream].filter(Boolean)));
|
||||
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, ...remoteBranchNames, activeUpstream].filter(Boolean)));
|
||||
let graphBranchNameSet = $derived(new Set(graphBranchNames));
|
||||
|
||||
function focusBranchNames(): string[] {
|
||||
const focused = uniqueStrings([activeBranch, activeUpstream].filter((branch) => graphBranchNameSet.has(branch)));
|
||||
if (focused.length > 0) return focused;
|
||||
return localBranchNames[0] ? [localBranchNames[0]] : graphBranchNames.slice(0, 1);
|
||||
}
|
||||
|
||||
function branchNamesForMode(): string[] {
|
||||
if (branchVisibilityMode === "focus") return focusBranchNames();
|
||||
if (branchVisibilityMode === "local") return localBranchNames;
|
||||
if (branchVisibilityMode === "all") return graphBranchNames;
|
||||
return graphBranchNames.filter((branch) => customVisibleBranches.has(branch));
|
||||
}
|
||||
|
||||
let visibleGraphBranchNames = $derived(branchNamesForMode());
|
||||
let visibleGraphBranchNameSet = $derived(new Set(visibleGraphBranchNames));
|
||||
|
||||
function branchIsVisible(branch: string): boolean {
|
||||
if (branch === activeUpstream && activeUpstream) {
|
||||
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
|
||||
}
|
||||
return !hiddenGraphBranches.has(branch);
|
||||
}
|
||||
|
||||
function visibleBranchLabels(labels: string[]): string[] {
|
||||
return labels.filter(branchIsVisible);
|
||||
}
|
||||
|
||||
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
|
||||
const directBranches = graphBranchRefs(commit);
|
||||
if (directBranches.length > 0) return directBranches;
|
||||
|
||||
const containingBranches = row?.branchLabels ?? [];
|
||||
if (containingBranches.length <= 3) return containingBranches;
|
||||
return [...containingBranches.slice(0, 3), `+${containingBranches.length - 3} more`];
|
||||
return visibleGraphBranchNameSet.has(branch);
|
||||
}
|
||||
|
||||
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
||||
const directBranches = graphBranchRefs(commit);
|
||||
const directBranches = graphBranchRefs(commit).filter(branchIsVisible);
|
||||
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
||||
|
||||
const containingBranches = row?.branchLabels ?? [];
|
||||
const containingBranches = (row?.branchLabels ?? []).filter(branchIsVisible);
|
||||
if (containingBranches.length === 0) return commit.short_hash;
|
||||
return `Branches containing this commit: ${containingBranches.join(", ")}`;
|
||||
}
|
||||
@@ -250,7 +331,7 @@
|
||||
|
||||
function branchesAreVisible(branches: string[]): boolean {
|
||||
if (graphBranchNames.length === 0) return true;
|
||||
return branches.some(branchIsVisible);
|
||||
return branches.some((branch) => visibleGraphBranchNameSet.has(branch));
|
||||
}
|
||||
|
||||
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
|
||||
@@ -327,23 +408,30 @@
|
||||
}
|
||||
|
||||
function toggleGraphBranch(branch: string) {
|
||||
const next = new Set(hiddenGraphBranches);
|
||||
const next = branchVisibilityMode === "custom"
|
||||
? new Set(customVisibleBranches)
|
||||
: new Set(visibleGraphBranchNames);
|
||||
if (next.has(branch)) next.delete(branch); else next.add(branch);
|
||||
hiddenGraphBranches = next;
|
||||
userAdjustedBranchFilter = true;
|
||||
customVisibleBranches = next;
|
||||
branchVisibilityMode = "custom";
|
||||
}
|
||||
|
||||
function showAllGraphBranches() {
|
||||
hiddenGraphBranches = new Set();
|
||||
userAdjustedBranchFilter = true;
|
||||
branchVisibilityMode = "all";
|
||||
}
|
||||
|
||||
function hideAllGraphBranches() {
|
||||
hiddenGraphBranches = new Set(localBranchNames);
|
||||
userAdjustedBranchFilter = true;
|
||||
customVisibleBranches = new Set();
|
||||
branchVisibilityMode = "custom";
|
||||
}
|
||||
|
||||
function showFocusGraphBranches() {
|
||||
branchVisibilityMode = "focus";
|
||||
}
|
||||
|
||||
function openBranchDialog() {
|
||||
localBranchGroupOpen = true;
|
||||
remoteBranchGroupOpen = false;
|
||||
branchDialogOpen = true;
|
||||
}
|
||||
|
||||
@@ -357,6 +445,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCommitRefs(commit: GitCommit) {
|
||||
expandedRefsCommitHash = expandedRefsCommitHash === commit.hash ? "" : commit.hash;
|
||||
}
|
||||
|
||||
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -406,9 +498,59 @@
|
||||
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) {
|
||||
if (event.key !== "Escape") return;
|
||||
closeCommitContextMenu();
|
||||
expandedRefsCommitHash = "";
|
||||
handleBranchDialogKeydown(event);
|
||||
}
|
||||
|
||||
@@ -436,66 +578,217 @@
|
||||
return labels;
|
||||
}
|
||||
|
||||
function visibleRefs(commit: GitCommit): string[] {
|
||||
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
|
||||
}
|
||||
|
||||
function refClass(ref: string): string {
|
||||
if (ref.startsWith("HEAD")) return "head";
|
||||
if (ref.startsWith("tag:")) return "tag";
|
||||
if (localBranchNameSet.has(refLabel(ref))) return "branch";
|
||||
if (ref.includes("/")) return "remote";
|
||||
return "branch";
|
||||
}
|
||||
|
||||
function refLabel(ref: string): string {
|
||||
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
|
||||
}
|
||||
|
||||
function isSymbolicRemoteHead(ref: string): boolean {
|
||||
if (ref.startsWith("HEAD ->") || ref.startsWith("tag:")) return false;
|
||||
const label = refLabel(ref);
|
||||
return !localBranchNameSet.has(label) && /(?:^|\/)HEAD(?:\s*->|$)/.test(ref);
|
||||
}
|
||||
|
||||
function configuredUpstreamForBranch(local: string): string {
|
||||
return localBranchUpstreams[local] ?? (local === activeBranch ? activeUpstream : "");
|
||||
}
|
||||
|
||||
function matchingRemoteBranch(local: string, remoteBranches: string[]): string {
|
||||
const configuredUpstream = configuredUpstreamForBranch(local);
|
||||
return configuredUpstream && remoteBranches.includes(configuredUpstream) ? configuredUpstream : "";
|
||||
}
|
||||
|
||||
function compareBranchDecorations(left: CommitBranchDecoration, right: CommitBranchDecoration): number {
|
||||
const priority = (item: CommitBranchDecoration) => {
|
||||
if (item.label === activeBranch) return 0;
|
||||
if (item.kind === "head") return 1;
|
||||
if (item.kind === "local") return 2;
|
||||
if (item.label === activeUpstream) return 3;
|
||||
return 4;
|
||||
};
|
||||
return priority(left) - priority(right) || left.label.localeCompare(right.label, undefined, { numeric: true });
|
||||
}
|
||||
|
||||
function commitRefSummary(commit: GitCommit): CommitRefSummary {
|
||||
const local = new Set<string>();
|
||||
const remote = new Set<string>();
|
||||
const tags = new Set<string>();
|
||||
const other = new Set<string>();
|
||||
let detachedHead = false;
|
||||
|
||||
for (const ref of commit.refs) {
|
||||
if (isSymbolicRemoteHead(ref)) continue;
|
||||
const label = refLabel(ref);
|
||||
if (!label) continue;
|
||||
if (ref.startsWith("tag:")) {
|
||||
tags.add(label);
|
||||
} else if (localBranchNameSet.has(label)) {
|
||||
local.add(label);
|
||||
} else if (remoteBranchNameSet.has(label) || label === activeUpstream) {
|
||||
remote.add(label);
|
||||
} else if (label === "HEAD") {
|
||||
detachedHead = true;
|
||||
} else {
|
||||
other.add(label);
|
||||
}
|
||||
}
|
||||
|
||||
const remainingRemote = [...remote];
|
||||
const branches: CommitBranchDecoration[] = [...local].map((label) => {
|
||||
const configuredUpstream = configuredUpstreamForBranch(label);
|
||||
const trackedRemote = matchingRemoteBranch(label, remainingRemote);
|
||||
if (trackedRemote) remainingRemote.splice(remainingRemote.indexOf(trackedRemote), 1);
|
||||
return {
|
||||
label,
|
||||
kind: "local",
|
||||
current: label === activeBranch,
|
||||
trackedRemote,
|
||||
localOnly: !configuredUpstream,
|
||||
representedBranches: trackedRemote ? [label, trackedRemote] : [label],
|
||||
};
|
||||
});
|
||||
|
||||
if (detachedHead) {
|
||||
branches.push({
|
||||
label: "HEAD",
|
||||
kind: "head",
|
||||
current: true,
|
||||
trackedRemote: "",
|
||||
localOnly: false,
|
||||
representedBranches: [],
|
||||
});
|
||||
}
|
||||
|
||||
branches.push(...remainingRemote.map((label) => ({
|
||||
label,
|
||||
kind: "remote" as const,
|
||||
current: false,
|
||||
trackedRemote: "",
|
||||
localOnly: false,
|
||||
representedBranches: [label],
|
||||
})));
|
||||
|
||||
const sortedBranches = branches.sort(compareBranchDecorations);
|
||||
const primaryBranch = sortedBranches.find(
|
||||
(branch) => branch.kind === "head" || branch.representedBranches.some(branchIsVisible),
|
||||
) ?? null;
|
||||
const sortedTags = [...tags].sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
|
||||
const sortedOther = [...other].sort((left, right) => left.localeCompare(right));
|
||||
const primaryTag = sortedTags[0] ?? "";
|
||||
|
||||
return {
|
||||
branches: sortedBranches,
|
||||
tags: sortedTags,
|
||||
other: sortedOther,
|
||||
primaryBranch,
|
||||
primaryTag,
|
||||
overflowCount:
|
||||
Math.max(0, sortedBranches.length - (primaryBranch ? 1 : 0))
|
||||
+ Math.max(0, sortedTags.length - (primaryTag ? 1 : 0))
|
||||
+ sortedOther.length,
|
||||
};
|
||||
}
|
||||
|
||||
function branchStatusLabel(branch: CommitBranchDecoration): string {
|
||||
if (branch.label === activeBranch) {
|
||||
const parts = [];
|
||||
if (activeAhead > 0) parts.push(`↑${activeAhead}`);
|
||||
if (activeBehind > 0) parts.push(`↓${activeBehind}`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
if (branch.label === activeUpstream && activeBehind > 0) return `↓${activeBehind}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function branchDecorationTitle(branch: CommitBranchDecoration): string {
|
||||
if (branch.localOnly) {
|
||||
const status = branchStatusLabel(branch);
|
||||
return `${branch.label} · Local only — not published yet${status ? ` · ${status}` : ""}`;
|
||||
}
|
||||
const status = branchStatusLabel(branch);
|
||||
if (branch.trackedRemote) {
|
||||
return `${branch.label} · Tracks ${branch.trackedRemote}${status ? ` · ${status}` : ""}`;
|
||||
}
|
||||
if (status) return `${branch.label} · ${status}`;
|
||||
return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`;
|
||||
}
|
||||
|
||||
function formatCommitDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
return commitDateFormatter.format(date);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const available = new Set(localBranchNames);
|
||||
const nextHidden = new Set([...hiddenGraphBranches].filter((branch) => available.has(branch)));
|
||||
if (nextHidden.size !== hiddenGraphBranches.size) {
|
||||
hiddenGraphBranches = nextHidden;
|
||||
const currentRepository = repositoryKey;
|
||||
if (loadedVisibilityRepository === currentRepository) return;
|
||||
loadedVisibilityRepository = currentRepository;
|
||||
expandedRefsCommitHash = "";
|
||||
|
||||
if (!currentRepository) {
|
||||
branchVisibilityMode = "focus";
|
||||
customVisibleBranches = new Set();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`);
|
||||
if (!stored) {
|
||||
branchVisibilityMode = "focus";
|
||||
customVisibleBranches = new Set();
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(stored) as { mode?: unknown; branches?: unknown };
|
||||
const mode = parsed.mode;
|
||||
branchVisibilityMode = mode === "focus" || mode === "local" || mode === "all" || mode === "custom"
|
||||
? mode
|
||||
: "focus";
|
||||
customVisibleBranches = new Set(
|
||||
Array.isArray(parsed.branches)
|
||||
? parsed.branches.filter((branch): branch is string => typeof branch === "string")
|
||||
: [],
|
||||
);
|
||||
} catch {
|
||||
branchVisibilityMode = "focus";
|
||||
customVisibleBranches = new Set();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
|
||||
? activeBranch
|
||||
: (graphBranchNames[0] ?? "");
|
||||
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
|
||||
|
||||
if (!defaultBranch) {
|
||||
if (lastDefaultFilterKey !== defaultFilterKey) {
|
||||
hiddenGraphBranches = new Set();
|
||||
userAdjustedBranchFilter = false;
|
||||
lastDefaultFilterKey = defaultFilterKey;
|
||||
}
|
||||
return;
|
||||
const currentRepository = repositoryKey;
|
||||
const mode = branchVisibilityMode;
|
||||
const branches = [...customVisibleBranches];
|
||||
if (!currentRepository || loadedVisibilityRepository !== currentRepository) return;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`,
|
||||
JSON.stringify({ mode, branches }),
|
||||
);
|
||||
} catch {
|
||||
// The graph still works if storage is unavailable.
|
||||
}
|
||||
});
|
||||
|
||||
if (lastDefaultFilterKey !== defaultFilterKey) {
|
||||
userAdjustedBranchFilter = false;
|
||||
lastDefaultFilterKey = defaultFilterKey;
|
||||
}
|
||||
$effect(() => {
|
||||
if (graphBranchNames.length === 0 || customVisibleBranches.size === 0) return;
|
||||
const available = new Set(graphBranchNames);
|
||||
const next = new Set([...customVisibleBranches].filter((branch) => available.has(branch)));
|
||||
if (next.size !== customVisibleBranches.size) customVisibleBranches = next;
|
||||
});
|
||||
|
||||
if (!userAdjustedBranchFilter) {
|
||||
hiddenGraphBranches = new Set(localBranchNames.filter((branch) => branch !== defaultBranch));
|
||||
}
|
||||
$effect(() => {
|
||||
if (!selectedCommitHash) return;
|
||||
queueMicrotask(() => {
|
||||
panelElement
|
||||
?.querySelector<HTMLElement>(`[data-commit-hash="${CSS.escape(selectedCommitHash)}"]`)
|
||||
?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
});
|
||||
});
|
||||
|
||||
let branchMembership = $derived(branchMembershipByHash(commits));
|
||||
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
|
||||
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
|
||||
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
|
||||
let visibleBranchCount = $derived(localBranchNames.filter(branchIsVisible).length);
|
||||
let visibleBranchCount = $derived(visibleGraphBranchNames.length);
|
||||
let graph = $derived(computeGraph(graphCommits, branchMembership));
|
||||
let graphRows = $derived(graph.rows);
|
||||
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
||||
@@ -509,41 +802,42 @@
|
||||
<span class="eyebrow">History</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
||||
</div>
|
||||
<div class="section-head-actions">
|
||||
{#if localBranchNames.length > 0}
|
||||
{#if graphBranchNames.length > 0}
|
||||
<div class="section-head-actions">
|
||||
<button
|
||||
class="graph-branch-dialog-button"
|
||||
type="button"
|
||||
onclick={openBranchDialog}
|
||||
title="Select branches shown in the graph"
|
||||
title="Customize visible branches"
|
||||
aria-label={`${visibleBranchCount} of ${graphBranchNames.length} branches visible. Customize branches.`}
|
||||
>
|
||||
<GitBranch size={13} aria-hidden="true" />
|
||||
Branches
|
||||
<span>{visibleBranchCount}/{localBranchNames.length}</span>
|
||||
<span>{visibleBranchCount}/{graphBranchNames.length}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<span class="pill pill-count" title={visibleCommits.length === commits.length ? "Commits" : `${visibleCommits.length} of ${commits.length} commits shown`}>
|
||||
{visibleCommits.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
{:else if commits.length === 0}
|
||||
<div class="blank-state">No commits returned.</div>
|
||||
{:else if visibleCommits.length === 0}
|
||||
<div class="blank-state">No commits match the selected branches.</div>
|
||||
{:else}
|
||||
<div class="history-list graph-list overflow-auto">
|
||||
{#if visibleCommits.length === 0}
|
||||
<div class="blank-state">No loaded commits match the selected branches.</div>
|
||||
{/if}
|
||||
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
||||
{@const item = entry.commit}
|
||||
{@const row = graphRows[rowIndex]}
|
||||
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
|
||||
{@const otherRefs = visibleRefs(item)}
|
||||
{@const refSummary = commitRefSummary(item)}
|
||||
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
||||
<article
|
||||
class="commit-row graph-row"
|
||||
class:selected={selectedCommitHash === item.hash}
|
||||
class:has-note={item.has_note}
|
||||
data-commit-hash={item.hash}
|
||||
class:graph-ahead-row={rowSyncClass === "ahead"}
|
||||
class:graph-behind-row={rowSyncClass === "behind"}
|
||||
class:merge-row={item.parents.length > 1}
|
||||
@@ -575,6 +869,15 @@
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/each}
|
||||
{#if refSummary.primaryBranch}
|
||||
<path
|
||||
class="graph-ref-connector"
|
||||
d={`M ${graphColX(row.dotCol)} 50 L ${graphWidth - GRAPH_REF_ARM * 2} 50`}
|
||||
stroke={row.dotColor}
|
||||
stroke-width="1.5"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<span
|
||||
class="graph-dot"
|
||||
@@ -586,25 +889,114 @@
|
||||
title={commitHoverTitle(item, row)}
|
||||
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
||||
></span>
|
||||
{#if hoverBranchRefs.length > 0}
|
||||
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
|
||||
{#each hoverBranchRefs as branch}
|
||||
<span
|
||||
class:remote={branch === activeUpstream}
|
||||
class:ahead={activeBranch === branch && rowSyncClass === "ahead"}
|
||||
class:behind={activeUpstream === branch && rowSyncClass === "behind"}
|
||||
title={branch}
|
||||
>
|
||||
<GitBranch size={10} aria-hidden="true" />
|
||||
{branch}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="commit-body">
|
||||
<div
|
||||
class="commit-body"
|
||||
class:has-branch-ref={Boolean(refSummary.primaryBranch)}
|
||||
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`}
|
||||
>
|
||||
{#if 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">
|
||||
<span class="commit-avatar">
|
||||
{authorInitials(item.author_name)}
|
||||
@@ -622,18 +1014,47 @@
|
||||
<div class="commit-meta-line">
|
||||
<span class="commit-hash">{item.short_hash}</span>
|
||||
<span class="commit-author" title={item.author_email}>{item.author_name}</span>
|
||||
{#if item.has_note}
|
||||
<span class="commit-note-indicator">
|
||||
<button
|
||||
class="commit-note-presence"
|
||||
type="button"
|
||||
onpointerenter={() => void loadCommitNotePreview(item)}
|
||||
onfocus={() => void loadCommitNotePreview(item)}
|
||||
onclick={() => openCommitNote(item)}
|
||||
disabled={isBusy}
|
||||
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}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if otherRefs.length > 0}
|
||||
<div class="ref-list" aria-label="Commit refs">
|
||||
{#each otherRefs as ref}
|
||||
<span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.files.length > 0}
|
||||
<div class="commit-files">
|
||||
<button
|
||||
@@ -672,6 +1093,18 @@
|
||||
<div class="commit-actions">
|
||||
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||
<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
|
||||
class="commit-menu-button"
|
||||
type="button"
|
||||
@@ -689,6 +1122,21 @@
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{#if hasMore || isLoadingMore || loadMoreError}
|
||||
<div class="history-load-more" use:observeHistoryEnd aria-live="polite">
|
||||
{#if isLoadingMore}
|
||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||
<span>Loading older commits…</span>
|
||||
{:else if loadMoreError}
|
||||
<span title={loadMoreError}>Older commits could not be loaded.</span>
|
||||
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>Retry</button>
|
||||
{:else}
|
||||
<button type="button" class="history-load-more-button" onclick={() => { void onLoadMore(); }} disabled={isBusy}>
|
||||
Load older commits
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -704,6 +1152,10 @@
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
Branch
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextCommitNote} disabled={isBusy}>
|
||||
<StickyNote size={14} aria-hidden="true" />
|
||||
Note
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Restore
|
||||
@@ -718,6 +1170,9 @@
|
||||
<Cherry size={14} aria-hidden="true" />
|
||||
Cherry-pick
|
||||
</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>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -741,25 +1196,73 @@
|
||||
</header>
|
||||
|
||||
<div class="branch-filter-summary">
|
||||
<span>{visibleBranchCount} of {localBranchNames.length} branches selected</span>
|
||||
<span>{visibleBranchCount} of {graphBranchNames.length} branches selected</span>
|
||||
<div class="branch-filter-actions">
|
||||
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === localBranchNames.length}>Show all</button>
|
||||
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>Focus</button>
|
||||
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>Show all</button>
|
||||
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="branch-filter-dialog-list">
|
||||
{#each localBranchNames as branch}
|
||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branchIsVisible(branch)}
|
||||
onchange={() => toggleGraphBranch(branch)}
|
||||
/>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<span>{branch}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{#if localBranchNames.length > 0}
|
||||
<section class="branch-filter-group">
|
||||
<button
|
||||
class="branch-filter-group-toggle"
|
||||
type="button"
|
||||
aria-expanded={localBranchGroupOpen}
|
||||
onclick={() => { localBranchGroupOpen = !localBranchGroupOpen; }}
|
||||
>
|
||||
{#if localBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
|
||||
<span>Local</span>
|
||||
<em>{localBranchNames.filter(branchIsVisible).length}/{localBranchNames.length}</em>
|
||||
</button>
|
||||
{#if localBranchGroupOpen}
|
||||
<div class="branch-filter-group-list">
|
||||
{#each localBranchNames as branch}
|
||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branchIsVisible(branch)}
|
||||
onchange={() => toggleGraphBranch(branch)}
|
||||
/>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<span>{branch}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{#if remoteBranchNames.length > 0}
|
||||
<section class="branch-filter-group">
|
||||
<button
|
||||
class="branch-filter-group-toggle"
|
||||
type="button"
|
||||
aria-expanded={remoteBranchGroupOpen}
|
||||
onclick={() => { remoteBranchGroupOpen = !remoteBranchGroupOpen; }}
|
||||
>
|
||||
{#if remoteBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
|
||||
<span>Remote</span>
|
||||
<em>{remoteBranchNames.filter(branchIsVisible).length}/{remoteBranchNames.length}</em>
|
||||
</button>
|
||||
{#if remoteBranchGroupOpen}
|
||||
<div class="branch-filter-group-list">
|
||||
{#each remoteBranchNames as branch}
|
||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branchIsVisible(branch)}
|
||||
onchange={() => toggleGraphBranch(branch)}
|
||||
/>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<span>{branch}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { Folder, FolderOpen, GitBranch, LoaderCircle, Plus, X } from "@lucide/svelte";
|
||||
import type { AppLanguage } from "../types";
|
||||
|
||||
interface Props {
|
||||
isBusy: boolean;
|
||||
language: AppLanguage;
|
||||
onInit: (path: string, branch: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isBusy = false,
|
||||
language = "en",
|
||||
onInit = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let path = $state("");
|
||||
let branch = $state("main");
|
||||
let browseError = $state("");
|
||||
const isGerman = $derived(language === "de");
|
||||
const pathPlaceholder = navigator.userAgent.includes("Windows")
|
||||
? "C:\\Projects\\my-repository"
|
||||
: "/home/user/projects/my-repository";
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const targetPath = path.trim();
|
||||
const branchName = branch.trim();
|
||||
if (!targetPath || !branchName || isBusy) return;
|
||||
onInit(targetPath, branchName);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && !isBusy) onClose();
|
||||
}
|
||||
|
||||
async function chooseFolder() {
|
||||
if (isBusy) return;
|
||||
browseError = "";
|
||||
try {
|
||||
const selected = await openDialog({
|
||||
title: isGerman ? "Ordner für das neue Repository auswählen" : "Select folder for the new repository",
|
||||
directory: true,
|
||||
multiple: false,
|
||||
defaultPath: path.trim() || undefined,
|
||||
});
|
||||
if (typeof selected === "string") path = selected;
|
||||
} catch (error) {
|
||||
browseError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||
<div
|
||||
class="dialog init-repository-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="init-repository-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header class="dialog-header init-repository-header">
|
||||
<div class="init-repository-heading">
|
||||
<span class="init-repository-icon" aria-hidden="true"><Plus size={18} /></span>
|
||||
<div>
|
||||
<span class="eyebrow">{isGerman ? "Neues Repository" : "New repository"}</span>
|
||||
<h2 id="init-repository-title">{isGerman ? "Repository initialisieren" : "Initialize repository"}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="init-repository-form" onsubmit={submit}>
|
||||
<p class="init-repository-description">
|
||||
{isGerman
|
||||
? "GitLite richtet in diesem Ordner ein neues Git-Repository ein. Vorhandene Dateien bleiben unverändert."
|
||||
: "GitLite will create a new Git repository in this folder. Existing files will remain unchanged."}
|
||||
</p>
|
||||
|
||||
<label class="new-branch-field init-repository-field init-repository-path-field">
|
||||
<span>{isGerman ? "Zielordner" : "Repository folder"}</span>
|
||||
<div class="init-repository-path-control">
|
||||
<Folder size={16} aria-hidden="true" />
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={path}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder={pathPlaceholder}
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
aria-describedby="init-repository-path-hint"
|
||||
/>
|
||||
<button class="btn-secondary init-repository-browse" type="button" onclick={chooseFolder} disabled={isBusy}>
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
{isGerman ? "Auswählen…" : "Browse…"}
|
||||
</button>
|
||||
</div>
|
||||
<small id="init-repository-path-hint">
|
||||
{isGerman ? "Der Ordner wird angelegt, falls er noch nicht existiert." : "The folder will be created if it does not exist."}
|
||||
</small>
|
||||
{#if browseError}<small class="init-repository-error" role="alert">{browseError}</small>{/if}
|
||||
</label>
|
||||
|
||||
<label class="new-branch-field init-repository-field">
|
||||
<span>{isGerman ? "Name des ersten Branches" : "Initial branch name"}</span>
|
||||
<div>
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
<input
|
||||
bind:value={branch}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="main"
|
||||
disabled={isBusy}
|
||||
aria-describedby="init-repository-branch-hint"
|
||||
/>
|
||||
</div>
|
||||
<small id="init-repository-branch-hint">
|
||||
{isGerman ? "Du kannst den Branch später jederzeit umbenennen." : "You can rename the branch at any time."}
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<div class="new-branch-actions init-repository-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||
{isGerman ? "Abbrechen" : "Cancel"}
|
||||
</button>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || path.trim().length === 0 || branch.trim().length === 0}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
{isGerman ? "Repository erstellen" : "Create repository"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,290 @@
|
||||
<script lang="ts">
|
||||
import { Building2, CheckCircle2, CircleDashed, Eye, EyeOff, KeyRound, Plus, Server, Trash2 } from "@lucide/svelte";
|
||||
import { siGitea, siGithub, siGitlab, type SimpleIcon } from "simple-icons";
|
||||
import { gitIntegrationProviders, organizationNameFromUrl, providerLabel } from "../integrations";
|
||||
import type { AppLanguage, AzureDevOpsOrganization, GitIntegrationConfig, GitIntegrationProvider, GitIntegrationSecretUpdate, GitIntegrationSettings } from "../types";
|
||||
|
||||
interface Props {
|
||||
language: AppLanguage;
|
||||
settings: GitIntegrationSettings;
|
||||
onChange: (settings: GitIntegrationSettings) => void;
|
||||
onSecretsChange: (updates: GitIntegrationSecretUpdate[]) => void;
|
||||
}
|
||||
|
||||
let { language, settings, onChange, onSecretsChange }: Props = $props();
|
||||
let selected = $state<GitIntegrationProvider>("github");
|
||||
let selectedAzureOrganizationId = $state("");
|
||||
let tokenValues = $state<Record<string, string>>({});
|
||||
let secretUpdates = $state<Record<string, GitIntegrationSecretUpdate>>({});
|
||||
let showToken = $state(false);
|
||||
|
||||
const isGerman = $derived(language === "de");
|
||||
const selectedAzureOrganization = $derived(settings.azureDevOpsOrganizations.find((organization) => organization.id === selectedAzureOrganizationId));
|
||||
const current = $derived<GitIntegrationConfig | AzureDevOpsOrganization | undefined>(selected === "azure-devops" ? selectedAzureOrganization : settings.providers[selected]);
|
||||
const currentAccountId = $derived(selected === "azure-devops" ? selectedAzureOrganization?.id : undefined);
|
||||
|
||||
const azureDevOpsIcon: SimpleIcon = {
|
||||
title: "Azure DevOps", slug: "azuredevops", hex: "0078D4", source: "https://azure.microsoft.com/products/devops", svg: "",
|
||||
path: "M0 8.877 2.247 5.91l8.405-3.416v19.127l-8.405-3.53L0 15.123V8.877Zm12.154-6.968 11.846 2.423v15.336l-11.846 2.423V1.909Z",
|
||||
};
|
||||
const providerIcons: Record<GitIntegrationProvider, SimpleIcon> = { github: siGithub, gitlab: siGitlab, "gitlab-self-hosted": siGitlab, "azure-devops": azureDevOpsIcon, gitea: siGitea };
|
||||
|
||||
function providerDescription(provider: GitIntegrationProvider): string {
|
||||
const descriptions = isGerman
|
||||
? { github: "Konto auf github.com", gitlab: "Cloud-Konto auf gitlab.com", "gitlab-self-hosted": "Eigene GitLab-Instanz", "azure-devops": "Mehrere Organisationen", gitea: "Cloud- oder eigene Instanz" }
|
||||
: { github: "Cloud account on github.com", gitlab: "Cloud account on gitlab.com", "gitlab-self-hosted": "Your own GitLab instance", "azure-devops": "Multiple organizations", gitea: "Cloud or self-hosted instance" };
|
||||
return descriptions[provider];
|
||||
}
|
||||
|
||||
function providerColor(provider: GitIntegrationProvider): string {
|
||||
return provider === "github" ? "var(--color-ink)" : `#${providerIcons[provider].hex}`;
|
||||
}
|
||||
|
||||
function secretId(provider: GitIntegrationProvider, accountId?: string): string {
|
||||
return accountId ? `${provider}:${accountId}` : provider;
|
||||
}
|
||||
|
||||
function emitSecrets() {
|
||||
onSecretsChange(Object.values(secretUpdates));
|
||||
}
|
||||
|
||||
function updateCurrent(patch: Partial<GitIntegrationConfig & AzureDevOpsOrganization>) {
|
||||
if (!current) return;
|
||||
if (selected === "azure-devops" && selectedAzureOrganization) {
|
||||
onChange({
|
||||
...settings,
|
||||
azureDevOpsOrganizations: settings.azureDevOpsOrganizations.map((organization) => organization.id === selectedAzureOrganization.id ? { ...organization, ...patch } : organization),
|
||||
});
|
||||
return;
|
||||
}
|
||||
onChange({ ...settings, providers: { ...settings.providers, [selected]: { ...settings.providers[selected], ...patch } } });
|
||||
}
|
||||
|
||||
function setToken(value: string) {
|
||||
if (!current) return;
|
||||
const id = secretId(selected, currentAccountId);
|
||||
tokenValues[id] = value;
|
||||
if (value.trim()) secretUpdates[id] = { provider: selected, accountId: currentAccountId, token: value };
|
||||
else delete secretUpdates[id];
|
||||
tokenValues = { ...tokenValues };
|
||||
secretUpdates = { ...secretUpdates };
|
||||
emitSecrets();
|
||||
}
|
||||
|
||||
function forgetToken() {
|
||||
if (!current) return;
|
||||
const id = secretId(selected, currentAccountId);
|
||||
tokenValues[id] = "";
|
||||
secretUpdates[id] = { provider: selected, accountId: currentAccountId, removeToken: true };
|
||||
tokenValues = { ...tokenValues };
|
||||
secretUpdates = { ...secretUpdates };
|
||||
updateCurrent({ enabled: false, tokenStored: false });
|
||||
emitSecrets();
|
||||
}
|
||||
|
||||
function pendingRemoval(provider: GitIntegrationProvider, accountId?: string): boolean {
|
||||
return secretUpdates[secretId(provider, accountId)]?.removeToken === true;
|
||||
}
|
||||
|
||||
function tokenValue(): string {
|
||||
return tokenValues[secretId(selected, currentAccountId)] ?? "";
|
||||
}
|
||||
|
||||
function selectProvider(provider: GitIntegrationProvider) {
|
||||
selected = provider;
|
||||
showToken = false;
|
||||
if (provider === "azure-devops" && !settings.azureDevOpsOrganizations.some((organization) => organization.id === selectedAzureOrganizationId)) {
|
||||
selectedAzureOrganizationId = settings.azureDevOpsOrganizations[0]?.id ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
function createOrganizationId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
||||
return `org-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function addAzureOrganization() {
|
||||
const id = createOrganizationId();
|
||||
const number = settings.azureDevOpsOrganizations.length + 1;
|
||||
const organization: AzureDevOpsOrganization = {
|
||||
id,
|
||||
name: isGerman ? `Organisation ${number}` : `Organization ${number}`,
|
||||
enabled: true,
|
||||
baseUrl: "https://dev.azure.com/",
|
||||
username: "",
|
||||
tokenStored: false,
|
||||
};
|
||||
onChange({ ...settings, azureDevOpsOrganizations: [...settings.azureDevOpsOrganizations, organization] });
|
||||
selectedAzureOrganizationId = id;
|
||||
showToken = false;
|
||||
}
|
||||
|
||||
function removeAzureOrganization(organization: AzureDevOpsOrganization) {
|
||||
const id = secretId("azure-devops", organization.id);
|
||||
if (organization.tokenStored) secretUpdates[id] = { provider: "azure-devops", accountId: organization.id, removeToken: true };
|
||||
else delete secretUpdates[id];
|
||||
delete tokenValues[id];
|
||||
secretUpdates = { ...secretUpdates };
|
||||
tokenValues = { ...tokenValues };
|
||||
const remaining = settings.azureDevOpsOrganizations.filter((candidate) => candidate.id !== organization.id);
|
||||
onChange({ ...settings, azureDevOpsOrganizations: remaining });
|
||||
selectedAzureOrganizationId = remaining[0]?.id ?? "";
|
||||
showToken = false;
|
||||
emitSecrets();
|
||||
}
|
||||
|
||||
function organizationDisplayName(organization: AzureDevOpsOrganization): string {
|
||||
return organization.name.trim() || organizationNameFromUrl(organization.baseUrl) || (isGerman ? "Unbenannte Organisation" : "Unnamed organization");
|
||||
}
|
||||
|
||||
function isOrganizationConfigured(organization: AzureDevOpsOrganization): boolean {
|
||||
return organization.tokenStored && organization.baseUrl.trim().length > 0 && !pendingRemoval("azure-devops", organization.id);
|
||||
}
|
||||
|
||||
function isConfigured(provider: GitIntegrationProvider): boolean {
|
||||
if (provider === "azure-devops") return settings.azureDevOpsOrganizations.some(isOrganizationConfigured);
|
||||
const config = settings.providers[provider];
|
||||
return config.tokenStored && config.baseUrl.trim().length > 0 && !pendingRemoval(provider);
|
||||
}
|
||||
|
||||
function currentConfigured(): boolean {
|
||||
if (!current) return false;
|
||||
return current.tokenStored && current.baseUrl.trim().length > 0 && !pendingRemoval(selected, currentAccountId);
|
||||
}
|
||||
|
||||
function baseUrlPlaceholder(): string {
|
||||
if (selected === "azure-devops") return "https://dev.azure.com/meine-organisation";
|
||||
if (selected === "github") return "https://github.com";
|
||||
if (selected === "gitlab") return "https://gitlab.com";
|
||||
if (selected === "gitlab-self-hosted") return "https://gitlab.example.com";
|
||||
return "https://gitea.example.com";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="integration-layout">
|
||||
<div class="integration-providers" role="tablist" aria-label={isGerman ? "Git-Anbieter" : "Git providers"}>
|
||||
{#each gitIntegrationProviders as provider}
|
||||
<button type="button" role="tab" aria-selected={selected === provider} class:active={selected === provider} onclick={() => selectProvider(provider)}>
|
||||
<span class="provider-logo" style={`--provider-color:${providerColor(provider)}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path d={providerIcons[provider].path} /></svg></span>
|
||||
<span class="provider-copy"><strong>{providerLabel(provider)}</strong><small>{providerDescription(provider)}</small></span>
|
||||
<span class="provider-state" class:configured={isConfigured(provider)} title={isConfigured(provider) ? (isGerman ? "Konfiguriert" : "Configured") : (isGerman ? "Nicht verbunden" : "Not connected")}></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<section class="integration-config" aria-label={`${providerLabel(selected)} ${isGerman ? "konfigurieren" : "configuration"}`}>
|
||||
<header class="integration-summary">
|
||||
<span class="provider-logo provider-logo-large" style={`--provider-color:${providerColor(selected)}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path d={providerIcons[selected].path} /></svg></span>
|
||||
<div><h4>{providerLabel(selected)}</h4><p>{providerDescription(selected)}</p></div>
|
||||
{#if selected === "azure-devops"}
|
||||
<span class="integration-status" class:configured={isConfigured(selected)}><Building2 size={13} />{settings.azureDevOpsOrganizations.length} {isGerman ? "Orgas" : "orgs"}</span>
|
||||
{:else}
|
||||
<span class="integration-status" class:configured={currentConfigured()}>{#if currentConfigured()}<CheckCircle2 size={13} />{:else}<CircleDashed size={13} />{/if}{currentConfigured() ? (isGerman ? "Konfiguriert" : "Configured") : (isGerman ? "Nicht verbunden" : "Not connected")}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if selected === "azure-devops"}
|
||||
<div class="azure-organizations">
|
||||
<div class="azure-organizations-head"><div><strong>{isGerman ? "Organisationen" : "Organizations"}</strong><small>{isGerman ? "Jede Organisation verwendet einen eigenen Token." : "Each organization uses its own token."}</small></div><button type="button" onclick={addAzureOrganization}><Plus size={14} />{isGerman ? "Hinzufügen" : "Add"}</button></div>
|
||||
{#if settings.azureDevOpsOrganizations.length === 0}
|
||||
<div class="azure-organizations-empty"><Building2 size={22} /><span>{isGerman ? "Noch keine Azure-DevOps-Organisation angelegt." : "No Azure DevOps organization has been added yet."}</span><button type="button" onclick={addAzureOrganization}><Plus size={14} />{isGerman ? "Erste Organisation anlegen" : "Add first organization"}</button></div>
|
||||
{:else}
|
||||
<div class="azure-organization-list" role="tablist" aria-label={isGerman ? "Azure-DevOps-Organisationen" : "Azure DevOps organizations"}>
|
||||
{#each settings.azureDevOpsOrganizations as organization (organization.id)}
|
||||
<div class="azure-organization-row" class:active={selectedAzureOrganizationId === organization.id}>
|
||||
<button type="button" role="tab" aria-selected={selectedAzureOrganizationId === organization.id} onclick={() => { selectedAzureOrganizationId = organization.id; showToken = false; }}>
|
||||
<span><strong>{organizationDisplayName(organization)}</strong><small>{organization.baseUrl}</small></span><i class:configured={isOrganizationConfigured(organization)}></i>
|
||||
</button>
|
||||
<button class="azure-remove" type="button" onclick={() => removeAzureOrganization(organization)} title={isGerman ? "Organisation entfernen" : "Remove organization"} aria-label={`${organizationDisplayName(organization)} ${isGerman ? "entfernen" : "remove"}`}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if current}
|
||||
{#if selected === "azure-devops"}
|
||||
<label class="integration-field"><span><Building2 size={13} />{isGerman ? "Anzeigename" : "Display name"}</span><input value={selectedAzureOrganization?.name ?? ""} oninput={(event) => updateCurrent({ name: event.currentTarget.value })} placeholder={isGerman ? "z. B. Contoso Platform" : "e.g. Contoso Platform"} /></label>
|
||||
{/if}
|
||||
|
||||
<label class="integration-field">
|
||||
<span><Server size={13} />{selected === "azure-devops" ? (isGerman ? "Organisations-URL" : "Organization URL") : (isGerman ? "Server-URL" : "Server URL")}</span>
|
||||
<input value={current.baseUrl} oninput={(event) => updateCurrent({ baseUrl: event.currentTarget.value })} placeholder={baseUrlPlaceholder()} spellcheck="false" inputmode="url" />
|
||||
<small>{isGerman ? "Basis-URL ohne Repository-Pfad." : "Base URL without a repository path."}</small>
|
||||
</label>
|
||||
|
||||
<label class="integration-field"><span>{isGerman ? "Benutzername oder E-Mail" : "Username or email"}</span><input value={current.username} oninput={(event) => updateCurrent({ username: event.currentTarget.value })} autocomplete="off" placeholder={selected === "azure-devops" ? "name@example.com" : (isGerman ? "Benutzername" : "Username")} spellcheck="false" /></label>
|
||||
|
||||
<label class="integration-field">
|
||||
<span><KeyRound size={13} />Personal Access Token</span>
|
||||
<div class="token-row"><input type={showToken ? "text" : "password"} value={tokenValue()} oninput={(event) => setToken(event.currentTarget.value)} autocomplete="new-password" placeholder={current.tokenStored && !pendingRemoval(selected, currentAccountId) ? (isGerman ? "Token ist sicher gespeichert" : "Token is stored securely") : (isGerman ? "Token einfügen" : "Paste token")} spellcheck="false" /><button type="button" onclick={() => { showToken = !showToken; }} title={showToken ? (isGerman ? "Token ausblenden" : "Hide token") : (isGerman ? "Token anzeigen" : "Show token")} aria-label={showToken ? (isGerman ? "Token ausblenden" : "Hide token") : (isGerman ? "Token anzeigen" : "Show token")}>{#if showToken}<EyeOff size={15} />{:else}<Eye size={15} />{/if}</button></div>
|
||||
<small>{isGerman ? "Der Token wird separat im Schlüsselbund des Betriebssystems gespeichert." : "The token is stored separately in the operating system keychain."}</small>
|
||||
</label>
|
||||
|
||||
<div class="integration-actions">
|
||||
<label class="integration-enabled"><span><strong>{selected === "azure-devops" ? (isGerman ? "Organisation aktivieren" : "Enable organization") : (isGerman ? "Integration aktivieren" : "Enable integration")}</strong><small>{isGerman ? "Für Hosting- und Clone-Funktionen verwenden." : "Use for hosting and clone features."}</small></span><input type="checkbox" checked={current.enabled} onchange={(event) => updateCurrent({ enabled: event.currentTarget.checked })} /></label>
|
||||
{#if current.tokenStored && !pendingRemoval(selected, currentAccountId)}<button class="forget-token" type="button" onclick={forgetToken}><Trash2 size={14} />{isGerman ? "Gespeicherten Token entfernen" : "Remove stored token"}</button>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.integration-layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 14px; min-height: 420px; }
|
||||
.integration-providers { display: flex; flex-direction: column; gap: 5px; }
|
||||
.integration-providers > button { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 64px; padding: 9px 10px; border: 1px solid var(--color-border-subtle); border-radius: 9px; color: var(--color-ink-dim); background: var(--app-settings-row-bg); text-align: left; }
|
||||
.integration-providers > button:hover { color: var(--color-ink); border-color: var(--color-border); background: var(--color-surface-hover); }
|
||||
.integration-providers > button.active { border-color: color-mix(in srgb, var(--color-accent) 34%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
||||
.provider-logo { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid color-mix(in srgb, var(--provider-color) 34%, var(--color-border)); border-radius: 8px; color: var(--provider-color); background: color-mix(in srgb, var(--provider-color) 10%, transparent); }
|
||||
.provider-logo svg { width: 17px; height: 17px; fill: currentColor; }
|
||||
.provider-logo-large { width: 42px; height: 42px; border-radius: 10px; }
|
||||
.provider-logo-large svg { width: 22px; height: 22px; }
|
||||
.provider-copy { display: grid; min-width: 0; gap: 3px; }
|
||||
.provider-copy strong { color: inherit; font-size: 11px; }
|
||||
.provider-copy small { overflow: hidden; color: var(--color-ink-faint); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.provider-state { width: 7px; height: 7px; border-radius: 50%; background: var(--color-ink-faint); }
|
||||
.provider-state.configured { background: var(--color-success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 12%, transparent); }
|
||||
.integration-config { display: grid; align-content: start; gap: 13px; min-width: 0; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 12px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
|
||||
.integration-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding-bottom: 13px; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.integration-summary h4 { margin: 0; color: var(--color-ink); font-size: 14px; }
|
||||
.integration-summary p { margin: 3px 0 0; color: var(--color-ink-dim); font-size: 10.5px; }
|
||||
.integration-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; }
|
||||
.integration-status.configured { 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); }
|
||||
.azure-organizations { display: grid; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
|
||||
.azure-organizations-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.azure-organizations-head > div { display: grid; gap: 2px; }
|
||||
.azure-organizations-head strong { color: var(--color-ink); font-size: 10.5px; }
|
||||
.azure-organizations-head small { color: var(--color-ink-faint); font-size: 8.5px; }
|
||||
.azure-organizations-head button, .azure-organizations-empty button { min-height: 27px; padding: 0 8px; font-size: 9.5px; font-weight: 750; }
|
||||
.azure-organization-list { display: grid; gap: 5px; max-height: 142px; overflow: auto; }
|
||||
.azure-organization-row { display: grid; grid-template-columns: minmax(0, 1fr) 30px; gap: 4px; border: 1px solid var(--color-border-subtle); border-radius: 7px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
|
||||
.azure-organization-row.active { border-color: color-mix(in srgb, var(--color-accent) 38%, var(--color-border)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
||||
.azure-organization-row > button:first-child { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; min-height: 42px; padding: 5px 8px; border: 0; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
||||
.azure-organization-row > button:first-child span { display: grid; min-width: 0; gap: 2px; }
|
||||
.azure-organization-row strong, .azure-organization-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.azure-organization-row strong { color: var(--color-ink); font-size: 10px; }
|
||||
.azure-organization-row small { color: var(--color-ink-faint); font-size: 8.5px; }
|
||||
.azure-organization-row i { width: 6px; height: 6px; border-radius: 50%; background: var(--color-ink-faint); }
|
||||
.azure-organization-row i.configured { background: var(--color-success); }
|
||||
.azure-remove { min-height: 30px; align-self: center; padding: 0; border: 0; color: var(--color-ink-faint); background: transparent; }
|
||||
.azure-remove:hover { color: #e86060; background: color-mix(in srgb, #e86060 8%, transparent); }
|
||||
.azure-organizations-empty { display: grid; place-items: center; gap: 7px; padding: 14px; color: var(--color-ink-faint); text-align: center; }
|
||||
.azure-organizations-empty > :global(svg) { color: var(--color-accent); }
|
||||
.azure-organizations-empty span { font-size: 9.5px; }
|
||||
.integration-field { display: grid; gap: 6px; min-width: 0; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
|
||||
.integration-field > span { display: flex; align-items: center; gap: 5px; }
|
||||
.integration-field input { height: 36px; border-color: var(--color-border); background: var(--color-surface-raised); font-size: 11px; }
|
||||
.integration-field small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; }
|
||||
.token-row { display: grid; grid-template-columns: minmax(0, 1fr) 36px; gap: 6px; }
|
||||
.token-row button { display: grid; place-items: center; min-height: 36px; padding: 0; }
|
||||
.integration-actions { display: grid; gap: 10px; padding-top: 2px; }
|
||||
.integration-enabled { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 11px 12px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
||||
.integration-enabled > span { display: grid; gap: 3px; }
|
||||
.integration-enabled strong { color: var(--color-ink); font-size: 10.5px; }
|
||||
.integration-enabled small { color: var(--color-ink-faint); font-size: 9px; }
|
||||
.integration-enabled input { width: 32px; height: 18px; accent-color: var(--color-accent); }
|
||||
.forget-token { justify-self: start; min-height: 28px; color: #e86060; font-size: 10px; }
|
||||
@media (max-width: 680px) { .integration-layout { grid-template-columns: 1fr; min-height: 0; } .integration-providers { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } .integration-providers > button { min-height: 54px; } .provider-copy small { display: none; } }
|
||||
@media (max-width: 430px) { .integration-providers { grid-template-columns: 1fr; } .integration-summary { grid-template-columns: auto minmax(0, 1fr); } .integration-status { grid-column: 1 / -1; justify-self: start; } .azure-organizations-head { align-items: stretch; flex-direction: column; } .azure-organizations-head button { align-self: start; } }
|
||||
</style>
|
||||
@@ -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">
|
||||
import { LoaderCircle, X } from "@lucide/svelte";
|
||||
import { Check, ExternalLink, FileDiff, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
|
||||
import type { GitFileStatus, PatchApplyAction } from "../types";
|
||||
|
||||
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
||||
@@ -8,12 +8,17 @@
|
||||
id: string;
|
||||
text: string;
|
||||
kind: PatchLineKind;
|
||||
oldLine: number | null;
|
||||
newLine: number | null;
|
||||
}
|
||||
|
||||
interface PatchHunk {
|
||||
id: string;
|
||||
header: string;
|
||||
lines: PatchLine[];
|
||||
oldStart: number;
|
||||
newStart: number;
|
||||
suffix: string;
|
||||
}
|
||||
|
||||
interface ParsedPatch {
|
||||
@@ -29,9 +34,12 @@
|
||||
isBusy: boolean;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
language?: "en" | "de";
|
||||
diffName?: string;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
onApply: (action: PatchApplyAction, patch: string) => void | Promise<void>;
|
||||
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
|
||||
onExternalDiff: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -41,18 +49,32 @@
|
||||
isBusy = false,
|
||||
isLoading = false,
|
||||
error = "",
|
||||
language = "en",
|
||||
diffName = "diff tool",
|
||||
onClose = () => {},
|
||||
onRefresh = () => {},
|
||||
onApply = () => {},
|
||||
onExternalDiff = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
const isGerman = $derived(language === "de");
|
||||
|
||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||
let patchScroll = $state<HTMLDivElement | null>(null);
|
||||
let selectedLineIds = $state<Set<string>>(new Set());
|
||||
let lastSelectedLineId = $state("");
|
||||
|
||||
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
||||
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||
let selectableLines = $derived(
|
||||
parsed.hunks.flatMap((hunk) => hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete")),
|
||||
);
|
||||
let selectedCount = $derived(selectableLines.filter((line) => selectedLineIds.has(line.id)).length);
|
||||
|
||||
$effect(() => {
|
||||
parsed = parsePatch(patch);
|
||||
selectedLineIds = new Set();
|
||||
lastSelectedLineId = "";
|
||||
});
|
||||
|
||||
function parsePatch(input: string): ParsedPatch {
|
||||
@@ -63,10 +85,22 @@
|
||||
const headerLines: string[] = [];
|
||||
const hunks: PatchHunk[] = [];
|
||||
let current: PatchHunk | null = null;
|
||||
let oldCursor = 0;
|
||||
let newCursor = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("@@ ")) {
|
||||
current = { id: `hunk-${hunks.length}`, header: line, lines: [] };
|
||||
const range = parseHunkHeader(line);
|
||||
current = {
|
||||
id: `hunk-${hunks.length}`,
|
||||
header: line,
|
||||
lines: [],
|
||||
oldStart: range.oldStart,
|
||||
newStart: range.newStart,
|
||||
suffix: range.suffix,
|
||||
};
|
||||
oldCursor = range.oldStart;
|
||||
newCursor = range.newStart;
|
||||
hunks.push(current);
|
||||
continue;
|
||||
}
|
||||
@@ -77,11 +111,17 @@
|
||||
}
|
||||
|
||||
const kind = patchLineKind(line);
|
||||
const oldLine = kind === "context" || kind === "delete" ? oldCursor : null;
|
||||
const newLine = kind === "context" || kind === "add" ? newCursor : null;
|
||||
current.lines.push({
|
||||
id: `${current.id}-line-${current.lines.length}`,
|
||||
text: line,
|
||||
kind,
|
||||
oldLine,
|
||||
newLine,
|
||||
});
|
||||
if (oldLine !== null) oldCursor += 1;
|
||||
if (newLine !== null) newCursor += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -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 {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
|
||||
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
|
||||
@@ -116,7 +165,108 @@
|
||||
|
||||
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
|
||||
if (isBusy || isLoading) return;
|
||||
await onApply(action, buildHunkPatch(hunk));
|
||||
await onApply(action, buildHunkPatch(hunk), "hunk");
|
||||
}
|
||||
|
||||
function toggleLine(event: MouseEvent, line: PatchLine) {
|
||||
if (isBusy || isLoading || (line.kind !== "add" && line.kind !== "delete")) return;
|
||||
const next = new Set(selectedLineIds);
|
||||
const selecting = !next.has(line.id);
|
||||
if (event.shiftKey && lastSelectedLineId) {
|
||||
const start = selectableLines.findIndex((candidate) => candidate.id === lastSelectedLineId);
|
||||
const end = selectableLines.findIndex((candidate) => candidate.id === line.id);
|
||||
if (start >= 0 && end >= 0) {
|
||||
for (const candidate of selectableLines.slice(Math.min(start, end), Math.max(start, end) + 1)) {
|
||||
if (selecting) next.add(candidate.id);
|
||||
else next.delete(candidate.id);
|
||||
}
|
||||
}
|
||||
} else if (selecting) {
|
||||
next.add(line.id);
|
||||
} else {
|
||||
next.delete(line.id);
|
||||
}
|
||||
selectedLineIds = next;
|
||||
lastSelectedLineId = line.id;
|
||||
}
|
||||
|
||||
function toggleHunkSelection(hunk: PatchHunk) {
|
||||
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
|
||||
const allSelected = changed.length > 0 && changed.every((line) => selectedLineIds.has(line.id));
|
||||
const next = new Set(selectedLineIds);
|
||||
for (const line of changed) {
|
||||
if (allSelected) next.delete(line.id);
|
||||
else next.add(line.id);
|
||||
}
|
||||
selectedLineIds = next;
|
||||
lastSelectedLineId = changed[changed.length - 1]?.id ?? "";
|
||||
}
|
||||
|
||||
function hunkSelectionState(hunk: PatchHunk): "none" | "some" | "all" {
|
||||
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
|
||||
const count = changed.filter((line) => selectedLineIds.has(line.id)).length;
|
||||
return count === 0 ? "none" : count === changed.length ? "all" : "some";
|
||||
}
|
||||
|
||||
function rangePart(start: number, count: number): string {
|
||||
return count === 1 ? `${start}` : `${start},${count}`;
|
||||
}
|
||||
|
||||
function buildSelectedHunk(hunk: PatchHunk): string | null {
|
||||
if (!hunk.lines.some((line) => selectedLineIds.has(line.id))) return null;
|
||||
const output: string[] = [];
|
||||
let previousIncluded = false;
|
||||
|
||||
for (const line of hunk.lines) {
|
||||
if (line.kind === "context") {
|
||||
output.push(line.text);
|
||||
previousIncluded = true;
|
||||
} else if (line.kind === "delete") {
|
||||
output.push(selectedLineIds.has(line.id) ? line.text : ` ${line.text.slice(1)}`);
|
||||
previousIncluded = true;
|
||||
} else if (line.kind === "add") {
|
||||
if (selectedLineIds.has(line.id)) {
|
||||
output.push(line.text);
|
||||
previousIncluded = true;
|
||||
} else {
|
||||
previousIncluded = false;
|
||||
}
|
||||
} else if (previousIncluded) {
|
||||
output.push(line.text);
|
||||
}
|
||||
}
|
||||
|
||||
const oldCount = output.filter((line) => line.startsWith(" ") || line.startsWith("-")).length;
|
||||
const newCount = output.filter((line) => line.startsWith(" ") || line.startsWith("+")).length;
|
||||
const header = `@@ -${rangePart(hunk.oldStart, oldCount)} +${rangePart(hunk.newStart, newCount)} @@${hunk.suffix}`;
|
||||
return [header, ...output].join("\n");
|
||||
}
|
||||
|
||||
function buildSelectedPatch(): string {
|
||||
const hunks = parsed.hunks.map(buildSelectedHunk).filter((hunk): hunk is string => Boolean(hunk));
|
||||
return `${[...parsed.headerLines, ...hunks].join("\n")}\n`;
|
||||
}
|
||||
|
||||
async function applySelected(action: PatchApplyAction) {
|
||||
if (isBusy || isLoading || selectedCount === 0) return;
|
||||
await onApply(action, buildSelectedPatch(), "lines");
|
||||
}
|
||||
|
||||
function hunkPosition(index: number): number {
|
||||
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>
|
||||
|
||||
@@ -127,7 +277,16 @@
|
||||
<span class="eyebrow">{scopeLabel}</span>
|
||||
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
<div class="dialog-header-actions line-patch-header-actions">
|
||||
<div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}>
|
||||
<span>{isGerman ? "Öffnen mit" : "Open with"}</span>
|
||||
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}>
|
||||
<FileDiff size={13} aria-hidden="true" />Gitty
|
||||
</button>
|
||||
<button type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={isGerman ? `In ${diffName} öffnen` : `Open in ${diffName}`}>
|
||||
<ExternalLink size={13} aria-hidden="true" /><span>{diffName}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<X size={16} aria-hidden="true" />
|
||||
@@ -148,40 +307,106 @@
|
||||
{:else if parsed.binary || parsed.hunks.length === 0}
|
||||
<div class="blank-state">This change cannot be split into text lines.</div>
|
||||
{:else}
|
||||
<div class="line-patch-scroll">
|
||||
{#each parsed.hunks as hunk (hunk.id)}
|
||||
<section class="line-patch-hunk">
|
||||
<div class="line-patch-hunk-head">
|
||||
<code>{hunk.header}</code>
|
||||
<div class="line-patch-hunk-actions">
|
||||
{#if staged}
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
|
||||
Discard Hunk
|
||||
</button>
|
||||
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
|
||||
Unstage Hunk
|
||||
</button>
|
||||
{:else}
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
|
||||
Discard Hunk
|
||||
</button>
|
||||
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
|
||||
Stage Hunk
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<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-lines">
|
||||
{#each hunk.lines as line (line.id)}
|
||||
<div class={`line-patch-row ${line.kind}`}>
|
||||
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
||||
<code>{lineBody(line)}</code>
|
||||
<div class="line-patch-workspace">
|
||||
<div class="line-patch-scroll" bind:this={patchScroll}>
|
||||
{#each parsed.hunks as hunk (hunk.id)}
|
||||
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
|
||||
<div class="line-patch-hunk-head">
|
||||
<button
|
||||
class:all={hunkSelectionState(hunk) === "all"}
|
||||
class:some={hunkSelectionState(hunk) === "some"}
|
||||
class="line-patch-select-hunk"
|
||||
type="button"
|
||||
onclick={() => toggleHunkSelection(hunk)}
|
||||
aria-label={`Select changed lines in ${hunk.header}`}
|
||||
aria-pressed={hunkSelectionState(hunk) === "all"}
|
||||
title="Select all changed lines in this hunk"
|
||||
>
|
||||
{#if hunkSelectionState(hunk) !== "none"}<Check size={12} aria-hidden="true" />{/if}
|
||||
</button>
|
||||
<code>{hunk.header}</code>
|
||||
<div class="line-patch-hunk-actions">
|
||||
{#if staged}
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
|
||||
Discard Hunk
|
||||
</button>
|
||||
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
|
||||
Unstage Hunk
|
||||
</button>
|
||||
{:else}
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
|
||||
Discard Hunk
|
||||
</button>
|
||||
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
|
||||
Stage Hunk
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="line-patch-lines">
|
||||
{#each hunk.lines as line (line.id)}
|
||||
<div class:selected={selectedLineIds.has(line.id)} class:selectable={line.kind === "add" || line.kind === "delete"} class={`line-patch-row ${line.kind}`}>
|
||||
{#if line.kind === "add" || line.kind === "delete"}
|
||||
<button
|
||||
class="line-patch-line-select"
|
||||
type="button"
|
||||
onclick={(event) => toggleLine(event, line)}
|
||||
aria-label={`${selectedLineIds.has(line.id) ? "Deselect" : "Select"} ${line.kind === "add" ? "added" : "deleted"} line ${line.newLine ?? line.oldLine ?? ""}`}
|
||||
aria-pressed={selectedLineIds.has(line.id)}
|
||||
title="Select line (Shift-click for range)"
|
||||
>
|
||||
{#if selectedLineIds.has(line.id)}<Check size={11} aria-hidden="true" />{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="line-patch-line-select-placeholder"></span>
|
||||
{/if}
|
||||
<span class="line-patch-line-number">{line.oldLine ?? ""}</span>
|
||||
<span class="line-patch-line-number">{line.newLine ?? ""}</span>
|
||||
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
||||
<code>{lineBody(line)}</code>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</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}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { Check, GitMerge, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { AppLanguage, GitBranch, MergeStrategy } from "../types";
|
||||
|
||||
interface Props {
|
||||
branch: GitBranch;
|
||||
currentBranch: string;
|
||||
isBusy: boolean;
|
||||
language: AppLanguage;
|
||||
onMerge: (strategy: MergeStrategy) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
branch,
|
||||
currentBranch,
|
||||
isBusy = false,
|
||||
language = "en",
|
||||
onMerge = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let strategy = $state<MergeStrategy>("default");
|
||||
const isGerman = $derived(language === "de");
|
||||
const options = $derived([
|
||||
{
|
||||
value: "default" as const,
|
||||
label: isGerman ? "Standard" : "Default",
|
||||
description: isGerman ? "Git wählt Fast-forward oder erstellt einen Merge-Commit." : "Git chooses fast-forward or creates a merge commit.",
|
||||
},
|
||||
{
|
||||
value: "squash" as const,
|
||||
label: "Squash",
|
||||
description: isGerman ? "Fasst alle Änderungen zu einem neuen Commit zusammen." : "Combines all changes into one new commit.",
|
||||
},
|
||||
{
|
||||
value: "ff-only" as const,
|
||||
label: "Fast-forward only",
|
||||
description: isGerman ? "Bricht ab, wenn ein Merge-Commit erforderlich wäre." : "Stops if a merge commit would be required.",
|
||||
},
|
||||
{
|
||||
value: "no-ff" as const,
|
||||
label: "No fast-forward",
|
||||
description: isGerman ? "Erstellt immer einen eigenen Merge-Commit." : "Always creates a dedicated merge commit.",
|
||||
},
|
||||
]);
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!isBusy) onMerge(strategy);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && !isBusy) onClose();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||
<div class="dialog merge-branch-dialog" role="dialog" aria-modal="true" aria-labelledby="merge-branch-title" tabindex="-1">
|
||||
<header class="dialog-header merge-branch-header">
|
||||
<div class="merge-branch-heading">
|
||||
<span class="merge-branch-icon" aria-hidden="true"><GitMerge size={18} /></span>
|
||||
<div>
|
||||
<span class="eyebrow">{isGerman ? "Branches zusammenführen" : "Combine branches"}</span>
|
||||
<h2 id="merge-branch-title">{isGerman ? "Merge konfigurieren" : "Configure merge"}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="merge-branch-form" onsubmit={submit}>
|
||||
<div class="merge-branch-route" aria-label={isGerman ? "Merge-Richtung" : "Merge direction"}>
|
||||
<div>
|
||||
<span>{isGerman ? "Quell-Branch" : "Source branch"}</span>
|
||||
<strong>{branch.name}</strong>
|
||||
</div>
|
||||
<GitMerge size={18} aria-hidden="true" />
|
||||
<div>
|
||||
<span>{isGerman ? "In aktuellen Branch" : "Into current branch"}</span>
|
||||
<strong>{currentBranch || "HEAD"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="merge-strategy-fieldset" disabled={isBusy}>
|
||||
<legend>{isGerman ? "Merge-Strategie" : "Merge strategy"}</legend>
|
||||
<div class="merge-strategy-grid">
|
||||
{#each options as option}
|
||||
<label class:active={strategy === option.value} class="merge-strategy-option">
|
||||
<input type="radio" name="merge-strategy" value={option.value} bind:group={strategy} />
|
||||
<span class="merge-strategy-check" aria-hidden="true">
|
||||
{#if strategy === option.value}<Check size={13} />{/if}
|
||||
</span>
|
||||
<span class="merge-strategy-copy">
|
||||
<strong>{option.label}</strong>
|
||||
<small>{option.description}</small>
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="merge-branch-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy}>
|
||||
{#if isBusy}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<GitMerge size={16} aria-hidden="true" />{/if}
|
||||
{isGerman ? "Branch mergen" : "Merge branch"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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("");
|
||||
|
||||
function remoteName(branch: GitBranchInfo): string {
|
||||
if (!branch.remote) return "";
|
||||
const slash = branch.name.indexOf("/");
|
||||
return slash > 0 ? branch.name.slice(0, slash) : branch.name;
|
||||
}
|
||||
|
||||
function editableName(branch: GitBranchInfo): string {
|
||||
if (!branch.remote) return branch.name;
|
||||
const slash = branch.name.indexOf("/");
|
||||
return slash >= 0 ? branch.name.slice(slash + 1) : branch.name;
|
||||
}
|
||||
|
||||
let originalName = $derived(editableName(branch));
|
||||
let remote = $derived(remoteName(branch));
|
||||
|
||||
$effect(() => {
|
||||
name = branch.name;
|
||||
name = originalName;
|
||||
});
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const value = name.trim();
|
||||
if (!value || value === branch.name) return;
|
||||
if (!value || value === originalName) return;
|
||||
onRename(value);
|
||||
}
|
||||
</script>
|
||||
@@ -34,10 +49,10 @@
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label={branch.remote ? "Rename remote branch" : "Rename branch"} tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Rename branch</span>
|
||||
<span class="eyebrow">{branch.remote ? "Rename remote branch" : "Rename branch"}</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
@@ -48,27 +63,37 @@
|
||||
<form class="rename-branch-form" onsubmit={submit}>
|
||||
<label class="new-branch-field">
|
||||
<span>Branch name</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={name}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
/>
|
||||
<div class:remote-branch-name-field={branch.remote}>
|
||||
{#if branch.remote}<strong>{remote}/</strong>{/if}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={name}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{#if branch.remote}
|
||||
<p class="rename-remote-note">
|
||||
Gitty creates <strong>{remote}/{name.trim() || "new-name"}</strong> and removes
|
||||
<strong>{branch.name}</strong> in one atomic push. The operation stops if the remote changed in the meantime.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="new-branch-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === branch.name}>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === originalName}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Rename
|
||||
{branch.remote ? "Rename on remote" : "Rename"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<style>
|
||||
.repo-loading {
|
||||
position: fixed;
|
||||
top: calc(var(--app-titlebar-height, 42px) + 56px);
|
||||
top: calc(var(--app-titlebar-height, 40px) + var(--app-repo-tabbar-height, 36px));
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
@@ -42,18 +42,23 @@
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 50% 42%, rgba(137, 92, 255, 0.18), transparent 34%),
|
||||
linear-gradient(135deg, #050712 0%, #080b16 46%, #120b18 100%);
|
||||
radial-gradient(
|
||||
circle at 50% 42%,
|
||||
color-mix(in srgb, var(--color-accent) 13%, transparent),
|
||||
transparent 38%
|
||||
),
|
||||
color-mix(in srgb, var(--app-dialog-bg) 78%, #05070a 22%);
|
||||
backdrop-filter: blur(9px);
|
||||
animation: overlay-in 180ms ease;
|
||||
}
|
||||
|
||||
.repo-loading-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.28;
|
||||
opacity: 0.34;
|
||||
background-image:
|
||||
linear-gradient(rgba(151, 118, 255, 0.12) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 109, 38, 0.08) 1px, transparent 1px);
|
||||
linear-gradient(color-mix(in srgb, var(--color-primary) 9%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--color-accent) 8%, transparent) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
mask-image: radial-gradient(circle at center, black 0%, transparent 68%);
|
||||
animation: grid-drift 10s linear infinite;
|
||||
@@ -64,23 +69,21 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
width: min(420px, calc(100vw - 42px));
|
||||
padding: 30px 34px 28px;
|
||||
border: 1px solid rgba(160, 124, 255, 0.24);
|
||||
border-radius: 16px;
|
||||
gap: 14px;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
padding: 26px 30px 24px;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(23, 26, 43, 0.9), rgba(12, 15, 27, 0.92)),
|
||||
var(--color-surface-raised);
|
||||
box-shadow:
|
||||
0 26px 90px rgba(0, 0, 0, 0.62),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
var(--app-panel-highlight),
|
||||
var(--app-dialog-bg);
|
||||
box-shadow: var(--app-dialog-shadow);
|
||||
}
|
||||
|
||||
.repo-loading-mark {
|
||||
position: relative;
|
||||
width: 168px;
|
||||
height: 168px;
|
||||
width: 116px;
|
||||
height: 116px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
isolation: isolate;
|
||||
@@ -88,25 +91,25 @@
|
||||
|
||||
.repo-loading-halo {
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
border: 1px solid rgba(151, 118, 255, 0.24);
|
||||
border-radius: 34px;
|
||||
inset: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 34%, transparent);
|
||||
border-radius: 24px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.repo-loading-halo.halo-one {
|
||||
animation: halo-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
.repo-loading-halo.halo-two {
|
||||
inset: 24px;
|
||||
border-color: rgba(255, 109, 38, 0.26);
|
||||
inset: 18px;
|
||||
border-color: color-mix(in srgb, var(--color-accent) 38%, transparent);
|
||||
animation: halo-breathe 2.4s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
.repo-loading-traces {
|
||||
position: absolute;
|
||||
inset: -18px;
|
||||
width: 204px;
|
||||
height: 204px;
|
||||
inset: -14px;
|
||||
width: 144px;
|
||||
height: 144px;
|
||||
overflow: visible;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -116,18 +119,18 @@
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 165;
|
||||
stroke-dashoffset: 165;
|
||||
filter: drop-shadow(0 0 8px rgba(151, 118, 255, 0.55));
|
||||
filter: drop-shadow(0 0 7px color-mix(in srgb, var(--color-accent) 38%, transparent));
|
||||
animation: trace-draw 2.6s ease-in-out infinite;
|
||||
}
|
||||
.repo-loading-traces .trace-main {
|
||||
stroke: #9b70ff;
|
||||
stroke: var(--color-primary);
|
||||
}
|
||||
.repo-loading-traces .trace-branch {
|
||||
stroke: #ff6d26;
|
||||
stroke: var(--color-accent);
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
.repo-loading-traces .trace-cut {
|
||||
stroke: rgba(255, 255, 255, 0.42);
|
||||
stroke: color-mix(in srgb, var(--color-ink-muted) 52%, transparent);
|
||||
stroke-dasharray: 128;
|
||||
stroke-dashoffset: 128;
|
||||
animation-delay: 0.55s;
|
||||
@@ -136,12 +139,12 @@
|
||||
.repo-loading-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
width: 82px;
|
||||
height: 82px;
|
||||
object-fit: contain;
|
||||
filter:
|
||||
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 10px 16px color-mix(in srgb, var(--color-ink) 24%, transparent))
|
||||
drop-shadow(0 0 14px color-mix(in srgb, var(--color-accent) 24%, transparent));
|
||||
animation: icon-float 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -155,16 +158,16 @@
|
||||
|
||||
.repo-loading-label {
|
||||
color: var(--color-ink);
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.repo-loading-name {
|
||||
max-width: 260px;
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -174,16 +177,17 @@
|
||||
width: min(230px, 100%);
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 999px;
|
||||
background: rgba(151, 118, 255, 0.14);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface-dim));
|
||||
}
|
||||
.repo-loading-bar span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 46%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, transparent, #9b70ff 40%, #ff6d26 74%, transparent);
|
||||
box-shadow: 0 0 16px rgba(255, 109, 38, 0.32);
|
||||
background: linear-gradient(90deg, transparent, var(--color-primary) 40%, var(--color-accent) 74%, transparent);
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--color-accent) 32%, transparent);
|
||||
animation: bar-slide 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -215,4 +219,11 @@
|
||||
.repo-loading-bar span { animation: none; }
|
||||
.repo-loading-traces .trace { stroke-dashoffset: 0; opacity: 0.72; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.repo-loading-card {
|
||||
width: calc(100vw - 20px);
|
||||
padding: 22px 20px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
<script lang="ts">
|
||||
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LoaderCircle, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte";
|
||||
import { listIntegrationReviewRequests, listRemotes } from "../git";
|
||||
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
|
||||
import type { GitIntegrationSettings, GitIntegrationSource, IntegrationReviewRequest, StoredCredential } from "../types";
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
|
||||
interface DashboardRepository {
|
||||
path: string;
|
||||
name: string;
|
||||
branch: string | null;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
changed: number;
|
||||
lastOpened: number;
|
||||
known: boolean;
|
||||
favorite: boolean;
|
||||
isOpen: boolean;
|
||||
recent: boolean;
|
||||
}
|
||||
|
||||
interface Workspace { id: string; name: string; }
|
||||
interface PullRequestBadgeState {
|
||||
status: "loading" | "ready" | "error" | "unavailable";
|
||||
count: number;
|
||||
sourceId: string;
|
||||
repository: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface RemoteIdentity { host: string; repository: string; organization: string; }
|
||||
|
||||
export let repos: DashboardRepository[] = [];
|
||||
export let language: "de" | "en" = "en";
|
||||
export let isBusy = false;
|
||||
export let onOpen: (path: string) => unknown;
|
||||
export let onClose: (path: string) => unknown;
|
||||
export let onAdd: () => unknown;
|
||||
export let onClone: () => unknown;
|
||||
export let onInit: () => unknown;
|
||||
export let onFavorite: (path: string) => unknown;
|
||||
export let onRemoveRecent: (path: string) => unknown;
|
||||
export let integrations: GitIntegrationSettings;
|
||||
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||
export let onOpenPullRequests: (repository: string, sourceId: string) => unknown;
|
||||
|
||||
const STORAGE_KEY = "gitty.dashboard.v1";
|
||||
let query = "";
|
||||
let selectedWorkspace = "";
|
||||
let collapsedCategories = new Set<string>();
|
||||
let workspaces: Workspace[] = [];
|
||||
let assignments: Record<string, string> = {};
|
||||
let workspaceDialog: HTMLDialogElement;
|
||||
let workspaceInput: HTMLInputElement;
|
||||
let workspaceName = "";
|
||||
let workspaceError = "";
|
||||
let workspaceSelection = new Set<string>();
|
||||
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
|
||||
let pullRequestLoadGeneration = 0;
|
||||
let lastPullRequestLoadKey = "";
|
||||
|
||||
$: de = language === "de";
|
||||
$: normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
$: filteredRepos = repos.filter((repo) => {
|
||||
const matchesSearch = !normalizedQuery || `${repo.name} ${repo.path} ${repo.branch ?? ""}`.toLocaleLowerCase().includes(normalizedQuery);
|
||||
return matchesSearch && (!selectedWorkspace || assignments[repo.path] === selectedWorkspace);
|
||||
});
|
||||
$: openRepos = filteredRepos.filter((repo) => repo.isOpen);
|
||||
$: favoriteRepos = filteredRepos.filter((repo) => repo.favorite);
|
||||
$: recentRepos = filteredRepos.filter((repo) => repo.recent && !repo.isOpen);
|
||||
$: categories = [
|
||||
{ id: "open", label: "Open", repos: openRepos },
|
||||
{ id: "favorites", label: de ? "Favoriten" : "Favorites", repos: favoriteRepos },
|
||||
{ id: "recent", label: "Recent", repos: recentRepos },
|
||||
];
|
||||
$: changedCount = repos.filter((repo) => repo.known && repo.changed > 0).length;
|
||||
$: workspaceOptions = [
|
||||
{ value: "", label: de ? "Alle Workspaces" : "All workspaces" },
|
||||
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })),
|
||||
];
|
||||
$: integrationSources = configuredIntegrationSources(integrations);
|
||||
$: pullRequestLoadKey = JSON.stringify({
|
||||
repositories: repos.map((repo) => repo.path),
|
||||
sources: integrationSources.map((source) => [source.id, source.baseUrl]),
|
||||
});
|
||||
$: if (pullRequestLoadKey !== lastPullRequestLoadKey) {
|
||||
lastPullRequestLoadKey = pullRequestLoadKey;
|
||||
void loadPullRequestBadges();
|
||||
}
|
||||
|
||||
loadPreferences();
|
||||
|
||||
function loadPreferences() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown };
|
||||
if (Array.isArray(saved.workspaces)) {
|
||||
workspaces = saved.workspaces.filter((item): item is Workspace => Boolean(
|
||||
item && typeof item === "object"
|
||||
&& "id" in item && typeof item.id === "string" && item.id.startsWith("workspace-")
|
||||
&& "name" in item && typeof item.name === "string" && item.name.trim(),
|
||||
));
|
||||
}
|
||||
if (saved.assignments && typeof saved.assignments === "object" && !Array.isArray(saved.assignments)) {
|
||||
assignments = Object.fromEntries(Object.entries(saved.assignments).filter(([, workspaceId]) =>
|
||||
typeof workspaceId === "string" && workspaces.some((workspace) => workspace.id === workspaceId)));
|
||||
}
|
||||
} catch {
|
||||
workspaces = [];
|
||||
assignments = {};
|
||||
}
|
||||
}
|
||||
|
||||
function savePreferences() {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ workspaces, assignments })); }
|
||||
catch { /* Preferences remain optional when storage is unavailable. */ }
|
||||
}
|
||||
|
||||
function openWorkspaceDialog() {
|
||||
workspaceName = "";
|
||||
workspaceError = "";
|
||||
workspaceSelection = new Set();
|
||||
workspaceDialog.showModal();
|
||||
requestAnimationFrame(() => workspaceInput.focus());
|
||||
}
|
||||
|
||||
function toggleWorkspaceRepo(path: string) {
|
||||
const next = new Set(workspaceSelection);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
workspaceSelection = next;
|
||||
}
|
||||
|
||||
function createWorkspace(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const name = workspaceName.trim();
|
||||
if (!name) {
|
||||
workspaceError = de ? "Bitte einen Namen eingeben." : "Please enter a name.";
|
||||
return;
|
||||
}
|
||||
if (workspaces.some((workspace) => workspace.name.toLocaleLowerCase() === name.toLocaleLowerCase())) {
|
||||
workspaceError = de ? "Dieser Workspace existiert bereits." : "This workspace already exists.";
|
||||
return;
|
||||
}
|
||||
const id = `workspace-${crypto.randomUUID()}`;
|
||||
workspaces = [...workspaces, { id, name }];
|
||||
assignments = { ...assignments, ...Object.fromEntries([...workspaceSelection].map((path) => [path, id])) };
|
||||
selectedWorkspace = id;
|
||||
savePreferences();
|
||||
workspaceDialog.close();
|
||||
}
|
||||
|
||||
function deleteSelectedWorkspace() {
|
||||
if (!selectedWorkspace) return;
|
||||
const deletedWorkspace = selectedWorkspace;
|
||||
workspaces = workspaces.filter((workspace) => workspace.id !== deletedWorkspace);
|
||||
assignments = Object.fromEntries(Object.entries(assignments).filter(([, workspaceId]) => workspaceId !== deletedWorkspace));
|
||||
selectedWorkspace = "";
|
||||
savePreferences();
|
||||
}
|
||||
|
||||
function openCard(path: string) { if (!isBusy) onOpen(path); }
|
||||
function closeCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onClose(path); }
|
||||
function favoriteCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onFavorite(path); }
|
||||
function removeRecentCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onRemoveRecent(path); }
|
||||
function openPullRequests(event: MouseEvent, repo: DashboardRepository) {
|
||||
event.stopPropagation();
|
||||
const badge = pullRequestBadges[repo.path];
|
||||
if (!isBusy && badge?.status === "ready") onOpenPullRequests(badge.repository || repo.name, badge.sourceId);
|
||||
}
|
||||
|
||||
function remoteIdentity(value: string): RemoteIdentity | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
let host = "";
|
||||
let path = "";
|
||||
const scp = trimmed.match(/^[^@\s]+@([^:\s]+):(.+)$/);
|
||||
if (scp) {
|
||||
host = scp[1];
|
||||
path = scp[2];
|
||||
} else {
|
||||
try {
|
||||
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
||||
host = url.hostname;
|
||||
path = url.pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const parts = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/").filter(Boolean);
|
||||
const gitIndex = parts.findIndex((part) => part.toLocaleLowerCase() === "_git");
|
||||
const normalizedHost = host.toLocaleLowerCase();
|
||||
if (gitIndex > 0 && parts[gitIndex + 1]) return { host: normalizedHost, repository: `${parts[gitIndex - 1]}/${parts[gitIndex + 1]}`.toLocaleLowerCase(), organization: normalizedHost === "dev.azure.com" ? (parts[0] ?? "").toLocaleLowerCase() : "" };
|
||||
if (parts[0]?.toLocaleLowerCase() === "v3" && parts.length >= 4) return { host: normalizedHost, repository: `${parts[2]}/${parts[3]}`.toLocaleLowerCase(), organization: parts[1].toLocaleLowerCase() };
|
||||
return { host: normalizedHost, repository: parts.join("/").toLocaleLowerCase(), organization: "" };
|
||||
}
|
||||
|
||||
function sourceMatchesRemote(source: GitIntegrationSource, identity: RemoteIdentity): boolean {
|
||||
try {
|
||||
const sourceUrl = new URL(source.baseUrl);
|
||||
const sourceHost = sourceUrl.hostname.toLocaleLowerCase();
|
||||
const remoteHost = identity.host;
|
||||
if (source.provider === "azure-devops" ? sourceHost.replace(/^ssh\./, "") !== remoteHost.replace(/^ssh\./, "") : sourceHost !== remoteHost) return false;
|
||||
if (source.provider !== "azure-devops") return true;
|
||||
const organization = sourceUrl.hostname.toLocaleLowerCase() === "dev.azure.com"
|
||||
? sourceUrl.pathname.split("/").filter(Boolean)[0]?.toLocaleLowerCase() ?? ""
|
||||
: sourceUrl.hostname.split(".")[0]?.toLocaleLowerCase() ?? "";
|
||||
return !organization || !identity.organization || organization === identity.organization;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestMatchesRepository(request: IntegrationReviewRequest, repository: string): boolean {
|
||||
const requestRepository = request.repositoryName.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLocaleLowerCase();
|
||||
if (!requestRepository) return false;
|
||||
if (requestRepository === repository || repository.endsWith(`/${requestRepository}`) || requestRepository.endsWith(`/${repository}`)) return true;
|
||||
const requestParts = requestRepository.split("/");
|
||||
const remoteParts = repository.split("/");
|
||||
return requestParts[requestParts.length - 1] === remoteParts[remoteParts.length - 1];
|
||||
}
|
||||
|
||||
function updatePullRequestBadge(path: string, state: PullRequestBadgeState, generation: number) {
|
||||
if (generation !== pullRequestLoadGeneration) return;
|
||||
pullRequestBadges = { ...pullRequestBadges, [path]: state };
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); });
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPullRequestBadges() {
|
||||
const generation = ++pullRequestLoadGeneration;
|
||||
const sources = integrationSources;
|
||||
pullRequestBadges = Object.fromEntries(repos.map((repo) => [repo.path, {
|
||||
status: sources.length ? "loading" : "unavailable",
|
||||
count: 0,
|
||||
sourceId: "",
|
||||
repository: "",
|
||||
message: sources.length ? "" : (de ? "Keine passende Integration eingerichtet." : "No matching integration configured."),
|
||||
} satisfies PullRequestBadgeState]));
|
||||
if (!repos.length || !sources.length) return;
|
||||
|
||||
const grouped = new Map<string, Array<{ repo: DashboardRepository; identity: RemoteIdentity }>>();
|
||||
await Promise.all(repos.map(async (repo) => {
|
||||
try {
|
||||
const remotes = await listRemotes(repo.path);
|
||||
const identities = remotes
|
||||
.sort((left, right) => Number(right.name === "origin") - Number(left.name === "origin"))
|
||||
.map((remote) => remoteIdentity(remote.fetch_url))
|
||||
.filter((identity): identity is RemoteIdentity => Boolean(identity));
|
||||
const match = identities.map((identity) => ({ identity, source: sources.find((source) => sourceMatchesRemote(source, identity)) })).find((item) => item.source);
|
||||
if (!match?.source) {
|
||||
updatePullRequestBadge(repo.path, { status: "unavailable", count: 0, sourceId: "", repository: "", message: de ? "Keine passende Integration für das Remote gefunden." : "No matching integration for this remote." }, generation);
|
||||
return;
|
||||
}
|
||||
grouped.set(match.source.id, [...(grouped.get(match.source.id) ?? []), { repo, identity: match.identity }]);
|
||||
} catch (error) {
|
||||
updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId: "", repository: "", message: error instanceof Error ? error.message : String(error) }, generation);
|
||||
}
|
||||
}));
|
||||
|
||||
await Promise.all([...grouped.entries()].map(async ([sourceId, entries]) => {
|
||||
const source = sources.find((candidate) => candidate.id === sourceId);
|
||||
if (!source) return;
|
||||
try {
|
||||
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000, de ? "Der System-Schlüsselbund hat nicht geantwortet." : "The system keychain did not respond.");
|
||||
if (!credential?.password) throw new Error(de ? "Kein Integrationstoken gespeichert." : "No integration token stored.");
|
||||
const requests = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, "open"), 46_000, de ? `${source.label} hat nicht geantwortet.` : `${source.label} did not respond.`);
|
||||
for (const { repo, identity } of entries) {
|
||||
updatePullRequestBadge(repo.path, {
|
||||
status: "ready",
|
||||
count: requests.filter((request) => requestMatchesRepository(request, identity.repository)).length,
|
||||
sourceId,
|
||||
repository: identity.repository,
|
||||
message: "",
|
||||
}, generation);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
for (const { repo, identity } of entries) updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId, repository: identity.repository, message }, generation);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function pullRequestTitle(repo: DashboardRepository): string {
|
||||
const badge = pullRequestBadges[repo.path];
|
||||
if (!badge || badge.status === "loading") return de ? "Offene Pull Requests werden geladen" : "Loading open pull requests";
|
||||
if (badge.status === "error" || badge.status === "unavailable") return badge.message;
|
||||
return de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`;
|
||||
}
|
||||
function toggleCategory(id: string) {
|
||||
const next = new Set(collapsedCategories);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
collapsedCategories = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="repo-dashboard" aria-label={de ? "Repository-Verwaltung" : "Repository management"}>
|
||||
<header class="dashboard-header">
|
||||
<h1>Repository Management</h1>
|
||||
<div class="header-actions">
|
||||
<button type="button" onclick={onClone} disabled={isBusy}><Download size={14} />{de ? "Klonen" : "Clone"}</button>
|
||||
<button type="button" onclick={onInit} disabled={isBusy}><FolderGit2 size={14} />{de ? "Initialisieren" : "Initialize"}</button>
|
||||
<button class="primary" type="button" onclick={onAdd} disabled={isBusy}><Plus size={14} />Repository</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dashboard-toolbar">
|
||||
<label class="dashboard-search">
|
||||
<Search size={14} />
|
||||
<input bind:value={query} autocomplete="off" spellcheck="false" placeholder={de ? "Repositories suchen …" : "Search repositories …"} aria-label={de ? "Repositories suchen" : "Search repositories"} />
|
||||
</label>
|
||||
<div class="workspace-tools">
|
||||
<SelectMenu
|
||||
class="workspace-select"
|
||||
value={selectedWorkspace}
|
||||
options={workspaceOptions}
|
||||
ariaLabel={de ? "Workspace auswählen" : "Select workspace"}
|
||||
onChange={(value) => { selectedWorkspace = value; }}
|
||||
/>
|
||||
{#if selectedWorkspace}
|
||||
<button class="workspace-delete" type="button" onclick={deleteSelectedWorkspace} aria-label={de ? "Ausgewählten Workspace löschen" : "Delete selected workspace"} title={de ? "Workspace löschen" : "Delete workspace"}><Trash2 size={15} strokeWidth={2} /></button>
|
||||
{/if}
|
||||
<button type="button" onclick={openWorkspaceDialog}><Plus size={14} />Workspace</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-summary">{repos.length} Repositories <span>·</span> {changedCount} {de ? "mit Änderungen" : "with changes"}</div>
|
||||
|
||||
<div class="dashboard-content">
|
||||
{#each categories as category (category.id)}
|
||||
<section class="dashboard-section">
|
||||
<header class="section-header">
|
||||
<button class="section-toggle" type="button" aria-expanded={!collapsedCategories.has(category.id)} onclick={() => toggleCategory(category.id)}>
|
||||
{#if collapsedCategories.has(category.id)}<ChevronRight size={14} />{:else}<ChevronDown size={14} />{/if}
|
||||
<strong>{category.label}</strong><span>{category.repos.length}</span><i></i>
|
||||
</button>
|
||||
</header>
|
||||
{#if !collapsedCategories.has(category.id)}
|
||||
{#if category.repos.length > 0}
|
||||
<div class="repo-grid">
|
||||
{#each category.repos as repo (repo.path)}
|
||||
{@const pullRequests = pullRequestBadges[repo.path]}
|
||||
<article class="repo-card" class:open={repo.isOpen}>
|
||||
<button class="card-main" type="button" onclick={() => openCard(repo.path)} disabled={isBusy} title={repo.path}>
|
||||
<span class="card-title"><FolderGit2 size={15} /><strong>{repo.name}</strong></span>
|
||||
<span class="card-path">{repo.path}</span>
|
||||
<span class="card-branch"><GitBranch size={13} />{repo.branch ?? (de ? "Branch unbekannt" : "Unknown branch")}</span>
|
||||
<span class="card-status">
|
||||
{#if !repo.known}<span class="muted"><Circle size={10} />{de ? "Status nicht geladen" : "Status not loaded"}</span>
|
||||
{:else if repo.changed > 0}<span class="changed"><Circle class="filled" size={9} />{repo.changed} {de ? (repo.changed === 1 ? "Änderung" : "Änderungen") : (repo.changed === 1 ? "change" : "changes")}</span>
|
||||
{:else}<span class="clean"><Circle class="filled" size={9} />{de ? "Sauber" : "Clean"}</span>{/if}
|
||||
{#if repo.ahead > 0}<span class="ahead"><ArrowUp size={13} />{repo.ahead} {de ? "voraus" : "ahead"}</span>{/if}
|
||||
{#if repo.behind > 0}<span class="behind"><ArrowDown size={13} />{repo.behind} {de ? "zurück" : "behind"}</span>{/if}
|
||||
</span>
|
||||
</button>
|
||||
<div class="card-actions">
|
||||
<button class:has-open-prs={pullRequests?.status === "ready" && pullRequests.count > 0} class:pr-error={pullRequests?.status === "error"} class="pr-badge" type="button" onclick={(event) => openPullRequests(event, repo)} disabled={isBusy || !pullRequests || pullRequests.status !== "ready"} aria-label={`${repo.name}: ${pullRequestTitle(repo)}`} title={pullRequestTitle(repo)}>
|
||||
{#if !pullRequests || pullRequests.status === "loading"}<LoaderCircle class="spin" size={14} />{:else if pullRequests.status === "error"}<TriangleAlert size={14} />{:else}<GitPullRequest size={14} /><span>{pullRequests.status === "ready" ? pullRequests.count : "–"}</span>{/if}
|
||||
</button>
|
||||
<button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button>
|
||||
{#if repo.isOpen}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if}
|
||||
{#if category.id === "recent"}<button type="button" onclick={(event) => removeRecentCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Aus Recent entfernen" : "Remove from recent"}`} title={de ? "Aus Recent entfernen" : "Remove from recent"}><X size={15} /></button>{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="category-empty">{de ? "Keine Repositories in dieser Kategorie." : "No repositories in this category."}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
<footer class="dashboard-footer">{repos.length} {de ? "lokale Repositories" : "local repositories"}</footer>
|
||||
</section>
|
||||
|
||||
<dialog class="workspace-dialog" bind:this={workspaceDialog} aria-labelledby="workspace-title">
|
||||
<form onsubmit={createWorkspace}>
|
||||
<header><h2 id="workspace-title">{de ? "Workspace anlegen" : "Create workspace"}</h2><button type="button" onclick={() => workspaceDialog.close()} aria-label={de ? "Dialog schließen" : "Close dialog"}><X size={14} /></button></header>
|
||||
<label for="workspace-name">Name</label>
|
||||
<input id="workspace-name" bind:this={workspaceInput} bind:value={workspaceName} maxlength="64" autocomplete="off" aria-invalid={Boolean(workspaceError)} />
|
||||
{#if workspaceError}<p class="dialog-error" role="alert">{workspaceError}</p>{/if}
|
||||
{#if repos.length > 0}
|
||||
<fieldset><legend>{de ? "Repositories zuordnen" : "Assign repositories"}</legend><div class="workspace-repos">
|
||||
{#each repos as repo (repo.path)}<label><input type="checkbox" checked={workspaceSelection.has(repo.path)} onchange={() => toggleWorkspaceRepo(repo.path)} /><span>{repo.name}</span><small>{repo.path}</small></label>{/each}
|
||||
</div></fieldset>
|
||||
{/if}
|
||||
<footer><button type="button" onclick={() => workspaceDialog.close()}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit"><Check size={14} />{de ? "Anlegen" : "Create"}</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.repo-dashboard{display:flex;flex:1;min-height:0;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}button,input{font:inherit}button{color:inherit;cursor:pointer}button:disabled{cursor:default;opacity:.5}
|
||||
.dashboard-header{display:flex;min-height:58px;align-items:center;justify-content:space-between;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-header h1{margin:0;font-size:18px;line-height:1.2;font-weight:700}.header-actions,.workspace-tools{display:flex;align-items:center;gap:8px}.header-actions button,.workspace-tools button{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}button.primary{border-color:var(--color-primary);background:var(--color-primary);color:#fff}
|
||||
.dashboard-toolbar{display:flex;min-height:48px;align-items:center;gap:8px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-search{display:flex;width:300px;height:30px;align-items:center;gap:8px;padding:0 9px;border:1px solid var(--color-border-input);border-radius:var(--ui-radius-sm);color:var(--color-ink-faint);background:var(--app-input-bg)}.dashboard-search:focus-within{border-color:var(--color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 14%,transparent)}.dashboard-search :global(svg){flex:0 0 auto}.dashboard-search input{width:100%;min-width:0;height:100%;padding:0;border:0;border-radius:0;outline:0;color:var(--color-ink);background:transparent;box-shadow:none}.dashboard-search input:focus,.dashboard-search input:focus-visible{border:0;outline:0;box-shadow:none}.workspace-tools{margin-left:auto}.workspace-tools :global(.workspace-select){width:240px}.workspace-tools :global(.workspace-select .select-menu-trigger){height:30px;min-height:30px;padding:0 9px;border-color:var(--color-border-input);background:var(--app-input-bg);font-size:12px;font-weight:500}.workspace-tools .workspace-delete{width:30px;min-width:30px;padding:0;border-color:color-mix(in srgb,#df626b 55%,var(--color-border));color:#df747b;background:color-mix(in srgb,#c92f3a 8%,var(--app-button-bg))}.workspace-tools .workspace-delete:hover:not(:disabled){border-color:#df626b;color:#fff;background:#c93b45;box-shadow:0 0 0 2px color-mix(in srgb,#c92f3a 18%,transparent)}
|
||||
.dashboard-summary{padding:8px 20px;color:var(--color-ink-muted)}.dashboard-summary span{padding:0 6px;color:var(--color-ink-faint)}.dashboard-content{flex:1;min-height:0;overflow:auto;padding:0 20px 24px}.dashboard-section+.dashboard-section{margin-top:12px}.section-header{min-height:31px}.section-toggle{display:flex;width:100%;min-height:31px;align-items:center;justify-content:flex-start;gap:7px;padding:0;border:0;background:transparent;color:var(--color-ink);text-align:left}.section-toggle:hover:not(:disabled){border:0;background:transparent;color:var(--color-ink)}.section-toggle :global(svg){flex:0 0 auto;color:var(--color-ink-muted)}.section-toggle strong{font-size:13px;line-height:1.2;font-weight:750}.section-toggle span{color:var(--color-ink-faint);font-weight:400}.section-toggle i{height:1px;flex:1;background:var(--color-border-subtle)}.category-empty{margin:0;padding:9px 12px;border:1px solid var(--color-border-subtle);color:var(--color-ink-faint);background:var(--color-surface)}
|
||||
.repo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.repo-card{position:relative;min-width:0;height:138px;border:1px solid var(--color-border);background:var(--color-surface)}.repo-card:hover,.repo-card:focus-within{border-color:var(--color-border-input);background:var(--color-surface-hover)}.card-main{display:flex;width:100%;height:100%;flex-direction:column;align-items:flex-start;gap:7px;padding:12px 104px 11px 14px;border:0;background:transparent;text-align:left}.card-main:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}.card-title{display:flex;max-width:100%;align-items:center;gap:8px}.card-title strong{overflow:hidden;font-size:14px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.card-path{width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed,.behind{color:#eeb94e}.clean{color:#68c878}.ahead{color:var(--color-accent)}.muted{color:var(--color-ink-muted)}
|
||||
.card-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px}.card-actions button{display:grid;width:26px;height:26px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.card-actions button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-dim)}.card-actions button.active{color:var(--color-accent)}.card-actions button.active :global(svg){fill:color-mix(in srgb,var(--color-accent) 22%,transparent)}.card-actions .pr-badge{display:flex;width:auto;min-width:31px;grid-template-columns:none;align-items:center;justify-content:center;gap:4px;padding:0 5px;color:var(--color-ink-faint);font-size:11px}.card-actions .pr-badge:disabled{opacity:.72}.card-actions .pr-badge.has-open-prs{color:var(--color-accent)}.card-actions .pr-badge.pr-error{color:#e0a35b}.card-actions .pr-badge:hover:not(:disabled){color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 9%,transparent)}
|
||||
.dashboard-footer{display:flex;min-height:30px;align-items:center;padding:0 20px;border-top:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
|
||||
.workspace-dialog{width:min(420px,calc(100vw - 32px));margin:auto;padding:0;border:1px solid var(--color-border);color:var(--color-ink);background:var(--app-dialog-bg);box-shadow:var(--app-dialog-shadow);font-size:12px}.workspace-dialog::backdrop{background:var(--app-dialog-backdrop)}.workspace-dialog form>header,.workspace-dialog form>footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--app-dialog-chrome)}.workspace-dialog form>header{border-bottom:1px solid var(--color-border)}.workspace-dialog form>footer{justify-content:flex-end;gap:8px;border-top:1px solid var(--color-border)}.workspace-dialog h2{margin:0;font-size:13px}.workspace-dialog header button{width:26px;height:26px;padding:0;border:0;background:transparent}.workspace-dialog form>label,.workspace-dialog form>input,.workspace-dialog fieldset,.dialog-error{margin-right:12px;margin-left:12px}.workspace-dialog form>label{display:block;margin-top:12px;margin-bottom:5px;color:var(--color-ink-muted)}.workspace-dialog form>input{width:calc(100% - 24px);height:30px;padding:0 8px;border:1px solid var(--color-border-input);color:var(--color-ink);background:var(--app-input-bg)}.dialog-error{margin-top:7px;color:#ed9292}.workspace-dialog fieldset{margin-top:14px;margin-bottom:14px;padding:0;border:0}.workspace-dialog legend{margin-bottom:6px;color:var(--color-ink-muted)}.workspace-repos{max-height:190px;overflow:auto;border:1px solid var(--color-border-subtle)}.workspace-repos label{display:grid;grid-template-columns:auto minmax(100px,auto) 1fr;align-items:center;gap:8px;min-height:32px;padding:4px 8px;border-bottom:1px solid var(--color-border-subtle)}.workspace-repos label:last-child{border-bottom:0}.workspace-repos small{overflow:hidden;color:var(--color-ink-faint);text-overflow:ellipsis;white-space:nowrap}.workspace-dialog footer button{display:inline-flex;min-height:28px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}
|
||||
@media(max-width:1000px){.dashboard-toolbar{flex-wrap:wrap;gap:8px}.dashboard-search{flex:1 1 260px}.workspace-tools{margin-left:0}.repo-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:680px){.dashboard-header{min-height:auto;align-items:flex-start;flex-direction:column}.header-actions{flex-wrap:wrap}.workspace-tools{width:100%}.workspace-tools :global(.workspace-select){min-width:0;flex:1}.repo-grid{grid-template-columns:1fr}.repo-card{height:132px}}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { AlertCircle, Check, LoaderCircle, X } from "@lucide/svelte";
|
||||
import { AlertCircle, Check, ExternalLink, GitMerge, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
|
||||
|
||||
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
|
||||
@@ -24,6 +24,9 @@
|
||||
onSelectFile: (path: string) => void;
|
||||
onMarkResolved: (path: string, resolution: PreparedResolution) => void;
|
||||
onApply: () => void;
|
||||
onExternalMerge: (path: string) => void | Promise<void>;
|
||||
language?: "en" | "de";
|
||||
mergeName?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -37,7 +40,11 @@
|
||||
onSelectFile = () => {},
|
||||
onMarkResolved = () => {},
|
||||
onApply = () => {},
|
||||
onExternalMerge = () => {},
|
||||
language = "en",
|
||||
mergeName = "merge tool",
|
||||
}: Props = $props();
|
||||
const isGerman = $derived(language === "de");
|
||||
|
||||
let conflictParts = $derived<ConflictPart[]>(
|
||||
conflict && !conflict.binary ? parseConflicts(conflict.content) : [],
|
||||
@@ -268,9 +275,22 @@
|
||||
<span class="eyebrow">Resolve</span>
|
||||
<h2 class="dialog-title">Conflicts</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<div class="dialog-header-actions">
|
||||
{#if conflictTarget}
|
||||
<div class="tool-surface-choice" aria-label={isGerman ? "Konflikt bearbeiten mit" : "Edit conflict with"}>
|
||||
<span>{isGerman ? "Bearbeiten mit" : "Edit with"}</span>
|
||||
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty bearbeiten" : "Edit in Gitty"}>
|
||||
<GitMerge size={13} aria-hidden="true" />Gitty
|
||||
</button>
|
||||
<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={isGerman ? `In ${mergeName} öffnen` : `Open in ${mergeName}`}>
|
||||
<ExternalLink size={13} aria-hidden="true" /><span>{mergeName}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Konflikte schließen" : "Close conflicts"}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if conflictedFiles.length === 0}
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { fly } from "svelte/transition";
|
||||
import {
|
||||
AlertTriangle, AtSign, Bold, Check, ChevronDown, ChevronRight, Code2,
|
||||
CircleCheck, Clock3, CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox, Italic,
|
||||
Link, List, LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
|
||||
} from "@lucide/svelte";
|
||||
import { addIntegrationReviewComment, getIntegrationReviewDetails, listIntegrationReviewRequests, openInBrowser, runIntegrationReviewAction } from "../git";
|
||||
import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations";
|
||||
import type { AppLanguage, GitIntegrationSettings, IntegrationReviewAction, IntegrationReviewRequest, IntegrationReviewState, StoredCredential } from "../types";
|
||||
|
||||
type LocalResolutionPhase = "idle" | "preparing" | "conflicts" | "ready-to-continue" | "ready-to-push" | "complete" | "error";
|
||||
|
||||
interface Props {
|
||||
language: AppLanguage;
|
||||
integrations: GitIntegrationSettings;
|
||||
initialQuery?: string;
|
||||
initialSourceId?: string;
|
||||
localResolutionRequestId?: string;
|
||||
localResolutionPhase?: LocalResolutionPhase;
|
||||
localResolutionMessage?: string;
|
||||
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||
onOpenSettings: () => void;
|
||||
onStartLocalResolution?: (request: IntegrationReviewRequest, sourceId: string) => void | Promise<void>;
|
||||
onOpenLocalResolver?: () => void | Promise<void>;
|
||||
onContinueLocalResolution?: () => void | Promise<void>;
|
||||
onAbortLocalResolution?: () => void | Promise<void>;
|
||||
onPushLocalResolution?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
let { language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
|
||||
let requests = $state<IntegrationReviewRequest[]>([]);
|
||||
let loading = $state(false);
|
||||
let errors = $state<Array<{ source: string; message: string }>>([]);
|
||||
let query = $state("");
|
||||
let stateFilter = $state<IntegrationReviewState>("open");
|
||||
let selectedSourceId = $state("");
|
||||
let selectedId = $state("");
|
||||
let detailOpen = $state(false);
|
||||
let detailPanel = $state<HTMLElement>();
|
||||
let actionMenuId = $state("");
|
||||
let actionBusyId = $state("");
|
||||
let actionNotice = $state("");
|
||||
let detailLoadingId = $state("");
|
||||
let commentDraft = $state("");
|
||||
let commentTextarea = $state<HTMLTextAreaElement>();
|
||||
let commentPosting = $state(false);
|
||||
let collapsedRepositories = $state(new Set<string>());
|
||||
let loadGeneration = 0;
|
||||
let loadedStates = $state(new Set<"open" | "merged" | "closed">());
|
||||
let completedResolutionRefreshKey = "";
|
||||
|
||||
const SOURCE_TIMEOUT_MS = 46_000;
|
||||
const de = $derived(language === "de");
|
||||
const sources = $derived(configuredIntegrationSources(integrations));
|
||||
const activeSource = $derived(sources.find((source) => source.id === selectedSourceId) ?? null);
|
||||
const normalizedQuery = $derived(query.trim().toLocaleLowerCase());
|
||||
const filtered = $derived(requests.filter((request) => {
|
||||
const haystack = `${request.title} ${request.repositoryName} ${request.author} ${request.number} ${request.sourceBranch} ${request.targetBranch}`.toLocaleLowerCase();
|
||||
return request.state === stateFilter && (!normalizedQuery || haystack.includes(normalizedQuery));
|
||||
}));
|
||||
const groupedRequests = $derived.by(() => {
|
||||
const groups = new Map<string, IntegrationReviewRequest[]>();
|
||||
for (const request of filtered) {
|
||||
const key = request.repositoryName || providerLabel(request.provider);
|
||||
groups.set(key, [...(groups.get(key) ?? []), request]);
|
||||
}
|
||||
return [...groups.entries()].map(([name, items]) => ({ name, items }));
|
||||
});
|
||||
const selected = $derived(requests.find((request) => request.id === selectedId) ?? null);
|
||||
const backendRestartRequired = $derived(errors.some((error) => /command.*not found|unknown command|not registered/i.test(error.message)));
|
||||
|
||||
onMount(() => {
|
||||
const closeActionMenu = () => { actionMenuId = ""; };
|
||||
const closeDetailsOutside = (event: PointerEvent) => {
|
||||
if (detailOpen && detailPanel && event.target instanceof Node && !detailPanel.contains(event.target)) {
|
||||
detailOpen = false;
|
||||
}
|
||||
};
|
||||
window.addEventListener("click", closeActionMenu);
|
||||
window.addEventListener("pointerdown", closeDetailsOutside, true);
|
||||
query = initialQuery;
|
||||
const source = sources.find((candidate) => candidate.id === initialSourceId) ?? sources[0];
|
||||
if (source) {
|
||||
selectedSourceId = source.id;
|
||||
void loadRequests("open", source.id);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("click", closeActionMenu);
|
||||
window.removeEventListener("pointerdown", closeDetailsOutside, true);
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const key = localResolutionPhase === "complete" && localResolutionRequestId && selectedSourceId
|
||||
? `${selectedSourceId}:${localResolutionRequestId}`
|
||||
: "";
|
||||
if (!key || key === completedResolutionRefreshKey) return;
|
||||
completedResolutionRefreshKey = key;
|
||||
void refreshCompletedResolution(key, localResolutionRequestId, selectedSourceId);
|
||||
});
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, source: string, timeoutMs = SOURCE_TIMEOUT_MS): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const seconds = Math.round(timeoutMs / 1_000);
|
||||
const timer = window.setTimeout(() => reject(new Error(de ? `${source} hat nach ${seconds} Sekunden nicht geantwortet.` : `${source} did not respond within ${seconds} seconds.`)), timeoutMs);
|
||||
promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); });
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRequests(state: "open" | "merged" | "closed" = stateFilter === "draft" ? "open" : stateFilter, sourceId = selectedSourceId) {
|
||||
const source = sources.find((candidate) => candidate.id === sourceId);
|
||||
if (!source) return;
|
||||
const generation = ++loadGeneration;
|
||||
loading = true;
|
||||
errors = [];
|
||||
requests = requests.filter((request) => state === "open" ? request.state !== "open" && request.state !== "draft" : request.state !== state);
|
||||
selectedId = "";
|
||||
detailOpen = false;
|
||||
actionMenuId = "";
|
||||
actionNotice = "";
|
||||
try {
|
||||
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
|
||||
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
|
||||
const loaded = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, state), source.label);
|
||||
if (generation !== loadGeneration) return;
|
||||
requests = [...requests, ...loaded].sort((left, right) => Date.parse(right.updatedAt || right.createdAt) - Date.parse(left.updatedAt || left.createdAt));
|
||||
loadedStates = new Set([...loadedStates, state]);
|
||||
const missingBranches = loaded.filter((request) => !request.sourceBranch || !request.targetBranch);
|
||||
if (missingBranches.length) void enrichMissingBranches(missingBranches, source, credential, generation);
|
||||
} catch (error) {
|
||||
if (generation !== loadGeneration) return;
|
||||
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
|
||||
} finally {
|
||||
if (generation === loadGeneration) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichMissingBranches(items: IntegrationReviewRequest[], source: (typeof sources)[number], credential: StoredCredential, generation: number) {
|
||||
const queue = [...items];
|
||||
const worker = async () => {
|
||||
while (queue.length && generation === loadGeneration) {
|
||||
const request = queue.shift();
|
||||
if (!request) return;
|
||||
try {
|
||||
const details = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label, 20_000);
|
||||
if (generation !== loadGeneration) return;
|
||||
requests = requests.map((item) => item.id === details.id ? details : item);
|
||||
} catch {
|
||||
// Keep the request visible; selecting it retries the detail request and surfaces the error.
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(4, queue.length) }, worker));
|
||||
}
|
||||
|
||||
function selectSource(sourceId: string) {
|
||||
if (sourceId === selectedSourceId) return;
|
||||
selectedSourceId = sourceId;
|
||||
stateFilter = "open";
|
||||
requests = [];
|
||||
errors = [];
|
||||
selectedId = "";
|
||||
detailOpen = false;
|
||||
actionMenuId = "";
|
||||
collapsedRepositories = new Set();
|
||||
loadedStates = new Set();
|
||||
void loadRequests("open", sourceId);
|
||||
}
|
||||
|
||||
function selectState(state: IntegrationReviewState) {
|
||||
stateFilter = state;
|
||||
selectedId = "";
|
||||
detailOpen = false;
|
||||
const apiState = state === "draft" ? "open" : state;
|
||||
if (!loadedStates.has(apiState)) void loadRequests(apiState);
|
||||
}
|
||||
|
||||
function count(state: IntegrationReviewState): number {
|
||||
return requests.filter((request) => request.state === state).length;
|
||||
}
|
||||
|
||||
function countLabel(state: IntegrationReviewState): string {
|
||||
return loadedStates.has(state === "draft" ? "open" : state) ? String(count(state)) : "";
|
||||
}
|
||||
|
||||
function stateLabel(state: IntegrationReviewState): string {
|
||||
if (state === "draft") return "Draft";
|
||||
if (state === "merged") return de ? "Zusammengeführt" : "Merged";
|
||||
if (state === "closed") return de ? "Geschlossen" : "Closed";
|
||||
return "Open";
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
if (!value) return "–";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "–";
|
||||
return new Intl.DateTimeFormat(de ? "de-DE" : "en-US", { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
}
|
||||
|
||||
function formatRelativeDate(value: string): string {
|
||||
if (!value) return "–";
|
||||
const timestamp = Date.parse(value);
|
||||
if (Number.isNaN(timestamp)) return "–";
|
||||
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
|
||||
if (minutes < 1) return de ? "gerade eben" : "just now";
|
||||
if (minutes < 60) return de ? `vor ${minutes} Min.` : `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return de ? `vor ${hours} Std.` : `${hours}h ago`;
|
||||
const days = Math.round(hours / 24);
|
||||
return de ? `vor ${days} T.` : `${days}d ago`;
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return "?";
|
||||
return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : parts[0].slice(0, 2)).toLocaleUpperCase();
|
||||
}
|
||||
|
||||
function actionLabel(provider: IntegrationReviewRequest["provider"]): string {
|
||||
return provider === "azure-devops" ? "Open in Azure" : `Open in ${providerLabel(provider)}`;
|
||||
}
|
||||
|
||||
function requestTypeLabel(provider: IntegrationReviewRequest["provider"]): string {
|
||||
return provider === "gitlab" || provider === "gitlab-self-hosted" ? "Merge Request" : "Pull Request";
|
||||
}
|
||||
|
||||
function toggleRepository(name: string) {
|
||||
const next = new Set(collapsedRepositories);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
collapsedRepositories = next;
|
||||
}
|
||||
|
||||
function formatComment(prefix: string, suffix = prefix) {
|
||||
const textarea = commentTextarea;
|
||||
if (!textarea) return;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selection = commentDraft.slice(start, end);
|
||||
commentDraft = `${commentDraft.slice(0, start)}${prefix}${selection}${suffix}${commentDraft.slice(end)}`;
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
const cursorStart = start + prefix.length;
|
||||
textarea.setSelectionRange(cursorStart, cursorStart + selection.length);
|
||||
});
|
||||
}
|
||||
|
||||
function selectRequest(request: IntegrationReviewRequest, showDetail = false) {
|
||||
if (selectedId !== request.id) commentDraft = "";
|
||||
selectedId = request.id;
|
||||
if (showDetail) detailOpen = true;
|
||||
if (request.changedFiles === null && detailLoadingId !== request.id) void loadDetails(request);
|
||||
}
|
||||
|
||||
function openDetail(request: IntegrationReviewRequest) {
|
||||
selectRequest(request, true);
|
||||
}
|
||||
|
||||
async function loadDetails(request: IntegrationReviewRequest) {
|
||||
const source = activeSource;
|
||||
if (!source) return;
|
||||
detailLoadingId = request.id;
|
||||
try {
|
||||
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
|
||||
if (!credential?.password) return;
|
||||
const details = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label);
|
||||
requests = requests.map((item) => item.id === details.id ? details : item);
|
||||
} catch (error) {
|
||||
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
|
||||
} finally { detailLoadingId = ""; }
|
||||
}
|
||||
|
||||
async function refreshCompletedResolution(key: string, requestId: string, sourceId: string) {
|
||||
const source = sources.find((candidate) => candidate.id === sourceId);
|
||||
const initialRequest = requests.find((candidate) => candidate.id === requestId);
|
||||
if (!source || !initialRequest) return;
|
||||
let request: IntegrationReviewRequest = initialRequest;
|
||||
actionNotice = de ? "Branch wurde gepusht. Der Merge-Status wird geprüft …" : "Branch pushed. Checking the merge status …";
|
||||
try {
|
||||
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
|
||||
if (!credential?.password) return;
|
||||
for (const delay of [700, 1_500, 3_000, 5_000]) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, delay));
|
||||
if (completedResolutionRefreshKey !== key || selectedSourceId !== sourceId) return;
|
||||
const details: IntegrationReviewRequest = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label, 20_000);
|
||||
requests = requests.map((candidate) => candidate.id === details.id ? details : candidate);
|
||||
request = details;
|
||||
if (details.mergeStatus === "mergeable") {
|
||||
actionNotice = de ? "Der Konflikt ist gelöst. Der Request kann jetzt zusammengeführt werden." : "The conflict is resolved. The request can now be merged.";
|
||||
return;
|
||||
}
|
||||
if (details.mergeStatus !== "conflicts" && details.mergeStatus !== "checking") return;
|
||||
}
|
||||
actionNotice = de ? "Der Anbieter prüft den neuen Branch noch. Aktualisiere den Status in einigen Sekunden erneut." : "The provider is still checking the updated branch. Refresh the status again in a few seconds.";
|
||||
} catch (error) {
|
||||
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
|
||||
}
|
||||
}
|
||||
|
||||
function hasConflicts(request: IntegrationReviewRequest): boolean { return request.mergeStatus === "conflicts"; }
|
||||
|
||||
function isLocalResolutionActive(request: IntegrationReviewRequest): boolean {
|
||||
return request.id === localResolutionRequestId && localResolutionPhase !== "idle";
|
||||
}
|
||||
|
||||
function isWaitingForResolvedStatus(request: IntegrationReviewRequest): boolean {
|
||||
return isLocalResolutionActive(request) && localResolutionPhase === "complete" && hasConflicts(request);
|
||||
}
|
||||
|
||||
async function startLocalResolution(request: IntegrationReviewRequest) {
|
||||
const source = activeSource;
|
||||
if (!source || !request.sourceBranch || !request.targetBranch) return;
|
||||
actionMenuId = "";
|
||||
selectedId = request.id;
|
||||
detailOpen = true;
|
||||
await onStartLocalResolution(request, source.id);
|
||||
}
|
||||
|
||||
async function runPrimaryAction(request: IntegrationReviewRequest) {
|
||||
if (isWaitingForResolvedStatus(request)) return;
|
||||
if (hasConflicts(request)) await startLocalResolution(request);
|
||||
else if (request.state === "open" || request.state === "draft") await performReviewAction(request, "merge");
|
||||
else if (request.state === "closed") await performReviewAction(request, "reopen");
|
||||
else await openRequest(request);
|
||||
}
|
||||
|
||||
function primaryActionLabel(request: IntegrationReviewRequest): string {
|
||||
if (isWaitingForResolvedStatus(request)) return de ? "Status wird geprüft" : "Checking status";
|
||||
if (hasConflicts(request)) return de ? "Konflikt lösen" : "Resolve conflict";
|
||||
if (request.state === "open" || request.state === "draft") return de ? "Zusammenführen" : "Merge";
|
||||
if (request.state === "closed") return de ? "Wieder öffnen" : "Reopen";
|
||||
return actionLabel(request.provider);
|
||||
}
|
||||
|
||||
async function openRequest(request: IntegrationReviewRequest | null = selected) {
|
||||
if (!request?.webUrl) return;
|
||||
try { await openInBrowser(request.webUrl); }
|
||||
catch (error) { errors = [{ source: providerLabel(request.provider), message: error instanceof Error ? error.message : String(error) }]; }
|
||||
}
|
||||
|
||||
function reviewActionLabel(action: IntegrationReviewAction): string {
|
||||
if (action === "merge") return de ? "Zusammenführen" : "Merge";
|
||||
if (action === "approve") return de ? "Freigeben" : "Approve";
|
||||
if (action === "close") return de ? "Request schließen" : "Close request";
|
||||
return de ? "Request wieder öffnen" : "Reopen request";
|
||||
}
|
||||
|
||||
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
|
||||
const source = activeSource;
|
||||
if (!source || actionBusyId) return;
|
||||
if (action !== "approve") {
|
||||
const prompt = action === "merge"
|
||||
? (de ? `Request #${request.number} wirklich zusammenführen?` : `Merge request #${request.number}?`)
|
||||
: action === "close"
|
||||
? (de ? `Request #${request.number} wirklich schließen?` : `Close request #${request.number}?`)
|
||||
: (de ? `Request #${request.number} wieder öffnen?` : `Reopen request #${request.number}?`);
|
||||
if (!window.confirm(prompt)) return;
|
||||
}
|
||||
actionMenuId = "";
|
||||
actionNotice = "";
|
||||
actionBusyId = request.id;
|
||||
errors = [];
|
||||
try {
|
||||
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
|
||||
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
|
||||
await withTimeout(runIntegrationReviewAction(source.provider, source.baseUrl, credential.username, credential.password, request, action), source.label);
|
||||
actionNotice = de ? `${reviewActionLabel(action)} erfolgreich.` : `${reviewActionLabel(action)} succeeded.`;
|
||||
requests = [];
|
||||
loadedStates = new Set();
|
||||
await loadRequests(stateFilter === "draft" ? "open" : stateFilter);
|
||||
} catch (error) {
|
||||
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
|
||||
} finally {
|
||||
actionBusyId = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
const request = selected;
|
||||
const source = activeSource;
|
||||
const body = commentDraft.trim();
|
||||
if (!request || !source || !body || commentPosting) return;
|
||||
commentPosting = true;
|
||||
errors = [];
|
||||
try {
|
||||
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
|
||||
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
|
||||
await withTimeout(addIntegrationReviewComment(source.provider, source.baseUrl, credential.username, credential.password, request, body), source.label);
|
||||
commentDraft = "";
|
||||
await loadDetails(request);
|
||||
} catch (error) {
|
||||
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
|
||||
} finally { commentPosting = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class:has-inspector={detailOpen && !!selected} class="review-center" aria-label="Review Center">
|
||||
<header class="review-header">
|
||||
<div class="review-heading"><GitPullRequest size={17} /><h1>Review Center</h1></div>
|
||||
</header>
|
||||
|
||||
{#if sources.length === 0}
|
||||
<div class="empty-state"><div class="empty-icon"><GitPullRequest size={23} /></div><h2>{de ? "Keine Integration eingerichtet" : "No integration configured"}</h2><p>{de ? "Verbinde GitHub, GitLab, Gitea oder Azure DevOps, um Pull- und Merge-Requests hier zu sehen." : "Connect GitHub, GitLab, Gitea, or Azure DevOps to see pull and merge requests here."}</p><button class="primary" type="button" onclick={onOpenSettings}><Settings2 size={14} />{de ? "Integrationen öffnen" : "Open integrations"}</button></div>
|
||||
{:else}
|
||||
<div class="review-toolbar">
|
||||
<nav class="state-tabs" aria-label={de ? "Request-Status" : "Request status"}>
|
||||
{#each ["open", "draft", "merged", "closed"] as state}
|
||||
<button class:active={stateFilter === state} type="button" onclick={() => selectState(state as IntegrationReviewState)}>{state === "draft" ? (de ? "Entwürfe" : "Drafts") : stateLabel(state as IntegrationReviewState)}{#if countLabel(state as IntegrationReviewState)}<span>{countLabel(state as IntegrationReviewState)}</span>{/if}</button>
|
||||
{/each}
|
||||
</nav>
|
||||
<label class="review-search"><Search size={14} /><input bind:value={query} placeholder={de ? "Requests durchsuchen …" : "Search requests …"} /></label>
|
||||
<div class="integration-picker">
|
||||
<SelectMenu value={selectedSourceId} options={sources.map(source => ({ value: source.id, label: source.label }))} ariaLabel={de ? "Integration auswählen" : "Select integration"} onChange={selectSource} />
|
||||
</div>
|
||||
<div class="group-actions">
|
||||
<button class="icon-button" title={de ? "Aktualisieren" : "Refresh"} aria-label={de ? "Aktualisieren" : "Refresh"} type="button" onclick={() => loadRequests()} disabled={loading}><RefreshCw class={loading ? "spin" : ""} size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if actionNotice}<div class="action-notice"><Check size={13} />{actionNotice}</div>{/if}
|
||||
|
||||
{#if errors.length > 0}
|
||||
<details class="source-warning" open>
|
||||
<summary><AlertTriangle size={14} /><span>{backendRestartRequired ? (de ? "Gitty muss neu gestartet werden, damit das Review-Backend geladen wird." : "Restart Gitty to load the Review Center backend.") : (de ? "Die ausgewählte Integration konnte nicht geladen werden." : "The selected integration could not be loaded.")}</span></summary>
|
||||
<ul>{#each errors as error}<li><strong>{error.source}:</strong> {error.message}</li>{/each}</ul>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<div class:inspector-open={detailOpen && selected} class="review-layout">
|
||||
<main class="table-pane">
|
||||
<div class="table-head">
|
||||
<span>Status</span><span>Request</span><span>{de ? "Autor" : "Author"}</span><span>{de ? "Prüfer" : "Reviewers"}</span><span>{de ? "Repository / Branch" : "Repository / Branch"}</span><span>{de ? "Aktion" : "Action"}</span>
|
||||
</div>
|
||||
{#if loading && requests.length === 0}
|
||||
<div class="loading-state"><LoaderCircle class="spin" size={17} />{de ? "Requests werden geladen …" : "Loading requests …"}</div>
|
||||
{:else if groupedRequests.length === 0}
|
||||
<div class="list-empty"><Inbox size={20} /><strong>{de ? "Keine passenden Requests" : "No matching requests"}</strong><span>{de ? "Passe Status oder Suche an." : "Adjust the status or search."}</span></div>
|
||||
{:else}
|
||||
<div class="request-groups">
|
||||
{#each groupedRequests as group (group.name)}
|
||||
<section class="request-group">
|
||||
<button class="group-header" type="button" onclick={() => toggleRepository(group.name)}>
|
||||
{#if collapsedRepositories.has(group.name)}<ChevronRight size={14} />{:else}<ChevronDown size={14} />{/if}
|
||||
<GitBranch size={13} /><strong>{group.name}</strong><span>{group.items.length}</span>
|
||||
</button>
|
||||
{#if !collapsedRepositories.has(group.name)}
|
||||
{#each group.items as request (request.id)}
|
||||
<div class:selected={selectedId === request.id} class="request-row" role="button" tabindex="0" onclick={() => selectRequest(request)} onkeydown={(event) => { if (event.key === "Enter") selectRequest(request); }}>
|
||||
<span class="request-status" class:open={request.state === "open"} class:draft={request.state === "draft"} class:merged={request.state === "merged"} class:closed={request.state === "closed"}>
|
||||
{#if request.state === "merged"}<GitMerge size={13} />{:else if request.state === "closed"}<XCircle size={13} />{:else if request.state === "draft"}<CircleDotDashed size={13} />{:else}<GitPullRequest size={13} />{/if}
|
||||
<span>{stateLabel(request.state)}<small title={formatDate(request.updatedAt || request.createdAt)}>{formatRelativeDate(request.updatedAt || request.createdAt)}</small></span>
|
||||
</span>
|
||||
<span class="request-title"><span><code>#{request.number}</code><strong title={request.title}>{request.title}</strong></span><small class="change-stats"><b>+{request.additions ?? "–"}</b><i>/</i><em>−{request.deletions ?? "–"}</em>{#if request.changedFiles !== null}<span title={de ? "Geänderte Dateien" : "Changed files"}>{request.changedFiles} {request.changedFiles === 1 ? (de ? "Datei geändert" : "file changed") : (de ? "Dateien geändert" : "files changed")}</span>{:else if detailLoadingId === request.id}<span>{de ? "Lädt …" : "Loading …"}</span>{/if}</small></span>
|
||||
<span class="request-author"><i>{initials(request.author)}</i><span>{request.author || (de ? "Unbekannt" : "Unknown")}</span></span>
|
||||
<span class="collaborators">{#if request.collaborators.length}{#each request.collaborators.slice(0, 3) as collaborator}<i title={collaborator}>{initials(collaborator)}</i>{/each}{:else}<span>–</span>{/if}</span>
|
||||
<span class="repo-branch"><strong>{request.repositoryName}</strong>{#if request.sourceBranch && request.targetBranch}<span class="branch-route"><GitBranch size={11} /><code title={request.sourceBranch}>{request.sourceBranch}</code><b>→</b><code title={request.targetBranch}>{request.targetBranch}</code></span>{:else}<span class="branch-loading"><LoaderCircle class="spin" size={11} />{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span>
|
||||
<span class="row-actions">
|
||||
<span class:merge-action={request.state === "open" || request.state === "draft"} class:conflict-action={hasConflicts(request)} class="provider-action">
|
||||
<button class="provider-button" class:merge-primary={request.state === "open" || request.state === "draft"} class:conflict={hasConflicts(request)} disabled={!!actionBusyId || isWaitingForResolvedStatus(request) || (hasConflicts(request) && (!request.sourceBranch || !request.targetBranch || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")))} type="button" onclick={(event) => { event.stopPropagation(); void runPrimaryAction(request); }}>{#if actionBusyId === request.id || isWaitingForResolvedStatus(request) || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")}<LoaderCircle class="spin" size={12} />{:else if request.state === "open" || request.state === "draft"}<GitMerge size={12} />{:else}<ExternalLink size={12} />{/if}{primaryActionLabel(request)}</button>
|
||||
<button class="action-toggle" class:active={actionMenuId === request.id} type="button" aria-label={de ? "Weitere Aktionen" : "More actions"} onclick={(event) => { event.stopPropagation(); actionMenuId = actionMenuId === request.id ? "" : request.id; }}><ChevronDown size={13} /></button>
|
||||
{#if actionMenuId === request.id}
|
||||
<span class="action-menu" role="menu" tabindex="-1">
|
||||
{#if request.state === "open" || request.state === "draft"}
|
||||
{#if isWaitingForResolvedStatus(request)}<button class="menu-merge" type="button" role="menuitem" disabled><LoaderCircle class="spin" size={13} />{de ? "Merge-Status wird geprüft" : "Checking merge status"}</button>{:else if hasConflicts(request)}<button class="menu-conflict" type="button" role="menuitem" disabled={!request.sourceBranch || !request.targetBranch || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")} onclick={() => void startLocalResolution(request)}><GitMerge size={13} />{de ? "Konflikt lokal lösen" : "Resolve conflict locally"}</button>{:else}<button class="menu-merge" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "merge")}><GitMerge size={13} />{reviewActionLabel("merge")}</button>{/if}
|
||||
<button class="menu-approve" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "approve")}><CircleCheck size={14} />{reviewActionLabel("approve")}</button>
|
||||
<button class="menu-provider" type="button" role="menuitem" disabled={!request.webUrl} onclick={() => void openRequest(request)}><ExternalLink size={13} />{actionLabel(request.provider)}</button>
|
||||
<button class="danger" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "close")}><XCircle size={13} />{reviewActionLabel("close")}</button>
|
||||
{:else if request.state === "closed"}
|
||||
<button type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "reopen")}><RotateCcw size={13} />{reviewActionLabel("reopen")}</button>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<button class="panel-button" class:active={detailOpen && selectedId === request.id} title={de ? "Detailpanel öffnen" : "Open detail panel"} aria-label={de ? "Detailpanel öffnen" : "Open detail panel"} type="button" onclick={(event) => { event.stopPropagation(); openDetail(request); }}><PanelRightOpen size={14} /></button>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
{#if detailOpen && selected}
|
||||
<aside bind:this={detailPanel} class="detail-panel" transition:fly={{ x: 140, duration: 210, easing: cubicOut }}>
|
||||
<header class="detail-header">
|
||||
<div class="detail-provider"><GitPullRequest size={17} /><strong>{providerLabel(selected.provider)} {requestTypeLabel(selected.provider)}</strong></div>
|
||||
<div class="detail-header-actions"><button class="icon-button" type="button" aria-label={de ? "Im Anbieter öffnen" : "Open in provider"} onclick={() => void openRequest()}><ExternalLink size={16} /></button><button class="icon-button" type="button" aria-label={de ? "Detailansicht schließen" : "Close details"} onclick={() => { detailOpen = false; }}><X size={17} /></button></div>
|
||||
</header>
|
||||
<div class="detail-content">
|
||||
<main class="detail-main">
|
||||
<section class="detail-title"><div class="detail-title-line"><span>#{selected.number}</span><h2>{selected.title}</h2></div><div class="detail-summary"><span class="state-badge" class:open={selected.state === "open"} class:draft={selected.state === "draft"} class:merged={selected.state === "merged"} class:closed={selected.state === "closed"}>{stateLabel(selected.state)}</span><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong><span class="summary-separator">·</span><span class="detail-branch-route"><GitBranch size={13} />{#if selected.sourceBranch && selected.targetBranch}<code>{selected.sourceBranch}</code><span>→</span><code>{selected.targetBranch}</code>{:else}<span>{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span></div></section>
|
||||
<div class:conflict={hasConflicts(selected) && !isWaitingForResolvedStatus(selected)} class="merge-summary">{#if detailLoadingId === selected.id || isWaitingForResolvedStatus(selected)}<LoaderCircle class="spin" size={14} /><span>{de ? "Der Anbieter prüft den gelösten Branch …" : "The provider is checking the resolved branch …"}</span>{:else if hasConflicts(selected)}<AlertTriangle size={14} /><strong>{de ? "Dieser Request hat Merge-Konflikte." : "This request has merge conflicts."}</strong>{:else}<CircleCheck size={19} /><span>{de ? "Kann ohne erkannte Konflikte zusammengeführt werden." : "Can be merged without detected conflicts."}</span>{/if}<span class="merge-spacer"></span><small class="detail-change-stats"><b>+{selected.additions ?? "–"}</b><em>−{selected.deletions ?? "–"}</em>{#if selected.changedFiles !== null}<span>{selected.changedFiles} {selected.changedFiles === 1 ? (de ? "Datei geändert" : "file changed") : (de ? "Dateien geändert" : "files changed")}</span>{/if}</small></div>
|
||||
{#if hasConflicts(selected) || isLocalResolutionActive(selected)}
|
||||
<div class:error={localResolutionPhase === "error"} class:complete={localResolutionPhase === "complete"} class="local-resolution">
|
||||
<div>{#if localResolutionPhase === "preparing"}<LoaderCircle class="spin" size={14} />{:else if localResolutionPhase === "complete"}<Check size={14} />{:else}<GitMerge size={14} />{/if}<span>{isLocalResolutionActive(selected) && localResolutionMessage ? localResolutionMessage : (de ? "Löse den Konflikt mit dem lokalen Repository und Gittys Konflikt-Editor." : "Resolve this conflict with the local repository and Gitty's conflict editor.")}</span></div>
|
||||
<footer>
|
||||
{#if !isLocalResolutionActive(selected) || localResolutionPhase === "idle" || localResolutionPhase === "error"}<button type="button" onclick={() => void startLocalResolution(selected)} disabled={!selected.sourceBranch || !selected.targetBranch}>{de ? "Konflikt lokal lösen" : "Resolve locally"}</button>{/if}
|
||||
{#if isLocalResolutionActive(selected) && localResolutionPhase === "conflicts"}<button type="button" onclick={() => void onOpenLocalResolver()}>{de ? "Konflikt-Editor öffnen" : "Open conflict editor"}</button><button class="secondary" type="button" onclick={() => void onAbortLocalResolution()}>{de ? "Abbrechen" : "Abort"}</button>{/if}
|
||||
{#if isLocalResolutionActive(selected) && localResolutionPhase === "ready-to-continue"}<button type="button" onclick={() => void onContinueLocalResolution()}>{de ? "Merge abschließen" : "Complete merge"}</button><button class="secondary" type="button" onclick={() => void onAbortLocalResolution()}>{de ? "Abbrechen" : "Abort"}</button>{/if}
|
||||
{#if isLocalResolutionActive(selected) && localResolutionPhase === "ready-to-push"}<button type="button" onclick={() => void onPushLocalResolution()}>{de ? "Gelösten Branch pushen" : "Push resolved branch"}</button>{/if}
|
||||
</footer>
|
||||
</div>
|
||||
{/if}
|
||||
<section class="description"><h3>{de ? "Beschreibung" : "Description"}</h3><p class:muted={!selected.description}>{selected.description || (de ? "Keine Beschreibung vorhanden." : "No description provided.")}</p></section>
|
||||
<section class="comments-section">
|
||||
<header><h3>{de ? "Kommentare" : "Comments"}</h3><span>{selected.comments?.length ?? 0}</span></header>
|
||||
{#if detailLoadingId === selected.id && !(selected.comments?.length)}<div class="comments-loading"><LoaderCircle class="spin" size={13} />{de ? "Kommentare werden geladen …" : "Loading comments …"}</div>{:else if selected.comments?.length}<div class="comment-list">{#each selected.comments as comment (comment.id)}<article><div class="comment-card"><header><span class="avatar">{initials(comment.author)}</span><strong>{comment.author || (de ? "Unbekannt" : "Unknown")}</strong><time>{formatRelativeDate(comment.createdAt)}</time>{#if comment.author === selected.author}<span class="owner-badge" title={de ? "Autor dieses Requests" : "Author of this request"}>{de ? "Autor" : "Author"}</span>{/if}</header><p>{comment.body}</p></div></article>{/each}</div>{:else}<p class="no-comments">{de ? "Noch keine Kommentare." : "No comments yet."}</p>{/if}
|
||||
<form class="comment-composer" onsubmit={(event) => { event.preventDefault(); void submitComment(); }}><textarea bind:this={commentTextarea} bind:value={commentDraft} maxlength="100000" rows="4" placeholder={de ? "Kommentar hinzufügen …" : "Add a comment …"}></textarea><footer><div class="format-actions"><button type="button" title={de ? "Fett" : "Bold"} onclick={() => formatComment("**")}><Bold size={14} /></button><button type="button" title={de ? "Kursiv" : "Italic"} onclick={() => formatComment("_")}><Italic size={14} /></button><button type="button" title="Code" onclick={() => formatComment("`", "`")}><Code2 size={14} /></button><button type="button" title="Link" onclick={() => formatComment("[", "](url)")}><Link size={14} /></button><button type="button" title={de ? "Liste" : "List"} onclick={() => formatComment("- ", "")}><List size={14} /></button><button type="button" title={de ? "Erwähnen" : "Mention"} onclick={() => formatComment("@", "")}><AtSign size={14} /></button></div><button class="submit-comment" type="submit" disabled={!commentDraft.trim() || commentPosting}>{#if commentPosting}<LoaderCircle class="spin" size={13} />{/if}{de ? "Kommentar senden" : "Add comment"}</button></footer></form>
|
||||
</section>
|
||||
</main>
|
||||
<aside class="detail-sidebar">
|
||||
<div class="detail-actions">{#if selected.state === "open" || selected.state === "draft"}{#if isWaitingForResolvedStatus(selected)}<button class="detail-action" type="button" disabled><LoaderCircle class="spin" size={14} />{de ? "Status wird geprüft" : "Checking status"}</button>{:else if hasConflicts(selected)}<button class="detail-action danger-action" type="button" disabled={isLocalResolutionActive(selected) && localResolutionPhase === "preparing"} onclick={() => void startLocalResolution(selected)}><GitMerge size={14} />{de ? "Konflikt lösen" : "Resolve conflict"}</button>{:else}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "merge")}><GitMerge size={14} />{reviewActionLabel("merge")}</button>{/if}<button class="detail-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "approve")}><Check size={14} />{reviewActionLabel("approve")}</button><button class="detail-action danger-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "close")}><XCircle size={14} />{reviewActionLabel("close")}</button>{:else if selected.state === "closed"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "reopen")}><RotateCcw size={14} />{reviewActionLabel("reopen")}</button>{/if}<button class="detail-action" type="button" onclick={() => void openRequest()} disabled={!selected.webUrl}><ExternalLink size={14} />{actionLabel(selected.provider)}</button></div>
|
||||
<section class="people-section"><header><h3>{de ? "Teilnehmer" : "Participants"}</h3></header><div class="people compact"><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong></div></section>
|
||||
<section class="detail-meta"><h3>Repository</h3><strong><GitBranch size={16} />{selected.repositoryName}</strong></section>
|
||||
<section class="detail-meta"><h3>{de ? "Aktualisiert" : "Updated"}</h3><span><Clock3 size={16} />{formatDate(selected.updatedAt || selected.createdAt)}</span></section>
|
||||
</aside>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.review-center{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}.review-center button,.review-center input{font:inherit}.review-header{display:flex;min-height:48px;align-items:center;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.review-heading{display:flex;align-items:center;gap:9px}.review-heading>:global(svg){color:var(--color-accent)}.review-heading h1{margin:0;font-size:16px;font-weight:650}
|
||||
.state-tabs{display:flex;min-height:42px;align-items:stretch;gap:18px;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.state-tabs button{position:relative;display:flex;align-items:center;gap:7px;padding:0 5px;border:0;color:var(--color-ink-muted);background:transparent;font-size:11px}.state-tabs button:hover{color:var(--color-ink)}.state-tabs button.active{color:var(--color-accent)}.state-tabs button.active:after{position:absolute;right:0;bottom:0;left:0;height:2px;background:var(--color-accent);content:""}.state-tabs span,.group-header>span{display:grid;min-width:18px;height:18px;place-items:center;padding:0 5px;border-radius:9px;color:var(--color-ink-muted);background:var(--color-surface-raised);font-size:9.5px}
|
||||
.review-toolbar{display:flex;min-height:48px;align-items:center;gap:10px;padding:7px 18px;border-bottom:1px solid var(--color-border-subtle)}.group-actions{display:flex;align-items:center;gap:2px}.group-actions button,.icon-button{display:inline-flex;height:30px;align-items:center;gap:5px;padding:0 7px;border:1px solid transparent;color:var(--color-ink-muted);background:transparent}.group-actions button:hover,.icon-button:hover{border-color:var(--color-border);color:var(--color-ink);background:var(--color-surface-hover)}.group-actions .icon-button{width:30px;justify-content:center;padding:0;border-color:var(--color-border-subtle);margin-left:3px}.review-search{display:flex;min-width:180px;height:30px;align-items:center;gap:7px;flex:1;padding:0 9px;border:1px solid var(--color-border-input);color:var(--color-ink-faint);background:var(--app-input-bg)}.review-search:focus-within{border-color:var(--color-accent)}.review-search input{width:100%;min-width:0;height:100%;padding:0;border:0;outline:0;color:var(--color-ink);background:transparent}
|
||||
.source-warning{padding:7px 18px;border-bottom:1px solid color-mix(in srgb,#d3a64d 28%,var(--color-border));color:#d3a64d;background:color-mix(in srgb,#d3a64d 6%,var(--app-bg))}.source-warning summary{display:flex;align-items:center;gap:7px;cursor:pointer}.source-warning ul{display:grid;gap:4px;margin:7px 0 2px;padding-left:22px;color:var(--color-ink-muted);font-size:10.5px}.source-warning li strong{color:var(--color-ink)}.action-notice{display:flex;min-height:30px;align-items:center;gap:7px;padding:0 18px;border-bottom:1px solid color-mix(in srgb,#63c783 28%,var(--color-border));color:#63c783;background:color-mix(in srgb,#63c783 6%,var(--app-bg));font-size:10.5px}
|
||||
.review-layout{display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0}.review-layout.inspector-open{grid-template-columns:minmax(560px,1fr) minmax(360px,38%)}.table-pane{min-width:0;min-height:0;overflow:auto}.table-head,.request-row{display:grid;grid-template-columns:95px minmax(280px,1.5fr) 135px 145px minmax(210px,1fr) 190px;align-items:center}.table-head{position:sticky;z-index:2;top:0;min-height:30px;padding:0 12px;border-bottom:1px solid var(--color-border);color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px;font-weight:700;letter-spacing:.035em;text-transform:uppercase}.request-groups{min-width:1075px}.request-group{border-bottom:1px solid var(--color-border-subtle)}.group-header{display:flex;width:100%;height:34px;align-items:center;justify-content:flex-start;gap:7px;padding:0 12px;border:0;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink);background:var(--color-surface);text-align:left}.group-header:hover{background:var(--color-surface-hover)}.group-header>:global(svg){color:var(--color-ink-faint)}.group-header strong{font-size:11px}.group-header>span{margin-left:2px}.request-row{position:relative;min-height:52px;padding:0 12px;border-bottom:1px solid var(--color-border-subtle);outline:1px solid transparent;outline-offset:-1px;color:var(--color-ink-muted);background:transparent;cursor:default}.request-row:hover{background:var(--color-surface-hover)}.request-row.selected{z-index:1;outline-color:var(--color-accent);background:var(--color-surface-raised)}.request-status{display:flex;align-items:center;gap:6px;font-size:10px}.request-status>span{display:grid;gap:2px}.request-status small{color:var(--color-ink-faint);font-size:9px}.request-title{display:grid;min-width:0;gap:5px;padding-right:14px}.request-title>span{display:flex;min-width:0;gap:8px}.request-title code{color:var(--color-accent);font-size:10px}.request-title strong{overflow:hidden;color:var(--color-ink);font-size:11px;font-weight:560;text-overflow:ellipsis;white-space:nowrap}.change-stats{display:flex;align-items:center;gap:6px;font-size:9.5px}.change-stats b{color:#63c783}.change-stats em{color:#e0737b;font-style:normal}.change-stats i{color:var(--color-ink-faint);font-style:normal}.change-stats span{color:var(--color-ink-faint)}.request-author{display:flex;min-width:0;align-items:center;gap:7px;padding-right:10px}.request-author i,.avatar,.collaborators i{display:grid;width:23px;height:23px;flex:0 0 auto;place-items:center;border:1px solid color-mix(in srgb,var(--color-accent) 35%,var(--color-border));border-radius:50%;color:#fff;background:color-mix(in srgb,var(--color-accent) 45%,var(--color-surface-raised));font-size:9px;font-style:normal;font-weight:750}.request-author>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.collaborators{display:flex;padding-left:3px}.collaborators i+ i{margin-left:-5px}.repo-branch{display:grid;min-width:0;gap:4px}.repo-branch>strong{overflow:hidden;color:var(--color-ink-muted);font-size:10px;text-overflow:ellipsis;white-space:nowrap}.branch-route{display:flex;min-width:0;align-items:center;gap:5px;padding-right:12px}.branch-route code{max-width:42%;overflow:hidden;color:var(--color-ink-faint);font-size:9px;text-overflow:ellipsis;white-space:nowrap}.branch-route b{color:var(--color-ink-faint);font-weight:400}.row-actions{display:flex;align-items:center;justify-content:flex-end;gap:5px}.provider-action{position:relative;display:flex}.provider-button,.action-toggle,.detail-action{display:inline-flex;height:28px;align-items:center;justify-content:center;gap:6px;border:1px solid var(--color-accent);color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 5%,transparent)}.provider-button{min-width:120px;padding:0 8px;border-right:0}.provider-button.merge-primary{color:#63c783;border-color:#4fa565;background:color-mix(in srgb,#4fa565 15%,var(--color-surface))}.provider-button.conflict{color:#e0737b;border-color:#a74b55}.action-toggle{width:27px;padding:0}.provider-button:hover,.action-toggle:hover,.action-toggle.active,.detail-action:hover{color:#fff;background:color-mix(in srgb,var(--color-accent) 24%,var(--color-surface))}.action-menu{position:absolute;z-index:8;top:31px;right:0;display:grid;width:178px;padding:4px;border:1px solid var(--color-border);box-shadow:0 8px 22px #0008;background:var(--color-surface-raised)}.action-menu button{display:flex;height:29px;align-items:center;gap:8px;padding:0 8px;border:0;color:var(--color-ink-muted);background:transparent;text-align:left}.action-menu button:hover{color:var(--color-ink);background:var(--color-surface-hover)}.action-menu button.danger{color:#e0737b}.panel-button{display:grid;width:28px;height:28px;place-items:center;border:1px solid var(--color-border);color:var(--color-ink-muted);background:var(--app-button-bg)}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:var(--color-accent)}
|
||||
.detail-panel{min-width:0;min-height:0;overflow:auto;border-left:1px solid var(--color-border);background:var(--color-surface)}.detail-header{display:flex;min-height:78px;align-items:flex-start;justify-content:space-between;gap:14px;padding:15px 16px;border-bottom:1px solid var(--color-border)}.detail-header>div{min-width:0}.detail-content{display:grid;gap:0;padding:0 16px}.detail-summary{display:flex;min-height:48px;align-items:center;gap:8px;border-bottom:1px solid var(--color-border-subtle)}.state-badge{padding:3px 7px;border:1px solid currentColor;font-size:9px;font-weight:700}.detail-summary .avatar{margin-left:4px}.detail-summary strong{font-size:11px}.description{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.description h3{margin:0 0 9px;font-size:11px}.description p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:11px;line-height:1.55}.description p.muted{color:var(--color-ink-faint)}.detail-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:16px}.detail-action{width:100%;height:32px;padding:0 8px}.detail-action.primary-action{color:#63c783;border-color:#63c783}.detail-action.danger-action{color:#e0737b;border-color:color-mix(in srgb,#e0737b 75%,var(--color-border))}.open{color:#63c783}.draft{color:var(--color-ink-muted)}.merged{color:#b785e8}.closed{color:#e0737b}
|
||||
.loading-state,.list-empty,.empty-state{display:flex;min-height:180px;align-items:center;justify-content:center;gap:8px;color:var(--color-ink-muted)}.list-empty,.empty-state{flex-direction:column}.list-empty strong{color:var(--color-ink);font-size:12px}.list-empty span{font-size:10px}.empty-state{flex:1;text-align:center}.empty-state h2{margin:4px 0 0;font-size:14px}.empty-state p{max-width:430px;margin:0 0 8px;line-height:1.5}.empty-icon{display:grid;width:42px;height:42px;place-items:center;border:1px solid var(--color-border);color:var(--color-accent);background:var(--color-surface)}.primary{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-primary);color:#fff;background:var(--color-primary)}
|
||||
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(500px,1fr) 360px}.table-head,.request-row{grid-template-columns:78px minmax(230px,1.5fr) minmax(180px,1fr) 90px 170px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
|
||||
@media(max-width:760px){.review-toolbar{flex-wrap:wrap}.group-actions{order:2}.review-search{order:1;flex-basis:calc(100% - 200px)}.review-layout.inspector-open{display:block}.detail-panel{position:absolute;inset:90px 0 0 15%;z-index:5;box-shadow:-12px 0 30px #0008}.state-tabs{gap:8px}.group-actions button{font-size:0}.group-actions button>:global(svg){margin:0}.table-head,.request-row{grid-template-columns:72px minmax(220px,1fr) 90px 160px}.table-head>span:nth-child(3),.table-head>span:nth-child(4),.request-author,.branch-route{display:none}.request-groups{min-width:580px}}
|
||||
.merge-summary{display:flex;min-height:45px;align-items:center;gap:7px;border-bottom:1px solid var(--color-border-subtle);color:#63c783}.merge-summary.conflict{color:#e0737b}.merge-summary>span,.merge-summary>strong{font-size:10.5px}
|
||||
@media(max-width:1100px){.table-head,.request-row{grid-template-columns:78px minmax(270px,1fr) minmax(190px,1fr) 190px}.table-head>span:nth-child(3),.table-head>span:nth-child(4),.request-author,.collaborators{display:none}.request-groups{min-width:780px}}
|
||||
@media(max-width:760px){.table-head,.request-row{grid-template-columns:72px minmax(260px,1fr) 190px}.table-head>span:nth-child(5),.repo-branch{display:none}.request-groups{min-width:600px}.merge-summary{align-items:flex-start;flex-wrap:wrap;padding:10px 0}}
|
||||
.branch-loading{display:flex;align-items:center;gap:5px;color:var(--color-ink-faint);font-size:9px}
|
||||
.provider-action{overflow:visible;border-radius:2px;box-shadow:0 1px 0 #0005}.provider-button{border-radius:2px 0 0 2px;font-weight:600}.action-toggle{border-radius:0 2px 2px 0;background:color-mix(in srgb,var(--color-accent) 14%,var(--color-surface))}.panel-button{margin-left:2px;border-radius:2px}.review-layout.inspector-open{grid-template-columns:minmax(580px,1fr) minmax(420px,44%)}.detail-header{background:linear-gradient(180deg,color-mix(in srgb,var(--color-surface-raised) 72%,var(--color-surface)),var(--color-surface))}
|
||||
.comments-section{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.comments-section>header{display:flex;align-items:center;gap:7px;margin-bottom:10px}.comments-section h3{margin:0;font-size:11px}.comments-section>header>span{display:grid;min-width:18px;height:18px;place-items:center;border-radius:9px;color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px}.comments-loading,.no-comments{margin:8px 0;color:var(--color-ink-faint);font-size:10px}.comments-loading{display:flex;align-items:center;gap:6px}.comment-list{display:grid;gap:10px;margin-bottom:12px}.comment-list article{display:flex;align-items:flex-start;gap:9px}.comment-list article>div{min-width:0;flex:1;padding:9px 10px;border:1px solid var(--color-border-subtle);border-radius:2px;background:var(--app-bg)}.comment-list article header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}.comment-list article strong{font-size:10.5px}.comment-list article time{color:var(--color-ink-faint);font-size:9px}.comment-list article p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:10.5px;line-height:1.5}.comment-composer{display:flex;align-items:flex-start;gap:9px;margin-top:10px}.comment-composer textarea{box-sizing:border-box;width:100%;min-height:82px;resize:vertical;padding:9px 10px;border:1px solid var(--color-border-input);outline:0;color:var(--color-ink);background:var(--app-input-bg);font:inherit;line-height:1.45}.comment-composer textarea:focus{border-color:var(--color-accent)}.comment-composer button{display:inline-flex;height:29px;align-items:center;gap:6px;padding:0 10px;border:1px solid #4fa565;border-radius:2px;color:#63c783;background:color-mix(in srgb,#4fa565 12%,var(--color-surface))}.comment-composer button:hover:not(:disabled){color:#fff;background:#397849}
|
||||
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(480px,1fr) 380px}}
|
||||
@media(max-width:760px){.review-layout.inspector-open{display:block}.detail-panel{position:absolute;inset:90px 0 0 12%;z-index:5;box-shadow:-12px 0 30px #0008}}
|
||||
.comment-list{max-height:230px;overflow-y:auto;overscroll-behavior:contain;padding-right:5px;scrollbar-gutter:stable}.comment-list::-webkit-scrollbar{width:7px}.comment-list::-webkit-scrollbar-thumb{border:2px solid transparent;border-radius:4px;background:var(--color-border-input);background-clip:padding-box}.comment-list::-webkit-scrollbar-track{background:transparent}
|
||||
.detail-panel{display:flex;flex-direction:column;overflow:hidden}.detail-header{flex:0 0 auto}.detail-content{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.detail-summary,.merge-summary,.description,.detail-actions{flex:0 0 auto}.description p{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3}.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.comments-section>header,.comment-composer{flex:0 0 auto}.comment-list{min-height:0;max-height:none;flex:1;overflow-y:auto}.comment-composer textarea{min-height:68px;max-height:92px}.detail-actions{margin-bottom:12px}
|
||||
.review-center{position:relative}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}.detail-panel{position:absolute;z-index:20;inset:0;display:flex;width:100%;height:100%;flex-direction:column;overflow:hidden;border:0;background:var(--app-bg)}.detail-header{display:flex;min-height:48px;align-items:center;padding:0 20px;border-bottom:1px solid var(--color-border);background:var(--color-surface-raised)}.detail-provider{display:flex;align-items:center;gap:9px;color:var(--color-ink-muted)}.detail-provider>:global(svg){color:var(--color-accent)}.detail-provider strong{font-size:13px;font-weight:600}.detail-header-actions{display:flex;align-items:center;gap:3px;margin-left:auto}.detail-content{display:grid;min-height:0;flex:1;grid-template-columns:minmax(0,1fr) 300px;gap:28px;overflow:hidden;padding:0 28px}.detail-main{display:flex;min-width:0;min-height:0;flex-direction:column;overflow:hidden}.detail-title{flex:0 0 auto;padding:18px 0 0;border-bottom:1px solid var(--color-border-subtle)}.detail-title h2{margin:7px 0 10px;font-size:20px;font-weight:520;line-height:1.25}.detail-title .detail-summary{min-height:34px;border:0}.detail-title .detail-summary>span:last-child{color:var(--color-ink-muted);font-size:10.5px}.detail-main>.description{padding:13px 0}.detail-main>.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;padding:13px 0 18px}.detail-main .merge-summary{flex:0 0 auto;margin-bottom:11px;padding:0 12px;border:1px solid var(--color-border);background:var(--color-surface)}.detail-main .comment-list{min-height:0;flex:1}.detail-main .comment-composer{flex:0 0 auto}.detail-sidebar{display:flex;min-height:0;flex-direction:column;gap:0;overflow:hidden;padding:18px 0;border-left:1px solid var(--color-border-subtle);padding-left:22px;background:var(--color-surface)}.detail-sidebar .detail-actions{display:grid;grid-template-columns:1fr;gap:6px;margin:0 0 8px}.detail-sidebar section{padding:17px 0;border-bottom:1px solid var(--color-border-subtle)}.detail-sidebar section header{display:flex;align-items:center;justify-content:space-between;gap:12px}.detail-sidebar section h3{margin:0;color:var(--color-ink-muted);font-size:11px;font-weight:560}.people{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:8px;margin-top:12px}.people.compact{grid-template-columns:24px}
|
||||
@media(max-width:900px){.detail-content{grid-template-columns:minmax(0,1fr) 270px;gap:18px;padding:0 18px}.detail-sidebar{padding-left:16px}.detail-title h2{font-size:17px}}
|
||||
@media(max-width:680px){.detail-content{display:block;overflow:hidden;padding:0 15px}.detail-main{height:100%}.detail-sidebar{display:none}.detail-panel{inset:0}}
|
||||
.comment-list{align-content:start;grid-auto-rows:max-content}
|
||||
.detail-panel{inset:0 0 0 auto;width:50%;min-width:760px;max-width:100%;height:100%;border-left:1px solid var(--color-border);box-shadow:-14px 0 30px #0007}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}
|
||||
.provider-action.merge-action .action-toggle{border-color:#4fa565;color:#70d58d;background:color-mix(in srgb,#4fa565 15%,var(--color-surface))}.provider-action.merge-action .action-toggle:hover,.provider-action.merge-action .action-toggle.active{color:#fff;background:#397849}.provider-action.conflict-action .action-toggle{border-color:#a74b55;color:#e0737b;background:color-mix(in srgb,#a74b55 12%,var(--color-surface))}.panel-button{border-color:color-mix(in srgb,var(--color-accent) 45%,var(--color-border));color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface))}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:#fff;background:color-mix(in srgb,var(--color-accent) 25%,var(--color-surface))}
|
||||
.local-resolution{display:grid;flex:0 0 auto;gap:9px;margin:0 0 11px;padding:10px 12px;border:1px solid color-mix(in srgb,#e0737b 52%,var(--color-border));background:color-mix(in srgb,#e0737b 6%,var(--color-surface))}.local-resolution>div{display:flex;align-items:flex-start;gap:8px;color:var(--color-ink-muted);font-size:10.5px;line-height:1.45}.local-resolution>div>:global(svg){flex:0 0 auto;margin-top:1px;color:#e0737b}.local-resolution footer{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}.local-resolution button{display:inline-flex;min-height:28px;align-items:center;padding:0 9px;border:1px solid #c65e67;color:#f08a92;background:color-mix(in srgb,#c65e67 12%,var(--color-surface))}.local-resolution button:hover:not(:disabled){color:#fff;background:#8e3f47}.local-resolution button.secondary{border-color:var(--color-border-input);color:var(--color-ink-muted);background:var(--app-button-bg)}.local-resolution.error{border-color:#d29a47;background:color-mix(in srgb,#d29a47 7%,var(--color-surface))}.local-resolution.error>div>:global(svg){color:#d29a47}.local-resolution.complete{border-color:#4fa565;background:color-mix(in srgb,#4fa565 7%,var(--color-surface))}.local-resolution.complete>div>:global(svg){color:#63c783}
|
||||
@media(max-width:760px){.detail-panel{inset:0;width:100%;min-width:0;max-width:none}}
|
||||
|
||||
/* Review Center B — compact hierarchy, overlay inspector, semantic actions. */
|
||||
.review-center{font-size:11px}.review-header{min-height:44px}.review-heading h1{font-size:14px}
|
||||
.review-toolbar{display:grid;min-height:50px;grid-template-columns:auto minmax(220px,1fr) 190px auto;align-items:center;gap:10px;padding:0 14px;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))}
|
||||
.state-tabs{height:100%;min-height:0;gap:3px;padding:0;border:0;background:transparent}.state-tabs button{min-width:58px;justify-content:center;padding:0 9px;font-size:10.5px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:7px;left:7px}.state-tabs span,.group-header>span{min-width:17px;height:17px;padding:0 4px;border:1px solid var(--color-border-subtle);border-radius:1px;font-size:9px}
|
||||
.review-search{min-width:0;height:30px}.review-search:focus-within{box-shadow:inset 2px 0 0 var(--color-accent)}.group-actions{gap:4px}.group-actions .icon-button{width:30px;margin:0;padding:0;border-color:var(--color-border-subtle)}
|
||||
.table-head,.request-row{grid-template-columns:92px minmax(285px,1.65fr) 132px 126px minmax(220px,1fr) 190px}.table-head{min-height:34px;padding:0 16px;border-top:1px solid var(--color-border-subtle);border-bottom-color:var(--color-border);color:color-mix(in srgb,var(--color-ink-faint) 82%,transparent);background:color-mix(in srgb,var(--color-surface-raised) 80%,var(--app-bg));font-size:8.5px;letter-spacing:.07em}.request-groups{min-width:1080px}.request-group{border-bottom:0}.group-header{height:39px;gap:8px;padding:0 16px;border-bottom-color:var(--color-border);color:var(--color-ink);background:color-mix(in srgb,var(--color-surface) 78%,var(--app-bg))}.group-header:hover{background:color-mix(in srgb,var(--color-surface-hover) 82%,var(--app-bg))}.group-header>:global(svg){color:var(--color-accent)}.group-header strong{font-size:11.5px;font-weight:680;letter-spacing:.005em}.group-header>span{margin-left:4px;color:var(--color-ink-muted);background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg))}
|
||||
.request-row{min-height:64px;padding:0 16px;border-bottom-color:color-mix(in srgb,var(--color-border-subtle) 76%,transparent);background:color-mix(in srgb,var(--app-bg) 96%,var(--color-surface))}.request-row:before{position:absolute;top:0;bottom:0;left:0;width:3px;background:transparent;content:""}.request-row:hover{background:color-mix(in srgb,var(--color-surface-hover) 72%,var(--app-bg))}.request-row.selected{outline-color:color-mix(in srgb,var(--color-accent) 74%,var(--color-border));background:color-mix(in srgb,var(--color-accent) 5%,var(--color-surface-raised));box-shadow:inset 0 1px color-mix(in srgb,var(--color-accent) 15%,transparent),inset 0 -1px color-mix(in srgb,var(--color-accent) 15%,transparent)}.request-row.selected:before{background:var(--color-accent);box-shadow:2px 0 10px color-mix(in srgb,var(--color-accent) 24%,transparent)}.request-status{gap:8px}.request-status>:global(svg){width:15px;height:15px}.request-status>span{gap:4px;font-weight:600}.request-status small{font-weight:400}.request-title{gap:7px}.request-title>span{align-items:center}.request-title code{font-size:10.5px;font-weight:650}.request-title strong{font-size:11.5px;font-weight:680}.change-stats{gap:7px;font-size:9.5px}.request-author i,.avatar,.collaborators i{border-radius:1px}.repo-branch{gap:6px}.repo-branch>strong{color:var(--color-ink);font-size:10.5px;font-weight:650}.branch-route code{padding:2px 4px;color:var(--color-ink-muted);background:color-mix(in srgb,var(--color-accent) 6%,transparent)}
|
||||
.provider-action{box-shadow:none}.provider-button,.action-toggle{height:32px}.provider-button{min-width:124px;padding:0 11px;font-weight:650}.provider-button.merge-primary{color:#72d58f;border-color:#4e9f64;background:color-mix(in srgb,#4fa565 9%,var(--app-bg))}.provider-button.merge-primary:hover{color:#fff;background:#397849}.provider-action.merge-action .action-toggle{background:color-mix(in srgb,#4fa565 20%,var(--app-bg))}.provider-button.conflict{color:#e2ac58;border-color:#b88432;background:color-mix(in srgb,#b88432 9%,var(--app-bg))}.provider-action.conflict-action .action-toggle{color:#e2ac58;border-color:#b88432;background:color-mix(in srgb,#b88432 17%,var(--app-bg))}.panel-button{width:32px;height:32px;margin-left:3px;background:color-mix(in srgb,var(--color-accent) 5%,var(--app-bg))}
|
||||
.action-menu{top:36px;width:190px;padding:6px;border-color:color-mix(in srgb,var(--color-border) 86%,var(--color-accent));box-shadow:0 12px 30px #000b,0 0 0 1px #0005;background:color-mix(in srgb,var(--color-surface-raised) 92%,#10151a)}.action-menu:before{position:absolute;top:-5px;right:10px;width:8px;height:8px;transform:rotate(45deg);border-top:1px solid var(--color-border);border-left:1px solid var(--color-border);background:inherit;content:""}.action-menu button{position:relative;height:34px;gap:10px;padding:0 10px;border-left:2px solid transparent;color:var(--color-ink-muted);font-size:10.5px}.action-menu button:hover:not(:disabled){color:var(--color-ink);border-left-color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 8%,var(--color-surface-hover))}.action-menu button>:global(svg){flex:0 0 auto}.action-menu .menu-merge>:global(svg){color:#72d58f}.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-accent)}.action-menu button.danger{margin-top:4px;border-top:1px solid var(--color-border-subtle);color:#e7767e}.action-menu button.danger:hover{border-left-color:#e7767e;background:color-mix(in srgb,#e7767e 8%,var(--color-surface-hover))}.action-menu .menu-conflict{color:#e2ac58}.action-menu button:disabled{opacity:.48}
|
||||
.detail-panel{width:50%;min-width:720px;max-width:960px;box-shadow:-14px 0 30px #0008}.detail-header{min-height:47px;padding:0 16px}.detail-provider strong{font-size:12px}.detail-content{grid-template-columns:minmax(0,1fr) 250px;gap:20px;padding:0 16px}.detail-title{padding:15px 0 10px}.detail-title h2{margin:5px 0 8px;font-size:18px;font-weight:620}.detail-summary{min-height:27px;border:0}.detail-summary .avatar{margin-left:3px}
|
||||
.detail-main>.merge-summary{min-height:43px;margin:11px 0 0;padding:0 10px;border:1px solid color-mix(in srgb,#63c783 48%,var(--color-border));background:color-mix(in srgb,#63c783 6%,var(--color-surface))}.detail-main>.merge-summary.conflict{border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));color:#d6a64f;background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.merge-spacer{flex:1}.detail-change-stats{display:flex;align-items:center;gap:7px;white-space:nowrap;font-size:9px}.detail-change-stats b{color:#63c783}.detail-change-stats em{color:#e0737b;font-style:normal}.detail-change-stats span{color:var(--color-ink-faint)}
|
||||
.detail-main>.description{padding:12px 0}.detail-main>.comments-section{padding:12px 0 14px}.comment-list{gap:8px}.comment-list article{gap:8px}.comment-list article>div{padding:8px 9px;border-radius:0;background:color-mix(in srgb,var(--color-surface) 48%,var(--app-bg))}.comment-composer{margin-top:9px;padding-top:9px;border-top:1px solid var(--color-border-subtle)}.comment-composer textarea{min-height:72px;max-height:92px}.comment-composer button{color:var(--color-accent);border-color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface))}
|
||||
.detail-sidebar{padding:15px 0 15px 16px;background:color-mix(in srgb,var(--color-surface) 58%,var(--app-bg))}.detail-sidebar .detail-actions{gap:6px;margin-bottom:9px}.detail-action{height:31px}.detail-sidebar section{padding:14px 0}.people{grid-template-columns:23px minmax(0,1fr);gap:8px;margin-top:10px}.people strong,.detail-meta strong,.detail-meta span{overflow:hidden;color:var(--color-ink-muted);font-size:10px;font-weight:500;text-overflow:ellipsis}.detail-meta{display:grid;gap:8px}.detail-actions>.danger-action:first-child{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 8%,var(--color-surface))}
|
||||
.local-resolution{margin:8px 0 0;border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.local-resolution>div>:global(svg){color:#d6a64f}.local-resolution button{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 10%,var(--color-surface))}
|
||||
@media(max-width:1280px){.review-toolbar{grid-template-columns:auto minmax(180px,1fr) 155px auto}.state-tabs button{min-width:52px;padding:0 6px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 110px minmax(190px,1fr) 180px}.table-head>span:nth-child(4),.collaborators{display:none}.request-groups{min-width:900px}.detail-panel{width:58%;min-width:680px}}
|
||||
@media(max-width:900px){.review-toolbar{grid-template-columns:1fr 150px auto;grid-template-rows:38px 38px;padding:0 10px}.state-tabs{grid-column:1/-1;grid-row:1}.review-search{grid-column:1;grid-row:2}.group-actions{grid-column:3;grid-row:2}.detail-panel{width:76%;min-width:620px}.detail-content{grid-template-columns:minmax(0,1fr) 220px;gap:14px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 180px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
|
||||
@media(max-width:680px){.state-tabs button{min-width:0;flex:1}.group-actions .icon-button:nth-child(-n+2){display:none}.detail-panel{width:100%;min-width:0;max-width:none}.detail-content{display:block;padding:0 13px}.detail-main{height:100%}.detail-sidebar{display:none}}
|
||||
|
||||
/* Accepted Review Center concept — faithful final layout. */
|
||||
.review-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.review-heading{gap:10px}.review-heading h1{font-size:14px;font-weight:700}
|
||||
.review-toolbar{min-height:55px;grid-template-columns:350px minmax(220px,1fr) 160px 34px;gap:10px;padding:0 16px;border-bottom-color:var(--color-border);background:color-mix(in srgb,var(--app-bg) 88%,var(--color-surface))}.state-tabs{gap:8px}.state-tabs button{min-width:72px;justify-content:flex-start;padding:0 8px;color:var(--color-ink-muted);font-size:11px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:0;left:0;height:2px}.state-tabs span{min-width:17px;height:17px;margin-left:auto;border:0;background:var(--color-surface-raised)}.review-search,.group-actions .icon-button{height:34px;background:color-mix(in srgb,var(--app-input-bg) 92%,#11171c)}.review-search{padding:0 11px}.group-actions .icon-button{width:34px}
|
||||
.table-head,.request-row{grid-template-columns:90px minmax(255px,1.65fr) 105px 120px minmax(180px,1fr) 150px}.table-head{min-height:37px;padding:0 16px;background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg));font-size:8.5px}.request-groups{min-width:900px}.group-header{height:45px;padding:0 17px;background:color-mix(in srgb,var(--app-bg) 72%,var(--color-surface))}.group-header strong{font-size:11.5px}.request-row{min-height:78px;padding:0 16px}.request-row.selected{background:linear-gradient(90deg,color-mix(in srgb,var(--color-accent) 7%,var(--color-surface-raised)),color-mix(in srgb,var(--color-surface-raised) 72%,var(--app-bg)))}.request-title{gap:8px}.request-title strong{font-size:12px}.request-status{font-size:10.5px}.request-author i,.avatar,.collaborators i{width:28px;height:28px;background:color-mix(in srgb,var(--color-accent) 58%,#26333a)}.provider-button,.action-toggle,.panel-button{height:34px}.provider-button{min-width:102px}.action-toggle{width:34px}.panel-button{width:34px}.action-menu{top:38px;width:154px}.action-menu button{height:36px}
|
||||
.detail-panel{width:44%;min-width:680px;max-width:none;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}.detail-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.detail-provider strong{font-size:13px}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:18px;padding:0 0 0 24px}.detail-main{padding-right:0}.detail-title{padding:18px 0 16px}.detail-title-line{display:flex;min-width:0;align-items:baseline;gap:10px}.detail-title-line>span{flex:0 0 auto;color:var(--color-accent);font-size:17px;font-weight:700}.detail-title h2{min-width:0;margin:0;overflow:hidden;font-size:19px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.detail-title .detail-summary{min-height:33px;gap:7px}.detail-summary .avatar{width:23px;height:23px;margin-left:5px}.detail-summary strong{font-size:10.5px}.summary-separator{color:var(--color-ink-faint)}.detail-branch-route{display:flex;min-width:0;align-items:center;gap:6px;color:var(--color-ink-muted)}.detail-branch-route>:global(svg){color:var(--color-ink-muted)}.detail-branch-route code{max-width:95px;overflow:hidden;color:var(--color-ink-muted);font-size:10.5px;text-overflow:ellipsis;white-space:nowrap}.detail-branch-route>span{color:var(--color-ink-faint)}.state-badge{padding:0;border:0;font-size:10.5px}
|
||||
.detail-main>.merge-summary{min-height:55px;margin:0 0 4px;padding:0 12px;border-color:color-mix(in srgb,#63c783 65%,var(--color-border));background:color-mix(in srgb,#63c783 5%,var(--app-bg))}.detail-main>.description{padding:14px 0 18px}.description h3,.comments-section h3{font-size:11.5px}.description p{font-size:10.5px}.detail-main>.comments-section{padding:13px 0 0}.comments-section>header{min-height:28px;margin:0 0 8px}.comments-section>header>span{border-radius:1px}.comment-list{gap:10px;padding-right:0}.comment-list article{display:block}.comment-card{padding:0!important;border:1px solid var(--color-border)!important;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))!important}.comment-card header{min-height:42px;margin:0!important;padding:0 10px;border-bottom:0}.comment-card header .avatar{width:25px;height:25px;margin-right:2px}.comment-card header strong{font-size:10.5px}.comment-card header time{margin-left:auto}.owner-badge{padding:3px 6px;border:1px solid var(--color-border);color:var(--color-ink-faint);font-size:8.5px}.comment-card p{padding:0 44px 13px!important;color:var(--color-ink)!important}
|
||||
.comment-composer{display:grid;gap:0;margin:0;padding:14px 0 18px;border-top:1px solid var(--color-border-subtle)}.comment-composer textarea{min-height:86px;max-height:120px;resize:none;padding:11px 12px;border-bottom:0;background:color-mix(in srgb,var(--app-input-bg) 94%,#11171c)}.comment-composer footer{display:flex;min-height:39px;align-items:center;justify-content:space-between;border:1px solid var(--color-border-input);border-top:0;background:color-mix(in srgb,var(--app-bg) 85%,var(--color-surface))}.format-actions{display:flex;align-items:center;padding-left:5px}.comment-composer .format-actions button{display:grid;width:30px;height:30px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.comment-composer .format-actions button:hover{color:var(--color-ink);background:var(--color-surface-hover)}.comment-composer .submit-comment{height:29px;margin-right:6px;padding:0 11px;border:1px solid var(--color-accent);border-radius:0;color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 5%,var(--app-bg))}.comment-composer .submit-comment:hover:not(:disabled){color:#fff;background:color-mix(in srgb,var(--color-accent) 72%,var(--app-bg))}
|
||||
.detail-sidebar{padding:28px 17px 16px;border-left-color:var(--color-border);background:color-mix(in srgb,var(--color-surface) 46%,var(--app-bg))}.detail-sidebar .detail-actions{gap:10px;margin:0 0 12px}.detail-action{height:39px;font-size:11px}.detail-sidebar section{padding:17px 0}.detail-sidebar section h3{font-size:11.5px}.people{margin-top:12px}.detail-meta{gap:11px}
|
||||
@media(max-width:1280px){.review-toolbar{grid-template-columns:310px minmax(180px,1fr) 145px 34px}.state-tabs{gap:3px}.state-tabs button{min-width:64px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 105px minmax(190px,1fr) 150px}.request-groups{min-width:840px}.detail-panel{width:50%;min-width:650px}}
|
||||
@media(max-width:900px){.review-toolbar{grid-template-columns:1fr 150px 34px;grid-template-rows:40px 40px}.detail-panel{width:72%;min-width:600px}.detail-content{grid-template-columns:minmax(0,1fr) 205px;gap:14px;padding-left:16px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 150px}.request-groups{min-width:720px}}
|
||||
@media(max-width:680px){.detail-panel{width:100%;min-width:0}.detail-content{display:block;padding:0 14px}.detail-sidebar{display:none}.detail-title h2{font-size:16px}.detail-title-line>span{font-size:14px}}
|
||||
|
||||
/* Match the approved reference at its 1672px desktop width. */
|
||||
.review-center{--review-divider:color-mix(in srgb,var(--color-border) 82%,transparent);font-size:13px;background:color-mix(in srgb,var(--app-bg) 96%,#172129)}
|
||||
.review-center.has-inspector{--inspector-width:44%}
|
||||
.review-header,.detail-header{box-sizing:border-box;height:54px;min-height:54px;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}
|
||||
.review-heading h1,.detail-provider strong{font-size:14px}.detail-provider>:global(svg){display:none}.detail-header{gap:4px}.detail-header-actions{display:contents}.detail-header-actions button:first-child{margin-right:auto}.detail-provider{flex:none}
|
||||
.review-toolbar{height:60px;min-height:60px}.review-search input{font-size:13px}.review-center .state-tabs button{font-size:13px}.state-tabs span{font-size:11px}.table-pane{padding:8px 7px 0;box-sizing:border-box}.table-head{padding:0 16px;height:37px}.table-head,.request-row{grid-template-columns:100px minmax(210px,1fr) 106px 120px 165px 145px}.table-head>span{font-size:10px}.request-groups{min-width:900px}.group-header{height:44px;padding:0 12px}.group-header strong{font-size:13px}.request-row{padding:0 10px;min-height:78px}.request-row.selected{outline-color:color-mix(in srgb,var(--color-accent) 50%,var(--color-border));background:color-mix(in srgb,var(--app-bg) 93%,var(--color-accent));box-shadow:none}.request-row.selected:before{width:2px;box-shadow:none}.request-status{font-size:13px;font-weight:400}.request-status>span{font-weight:400}.request-status small{font-size:11px}.request-title strong{font-size:13px;font-weight:650}.request-title code{font-size:12px}.change-stats{font-size:12px}.request-author{font-size:12px}.repo-branch>strong{font-size:12px;font-weight:500}.branch-route code{padding:0;background:none;font-size:11px}.branch-route{gap:6px}
|
||||
.review-center .provider-button{min-width:68px;padding:0 8px;font-size:13px;font-weight:500}.provider-button.merge-primary>:global(svg){display:none}.provider-action .action-toggle{width:32px;background:transparent}.row-actions{gap:7px}.panel-button{margin:0;width:32px}.action-menu{width:154px;padding:4px;background:color-mix(in srgb,var(--app-bg) 90%,var(--color-surface-raised));border:1px solid var(--color-border);box-shadow:0 6px 12px #0005}.action-menu button{font-size:13px;gap:10px;height:37px;padding:0 10px}.action-menu button.danger{margin:0;border-top:0;color:var(--color-ink)}.action-menu button.danger>:global(svg){color:#e7767e}.action-menu .menu-merge>:global(svg),.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-ink-muted)}
|
||||
.detail-panel{min-width:0;width:var(--inspector-width,44%);box-shadow:-2px 0 8px #0002}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:0;padding:0}.detail-main{padding:0 18px 0 24px}.detail-sidebar{padding:28px 17px 16px;background:transparent;border-left:1px solid var(--review-divider)}.detail-title{padding:17px 0 9px;border:0}.detail-title h2{font-size:21px;font-weight:650}.detail-title-line>span{font-size:20px;color:var(--color-ink-muted)}.detail-title .detail-summary{min-height:34px;gap:7px}.detail-summary strong{font-size:12px;font-weight:400}.state-badge{font-size:13px;font-weight:400}.detail-branch-route code{font-size:13px}.detail-main>.merge-summary{min-height:54px;margin:0;padding:0 11px;gap:10px}.merge-summary>span,.merge-summary>strong{font-size:12px}.detail-change-stats{font-size:12px;gap:9px}.merge-summary>:global(svg){flex:none}.detail-main>.description{padding:20px 0 22px}.description h3{font-size:14px;font-weight:600;margin-bottom:10px}.description p{font-size:13px}.detail-main>.comments-section{padding:12px 0 0;overflow:visible;border:0}.comments-section>header{margin-bottom:10px}.comments-section h3{font-size:14px;font-weight:600}.comments-section>header>span{font-size:10px}.comment-list{gap:10px;margin:0;scrollbar-gutter:auto}.comment-list article .comment-card{min-height:78px;border-color:var(--review-divider)!important;background:transparent!important}.comment-list article header{justify-content:flex-start;gap:5px;padding:0 11px}.comment-card header .avatar{margin-right:5px;width:28px;height:28px}.comment-list article strong{font-size:12px;font-weight:500}.comment-list article time{font-size:11px;margin:0}.owner-badge{margin-left:auto;font-size:10px}.comment-list article p{font-size:12px;padding:2px 14px 15px 56px!important}.comment-composer{margin-top:auto;padding:16px 0 26px}.comment-composer textarea{font-size:13px;min-height:86px;height:86px;max-height:86px;border-bottom:1px solid var(--color-border-input);background:transparent}.comment-composer footer{margin-top:8px;border:0;background:transparent;min-height:39px}.comment-composer .submit-comment{height:37px;margin:0;font-size:13px}.format-actions{gap:4px;padding:0}.comment-composer .format-actions button{width:27px}.detail-sidebar .detail-actions{gap:15px;margin-bottom:27px}.review-center .detail-action{font-size:13px;position:relative}.detail-action>:global(svg){position:absolute;left:12px;width:18px;height:18px}.detail-action.primary-action{background:color-mix(in srgb,#63c783 4%,transparent)}.detail-sidebar section{padding:16px 0;border-top:1px solid var(--review-divider);border-bottom:0}.detail-sidebar section h3{font-size:14px;font-weight:600}.people.compact{display:flex;gap:10px}.people strong,.detail-meta strong,.detail-meta span{font-size:12px}.detail-meta strong,.detail-meta span{display:flex;align-items:center;gap:10px}.detail-meta :global(svg){flex:none;color:var(--color-ink-muted)}
|
||||
@media(max-width:1450px){.review-center.has-inspector{--inspector-width:52%}.detail-content{grid-template-columns:minmax(0,1fr) 190px}.detail-main{padding:0 14px}.detail-title h2{font-size:17px}.detail-title-line>span{font-size:17px}.detail-summary{flex-wrap:wrap}.detail-main>.merge-summary{flex-wrap:wrap;gap:7px;padding:9px}.detail-change-stats{font-size:10px}.detail-main>.merge-summary>span{font-size:11px}.detail-sidebar{padding:24px 12px}.format-actions{gap:0}.comment-composer .format-actions button{width:23px}}
|
||||
@media(max-width:900px){.review-center.has-inspector{--inspector-width:100%}.detail-content{display:grid;grid-template-columns:minmax(0,1fr) 200px}.detail-sidebar{display:flex}.detail-main{height:auto}.detail-panel{inset:0 0 0 auto;min-width:0}.group-actions .icon-button{display:flex!important}}
|
||||
@media(max-width:520px){.detail-content{display:flex;flex-direction:column}.detail-main{flex:1;min-height:0}.detail-sidebar{flex:none;padding:8px 14px;border-top:1px solid var(--review-divider);border-left:0}.detail-sidebar section{display:none}.detail-sidebar .detail-actions{grid-template-columns:1fr 1fr;gap:6px;margin:0}.detail-action{height:32px}.detail-header{padding:0 14px}.detail-main>.description{padding:10px 0}.comment-composer{padding-bottom:8px}.detail-title h2{white-space:normal}}
|
||||
|
||||
|
||||
/* The inspector overlays an unchanged background; only viewport size reflows it. */
|
||||
.review-toolbar{box-sizing:border-box;width:100%;height:56px;min-height:56px;grid-template-columns:316px minmax(140px,1fr) 145px 30px;grid-template-rows:1fr;gap:10px;padding:0 14px 0 8px}
|
||||
.review-toolbar .state-tabs{grid-column:1;grid-row:1;gap:8px;align-items:stretch}
|
||||
.review-center .state-tabs button{min-width:0;flex:1;gap:6px;padding:0 6px;justify-content:center;font-size:12px;white-space:nowrap}
|
||||
.state-tabs span{min-width:14px;height:16px;margin-left:0;padding:0 3px;font-size:10px}
|
||||
.state-tabs button.active span{background:transparent}
|
||||
.review-search{grid-column:2;grid-row:1;height:32px;order:0}
|
||||
.review-search input{font-size:12px}
|
||||
.integration-picker{position:relative;grid-column:3;grid-row:1;height:32px;min-width:0}
|
||||
|
||||
|
||||
.review-toolbar .group-actions{grid-column:4;grid-row:1;order:0}
|
||||
.review-toolbar .group-actions .icon-button{width:30px;height:32px}
|
||||
@media(max-width:720px){.review-toolbar{grid-template-columns:minmax(120px,1fr) 145px 30px;grid-template-rows:36px 38px;height:80px;min-height:80px;gap:0 10px}.review-toolbar .state-tabs{grid-column:1/-1;grid-row:1;max-width:316px}.review-search{grid-column:1;grid-row:2}.integration-picker{grid-column:2;grid-row:2}.review-toolbar .group-actions{grid-column:3;grid-row:2}}
|
||||
|
||||
.integration-picker :global(.select-menu){width:100%;height:32px}
|
||||
.integration-picker :global(.select-menu-trigger){height:32px;min-height:32px;font-size:12px}
|
||||
/* Compact request actions, matching the approved menu reference. */
|
||||
.review-center .row-actions .provider-button{min-width:54px;height:27px;min-height:27px;padding:0 8px;font-size:11px;font-weight:500}
|
||||
.review-center .row-actions .action-toggle{width:25px;height:27px;min-height:27px;background:transparent}
|
||||
.review-center .row-actions .action-toggle.active{color:#72d58f;background:color-mix(in srgb,#4fa565 8%,var(--app-bg))}
|
||||
.review-center .row-actions .panel-button{width:27px;height:27px;min-height:27px}
|
||||
.review-center .action-menu{top:36px;right:-2px;box-sizing:border-box;width:max-content;min-width:122px;padding:3px 0;border:1px solid var(--color-border);border-radius:0;background:color-mix(in srgb,var(--app-bg) 96%,var(--color-surface-raised));box-shadow:0 5px 12px #0006}
|
||||
.review-center .action-menu:before{right:calc(50% - 4px)}
|
||||
.review-center .action-menu button{box-sizing:border-box;display:flex;width:100%;height:30px;min-height:30px;justify-content:flex-start;align-items:center;gap:9px;padding:0 11px;margin:0;border:0;border-radius:0;font-size:11px;font-weight:400;line-height:1.2;text-align:left;white-space:nowrap;color:var(--color-ink)}
|
||||
.review-center .action-menu button>:global(svg){width:14px;height:14px;flex:0 0 14px;color:var(--color-ink-muted)}
|
||||
.review-center .action-menu button.danger>:global(svg){color:#e7767e}
|
||||
.review-center .action-menu button.menu-conflict{color:#e2ac58}
|
||||
.review-center .action-menu button:hover:not(:disabled){background:var(--color-surface-hover)}
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<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 groupHeaderCount = new Set(options.map((option) => option.group).filter(Boolean)).size;
|
||||
const desiredHeight = Math.min(300, options.length * 32 + groupHeaderCount * 36 + 12);
|
||||
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
|
||||
const spaceAbove = rect.top - viewportGap;
|
||||
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
|
||||
const maxHeight = Math.max(96, Math.min(desiredHeight, openAbove ? spaceAbove - menuGap : spaceBelow - menuGap));
|
||||
const width = Math.max(rect.width, 180);
|
||||
const left = Math.min(rect.left, window.innerWidth - width - viewportGap);
|
||||
const top = openAbove ? rect.top - menuGap : rect.bottom + menuGap;
|
||||
menuStyle = `left:${Math.max(viewportGap, left)}px;${openAbove ? `bottom:${window.innerHeight - top}px;` : `top:${top}px;`}min-width:${width}px;max-width:${Math.max(180, window.innerWidth - viewportGap * 2)}px;max-height:${maxHeight}px;`;
|
||||
}
|
||||
|
||||
async function show() {
|
||||
if (disabled || enabledIndices.length === 0) return;
|
||||
const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled);
|
||||
activeIndex = selectedIndex >= 0 ? selectedIndex : enabledIndices[0];
|
||||
open = true;
|
||||
await tick();
|
||||
positionMenu();
|
||||
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
function choose(index: number) {
|
||||
const option = options[index];
|
||||
if (!option || option.disabled) return;
|
||||
onChange(option.value);
|
||||
close();
|
||||
trigger?.focus();
|
||||
}
|
||||
|
||||
function moveActive(direction: -1 | 1) {
|
||||
if (enabledIndices.length === 0) return;
|
||||
const current = enabledIndices.indexOf(activeIndex);
|
||||
const next = current < 0
|
||||
? (direction > 0 ? 0 : enabledIndices.length - 1)
|
||||
: (current + direction + enabledIndices.length) % enabledIndices.length;
|
||||
activeIndex = enabledIndices[next];
|
||||
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
if (!open && ["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
void show();
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
moveActive(event.key === "ArrowDown" ? 1 : -1);
|
||||
} else if (event.key === "Home" || event.key === "End") {
|
||||
event.preventDefault();
|
||||
activeIndex = event.key === "Home" ? enabledIndices[0] : enabledIndices[enabledIndices.length - 1];
|
||||
} else if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
choose(activeIndex);
|
||||
} else if (event.key === "Escape" || event.key === "Tab") {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowPointerDown(event: PointerEvent) {
|
||||
if (open && root && !root.contains(event.target as Node)) close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onpointerdown={handleWindowPointerDown} onresize={positionMenu} onscroll={positionMenu} />
|
||||
|
||||
<div class={`select-menu ${className}`} class:open bind:this={root}>
|
||||
<button
|
||||
bind:this={trigger}
|
||||
class="select-menu-trigger"
|
||||
type="button"
|
||||
{disabled}
|
||||
aria-label={ariaLabel || undefined}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? menuId : undefined}
|
||||
onclick={() => open ? close() : void show()}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<span class="select-menu-value" class:placeholder={!selectedOption} title={selectedOption?.label}>
|
||||
{#if showSelectedGroup && selectedOption?.group}<small>{selectedOption.group}</small>{/if}
|
||||
<span>{selectedOption?.label ?? placeholder}</span>
|
||||
</span>
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div id={menuId} class="select-menu-popup" style={menuStyle} role="listbox" aria-label={ariaLabel || undefined}>
|
||||
{#each options as option, index (`${option.value}:${index}`)}
|
||||
{#if option.group && (index === 0 || options[index - 1]?.group !== option.group)}
|
||||
<div class="select-menu-group" role="presentation">
|
||||
<span>{option.group}</span>
|
||||
<small>{groupCount(option.group)}</small>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
id={`${menuId}-option-${index}`}
|
||||
class="select-menu-option"
|
||||
class:active={index === activeIndex}
|
||||
class:selected={option.value === value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
disabled={option.disabled}
|
||||
onmouseenter={() => { if (!option.disabled) activeIndex = index; }}
|
||||
onclick={() => choose(index)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{#if option.value === value}<Check size={14} aria-hidden="true" />{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
|
||||
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
||||
import {
|
||||
Archive,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
FileDiff,
|
||||
FileMinus2,
|
||||
FileType,
|
||||
FileX,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderMinus,
|
||||
FolderTree,
|
||||
FolderX,
|
||||
RotateCcw,
|
||||
} from "@lucide/svelte";
|
||||
import iconUrl from "../../../src-tauri/icons/icon.png";
|
||||
import type { FileStatusKind, GitFileStatus, GitIgnoreKind, GitStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
changedFiles: GitFileStatus[];
|
||||
@@ -8,29 +23,68 @@
|
||||
unstagedCount: number;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
operation: string;
|
||||
status: GitStatus | null;
|
||||
selectedFilePath: string;
|
||||
onSelectFile: (file: GitFileStatus) => void;
|
||||
onStage: (files: GitFileStatus[]) => void;
|
||||
onUnstage: (files: GitFileStatus[]) => void;
|
||||
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
||||
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
|
||||
onDiscardMany: (files: GitFileStatus[]) => void;
|
||||
onStash: (files: GitFileStatus[], label: string) => void;
|
||||
onIgnore: (target: string, kind: GitIgnoreKind) => void;
|
||||
onStopTracking: (target: string, kind: "file" | "folder") => void;
|
||||
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
||||
onStageAll: () => void;
|
||||
onUnstageAll: () => void;
|
||||
}
|
||||
|
||||
type StatusViewMode = "list" | "tree";
|
||||
type StatusLaneKind = "unstaged" | "staged";
|
||||
|
||||
interface StatusFolderNode {
|
||||
kind: "folder";
|
||||
name: string;
|
||||
path: string;
|
||||
depth: number;
|
||||
fileCount: number;
|
||||
children: StatusTreeNode[];
|
||||
}
|
||||
|
||||
interface StatusFileNode {
|
||||
kind: "file";
|
||||
name: string;
|
||||
path: string;
|
||||
depth: number;
|
||||
file: GitFileStatus;
|
||||
}
|
||||
|
||||
type StatusTreeNode = StatusFolderNode | StatusFileNode;
|
||||
|
||||
interface StatusContextTarget {
|
||||
lane: StatusLaneKind;
|
||||
kind: "file" | "folder";
|
||||
label: string;
|
||||
files: GitFileStatus[];
|
||||
}
|
||||
|
||||
let {
|
||||
changedFiles = [],
|
||||
stagedCount = 0,
|
||||
unstagedCount = 0,
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
operation = "",
|
||||
status = null,
|
||||
selectedFilePath = "",
|
||||
onSelectFile = () => {},
|
||||
onStage = () => {},
|
||||
onUnstage = () => {},
|
||||
onDiscard = () => {},
|
||||
onDiscardMany = () => {},
|
||||
onStash = () => {},
|
||||
onIgnore = () => {},
|
||||
onStopTracking = () => {},
|
||||
onPatch = () => {},
|
||||
onStageAll = () => {},
|
||||
onUnstageAll = () => {},
|
||||
@@ -60,8 +114,202 @@
|
||||
return `${file.old_path ?? ""}:${file.path}`;
|
||||
}
|
||||
|
||||
function sortStatusTree(nodes: StatusTreeNode[]) {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "folder") sortStatusTree(node.children);
|
||||
}
|
||||
}
|
||||
|
||||
function buildStatusTree(files: GitFileStatus[]): StatusTreeNode[] {
|
||||
const roots: StatusTreeNode[] = [];
|
||||
const folders = new Map<string, StatusFolderNode>();
|
||||
|
||||
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
||||
const parts = file.path.split(/[\\/]+/).filter(Boolean);
|
||||
if (parts.length === 0) continue;
|
||||
|
||||
let currentPath = "";
|
||||
let siblings = roots;
|
||||
const ancestors: StatusFolderNode[] = [];
|
||||
|
||||
for (let index = 0; index < parts.length - 1; index++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[index]}` : parts[index];
|
||||
let folder = folders.get(currentPath);
|
||||
if (!folder) {
|
||||
folder = {
|
||||
kind: "folder",
|
||||
name: parts[index],
|
||||
path: currentPath,
|
||||
depth: index,
|
||||
fileCount: 0,
|
||||
children: [],
|
||||
};
|
||||
folders.set(currentPath, folder);
|
||||
siblings.push(folder);
|
||||
}
|
||||
ancestors.push(folder);
|
||||
siblings = folder.children;
|
||||
}
|
||||
|
||||
siblings.push({
|
||||
kind: "file",
|
||||
name: parts[parts.length - 1] ?? file.path,
|
||||
path: file.path,
|
||||
depth: Math.max(parts.length - 1, 0),
|
||||
file,
|
||||
});
|
||||
for (const folder of ancestors) folder.fileCount += 1;
|
||||
}
|
||||
|
||||
sortStatusTree(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
function flattenStatusTree(nodes: StatusTreeNode[], lane: StatusLaneKind): StatusTreeNode[] {
|
||||
const visible: StatusTreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
visible.push(node);
|
||||
if (node.kind === "folder" && !collapsedStatusFolders.has(`${lane}:${node.path}`)) {
|
||||
visible.push(...flattenStatusTree(node.children, lane));
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
function listStatusRows(files: GitFileStatus[]): StatusFileNode[] {
|
||||
return files.map((file) => ({
|
||||
kind: "file",
|
||||
name: fileName(file),
|
||||
path: file.path,
|
||||
depth: 0,
|
||||
file,
|
||||
}));
|
||||
}
|
||||
|
||||
let selectedStatusPaths = $state<Set<string>>(new Set());
|
||||
let selectionAnchorKey = $state("");
|
||||
let statusView = $state<StatusViewMode>("list");
|
||||
let collapsedStatusFolders = $state<Set<string>>(new Set());
|
||||
let statusContextTarget = $state<StatusContextTarget | null>(null);
|
||||
let statusContextMenuX = $state(0);
|
||||
let statusContextMenuY = $state(0);
|
||||
let statusContextMenuElement = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function toggleStatusFolder(lane: StatusLaneKind, path: string) {
|
||||
const next = new Set(collapsedStatusFolders);
|
||||
const key = `${lane}:${path}`;
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
collapsedStatusFolders = next;
|
||||
}
|
||||
|
||||
function filesInStatusFolder(lane: StatusLaneKind, path: string): GitFileStatus[] {
|
||||
const prefix = `${path.replace(/\\/g, "/").replace(/\/$/, "")}/`;
|
||||
const files = lane === "unstaged" ? unstagedFiles : stagedFiles;
|
||||
return files.filter((file) => {
|
||||
const currentPath = file.path.replace(/\\/g, "/");
|
||||
const oldPath = file.old_path?.replace(/\\/g, "/") ?? "";
|
||||
return currentPath.startsWith(prefix) || oldPath.startsWith(prefix);
|
||||
});
|
||||
}
|
||||
|
||||
function openStatusContextMenu(
|
||||
event: MouseEvent,
|
||||
lane: StatusLaneKind,
|
||||
kind: "file" | "folder",
|
||||
label: string,
|
||||
files: GitFileStatus[],
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (files.length === 0) return;
|
||||
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288));
|
||||
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174));
|
||||
statusContextTarget = { lane, kind, label, files };
|
||||
requestAnimationFrame(() => {
|
||||
if (!statusContextMenuElement) return;
|
||||
const bounds = statusContextMenuElement.getBoundingClientRect();
|
||||
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - bounds.width - 8));
|
||||
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - bounds.height - 8));
|
||||
});
|
||||
}
|
||||
|
||||
function statusContextName(label: string): string {
|
||||
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
return normalized.split("/").pop() || label;
|
||||
}
|
||||
|
||||
function statusContextParent(label: string): string {
|
||||
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const separator = normalized.lastIndexOf("/");
|
||||
return separator > 0 ? normalized.slice(0, separator) : "Repository root";
|
||||
}
|
||||
|
||||
function closeStatusContextMenu() {
|
||||
statusContextTarget = null;
|
||||
}
|
||||
|
||||
function runStatusContextStageAction() {
|
||||
const target = statusContextTarget;
|
||||
if (!target) return;
|
||||
closeStatusContextMenu();
|
||||
if (target.lane === "unstaged") onStage(target.files);
|
||||
else onUnstage(target.files);
|
||||
}
|
||||
|
||||
function runStatusContextStashAction() {
|
||||
const target = statusContextTarget;
|
||||
if (!target) return;
|
||||
closeStatusContextMenu();
|
||||
onStash(target.files, target.label);
|
||||
}
|
||||
|
||||
function isIgnoreableNewFile(file: GitFileStatus): boolean {
|
||||
const path = file.path.replace(/\\/g, "/").toLowerCase();
|
||||
if (path === ".gitignore") return false;
|
||||
return file.unstaged === "untracked" || (file.staged === "added" && file.old_path === null);
|
||||
}
|
||||
|
||||
function isTrackedStatusFile(file: GitFileStatus): boolean {
|
||||
return file.unstaged !== "untracked" && file.staged !== "deleted";
|
||||
}
|
||||
|
||||
function statusContextExtension(label: string): string {
|
||||
const name = statusContextName(label);
|
||||
const separator = name.lastIndexOf(".");
|
||||
return separator > 0 && separator < name.length - 1 ? name.slice(separator + 1) : "";
|
||||
}
|
||||
|
||||
function statusContextFolder(target: StatusContextTarget): string {
|
||||
if (target.kind === "folder") return target.label.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalized = target.label.replace(/\\/g, "/");
|
||||
const separator = normalized.lastIndexOf("/");
|
||||
return separator > 0 ? normalized.slice(0, separator) : "";
|
||||
}
|
||||
|
||||
function runStatusContextIgnoreAction(kind: GitIgnoreKind) {
|
||||
const target = statusContextTarget;
|
||||
if (!target) return;
|
||||
const ignoreTarget = kind === "folder" ? statusContextFolder(target) : target.label;
|
||||
if (!ignoreTarget) return;
|
||||
closeStatusContextMenu();
|
||||
onIgnore(ignoreTarget, kind);
|
||||
}
|
||||
|
||||
function runStatusContextStopTracking() {
|
||||
const target = statusContextTarget;
|
||||
if (!target) return;
|
||||
closeStatusContextMenu();
|
||||
onStopTracking(target.label, target.kind);
|
||||
}
|
||||
|
||||
function handleStatusWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeStatusContextMenu();
|
||||
}
|
||||
|
||||
function isStatusSelected(file: GitFileStatus): boolean {
|
||||
return selectedStatusPaths.has(fileKey(file));
|
||||
@@ -122,11 +370,35 @@
|
||||
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 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 unstagedTree = $derived(buildStatusTree(unstagedFiles));
|
||||
let stagedTree = $derived(buildStatusTree(stagedFiles));
|
||||
let visibleUnstagedRows = $derived(statusView === "tree" ? flattenStatusTree(unstagedTree, "unstaged") : listStatusRows(unstagedFiles));
|
||||
let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles));
|
||||
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
|
||||
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
|
||||
let statusContextCanIgnore = $derived(statusContextTarget?.files.some(isIgnoreableNewFile) ?? false);
|
||||
let statusContextCanStopTracking = $derived(statusContextTarget?.files.some(isTrackedStatusFile) ?? false);
|
||||
let statusContextIgnoreExtension = $derived(statusContextTarget?.kind === "file" ? statusContextExtension(statusContextTarget.label) : "");
|
||||
let statusContextIgnoreFolder = $derived(statusContextTarget ? statusContextFolder(statusContextTarget) : "");
|
||||
|
||||
$effect(() => {
|
||||
const validKeys = new Set(changedFiles.map(fileKey));
|
||||
@@ -136,65 +408,32 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="panel grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status">
|
||||
<svelte:window onclick={closeStatusContextMenu} onkeydown={handleStatusWindowKeydown} />
|
||||
|
||||
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Working tree</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Status</h2>
|
||||
<span class="eyebrow">Workspace</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="status-view-switch" role="group" aria-label="Changes view">
|
||||
<button class:active={statusView === "list"} type="button" onclick={() => statusView = "list"} title="List view" aria-label="List view" aria-pressed={statusView === "list"}>
|
||||
<i class="status-view-icon list-icon" aria-hidden="true"></i><span>List</span>
|
||||
</button>
|
||||
<button class:active={statusView === "tree"} type="button" onclick={() => statusView = "tree"} title="Tree view" aria-label="Tree view" aria-pressed={statusView === "tree"}>
|
||||
<FolderTree size={13} aria-hidden="true" /><span>Tree</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="status-head-actions">
|
||||
<span class="pill pill-count">{stagedCount} staged</span>
|
||||
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if hasRepository && changedFiles.length > 0}
|
||||
<div class="status-toolbar">
|
||||
<button
|
||||
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
|
||||
{#if hasRepository && changedFiles.length > 0}
|
||||
<button class="btn-sm status-discard-all" type="button" onclick={() => onDiscardMany(changedFiles)} disabled={isBusy} title="Discard all staged and unstaged changes">
|
||||
<RotateCcw size={13} aria-hidden="true" /> Discard all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
@@ -203,79 +442,371 @@
|
||||
{:else if changedFiles.length === 0}
|
||||
<div class="blank-state">No file changes returned.</div>
|
||||
{:else}
|
||||
<div class="overflow-auto p-2">
|
||||
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||
<article
|
||||
class="file-row"
|
||||
class:selected={isStatusSelected(file)}
|
||||
class:active={selectedFilePath === file.path}
|
||||
>
|
||||
<div class="file-title">
|
||||
<button
|
||||
class="file-title-button"
|
||||
type="button"
|
||||
onclick={(event) => handleFileSelect(event, file)}
|
||||
title={`Select ${displayPath(file)} in Explorer`}
|
||||
>
|
||||
<strong>{fileName(file)}</strong>
|
||||
<div class="status-lanes">
|
||||
<section class="status-lane unstaged-lane" aria-label="Unstaged changes">
|
||||
<header class="status-lane-head">
|
||||
<div class="status-lane-title">
|
||||
<div class="status-lane-copy"><strong>Unstaged</strong><small>Working tree</small></div>
|
||||
<span class="status-lane-count">{unstagedCount}</span>
|
||||
</div>
|
||||
<div class="status-lane-actions">
|
||||
{#if selectedUnstagedCount > 1}
|
||||
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
|
||||
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={`Stage ${selectedUnstagedCount} selected files`}>
|
||||
<ArrowRight size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
|
||||
<ArrowRight size={13} aria-hidden="true" /> Stage all
|
||||
</button>
|
||||
{#if selectedUnstagedCount > 1}
|
||||
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={`Discard unstaged changes in ${selectedUnstagedCount} selected files`}>
|
||||
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedUnstagedCount}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="change-lanes">
|
||||
<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 class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
|
||||
<FileDiff size={14} aria-hidden="true" />
|
||||
Details
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="change-lane" class:inactive={!file.unstaged}>
|
||||
<div class="lane-header">
|
||||
<span class="lane-name">Unstaged</span>
|
||||
</header>
|
||||
<div class="status-file-list">
|
||||
{#each visibleUnstagedRows as row (`unstaged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
|
||||
{#if row.kind === "folder"}
|
||||
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("unstaged", row.path)} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "folder", row.path, filesInStatusFolder("unstaged", row.path))} title={row.path} aria-expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)}>
|
||||
<i class:expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
|
||||
{#if collapsedStatusFolders.has(`unstaged:${row.path}`)}<Folder class="status-folder-lucide" size={15} aria-hidden="true" />{:else}<FolderOpen class="status-folder-lucide" size={15} aria-hidden="true" />{/if}
|
||||
<strong>{row.name}</strong>
|
||||
<span class="status-folder-count">{row.fileCount}</span>
|
||||
</button>
|
||||
{:else}
|
||||
{@const file = row.file}
|
||||
{@const stageTargets = selectedStageTargets(file)}
|
||||
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "file", file.path, [file])}>
|
||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||
<strong>{fileName(file)}</strong>
|
||||
<span>{displayPath(file)}</span>
|
||||
</button>
|
||||
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
||||
</div>
|
||||
<div class="lane-actions">
|
||||
{#if file.unstaged}
|
||||
{@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"}>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{stageTargets.length > 1 ? `Stage ${stageTargets.length}` : "Stage"}
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
|
||||
<FileDiff size={14} aria-hidden="true" />
|
||||
Details
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Discard
|
||||
</button>
|
||||
{:else}
|
||||
<span class="quiet">No unstaged change</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-file-actions">
|
||||
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><ArrowRight size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="status-flow-divider" aria-hidden="true">
|
||||
<span><ArrowRight size={12} /></span>
|
||||
</div>
|
||||
|
||||
<section class="status-lane staged-lane" aria-label="Staged changes">
|
||||
<header class="status-lane-head">
|
||||
<div class="status-lane-title">
|
||||
<div class="status-lane-copy"><strong>Staged</strong><small>Next commit</small></div>
|
||||
<span class="status-lane-count">{stagedCount}</span>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
<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`}>
|
||||
<ArrowLeft 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">
|
||||
<ArrowLeft 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 visibleStagedRows as row (`staged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
|
||||
{#if row.kind === "folder"}
|
||||
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("staged", row.path)} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "folder", row.path, filesInStatusFolder("staged", row.path))} title={row.path} aria-expanded={!collapsedStatusFolders.has(`staged:${row.path}`)}>
|
||||
<i class:expanded={!collapsedStatusFolders.has(`staged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
|
||||
{#if collapsedStatusFolders.has(`staged:${row.path}`)}<Folder class="status-folder-lucide" size={15} aria-hidden="true" />{:else}<FolderOpen class="status-folder-lucide" size={15} aria-hidden="true" />{/if}
|
||||
<strong>{row.name}</strong>
|
||||
<span class="status-folder-count">{row.fileCount}</span>
|
||||
</button>
|
||||
{:else}
|
||||
{@const file = row.file}
|
||||
{@const unstageTargets = selectedUnstageTargets(file)}
|
||||
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "file", file.path, [file])}>
|
||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||
<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"><ArrowLeft size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||
<button 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>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isBusy && hasRepository}
|
||||
<div class="status-panel-overlay" role="status" aria-live="polite">
|
||||
<div class="status-panel-overlay-card">
|
||||
<div class="status-panel-overlay-mark">
|
||||
<span class="status-panel-overlay-halo halo-one"></span>
|
||||
<span class="status-panel-overlay-halo halo-two"></span>
|
||||
<svg class="status-panel-overlay-traces" viewBox="0 0 220 220" aria-hidden="true">
|
||||
<path class="trace trace-main" d="M28 154 C72 114, 88 108, 110 110 S156 116, 192 68" />
|
||||
<path class="trace trace-branch" d="M62 74 C100 88, 126 124, 158 166" />
|
||||
<path class="trace trace-cut" d="M46 180 L174 180" />
|
||||
</svg>
|
||||
<img src={iconUrl} alt="" class="status-panel-overlay-icon" />
|
||||
</div>
|
||||
<span class="status-panel-overlay-label">{operation || "Working"}…</span>
|
||||
<div class="status-panel-overlay-bar"><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if statusContextTarget}
|
||||
<div bind:this={statusContextMenuElement} class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
|
||||
<div class="status-context-label">
|
||||
<span class="status-context-object-icon" aria-hidden="true">
|
||||
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
|
||||
</span>
|
||||
<span class="status-context-object-copy">
|
||||
<span class="status-context-kind">{statusContextTarget.lane === "unstaged" ? "Unstaged" : "Staged"} {statusContextTarget.kind}</span>
|
||||
<strong title={statusContextTarget.label}>{statusContextName(statusContextTarget.label)}</strong>
|
||||
<span class="status-context-path" title={statusContextTarget.label}><Folder size={10} aria-hidden="true" />{statusContextParent(statusContextTarget.label)}</span>
|
||||
</span>
|
||||
<span class="status-context-count" title={`${statusContextTarget.files.length} ${statusContextTarget.files.length === 1 ? "file" : "files"}`}>
|
||||
{statusContextTarget.files.length}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" role="menuitem" onclick={runStatusContextStageAction} disabled={isBusy}>
|
||||
<span class="status-context-action-icon" aria-hidden="true">
|
||||
{#if statusContextTarget.lane === "unstaged"}<ArrowRight size={15} />{:else}<ArrowLeft size={15} />{/if}
|
||||
</span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>{statusContextTarget.lane === "unstaged" ? "Stage" : "Unstage"} {statusContextTarget.kind}</strong>
|
||||
<span>{statusContextTarget.lane === "unstaged" ? "Add to the next commit" : "Move back to working changes"}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={runStatusContextStashAction} disabled={isBusy}>
|
||||
<span class="status-context-action-icon" aria-hidden="true"><Archive size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Stash {statusContextTarget.kind}</strong>
|
||||
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if statusContextCanIgnore || statusContextCanStopTracking}
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
{/if}
|
||||
{#if statusContextCanStopTracking}
|
||||
<button type="button" role="menuitem" onclick={runStatusContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
|
||||
<span class="status-context-action-icon untrack" aria-hidden="true">
|
||||
{#if statusContextTarget.kind === "folder"}<FolderMinus size={15} />{:else}<FileMinus2 size={15} />{/if}
|
||||
</span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Stop tracking {statusContextTarget.kind}</strong>
|
||||
<span>Keep it on disk and remove it from Git</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextCanIgnore}
|
||||
{#if statusContextTarget.kind === "file"}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("file")} disabled={isBusy} title={`Add /${statusContextTarget.label.replace(/\\/g, "/")} to .gitignore`}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FileX size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore file</strong>
|
||||
<span>Add only this file to .gitignore</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextIgnoreExtension}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("extension")} disabled={isBusy} title={`Add *.${statusContextIgnoreExtension} to .gitignore`}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FileType size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore all *.{statusContextIgnoreExtension} files</strong>
|
||||
<span>Match this file type repository-wide</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextTarget.kind === "folder" && statusContextIgnoreFolder}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("folder")} disabled={isBusy} title={`Add /${statusContextIgnoreFolder}/ to .gitignore`}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FolderX size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore folder</strong>
|
||||
<span>Add this folder and its contents to .gitignore</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.status-panel-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 50% 38%,
|
||||
color-mix(in srgb, var(--color-accent) 13%, transparent),
|
||||
transparent 55%
|
||||
),
|
||||
var(--app-dialog-backdrop);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: status-panel-overlay-in 120ms ease;
|
||||
}
|
||||
|
||||
.status-panel-overlay-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: min(300px, calc(100% - 32px));
|
||||
padding: 26px 28px 26px;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
var(--app-panel-highlight),
|
||||
var(--app-dialog-bg);
|
||||
color: var(--color-ink);
|
||||
box-shadow: var(--app-dialog-shadow);
|
||||
}
|
||||
|
||||
.status-panel-overlay-mark {
|
||||
position: relative;
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.status-panel-overlay-halo {
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 34%, transparent);
|
||||
border-radius: 22px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.status-panel-overlay-halo.halo-one {
|
||||
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
.status-panel-overlay-halo.halo-two {
|
||||
inset: 16px;
|
||||
border-color: color-mix(in srgb, var(--color-accent) 38%, transparent);
|
||||
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
.status-panel-overlay-traces {
|
||||
position: absolute;
|
||||
inset: -12px;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
overflow: visible;
|
||||
z-index: 0;
|
||||
}
|
||||
.status-panel-overlay-traces .trace {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 165;
|
||||
stroke-dashoffset: 165;
|
||||
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--color-accent) 38%, transparent));
|
||||
animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite;
|
||||
}
|
||||
.status-panel-overlay-traces .trace-main { stroke: var(--color-primary); }
|
||||
.status-panel-overlay-traces .trace-branch {
|
||||
stroke: var(--color-accent);
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
.status-panel-overlay-traces .trace-cut {
|
||||
stroke: color-mix(in srgb, var(--color-ink-muted) 52%, transparent);
|
||||
stroke-dasharray: 128;
|
||||
stroke-dashoffset: 128;
|
||||
animation-delay: 0.55s;
|
||||
}
|
||||
|
||||
.status-panel-overlay-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
filter:
|
||||
drop-shadow(0 8px 12px color-mix(in srgb, var(--color-ink) 24%, transparent))
|
||||
drop-shadow(0 0 10px color-mix(in srgb, var(--color-accent) 24%, transparent));
|
||||
animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-panel-overlay-label {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--color-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-panel-overlay-bar {
|
||||
position: relative;
|
||||
width: min(190px, 100%);
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface-dim));
|
||||
}
|
||||
.status-panel-overlay-bar span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 46%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, transparent, var(--color-primary) 40%, var(--color-accent) 74%, transparent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--color-accent) 32%, transparent);
|
||||
animation: status-panel-overlay-bar-slide 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes status-panel-overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes status-panel-overlay-icon-float {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(-3px) scale(1.015); }
|
||||
}
|
||||
@keyframes status-panel-overlay-halo-breathe {
|
||||
0%, 100% { opacity: 0.35; transform: rotate(45deg) scale(0.95); }
|
||||
50% { opacity: 0.8; transform: rotate(45deg) scale(1.04); }
|
||||
}
|
||||
@keyframes status-panel-overlay-bar-slide {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(320%); }
|
||||
}
|
||||
@keyframes status-panel-overlay-trace-draw {
|
||||
0% { stroke-dashoffset: 165; opacity: 0; }
|
||||
36% { opacity: 1; }
|
||||
64%, 100% { stroke-dashoffset: 0; opacity: 0.72; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-panel-overlay,
|
||||
.status-panel-overlay-halo,
|
||||
.status-panel-overlay-traces .trace,
|
||||
.status-panel-overlay-icon,
|
||||
.status-panel-overlay-bar span { animation: none; }
|
||||
.status-panel-overlay-traces .trace { stroke-dashoffset: 0; opacity: 0.72; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -53,7 +53,7 @@
|
||||
);
|
||||
const versionLabel = $derived(
|
||||
version && currentVersion
|
||||
? `${currentVersion} -> ${version}`
|
||||
? `${currentVersion} → ${version}`
|
||||
: version
|
||||
? `Version ${version}`
|
||||
: "New version",
|
||||
@@ -69,34 +69,32 @@
|
||||
role={state === "error" ? "alert" : "status"}
|
||||
aria-live={state === "error" ? "assertive" : "polite"}
|
||||
>
|
||||
<div class="update-toast-glow" aria-hidden="true"></div>
|
||||
|
||||
<div class="update-toast-icon" class:busy={isBusy}>
|
||||
{#if state === "downloading"}
|
||||
<LoaderCircle class="spin" size={21} aria-hidden="true" />
|
||||
{:else if state === "installed"}
|
||||
<PackageCheck size={21} aria-hidden="true" />
|
||||
{:else if state === "error"}
|
||||
<AlertCircle size={21} aria-hidden="true" />
|
||||
{:else}
|
||||
<Sparkles size={21} aria-hidden="true" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="update-toast-content">
|
||||
<div class="update-toast-top">
|
||||
<div class="update-toast-copy">
|
||||
<span class="update-toast-kicker">{versionLabel}</span>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
|
||||
{#if !isBusy}
|
||||
<button class="update-toast-close" type="button" onclick={onDismiss} title="Close" aria-label="Close">
|
||||
<X size={15} aria-hidden="true" />
|
||||
</button>
|
||||
<header class="update-toast-header">
|
||||
<div class="update-toast-icon" class:busy={isBusy}>
|
||||
{#if state === "downloading"}
|
||||
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||
{:else if state === "installed"}
|
||||
<PackageCheck size={18} aria-hidden="true" />
|
||||
{:else if state === "error"}
|
||||
<AlertCircle size={18} aria-hidden="true" />
|
||||
{:else}
|
||||
<Sparkles size={18} aria-hidden="true" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="update-toast-copy">
|
||||
<span class="update-toast-kicker">Gitty update · {versionLabel}</span>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
|
||||
{#if !isBusy}
|
||||
<button class="update-toast-close" type="button" onclick={onDismiss} title="Close" aria-label="Close">
|
||||
<X size={15} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="update-toast-body">
|
||||
<p>{description}</p>
|
||||
|
||||
{#if showProgress}
|
||||
@@ -115,33 +113,34 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="update-toast-actions">
|
||||
{#if state === "installed"}
|
||||
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
|
||||
Got it
|
||||
</button>
|
||||
{:else if state === "error"}
|
||||
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
|
||||
Close
|
||||
</button>
|
||||
<button class="update-toast-primary" type="button" onclick={onInstall}>
|
||||
<Download size={14} aria-hidden="true" />
|
||||
Try again
|
||||
</button>
|
||||
{:else}
|
||||
<button class="update-toast-secondary" type="button" onclick={onLater} disabled={isBusy}>
|
||||
Later
|
||||
</button>
|
||||
<button class="update-toast-primary" type="button" onclick={onInstall} disabled={isBusy}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
||||
Installing
|
||||
{:else}
|
||||
<Download size={14} aria-hidden="true" />
|
||||
Install
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="update-toast-actions">
|
||||
{#if state === "installed"}
|
||||
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
|
||||
Got it
|
||||
</button>
|
||||
{:else if state === "error"}
|
||||
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
|
||||
Close
|
||||
</button>
|
||||
<button class="update-toast-primary" type="button" onclick={onInstall}>
|
||||
<Download size={14} aria-hidden="true" />
|
||||
Try again
|
||||
</button>
|
||||
{:else}
|
||||
<button class="update-toast-secondary" type="button" onclick={onLater} disabled={isBusy}>
|
||||
Later
|
||||
</button>
|
||||
<button class="update-toast-primary" type="button" onclick={onInstall} disabled={isBusy}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
||||
Installing
|
||||
{:else}
|
||||
<Download size={14} aria-hidden="true" />
|
||||
Install
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@@ -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 —
|
||||
* the same granularity Azure DevOps / GitHub use. Examples:
|
||||
@@ -37,13 +35,6 @@ export function orgKeyFromUrl(raw: string): string | null {
|
||||
return org ? `${host}/${org}` : host;
|
||||
}
|
||||
|
||||
/** A stored credential is expired only if it carries a past expiry date. */
|
||||
export function isCredentialExpired(cred: StoredCredential): boolean {
|
||||
if (!cred.expiresAt) return false;
|
||||
const time = new Date(cred.expiresAt).getTime();
|
||||
return !Number.isNaN(time) && time < Date.now();
|
||||
}
|
||||
|
||||
const AUTH_PREFIX = "AUTH_FAILED:";
|
||||
|
||||
export function isAuthError(message: string): boolean {
|
||||
@@ -55,3 +46,17 @@ export function stripAuthPrefix(message: string): string {
|
||||
? message.slice(AUTH_PREFIX.length).trim()
|
||||
: 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;
|
||||
}
|
||||
+313
-37
@@ -1,20 +1,37 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { tracedInvoke as invoke } from "./telemetry";
|
||||
|
||||
import type {
|
||||
CommitAiLocalProfile,
|
||||
AiReviewResult,
|
||||
AiCommitPlan,
|
||||
BisectState,
|
||||
CommitAiProvider,
|
||||
CommitAiStatus,
|
||||
CloneOptions,
|
||||
ConflictFile,
|
||||
DetectedExternalTool,
|
||||
ExternalToolCommand,
|
||||
ExternalDiffScope,
|
||||
GitBlameResult,
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
GitCommitComparison,
|
||||
GitIgnoreKind,
|
||||
GitIntegrationProvider,
|
||||
GitIntegrationRepository,
|
||||
IntegrationReviewRequest,
|
||||
IntegrationReviewAction,
|
||||
GitLfsStatus,
|
||||
GitRepositoryFile,
|
||||
GitRemote,
|
||||
MergeStrategy,
|
||||
PullStrategy,
|
||||
RebaseCommit,
|
||||
RebasePlanItem,
|
||||
ReflogEntry,
|
||||
GitSearchHit,
|
||||
GitStash,
|
||||
GitStatus,
|
||||
GitTag,
|
||||
LocalModelOption,
|
||||
GitWorktree,
|
||||
PatchApplyAction,
|
||||
RepositoryBundle,
|
||||
StoredCredential,
|
||||
@@ -24,6 +41,10 @@ export function openRepository(path: string): Promise<GitStatus> {
|
||||
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> {
|
||||
return invoke<void>("open_repo_in_explorer", { path });
|
||||
}
|
||||
@@ -32,6 +53,46 @@ export function openRepositoryFile(path: string, file: string): Promise<void> {
|
||||
return invoke<void>("open_repository_file", { path, file });
|
||||
}
|
||||
|
||||
export function detectExternalTools(): Promise<DetectedExternalTool[]> {
|
||||
return invoke<DetectedExternalTool[]>("detect_external_tools");
|
||||
}
|
||||
|
||||
export function listIntegrationRepositories(provider: GitIntegrationProvider, baseUrl: string, accountId?: string): Promise<GitIntegrationRepository[]> {
|
||||
return invoke<GitIntegrationRepository[]>("list_integration_repositories", { provider, baseUrl, accountId: accountId ?? null });
|
||||
}
|
||||
|
||||
export function listIntegrationReviewRequests(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, state: "open" | "merged" | "closed"): Promise<IntegrationReviewRequest[]> {
|
||||
return invoke<IntegrationReviewRequest[]>("list_integration_review_requests", { provider, baseUrl, username, token, state });
|
||||
}
|
||||
|
||||
export function runIntegrationReviewAction(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, action: IntegrationReviewAction): Promise<void> {
|
||||
return invoke<void>("run_integration_review_action", { provider, baseUrl, username, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName, number: request.number, action });
|
||||
}
|
||||
|
||||
export function getIntegrationReviewDetails(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest): Promise<IntegrationReviewRequest> {
|
||||
return invoke<IntegrationReviewRequest>("get_integration_review_details", { provider, baseUrl, username, token, request });
|
||||
}
|
||||
|
||||
export function addIntegrationReviewComment(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, body: string): Promise<void> {
|
||||
return invoke<void>("add_integration_review_comment", { provider, baseUrl, username, token, request, body });
|
||||
}
|
||||
|
||||
export function openInBrowser(url: string): Promise<void> {
|
||||
return invoke<void>("open_in_browser", { url });
|
||||
}
|
||||
|
||||
export function launchExternalTool(path: string, command: ExternalToolCommand, file?: string): Promise<void> {
|
||||
return invoke<void>("launch_external_tool", { path, file: file ?? null, command });
|
||||
}
|
||||
|
||||
export function launchExternalDiff(path: string, file: string, command: ExternalToolCommand, scope: ExternalDiffScope = "head"): Promise<void> {
|
||||
return invoke<void>("launch_external_diff", { path, file, command, scope });
|
||||
}
|
||||
|
||||
export function launchExternalMerge(path: string, file: string, command: ExternalToolCommand): Promise<void> {
|
||||
return invoke<void>("launch_external_merge", { path, file, command });
|
||||
}
|
||||
|
||||
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
|
||||
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
||||
}
|
||||
@@ -43,6 +104,7 @@ export function cloneRepository(
|
||||
username?: string,
|
||||
password?: string,
|
||||
commitLimit = 100,
|
||||
options: CloneOptions = { branch: null, blobless: false, customFlags: "", shallowDepth: null, shallowSince: null, sparse: false, sparsePaths: [] },
|
||||
): Promise<RepositoryBundle> {
|
||||
return invoke<RepositoryBundle>("clone_repository", {
|
||||
remoteUrl,
|
||||
@@ -51,6 +113,13 @@ export function cloneRepository(
|
||||
username: username ?? null,
|
||||
password: password ?? null,
|
||||
commitLimit,
|
||||
branch: options.branch,
|
||||
blobless: options.blobless,
|
||||
customFlags: options.customFlags,
|
||||
shallowDepth: options.shallowDepth,
|
||||
shallowSince: options.shallowSince,
|
||||
sparse: options.sparse,
|
||||
sparsePaths: options.sparsePaths,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,6 +127,51 @@ export function getStatus(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("get_status", { path });
|
||||
}
|
||||
|
||||
export function getBisectState(path: string): Promise<BisectState> {
|
||||
return invoke<BisectState>("get_bisect_state", { path });
|
||||
}
|
||||
|
||||
export function startBisect(path: string, good: string, bad: string): Promise<BisectState> {
|
||||
return invoke<BisectState>("start_bisect", { path, good, bad });
|
||||
}
|
||||
|
||||
export function markBisect(path: string, verdict: "good" | "bad" | "skip"): Promise<BisectState> {
|
||||
return invoke<BisectState>("mark_bisect", { path, verdict });
|
||||
}
|
||||
|
||||
export function resetBisect(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("reset_bisect", { path });
|
||||
}
|
||||
|
||||
export function getGitLfsStatus(path: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_status", { path });
|
||||
}
|
||||
|
||||
export function installGitLfs(path: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_install", { path });
|
||||
}
|
||||
|
||||
export function trackGitLfsPattern(path: string, pattern: string, lockable = false): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_track", { path, pattern, lockable });
|
||||
}
|
||||
|
||||
export function untrackGitLfsPattern(path: string, pattern: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_untrack", { path, pattern });
|
||||
}
|
||||
|
||||
export function pullGitLfsObjects(path: string, remote?: string, username?: string, password?: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_pull", {
|
||||
path,
|
||||
remote: remote || null,
|
||||
username: username ?? null,
|
||||
password: password ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function pruneGitLfsObjects(path: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_prune", { path });
|
||||
}
|
||||
|
||||
// Sets the taskbar icon badge to ahead + behind + changed status files (0 clears it). Windows only — a no-op on
|
||||
// other platforms, since Windows has no native numeric badge to fall back to.
|
||||
export function setSyncBadge(ahead: number, behind: number, changes: number): Promise<void> {
|
||||
@@ -68,6 +182,20 @@ export function listBranches(path: string): Promise<GitBranch[]> {
|
||||
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[]> {
|
||||
return invoke<GitStash[]>("list_stashes", { path });
|
||||
}
|
||||
@@ -92,10 +220,78 @@ export function renameBranch(
|
||||
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> {
|
||||
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
||||
}
|
||||
|
||||
export function listWorktrees(path: string): Promise<GitWorktree[]> {
|
||||
return invoke<GitWorktree[]>("list_worktrees", { path });
|
||||
}
|
||||
|
||||
export function addWorktree(
|
||||
path: string,
|
||||
worktreePath: string,
|
||||
options: {
|
||||
branch?: string;
|
||||
newBranch?: string;
|
||||
startPoint?: string;
|
||||
detached?: boolean;
|
||||
lock?: boolean;
|
||||
} = {},
|
||||
): Promise<GitWorktree[]> {
|
||||
return invoke<GitWorktree[]>("add_worktree", {
|
||||
path,
|
||||
worktreePath,
|
||||
branch: options.branch ?? null,
|
||||
newBranch: options.newBranch ?? null,
|
||||
startPoint: options.startPoint ?? null,
|
||||
detached: options.detached ?? false,
|
||||
lock: options.lock ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
export function removeWorktree(path: string, worktreePath: string, force = false): Promise<GitWorktree[]> {
|
||||
return invoke<GitWorktree[]>("remove_worktree", { path, worktreePath, force });
|
||||
}
|
||||
|
||||
export function moveWorktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]> {
|
||||
return invoke<GitWorktree[]>("move_worktree", { path, worktreePath, destination });
|
||||
}
|
||||
|
||||
export function lockWorktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]> {
|
||||
return invoke<GitWorktree[]>("lock_worktree", { path, worktreePath, reason: reason?.trim() || null });
|
||||
}
|
||||
|
||||
export function unlockWorktree(path: string, worktreePath: string): Promise<GitWorktree[]> {
|
||||
return invoke<GitWorktree[]>("unlock_worktree", { path, worktreePath });
|
||||
}
|
||||
|
||||
export function pruneWorktrees(path: string): Promise<GitWorktree[]> {
|
||||
return invoke<GitWorktree[]>("prune_worktrees", { path });
|
||||
}
|
||||
|
||||
export function repairWorktree(path: string, worktreePath: string): Promise<GitWorktree[]> {
|
||||
return invoke<GitWorktree[]>("repair_worktree", { path, worktreePath });
|
||||
}
|
||||
|
||||
export function listTags(path: string): Promise<GitTag[]> {
|
||||
return invoke<GitTag[]>("list_tags", { path });
|
||||
}
|
||||
@@ -147,6 +343,14 @@ export function unstageFiles(path: string, files: string[]): Promise<GitStatus>
|
||||
return invoke<GitStatus>("unstage_files", { path, files });
|
||||
}
|
||||
|
||||
export function addToGitignore(path: string, target: string, kind: GitIgnoreKind): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("add_to_gitignore", { path, target, kind });
|
||||
}
|
||||
|
||||
export function untrackPaths(path: string, targets: string[]): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("untrack_paths", { path, targets });
|
||||
}
|
||||
|
||||
export function restoreFiles(
|
||||
path: string,
|
||||
files: string[],
|
||||
@@ -188,11 +392,13 @@ export function stashPush(
|
||||
path: string,
|
||||
message?: string,
|
||||
includeUntracked = true,
|
||||
paths?: string[],
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("stash_push", {
|
||||
path,
|
||||
message: message?.trim() ? message.trim() : null,
|
||||
includeUntracked,
|
||||
paths: paths?.length ? paths : null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,22 +414,9 @@ export function stashDrop(path: string, selector: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("stash_drop", { path, selector });
|
||||
}
|
||||
|
||||
export function commitAiStatus(): Promise<CommitAiStatus> {
|
||||
return invoke<CommitAiStatus>("commit_ai_status");
|
||||
}
|
||||
|
||||
export function commitAiLoad(modelId: string): Promise<void> {
|
||||
return invoke<void>("commit_ai_load", { modelId });
|
||||
}
|
||||
|
||||
export function commitAiLocalModels(): Promise<LocalModelOption[]> {
|
||||
return invoke<LocalModelOption[]>("commit_ai_local_models");
|
||||
}
|
||||
|
||||
export interface CommitAiGenerateOptions {
|
||||
notes?: string;
|
||||
provider: CommitAiProvider;
|
||||
localProfile?: CommitAiLocalProfile;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
@@ -234,48 +427,108 @@ export function commitAiGenerate(path: string, options: CommitAiGenerateOptions)
|
||||
path,
|
||||
notes: options.notes,
|
||||
provider: options.provider,
|
||||
localProfile: options.localProfile,
|
||||
model: options.model,
|
||||
apiKey: options.apiKey,
|
||||
baseUrl: options.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
|
||||
export function commitAiReview(path: string, options: CommitAiGenerateOptions): Promise<AiReviewResult> {
|
||||
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> {
|
||||
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null });
|
||||
export function commitAiSplit(path: string, options: CommitAiGenerateOptions): Promise<AiCommitPlan> {
|
||||
return invoke<AiCommitPlan>("commit_ai_split", {
|
||||
path,
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
apiKey: options.apiKey,
|
||||
baseUrl: options.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export function push(path: string, username?: string, password?: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null });
|
||||
export function pull(
|
||||
path: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
strategy: PullStrategy = "merge",
|
||||
remote?: string,
|
||||
branch?: string,
|
||||
allowUnrelatedHistories = false,
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("pull", {
|
||||
path,
|
||||
username: username ?? null,
|
||||
password: password ?? null,
|
||||
strategy,
|
||||
remote: remote || null,
|
||||
branch: branch || null,
|
||||
allowUnrelatedHistories,
|
||||
});
|
||||
}
|
||||
|
||||
export function getRemoteUrl(path: string): Promise<string | null> {
|
||||
return invoke<string | null>("get_remote_url", { path });
|
||||
export function fetchRemote(path: string, username?: string, password?: string, prune = false, remote?: string): Promise<GitStatus> {
|
||||
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> {
|
||||
return invoke<StoredCredential | null>("cred_load", { key });
|
||||
}
|
||||
|
||||
export function credSave(
|
||||
key: string,
|
||||
username: string,
|
||||
password: string,
|
||||
expiresAt: string | null,
|
||||
): Promise<void> {
|
||||
return invoke<void>("cred_save", { key, username, password, expiresAt });
|
||||
export function credSave(key: string, username: string, password: string, mode: "credentials" | "token" = "credentials"): Promise<void> {
|
||||
return invoke<void>("cred_save", { key, username, password, mode });
|
||||
}
|
||||
|
||||
export function credDelete(key: string): Promise<void> {
|
||||
return invoke<void>("cred_delete", { key });
|
||||
}
|
||||
|
||||
export function listCommits(path: string, limit = 100): Promise<GitCommit[]> {
|
||||
return invoke<GitCommit[]>("list_commits", { path, limit });
|
||||
export function listCommits(path: string, limit = 100, skip = 0): Promise<GitCommit[]> {
|
||||
return invoke<GitCommit[]>("list_commits", { path, limit, skip });
|
||||
}
|
||||
|
||||
export function getCommitNote(path: string, commit: string): Promise<string | null> {
|
||||
return invoke<string | null>("get_commit_note", { path, commit });
|
||||
}
|
||||
|
||||
export function setCommitNote(path: string, commit: string, note: string): Promise<void> {
|
||||
return invoke<void>("set_commit_note", { path, commit, note });
|
||||
}
|
||||
|
||||
export function deleteCommitNote(path: string, commit: string): Promise<void> {
|
||||
return invoke<void>("delete_commit_note", { path, commit });
|
||||
}
|
||||
|
||||
export function fetchCommitNotes(path: string, remote: string, username?: string, password?: string): Promise<void> {
|
||||
return invoke<void>("fetch_commit_notes", {
|
||||
path,
|
||||
remote,
|
||||
username: username ?? null,
|
||||
password: password ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function pushCommitNotes(path: string, remote: string, username?: string, password?: string): Promise<void> {
|
||||
return invoke<void>("push_commit_notes", {
|
||||
path,
|
||||
remote,
|
||||
username: username ?? null,
|
||||
password: password ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {
|
||||
@@ -290,9 +543,12 @@ export function restoreFileFromCommit(
|
||||
return invoke<GitStatus>("restore_file_from_commit", { path, commit, file });
|
||||
}
|
||||
|
||||
export function mergeBranch(path: string, branch: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("merge_branch", { path, branch });
|
||||
export function mergeBranch(path: string, branch: string, strategy: MergeStrategy = "default"): Promise<GitStatus> {
|
||||
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> {
|
||||
return invoke<GitStatus>("rebase_branch", { path, branch });
|
||||
@@ -306,6 +562,26 @@ export function rebaseAbort(path: string): Promise<GitStatus> {
|
||||
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[]> {
|
||||
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import type {
|
||||
AzureDevOpsOrganization,
|
||||
GitIntegrationConfig,
|
||||
GitIntegrationProvider,
|
||||
GitIntegrationSource,
|
||||
GitIntegrationSettings,
|
||||
} from "./types";
|
||||
|
||||
export const gitIntegrationProviders: GitIntegrationProvider[] = [
|
||||
"github",
|
||||
"gitlab",
|
||||
"gitlab-self-hosted",
|
||||
"azure-devops",
|
||||
"gitea",
|
||||
];
|
||||
|
||||
const defaults: Record<GitIntegrationProvider, Omit<GitIntegrationConfig, "provider">> = {
|
||||
github: {
|
||||
enabled: false,
|
||||
baseUrl: "https://github.com",
|
||||
username: "",
|
||||
tokenStored: false,
|
||||
},
|
||||
gitlab: {
|
||||
enabled: false,
|
||||
baseUrl: "https://gitlab.com",
|
||||
username: "",
|
||||
tokenStored: false,
|
||||
},
|
||||
"gitlab-self-hosted": {
|
||||
enabled: false,
|
||||
baseUrl: "",
|
||||
username: "",
|
||||
tokenStored: false,
|
||||
},
|
||||
"azure-devops": {
|
||||
enabled: false,
|
||||
baseUrl: "https://dev.azure.com/",
|
||||
username: "",
|
||||
tokenStored: false,
|
||||
},
|
||||
gitea: {
|
||||
enabled: false,
|
||||
baseUrl: "",
|
||||
username: "",
|
||||
tokenStored: false,
|
||||
},
|
||||
};
|
||||
|
||||
export function defaultGitIntegrationSettings(): GitIntegrationSettings {
|
||||
return {
|
||||
providers: Object.fromEntries(
|
||||
gitIntegrationProviders.map((provider) => [provider, { provider, ...defaults[provider] }]),
|
||||
) as GitIntegrationSettings["providers"],
|
||||
azureDevOpsOrganizations: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseAzureOrganization(value: unknown): AzureDevOpsOrganization | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const stored = value as Partial<AzureDevOpsOrganization>;
|
||||
const id = typeof stored.id === "string" && /^[a-zA-Z0-9_-]{1,80}$/.test(stored.id) ? stored.id : "";
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
name: typeof stored.name === "string" ? stored.name : "",
|
||||
enabled: stored.enabled === true,
|
||||
baseUrl: typeof stored.baseUrl === "string" ? stored.baseUrl : "",
|
||||
username: typeof stored.username === "string" ? stored.username : "",
|
||||
tokenStored: stored.tokenStored === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function normaliseGitIntegrationSettings(value: unknown): GitIntegrationSettings {
|
||||
const fallback = defaultGitIntegrationSettings();
|
||||
if (!value || typeof value !== "object") return fallback;
|
||||
const storedProviders = (value as Partial<GitIntegrationSettings>).providers;
|
||||
if (!storedProviders || typeof storedProviders !== "object") return fallback;
|
||||
|
||||
for (const provider of gitIntegrationProviders) {
|
||||
const stored = storedProviders[provider] as Partial<GitIntegrationConfig> | undefined;
|
||||
if (!stored || typeof stored !== "object") continue;
|
||||
fallback.providers[provider] = {
|
||||
provider,
|
||||
enabled: stored.enabled === true,
|
||||
baseUrl: typeof stored.baseUrl === "string" ? stored.baseUrl : fallback.providers[provider].baseUrl,
|
||||
username: typeof stored.username === "string" ? stored.username : "",
|
||||
tokenStored: stored.tokenStored === true,
|
||||
};
|
||||
}
|
||||
|
||||
const storedOrganizations = (value as Partial<GitIntegrationSettings>).azureDevOpsOrganizations;
|
||||
if (Array.isArray(storedOrganizations)) {
|
||||
const seen = new Set<string>();
|
||||
fallback.azureDevOpsOrganizations = storedOrganizations
|
||||
.map(normaliseAzureOrganization)
|
||||
.filter((organization): organization is AzureDevOpsOrganization => {
|
||||
if (!organization || seen.has(organization.id)) return false;
|
||||
seen.add(organization.id);
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
const legacy = fallback.providers["azure-devops"];
|
||||
const hasLegacyConfiguration = legacy.enabled || legacy.tokenStored || legacy.username.trim().length > 0 || !/^https:\/\/dev\.azure\.com\/?$/i.test(legacy.baseUrl.trim());
|
||||
if (hasLegacyConfiguration) {
|
||||
fallback.azureDevOpsOrganizations = [{
|
||||
id: "default",
|
||||
name: "Azure DevOps",
|
||||
enabled: legacy.enabled,
|
||||
baseUrl: legacy.baseUrl,
|
||||
username: legacy.username,
|
||||
tokenStored: legacy.tokenStored,
|
||||
}];
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function integrationCredentialKey(provider: GitIntegrationProvider, accountId?: string): string {
|
||||
if (provider === "azure-devops" && accountId && accountId !== "default") {
|
||||
return `integration:${provider}:${accountId}`;
|
||||
}
|
||||
return `integration:${provider}`;
|
||||
}
|
||||
|
||||
export function configuredIntegrationSources(settings: GitIntegrationSettings): GitIntegrationSource[] {
|
||||
const sources: GitIntegrationSource[] = [];
|
||||
for (const provider of gitIntegrationProviders) {
|
||||
if (provider === "azure-devops") continue;
|
||||
const config = settings.providers[provider];
|
||||
if (config.enabled && config.tokenStored && config.baseUrl.trim()) {
|
||||
sources.push({ id: provider, provider, label: providerLabel(provider), baseUrl: config.baseUrl });
|
||||
}
|
||||
}
|
||||
for (const organization of settings.azureDevOpsOrganizations) {
|
||||
if (!organization.enabled || !organization.tokenStored || !organization.baseUrl.trim()) continue;
|
||||
sources.push({
|
||||
id: `azure-devops:${organization.id}`,
|
||||
provider: "azure-devops",
|
||||
accountId: organization.id,
|
||||
label: organization.name.trim() || organizationNameFromUrl(organization.baseUrl) || "Azure DevOps",
|
||||
baseUrl: organization.baseUrl,
|
||||
});
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
export function configuredIntegrationCount(settings: GitIntegrationSettings): number {
|
||||
return configuredIntegrationSources(settings).length;
|
||||
}
|
||||
|
||||
export function providerLabel(provider: GitIntegrationProvider): string {
|
||||
return {
|
||||
github: "GitHub",
|
||||
gitlab: "GitLab.com",
|
||||
"gitlab-self-hosted": "GitLab Self-Managed",
|
||||
"azure-devops": "Azure DevOps",
|
||||
gitea: "Gitea",
|
||||
}[provider];
|
||||
}
|
||||
|
||||
export function organizationNameFromUrl(value: string): string {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.pathname.split("/").filter(Boolean)[0] ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export function resumeFrontend() {
|
||||
frontendShuttingDown = false;
|
||||
}
|
||||
|
||||
// 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");
|
||||
});
|
||||
}
|
||||
+258
-12
@@ -7,26 +7,135 @@ export type FileStatusKind =
|
||||
| "conflicted"
|
||||
| "unknown";
|
||||
|
||||
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
||||
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
||||
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
||||
export type GitIgnoreKind = "file" | "extension" | "folder";
|
||||
|
||||
export interface CommitAiStatus {
|
||||
phase: CommitAiPhase;
|
||||
model_id: string | null;
|
||||
error: string | null;
|
||||
export type CommitAiProvider = "openai" | "anthropic" | "custom";
|
||||
export type AppTheme = "system" | "light" | "dark";
|
||||
export type AppAppearance = "modern" | "classic" | "custom";
|
||||
export type AppLanguage = "en" | "de";
|
||||
|
||||
export type GitIntegrationProvider = "github" | "gitlab" | "gitlab-self-hosted" | "azure-devops" | "gitea";
|
||||
|
||||
export interface GitIntegrationConfig {
|
||||
provider: GitIntegrationProvider;
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
tokenStored: boolean;
|
||||
}
|
||||
|
||||
export interface LocalModelOption {
|
||||
export interface GitIntegrationSettings {
|
||||
providers: Record<GitIntegrationProvider, GitIntegrationConfig>;
|
||||
azureDevOpsOrganizations: AzureDevOpsOrganization[];
|
||||
}
|
||||
|
||||
export interface AzureDevOpsOrganization {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
tokenStored: boolean;
|
||||
}
|
||||
|
||||
export interface GitIntegrationSecretUpdate {
|
||||
provider: GitIntegrationProvider;
|
||||
accountId?: string;
|
||||
token?: string;
|
||||
removeToken?: boolean;
|
||||
}
|
||||
|
||||
export interface GitIntegrationSource {
|
||||
id: string;
|
||||
provider: GitIntegrationProvider;
|
||||
accountId?: string;
|
||||
label: string;
|
||||
approx_size_mb: number;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface GitIntegrationRepository {
|
||||
id: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
description: string;
|
||||
cloneUrl: string;
|
||||
sshUrl: string;
|
||||
webUrl: string;
|
||||
updatedAt: string;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export type IntegrationReviewState = "open" | "draft" | "merged" | "closed";
|
||||
export type IntegrationReviewAction = "merge" | "approve" | "close" | "reopen";
|
||||
|
||||
export interface IntegrationReviewComment {
|
||||
id: string;
|
||||
author: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface IntegrationReviewRequest {
|
||||
id: string;
|
||||
number: number;
|
||||
provider: GitIntegrationProvider;
|
||||
repositoryId: string;
|
||||
repositoryName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
author: string;
|
||||
state: IntegrationReviewState;
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
webUrl: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
collaborators: string[];
|
||||
additions: number | null;
|
||||
deletions: number | null;
|
||||
changedFiles: number | null;
|
||||
mergeStatus: "" | "mergeable" | "conflicts" | "checking" | "blocked";
|
||||
comments: IntegrationReviewComment[];
|
||||
}
|
||||
|
||||
export interface CustomThemeColors {
|
||||
background: string;
|
||||
surface: string;
|
||||
accent: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
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 AiSettings {
|
||||
provider: CommitAiProvider;
|
||||
localModelId: string;
|
||||
localProfile: CommitAiLocalProfile;
|
||||
openaiModel: string;
|
||||
anthropicModel: string;
|
||||
customBaseUrl: string;
|
||||
@@ -38,6 +147,36 @@ export interface AnalyticsSettings {
|
||||
noticeSeen: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalToolCommand {
|
||||
program: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
export interface ExternalToolSetting extends ExternalToolCommand {
|
||||
preset: string;
|
||||
}
|
||||
|
||||
export type ExternalToolKind = "editor" | "diff" | "merge" | "terminal" | "fileManager";
|
||||
export type ToolOpenMode = "gitty" | "external";
|
||||
export type ExternalDiffScope = "head" | "staged" | "unstaged";
|
||||
|
||||
export interface ExternalToolsSettings {
|
||||
editor: ExternalToolSetting;
|
||||
diff: ExternalToolSetting;
|
||||
merge: ExternalToolSetting;
|
||||
terminal: ExternalToolSetting;
|
||||
fileManager: ExternalToolSetting;
|
||||
diffOpenMode: ToolOpenMode;
|
||||
mergeOpenMode: ToolOpenMode;
|
||||
}
|
||||
|
||||
export interface DetectedExternalTool {
|
||||
id: string;
|
||||
label: string;
|
||||
program: string;
|
||||
kinds: ExternalToolKind[];
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
repo_path: string;
|
||||
current_branch: string | null;
|
||||
@@ -48,6 +187,21 @@ export interface GitStatus {
|
||||
clean: boolean;
|
||||
rebase_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 CloneOptions {
|
||||
branch: string | null;
|
||||
blobless: boolean;
|
||||
customFlags: string;
|
||||
shallowDepth: number | null;
|
||||
shallowSince: string | null;
|
||||
sparse: boolean;
|
||||
sparsePaths: string[];
|
||||
}
|
||||
|
||||
export interface GitFileStatus {
|
||||
@@ -63,6 +217,25 @@ export interface GitBranch {
|
||||
name: string;
|
||||
current: 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 {
|
||||
@@ -93,6 +266,25 @@ export interface GitCommit {
|
||||
refs: string[];
|
||||
parents: string[];
|
||||
files: GitCommitFile[];
|
||||
has_note: boolean;
|
||||
}
|
||||
|
||||
export interface BisectCommit {
|
||||
hash: string;
|
||||
short_hash: string;
|
||||
summary: string;
|
||||
author_name: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface BisectState {
|
||||
active: boolean;
|
||||
finished: boolean;
|
||||
current: BisectCommit | null;
|
||||
culprit: BisectCommit | null;
|
||||
remaining_revisions: number | null;
|
||||
remaining_steps: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GitCommitFile {
|
||||
@@ -107,6 +299,34 @@ export interface GitRepositoryFile {
|
||||
status: FileStatusKind | null;
|
||||
}
|
||||
|
||||
export interface GitLfsPattern {
|
||||
pattern: string;
|
||||
source: string;
|
||||
lockable: boolean;
|
||||
tracked: boolean;
|
||||
}
|
||||
|
||||
export interface GitLfsFile {
|
||||
name: string;
|
||||
size: number;
|
||||
checkout: boolean;
|
||||
downloaded: boolean;
|
||||
oid_type: string;
|
||||
oid: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface GitLfsStatus {
|
||||
available: boolean;
|
||||
bundled: boolean;
|
||||
version: string | null;
|
||||
filters_configured: boolean;
|
||||
hook_installed: boolean;
|
||||
repository_uses_lfs: boolean;
|
||||
patterns: GitLfsPattern[];
|
||||
files: GitLfsFile[];
|
||||
}
|
||||
|
||||
export interface RepositoryBundle {
|
||||
status: GitStatus;
|
||||
branches: GitBranch[];
|
||||
@@ -114,6 +334,7 @@ export interface RepositoryBundle {
|
||||
stashes: GitStash[];
|
||||
commits: GitCommit[];
|
||||
files: GitRepositoryFile[];
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
export interface GitDiffFile {
|
||||
@@ -197,8 +418,33 @@ export interface GitBlameResult {
|
||||
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 {
|
||||
username: 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.css";
|
||||
import { installGlobalErrorTelemetry } from "./lib/telemetry";
|
||||
|
||||
const THEME_KEY = "gitlite.theme.v1";
|
||||
const target = document.getElementById("app");
|
||||
|
||||
installGlobalErrorTelemetry();
|
||||
|
||||
if (!target) {
|
||||
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 });
|
||||
|
||||
export default app;
|
||||
|
||||
Reference in New Issue
Block a user