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]
|
types: [published]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-tauri:
|
publish-windows:
|
||||||
|
name: Build and publish Windows installer
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
environment: production
|
environment: production
|
||||||
@@ -30,25 +31,7 @@ jobs:
|
|||||||
run:
|
run:
|
||||||
working-directory: GitLite
|
working-directory: GitLite
|
||||||
|
|
||||||
strategy:
|
runs-on: windows-latest
|
||||||
fail-fast: false
|
|
||||||
max-parallel: 1
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
# - platform: 'macos-latest'
|
|
||||||
# args: '--target aarch64-apple-darwin'
|
|
||||||
# - platform: 'macos-latest'
|
|
||||||
# args: '--target x86_64-apple-darwin'
|
|
||||||
- platform: "windows-latest"
|
|
||||||
bundles: "nsis"
|
|
||||||
updater_platform: "windows-x86_64"
|
|
||||||
no_strip: ""
|
|
||||||
- platform: "ubuntu-22.04"
|
|
||||||
bundles: "appimage"
|
|
||||||
updater_platform: "linux-x86_64"
|
|
||||||
no_strip: "1"
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# cicd_tool/main.py expects the repo at <workspace>/GitLite, so check out there.
|
# cicd_tool/main.py expects the repo at <workspace>/GitLite, so check out there.
|
||||||
@@ -56,25 +39,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: GitLite
|
path: GitLite
|
||||||
|
|
||||||
- name: install dependencies (ubuntu only)
|
|
||||||
if: matrix.platform == 'ubuntu-22.04'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
|
|
||||||
|
|
||||||
- name: setup node
|
- name: setup node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
if: matrix.platform == 'ubuntu-22.04'
|
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
|
|
||||||
|
|
||||||
- name: install rust toolchain
|
|
||||||
if: matrix.platform == 'ubuntu-22.04'
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: install frontend dependencies
|
- name: install frontend dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
@@ -88,7 +57,6 @@ jobs:
|
|||||||
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
|
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
|
||||||
|
|
||||||
- name: Commit and Push Changes
|
- name: Commit and Push Changes
|
||||||
if: matrix.platform == 'windows-latest'
|
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
git config user.name '${{ vars.USERNAME_GIT }}'
|
git config user.name '${{ vars.USERNAME_GIT }}'
|
||||||
@@ -139,15 +107,421 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
|
||||||
NO_STRIP: ${{ matrix.no_strip }}
|
NO_STRIP: ""
|
||||||
run: npm run tauri -- build --bundles ${{ matrix.bundles }}
|
run: npm run tauri -- build --bundles nsis
|
||||||
# ----------------------
|
# ----------------------
|
||||||
# Upload to MinIO (via mc) and create latest.json
|
# Upload to MinIO (via mc) and create latest.json
|
||||||
# ----------------------
|
# ----------------------
|
||||||
- name: Upload artifacts to MinIO with cicd_tool
|
- name: Upload artifacts to MinIO with cicd_tool
|
||||||
working-directory: GitLite/cicd_tool
|
working-directory: GitLite/cicd_tool
|
||||||
env:
|
env:
|
||||||
PUBLISH_PLATFORM: ${{ matrix.updater_platform }}
|
PUBLISH_PLATFORM: windows-x86_64
|
||||||
|
run: |
|
||||||
|
uv sync
|
||||||
|
uv run main.py
|
||||||
|
|
||||||
|
publish-arch:
|
||||||
|
name: Build and publish AUR packages
|
||||||
|
needs: publish-ubuntu
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
runs-on: archlinux
|
||||||
|
environment: production
|
||||||
|
env:
|
||||||
|
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||||
|
GITEA_API: https://git.cbsk-tech.de/api/v1
|
||||||
|
OWNER: Christoph
|
||||||
|
REPO: GitLite
|
||||||
|
GITEA_TOKEN: ${{ secrets.ACTIONS_TOKEN }}
|
||||||
|
GITEA_FALLBACK_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: GitLite
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Prepare Arch job container
|
||||||
|
working-directory: /
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# The runner image may contain only a single geo mirror. Seed several
|
||||||
|
# currently active HTTPS mirrors and retain the image's list as a
|
||||||
|
# fallback so one slow CDN cannot fail the complete release.
|
||||||
|
ARCH_MIRROR_BACKUP="$(mktemp)"
|
||||||
|
cp /etc/pacman.d/mirrorlist "$ARCH_MIRROR_BACKUP"
|
||||||
|
printf '%s\n' \
|
||||||
|
'Server = https://frankfurt.mirror.pkgbuild.com/$repo/os/$arch' \
|
||||||
|
'Server = https://mirror.osbeck.com/archlinux/$repo/os/$arch' \
|
||||||
|
'Server = https://mirror.ubrco.de/archlinux/$repo/os/$arch' \
|
||||||
|
'Server = https://ftp.halifax.rwth-aachen.de/archlinux/$repo/os/$arch' \
|
||||||
|
'Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch' \
|
||||||
|
> /etc/pacman.d/mirrorlist
|
||||||
|
sed -n '/^[[:space:]]*Server[[:space:]]*=/p' "$ARCH_MIRROR_BACKUP" \
|
||||||
|
>> /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
|
ARCH_PACKAGES=(
|
||||||
|
base-devel curl git 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: |
|
run: |
|
||||||
uv sync
|
uv sync
|
||||||
uv run main.py
|
uv run main.py
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/dist
|
/dist
|
||||||
/src-tauri/target
|
/src-tauri/target
|
||||||
|
/src-tauri/binaries/git-lfs
|
||||||
|
/src-tauri/binaries/git-lfs.exe
|
||||||
|
/src-tauri/binaries/git-lfs-*-*-*
|
||||||
*.log
|
*.log
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
~
|
~
|
||||||
.codex*
|
.codex*
|
||||||
target
|
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
|
- 🔄 Pull, Push & Fetch
|
||||||
- 🔀 Merge & Rebase
|
- 🔀 Merge & Rebase
|
||||||
- 📦 Repository management
|
- 📦 Repository management
|
||||||
|
- ☁️ GitHub, GitLab, Azure DevOps, and Gitea integrations
|
||||||
|
- 🗄️ Git LFS detection, tracking and object management
|
||||||
- 🎨 Modern and intuitive UI
|
- 🎨 Modern and intuitive UI
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📸 Preview
|
## 📸 Preview
|
||||||
|
|
||||||
> Screenshots coming soon.
|
> Screenshots comes later.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -49,3 +51,83 @@ Contributions, feedback and feature requests are always welcome!
|
|||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arch Linux
|
||||||
|
|
||||||
|
Install Gitty from the AUR with an AUR helper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yay -S gitty-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
To install the prebuilt native Arch package instead of compiling from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yay -S gitty-desktop-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Or build the AUR package manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://aur.archlinux.org/gitty-desktop.git
|
||||||
|
cd gitty-desktop
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
The source recipe downloads the public Gitea release archive and builds Gitty.
|
||||||
|
The `-bin` recipe repackages the native `.pkg.tar.zst` release artifact. The
|
||||||
|
release pipeline updates both packages' versions, checksums, and `.SRCINFO`
|
||||||
|
files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 argparse
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable, List, NamedTuple, Optional
|
from typing import Any, Iterable, List, NamedTuple, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
from urllib.parse import quote, urlencode
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from minio import Minio
|
from minio import Minio
|
||||||
from minio.error import S3Error
|
from minio.error import S3Error
|
||||||
@@ -329,6 +333,65 @@ def _publish_target(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _gitea_request(url: str, token: str, data: Optional[bytes] = None, **headers: str):
|
||||||
|
request = Request(url, data=data, headers={"Authorization": f"token {token}", **headers})
|
||||||
|
return urlopen(request, timeout=120)
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_gitea_release_asset(version: str, artifact: Path) -> None:
|
||||||
|
"""Attach the installer to the Gitea release that triggered this build."""
|
||||||
|
api_url = os.environ.get("GITEA_API", "").rstrip("/")
|
||||||
|
owner = os.environ.get("OWNER", "")
|
||||||
|
repo = os.environ.get("REPO", "")
|
||||||
|
token = os.environ.get("GITEA_TOKEN") or os.environ.get("GITEA_FALLBACK_TOKEN")
|
||||||
|
if not all((api_url, owner, repo, token)):
|
||||||
|
raise ConfigurationError(
|
||||||
|
"GITEA_API, OWNER, REPO and GITEA_TOKEN are required to publish "
|
||||||
|
"the release asset."
|
||||||
|
)
|
||||||
|
|
||||||
|
release_url = (
|
||||||
|
f"{api_url}/repos/{quote(owner, safe='')}/{quote(repo, safe='')}"
|
||||||
|
f"/releases/tags/{quote(version, safe='')}"
|
||||||
|
)
|
||||||
|
with _gitea_request(release_url, token) as response:
|
||||||
|
release = json.load(response)
|
||||||
|
|
||||||
|
existing_names = {
|
||||||
|
asset.get("name") for asset in release.get("assets", []) if isinstance(asset, dict)
|
||||||
|
}
|
||||||
|
if artifact.name in existing_names:
|
||||||
|
print(f"Release asset {artifact.name} already exists; skipping upload.")
|
||||||
|
return
|
||||||
|
|
||||||
|
boundary = f"----gitty-{uuid.uuid4().hex}"
|
||||||
|
content_type = mimetypes.guess_type(artifact.name)[0] or "application/octet-stream"
|
||||||
|
body = b"".join(
|
||||||
|
(
|
||||||
|
f"--{boundary}\r\n".encode(),
|
||||||
|
(
|
||||||
|
f'Content-Disposition: form-data; name="attachment"; '
|
||||||
|
f'filename="{artifact.name}"\r\n'
|
||||||
|
).encode(),
|
||||||
|
f"Content-Type: {content_type}\r\n\r\n".encode(),
|
||||||
|
artifact.read_bytes(),
|
||||||
|
f"\r\n--{boundary}--\r\n".encode(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
upload_url = (
|
||||||
|
f"{api_url}/repos/{quote(owner, safe='')}/{quote(repo, safe='')}"
|
||||||
|
f"/releases/{release['id']}/assets?{urlencode({'name': artifact.name})}"
|
||||||
|
)
|
||||||
|
with _gitea_request(
|
||||||
|
upload_url,
|
||||||
|
token,
|
||||||
|
data=body,
|
||||||
|
**{"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
print(f"Attached {artifact.name} to Gitea release {version}.")
|
||||||
|
|
||||||
|
|
||||||
def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
||||||
resolved_version = _resolve_version(version)
|
resolved_version = _resolve_version(version)
|
||||||
artifact_base_url = os.environ.get("ARTIFACT_BASE_URL", "")
|
artifact_base_url = os.environ.get("ARTIFACT_BASE_URL", "")
|
||||||
@@ -385,6 +448,8 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
|||||||
artifact_base_url=artifact_base_url,
|
artifact_base_url=artifact_base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_upload_gitea_release_asset(resolved_version, primary)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
|||||||
+101
-4
@@ -15,6 +15,9 @@ interface GitStatus {
|
|||||||
behind: number;
|
behind: number;
|
||||||
files: GitFileStatus[];
|
files: GitFileStatus[];
|
||||||
clean: boolean;
|
clean: boolean;
|
||||||
|
rebase_in_progress: boolean;
|
||||||
|
cherry_pick_in_progress: boolean;
|
||||||
|
merge_in_progress: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GitFileStatus {
|
interface GitFileStatus {
|
||||||
@@ -30,6 +33,24 @@ interface GitBranch {
|
|||||||
remote: boolean;
|
remote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GitWorktree {
|
||||||
|
path: string;
|
||||||
|
head: string | null;
|
||||||
|
short_head: string | null;
|
||||||
|
branch: string | null;
|
||||||
|
bare: boolean;
|
||||||
|
detached: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
lock_reason: string | null;
|
||||||
|
prunable: boolean;
|
||||||
|
prune_reason: string | null;
|
||||||
|
missing: boolean;
|
||||||
|
is_main: boolean;
|
||||||
|
is_current: boolean;
|
||||||
|
clean: boolean;
|
||||||
|
changed_files: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface GitCommit {
|
interface GitCommit {
|
||||||
hash: string;
|
hash: string;
|
||||||
short_hash: string;
|
short_hash: string;
|
||||||
@@ -52,23 +73,99 @@ interface GitRepositoryFile {
|
|||||||
tracked: boolean;
|
tracked: boolean;
|
||||||
status: FileStatusKind | null;
|
status: FileStatusKind | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GitLfsStatus {
|
||||||
|
available: boolean;
|
||||||
|
bundled: boolean;
|
||||||
|
version: string | null;
|
||||||
|
filters_configured: boolean;
|
||||||
|
hook_installed: boolean;
|
||||||
|
repository_uses_lfs: boolean;
|
||||||
|
patterns: GitLfsPattern[];
|
||||||
|
files: GitLfsFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitLfsPattern {
|
||||||
|
pattern: string;
|
||||||
|
source: string;
|
||||||
|
lockable: boolean;
|
||||||
|
tracked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitLfsFile {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
checkout: boolean;
|
||||||
|
downloaded: boolean;
|
||||||
|
oid_type: string;
|
||||||
|
oid: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
## Commands
|
||||||
|
|
||||||
|
The command list below includes the repository-management and synchronization API. The TypeScript wrappers in `src/lib/git.ts` are the authoritative full list.
|
||||||
|
|
||||||
- `open_repository(path: string): Promise<GitStatus>`
|
- `open_repository(path: string): Promise<GitStatus>`
|
||||||
|
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
|
||||||
|
- `clone_repository(...): Promise<RepositoryBundle>`
|
||||||
|
- `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>`
|
- `get_status(path: string): Promise<GitStatus>`
|
||||||
|
- `git_lfs_status(path: string): Promise<GitLfsStatus>`
|
||||||
|
- `git_lfs_install(path: string): Promise<GitLfsStatus>`
|
||||||
|
- `git_lfs_track(path: string, pattern: string, lockable?: boolean): Promise<GitLfsStatus>`
|
||||||
|
- `git_lfs_untrack(path: string, pattern: string): Promise<GitLfsStatus>`
|
||||||
|
- `git_lfs_pull(path: string, remote?: string, username?: string, password?: string): Promise<GitLfsStatus>`
|
||||||
|
- `git_lfs_prune(path: string): Promise<GitLfsStatus>`
|
||||||
|
- `pull(...)` automatically runs `git lfs pull` after a successful Git pull when
|
||||||
|
the repository contains LFS attributes or tracked LFS objects.
|
||||||
- `list_branches(path: string): Promise<GitBranch[]>`
|
- `list_branches(path: string): Promise<GitBranch[]>`
|
||||||
|
- `list_remotes(path: string): Promise<GitRemote[]>`
|
||||||
|
- `add_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
|
||||||
|
- `update_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
|
||||||
|
- `remove_remote(path: string, name: string): Promise<GitRemote[]>`
|
||||||
|
- `set_branch_upstream(path: string, branch: string, upstream?: string): Promise<GitStatus>`
|
||||||
|
- `delete_remote_branch(path: string, remote: string, branch: string): Promise<GitStatus>`
|
||||||
- `checkout_branch(path: string, branch: string): Promise<GitStatus>`
|
- `checkout_branch(path: string, branch: string): Promise<GitStatus>`
|
||||||
|
- `list_worktrees(path: string): Promise<GitWorktree[]>`
|
||||||
|
- `add_worktree(path: string, worktreePath: string, ...): Promise<GitWorktree[]>`
|
||||||
|
- `remove_worktree(path: string, worktreePath: string, force?: boolean): Promise<GitWorktree[]>`
|
||||||
|
- `move_worktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]>`
|
||||||
|
- `lock_worktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]>`
|
||||||
|
- `unlock_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
|
||||||
|
- `prune_worktrees(path: string): Promise<GitWorktree[]>`
|
||||||
|
- `repair_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
|
||||||
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
|
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||||
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
|
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||||
|
- `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>`
|
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
|
||||||
|
- `stash_push(path: string, message?: string, includeUntracked?: boolean, paths?: string[]): Promise<GitStatus>`; when `paths` is provided, only matching files are stashed.
|
||||||
- `commit(path: string, message: string): Promise<GitStatus>`
|
- `commit(path: string, message: string): Promise<GitStatus>`
|
||||||
- `pull(path: string): Promise<GitStatus>`
|
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
|
||||||
- `push(path: string): Promise<GitStatus>`
|
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
|
||||||
- `list_commits(path: string, limit?: number): Promise<GitCommit[]>`
|
- `push(path: string, forceWithLease?: boolean, remote?: string): Promise<GitStatus>`
|
||||||
|
- `list_commits(path: string, limit?: number, skip?: number): Promise<GitCommit[]>`
|
||||||
- `restore_to_commit(path: string, commit: string): Promise<GitStatus>`
|
- `restore_to_commit(path: string, commit: string): Promise<GitStatus>`
|
||||||
- `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path)
|
- `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path)
|
||||||
- `merge_branch(path: string, branch: string): Promise<GitStatus>`
|
- `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise<GitStatus>`
|
||||||
|
- `merge_continue(path: string): Promise<GitStatus>`
|
||||||
|
- `merge_abort(path: string): Promise<GitStatus>`
|
||||||
|
- `revert_commit(path: string, commit: string): Promise<GitStatus>`
|
||||||
- `list_repository_files(path: string): Promise<GitRepositoryFile[]>`
|
- `list_repository_files(path: string): Promise<GitRepositoryFile[]>`
|
||||||
- `list_file_history(path: string, file: string, limit?: number): Promise<GitCommit[]>` (the `file` argument can also be a folder path)
|
- `list_file_history(path: string, file: string, limit?: number): Promise<GitCommit[]>` (the `file` argument can also be a folder path)
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "0.200.5",
|
"version": "2026.9.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "0.200.5",
|
"version": "2026.9.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^1.21.0",
|
"@lucide/svelte": "^1.21.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
|||||||
+3
-2
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "0.200.5",
|
"version": "2026.9.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 127.0.0.1",
|
"dev": "vite --host 127.0.0.1",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"prepare:lfs": "node scripts/prepare-git-lfs-sidecar.mjs",
|
||||||
"icons": "node scripts/generate-app-icons.mjs",
|
"icons": "node scripts/generate-app-icons.mjs",
|
||||||
"preview": "vite preview --host 127.0.0.1",
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:dev": "tauri dev",
|
"tauri:dev": "node scripts/tauri-dev.mjs",
|
||||||
"tauri:build": "tauri build",
|
"tauri:build": "tauri build",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
"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"
|
tauri-plugin-aptabase = "1.0"
|
||||||
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
||||||
commit_ai = { path = "crates/commit_ai" }
|
commit_ai = { path = "crates/commit_ai" }
|
||||||
tokio = "1.52.3"
|
tokio = { 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]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|
||||||
|
# Self-updating only makes sense for install methods this app ships itself
|
||||||
|
# (NSIS on Windows, the .app bundle on macOS). On Linux the app is meant to be
|
||||||
|
# installed via the system package manager (e.g. the PKGBUILD), which owns
|
||||||
|
# updates instead — so the updater plugin isn't even compiled in there.
|
||||||
|
[target.'cfg(not(any(target_os = "android", target_os = "ios", target_os = "linux")))'.dependencies]
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
|
|
||||||
# ── Build profiles ───────────────────────────────────────────────────────────
|
# ── Build profiles ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2014- GitHub, Inc. and Git LFS contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
Portions of the subprocess and tools directories are copied from Go and are
|
||||||
|
under the following license:
|
||||||
|
|
||||||
|
Copyright (c) 2009,2010 The Go Authors. All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
* Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
* Neither the name of Google Inc. nor the names of its contributors may be
|
||||||
|
used to endorse or promote products derived from this software without
|
||||||
|
specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||||
|
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||||
|
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||||
|
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||||
|
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-unminimize",
|
"core:window:allow-unminimize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-destroy",
|
||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
"identifier": "desktop-capability",
|
"identifier": "desktop-capability",
|
||||||
"platforms": [
|
"platforms": [
|
||||||
"macOS",
|
"macOS",
|
||||||
"windows",
|
"windows"
|
||||||
"linux"
|
|
||||||
],
|
],
|
||||||
"windows": [
|
"windows": [
|
||||||
"main"
|
"main"
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mistralrs = "0.8"
|
|
||||||
tokio = { version = "1", features = ["sync"] }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{build_messages, looks_like_diff_echo, sanitize_message};
|
use crate::{build_messages, build_review_messages, looks_like_diff_echo, sanitize_message};
|
||||||
|
|
||||||
// Generous sizing so a detailed body with bullet points isn't cut off.
|
// Generous sizing so a detailed body with bullet points isn't cut off.
|
||||||
const DEFAULT_MAX_TOKENS: u32 = 1500;
|
const DEFAULT_MAX_TOKENS: u32 = 1500;
|
||||||
@@ -139,6 +139,165 @@ pub async fn generate_custom(
|
|||||||
openai_compatible_request(url, api_key, model, diff, notes).await
|
openai_compatible_request(url, api_key, model, diff, notes).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn openai_compatible_review_request(
|
||||||
|
url: String,
|
||||||
|
bearer: Option<&str>,
|
||||||
|
model: &str,
|
||||||
|
diff: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let (system, user) = build_review_messages(diff)?;
|
||||||
|
let body = OpenAiRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
messages: vec![
|
||||||
|
OpenAiMessage {
|
||||||
|
role: "system",
|
||||||
|
content: system,
|
||||||
|
},
|
||||||
|
OpenAiMessage {
|
||||||
|
role: "user",
|
||||||
|
content: user,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.1,
|
||||||
|
};
|
||||||
|
let client = http_client()?;
|
||||||
|
let mut request = client.post(url).json(&body);
|
||||||
|
if let Some(key) = bearer.filter(|key| !key.trim().is_empty()) {
|
||||||
|
request = request.bearer_auth(key);
|
||||||
|
}
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Request to the AI model failed: {err}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("API error ({status}): {text}"));
|
||||||
|
}
|
||||||
|
let parsed: OpenAiResponse =
|
||||||
|
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
parsed
|
||||||
|
.choices
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.and_then(|choice| choice.message.content)
|
||||||
|
.map(|content| sanitize_message(&content))
|
||||||
|
.filter(|content| !content.is_empty())
|
||||||
|
.ok_or_else(|| "The model did not return a review.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn review_openai(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return Err("OpenAI API key is missing.".to_string());
|
||||||
|
}
|
||||||
|
openai_compatible_review_request(
|
||||||
|
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||||
|
Some(api_key),
|
||||||
|
model,
|
||||||
|
diff,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn review_custom(
|
||||||
|
base_url: &str,
|
||||||
|
api_key: Option<&str>,
|
||||||
|
model: &str,
|
||||||
|
diff: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if base_url.trim().is_empty() {
|
||||||
|
return Err("Endpoint URL is missing.".to_string());
|
||||||
|
}
|
||||||
|
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
|
||||||
|
openai_compatible_review_request(url, api_key, model, diff).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn openai_compatible_split_request(
|
||||||
|
url: String,
|
||||||
|
bearer: Option<&str>,
|
||||||
|
model: &str,
|
||||||
|
diff: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.";
|
||||||
|
let user =
|
||||||
|
format!("Analyze these staged changes and propose an ordered commit plan:\n\n{diff}");
|
||||||
|
let body = OpenAiRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
messages: vec![
|
||||||
|
OpenAiMessage {
|
||||||
|
role: "system",
|
||||||
|
content: system.to_string(),
|
||||||
|
},
|
||||||
|
OpenAiMessage {
|
||||||
|
role: "user",
|
||||||
|
content: user,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.1,
|
||||||
|
};
|
||||||
|
let client = http_client()?;
|
||||||
|
let mut request = client.post(url).json(&body);
|
||||||
|
if let Some(key) = bearer.filter(|key| !key.trim().is_empty()) {
|
||||||
|
request = request.bearer_auth(key);
|
||||||
|
}
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Request to the AI model failed: {err}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("API error ({status}): {text}"));
|
||||||
|
}
|
||||||
|
let parsed: OpenAiResponse =
|
||||||
|
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
parsed
|
||||||
|
.choices
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.and_then(|choice| choice.message.content)
|
||||||
|
.map(|content| sanitize_message(&content))
|
||||||
|
.filter(|content| !content.is_empty())
|
||||||
|
.ok_or_else(|| "The model did not return a commit plan.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn split_openai(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return Err("OpenAI API key is missing.".to_string());
|
||||||
|
}
|
||||||
|
openai_compatible_split_request(
|
||||||
|
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||||
|
Some(api_key),
|
||||||
|
model,
|
||||||
|
diff,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn split_custom(
|
||||||
|
base_url: &str,
|
||||||
|
api_key: Option<&str>,
|
||||||
|
model: &str,
|
||||||
|
diff: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if base_url.trim().is_empty() {
|
||||||
|
return Err("Endpoint URL is missing.".to_string());
|
||||||
|
}
|
||||||
|
openai_compatible_split_request(
|
||||||
|
format!("{}/chat/completions", base_url.trim_end_matches('/')),
|
||||||
|
api_key,
|
||||||
|
model,
|
||||||
|
diff,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct AnthropicMessage {
|
struct AnthropicMessage {
|
||||||
role: &'static str,
|
role: &'static str,
|
||||||
@@ -220,3 +379,89 @@ pub async fn generate_anthropic(
|
|||||||
}
|
}
|
||||||
Ok(message)
|
Ok(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn review_anthropic(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return Err("Anthropic API key is missing.".to_string());
|
||||||
|
}
|
||||||
|
let (system, user) = build_review_messages(diff)?;
|
||||||
|
let body = AnthropicRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
max_tokens: 2400,
|
||||||
|
system,
|
||||||
|
messages: vec![AnthropicMessage {
|
||||||
|
role: "user",
|
||||||
|
content: user,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let client = http_client()?;
|
||||||
|
let response = client
|
||||||
|
.post("https://api.anthropic.com/v1/messages")
|
||||||
|
.header("x-api-key", api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("API error ({status}): {text}"));
|
||||||
|
}
|
||||||
|
let parsed: AnthropicResponse =
|
||||||
|
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
parsed
|
||||||
|
.content
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|block| block.text)
|
||||||
|
.map(|text| sanitize_message(&text))
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.ok_or_else(|| "The model did not return a review.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn split_anthropic(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return Err("Anthropic API key is missing.".to_string());
|
||||||
|
}
|
||||||
|
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.".to_string();
|
||||||
|
let body = AnthropicRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
max_tokens: 2400,
|
||||||
|
system,
|
||||||
|
messages: vec![AnthropicMessage {
|
||||||
|
role: "user",
|
||||||
|
content: format!(
|
||||||
|
"Analyze these staged changes and propose an ordered commit plan:\n\n{diff}"
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let client = http_client()?;
|
||||||
|
let response = client
|
||||||
|
.post("https://api.anthropic.com/v1/messages")
|
||||||
|
.header("x-api-key", api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("API error ({status}): {text}"));
|
||||||
|
}
|
||||||
|
let parsed: AnthropicResponse =
|
||||||
|
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
parsed
|
||||||
|
.content
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|block| block.text)
|
||||||
|
.map(|text| sanitize_message(&text))
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.ok_or_else(|| "The model did not return a commit plan.".to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,319 +1,12 @@
|
|||||||
mod cloud;
|
mod cloud;
|
||||||
|
|
||||||
pub use cloud::{generate_anthropic, generate_custom, generate_openai};
|
pub use cloud::{
|
||||||
|
generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom,
|
||||||
use std::{
|
review_openai, split_anthropic, split_custom, split_openai,
|
||||||
collections::hash_map::DefaultHasher,
|
|
||||||
hash::{Hash, Hasher},
|
|
||||||
sync::Arc,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole};
|
/// Strip a wrapping code fence and wrapping quotes so the result can go straight into
|
||||||
use tokio::sync::RwLock;
|
/// the commit-message box.
|
||||||
|
|
||||||
/// 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.
|
|
||||||
pub(crate) fn sanitize_message(raw: &str) -> String {
|
pub(crate) fn sanitize_message(raw: &str) -> String {
|
||||||
let mut text = raw.trim().to_string();
|
let mut text = raw.trim().to_string();
|
||||||
if text.starts_with("```") {
|
if text.starts_with("```") {
|
||||||
@@ -330,9 +23,9 @@ pub(crate) fn sanitize_message(raw: &str) -> String {
|
|||||||
trimmed.to_string()
|
trimmed.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Weak models (small local ones especially) sometimes just echo the prompt's diff
|
/// Some models echo the prompt's diff sections instead of writing a commit message.
|
||||||
/// sections back instead of writing a commit message. Catch that so the UI can show a
|
/// Catch that so the UI can show a clear error instead of dumping raw diff text into
|
||||||
/// clear error instead of dumping raw diff text into the commit-message box.
|
/// the commit-message box.
|
||||||
pub(crate) fn looks_like_diff_echo(message: &str) -> bool {
|
pub(crate) fn looks_like_diff_echo(message: &str) -> bool {
|
||||||
let lower = message.to_ascii_lowercase();
|
let lower = message.to_ascii_lowercase();
|
||||||
lower.contains("diff --git")
|
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])
|
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> {
|
pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> {
|
||||||
if diff.trim().is_empty() {
|
if diff.trim().is_empty() {
|
||||||
return Err("No staged changes available for a commit message.".to_string());
|
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;
|
const MAX_CHARS: usize = 24_000;
|
||||||
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
|
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}"));
|
user.push_str(&format!("Staged diff:\n{diff}"));
|
||||||
Ok((system, user))
|
Ok((system, user))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_review_messages(diff: &str) -> Result<(String, String), String> {
|
||||||
|
if diff.trim().is_empty() {
|
||||||
|
return Err("No staged changes available for review.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CHARS: usize = 36_000;
|
||||||
|
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
|
||||||
|
let system = r#"You are a senior software engineer performing a focused pre-commit review.
|
||||||
|
Review only the supplied staged Git diff. Look for concrete correctness bugs, security issues,
|
||||||
|
data loss, regressions, broken edge cases, unsafe error handling, and meaningful performance or
|
||||||
|
maintainability risks. Do not report formatting preferences or speculative nitpicks.
|
||||||
|
|
||||||
|
Return ONLY valid JSON with this exact shape:
|
||||||
|
{"summary":"one concise overall assessment","risk":"low|medium|high","findings":[{"severity":"critical|warning|info","title":"short title","description":"clear evidence and impact","file":"path or null","line":123,"suggestion":"specific safe next step"}]}
|
||||||
|
|
||||||
|
Use the new-file line number from the diff when it is known; otherwise use null. Use null for file
|
||||||
|
when the issue is repository-wide. Maximum 12 findings, ordered critical then warning then info.
|
||||||
|
If no actionable issue exists, return an empty findings array and risk low. Never use markdown,
|
||||||
|
code fences, commentary outside the JSON, or claim that tests were executed."#
|
||||||
|
.to_string();
|
||||||
|
let user = format!("Staged diff to review:\n{diff}");
|
||||||
|
Ok((system, user))
|
||||||
|
}
|
||||||
|
|||||||
+15
-1
@@ -177,7 +177,21 @@ pub fn set_sync_badge(
|
|||||||
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
|
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
// Native badge count: works on Linux (via the desktop's launcher API, e.g. Unity's
|
||||||
|
// LauncherEntry, also honored by GNOME/KDE) and macOS (dock badge). Matched against the
|
||||||
|
// app's `<product-name>.desktop` file on Linux, so it's a silent no-op on launchers that
|
||||||
|
// don't implement the protocol.
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
{
|
||||||
|
let Some(window) = app.get_webview_window("main") else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
window
|
||||||
|
.set_badge_count(if count > 0 { Some(count as i64) } else { None })
|
||||||
|
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
|
||||||
{
|
{
|
||||||
let _ = (app, count);
|
let _ = (app, count);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+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")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod badge;
|
mod badge;
|
||||||
|
mod external_tools;
|
||||||
mod git;
|
mod git;
|
||||||
|
mod integrations;
|
||||||
|
mod telemetry;
|
||||||
|
|
||||||
use badge::set_sync_badge;
|
use badge::set_sync_badge;
|
||||||
use git::{
|
use external_tools::{
|
||||||
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
|
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
|
||||||
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
|
|
||||||
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
|
||||||
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
|
|
||||||
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
|
|
||||||
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame,
|
|
||||||
get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits,
|
|
||||||
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
|
|
||||||
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
|
|
||||||
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
|
|
||||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
|
||||||
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
|
|
||||||
stash_push, undo_last_commit, unstage_files,
|
|
||||||
};
|
};
|
||||||
use tauri::{Manager, AppHandle};
|
use git::{
|
||||||
|
SearchCancellationState, add_remote, add_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]
|
#[tauri::command]
|
||||||
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
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") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.maximize();
|
||||||
window
|
window
|
||||||
.show()
|
.show()
|
||||||
.map_err(|error| format!("failed to show main window: {error}"))?;
|
.map_err(|error| format!("failed to show main window: {error}"))?;
|
||||||
@@ -42,14 +274,41 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tauri::Builder::default()
|
init_console_logging();
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
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)]
|
#[cfg(desktop)]
|
||||||
let _ = app.get_webview_window("main")
|
let _ = app
|
||||||
|
.get_webview_window("main")
|
||||||
.expect("no main window")
|
.expect("no main window")
|
||||||
.set_focus();
|
.set_focus();
|
||||||
}))
|
}))
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
|
tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
|
||||||
.with_options(tauri_plugin_aptabase::InitOptions {
|
.with_options(tauri_plugin_aptabase::InitOptions {
|
||||||
@@ -58,21 +317,57 @@ async fn main() {
|
|||||||
})
|
})
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
|
.manage(StartupRepository(Mutex::new(startup_repository)))
|
||||||
|
.manage(StartupClone(Mutex::new(startup_clone)))
|
||||||
.manage(SearchCancellationState::default())
|
.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![
|
.invoke_handler(tauri::generate_handler![
|
||||||
open_repository,
|
open_repository,
|
||||||
|
init_repository,
|
||||||
clone_repository,
|
clone_repository,
|
||||||
open_repo_in_explorer,
|
open_repo_in_explorer,
|
||||||
open_repository_file,
|
open_repository_file,
|
||||||
|
detect_external_tools,
|
||||||
|
launch_external_tool,
|
||||||
|
launch_external_diff,
|
||||||
|
launch_external_merge,
|
||||||
get_status,
|
get_status,
|
||||||
|
git_lfs_status,
|
||||||
|
git_lfs_install,
|
||||||
|
git_lfs_track,
|
||||||
|
git_lfs_untrack,
|
||||||
|
git_lfs_pull,
|
||||||
|
git_lfs_prune,
|
||||||
list_branches,
|
list_branches,
|
||||||
|
list_remotes,
|
||||||
|
add_remote,
|
||||||
|
update_remote,
|
||||||
|
remove_remote,
|
||||||
|
set_branch_upstream,
|
||||||
|
delete_remote_branch,
|
||||||
|
delete_remote_branches,
|
||||||
list_stashes,
|
list_stashes,
|
||||||
checkout_branch,
|
checkout_branch,
|
||||||
create_branch,
|
create_branch,
|
||||||
rename_branch,
|
rename_branch,
|
||||||
|
rename_remote_branch,
|
||||||
delete_branch,
|
delete_branch,
|
||||||
|
list_worktrees,
|
||||||
|
add_worktree,
|
||||||
|
remove_worktree,
|
||||||
|
move_worktree,
|
||||||
|
lock_worktree,
|
||||||
|
unlock_worktree,
|
||||||
|
prune_worktrees,
|
||||||
|
repair_worktree,
|
||||||
list_tags,
|
list_tags,
|
||||||
create_tag,
|
create_tag,
|
||||||
delete_tag,
|
delete_tag,
|
||||||
@@ -82,6 +377,8 @@ async fn main() {
|
|||||||
cherry_pick_abort,
|
cherry_pick_abort,
|
||||||
stage_files,
|
stage_files,
|
||||||
unstage_files,
|
unstage_files,
|
||||||
|
add_to_gitignore,
|
||||||
|
untrack_paths,
|
||||||
stash_push,
|
stash_push,
|
||||||
stash_apply,
|
stash_apply,
|
||||||
stash_pop,
|
stash_pop,
|
||||||
@@ -93,20 +390,35 @@ async fn main() {
|
|||||||
amend_commit,
|
amend_commit,
|
||||||
undo_last_commit,
|
undo_last_commit,
|
||||||
last_commit_message,
|
last_commit_message,
|
||||||
commit_ai_status,
|
|
||||||
commit_ai_load,
|
|
||||||
commit_ai_local_models,
|
|
||||||
commit_ai_generate,
|
commit_ai_generate,
|
||||||
|
commit_ai_review,
|
||||||
|
commit_ai_split,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
fetch,
|
fetch,
|
||||||
list_commits,
|
list_commits,
|
||||||
|
get_commit_note,
|
||||||
|
set_commit_note,
|
||||||
|
delete_commit_note,
|
||||||
|
fetch_commit_notes,
|
||||||
|
push_commit_notes,
|
||||||
restore_to_commit,
|
restore_to_commit,
|
||||||
restore_file_from_commit,
|
restore_file_from_commit,
|
||||||
merge_branch,
|
merge_branch,
|
||||||
|
merge_continue,
|
||||||
|
merge_abort,
|
||||||
|
revert_commit,
|
||||||
rebase_branch,
|
rebase_branch,
|
||||||
rebase_continue,
|
rebase_continue,
|
||||||
rebase_abort,
|
rebase_abort,
|
||||||
|
list_interactive_rebase_commits,
|
||||||
|
start_interactive_rebase,
|
||||||
|
list_reflog,
|
||||||
|
restore_reflog_entry,
|
||||||
|
get_bisect_state,
|
||||||
|
start_bisect,
|
||||||
|
mark_bisect,
|
||||||
|
reset_bisect,
|
||||||
list_repository_files,
|
list_repository_files,
|
||||||
open_repository_bundle,
|
open_repository_bundle,
|
||||||
list_file_history,
|
list_file_history,
|
||||||
@@ -125,8 +437,19 @@ async fn main() {
|
|||||||
cred_load,
|
cred_load,
|
||||||
cred_save,
|
cred_save,
|
||||||
cred_delete,
|
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,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
use std::{
|
||||||
|
sync::{
|
||||||
|
OnceLock,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
mpsc::{SyncSender, sync_channel},
|
||||||
|
},
|
||||||
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
const DEFAULT_OTLP_LOGS_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/logs";
|
||||||
|
const DEFAULT_OTLP_TRACES_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/traces";
|
||||||
|
const DEFAULT_OTLP_METRICS_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/metrics";
|
||||||
|
const METRICS_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
|
const MAX_BODY_BYTES: usize = 2_048;
|
||||||
|
|
||||||
|
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||||
|
static SENDER: OnceLock<SyncSender<TelemetrySignal>> = OnceLock::new();
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum TelemetrySignal {
|
||||||
|
Log(TelemetryRecord),
|
||||||
|
Span(FrontendSpan),
|
||||||
|
Metrics(ProcessMetrics),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ProcessMetrics {
|
||||||
|
cpu_utilization: f64,
|
||||||
|
memory_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TelemetryRecord {
|
||||||
|
severity: &'static str,
|
||||||
|
severity_number: u8,
|
||||||
|
body: String,
|
||||||
|
event_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FrontendLog {
|
||||||
|
level: String,
|
||||||
|
message: String,
|
||||||
|
event_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FrontendSpan {
|
||||||
|
name: String,
|
||||||
|
trace_id: String,
|
||||||
|
span_id: String,
|
||||||
|
started_at_ms: u64,
|
||||||
|
duration_ms: f64,
|
||||||
|
success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init() {
|
||||||
|
let endpoint = std::env::var("GITTY_OTLP_LOGS_ENDPOINT")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_OTLP_LOGS_ENDPOINT.to_string());
|
||||||
|
let traces_endpoint = std::env::var("GITTY_OTLP_TRACES_ENDPOINT")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_OTLP_TRACES_ENDPOINT.to_string());
|
||||||
|
let metrics_endpoint = std::env::var("GITTY_OTLP_METRICS_ENDPOINT")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_OTLP_METRICS_ENDPOINT.to_string());
|
||||||
|
let (sender, receiver) = sync_channel::<TelemetrySignal>(256);
|
||||||
|
if SENDER.set(sender.clone()).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
start_process_metrics(sender);
|
||||||
|
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("gitty-telemetry".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let client = match reqwest::blocking::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("[WARN] [gitty::telemetry] could not create OTLP client: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut export_warning_shown = false;
|
||||||
|
while let Ok(signal) = receiver.recv() {
|
||||||
|
let (target_endpoint, payload) = match signal {
|
||||||
|
TelemetrySignal::Log(record) => (&endpoint, log_payload(record)),
|
||||||
|
TelemetrySignal::Span(span) => (&traces_endpoint, span_payload(span)),
|
||||||
|
TelemetrySignal::Metrics(metrics) => {
|
||||||
|
(&metrics_endpoint, metrics_payload(metrics))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export(
|
||||||
|
&client,
|
||||||
|
target_endpoint,
|
||||||
|
&payload,
|
||||||
|
&mut export_warning_shown,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("failed to start telemetry worker");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_process_metrics(sender: SyncSender<TelemetrySignal>) {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("gitty-process-metrics".into())
|
||||||
|
.spawn(move || {
|
||||||
|
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
|
||||||
|
|
||||||
|
let Ok(pid) = sysinfo::get_current_pid() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let refresh_kind = ProcessRefreshKind::nothing().with_cpu().with_memory();
|
||||||
|
let cpu_count = std::thread::available_parallelism()
|
||||||
|
.map(|count| count.get())
|
||||||
|
.unwrap_or(1) as f64;
|
||||||
|
let mut system = System::new();
|
||||||
|
let mut primed = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(METRICS_INTERVAL);
|
||||||
|
if !ENABLED.load(Ordering::Relaxed) {
|
||||||
|
primed = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
system.refresh_processes_specifics(
|
||||||
|
ProcessesToUpdate::Some(&[pid]),
|
||||||
|
true,
|
||||||
|
refresh_kind,
|
||||||
|
);
|
||||||
|
let Some(process) = system.process(pid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !primed {
|
||||||
|
primed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let metrics = ProcessMetrics {
|
||||||
|
cpu_utilization: (f64::from(process.cpu_usage()) / (100.0 * cpu_count))
|
||||||
|
.clamp(0.0, 1.0),
|
||||||
|
memory_bytes: process.memory(),
|
||||||
|
};
|
||||||
|
let _ = sender.try_send(TelemetrySignal::Metrics(metrics));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("failed to start process metrics worker");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resource_attributes() -> serde_json::Value {
|
||||||
|
json!([
|
||||||
|
{ "key": "service.name", "value": { "stringValue": "gitty-desktop" } },
|
||||||
|
{ "key": "service.version", "value": { "stringValue": env!("CARGO_PKG_VERSION") } },
|
||||||
|
{ "key": "deployment.environment.name", "value": { "stringValue": if cfg!(debug_assertions) { "development" } else { "production" } } },
|
||||||
|
{ "key": "os.type", "value": { "stringValue": std::env::consts::OS } },
|
||||||
|
{ "key": "host.arch", "value": { "stringValue": std::env::consts::ARCH } }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_payload(record: TelemetryRecord) -> serde_json::Value {
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
.to_string();
|
||||||
|
let mut attributes = vec![json!({
|
||||||
|
"key": "telemetry.sdk.language",
|
||||||
|
"value": { "stringValue": "rust" }
|
||||||
|
})];
|
||||||
|
if let Some(event_name) = record.event_name {
|
||||||
|
attributes.push(json!({
|
||||||
|
"key": "event.name",
|
||||||
|
"value": { "stringValue": event_name }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let payload = json!({
|
||||||
|
"resourceLogs": [{
|
||||||
|
"resource": { "attributes": resource_attributes() },
|
||||||
|
"scopeLogs": [{
|
||||||
|
"scope": { "name": "gitty.telemetry" },
|
||||||
|
"logRecords": [{
|
||||||
|
"timeUnixNano": timestamp,
|
||||||
|
"observedTimeUnixNano": timestamp,
|
||||||
|
"severityNumber": record.severity_number,
|
||||||
|
"severityText": record.severity,
|
||||||
|
"body": { "stringValue": record.body },
|
||||||
|
"attributes": attributes
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fn span_payload(mut span: FrontendSpan) -> serde_json::Value {
|
||||||
|
if span.name.len() > 128 {
|
||||||
|
span.name.truncate(128);
|
||||||
|
}
|
||||||
|
let start = u128::from(span.started_at_ms) * 1_000_000;
|
||||||
|
let duration = (span.duration_ms.max(0.0) * 1_000_000.0) as u128;
|
||||||
|
let attributes = vec![
|
||||||
|
json!({ "key": "rpc.system", "value": { "stringValue": "tauri" } }),
|
||||||
|
json!({ "key": "rpc.method", "value": { "stringValue": span.name } }),
|
||||||
|
];
|
||||||
|
json!({
|
||||||
|
"resourceSpans": [{
|
||||||
|
"resource": { "attributes": resource_attributes() },
|
||||||
|
"scopeSpans": [{
|
||||||
|
"scope": { "name": "gitty.tauri", "version": env!("CARGO_PKG_VERSION") },
|
||||||
|
"spans": [{
|
||||||
|
"traceId": span.trace_id,
|
||||||
|
"spanId": span.span_id,
|
||||||
|
"name": format!("tauri.{}", span.name),
|
||||||
|
"kind": 1,
|
||||||
|
"startTimeUnixNano": start.to_string(),
|
||||||
|
"endTimeUnixNano": (start + duration).to_string(),
|
||||||
|
"attributes": attributes,
|
||||||
|
"status": {
|
||||||
|
"code": if span.success { 1 } else { 2 },
|
||||||
|
"message": if span.success { "" } else { "Tauri command failed" }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metrics_payload(metrics: ProcessMetrics) -> serde_json::Value {
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
.to_string();
|
||||||
|
json!({
|
||||||
|
"resourceMetrics": [{
|
||||||
|
"resource": { "attributes": resource_attributes() },
|
||||||
|
"scopeMetrics": [{
|
||||||
|
"scope": { "name": "gitty.process", "version": env!("CARGO_PKG_VERSION") },
|
||||||
|
"metrics": [
|
||||||
|
{
|
||||||
|
"name": "process.cpu.utilization",
|
||||||
|
"description": "Normalized CPU utilization of the Gitty process",
|
||||||
|
"unit": "1",
|
||||||
|
"gauge": { "dataPoints": [{
|
||||||
|
"timeUnixNano": timestamp,
|
||||||
|
"asDouble": metrics.cpu_utilization,
|
||||||
|
"attributes": []
|
||||||
|
}] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "process.memory.usage",
|
||||||
|
"description": "Resident memory used by the Gitty process",
|
||||||
|
"unit": "By",
|
||||||
|
"gauge": { "dataPoints": [{
|
||||||
|
"timeUnixNano": timestamp,
|
||||||
|
"asInt": metrics.memory_bytes.to_string(),
|
||||||
|
"attributes": []
|
||||||
|
}] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export(
|
||||||
|
client: &reqwest::blocking::Client,
|
||||||
|
endpoint: &str,
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
export_warning_shown: &mut bool,
|
||||||
|
) {
|
||||||
|
match client.post(endpoint).json(payload).send() {
|
||||||
|
Ok(response) if response.status().is_success() => {
|
||||||
|
if response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| value.starts_with("text/html"))
|
||||||
|
&& !*export_warning_shown
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[WARN] [gitty::telemetry] OTLP endpoint returned HTML; configure the collector endpoint with GITTY_OTLP_LOGS_ENDPOINT"
|
||||||
|
);
|
||||||
|
*export_warning_shown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response) if !*export_warning_shown => {
|
||||||
|
eprintln!(
|
||||||
|
"[WARN] [gitty::telemetry] OTLP export failed with status {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
*export_warning_shown = true;
|
||||||
|
}
|
||||||
|
Err(error) if !*export_warning_shown => {
|
||||||
|
eprintln!("[WARN] [gitty::telemetry] OTLP export failed: {error}");
|
||||||
|
*export_warning_shown = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(record: TelemetryRecord) {
|
||||||
|
if !ENABLED.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(sender) = SENDER.get() {
|
||||||
|
let _ = sender.try_send(TelemetrySignal::Log(record));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_telemetry_enabled(enabled: bool) {
|
||||||
|
ENABLED.store(enabled, Ordering::Relaxed);
|
||||||
|
if enabled {
|
||||||
|
emit(TelemetryRecord {
|
||||||
|
severity: "INFO",
|
||||||
|
severity_number: 9,
|
||||||
|
body: "Telemetry enabled".into(),
|
||||||
|
event_name: Some("telemetry.enabled".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn emit_frontend_log(log: FrontendLog) {
|
||||||
|
let (severity, severity_number) = match log.level.as_str() {
|
||||||
|
"error" => ("ERROR", 17),
|
||||||
|
"warn" => ("WARN", 13),
|
||||||
|
_ => ("INFO", 9),
|
||||||
|
};
|
||||||
|
let mut body = log.message;
|
||||||
|
if body.len() > MAX_BODY_BYTES {
|
||||||
|
body.truncate(MAX_BODY_BYTES);
|
||||||
|
}
|
||||||
|
emit(TelemetryRecord {
|
||||||
|
severity,
|
||||||
|
severity_number,
|
||||||
|
body,
|
||||||
|
event_name: log.event_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn emit_frontend_span(span: FrontendSpan) {
|
||||||
|
if !ENABLED.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if span.trace_id.len() != 32
|
||||||
|
|| span.span_id.len() != 16
|
||||||
|
|| !span.trace_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
|| !span.span_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(sender) = SENDER.get() {
|
||||||
|
let _ = sender.try_send(TelemetrySignal::Span(span));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Gitty",
|
"productName": "Gitty",
|
||||||
"version": "0.200.5",
|
"version": "2026.9.1",
|
||||||
"identifier": "com.gitty",
|
"identifier": "com.gitty",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
|
||||||
"devUrl": "http://127.0.0.1:1420",
|
"devUrl": "http://127.0.0.1:1420",
|
||||||
"beforeBuildCommand": "npm run build",
|
"beforeBuildCommand": "npm run prepare:lfs && npm run build",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
@@ -41,6 +41,12 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/git-lfs"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
"binaries/git-lfs-LICENSE.txt"
|
||||||
|
],
|
||||||
"targets": [
|
"targets": [
|
||||||
"nsis"
|
"nsis"
|
||||||
],
|
],
|
||||||
|
|||||||
+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">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte";
|
import { CircleHelp, Minus, Settings, X } from "@lucide/svelte";
|
||||||
import iconUrl from "../../src-tauri/icons/icon.png";
|
import iconUrl from "../../src-tauri/icons/icon.png";
|
||||||
|
|
||||||
export let branch: string = "";
|
export let onOpenHelp: () => void = () => {};
|
||||||
export let ahead: number = 0;
|
|
||||||
export let behind: number = 0;
|
|
||||||
export let repoName: string = "";
|
|
||||||
export let hasRepository: boolean = false;
|
|
||||||
export let isBusy: boolean = false;
|
|
||||||
export let operation: string = "";
|
|
||||||
export let autoRefreshEnabled: boolean = true;
|
|
||||||
export let autoRefreshInFlight: boolean = false;
|
|
||||||
export let onFetch: () => void = () => {};
|
|
||||||
export let onPull: () => void = () => {};
|
|
||||||
export let onPush: () => void = () => {};
|
|
||||||
export let onRefresh: () => void = () => {};
|
|
||||||
export let onSearch: () => void = () => {};
|
|
||||||
export let onCompare: () => void = () => {};
|
|
||||||
export let onOpenInExplorer: () => void = () => {};
|
|
||||||
export let onToggleAutoRefresh: () => void = () => {};
|
|
||||||
export let onOpenSettings: () => void = () => {};
|
export let onOpenSettings: () => void = () => {};
|
||||||
|
export let language: "en" | "de" = "en";
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
let win: ReturnType<typeof getCurrentWindow> | null = null;
|
||||||
let isMaximized = false;
|
let isMaximized = false;
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
let appVersion = "";
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
isMaximized = await win.isMaximized();
|
try {
|
||||||
unlisten = await win.onResized(async () => {
|
win = getCurrentWindow();
|
||||||
isMaximized = await win.isMaximized();
|
isMaximized = await win.isMaximized();
|
||||||
});
|
unlisten = await win.onResized(async () => {
|
||||||
appVersion = await getVersion();
|
isMaximized = await win?.isMaximized() ?? false;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
win = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unlisten?.();
|
unlisten?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function minimizeWindow() {
|
||||||
|
void win?.minimize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMaximizeWindow() {
|
||||||
|
void win?.toggleMaximize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWindow() {
|
||||||
|
void win?.close();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="titlebar" data-tauri-drag-region>
|
<header class="titlebar" data-tauri-drag-region>
|
||||||
@@ -49,180 +48,53 @@
|
|||||||
<img src={iconUrl} alt="" data-tauri-drag-region />
|
<img src={iconUrl} alt="" data-tauri-drag-region />
|
||||||
</span>
|
</span>
|
||||||
<span data-tauri-drag-region>Gitty</span>
|
<span data-tauri-drag-region>Gitty</span>
|
||||||
{#if appVersion}
|
|
||||||
<span class="tb-version" data-tauri-drag-region title="Version {appVersion}">v{appVersion}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: repo + branch info -->
|
<div class="titlebar-drag" data-tauri-drag-region aria-hidden="true"></div>
|
||||||
<div class="titlebar-info" data-tauri-drag-region>
|
|
||||||
{#if hasRepository}
|
<!-- Right: app-global actions + window controls -->
|
||||||
{#if repoName}
|
<div class="titlebar-globals">
|
||||||
<span class="tb-repo" data-tauri-drag-region>{repoName}</span>
|
<button
|
||||||
<span class="tb-sep" data-tauri-drag-region aria-hidden="true">/</span>
|
class="tb-action"
|
||||||
{/if}
|
onclick={onOpenHelp}
|
||||||
<GitBranch size={12} aria-hidden="true" />
|
title={language === "de" ? "Hilfe (Ctrl+/)" : "Help (Ctrl+/)"}
|
||||||
<span class="tb-branch" data-tauri-drag-region>{branch}</span>
|
aria-label={language === "de" ? "Hilfe öffnen" : "Open help"}
|
||||||
{#if ahead > 0}
|
>
|
||||||
<span class="tb-sync ahead" title="{ahead} commits ahead">↑{ahead}</span>
|
<CircleHelp size={14} aria-hidden="true" />
|
||||||
{/if}
|
</button>
|
||||||
{#if behind > 0}
|
<button
|
||||||
<span class="tb-sync behind" title="{behind} commits behind">↓{behind}</span>
|
class="tb-action"
|
||||||
{/if}
|
onclick={onOpenSettings}
|
||||||
{:else}
|
title={language === "de" ? "Einstellungen" : "Settings"}
|
||||||
<span class="tb-no-repo" data-tauri-drag-region>No repository open</span>
|
aria-label={language === "de" ? "Einstellungen" : "Settings"}
|
||||||
{/if}
|
>
|
||||||
|
<Settings size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: actions + window controls -->
|
<div class="titlebar-controls">
|
||||||
<div class="titlebar-right">
|
<button class="tb-btn" onclick={minimizeWindow} title="Minimize" aria-label="Minimize">
|
||||||
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
|
<Minus size={12} aria-hidden="true" />
|
||||||
<button
|
</button>
|
||||||
class="tb-action"
|
<button
|
||||||
onclick={onOpenInExplorer}
|
class="tb-btn"
|
||||||
disabled={!hasRepository || isBusy}
|
onclick={toggleMaximizeWindow}
|
||||||
title="Open repository in Explorer"
|
title={isMaximized ? "Restore" : "Maximize"}
|
||||||
aria-label="Open repository in Explorer"
|
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||||
>
|
>
|
||||||
<FolderOpen size={14} aria-hidden="true" />
|
{#if isMaximized}
|
||||||
<span class="tb-action-label">Explorer</span>
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
</button>
|
<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" />
|
||||||
<button
|
</svg>
|
||||||
class="tb-action"
|
{:else}
|
||||||
onclick={onSearch}
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
disabled={!hasRepository || isBusy}
|
<rect x="1" y="1" width="10" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||||
title="Global search"
|
</svg>
|
||||||
aria-label="Global search"
|
{/if}
|
||||||
>
|
</button>
|
||||||
<Search size={14} aria-hidden="true" />
|
<button class="tb-btn close" onclick={closeWindow} title="Close" aria-label="Close">
|
||||||
<span class="tb-action-label">Search</span>
|
<X size={12} aria-hidden="true" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { telemetryLog } from "./telemetry";
|
||||||
|
|
||||||
export type AnalyticsEventProperties = Record<string, string | number>;
|
export type AnalyticsEventProperties = Record<string, string | number>;
|
||||||
|
|
||||||
export function trackAnalyticsEvent(name: string, props?: AnalyticsEventProperties) {
|
export function trackAnalyticsEvent(name: string, props?: AnalyticsEventProperties) {
|
||||||
|
const details = props && Object.keys(props).length > 0 ? ` ${JSON.stringify(props)}` : "";
|
||||||
|
telemetryLog("info", `${name}${details}`, `product.${name}`);
|
||||||
void invoke("plugin:aptabase|track_event", {
|
void invoke("plugin:aptabase|track_event", {
|
||||||
name,
|
name,
|
||||||
props: props && Object.keys(props).length > 0 ? props : undefined,
|
props: props && Object.keys(props).length > 0 ? props : undefined,
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { GitCommitHorizontal, LoaderCircle, Sparkles, X } from "@lucide/svelte";
|
||||||
|
import type { AiCommitPlan } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
plan: AiCommitPlan;
|
||||||
|
isApplying: boolean;
|
||||||
|
onApply: (plan: AiCommitPlan) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { plan, isApplying = false, onApply, onClose }: Props = $props();
|
||||||
|
let draft = $state<AiCommitPlan>({ summary: "", groups: [] });
|
||||||
|
$effect.pre(() => {
|
||||||
|
if (draft.groups.length === 0) draft = structuredClone(plan);
|
||||||
|
});
|
||||||
|
let valid = $derived(draft.groups.length > 1 && draft.groups.every((group) => group.message.trim() && group.files.length));
|
||||||
|
let validationIssue = $derived(
|
||||||
|
draft.groups.findIndex((group) => group.files.length === 0) >= 0
|
||||||
|
? "Every commit needs at least one file."
|
||||||
|
: draft.groups.findIndex((group) => !group.message.trim()) >= 0
|
||||||
|
? "Every commit needs a message."
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
|
||||||
|
function setMessage(index: number, message: string) {
|
||||||
|
draft.groups[index].message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveFile(file: string, from: number, to: number) {
|
||||||
|
if (from === to) return;
|
||||||
|
draft.groups[from].files = draft.groups[from].files.filter((path) => path !== file);
|
||||||
|
draft.groups[to].files = [...draft.groups[to].files, file];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDraft() {
|
||||||
|
// `draft` is a deeply reactive Svelte proxy. `structuredClone(draft)`
|
||||||
|
// throws a DataCloneError before the callback runs, which made the button
|
||||||
|
// appear to do nothing. A state snapshot is a plain, cloneable object.
|
||||||
|
onApply($state.snapshot(draft));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={(event) => { if (event.key === "Escape" && !isApplying) onClose(); }} />
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog split-dialog" role="dialog" aria-modal="true" aria-label="AI commit split">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div><span class="eyebrow">Staged changes</span><h2>Split into logical commits</h2></div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isApplying} aria-label="Close"><X size={18} /></button>
|
||||||
|
</header>
|
||||||
|
<div class="split-intro"><Sparkles size={18} /><p>{draft.summary}</p></div>
|
||||||
|
<div class="split-groups">
|
||||||
|
{#each draft.groups as group, groupIndex}
|
||||||
|
<article class:empty={group.files.length === 0}>
|
||||||
|
<header><GitCommitHorizontal size={17} /><strong>Commit {groupIndex + 1}</strong><span>{group.files.length} files</span></header>
|
||||||
|
<label>
|
||||||
|
<span>Commit message <em>AI-generated · editable</em></span>
|
||||||
|
<input value={group.message} oninput={(event) => setMessage(groupIndex, event.currentTarget.value)} disabled={isApplying} />
|
||||||
|
</label>
|
||||||
|
{#if group.reason}<p>{group.reason}</p>{/if}
|
||||||
|
<div class="split-files">
|
||||||
|
{#each group.files as file}
|
||||||
|
<div><code>{file}</code>
|
||||||
|
<SelectMenu class="split-file-target" value={String(groupIndex)} options={draft.groups.map((_, target) => ({ value: String(target), label: `Commit ${target + 1}` }))} disabled={isApplying} onChange={(value) => moveFile(file, groupIndex, Number(value))} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<p class:invalid={!!validationIssue}>{validationIssue || "Messages are generated automatically. All commits are created in this order."}</p>
|
||||||
|
<div><button class="btn-secondary" type="button" onclick={onClose} disabled={isApplying}>Cancel</button>
|
||||||
|
<button class="btn-primary" type="button" onclick={applyDraft} disabled={!valid || isApplying}>
|
||||||
|
{#if isApplying}<LoaderCircle class="spin" size={14} />{/if}Commit all ({draft.groups.length})
|
||||||
|
</button></div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.split-dialog{width:min(820px,calc(100vw - 32px));max-height:min(820px,calc(100vh - 32px));display:flex;flex-direction:column}
|
||||||
|
.split-intro{display:flex;gap:10px;align-items:flex-start;padding:14px 18px;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
|
||||||
|
.split-intro p{margin:0;line-height:1.5}
|
||||||
|
.split-groups{display:grid;gap:10px;padding:14px 18px;overflow:auto}
|
||||||
|
article{display:grid;gap:10px;padding:13px;border:1px solid var(--color-border-subtle);border-radius:9px;background:var(--color-surface-raised)}
|
||||||
|
article.empty{border-color:#d88a45}
|
||||||
|
article>header{display:flex;align-items:center;gap:8px;color:var(--color-ink)}
|
||||||
|
article>header span{margin-left:auto;color:var(--color-ink-faint);font-size:11px}
|
||||||
|
label{display:grid;gap:5px;color:var(--color-ink-faint);font-size:10px;font-weight:800;text-transform:uppercase}
|
||||||
|
label span{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||||
|
label em{color:var(--color-accent);font-size:9px;font-style:normal;font-weight:700;text-transform:none}
|
||||||
|
input{height:34px;padding:0 10px;border:1px solid var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-ink);font-family:var(--font-mono)}
|
||||||
|
article>p{margin:0;color:var(--color-ink-muted);font-size:12px;line-height:1.45}
|
||||||
|
.split-files{display:grid;gap:5px}
|
||||||
|
.split-files>div{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;background:var(--color-surface)}
|
||||||
|
code{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;color:var(--color-ink-muted);font-size:11px;white-space:nowrap}
|
||||||
|
:global(.split-file-target){width:110px;flex:0 0 110px}
|
||||||
|
:global(.split-file-target .select-menu-trigger){height:28px;min-height:28px;font-size:11px}
|
||||||
|
.dialog-footer p.invalid{color:#e0a040}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,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">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from "svelte";
|
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 { credDelete, credLoad, credSave } from "../git";
|
||||||
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
|
import type { AiSettings, CommitAiProvider } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: AiSettings;
|
settings: AiSettings;
|
||||||
localModels: LocalModelOption[];
|
|
||||||
onSave: (settings: AiSettings) => void;
|
onSave: (settings: AiSettings) => void;
|
||||||
onClose: () => 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> = {
|
const CRED_KEYS: Record<CloudProvider, string> = {
|
||||||
openai: "ai:openai",
|
openai: "ai:openai",
|
||||||
@@ -21,9 +20,7 @@
|
|||||||
custom: "ai:custom",
|
custom: "ai:custom",
|
||||||
};
|
};
|
||||||
|
|
||||||
let provider = $state<CommitAiProvider>("local");
|
let provider = $state<CommitAiProvider>("openai");
|
||||||
let localModelId = $state("");
|
|
||||||
let localProfile = $state<CommitAiLocalProfile>("fast");
|
|
||||||
let openaiModel = $state("");
|
let openaiModel = $state("");
|
||||||
let anthropicModel = $state("");
|
let anthropicModel = $state("");
|
||||||
let customBaseUrl = $state("");
|
let customBaseUrl = $state("");
|
||||||
@@ -40,8 +37,6 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
provider = settings.provider;
|
provider = settings.provider;
|
||||||
localModelId = settings.localModelId;
|
|
||||||
localProfile = settings.localProfile ?? "fast";
|
|
||||||
openaiModel = settings.openaiModel;
|
openaiModel = settings.openaiModel;
|
||||||
anthropicModel = settings.anthropicModel;
|
anthropicModel = settings.anthropicModel;
|
||||||
customBaseUrl = settings.customBaseUrl;
|
customBaseUrl = settings.customBaseUrl;
|
||||||
@@ -85,7 +80,7 @@
|
|||||||
const key = CRED_KEYS[target];
|
const key = CRED_KEYS[target];
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
await credSave(key, "api-key", trimmed, null);
|
await credSave(key, "api-key", trimmed);
|
||||||
} else {
|
} else {
|
||||||
await credDelete(key);
|
await credDelete(key);
|
||||||
}
|
}
|
||||||
@@ -102,8 +97,6 @@
|
|||||||
]);
|
]);
|
||||||
onSave({
|
onSave({
|
||||||
provider,
|
provider,
|
||||||
localModelId,
|
|
||||||
localProfile,
|
|
||||||
openaiModel: openaiModel.trim() || "gpt-4o-mini",
|
openaiModel: openaiModel.trim() || "gpt-4o-mini",
|
||||||
anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest",
|
anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest",
|
||||||
customBaseUrl: customBaseUrl.trim(),
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -155,17 +128,6 @@
|
|||||||
|
|
||||||
<form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}>
|
<form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}>
|
||||||
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider">
|
<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"; }}>
|
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
|
||||||
<Bot size={16} aria-hidden="true" />
|
<Bot size={16} aria-hidden="true" />
|
||||||
OpenAI
|
OpenAI
|
||||||
@@ -180,42 +142,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if provider === "local"}
|
{#if provider === "openai"}
|
||||||
<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"}
|
|
||||||
<label class="cred-field">
|
<label class="cred-field">
|
||||||
<span class="cred-field-label">Model</span>
|
<span class="cred-field-label">Model</span>
|
||||||
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
|
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
|
||||||
|
|||||||
@@ -30,10 +30,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="analytics-notice-copy">
|
<div class="analytics-notice-copy">
|
||||||
<p>
|
<p>
|
||||||
Gitty can send anonymous usage events to Aptabase so crashes, rough edges, and commonly used workflows are easier to improve.
|
Gitty can send anonymous usage events and technical error logs to Aptabase and the self-hosted SigNoz service so crashes, rough edges, and commonly used workflows are easier to improve.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Events do not include repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, or source code.
|
Telemetry does not include repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, or source code.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="dialog-footer analytics-notice-footer">
|
<footer class="dialog-footer analytics-notice-footer">
|
||||||
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage events.</span>
|
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage and technical errors.</span>
|
||||||
<button class="btn-primary" type="button" onclick={() => onContinue(allowAnalytics)}>
|
<button class="btn-primary" type="button" onclick={() => onContinue(allowAnalytics)}>
|
||||||
<Check size={16} aria-hidden="true" />
|
<Check size={16} aria-hidden="true" />
|
||||||
Continue
|
Continue
|
||||||
|
|||||||
@@ -1,68 +1,719 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, Settings, X } from "@lucide/svelte";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import type { AnalyticsSettings } from "../types";
|
import {
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
CircleDashed,
|
||||||
|
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 {
|
interface Props {
|
||||||
analytics: AnalyticsSettings;
|
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;
|
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 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(() => {
|
$effect(() => {
|
||||||
analyticsEnabled = analytics.enabled;
|
analyticsEnabled = analytics.enabled;
|
||||||
|
selectedTheme = theme;
|
||||||
|
selectedAppearance = appearance;
|
||||||
|
customColors = structuredClone(customTheme);
|
||||||
|
selectedLanguage = language;
|
||||||
|
autoRefreshEnabled = autoRefresh;
|
||||||
|
tools = structuredClone(externalTools);
|
||||||
|
integrationDraft = structuredClone(integrations);
|
||||||
});
|
});
|
||||||
|
|
||||||
function save() {
|
async function save() {
|
||||||
onSave({
|
if (saving) return;
|
||||||
...analytics,
|
saving = true;
|
||||||
enabled: analyticsEnabled,
|
try {
|
||||||
noticeSeen: true,
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation">
|
<div class="dialog-backdrop" role="presentation">
|
||||||
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1">
|
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="app-settings-head">
|
||||||
<div>
|
<div class="app-settings-title">
|
||||||
<span class="eyebrow">Gitty</span>
|
<span class="app-settings-mark"><Settings2 size={18} aria-hidden="true" /></span>
|
||||||
<h2 class="dialog-title">Settings</h2>
|
<div>
|
||||||
|
<h2>{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||||
|
<p>{isGerman ? "Gitty an deinen Workflow anpassen" : "Make Gitty fit your workflow"}</p>
|
||||||
|
</div>
|
||||||
</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" />
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
<form class="app-settings-shell" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
||||||
<section class="settings-section">
|
<div class="app-settings-body">
|
||||||
<header>
|
<nav class="settings-nav" aria-label={isGerman ? "Einstellungsbereiche" : "Settings sections"}>
|
||||||
<Settings size={16} aria-hidden="true" />
|
<button type="button" class:active={activePage === "general"} onclick={() => { activePage = "general"; }}>
|
||||||
<div>
|
<SlidersHorizontal size={16} aria-hidden="true" />
|
||||||
<span class="eyebrow">Analytics</span>
|
<span>
|
||||||
<h3>Anonymous usage analytics</h3>
|
<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>
|
</div>
|
||||||
</header>
|
</nav>
|
||||||
|
|
||||||
<label class="settings-toggle-row">
|
<div class="settings-content">
|
||||||
<input type="checkbox" bind:checked={analyticsEnabled} />
|
{#if activePage === "general"}
|
||||||
<span>
|
<div class="settings-page-head">
|
||||||
<strong>Allow anonymous Aptabase events</strong>
|
<div>
|
||||||
<small>No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent.</small>
|
<h3>{isGerman ? "Allgemein" : "General"}</h3>
|
||||||
</span>
|
<p>{isGerman ? "Darstellung, Sprache und Hintergrundverhalten." : "Appearance, language, and background behavior."}</p>
|
||||||
</label>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<div class="new-branch-actions">
|
<div class="general-settings-grid">
|
||||||
<button class="btn-secondary" type="button" onclick={onClose}>Cancel</button>
|
<section class="general-setting-panel">
|
||||||
<button class="btn-primary" type="submit">
|
<header><Palette size={16} /><div><h4>{isGerman ? "Farbschema" : "Theme"}</h4><p>{isGerman ? "Passend zu deiner Umgebung." : "Match your environment."}</p></div></header>
|
||||||
<Check size={16} aria-hidden="true" />
|
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}>
|
||||||
Save
|
<label class:active={selectedTheme === "system"}><input type="radio" bind:group={selectedTheme} value="system" /><span>System</span></label>
|
||||||
</button>
|
<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>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app-settings-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr); width: min(920px, calc(100vw - 32px)); height: min(720px, calc(100vh - 32px)); overflow: hidden; }
|
||||||
|
.app-settings-head { display: flex; align-items: center; justify-content: space-between; min-height: 70px; padding: 14px 18px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||||
|
.app-settings-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
.app-settings-mark { display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, transparent); }
|
||||||
|
.app-settings-title h2, .settings-page-head h3, .tool-config-summary h4, .general-setting-panel h4 { margin: 0; color: var(--color-ink); }
|
||||||
|
.app-settings-title h2 { font-size: 18px; line-height: 1.2; }
|
||||||
|
.app-settings-title p, .settings-page-head p, .tool-config-summary p, .general-setting-panel p { margin: 0; color: var(--color-ink-dim); }
|
||||||
|
.app-settings-title p { margin-top: 3px; font-size: 11px; }
|
||||||
|
.app-settings-shell { display: grid; min-height: 0; grid-template-rows: minmax(0, 1fr) auto; }
|
||||||
|
.app-settings-body { display: grid; min-height: 0; grid-template-columns: 205px minmax(0, 1fr); }
|
||||||
|
.settings-nav { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 14px 12px; border-right: 1px solid var(--color-border-subtle); background: color-mix(in srgb, var(--app-dialog-chrome) 72%, var(--app-dialog-bg)); }
|
||||||
|
.settings-nav > button { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; min-height: 50px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
||||||
|
.settings-nav > button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||||
|
.settings-nav > button.active { border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
||||||
|
.settings-nav button > :global(svg) { color: var(--color-ink-muted); }
|
||||||
|
.settings-nav button.active > :global(svg) { color: var(--color-accent); }
|
||||||
|
.settings-nav button span { display: grid; min-width: 0; gap: 2px; }
|
||||||
|
.settings-nav button strong { font-size: 12px; }
|
||||||
|
.settings-nav button small { overflow: hidden; color: var(--color-ink-faint); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.settings-nav button em { display: grid; place-items: center; min-width: 21px; height: 20px; padding-inline: 5px; border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 12%, transparent); font-size: 9px; font-style: normal; font-weight: 800; }
|
||||||
|
.settings-nav-note { display: flex; align-items: flex-start; gap: 8px; margin-top: auto; padding: 10px; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-faint); }
|
||||||
|
.settings-nav-note :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-success); }
|
||||||
|
.settings-nav-note p { margin: 0; font-size: 9.5px; line-height: 1.45; }
|
||||||
|
.settings-content { min-width: 0; overflow: auto; padding: 18px 20px 22px; }
|
||||||
|
.settings-page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||||
|
.settings-page-head h3 { font-size: 18px; }
|
||||||
|
.settings-page-head p { margin-top: 4px; font-size: 11px; line-height: 1.45; }
|
||||||
|
.tool-rescan-button { display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; min-height: 30px; padding: 0 10px; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); font-size: 10px; font-weight: 750; }
|
||||||
|
.tool-rescan-button:hover:not(:disabled) { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
|
||||||
|
.tool-kind-tabs { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; margin-bottom: 14px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
|
||||||
|
.tool-kind-tabs button { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; min-width: 0; height: 38px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
|
||||||
|
.tool-kind-tabs button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||||
|
.tool-kind-tabs button.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-raised); box-shadow: 0 2px 8px rgba(0,0,0,.12); }
|
||||||
|
.tool-kind-tabs button.active :global(svg) { color: var(--color-accent); }
|
||||||
|
.tool-kind-tabs button small { position: absolute; top: 5px; right: 6px; width: 5px; height: 5px; border-radius: 50%; background: var(--color-ink-faint); }
|
||||||
|
.tool-kind-tabs button small.available { background: var(--color-success); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-success) 15%, transparent); }
|
||||||
|
.tool-config-panel { display: grid; gap: 14px; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 12px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
|
||||||
|
.tool-config-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; }
|
||||||
|
.tool-config-icon { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid color-mix(in srgb, var(--color-accent) 25%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 8%, transparent); }
|
||||||
|
.tool-config-summary h4 { font-size: 14px; }
|
||||||
|
.tool-config-summary p { margin-top: 3px; font-size: 10.5px; line-height: 1.4; }
|
||||||
|
.tool-status { display: inline-flex; align-items: center; gap: 5px; padding: 5px 7px; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-ink-faint); font-size: 9px; font-weight: 750; }
|
||||||
|
.tool-status.available { border-color: color-mix(in srgb, var(--color-success) 24%, var(--color-border)); color: var(--color-success); background: color-mix(in srgb, var(--color-success) 6%, transparent); }
|
||||||
|
.tool-route { display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 7px 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; color: var(--color-ink-faint); background: var(--color-surface-raised); font-size: 10.5px; }
|
||||||
|
.tool-route strong { color: var(--color-ink); }
|
||||||
|
.tool-open-mode { display: grid; gap: 6px; min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||||
|
.tool-open-mode legend { margin-bottom: 6px; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
|
||||||
|
.tool-open-mode > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
|
||||||
|
.tool-open-mode button { display: grid; justify-items: start; gap: 2px; min-width: 0; min-height: 46px; padding: 7px 10px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
||||||
|
.tool-open-mode button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||||
|
.tool-open-mode button.active { border-color: color-mix(in srgb, var(--color-accent) 38%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-hover)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
||||||
|
.tool-open-mode button span { max-width: 100%; overflow: hidden; font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.tool-open-mode button small { color: var(--color-ink-faint); font-size: 9px; font-weight: 550; }
|
||||||
|
.tool-default-field, .tool-advanced-panel label { display: grid; gap: 6px; min-width: 0; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
|
||||||
|
.tool-advanced-panel input, .tool-advanced-panel textarea { width: 100%; min-width: 0; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink); background: var(--color-surface-raised); font: inherit; }
|
||||||
|
:global(.tool-preset-select .select-menu-trigger) { height: 38px; min-height: 38px; padding: 0 11px; border-color: var(--color-border); font-size: 12px; font-weight: 700; }
|
||||||
|
.tool-default-field small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; }
|
||||||
|
.tool-usage-callout { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 10px; padding: 10px 11px; border-left: 2px solid var(--color-accent); border-radius: 0 7px 7px 0; background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
|
||||||
|
.tool-usage-callout span { color: var(--color-accent); font-size: 9.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.tool-usage-callout p { margin: 0; color: var(--color-ink-muted); font-size: 10.5px; line-height: 1.45; }
|
||||||
|
.tool-advanced-toggle { display: flex; align-items: center; justify-content: space-between; min-height: 32px; padding: 0; border: 0; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
|
||||||
|
.tool-advanced-toggle:hover { color: var(--color-ink); }
|
||||||
|
.tool-advanced-panel { display: grid; gap: 11px; padding-top: 2px; }
|
||||||
|
.tool-program-row { display: grid; grid-template-columns: minmax(0, 1fr) 34px; gap: 6px; }
|
||||||
|
.tool-advanced-panel input { height: 34px; padding: 0 9px; font-family: var(--font-mono); font-size: 10.5px; }
|
||||||
|
.tool-advanced-panel textarea { min-height: 80px; padding: 8px 9px; resize: vertical; font-family: var(--font-mono); font-size: 10.5px; line-height: 1.45; }
|
||||||
|
.tool-program-row button { display: grid; place-items: center; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); }
|
||||||
|
.tool-program-row button:hover { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
|
||||||
|
.tool-placeholders { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin: 0; color: var(--color-ink-faint); font-size: 9px; }
|
||||||
|
.tool-placeholders span { margin-right: 3px; }
|
||||||
|
.tool-placeholders code { padding: 2px 4px; border-radius: 4px; color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 9%, transparent); }
|
||||||
|
.general-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||||
|
.general-setting-panel { display: grid; align-content: start; gap: 14px; min-width: 0; padding: 14px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
|
||||||
|
.general-setting-panel.general-setting-wide { grid-column: 1 / -1; }
|
||||||
|
.general-setting-panel > header { display: flex; align-items: flex-start; gap: 9px; }
|
||||||
|
.general-setting-panel > header > :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-accent); }
|
||||||
|
.general-setting-panel h4 { font-size: 12.5px; }
|
||||||
|
.general-setting-panel p { margin-top: 3px; font-size: 9.5px; }
|
||||||
|
.settings-segmented { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 3px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
||||||
|
.settings-segmented.settings-language { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.settings-segmented label { display: flex; align-items: center; justify-content: center; min-height: 31px; border: 1px solid transparent; border-radius: 6px; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 750; }
|
||||||
|
.settings-segmented label.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-hover); }
|
||||||
|
.settings-segmented input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||||
|
.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 { FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
|
||||||
import type { GitBlameLine } from "../types";
|
import type { GitBlameLine } from "../types";
|
||||||
|
|
||||||
|
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
interface BlameGroup {
|
interface BlameGroup {
|
||||||
id: string;
|
id: string;
|
||||||
hash: string;
|
hash: string;
|
||||||
@@ -66,7 +71,7 @@
|
|||||||
if (!seconds) return "";
|
if (!seconds) return "";
|
||||||
const date = new Date(seconds * 1000);
|
const date = new Date(seconds * 1000);
|
||||||
if (Number.isNaN(date.getTime())) return "";
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
return commitDateFormatter.format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupTooltip(group: BlameGroup): string {
|
function groupTooltip(group: BlameGroup): string {
|
||||||
|
|||||||
@@ -18,16 +18,24 @@
|
|||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
|
let title = $derived(branch.remote ? "Delete remote branch?" : force ? "Force delete branch?" : "Delete branch?");
|
||||||
|
let remoteParts = $derived(branch.remote ? branch.name.split(/\/(.+)/) : []);
|
||||||
|
let branchName = $derived(branch.remote ? remoteParts[1] || branch.name : branch.name);
|
||||||
|
let branchLocation = $derived(branch.remote ? remoteParts[0] || "Remote" : "Local repository");
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation">
|
<div class="dialog-backdrop" role="presentation">
|
||||||
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="branch-delete-title">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header branch-delete-header">
|
||||||
<div>
|
<div class="branch-delete-heading">
|
||||||
<span class="eyebrow">{force ? "Force delete" : "Delete branch"}</span>
|
<span class:force class="branch-delete-heading-icon" aria-hidden="true">
|
||||||
<p class="dialog-title">{title}</p>
|
<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>
|
</div>
|
||||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||||
<X size={16} aria-hidden="true" />
|
<X size={16} aria-hidden="true" />
|
||||||
@@ -35,27 +43,35 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="branch-delete-body">
|
<div class="branch-delete-body">
|
||||||
<div class="discard-warning-icon" aria-hidden="true">
|
<div class:force class="discard-warning-icon branch-delete-warning-icon" aria-hidden="true">
|
||||||
<AlertTriangle size={22} />
|
<AlertTriangle size={22} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="discard-confirm-copy">
|
<div class="discard-confirm-copy">
|
||||||
<p>
|
<p class="branch-delete-lead">
|
||||||
{#if force}
|
{#if branch.remote}
|
||||||
This branch is not fully merged. Force deleting removes the branch pointer even if some commits are only reachable from this branch.
|
This branch will be removed from the shared remote repository.
|
||||||
|
{:else if force}
|
||||||
|
This branch is not fully merged. Some commits may only exist here.
|
||||||
{:else}
|
{:else}
|
||||||
Delete this local branch from the repository?
|
This branch will be removed from your local repository.
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
<code class="discard-target" title={branch.name}>
|
<div class="branch-delete-target" title={branch.name}>
|
||||||
<GitBranch size={13} aria-hidden="true" />
|
<span class="branch-delete-target-icon" aria-hidden="true"><GitBranch size={16} /></span>
|
||||||
{branch.name}
|
<span class="branch-delete-target-copy">
|
||||||
</code>
|
<code>{branchName}</code>
|
||||||
|
<span>{branchLocation}</span>
|
||||||
|
</span>
|
||||||
|
<span class:remote={branch.remote} class="branch-delete-scope">{branch.remote ? "Remote" : "Local"}</span>
|
||||||
|
</div>
|
||||||
<p class="discard-warning-text">
|
<p class="discard-warning-text">
|
||||||
{#if force}
|
{#if branch.remote}
|
||||||
Make sure you no longer need the unique commits on this branch.
|
This affects everyone using <strong>{remoteParts[0] || "the remote"}</strong>. Your local branch is kept.
|
||||||
|
{:else if force}
|
||||||
|
Force deletion can make unmerged commits difficult to recover.
|
||||||
{:else}
|
{:else}
|
||||||
Git will refuse if the branch is not fully merged.
|
Git will stop the deletion if the branch contains unmerged commits.
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,13 +79,13 @@
|
|||||||
|
|
||||||
<footer class="discard-confirm-actions">
|
<footer class="discard-confirm-actions">
|
||||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
|
||||||
<button class="btn-danger" type="button" onclick={onConfirm} disabled={isBusy}>
|
<button class="btn-danger branch-delete-confirm" type="button" onclick={onConfirm} disabled={isBusy}>
|
||||||
{#if isBusy}
|
{#if isBusy}
|
||||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<Trash2 size={15} aria-hidden="true" />
|
<Trash2 size={15} aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
{force ? "Force delete" : "Delete"}
|
{branch.remote ? "Delete from remote" : force ? "Force delete" : "Delete"}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||||
|
import { tick } from "svelte";
|
||||||
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
|
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
|
||||||
|
|
||||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
children: BranchTreeNode[];
|
children: BranchTreeNode[];
|
||||||
branchCount: number;
|
branchCount: number;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
|
branches: GitBranchInfo[];
|
||||||
folders: Map<string, BranchFolderNode>;
|
folders: Map<string, BranchFolderNode>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
depth: number;
|
depth: number;
|
||||||
branchCount: number;
|
branchCount: number;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
|
branches: GitBranchInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BranchLeafRow {
|
interface BranchLeafRow {
|
||||||
@@ -49,14 +52,19 @@
|
|||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onCheckout: (branch: GitBranchInfo) => void;
|
onCheckout: (branch: GitBranchInfo) => void;
|
||||||
|
onCompareBranch: (branch: GitBranchInfo) => void;
|
||||||
onMerge: (branch: GitBranchInfo) => void;
|
onMerge: (branch: GitBranchInfo) => void;
|
||||||
onRebase: (branch: GitBranchInfo) => void;
|
onRebase: (branch: GitBranchInfo) => void;
|
||||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||||
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
|
onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
|
onDeleteBranchFolder: (folderName: string, branches: GitBranchInfo[], depth: number) => void | Promise<void>;
|
||||||
onCreateTag: (name: string, message: string) => void | Promise<void>;
|
onCreateTag: (name: string, message: string) => void | Promise<void>;
|
||||||
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
||||||
onPushTag: (tag: GitTag) => void | Promise<void>;
|
onPushTag: (tag: GitTag) => void | Promise<void>;
|
||||||
|
onManageWorktrees: () => void;
|
||||||
|
onCreateWorktree: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
onToggleCollapsed?: () => void;
|
onToggleCollapsed?: () => void;
|
||||||
}
|
}
|
||||||
@@ -69,14 +77,19 @@
|
|||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
onCheckout = () => {},
|
onCheckout = () => {},
|
||||||
|
onCompareBranch = () => {},
|
||||||
onMerge = () => {},
|
onMerge = () => {},
|
||||||
onRebase = () => {},
|
onRebase = () => {},
|
||||||
onCreateBranch = () => {},
|
onCreateBranch = () => {},
|
||||||
onRenameBranch = () => {},
|
onRenameBranch = () => {},
|
||||||
onDeleteBranch = () => {},
|
onDeleteBranch = () => {},
|
||||||
|
onDeleteRemoteBranch = () => {},
|
||||||
|
onDeleteBranchFolder = () => {},
|
||||||
onCreateTag = () => {},
|
onCreateTag = () => {},
|
||||||
onDeleteTag = () => {},
|
onDeleteTag = () => {},
|
||||||
onPushTag = () => {},
|
onPushTag = () => {},
|
||||||
|
onManageWorktrees = () => {},
|
||||||
|
onCreateWorktree = () => {},
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
onToggleCollapsed = () => {},
|
onToggleCollapsed = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -91,11 +104,13 @@
|
|||||||
let newTagName = $state("");
|
let newTagName = $state("");
|
||||||
let newTagMessage = $state("");
|
let newTagMessage = $state("");
|
||||||
let tagCreateInput = $state<HTMLInputElement | null>(null);
|
let tagCreateInput = $state<HTMLInputElement | null>(null);
|
||||||
let panelElement = $state<HTMLElement | null>(null);
|
|
||||||
let contextBranch = $state<GitBranchInfo | null>(null);
|
let contextBranch = $state<GitBranchInfo | null>(null);
|
||||||
|
let contextFolder = $state<BranchFolderRow | null>(null);
|
||||||
|
let branchContextMenuElement = $state<HTMLElement | null>(null);
|
||||||
let contextMenuX = $state(0);
|
let contextMenuX = $state(0);
|
||||||
let contextMenuY = $state(0);
|
let contextMenuY = $state(0);
|
||||||
let contextTag = $state<GitTag | null>(null);
|
let contextTag = $state<GitTag | null>(null);
|
||||||
|
let tagContextMenuElement = $state<HTMLElement | null>(null);
|
||||||
let tagContextMenuX = $state(0);
|
let tagContextMenuX = $state(0);
|
||||||
let tagContextMenuY = $state(0);
|
let tagContextMenuY = $state(0);
|
||||||
let collapsedBranchFolders = $state<Set<string>>(new Set());
|
let collapsedBranchFolders = $state<Set<string>>(new Set());
|
||||||
@@ -111,6 +126,7 @@
|
|||||||
children: [],
|
children: [],
|
||||||
branchCount: 0,
|
branchCount: 0,
|
||||||
current: false,
|
current: false,
|
||||||
|
branches: [],
|
||||||
folders: new Map(),
|
folders: new Map(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -137,6 +153,7 @@
|
|||||||
|
|
||||||
folder.branchCount += 1;
|
folder.branchCount += 1;
|
||||||
folder.current ||= branch.current;
|
folder.current ||= branch.current;
|
||||||
|
folder.branches.push(branch);
|
||||||
parent = folder;
|
parent = folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +195,7 @@
|
|||||||
depth,
|
depth,
|
||||||
branchCount: node.branchCount,
|
branchCount: node.branchCount,
|
||||||
current: node.current,
|
current: node.current,
|
||||||
|
branches: node.branches,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isBranchFolderOpen(node.id)) {
|
if (isBranchFolderOpen(node.id)) {
|
||||||
@@ -235,24 +253,52 @@
|
|||||||
onCheckout(branch);
|
onCheckout(branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
|
function fitContextMenuToViewport(element: HTMLElement | null, x: number, y: number) {
|
||||||
|
const rect = element?.getBoundingClientRect();
|
||||||
|
const width = rect?.width ?? 184;
|
||||||
|
const height = rect?.height ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.max(8, Math.min(x + 2, window.innerWidth - width - 8)),
|
||||||
|
y: Math.max(8, Math.min(y + 2, window.innerHeight - height - 8)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
|
|
||||||
const rect = panelElement?.getBoundingClientRect();
|
|
||||||
const rawX = rect ? event.clientX - rect.left : event.offsetX;
|
|
||||||
const rawY = rect ? event.clientY - rect.top : event.offsetY;
|
|
||||||
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
|
|
||||||
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 190);
|
|
||||||
|
|
||||||
contextBranch = branch;
|
contextBranch = branch;
|
||||||
contextMenuX = Math.max(8, Math.min(rawX, maxX));
|
contextMenuX = event.clientX + 2;
|
||||||
contextMenuY = Math.max(8, Math.min(rawY, maxY));
|
contextMenuY = event.clientY + 2;
|
||||||
|
await tick();
|
||||||
|
if (contextBranch !== branch) return;
|
||||||
|
|
||||||
|
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY);
|
||||||
|
contextMenuX = position.x;
|
||||||
|
contextMenuY = position.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFolderContextMenu(event: MouseEvent, folder: BranchFolderRow) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (isBusy || (folder.depth === 0 && folder.branches.every((branch) => branch.remote))) return;
|
||||||
|
|
||||||
|
contextFolder = folder;
|
||||||
|
contextMenuX = event.clientX + 2;
|
||||||
|
contextMenuY = event.clientY + 2;
|
||||||
|
await tick();
|
||||||
|
if (contextFolder !== folder) return;
|
||||||
|
|
||||||
|
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY);
|
||||||
|
contextMenuX = position.x;
|
||||||
|
contextMenuY = position.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeBranchContextMenu() {
|
function closeBranchContextMenu() {
|
||||||
contextBranch = null;
|
contextBranch = null;
|
||||||
|
contextFolder = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renameContextBranch() {
|
async function renameContextBranch() {
|
||||||
@@ -262,11 +308,32 @@
|
|||||||
await onRenameBranch(branch);
|
await onRenameBranch(branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareContextBranch() {
|
||||||
|
const branch = contextBranch;
|
||||||
|
if (!branch || isBusy) return;
|
||||||
|
closeBranchContextMenu();
|
||||||
|
onCompareBranch(branch);
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteContextBranch() {
|
async function deleteContextBranch() {
|
||||||
const branch = contextBranch;
|
const branch = contextBranch;
|
||||||
if (!branch || branch.current || branch.remote || isBusy) return;
|
if (!branch || branch.current || isBusy) return;
|
||||||
closeBranchContextMenu();
|
closeBranchContextMenu();
|
||||||
await onDeleteBranch(branch);
|
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteContextFolder() {
|
||||||
|
const folder = contextFolder;
|
||||||
|
if (!folder || isBusy || (folder.depth === 0 && folder.branches.every((branch) => branch.remote))) return;
|
||||||
|
closeBranchContextMenu();
|
||||||
|
await onDeleteBranchFolder(folder.name, folder.branches, folder.depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createContextWorktree() {
|
||||||
|
const branch = contextBranch;
|
||||||
|
closeBranchContextMenu();
|
||||||
|
if (!branch) return;
|
||||||
|
await onCreateWorktree(branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkoutContextBranch() {
|
async function checkoutContextBranch() {
|
||||||
@@ -313,20 +380,20 @@
|
|||||||
tagsOpen = true;
|
tagsOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTagContextMenu(event: MouseEvent, tag: GitTag) {
|
async function openTagContextMenu(event: MouseEvent, tag: GitTag) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
|
|
||||||
const rect = panelElement?.getBoundingClientRect();
|
|
||||||
const rawX = rect ? event.clientX - rect.left : event.offsetX;
|
|
||||||
const rawY = rect ? event.clientY - rect.top : event.offsetY;
|
|
||||||
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
|
|
||||||
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 130);
|
|
||||||
|
|
||||||
contextTag = tag;
|
contextTag = tag;
|
||||||
tagContextMenuX = Math.max(8, Math.min(rawX, maxX));
|
tagContextMenuX = event.clientX + 2;
|
||||||
tagContextMenuY = Math.max(8, Math.min(rawY, maxY));
|
tagContextMenuY = event.clientY + 2;
|
||||||
|
await tick();
|
||||||
|
if (contextTag !== tag) return;
|
||||||
|
|
||||||
|
const position = fitContextMenuToViewport(tagContextMenuElement, event.clientX, event.clientY);
|
||||||
|
tagContextMenuX = position.x;
|
||||||
|
tagContextMenuY = position.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTagContextMenu() {
|
function closeTagContextMenu() {
|
||||||
@@ -359,7 +426,7 @@
|
|||||||
|
|
||||||
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
|
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
|
||||||
|
|
||||||
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
|
<section class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Branches</span>
|
<span class="eyebrow">Branches</span>
|
||||||
@@ -398,8 +465,6 @@
|
|||||||
<!-- collapsed -->
|
<!-- collapsed -->
|
||||||
{:else if !hasRepository}
|
{:else if !hasRepository}
|
||||||
<p class="blank-state">Open a repository to list branches.</p>
|
<p class="blank-state">Open a repository to list branches.</p>
|
||||||
{:else if branches.length === 0 && tags.length === 0}
|
|
||||||
<p class="blank-state">No branches returned.</p>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
||||||
{#if createOpen}
|
{#if createOpen}
|
||||||
@@ -451,6 +516,7 @@
|
|||||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => toggleBranchFolder(row.id)}
|
onclick={() => toggleBranchFolder(row.id)}
|
||||||
|
oncontextmenu={(event) => openFolderContextMenu(event, row)}
|
||||||
aria-expanded={isBranchFolderOpen(row.id)}
|
aria-expanded={isBranchFolderOpen(row.id)}
|
||||||
title={`${row.name} (${row.branchCount})`}
|
title={`${row.name} (${row.branchCount})`}
|
||||||
>
|
>
|
||||||
@@ -518,6 +584,7 @@
|
|||||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => toggleBranchFolder(row.id)}
|
onclick={() => toggleBranchFolder(row.id)}
|
||||||
|
oncontextmenu={(event) => openFolderContextMenu(event, row)}
|
||||||
aria-expanded={isBranchFolderOpen(row.id)}
|
aria-expanded={isBranchFolderOpen(row.id)}
|
||||||
title={`${row.name} (${row.branchCount})`}
|
title={`${row.name} (${row.branchCount})`}
|
||||||
>
|
>
|
||||||
@@ -636,11 +703,27 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="branch-group">
|
||||||
|
<button
|
||||||
|
class="branch-group-toggle worktree-group-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={onManageWorktrees}
|
||||||
|
disabled={isBusy}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
title="Manage repository worktrees"
|
||||||
|
>
|
||||||
|
<HardDrive size={14} aria-hidden="true" />
|
||||||
|
<span>Worktrees</span>
|
||||||
|
<span class="branch-group-count">Manage</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if contextBranch}
|
{#if contextBranch}
|
||||||
<div
|
<div
|
||||||
|
bind:this={branchContextMenuElement}
|
||||||
class="branch-context-menu"
|
class="branch-context-menu"
|
||||||
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||||
role="menu"
|
role="menu"
|
||||||
@@ -651,6 +734,10 @@
|
|||||||
<GitBranch size={14} aria-hidden="true" />
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
Checkout
|
Checkout
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={compareContextBranch} disabled={isBusy}>
|
||||||
|
<GitCompare size={14} aria-hidden="true" />
|
||||||
|
Compare with...
|
||||||
|
</button>
|
||||||
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
|
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
|
||||||
<GitMerge size={14} aria-hidden="true" />
|
<GitMerge size={14} aria-hidden="true" />
|
||||||
Merge into current
|
Merge into current
|
||||||
@@ -659,27 +746,55 @@
|
|||||||
<GitBranch size={14} aria-hidden="true" />
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
Rebase current onto this
|
Rebase current onto this
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={createContextWorktree} disabled={isBusy || contextBranch.remote || contextBranch.current}>
|
||||||
|
<HardDrive size={14} aria-hidden="true" />
|
||||||
|
Open in new worktree
|
||||||
|
</button>
|
||||||
<div class="menu-separator" role="separator"></div>
|
<div class="menu-separator" role="separator"></div>
|
||||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
|
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
|
||||||
<Pencil size={14} aria-hidden="true" />
|
<Pencil size={14} aria-hidden="true" />
|
||||||
Rename
|
{contextBranch.remote ? "Rename remote..." : "Rename"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="danger"
|
class="danger"
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onclick={deleteContextBranch}
|
onclick={deleteContextBranch}
|
||||||
disabled={isBusy || contextBranch.current || contextBranch.remote}
|
disabled={isBusy || contextBranch.current}
|
||||||
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Remote branch cannot be deleted here" : "Delete local branch"}
|
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Delete remote branch" : "Delete local branch"}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} aria-hidden="true" />
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
Delete
|
{contextBranch.remote ? "Delete remote" : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if contextFolder}
|
||||||
|
<div
|
||||||
|
bind:this={branchContextMenuElement}
|
||||||
|
class="branch-context-menu"
|
||||||
|
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label={`Actions for branch folder ${contextFolder.name}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="danger"
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onclick={deleteContextFolder}
|
||||||
|
disabled={isBusy || contextFolder.branches.every((branch) => branch.current)}
|
||||||
|
title={contextFolder.current ? "The current branch will be kept" : "Delete all branches in this folder"}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
|
Delete {contextFolder.branches.filter((branch) => !branch.current).length} branches
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if contextTag}
|
{#if contextTag}
|
||||||
<div
|
<div
|
||||||
|
bind:this={tagContextMenuElement}
|
||||||
class="branch-context-menu"
|
class="branch-context-menu"
|
||||||
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
|
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
|
||||||
role="menu"
|
role="menu"
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
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 {
|
interface Props {
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
error: string;
|
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;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { isBusy = false, error = "", language = "en", integrations, onClone = () => {}, onClose = () => {} }: Props = $props();
|
||||||
isBusy = false,
|
let source = $state<CloneSource>("url");
|
||||||
error = "",
|
|
||||||
onClone = () => {},
|
|
||||||
onClose = () => {},
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let remoteUrl = $state("");
|
let remoteUrl = $state("");
|
||||||
let parentPath = $state("");
|
let parentPath = $state("");
|
||||||
let directoryName = $state("");
|
let directoryName = $state("");
|
||||||
@@ -25,29 +27,164 @@
|
|||||||
let browseError = $state("");
|
let browseError = $state("");
|
||||||
let visibleError = $state("");
|
let visibleError = $state("");
|
||||||
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
|
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));
|
const isGerman = $derived(language === "de");
|
||||||
let canSubmit = $derived(
|
const configuredSources = $derived(configuredIntegrationSources(integrations));
|
||||||
!isBusy &&
|
const activeSource = $derived(configuredSources.find((candidate) => candidate.id === selectedSourceId));
|
||||||
remoteUrl.trim().length > 0 &&
|
const activeRepositories = $derived(activeSource ? repositoriesBySource[activeSource.id] ?? [] : []);
|
||||||
parentPath.trim().length > 0,
|
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(() => {
|
$effect(() => {
|
||||||
const nextError = error || browseError;
|
const nextError = error || browseError;
|
||||||
if (errorHideTimer) clearTimeout(errorHideTimer);
|
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||||
visibleError = nextError;
|
visibleError = nextError;
|
||||||
if (nextError) {
|
if (nextError) errorHideTimer = setTimeout(() => { visibleError = ""; }, 6000);
|
||||||
errorHideTimer = setTimeout(() => {
|
});
|
||||||
visibleError = "";
|
|
||||||
}, 6000);
|
$effect(() => {
|
||||||
}
|
filteredRepositories.length;
|
||||||
|
loadingSourceId;
|
||||||
|
repositoryError;
|
||||||
|
scheduleRepositoryScrollbarUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.addEventListener("resize", scheduleRepositoryScrollbarUpdate);
|
||||||
|
return () => window.removeEventListener("resize", scheduleRepositoryScrollbarUpdate);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (errorHideTimer) clearTimeout(errorHideTimer);
|
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 {
|
function directoryNameFromRemoteUrl(url: string): string {
|
||||||
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
|
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
|
||||||
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
|
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
|
||||||
@@ -64,112 +201,368 @@
|
|||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
browseError = "";
|
browseError = "";
|
||||||
try {
|
try {
|
||||||
const selected = await openDialog({
|
const selected = await openDialog({ title: isGerman ? "Zielordner zum Klonen auswählen" : "Select clone destination", directory: true, multiple: false, defaultPath: parentPath.trim() || undefined });
|
||||||
title: "Select clone destination",
|
if (typeof selected === "string") parentPath = selected;
|
||||||
directory: true,
|
} catch (error) { browseError = errorToMessage(error); }
|
||||||
multiple: false,
|
|
||||||
defaultPath: parentPath.trim() || undefined,
|
|
||||||
});
|
|
||||||
if (typeof selected !== "string") return;
|
|
||||||
parentPath = selected;
|
|
||||||
} catch (error) {
|
|
||||||
browseError = errorToMessage(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemoteInput(event: Event) {
|
function setRemoteUrl(nextRemoteUrl: string) {
|
||||||
const nextRemoteUrl = (event.currentTarget as HTMLInputElement).value;
|
remoteUrl = nextRemoteUrl;
|
||||||
if (directoryNameEdited) return;
|
if (directoryNameEdited) return;
|
||||||
directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl);
|
directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl);
|
||||||
directoryName = directoryAutoName;
|
directoryName = directoryAutoName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRemoteInput(event: Event) {
|
||||||
|
setRemoteUrl((event.currentTarget as HTMLInputElement).value);
|
||||||
|
selectedRepositoryId = "";
|
||||||
|
}
|
||||||
|
|
||||||
function handleDirectoryInput(event: Event) {
|
function handleDirectoryInput(event: Event) {
|
||||||
const nextDirectoryName = (event.currentTarget as HTMLInputElement).value;
|
const nextDirectoryName = (event.currentTarget as HTMLInputElement).value;
|
||||||
directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName;
|
directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName;
|
||||||
}
|
}
|
||||||
|
|
||||||
function submit(event: SubmitEvent) {
|
function selectRepository(repository: GitIntegrationRepository) {
|
||||||
event.preventDefault();
|
selectedRepositoryId = repository.id;
|
||||||
if (!canSubmit) return;
|
setRemoteUrl(repository.cloneUrl);
|
||||||
onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation">
|
<div class="dialog-backdrop" role="presentation">
|
||||||
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label="Clone repository" tabindex="-1">
|
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Repository klonen" : "Clone repository"} tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header clone-dialog-header">
|
||||||
<div>
|
<div><h2>{isGerman ? "Repository klonen" : "Clone a Repository"}</h2></div>
|
||||||
<span class="eyebrow">Repository Management</span>
|
<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>
|
||||||
<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>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="clone-dialog-form" onsubmit={submit}>
|
<form class="clone-dialog-form" onsubmit={submit}>
|
||||||
<label class="clone-dialog-field">
|
<div class="clone-dialog-layout">
|
||||||
<span>Remote URL</span>
|
<aside class="clone-source-nav" aria-label={isGerman ? "Repository-Quellen" : "Repository sources"}>
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
<div class="clone-source-heading">{isGerman ? "Quelle" : "Source"}</div>
|
||||||
<input
|
<div class="clone-source-list" role="tablist" aria-label={isGerman ? "Repository-Quelle" : "Repository source"}>
|
||||||
bind:value={remoteUrl}
|
<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>
|
||||||
oninput={handleRemoteInput}
|
{#each configuredSources as integrationSource}
|
||||||
autocomplete="off"
|
<button type="button" role="tab" aria-selected={source === "integrations" && selectedSourceId === integrationSource.id} class:active={source === "integrations" && selectedSourceId === integrationSource.id} onclick={() => selectIntegrationSource(integrationSource)}>
|
||||||
spellcheck="false"
|
{#if integrationSource.provider === "azure-devops"}<Cloud size={15} />{:else}<GitBranch size={15} />{/if}
|
||||||
placeholder="https://github.com/org/project.git"
|
<span>{integrationSource.provider === "azure-devops" ? `Azure · ${integrationSource.label}` : integrationSource.label}</span>
|
||||||
disabled={isBusy}
|
</button>
|
||||||
autofocus
|
{/each}
|
||||||
/>
|
</div>
|
||||||
</label>
|
{#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">
|
<section class="clone-dialog-content">
|
||||||
<span>Destination</span>
|
<div class="clone-dialog-title">
|
||||||
<div class="clone-dialog-path-field">
|
<span>{source === "integrations" ? (activeSource?.label ?? "Integration") : "URL"}</span>
|
||||||
<input
|
<h3>{isGerman ? "Repository klonen" : "Clone a Repo"}</h3>
|
||||||
bind:value={parentPath}
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<label class="clone-dialog-field">
|
<div class="clone-target-grid">
|
||||||
<span>Folder name</span>
|
<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>
|
||||||
<input
|
<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>
|
||||||
bind:value={directoryName}
|
</div>
|
||||||
oninput={handleDirectoryInput}
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
placeholder={directorySuggestion || "Optional"}
|
|
||||||
disabled={isBusy}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{#if visibleError}
|
<section class="clone-options" aria-label={isGerman ? "Clone-Optionen" : "Clone options"}>
|
||||||
<div class="clone-dialog-error" role="alert">{visibleError}</div>
|
<div class="clone-option-card" class:expanded={shallowClone}>
|
||||||
{/if}
|
<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">
|
{#if source === "url"}
|
||||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
<label class="clone-dialog-field clone-url-field">
|
||||||
Cancel
|
<span>{isGerman ? "Repository-URL" : "Repository URL"}</span>
|
||||||
</button>
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<button class="btn-primary" type="submit" disabled={!canSubmit}>
|
<input value={remoteUrl} oninput={handleRemoteInput} autocomplete="off" spellcheck="false" placeholder="https://gitlab.com/org/project.git" disabled={isBusy} autofocus />
|
||||||
{#if isBusy}
|
</label>
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
Clone
|
</section>
|
||||||
</button>
|
{/if}
|
||||||
|
|
||||||
|
{#if visibleError}<div class="clone-dialog-error" role="alert">{visibleError}</div>{/if}
|
||||||
|
</section>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<script lang="ts">
|
||||||
import { Check, LoaderCircle, RotateCcw, Settings, Sparkles } from "@lucide/svelte";
|
import { Check, GitCommitHorizontal, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
|
||||||
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
commitMessage: string;
|
commitMessage: string;
|
||||||
@@ -10,14 +9,16 @@
|
|||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
operation: string;
|
operation: string;
|
||||||
stagedCount: number;
|
stagedCount: number;
|
||||||
commitAiProvider: CommitAiProvider;
|
|
||||||
commitAiPhase: CommitAiPhase;
|
|
||||||
commitAiGenerating: boolean;
|
commitAiGenerating: boolean;
|
||||||
|
commitAiReviewing: boolean;
|
||||||
|
commitAiSplitting: boolean;
|
||||||
canAmend: boolean;
|
canAmend: boolean;
|
||||||
amendMode: boolean;
|
amendMode: boolean;
|
||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
onCommitMessageChange: (msg: string) => void;
|
onCommitMessageChange: (msg: string) => void;
|
||||||
onGenerateCommitMessage: () => void;
|
onGenerateCommitMessage: () => void;
|
||||||
|
onReviewStaged: () => void;
|
||||||
|
onSplitStaged: () => void;
|
||||||
onOpenAiSettings: () => void;
|
onOpenAiSettings: () => void;
|
||||||
onToggleAmend: (checked: boolean) => void;
|
onToggleAmend: (checked: boolean) => void;
|
||||||
onUndoLastCommit: () => void;
|
onUndoLastCommit: () => void;
|
||||||
@@ -31,14 +32,16 @@
|
|||||||
isBusy = false,
|
isBusy = false,
|
||||||
operation = "",
|
operation = "",
|
||||||
stagedCount = 0,
|
stagedCount = 0,
|
||||||
commitAiProvider = "local",
|
|
||||||
commitAiPhase = "idle",
|
|
||||||
commitAiGenerating = false,
|
commitAiGenerating = false,
|
||||||
|
commitAiReviewing = false,
|
||||||
|
commitAiSplitting = false,
|
||||||
canAmend = false,
|
canAmend = false,
|
||||||
amendMode = false,
|
amendMode = false,
|
||||||
onCommit = () => {},
|
onCommit = () => {},
|
||||||
onCommitMessageChange = () => {},
|
onCommitMessageChange = () => {},
|
||||||
onGenerateCommitMessage = () => {},
|
onGenerateCommitMessage = () => {},
|
||||||
|
onReviewStaged = () => {},
|
||||||
|
onSplitStaged = () => {},
|
||||||
onOpenAiSettings = () => {},
|
onOpenAiSettings = () => {},
|
||||||
onToggleAmend = () => {},
|
onToggleAmend = () => {},
|
||||||
onUndoLastCommit = () => {},
|
onUndoLastCommit = () => {},
|
||||||
@@ -49,37 +52,71 @@
|
|||||||
onCommit();
|
onCommit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function aiButtonTitle(provider: CommitAiProvider, phase: CommitAiPhase, staged: number): string {
|
function aiButtonTitle(staged: number): string {
|
||||||
if (staged === 0) return "Stage changes first";
|
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";
|
return "Generate commit message with AI from the staged diff";
|
||||||
}
|
}
|
||||||
|
|
||||||
let localModelLoading = $derived(commitAiProvider === "local" && commitAiPhase === "loading");
|
|
||||||
let canGenerate = $derived(
|
let canGenerate = $derived(
|
||||||
hasRepository &&
|
hasRepository &&
|
||||||
!isBusy &&
|
!isBusy &&
|
||||||
!commitAiGenerating &&
|
!commitAiGenerating &&
|
||||||
stagedCount > 0 &&
|
!commitAiReviewing &&
|
||||||
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
stagedCount > 0,
|
||||||
);
|
);
|
||||||
|
let canReview = $derived(canGenerate);
|
||||||
|
let canSplit = $derived(canReview && stagedCount > 1 && !commitAiSplitting);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel commit-panel" aria-label="Commit">
|
<section class="panel commit-panel" aria-label="Commit">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Commit</span>
|
<span class="eyebrow">Create revision</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Message</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commit</h2>
|
||||||
|
</div>
|
||||||
|
<div 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>
|
</div>
|
||||||
<span class="pill pill-count">{stagedCount} staged</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="commit-form" onsubmit={handleSubmit}>
|
<form class="commit-form" onsubmit={handleSubmit}>
|
||||||
<textarea
|
<textarea
|
||||||
value={commitMessage}
|
value={commitMessage}
|
||||||
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
|
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
|
||||||
placeholder="Commit message..."
|
placeholder="Commit message"
|
||||||
disabled={!hasRepository || isBusy}
|
disabled={!hasRepository || isBusy}
|
||||||
></textarea>
|
></textarea>
|
||||||
{#if commitBlockReason}
|
{#if commitBlockReason}
|
||||||
@@ -117,30 +154,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{amendMode ? "Amend" : "Commit"}
|
{amendMode ? "Amend" : "Commit"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
class="btn-secondary commit-ai-button"
|
|
||||||
type="button"
|
|
||||||
onclick={onGenerateCommitMessage}
|
|
||||||
disabled={!canGenerate}
|
|
||||||
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
|
|
||||||
>
|
|
||||||
{#if commitAiGenerating || localModelLoading}
|
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
|
||||||
{:else}
|
|
||||||
<Sparkles size={16} aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
AI
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-secondary commit-ai-settings-button"
|
|
||||||
type="button"
|
|
||||||
onclick={onOpenAiSettings}
|
|
||||||
disabled={isBusy}
|
|
||||||
title="AI settings"
|
|
||||||
aria-label="AI settings"
|
|
||||||
>
|
|
||||||
<Settings size={16} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowRight, FileCode, RotateCcw, X } from "@lucide/svelte";
|
import { ArrowRight, FileCode, GitCompare, RotateCcw, X } from "@lucide/svelte";
|
||||||
import type { GitCommitComparison, GitDiffFile, FileStatusKind } from "../types";
|
import type { GitCommitComparison, GitDiffFile, FileStatusKind } from "../types";
|
||||||
|
|
||||||
type SplitRow =
|
type SplitRow =
|
||||||
@@ -10,13 +10,22 @@
|
|||||||
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
|
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface DiffMarker {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
kind: "add" | "delete" | "mixed";
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
comparison: GitCommitComparison;
|
comparison: GitCommitComparison;
|
||||||
selectedDiffPath: string;
|
selectedDiffPath: string;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
fromLabel?: string;
|
||||||
|
toLabel?: string;
|
||||||
restoreLabel?: string;
|
restoreLabel?: string;
|
||||||
/** When opened from a search hit, the term to highlight on matching lines. */
|
/** When opened from a search hit, the term to highlight on matching lines. */
|
||||||
highlightQuery?: string;
|
highlightQuery?: string;
|
||||||
|
language?: "en" | "de";
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRestore?: () => void;
|
onRestore?: () => void;
|
||||||
onSelectFile: (file: GitDiffFile) => void;
|
onSelectFile: (file: GitDiffFile) => void;
|
||||||
@@ -26,13 +35,18 @@
|
|||||||
comparison,
|
comparison,
|
||||||
selectedDiffPath = "",
|
selectedDiffPath = "",
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
fromLabel = "",
|
||||||
|
toLabel = "",
|
||||||
restoreLabel = "",
|
restoreLabel = "",
|
||||||
highlightQuery = "",
|
highlightQuery = "",
|
||||||
|
language = "en",
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
onRestore = undefined,
|
onRestore = undefined,
|
||||||
onSelectFile = () => {},
|
onSelectFile = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let isGerman = $derived(language === "de");
|
||||||
|
|
||||||
// Needle = first non-empty line of the search query, lowercased for matching.
|
// Needle = first non-empty line of the search query, lowercased for matching.
|
||||||
let highlightNeedle = $derived(
|
let highlightNeedle = $derived(
|
||||||
highlightQuery
|
highlightQuery
|
||||||
@@ -173,26 +187,59 @@
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDiffMarkers(rows: SplitRow[]): DiffMarker[] {
|
||||||
|
const markers: DiffMarker[] = [];
|
||||||
|
let current: DiffMarker | null = null;
|
||||||
|
for (let index = 0; index < rows.length; index++) {
|
||||||
|
const row = rows[index];
|
||||||
|
if (row.type !== "pair" || (row.leftKind === "context" && row.rightKind === "context")) {
|
||||||
|
current = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const kind = row.leftKind === "del" && row.rightKind === "add"
|
||||||
|
? "mixed"
|
||||||
|
: row.rightKind === "add" ? "add" : "delete";
|
||||||
|
if (current && current.end === index - 1 && current.kind === kind) {
|
||||||
|
current.end = index;
|
||||||
|
} else {
|
||||||
|
current = { start: index, end: index, kind };
|
||||||
|
markers.push(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToDiffMarker(rowIndex: number) {
|
||||||
|
const ratio = rowIndex / Math.max(splitRows.length - 1, 1);
|
||||||
|
for (const pane of [beforePane, afterPane]) {
|
||||||
|
if (pane) pane.scrollTop = ratio * Math.max(pane.scrollHeight - pane.clientHeight, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let diffByPath = $derived(buildDiffByPath(comparison.patch));
|
let diffByPath = $derived(buildDiffByPath(comparison.patch));
|
||||||
let selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
|
let selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
|
||||||
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
|
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
|
||||||
let splitRows = $derived(buildSplitRows(selectedPatch));
|
let splitRows = $derived(buildSplitRows(selectedPatch));
|
||||||
|
let diffMarkers = $derived(buildDiffMarkers(splitRows));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop compare-dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
|
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Branch or commit comparison" tabindex="-1">
|
||||||
|
|
||||||
<header class="dialog-header">
|
<header class="compare-dialog-head">
|
||||||
<div>
|
<div class="compare-dialog-title">
|
||||||
<span class="eyebrow">Compare</span>
|
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
|
||||||
<h2 class="dialog-range">
|
<div>
|
||||||
<span class="hash">{comparison.from_short}</span>
|
<h2>{isGerman ? "Änderungen vergleichen" : "Compare changes"}</h2>
|
||||||
<ArrowRight size={14} aria-hidden="true" />
|
<p class="dialog-range">
|
||||||
<span class="hash">{comparison.to_short}</span>
|
<span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
|
||||||
</h2>
|
<ArrowRight size={14} aria-hidden="true" />
|
||||||
|
<span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-header-actions">
|
<div class="dialog-header-actions">
|
||||||
{#if restoreLabel && onRestore}
|
{#if restoreLabel && onRestore}
|
||||||
@@ -201,19 +248,23 @@
|
|||||||
<span>{restoreLabel}</span>
|
<span>{restoreLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Vergleich schließen" : "Close comparison"}>
|
||||||
<X size={18} aria-hidden="true" />
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if comparison.files.length === 0}
|
{#if comparison.files.length === 0}
|
||||||
<div class="blank-state">No differences — these versions are identical.</div>
|
<div class="blank-state">{isGerman ? "Keine Unterschiede - diese Versionen sind identisch." : "No differences - these versions are identical."}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
|
|
||||||
<!-- File list -->
|
<!-- File list -->
|
||||||
<aside class="dialog-files" aria-label="Changed files">
|
<aside class="dialog-files" aria-label={isGerman ? "Geänderte Dateien" : "Changed files"}>
|
||||||
|
<div class="compare-files-head">
|
||||||
|
<span>{isGerman ? "Geänderte Dateien" : "Changed files"}</span>
|
||||||
|
<strong>{comparison.files.length}</strong>
|
||||||
|
</div>
|
||||||
{#each comparison.files as file (`${file.old_path ?? ""}:${file.path}`)}
|
{#each comparison.files as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||||
<button
|
<button
|
||||||
class="dialog-file-row"
|
class="dialog-file-row"
|
||||||
@@ -235,9 +286,9 @@
|
|||||||
<!-- Diff pane -->
|
<!-- Diff pane -->
|
||||||
<div class="dialog-diff">
|
<div class="dialog-diff">
|
||||||
{#if !selectedFile}
|
{#if !selectedFile}
|
||||||
<div class="blank-state">Select a file to see its changes.</div>
|
<div class="blank-state">{isGerman ? "Wähle eine Datei aus, um ihre Änderungen zu sehen." : "Select a file to see its changes."}</div>
|
||||||
{:else if splitRows.length === 0}
|
{:else if splitRows.length === 0}
|
||||||
<div class="blank-state">No textual changes for this file.</div>
|
<div class="blank-state">{isGerman ? "Keine textuellen Änderungen für diese Datei." : "No textual changes for this file."}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Path bar -->
|
<!-- Path bar -->
|
||||||
<div class="diff-header">
|
<div class="diff-header">
|
||||||
@@ -252,16 +303,17 @@
|
|||||||
<!-- Column headers -->
|
<!-- Column headers -->
|
||||||
<div class="split-col-headers">
|
<div class="split-col-headers">
|
||||||
<div class="split-col-label">
|
<div class="split-col-label">
|
||||||
<span>Before</span>
|
<span>{isGerman ? "Vorher" : "Before"}</span>
|
||||||
<span class="split-col-hash">{comparison.from_short}</span>
|
<span class="split-col-hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="split-col-label">
|
<div class="split-col-label">
|
||||||
<span>After</span>
|
<span>{isGerman ? "Nachher" : "After"}</span>
|
||||||
<span class="split-col-hash">{comparison.to_short}</span>
|
<span class="split-col-hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Split diff grid -->
|
<!-- Split diff grid -->
|
||||||
|
<div class="split-diff-shell">
|
||||||
<div class="split-diff" role="table" aria-label="Side-by-side diff">
|
<div class="split-diff" role="table" aria-label="Side-by-side diff">
|
||||||
<div
|
<div
|
||||||
class="split-pane"
|
class="split-pane"
|
||||||
@@ -298,6 +350,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="diff-overview" aria-label="Change overview">
|
||||||
|
{#each diffMarkers as marker, index (`${marker.start}-${marker.end}-${marker.kind}`)}
|
||||||
|
<button
|
||||||
|
class="diff-overview-marker {marker.kind}"
|
||||||
|
type="button"
|
||||||
|
style={`--marker-position: ${(marker.start / Math.max(splitRows.length - 1, 1)) * 100}%`}
|
||||||
|
onclick={() => scrollToDiffMarker(marker.start)}
|
||||||
|
title={`Jump to change ${index + 1} of ${diffMarkers.length}`}
|
||||||
|
aria-label={`Jump to change ${index + 1} of ${diffMarkers.length}`}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowRight, GitCompare, LoaderCircle } from "@lucide/svelte";
|
import { ArrowRight, GitCompare, LoaderCircle } from "@lucide/svelte";
|
||||||
import type { GitCommit, GitCommitComparison } from "../types";
|
import type { GitCommit, GitCommitComparison } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
@@ -40,6 +41,8 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onCompare();
|
onCompare();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let commitOptions = $derived(commits.map((item) => ({ value: item.hash, label: commitOptionLabel(item), group: "Commits" })));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="compare-panel panel" aria-label="Compare commits">
|
<section class="compare-panel panel" aria-label="Compare commits">
|
||||||
@@ -61,32 +64,14 @@
|
|||||||
<form class="compare-form" onsubmit={handleSubmit}>
|
<form class="compare-form" onsubmit={handleSubmit}>
|
||||||
<label class="compare-field">
|
<label class="compare-field">
|
||||||
<span>From (older)</span>
|
<span>From (older)</span>
|
||||||
<select
|
<SelectMenu value={compareFrom} options={commitOptions} placeholder="Select a commit" showSelectedGroup disabled={isBusy} onChange={onCompareFromChange} />
|
||||||
value={compareFrom}
|
|
||||||
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
<option value="" disabled>Select a commit</option>
|
|
||||||
{#each commits as item (item.hash)}
|
|
||||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
|
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
|
||||||
|
|
||||||
<label class="compare-field">
|
<label class="compare-field">
|
||||||
<span>To (newer)</span>
|
<span>To (newer)</span>
|
||||||
<select
|
<SelectMenu value={compareTo} options={commitOptions} placeholder="Select a commit" showSelectedGroup disabled={isBusy} onChange={onCompareToChange} />
|
||||||
value={compareTo}
|
|
||||||
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
<option value="" disabled>Select a commit</option>
|
|
||||||
{#each commits as item (item.hash)}
|
|
||||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
|
import { ArrowRight, GitCompare, Info, LoaderCircle, X } from "@lucide/svelte";
|
||||||
import type { GitCommit } from "../types";
|
import type { GitBranch, GitCommit } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
|
branches: GitBranch[];
|
||||||
compareFrom: string;
|
compareFrom: string;
|
||||||
compareTo: string;
|
compareTo: string;
|
||||||
canCompare: boolean;
|
canCompare: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
operation: string;
|
operation: string;
|
||||||
|
language?: "en" | "de";
|
||||||
onCompareFromChange: (val: string) => void;
|
onCompareFromChange: (val: string) => void;
|
||||||
onCompareToChange: (val: string) => void;
|
onCompareToChange: (val: string) => void;
|
||||||
onCompare: () => void;
|
onCompare: () => void;
|
||||||
@@ -17,21 +20,50 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
commits = [],
|
commits = [],
|
||||||
|
branches = [],
|
||||||
compareFrom = "",
|
compareFrom = "",
|
||||||
compareTo = "",
|
compareTo = "",
|
||||||
canCompare = false,
|
canCompare = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
operation = "",
|
operation = "",
|
||||||
|
language = "en",
|
||||||
onCompareFromChange = () => {},
|
onCompareFromChange = () => {},
|
||||||
onCompareToChange = () => {},
|
onCompareToChange = () => {},
|
||||||
onCompare = () => {},
|
onCompare = () => {},
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let isGerman = $derived(language === "de");
|
||||||
|
|
||||||
function commitOptionLabel(item: GitCommit): string {
|
function commitOptionLabel(item: GitCommit): string {
|
||||||
return `${item.short_hash} - ${item.summary}`;
|
return `${item.short_hash} - ${item.summary}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function branchValue(branch: GitBranch): string {
|
||||||
|
return branch.remote ? `refs/remotes/${branch.name}` : `refs/heads/${branch.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let localBranches = $derived(branches.filter((branch) => !branch.remote));
|
||||||
|
let remoteBranches = $derived(branches.filter((branch) => branch.remote));
|
||||||
|
let targetCount = $derived(branches.length + commits.length);
|
||||||
|
let compareOptions = $derived([
|
||||||
|
...localBranches.map((branch) => ({
|
||||||
|
value: branchValue(branch),
|
||||||
|
label: `${branch.name}${branch.current ? (isGerman ? " (aktuell)" : " (current)") : ""}`,
|
||||||
|
group: isGerman ? "Lokale Branches" : "Local branches",
|
||||||
|
})),
|
||||||
|
...remoteBranches.map((branch) => ({
|
||||||
|
value: branchValue(branch),
|
||||||
|
label: branch.name,
|
||||||
|
group: isGerman ? "Remote-Branches" : "Remote branches",
|
||||||
|
})),
|
||||||
|
...commits.map((item) => ({
|
||||||
|
value: item.hash,
|
||||||
|
label: commitOptionLabel(item),
|
||||||
|
group: "Commits",
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
function handleSubmit(event: SubmitEvent) {
|
function handleSubmit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onCompare();
|
onCompare();
|
||||||
@@ -42,66 +74,66 @@
|
|||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
|
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"} tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="compare-dialog-head">
|
||||||
<div>
|
<div class="compare-dialog-title">
|
||||||
<span class="eyebrow">Compare</span>
|
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Select commits</h2>
|
<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>
|
</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" />
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if commits.length < 2}
|
<form class="compare-select-shell" onsubmit={handleSubmit}>
|
||||||
<div class="blank-state">At least two commits are needed to compare.</div>
|
<div class="compare-select-body">
|
||||||
{:else}
|
{#if targetCount < 2}
|
||||||
<form class="compare-form" onsubmit={handleSubmit}>
|
<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>
|
||||||
<label class="compare-field">
|
{:else}
|
||||||
<span>From (older)</span>
|
<section class="compare-target-panel">
|
||||||
<select
|
<div class="compare-target-heading">
|
||||||
value={compareFrom}
|
<h3>{isGerman ? "Vergleichsbereich" : "Comparison range"}</h3>
|
||||||
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
|
<p>{isGerman ? "Wähle Ausgangspunkt und Ziel des Vergleichs." : "Choose the starting point and target for the comparison."}</p>
|
||||||
disabled={isBusy}
|
</div>
|
||||||
>
|
<div class="compare-form">
|
||||||
<option value="" disabled>Select a commit</option>
|
<label class="compare-field">
|
||||||
{#each commits as item (item.hash)}
|
<span>{isGerman ? "Ausgangsbasis" : "Base"}</span>
|
||||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
<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} />
|
||||||
{/each}
|
</label>
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
|
<span class="compare-arrow-shell"><ArrowRight size={18} aria-hidden="true" /></span>
|
||||||
|
|
||||||
<label class="compare-field">
|
<label class="compare-field">
|
||||||
<span>To (newer)</span>
|
<span>{isGerman ? "Vergleichen mit" : "Compare with"}</span>
|
||||||
<select
|
<SelectMenu class="compare-target-select" value={compareTo} options={compareOptions} placeholder={isGerman ? "Branch oder Commit wählen" : "Select a branch or commit"} showSelectedGroup disabled={isBusy} onChange={onCompareToChange} />
|
||||||
value={compareTo}
|
</label>
|
||||||
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
|
</div>
|
||||||
disabled={isBusy}
|
</section>
|
||||||
>
|
|
||||||
<option value="" disabled>Select a commit</option>
|
|
||||||
{#each commits as item (item.hash)}
|
|
||||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
{#if compareFrom && compareTo && compareFrom === compareTo}
|
||||||
{#if operation === "Comparing commits"}
|
<div class="compare-target-help compare-target-warning"><Info size={15} aria-hidden="true" /><span>{isGerman ? "Wähle zwei unterschiedliche Branches oder Commits." : "Select two different branches or commits to compare."}</span></div>
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
Compare
|
{/if}
|
||||||
</button>
|
</div>
|
||||||
</form>
|
<footer class="compare-select-footer">
|
||||||
|
<span>{isGerman ? `${targetCount} Ziele verfügbar` : `${targetCount} targets available`}</span>
|
||||||
{#if compareFrom && compareTo && compareFrom === compareTo}
|
<div>
|
||||||
<div class="blank-state">Select two different commits to compare.</div>
|
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
||||||
{:else}
|
<button class="btn-primary" type="submit" disabled={!canCompare || targetCount < 2}>
|
||||||
<div class="blank-state">Pick two commits and run a comparison.</div>
|
{#if operation === "Comparing revisions"}
|
||||||
{/if}
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
{/if}
|
{:else}
|
||||||
|
<GitCompare size={16} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
{isGerman ? "Vergleich öffnen" : "Open comparison"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Download,
|
Download,
|
||||||
@@ -14,10 +15,12 @@
|
|||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: "push" | "pull" | "fetch" | "clone";
|
action: "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
|
||||||
error: string;
|
error: string;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
|
initialUsername?: string;
|
||||||
|
initialMode?: Mode;
|
||||||
|
onSubmit: (username: string, password: string, save: boolean, mode: Mode) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,35 +28,40 @@
|
|||||||
action,
|
action,
|
||||||
error = "",
|
error = "",
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
initialUsername = "",
|
||||||
|
initialMode = "credentials",
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
type Mode = "credentials" | "token";
|
type Mode = "credentials" | "token";
|
||||||
|
|
||||||
let mode = $state<Mode>("credentials");
|
let mode = $state<Mode>(untrack(() => initialMode));
|
||||||
let username = $state("");
|
let username = $state(untrack(() => initialUsername === "oauth2" ? "" : initialUsername));
|
||||||
let password = $state("");
|
let password = $state("");
|
||||||
let showPassword = $state(false);
|
let showPassword = $state(false);
|
||||||
let saveSession = $state(true);
|
let saveSession = $state(true);
|
||||||
let expiresAt = $state("");
|
|
||||||
|
|
||||||
let canSubmit = $derived(
|
let canSubmit = $derived(
|
||||||
!isBusy &&
|
!isBusy &&
|
||||||
password.trim().length > 0 &&
|
password.trim().length > 0 &&
|
||||||
(mode === "token" || username.trim().length > 0),
|
username.trim().length > 0,
|
||||||
);
|
);
|
||||||
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
|
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : action === "delete" ? "Delete" : "Pull");
|
||||||
let actionTitle = $derived(
|
let actionTitle = $derived(
|
||||||
action === "push"
|
action === "push"
|
||||||
? "Authenticate push"
|
? "Authenticate push"
|
||||||
: action === "fetch"
|
: action === "rename"
|
||||||
? "Authenticate fetch"
|
? "Authenticate remote rename"
|
||||||
: action === "clone"
|
: action === "delete"
|
||||||
? "Authenticate clone"
|
? "Authenticate remote deletion"
|
||||||
: "Authenticate pull",
|
: 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."
|
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
|
||||||
: action === "clone"
|
: action === "clone"
|
||||||
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
|
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
|
||||||
@@ -62,12 +70,7 @@
|
|||||||
function handleSubmit(e: SubmitEvent) {
|
function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
onSubmit(
|
onSubmit(username.trim(), password, saveSession, mode);
|
||||||
mode === "token" ? "oauth2" : username,
|
|
||||||
password,
|
|
||||||
saveSession,
|
|
||||||
saveSession && expiresAt ? expiresAt : null,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -79,7 +82,7 @@
|
|||||||
<div class="cred-hero">
|
<div class="cred-hero">
|
||||||
<div class="cred-hero-top">
|
<div class="cred-hero-top">
|
||||||
<div class="cred-hero-icon">
|
<div class="cred-hero-icon">
|
||||||
{#if action === "push"}
|
{#if action === "push" || action === "rename" || action === "delete"}
|
||||||
<Upload size={27} aria-hidden="true" />
|
<Upload size={27} aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<Download size={27} aria-hidden="true" />
|
<Download size={27} aria-hidden="true" />
|
||||||
@@ -98,7 +101,7 @@
|
|||||||
|
|
||||||
<div class="cred-security-note">
|
<div class="cred-security-note">
|
||||||
<ShieldCheck size={14} aria-hidden="true" />
|
<ShieldCheck size={14} aria-hidden="true" />
|
||||||
<span>When saved, the token is stored encrypted in the operating system's keychain — never in plain text.</span>
|
<span>When saved, the credentials are stored encrypted in the operating system's keychain — never in plain text.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,27 +125,25 @@
|
|||||||
aria-pressed={mode === "token"}
|
aria-pressed={mode === "token"}
|
||||||
>
|
>
|
||||||
<Key size={13} aria-hidden="true" />
|
<Key size={13} aria-hidden="true" />
|
||||||
Token
|
Access token
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cred-fields">
|
<div class="cred-fields">
|
||||||
{#if mode === "credentials"}
|
<div class="cred-field">
|
||||||
<div class="cred-field">
|
<label class="cred-field-label" for="cred-username">Username</label>
|
||||||
<label class="cred-field-label" for="cred-username">Username</label>
|
<div class="cred-input">
|
||||||
<div class="cred-input">
|
<User size={15} class="cred-field-icon" aria-hidden="true" />
|
||||||
<User size={15} class="cred-field-icon" aria-hidden="true" />
|
<input
|
||||||
<input
|
id="cred-username"
|
||||||
id="cred-username"
|
type="text"
|
||||||
type="text"
|
bind:value={username}
|
||||||
bind:value={username}
|
placeholder="Your account username"
|
||||||
placeholder="e.g. my-github-username"
|
autocomplete="username"
|
||||||
autocomplete="username"
|
disabled={isBusy}
|
||||||
disabled={isBusy}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
|
|
||||||
<div class="cred-field">
|
<div class="cred-field">
|
||||||
<label class="cred-field-label" for="cred-password">
|
<label class="cred-field-label" for="cred-password">
|
||||||
@@ -180,7 +181,7 @@
|
|||||||
{#if mode === "token"}
|
{#if mode === "token"}
|
||||||
<div class="cred-token-hint">
|
<div class="cred-token-hint">
|
||||||
<Key size={13} aria-hidden="true" />
|
<Key size={13} aria-hidden="true" />
|
||||||
<span>Username is automatically set to <code>oauth2</code>. This works with GitHub, GitLab, and Bitbucket.</span>
|
<span>Use your normal account username. The access token is sent as the password, as required by Gitea and most Git providers.</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -191,19 +192,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if saveSession}
|
|
||||||
<div class="cred-expiry">
|
|
||||||
<label class="cred-field-label" for="cred-expiry">Expiration date (optional)</label>
|
|
||||||
<input
|
|
||||||
id="cred-expiry"
|
|
||||||
type="date"
|
|
||||||
bind:value={expiresAt}
|
|
||||||
disabled={isBusy}
|
|
||||||
/>
|
|
||||||
<span class="cred-expiry-hint">After this date you'll automatically be asked to log in again.</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="cred-footer">
|
<div class="cred-footer">
|
||||||
<label class="cred-save">
|
<label class="cred-save">
|
||||||
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
|
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
import type { GitFileStatus } from "../types";
|
import type { GitFileStatus } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
file: GitFileStatus;
|
files: GitFileStatus[];
|
||||||
staged: boolean;
|
staged: boolean | null;
|
||||||
scope: "file" | "hunk";
|
scope: "file" | "hunk" | "lines";
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onConfirm: () => void | Promise<void>;
|
onConfirm: () => void | Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
file,
|
files,
|
||||||
staged = false,
|
staged = false,
|
||||||
scope = "file",
|
scope = "file",
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
@@ -20,10 +20,16 @@
|
|||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let targetPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
function targetPath(file: GitFileStatus): string {
|
||||||
let title = $derived(scope === "hunk" ? "Discard hunk?" : "Discard file changes?");
|
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
||||||
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
}
|
||||||
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
|
||||||
|
let count = $derived(files.length);
|
||||||
|
let title = $derived(
|
||||||
|
scope === "hunk" ? "Discard hunk?" : scope === "lines" ? "Discard selected lines?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
|
||||||
|
);
|
||||||
|
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : scope === "lines" ? "selected lines" : count > 1 ? `${count} files` : "file");
|
||||||
|
let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -46,11 +52,22 @@
|
|||||||
|
|
||||||
<div class="discard-confirm-copy">
|
<div class="discard-confirm-copy">
|
||||||
<p>
|
<p>
|
||||||
This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below.
|
This will reset the {sourceLabel} for the {scopeLabel} below.
|
||||||
</p>
|
</p>
|
||||||
<code class="discard-target" title={targetPath}>{targetPath}</code>
|
{#if count > 1}
|
||||||
|
<ul class="discard-target-list">
|
||||||
|
{#each files.slice(0, 8) as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||||
|
<li><code class="discard-target" title={targetPath(file)}>{targetPath(file)}</code></li>
|
||||||
|
{/each}
|
||||||
|
{#if files.length > 8}
|
||||||
|
<li class="discard-target-more">+{files.length - 8} more</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{:else if count === 1}
|
||||||
|
<code class="discard-target" title={targetPath(files[0])}>{targetPath(files[0])}</code>
|
||||||
|
{/if}
|
||||||
<p class="discard-warning-text">
|
<p class="discard-warning-text">
|
||||||
This cannot be undone. If the file only exists in your working tree, it can be deleted entirely.
|
This cannot be undone. If a file only exists in your working tree, it can be deleted entirely.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,18 +11,24 @@
|
|||||||
FileCog,
|
FileCog,
|
||||||
FileImage,
|
FileImage,
|
||||||
FileJson,
|
FileJson,
|
||||||
|
FileMinus2,
|
||||||
|
FileSearch,
|
||||||
|
GitCompare,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
FileText,
|
FileText,
|
||||||
FileType,
|
FileType,
|
||||||
FileVideo,
|
FileVideo,
|
||||||
|
FileX,
|
||||||
Folder,
|
Folder,
|
||||||
|
FolderMinus,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
FolderX,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
History,
|
History,
|
||||||
Terminal,
|
Terminal,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
import { languageIconForPath } from "../languageIcons";
|
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";
|
import LanguageIcon from "./LanguageIcon.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -32,12 +38,20 @@
|
|||||||
selectedExplorerKind: ExplorerNodeKind;
|
selectedExplorerKind: ExplorerNodeKind;
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
language?: "en" | "de";
|
||||||
|
editorName?: string;
|
||||||
|
diffName?: string;
|
||||||
onToggleFolder: (node: ExplorerNode) => void;
|
onToggleFolder: (node: ExplorerNode) => void;
|
||||||
onExpandAllFolders: () => void;
|
onExpandAllFolders: () => void;
|
||||||
onCollapseAllFolders: () => void;
|
onCollapseAllFolders: () => void;
|
||||||
onSelectNode: (node: ExplorerNode) => void;
|
onSelectNode: (node: ExplorerNode) => void;
|
||||||
onOpenFile: (node: ExplorerNode) => void;
|
onOpenFile: (node: ExplorerNode) => void;
|
||||||
|
onOpenInEditor: (node: ExplorerNode) => void;
|
||||||
|
onExternalDiff: (node: ExplorerNode) => void;
|
||||||
|
onFileHistory: (node: ExplorerNode) => void;
|
||||||
onBlame: (node: ExplorerNode) => void;
|
onBlame: (node: ExplorerNode) => void;
|
||||||
|
onIgnore: (target: string, kind: GitIgnoreKind) => void;
|
||||||
|
onStopTracking: (target: string, kind: "file" | "folder") => void;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
onToggleCollapsed?: () => void;
|
onToggleCollapsed?: () => void;
|
||||||
}
|
}
|
||||||
@@ -49,12 +63,20 @@
|
|||||||
selectedExplorerKind = "file",
|
selectedExplorerKind = "file",
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
language = "en",
|
||||||
|
editorName = "Editor",
|
||||||
|
diffName = "diff tool",
|
||||||
onToggleFolder = () => {},
|
onToggleFolder = () => {},
|
||||||
onExpandAllFolders = () => {},
|
onExpandAllFolders = () => {},
|
||||||
onCollapseAllFolders = () => {},
|
onCollapseAllFolders = () => {},
|
||||||
onSelectNode = () => {},
|
onSelectNode = () => {},
|
||||||
onOpenFile = () => {},
|
onOpenFile = () => {},
|
||||||
|
onOpenInEditor = () => {},
|
||||||
|
onExternalDiff = () => {},
|
||||||
|
onFileHistory = () => {},
|
||||||
onBlame = () => {},
|
onBlame = () => {},
|
||||||
|
onIgnore = () => {},
|
||||||
|
onStopTracking = () => {},
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
onToggleCollapsed = () => {},
|
onToggleCollapsed = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -62,6 +84,8 @@
|
|||||||
let contextNode = $state<ExplorerNode | null>(null);
|
let contextNode = $state<ExplorerNode | null>(null);
|
||||||
let contextMenuX = $state(0);
|
let contextMenuX = $state(0);
|
||||||
let contextMenuY = $state(0);
|
let contextMenuY = $state(0);
|
||||||
|
let contextMenuElement = $state<HTMLDivElement | null>(null);
|
||||||
|
const isGerman = $derived(language === "de");
|
||||||
|
|
||||||
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
|
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
|
||||||
if (!next) return current;
|
if (!next) return current;
|
||||||
@@ -168,14 +192,19 @@
|
|||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFileContextMenu(event: MouseEvent, node: ExplorerNode) {
|
function openNodeContextMenu(event: MouseEvent, node: ExplorerNode) {
|
||||||
if (node.kind !== "file") return;
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
contextNode = node;
|
contextNode = node;
|
||||||
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
|
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 248));
|
||||||
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 56));
|
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() {
|
function closeFileContextMenu() {
|
||||||
@@ -189,6 +218,20 @@
|
|||||||
onOpenFile(node);
|
onOpenFile(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openContextFileInEditor() {
|
||||||
|
const node = contextNode;
|
||||||
|
if (!node || node.kind !== "file") return;
|
||||||
|
closeFileContextMenu();
|
||||||
|
onOpenInEditor(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openContextExternalDiff() {
|
||||||
|
const node = contextNode;
|
||||||
|
if (!node || node.kind !== "file") return;
|
||||||
|
closeFileContextMenu();
|
||||||
|
onExternalDiff(node);
|
||||||
|
}
|
||||||
|
|
||||||
function openContextBlame() {
|
function openContextBlame() {
|
||||||
const node = contextNode;
|
const node = contextNode;
|
||||||
if (!node || node.kind !== "file") return;
|
if (!node || node.kind !== "file") return;
|
||||||
@@ -196,6 +239,38 @@
|
|||||||
onBlame(node);
|
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) {
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape") closeFileContextMenu();
|
if (event.key === "Escape") closeFileContextMenu();
|
||||||
}
|
}
|
||||||
@@ -203,6 +278,15 @@
|
|||||||
let explorerTree = $derived(buildExplorerTree(repoFiles));
|
let explorerTree = $derived(buildExplorerTree(repoFiles));
|
||||||
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
|
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
|
||||||
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
|
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
|
||||||
|
let 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>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
|
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
|
||||||
@@ -214,6 +298,35 @@
|
|||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="explorer-head-actions">
|
<div class="explorer-head-actions">
|
||||||
|
<button
|
||||||
|
class="explorer-bulk-button explorer-tool-action"
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectedFileNode && onOpenInEditor(selectedFileNode)}
|
||||||
|
disabled={isBusy || !selectedFileNode || selectedFileNode.status === "deleted"}
|
||||||
|
title={isGerman
|
||||||
|
? `Ausgewählte Datei in ${editorName} öffnen`
|
||||||
|
: `Open selected file in ${editorName}`}
|
||||||
|
aria-label={isGerman
|
||||||
|
? `Ausgewählte Datei in ${editorName} öffnen`
|
||||||
|
: `Open selected file in ${editorName}`}
|
||||||
|
>
|
||||||
|
<FileCode size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="explorer-bulk-button explorer-tool-action"
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectedFileNode && onExternalDiff(selectedFileNode)}
|
||||||
|
disabled={isBusy || !selectedFileNode || !selectedFileNode.tracked || selectedFileNode.status === "deleted"}
|
||||||
|
title={isGerman
|
||||||
|
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
|
||||||
|
: `Compare selected file with HEAD in ${diffName}`}
|
||||||
|
aria-label={isGerman
|
||||||
|
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
|
||||||
|
: `Compare selected file with HEAD in ${diffName}`}
|
||||||
|
>
|
||||||
|
<GitCompare size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span class="explorer-action-divider" aria-hidden="true"></span>
|
||||||
<button
|
<button
|
||||||
class="explorer-bulk-button"
|
class="explorer-bulk-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -261,12 +374,14 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="explorer-list overflow-auto p-2">
|
<div class="explorer-list overflow-auto p-2">
|
||||||
{#each visibleNodes as node (`${node.kind}:${node.path}`)}
|
{#each visibleNodes as node (`${node.kind}:${node.path}`)}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="explorer-row"
|
class="explorer-row"
|
||||||
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
|
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
|
||||||
class:folder={node.kind === "folder"}
|
class:folder={node.kind === "folder"}
|
||||||
style={`--depth: ${node.depth}`}
|
style={`--depth: ${node.depth}`}
|
||||||
title={node.path}
|
title={node.path}
|
||||||
|
oncontextmenu={(event) => openNodeContextMenu(event, node)}
|
||||||
>
|
>
|
||||||
{#if node.kind === "folder"}
|
{#if node.kind === "folder"}
|
||||||
<button
|
<button
|
||||||
@@ -328,9 +443,8 @@
|
|||||||
class="explorer-select"
|
class="explorer-select"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => onSelectNode(node)}
|
onclick={() => onSelectNode(node)}
|
||||||
oncontextmenu={(event) => openFileContextMenu(event, node)}
|
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
title={`Show history for ${node.path}`}
|
title={`Select ${node.path}`}
|
||||||
>
|
>
|
||||||
<span>{node.name}</span>
|
<span>{node.name}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -349,31 +463,60 @@
|
|||||||
|
|
||||||
{#if contextNode}
|
{#if contextNode}
|
||||||
<div
|
<div
|
||||||
|
bind:this={contextMenuElement}
|
||||||
class="explorer-context-menu"
|
class="explorer-context-menu"
|
||||||
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||||
role="menu"
|
role="menu"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-label={`Actions for ${contextNode.path}`}
|
aria-label={`Actions for ${contextNode.path}`}
|
||||||
>
|
>
|
||||||
<button
|
{#if contextNode.kind === "file"}
|
||||||
type="button"
|
<button type="button" role="menuitem" onclick={openContextFileInEditor} disabled={contextNode.status === "deleted"}>
|
||||||
role="menuitem"
|
<FileCode size={14} aria-hidden="true" />
|
||||||
onclick={openContextFile}
|
{isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`}
|
||||||
disabled={contextNode.status === "deleted"}
|
</button>
|
||||||
title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}
|
<button type="button" role="menuitem" onclick={openContextExternalDiff} disabled={!contextNode.tracked || contextNode.status === "deleted"}>
|
||||||
>
|
<GitCompare size={14} aria-hidden="true" />
|
||||||
<ExternalLink size={14} aria-hidden="true" />
|
{isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`}
|
||||||
Open in Explorer
|
</button>
|
||||||
</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"}>
|
||||||
<button
|
<History size={14} aria-hidden="true" />
|
||||||
type="button"
|
{isGerman ? "Dateiverlauf" : "File history"}
|
||||||
role="menuitem"
|
</button>
|
||||||
onclick={openContextBlame}
|
<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"}>
|
||||||
disabled={!contextNode.tracked || contextNode.status === "deleted"}
|
<ExternalLink size={14} aria-hidden="true" />
|
||||||
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
|
{isGerman ? "Im Explorer öffnen" : "Open in Explorer"}
|
||||||
>
|
</button>
|
||||||
<History size={14} aria-hidden="true" />
|
<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"}>
|
||||||
Blame
|
<FileSearch size={14} aria-hidden="true" />
|
||||||
</button>
|
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>
|
</div>
|
||||||
{/if}
|
{/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">
|
<script lang="ts">
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
import {
|
import {
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
FileCode,
|
FileCode,
|
||||||
@@ -15,6 +16,11 @@
|
|||||||
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
|
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
|
||||||
import LanguageIcon from "./LanguageIcon.svelte";
|
import LanguageIcon from "./LanguageIcon.svelte";
|
||||||
|
|
||||||
|
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
type SearchTab = "code" | "files";
|
type SearchTab = "code" | "files";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -82,7 +88,7 @@
|
|||||||
function formatCommitDate(value: string): string {
|
function formatCommitDate(value: string): string {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
return commitDateFormatter.format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayPath(hit: GitSearchHit): string {
|
function displayPath(hit: GitSearchHit): string {
|
||||||
@@ -188,12 +194,7 @@
|
|||||||
|
|
||||||
<label class="search-limit">
|
<label class="search-limit">
|
||||||
<span>Results</span>
|
<span>Results</span>
|
||||||
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}>
|
<SelectMenu value={String(limit)} options={[100, 250, 500, 1000].map((value) => ({ value: String(value), label: String(value) }))} disabled={!hasRepository || isBusy || isSearching} onChange={(value) => { limit = Number(value); }} />
|
||||||
<option value={100}>100</option>
|
|
||||||
<option value={250}>250</option>
|
|
||||||
<option value={500}>500</option>
|
|
||||||
<option value={1000}>1000</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
|
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
|
import { Cherry, ChevronDown, ChevronRight, CloudOff, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, X } from "@lucide/svelte";
|
||||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||||
|
|
||||||
interface GraphSegment {
|
interface GraphSegment {
|
||||||
@@ -22,52 +22,129 @@
|
|||||||
graphCommit: GitCommit;
|
graphCommit: GitCommit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BranchVisibilityMode = "focus" | "local" | "all" | "custom";
|
||||||
|
type CommitRefKind = "local" | "remote" | "head";
|
||||||
|
|
||||||
|
interface CommitBranchDecoration {
|
||||||
|
label: string;
|
||||||
|
kind: CommitRefKind;
|
||||||
|
current: boolean;
|
||||||
|
trackedRemote: string;
|
||||||
|
localOnly: boolean;
|
||||||
|
representedBranches: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommitRefSummary {
|
||||||
|
branches: CommitBranchDecoration[];
|
||||||
|
tags: string[];
|
||||||
|
other: string[];
|
||||||
|
primaryBranch: CommitBranchDecoration | null;
|
||||||
|
primaryTag: string;
|
||||||
|
overflowCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
const GRAPH_COLORS = [
|
const GRAPH_COLORS = [
|
||||||
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
|
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
|
||||||
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
||||||
];
|
];
|
||||||
const GRAPH_LANE = 18;
|
const GRAPH_LANE = 18;
|
||||||
|
const GRAPH_REF_ARM = 16;
|
||||||
|
const GRAPH_VISIBILITY_STORAGE_PREFIX = "gitlite.graphVisibility.v1:";
|
||||||
|
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
localBranchNames: string[];
|
localBranchNames: string[];
|
||||||
|
localBranchUpstreams: Record<string, string>;
|
||||||
|
remoteBranchNames: string[];
|
||||||
activeBranch: string;
|
activeBranch: string;
|
||||||
activeUpstream: string;
|
activeUpstream: string;
|
||||||
|
activeAhead: number;
|
||||||
|
activeBehind: number;
|
||||||
repositoryKey: string;
|
repositoryKey: string;
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
isLoadingMore: boolean;
|
||||||
|
loadMoreError: string;
|
||||||
expandedCommitHashes: Set<string>;
|
expandedCommitHashes: Set<string>;
|
||||||
|
selectedCommitHash: string;
|
||||||
|
onLoadMore: () => void | Promise<void>;
|
||||||
onRestoreCommit: (commit: GitCommit) => void;
|
onRestoreCommit: (commit: GitCommit) => void;
|
||||||
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||||
onToggleCommitFiles: (hash: string) => void;
|
onToggleCommitFiles: (hash: string) => void;
|
||||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||||
onCherryPickCommit: (commit: GitCommit) => void;
|
onCherryPickCommit: (commit: GitCommit) => void;
|
||||||
|
onRevertCommit: (commit: GitCommit) => void;
|
||||||
|
onOpenCommitNote: (commit: GitCommit) => void;
|
||||||
|
onLoadCommitNote: (commit: GitCommit) => Promise<string | null>;
|
||||||
|
onSelectCommit: (commit: GitCommit) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
commits = [],
|
commits = [],
|
||||||
localBranchNames = [],
|
localBranchNames = [],
|
||||||
|
localBranchUpstreams = {},
|
||||||
|
remoteBranchNames = [],
|
||||||
activeBranch = "",
|
activeBranch = "",
|
||||||
activeUpstream = "",
|
activeUpstream = "",
|
||||||
|
activeAhead = 0,
|
||||||
|
activeBehind = 0,
|
||||||
repositoryKey = "",
|
repositoryKey = "",
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
hasMore = false,
|
||||||
|
isLoadingMore = false,
|
||||||
|
loadMoreError = "",
|
||||||
expandedCommitHashes = new Set(),
|
expandedCommitHashes = new Set(),
|
||||||
|
selectedCommitHash = "",
|
||||||
|
onLoadMore = () => {},
|
||||||
onRestoreCommit = () => {},
|
onRestoreCommit = () => {},
|
||||||
onPreviewCommitFile = () => {},
|
onPreviewCommitFile = () => {},
|
||||||
onToggleCommitFiles = () => {},
|
onToggleCommitFiles = () => {},
|
||||||
onCreateBranchFromCommit = () => {},
|
onCreateBranchFromCommit = () => {},
|
||||||
onCherryPickCommit = () => {},
|
onCherryPickCommit = () => {},
|
||||||
|
onRevertCommit = () => {},
|
||||||
|
onOpenCommitNote = () => {},
|
||||||
|
onLoadCommitNote = async () => null,
|
||||||
|
onSelectCommit = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
let branchVisibilityMode = $state<BranchVisibilityMode>("focus");
|
||||||
|
let customVisibleBranches = $state<Set<string>>(new Set());
|
||||||
|
let loadedVisibilityRepository = $state("");
|
||||||
let branchDialogOpen = $state(false);
|
let branchDialogOpen = $state(false);
|
||||||
let userAdjustedBranchFilter = $state(false);
|
let localBranchGroupOpen = $state(true);
|
||||||
let lastDefaultFilterKey = $state("");
|
let remoteBranchGroupOpen = $state(false);
|
||||||
|
let expandedRefsCommitHash = $state("");
|
||||||
let panelElement = $state<HTMLElement | null>(null);
|
let panelElement = $state<HTMLElement | null>(null);
|
||||||
let contextCommit = $state<GitCommit | null>(null);
|
let contextCommit = $state<GitCommit | null>(null);
|
||||||
let contextMenuX = $state(0);
|
let contextMenuX = $state(0);
|
||||||
let contextMenuY = $state(0);
|
let contextMenuY = $state(0);
|
||||||
|
let notePreviews = $state<Record<string, string>>({});
|
||||||
|
let notePreviewLoading = $state<Set<string>>(new Set());
|
||||||
|
let notePreviewErrors = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
repositoryKey;
|
||||||
|
notePreviews = {};
|
||||||
|
notePreviewLoading = new Set();
|
||||||
|
notePreviewErrors = new Set();
|
||||||
|
});
|
||||||
|
|
||||||
|
function observeHistoryEnd(node: HTMLElement) {
|
||||||
|
const root = node.closest<HTMLElement>(".history-list");
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting) && hasMore && !isLoadingMore && !loadMoreError && !isBusy) {
|
||||||
|
void onLoadMore();
|
||||||
|
}
|
||||||
|
}, { root, rootMargin: "240px 0px" });
|
||||||
|
observer.observe(node);
|
||||||
|
return { destroy: () => observer.disconnect() };
|
||||||
|
}
|
||||||
|
|
||||||
function laneColor(col: number): string {
|
function laneColor(col: number): string {
|
||||||
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
|
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
|
||||||
@@ -115,15 +192,18 @@
|
|||||||
|
|
||||||
const currentBranches = branchMembership.get(commit.hash) ?? localBranchRefs(commit);
|
const currentBranches = branchMembership.get(commit.hash) ?? localBranchRefs(commit);
|
||||||
const commitBranches = uniqueStrings([...(beforeBranches[col] ?? []), ...currentBranches]);
|
const commitBranches = uniqueStrings([...(beforeBranches[col] ?? []), ...currentBranches]);
|
||||||
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
|
const firstParent = commit.parents[0] ?? null;
|
||||||
afterBranches[col] = after[col] ? commitBranches.slice() : [];
|
after[col] = firstParent;
|
||||||
|
afterBranches[col] = firstParent
|
||||||
|
? (branchMembership.get(firstParent) ?? commitBranches).slice()
|
||||||
|
: [];
|
||||||
|
|
||||||
const fromCommit = new Set<number>([col]);
|
const fromCommit = new Set<number>([col]);
|
||||||
for (let p = 1; p < commit.parents.length; p++) {
|
for (let p = 1; p < commit.parents.length; p++) {
|
||||||
let slot = after.indexOf(null);
|
let slot = after.indexOf(null);
|
||||||
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
|
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
|
||||||
after[slot] = commit.parents[p];
|
after[slot] = commit.parents[p];
|
||||||
afterBranches[slot] = [];
|
afterBranches[slot] = (branchMembership.get(commit.parents[p]) ?? commitBranches).slice();
|
||||||
fromCommit.add(slot);
|
fromCommit.add(slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,39 +278,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let localBranchNameSet = $derived(new Set(localBranchNames));
|
let localBranchNameSet = $derived(new Set(localBranchNames));
|
||||||
|
let remoteBranchNameSet = $derived(new Set(remoteBranchNames));
|
||||||
|
|
||||||
function uniqueStrings(values: string[]): string[] {
|
function uniqueStrings(values: string[]): string[] {
|
||||||
return [...new Set(values.filter(Boolean))];
|
return [...new Set(values.filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, activeUpstream].filter(Boolean)));
|
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, ...remoteBranchNames, activeUpstream].filter(Boolean)));
|
||||||
let graphBranchNameSet = $derived(new Set(graphBranchNames));
|
let graphBranchNameSet = $derived(new Set(graphBranchNames));
|
||||||
|
|
||||||
|
function focusBranchNames(): string[] {
|
||||||
|
const focused = uniqueStrings([activeBranch, activeUpstream].filter((branch) => graphBranchNameSet.has(branch)));
|
||||||
|
if (focused.length > 0) return focused;
|
||||||
|
return localBranchNames[0] ? [localBranchNames[0]] : graphBranchNames.slice(0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchNamesForMode(): string[] {
|
||||||
|
if (branchVisibilityMode === "focus") return focusBranchNames();
|
||||||
|
if (branchVisibilityMode === "local") return localBranchNames;
|
||||||
|
if (branchVisibilityMode === "all") return graphBranchNames;
|
||||||
|
return graphBranchNames.filter((branch) => customVisibleBranches.has(branch));
|
||||||
|
}
|
||||||
|
|
||||||
|
let visibleGraphBranchNames = $derived(branchNamesForMode());
|
||||||
|
let visibleGraphBranchNameSet = $derived(new Set(visibleGraphBranchNames));
|
||||||
|
|
||||||
function branchIsVisible(branch: string): boolean {
|
function branchIsVisible(branch: string): boolean {
|
||||||
if (branch === activeUpstream && activeUpstream) {
|
return visibleGraphBranchNameSet.has(branch);
|
||||||
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
|
|
||||||
}
|
|
||||||
return !hiddenGraphBranches.has(branch);
|
|
||||||
}
|
|
||||||
|
|
||||||
function visibleBranchLabels(labels: string[]): string[] {
|
|
||||||
return labels.filter(branchIsVisible);
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
|
|
||||||
const directBranches = graphBranchRefs(commit);
|
|
||||||
if (directBranches.length > 0) return directBranches;
|
|
||||||
|
|
||||||
const containingBranches = row?.branchLabels ?? [];
|
|
||||||
if (containingBranches.length <= 3) return containingBranches;
|
|
||||||
return [...containingBranches.slice(0, 3), `+${containingBranches.length - 3} more`];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
||||||
const directBranches = graphBranchRefs(commit);
|
const directBranches = graphBranchRefs(commit).filter(branchIsVisible);
|
||||||
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
||||||
|
|
||||||
const containingBranches = row?.branchLabels ?? [];
|
const containingBranches = (row?.branchLabels ?? []).filter(branchIsVisible);
|
||||||
if (containingBranches.length === 0) return commit.short_hash;
|
if (containingBranches.length === 0) return commit.short_hash;
|
||||||
return `Branches containing this commit: ${containingBranches.join(", ")}`;
|
return `Branches containing this commit: ${containingBranches.join(", ")}`;
|
||||||
}
|
}
|
||||||
@@ -250,7 +331,7 @@
|
|||||||
|
|
||||||
function branchesAreVisible(branches: string[]): boolean {
|
function branchesAreVisible(branches: string[]): boolean {
|
||||||
if (graphBranchNames.length === 0) return true;
|
if (graphBranchNames.length === 0) return true;
|
||||||
return branches.some(branchIsVisible);
|
return branches.some((branch) => visibleGraphBranchNameSet.has(branch));
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
|
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
|
||||||
@@ -327,23 +408,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleGraphBranch(branch: string) {
|
function toggleGraphBranch(branch: string) {
|
||||||
const next = new Set(hiddenGraphBranches);
|
const next = branchVisibilityMode === "custom"
|
||||||
|
? new Set(customVisibleBranches)
|
||||||
|
: new Set(visibleGraphBranchNames);
|
||||||
if (next.has(branch)) next.delete(branch); else next.add(branch);
|
if (next.has(branch)) next.delete(branch); else next.add(branch);
|
||||||
hiddenGraphBranches = next;
|
customVisibleBranches = next;
|
||||||
userAdjustedBranchFilter = true;
|
branchVisibilityMode = "custom";
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAllGraphBranches() {
|
function showAllGraphBranches() {
|
||||||
hiddenGraphBranches = new Set();
|
branchVisibilityMode = "all";
|
||||||
userAdjustedBranchFilter = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideAllGraphBranches() {
|
function hideAllGraphBranches() {
|
||||||
hiddenGraphBranches = new Set(localBranchNames);
|
customVisibleBranches = new Set();
|
||||||
userAdjustedBranchFilter = true;
|
branchVisibilityMode = "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFocusGraphBranches() {
|
||||||
|
branchVisibilityMode = "focus";
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBranchDialog() {
|
function openBranchDialog() {
|
||||||
|
localBranchGroupOpen = true;
|
||||||
|
remoteBranchGroupOpen = false;
|
||||||
branchDialogOpen = true;
|
branchDialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +445,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCommitRefs(commit: GitCommit) {
|
||||||
|
expandedRefsCommitHash = expandedRefsCommitHash === commit.hash ? "" : commit.hash;
|
||||||
|
}
|
||||||
|
|
||||||
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
|
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -406,9 +498,59 @@
|
|||||||
await onCherryPickCommit(commit);
|
await onCherryPickCommit(commit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function revertContextCommit() {
|
||||||
|
const commit = contextCommit;
|
||||||
|
if (!commit || isBusy) return;
|
||||||
|
closeCommitContextMenu();
|
||||||
|
await onRevertCommit(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openContextCommitNote() {
|
||||||
|
const commit = contextCommit;
|
||||||
|
if (!commit || isBusy) return;
|
||||||
|
closeCommitContextMenu();
|
||||||
|
onSelectCommit(commit);
|
||||||
|
await onOpenCommitNote(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCommitNote(commit: GitCommit) {
|
||||||
|
if (isBusy) return;
|
||||||
|
onSelectCommit(commit);
|
||||||
|
await onOpenCommitNote(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCommitNotePreview(commit: GitCommit) {
|
||||||
|
if (!commit.has_note || notePreviewLoading.has(commit.hash)) return;
|
||||||
|
|
||||||
|
const loading = new Set(notePreviewLoading);
|
||||||
|
loading.add(commit.hash);
|
||||||
|
notePreviewLoading = loading;
|
||||||
|
|
||||||
|
const errors = new Set(notePreviewErrors);
|
||||||
|
errors.delete(commit.hash);
|
||||||
|
notePreviewErrors = errors;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const note = await onLoadCommitNote(commit);
|
||||||
|
notePreviews = {
|
||||||
|
...notePreviews,
|
||||||
|
[commit.hash]: note?.trim() || "This Git note is empty.",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
const nextErrors = new Set(notePreviewErrors);
|
||||||
|
nextErrors.add(commit.hash);
|
||||||
|
notePreviewErrors = nextErrors;
|
||||||
|
} finally {
|
||||||
|
const nextLoading = new Set(notePreviewLoading);
|
||||||
|
nextLoading.delete(commit.hash);
|
||||||
|
notePreviewLoading = nextLoading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleWindowKeydown(event: KeyboardEvent) {
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
if (event.key !== "Escape") return;
|
if (event.key !== "Escape") return;
|
||||||
closeCommitContextMenu();
|
closeCommitContextMenu();
|
||||||
|
expandedRefsCommitHash = "";
|
||||||
handleBranchDialogKeydown(event);
|
handleBranchDialogKeydown(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,66 +578,217 @@
|
|||||||
return labels;
|
return labels;
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleRefs(commit: GitCommit): string[] {
|
|
||||||
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function refClass(ref: string): string {
|
|
||||||
if (ref.startsWith("HEAD")) return "head";
|
|
||||||
if (ref.startsWith("tag:")) return "tag";
|
|
||||||
if (localBranchNameSet.has(refLabel(ref))) return "branch";
|
|
||||||
if (ref.includes("/")) return "remote";
|
|
||||||
return "branch";
|
|
||||||
}
|
|
||||||
|
|
||||||
function refLabel(ref: string): string {
|
function refLabel(ref: string): string {
|
||||||
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
|
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSymbolicRemoteHead(ref: string): boolean {
|
||||||
|
if (ref.startsWith("HEAD ->") || ref.startsWith("tag:")) return false;
|
||||||
|
const label = refLabel(ref);
|
||||||
|
return !localBranchNameSet.has(label) && /(?:^|\/)HEAD(?:\s*->|$)/.test(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredUpstreamForBranch(local: string): string {
|
||||||
|
return localBranchUpstreams[local] ?? (local === activeBranch ? activeUpstream : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchingRemoteBranch(local: string, remoteBranches: string[]): string {
|
||||||
|
const configuredUpstream = configuredUpstreamForBranch(local);
|
||||||
|
return configuredUpstream && remoteBranches.includes(configuredUpstream) ? configuredUpstream : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareBranchDecorations(left: CommitBranchDecoration, right: CommitBranchDecoration): number {
|
||||||
|
const priority = (item: CommitBranchDecoration) => {
|
||||||
|
if (item.label === activeBranch) return 0;
|
||||||
|
if (item.kind === "head") return 1;
|
||||||
|
if (item.kind === "local") return 2;
|
||||||
|
if (item.label === activeUpstream) return 3;
|
||||||
|
return 4;
|
||||||
|
};
|
||||||
|
return priority(left) - priority(right) || left.label.localeCompare(right.label, undefined, { numeric: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitRefSummary(commit: GitCommit): CommitRefSummary {
|
||||||
|
const local = new Set<string>();
|
||||||
|
const remote = new Set<string>();
|
||||||
|
const tags = new Set<string>();
|
||||||
|
const other = new Set<string>();
|
||||||
|
let detachedHead = false;
|
||||||
|
|
||||||
|
for (const ref of commit.refs) {
|
||||||
|
if (isSymbolicRemoteHead(ref)) continue;
|
||||||
|
const label = refLabel(ref);
|
||||||
|
if (!label) continue;
|
||||||
|
if (ref.startsWith("tag:")) {
|
||||||
|
tags.add(label);
|
||||||
|
} else if (localBranchNameSet.has(label)) {
|
||||||
|
local.add(label);
|
||||||
|
} else if (remoteBranchNameSet.has(label) || label === activeUpstream) {
|
||||||
|
remote.add(label);
|
||||||
|
} else if (label === "HEAD") {
|
||||||
|
detachedHead = true;
|
||||||
|
} else {
|
||||||
|
other.add(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingRemote = [...remote];
|
||||||
|
const branches: CommitBranchDecoration[] = [...local].map((label) => {
|
||||||
|
const configuredUpstream = configuredUpstreamForBranch(label);
|
||||||
|
const trackedRemote = matchingRemoteBranch(label, remainingRemote);
|
||||||
|
if (trackedRemote) remainingRemote.splice(remainingRemote.indexOf(trackedRemote), 1);
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
kind: "local",
|
||||||
|
current: label === activeBranch,
|
||||||
|
trackedRemote,
|
||||||
|
localOnly: !configuredUpstream,
|
||||||
|
representedBranches: trackedRemote ? [label, trackedRemote] : [label],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detachedHead) {
|
||||||
|
branches.push({
|
||||||
|
label: "HEAD",
|
||||||
|
kind: "head",
|
||||||
|
current: true,
|
||||||
|
trackedRemote: "",
|
||||||
|
localOnly: false,
|
||||||
|
representedBranches: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
branches.push(...remainingRemote.map((label) => ({
|
||||||
|
label,
|
||||||
|
kind: "remote" as const,
|
||||||
|
current: false,
|
||||||
|
trackedRemote: "",
|
||||||
|
localOnly: false,
|
||||||
|
representedBranches: [label],
|
||||||
|
})));
|
||||||
|
|
||||||
|
const sortedBranches = branches.sort(compareBranchDecorations);
|
||||||
|
const primaryBranch = sortedBranches.find(
|
||||||
|
(branch) => branch.kind === "head" || branch.representedBranches.some(branchIsVisible),
|
||||||
|
) ?? null;
|
||||||
|
const sortedTags = [...tags].sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
|
||||||
|
const sortedOther = [...other].sort((left, right) => left.localeCompare(right));
|
||||||
|
const primaryTag = sortedTags[0] ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
branches: sortedBranches,
|
||||||
|
tags: sortedTags,
|
||||||
|
other: sortedOther,
|
||||||
|
primaryBranch,
|
||||||
|
primaryTag,
|
||||||
|
overflowCount:
|
||||||
|
Math.max(0, sortedBranches.length - (primaryBranch ? 1 : 0))
|
||||||
|
+ Math.max(0, sortedTags.length - (primaryTag ? 1 : 0))
|
||||||
|
+ sortedOther.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchStatusLabel(branch: CommitBranchDecoration): string {
|
||||||
|
if (branch.label === activeBranch) {
|
||||||
|
const parts = [];
|
||||||
|
if (activeAhead > 0) parts.push(`↑${activeAhead}`);
|
||||||
|
if (activeBehind > 0) parts.push(`↓${activeBehind}`);
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
if (branch.label === activeUpstream && activeBehind > 0) return `↓${activeBehind}`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchDecorationTitle(branch: CommitBranchDecoration): string {
|
||||||
|
if (branch.localOnly) {
|
||||||
|
const status = branchStatusLabel(branch);
|
||||||
|
return `${branch.label} · Local only — not published yet${status ? ` · ${status}` : ""}`;
|
||||||
|
}
|
||||||
|
const status = branchStatusLabel(branch);
|
||||||
|
if (branch.trackedRemote) {
|
||||||
|
return `${branch.label} · Tracks ${branch.trackedRemote}${status ? ` · ${status}` : ""}`;
|
||||||
|
}
|
||||||
|
if (status) return `${branch.label} · ${status}`;
|
||||||
|
return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatCommitDate(value: string): string {
|
function formatCommitDate(value: string): string {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
return commitDateFormatter.format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const available = new Set(localBranchNames);
|
const currentRepository = repositoryKey;
|
||||||
const nextHidden = new Set([...hiddenGraphBranches].filter((branch) => available.has(branch)));
|
if (loadedVisibilityRepository === currentRepository) return;
|
||||||
if (nextHidden.size !== hiddenGraphBranches.size) {
|
loadedVisibilityRepository = currentRepository;
|
||||||
hiddenGraphBranches = nextHidden;
|
expandedRefsCommitHash = "";
|
||||||
|
|
||||||
|
if (!currentRepository) {
|
||||||
|
branchVisibilityMode = "focus";
|
||||||
|
customVisibleBranches = new Set();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`);
|
||||||
|
if (!stored) {
|
||||||
|
branchVisibilityMode = "focus";
|
||||||
|
customVisibleBranches = new Set();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(stored) as { mode?: unknown; branches?: unknown };
|
||||||
|
const mode = parsed.mode;
|
||||||
|
branchVisibilityMode = mode === "focus" || mode === "local" || mode === "all" || mode === "custom"
|
||||||
|
? mode
|
||||||
|
: "focus";
|
||||||
|
customVisibleBranches = new Set(
|
||||||
|
Array.isArray(parsed.branches)
|
||||||
|
? parsed.branches.filter((branch): branch is string => typeof branch === "string")
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
branchVisibilityMode = "focus";
|
||||||
|
customVisibleBranches = new Set();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
|
const currentRepository = repositoryKey;
|
||||||
? activeBranch
|
const mode = branchVisibilityMode;
|
||||||
: (graphBranchNames[0] ?? "");
|
const branches = [...customVisibleBranches];
|
||||||
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
|
if (!currentRepository || loadedVisibilityRepository !== currentRepository) return;
|
||||||
|
try {
|
||||||
if (!defaultBranch) {
|
localStorage.setItem(
|
||||||
if (lastDefaultFilterKey !== defaultFilterKey) {
|
`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`,
|
||||||
hiddenGraphBranches = new Set();
|
JSON.stringify({ mode, branches }),
|
||||||
userAdjustedBranchFilter = false;
|
);
|
||||||
lastDefaultFilterKey = defaultFilterKey;
|
} catch {
|
||||||
}
|
// The graph still works if storage is unavailable.
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (lastDefaultFilterKey !== defaultFilterKey) {
|
$effect(() => {
|
||||||
userAdjustedBranchFilter = false;
|
if (graphBranchNames.length === 0 || customVisibleBranches.size === 0) return;
|
||||||
lastDefaultFilterKey = defaultFilterKey;
|
const available = new Set(graphBranchNames);
|
||||||
}
|
const next = new Set([...customVisibleBranches].filter((branch) => available.has(branch)));
|
||||||
|
if (next.size !== customVisibleBranches.size) customVisibleBranches = next;
|
||||||
|
});
|
||||||
|
|
||||||
if (!userAdjustedBranchFilter) {
|
$effect(() => {
|
||||||
hiddenGraphBranches = new Set(localBranchNames.filter((branch) => branch !== defaultBranch));
|
if (!selectedCommitHash) return;
|
||||||
}
|
queueMicrotask(() => {
|
||||||
|
panelElement
|
||||||
|
?.querySelector<HTMLElement>(`[data-commit-hash="${CSS.escape(selectedCommitHash)}"]`)
|
||||||
|
?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let branchMembership = $derived(branchMembershipByHash(commits));
|
let branchMembership = $derived(branchMembershipByHash(commits));
|
||||||
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
|
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
|
||||||
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
|
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
|
||||||
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
|
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
|
||||||
let visibleBranchCount = $derived(localBranchNames.filter(branchIsVisible).length);
|
let visibleBranchCount = $derived(visibleGraphBranchNames.length);
|
||||||
let graph = $derived(computeGraph(graphCommits, branchMembership));
|
let graph = $derived(computeGraph(graphCommits, branchMembership));
|
||||||
let graphRows = $derived(graph.rows);
|
let graphRows = $derived(graph.rows);
|
||||||
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
||||||
@@ -509,41 +802,42 @@
|
|||||||
<span class="eyebrow">History</span>
|
<span class="eyebrow">History</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-head-actions">
|
{#if graphBranchNames.length > 0}
|
||||||
{#if localBranchNames.length > 0}
|
<div class="section-head-actions">
|
||||||
<button
|
<button
|
||||||
class="graph-branch-dialog-button"
|
class="graph-branch-dialog-button"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={openBranchDialog}
|
onclick={openBranchDialog}
|
||||||
title="Select branches shown in the graph"
|
title="Customize visible branches"
|
||||||
|
aria-label={`${visibleBranchCount} of ${graphBranchNames.length} branches visible. Customize branches.`}
|
||||||
>
|
>
|
||||||
<GitBranch size={13} aria-hidden="true" />
|
<GitBranch size={13} aria-hidden="true" />
|
||||||
Branches
|
Branches
|
||||||
<span>{visibleBranchCount}/{localBranchNames.length}</span>
|
<span>{visibleBranchCount}/{graphBranchNames.length}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
</div>
|
||||||
<span class="pill pill-count" title={visibleCommits.length === commits.length ? "Commits" : `${visibleCommits.length} of ${commits.length} commits shown`}>
|
{/if}
|
||||||
{visibleCommits.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
<div class="blank-state">No repository loaded.</div>
|
<div class="blank-state">No repository loaded.</div>
|
||||||
{:else if commits.length === 0}
|
{:else if commits.length === 0}
|
||||||
<div class="blank-state">No commits returned.</div>
|
<div class="blank-state">No commits returned.</div>
|
||||||
{:else if visibleCommits.length === 0}
|
|
||||||
<div class="blank-state">No commits match the selected branches.</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="history-list graph-list overflow-auto">
|
<div class="history-list graph-list overflow-auto">
|
||||||
|
{#if visibleCommits.length === 0}
|
||||||
|
<div class="blank-state">No loaded commits match the selected branches.</div>
|
||||||
|
{/if}
|
||||||
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
||||||
{@const item = entry.commit}
|
{@const item = entry.commit}
|
||||||
{@const row = graphRows[rowIndex]}
|
{@const row = graphRows[rowIndex]}
|
||||||
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
|
{@const refSummary = commitRefSummary(item)}
|
||||||
{@const otherRefs = visibleRefs(item)}
|
|
||||||
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
||||||
<article
|
<article
|
||||||
class="commit-row graph-row"
|
class="commit-row graph-row"
|
||||||
|
class:selected={selectedCommitHash === item.hash}
|
||||||
|
class:has-note={item.has_note}
|
||||||
|
data-commit-hash={item.hash}
|
||||||
class:graph-ahead-row={rowSyncClass === "ahead"}
|
class:graph-ahead-row={rowSyncClass === "ahead"}
|
||||||
class:graph-behind-row={rowSyncClass === "behind"}
|
class:graph-behind-row={rowSyncClass === "behind"}
|
||||||
class:merge-row={item.parents.length > 1}
|
class:merge-row={item.parents.length > 1}
|
||||||
@@ -575,6 +869,15 @@
|
|||||||
vector-effect="non-scaling-stroke"
|
vector-effect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if refSummary.primaryBranch}
|
||||||
|
<path
|
||||||
|
class="graph-ref-connector"
|
||||||
|
d={`M ${graphColX(row.dotCol)} 50 L ${graphWidth - GRAPH_REF_ARM * 2} 50`}
|
||||||
|
stroke={row.dotColor}
|
||||||
|
stroke-width="1.5"
|
||||||
|
vector-effect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
class="graph-dot"
|
class="graph-dot"
|
||||||
@@ -586,25 +889,114 @@
|
|||||||
title={commitHoverTitle(item, row)}
|
title={commitHoverTitle(item, row)}
|
||||||
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
||||||
></span>
|
></span>
|
||||||
{#if hoverBranchRefs.length > 0}
|
|
||||||
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
|
|
||||||
{#each hoverBranchRefs as branch}
|
|
||||||
<span
|
|
||||||
class:remote={branch === activeUpstream}
|
|
||||||
class:ahead={activeBranch === branch && rowSyncClass === "ahead"}
|
|
||||||
class:behind={activeUpstream === branch && rowSyncClass === "behind"}
|
|
||||||
title={branch}
|
|
||||||
>
|
|
||||||
<GitBranch size={10} aria-hidden="true" />
|
|
||||||
{branch}
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="commit-body">
|
<div
|
||||||
|
class="commit-body"
|
||||||
|
class:has-branch-ref={Boolean(refSummary.primaryBranch)}
|
||||||
|
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`}
|
||||||
|
>
|
||||||
|
{#if item.has_note}
|
||||||
|
<span class="commit-note-rail" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
|
||||||
|
<div class="commit-ref-area">
|
||||||
|
<div class="commit-ref-strip" aria-label="Commit references">
|
||||||
|
{#if refSummary.primaryBranch}
|
||||||
|
<span class="branch-ref-cluster" class:local-only={refSummary.primaryBranch.localOnly}>
|
||||||
|
<span
|
||||||
|
class="compact-ref-chip branch"
|
||||||
|
class:current={refSummary.primaryBranch.current}
|
||||||
|
class:remote={refSummary.primaryBranch.kind === "remote"}
|
||||||
|
title={branchDecorationTitle(refSummary.primaryBranch)}
|
||||||
|
>
|
||||||
|
<GitBranch class="compact-ref-branch-icon" size={10} aria-hidden="true" />
|
||||||
|
<span>{refSummary.primaryBranch.label}</span>
|
||||||
|
{#if !refSummary.primaryBranch.localOnly && branchStatusLabel(refSummary.primaryBranch)}
|
||||||
|
<small>{branchStatusLabel(refSummary.primaryBranch)}</small>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if refSummary.primaryBranch.localOnly}
|
||||||
|
<span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet">
|
||||||
|
LOCAL
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.primaryTag}
|
||||||
|
<span class="compact-ref-chip tag" title={`Tag ${refSummary.primaryTag}`}>
|
||||||
|
<Tag size={10} aria-hidden="true" />
|
||||||
|
<span>{refSummary.primaryTag}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.overflowCount > 0}
|
||||||
|
<button
|
||||||
|
class="compact-ref-overflow"
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggleCommitRefs(item)}
|
||||||
|
aria-expanded={expandedRefsCommitHash === item.hash}
|
||||||
|
aria-controls={`commit-refs-${item.hash}`}
|
||||||
|
title={`Show ${refSummary.overflowCount} more ${refSummary.overflowCount === 1 ? "reference" : "references"}`}
|
||||||
|
>
|
||||||
|
+{refSummary.overflowCount}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if refSummary.overflowCount > 0 && expandedRefsCommitHash === item.hash}
|
||||||
|
<div class="commit-ref-details" id={`commit-refs-${item.hash}`}>
|
||||||
|
<strong>References on this commit</strong>
|
||||||
|
{#if refSummary.branches.some((branch) => branch.kind !== "remote")}
|
||||||
|
<section>
|
||||||
|
<span>Local</span>
|
||||||
|
<div>
|
||||||
|
{#each refSummary.branches.filter((branch) => branch.kind !== "remote") as branch}
|
||||||
|
<span class="commit-ref-detail-item local" title={branchDecorationTitle(branch)}>
|
||||||
|
<i aria-hidden="true"></i>{branch.label}
|
||||||
|
{#if branch.current}<small>Current</small>{/if}
|
||||||
|
{#if branch.trackedRemote}<small>{branch.trackedRemote}</small>{/if}
|
||||||
|
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />Local only</small>{/if}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.branches.some((branch) => branch.kind === "remote")}
|
||||||
|
<section>
|
||||||
|
<span>Remote</span>
|
||||||
|
<div>
|
||||||
|
{#each refSummary.branches.filter((branch) => branch.kind === "remote") as branch}
|
||||||
|
<span class="commit-ref-detail-item remote"><i aria-hidden="true"></i>{branch.label}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.tags.length > 0}
|
||||||
|
<section>
|
||||||
|
<span>Tags</span>
|
||||||
|
<div>
|
||||||
|
{#each refSummary.tags as tag}
|
||||||
|
<span class="commit-ref-detail-item tag"><Tag size={10} aria-hidden="true" />{tag}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{#if refSummary.other.length > 0}
|
||||||
|
<section>
|
||||||
|
<span>Other</span>
|
||||||
|
<div>
|
||||||
|
{#each refSummary.other as ref}
|
||||||
|
<span class="commit-ref-detail-item">{ref}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="commit-card-head">
|
<div class="commit-card-head">
|
||||||
<span class="commit-avatar">
|
<span class="commit-avatar">
|
||||||
{authorInitials(item.author_name)}
|
{authorInitials(item.author_name)}
|
||||||
@@ -622,18 +1014,47 @@
|
|||||||
<div class="commit-meta-line">
|
<div class="commit-meta-line">
|
||||||
<span class="commit-hash">{item.short_hash}</span>
|
<span class="commit-hash">{item.short_hash}</span>
|
||||||
<span class="commit-author" title={item.author_email}>{item.author_name}</span>
|
<span class="commit-author" title={item.author_email}>{item.author_name}</span>
|
||||||
|
{#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>
|
</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}
|
{#if item.files.length > 0}
|
||||||
<div class="commit-files">
|
<div class="commit-files">
|
||||||
<button
|
<button
|
||||||
@@ -672,6 +1093,18 @@
|
|||||||
<div class="commit-actions">
|
<div class="commit-actions">
|
||||||
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
|
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||||
<div class="commit-action-buttons">
|
<div class="commit-action-buttons">
|
||||||
|
{#if !item.has_note}
|
||||||
|
<button
|
||||||
|
class="commit-menu-button commit-note-button"
|
||||||
|
type="button"
|
||||||
|
onclick={() => openCommitNote(item)}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={`Add a Git note to ${item.short_hash}`}
|
||||||
|
aria-label={`Add a Git note to ${item.short_hash}`}
|
||||||
|
>
|
||||||
|
<StickyNote size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="commit-menu-button"
|
class="commit-menu-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -689,6 +1122,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if hasMore || isLoadingMore || loadMoreError}
|
||||||
|
<div class="history-load-more" use:observeHistoryEnd aria-live="polite">
|
||||||
|
{#if isLoadingMore}
|
||||||
|
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||||
|
<span>Loading older commits…</span>
|
||||||
|
{:else if loadMoreError}
|
||||||
|
<span title={loadMoreError}>Older commits could not be loaded.</span>
|
||||||
|
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>Retry</button>
|
||||||
|
{:else}
|
||||||
|
<button type="button" class="history-load-more-button" onclick={() => { void onLoadMore(); }} disabled={isBusy}>
|
||||||
|
Load older commits
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -704,6 +1152,10 @@
|
|||||||
<GitBranch size={14} aria-hidden="true" />
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
Branch
|
Branch
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={openContextCommitNote} disabled={isBusy}>
|
||||||
|
<StickyNote size={14} aria-hidden="true" />
|
||||||
|
Note
|
||||||
|
</button>
|
||||||
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
|
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
Restore
|
Restore
|
||||||
@@ -718,6 +1170,9 @@
|
|||||||
<Cherry size={14} aria-hidden="true" />
|
<Cherry size={14} aria-hidden="true" />
|
||||||
Cherry-pick
|
Cherry-pick
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title="Create a new commit that reverses this commit">
|
||||||
|
<RotateCcw size={14} aria-hidden="true" /> Revert
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
@@ -741,25 +1196,73 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="branch-filter-summary">
|
<div class="branch-filter-summary">
|
||||||
<span>{visibleBranchCount} of {localBranchNames.length} branches selected</span>
|
<span>{visibleBranchCount} of {graphBranchNames.length} branches selected</span>
|
||||||
<div class="branch-filter-actions">
|
<div class="branch-filter-actions">
|
||||||
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === localBranchNames.length}>Show all</button>
|
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>Focus</button>
|
||||||
|
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>Show all</button>
|
||||||
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
|
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="branch-filter-dialog-list">
|
<div class="branch-filter-dialog-list">
|
||||||
{#each localBranchNames as branch}
|
{#if localBranchNames.length > 0}
|
||||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
<section class="branch-filter-group">
|
||||||
<input
|
<button
|
||||||
type="checkbox"
|
class="branch-filter-group-toggle"
|
||||||
checked={branchIsVisible(branch)}
|
type="button"
|
||||||
onchange={() => toggleGraphBranch(branch)}
|
aria-expanded={localBranchGroupOpen}
|
||||||
/>
|
onclick={() => { localBranchGroupOpen = !localBranchGroupOpen; }}
|
||||||
<GitBranch size={14} aria-hidden="true" />
|
>
|
||||||
<span>{branch}</span>
|
{#if localBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
|
||||||
</label>
|
<span>Local</span>
|
||||||
{/each}
|
<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>
|
</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">
|
<script lang="ts">
|
||||||
import { LoaderCircle, X } from "@lucide/svelte";
|
import { Check, ExternalLink, FileDiff, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
|
||||||
import type { GitFileStatus, PatchApplyAction } from "../types";
|
import type { GitFileStatus, PatchApplyAction } from "../types";
|
||||||
|
|
||||||
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
||||||
@@ -8,12 +8,17 @@
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
kind: PatchLineKind;
|
kind: PatchLineKind;
|
||||||
|
oldLine: number | null;
|
||||||
|
newLine: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PatchHunk {
|
interface PatchHunk {
|
||||||
id: string;
|
id: string;
|
||||||
header: string;
|
header: string;
|
||||||
lines: PatchLine[];
|
lines: PatchLine[];
|
||||||
|
oldStart: number;
|
||||||
|
newStart: number;
|
||||||
|
suffix: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedPatch {
|
interface ParsedPatch {
|
||||||
@@ -29,9 +34,12 @@
|
|||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
|
language?: "en" | "de";
|
||||||
|
diffName?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRefresh: () => void | Promise<void>;
|
onRefresh: () => void | Promise<void>;
|
||||||
onApply: (action: PatchApplyAction, patch: string) => void | Promise<void>;
|
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
|
||||||
|
onExternalDiff: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -41,18 +49,32 @@
|
|||||||
isBusy = false,
|
isBusy = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = "",
|
error = "",
|
||||||
|
language = "en",
|
||||||
|
diffName = "diff tool",
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
onRefresh = () => {},
|
onRefresh = () => {},
|
||||||
onApply = () => {},
|
onApply = () => {},
|
||||||
|
onExternalDiff = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isGerman = $derived(language === "de");
|
||||||
|
|
||||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||||
|
let patchScroll = $state<HTMLDivElement | null>(null);
|
||||||
|
let selectedLineIds = $state<Set<string>>(new Set());
|
||||||
|
let lastSelectedLineId = $state("");
|
||||||
|
|
||||||
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
||||||
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||||
|
let selectableLines = $derived(
|
||||||
|
parsed.hunks.flatMap((hunk) => hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete")),
|
||||||
|
);
|
||||||
|
let selectedCount = $derived(selectableLines.filter((line) => selectedLineIds.has(line.id)).length);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
parsed = parsePatch(patch);
|
parsed = parsePatch(patch);
|
||||||
|
selectedLineIds = new Set();
|
||||||
|
lastSelectedLineId = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
function parsePatch(input: string): ParsedPatch {
|
function parsePatch(input: string): ParsedPatch {
|
||||||
@@ -63,10 +85,22 @@
|
|||||||
const headerLines: string[] = [];
|
const headerLines: string[] = [];
|
||||||
const hunks: PatchHunk[] = [];
|
const hunks: PatchHunk[] = [];
|
||||||
let current: PatchHunk | null = null;
|
let current: PatchHunk | null = null;
|
||||||
|
let oldCursor = 0;
|
||||||
|
let newCursor = 0;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith("@@ ")) {
|
if (line.startsWith("@@ ")) {
|
||||||
current = { id: `hunk-${hunks.length}`, header: line, lines: [] };
|
const range = parseHunkHeader(line);
|
||||||
|
current = {
|
||||||
|
id: `hunk-${hunks.length}`,
|
||||||
|
header: line,
|
||||||
|
lines: [],
|
||||||
|
oldStart: range.oldStart,
|
||||||
|
newStart: range.newStart,
|
||||||
|
suffix: range.suffix,
|
||||||
|
};
|
||||||
|
oldCursor = range.oldStart;
|
||||||
|
newCursor = range.newStart;
|
||||||
hunks.push(current);
|
hunks.push(current);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -77,11 +111,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const kind = patchLineKind(line);
|
const kind = patchLineKind(line);
|
||||||
|
const oldLine = kind === "context" || kind === "delete" ? oldCursor : null;
|
||||||
|
const newLine = kind === "context" || kind === "add" ? newCursor : null;
|
||||||
current.lines.push({
|
current.lines.push({
|
||||||
id: `${current.id}-line-${current.lines.length}`,
|
id: `${current.id}-line-${current.lines.length}`,
|
||||||
text: line,
|
text: line,
|
||||||
kind,
|
kind,
|
||||||
|
oldLine,
|
||||||
|
newLine,
|
||||||
});
|
});
|
||||||
|
if (oldLine !== null) oldCursor += 1;
|
||||||
|
if (newLine !== null) newCursor += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -91,6 +131,15 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseHunkHeader(header: string): { oldStart: number; newStart: number; suffix: string } {
|
||||||
|
const match = header.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
|
||||||
|
return {
|
||||||
|
oldStart: Number(match?.[1] ?? 0),
|
||||||
|
newStart: Number(match?.[2] ?? 0),
|
||||||
|
suffix: match?.[3] ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function patchLineKind(line: string): PatchLineKind {
|
function patchLineKind(line: string): PatchLineKind {
|
||||||
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
|
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
|
||||||
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
|
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
|
||||||
@@ -116,7 +165,108 @@
|
|||||||
|
|
||||||
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
|
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
|
||||||
if (isBusy || isLoading) return;
|
if (isBusy || isLoading) return;
|
||||||
await onApply(action, buildHunkPatch(hunk));
|
await onApply(action, buildHunkPatch(hunk), "hunk");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLine(event: MouseEvent, line: PatchLine) {
|
||||||
|
if (isBusy || isLoading || (line.kind !== "add" && line.kind !== "delete")) return;
|
||||||
|
const next = new Set(selectedLineIds);
|
||||||
|
const selecting = !next.has(line.id);
|
||||||
|
if (event.shiftKey && lastSelectedLineId) {
|
||||||
|
const start = selectableLines.findIndex((candidate) => candidate.id === lastSelectedLineId);
|
||||||
|
const end = selectableLines.findIndex((candidate) => candidate.id === line.id);
|
||||||
|
if (start >= 0 && end >= 0) {
|
||||||
|
for (const candidate of selectableLines.slice(Math.min(start, end), Math.max(start, end) + 1)) {
|
||||||
|
if (selecting) next.add(candidate.id);
|
||||||
|
else next.delete(candidate.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (selecting) {
|
||||||
|
next.add(line.id);
|
||||||
|
} else {
|
||||||
|
next.delete(line.id);
|
||||||
|
}
|
||||||
|
selectedLineIds = next;
|
||||||
|
lastSelectedLineId = line.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHunkSelection(hunk: PatchHunk) {
|
||||||
|
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
|
||||||
|
const allSelected = changed.length > 0 && changed.every((line) => selectedLineIds.has(line.id));
|
||||||
|
const next = new Set(selectedLineIds);
|
||||||
|
for (const line of changed) {
|
||||||
|
if (allSelected) next.delete(line.id);
|
||||||
|
else next.add(line.id);
|
||||||
|
}
|
||||||
|
selectedLineIds = next;
|
||||||
|
lastSelectedLineId = changed[changed.length - 1]?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hunkSelectionState(hunk: PatchHunk): "none" | "some" | "all" {
|
||||||
|
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
|
||||||
|
const count = changed.filter((line) => selectedLineIds.has(line.id)).length;
|
||||||
|
return count === 0 ? "none" : count === changed.length ? "all" : "some";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangePart(start: number, count: number): string {
|
||||||
|
return count === 1 ? `${start}` : `${start},${count}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelectedHunk(hunk: PatchHunk): string | null {
|
||||||
|
if (!hunk.lines.some((line) => selectedLineIds.has(line.id))) return null;
|
||||||
|
const output: string[] = [];
|
||||||
|
let previousIncluded = false;
|
||||||
|
|
||||||
|
for (const line of hunk.lines) {
|
||||||
|
if (line.kind === "context") {
|
||||||
|
output.push(line.text);
|
||||||
|
previousIncluded = true;
|
||||||
|
} else if (line.kind === "delete") {
|
||||||
|
output.push(selectedLineIds.has(line.id) ? line.text : ` ${line.text.slice(1)}`);
|
||||||
|
previousIncluded = true;
|
||||||
|
} else if (line.kind === "add") {
|
||||||
|
if (selectedLineIds.has(line.id)) {
|
||||||
|
output.push(line.text);
|
||||||
|
previousIncluded = true;
|
||||||
|
} else {
|
||||||
|
previousIncluded = false;
|
||||||
|
}
|
||||||
|
} else if (previousIncluded) {
|
||||||
|
output.push(line.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldCount = output.filter((line) => line.startsWith(" ") || line.startsWith("-")).length;
|
||||||
|
const newCount = output.filter((line) => line.startsWith(" ") || line.startsWith("+")).length;
|
||||||
|
const header = `@@ -${rangePart(hunk.oldStart, oldCount)} +${rangePart(hunk.newStart, newCount)} @@${hunk.suffix}`;
|
||||||
|
return [header, ...output].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelectedPatch(): string {
|
||||||
|
const hunks = parsed.hunks.map(buildSelectedHunk).filter((hunk): hunk is string => Boolean(hunk));
|
||||||
|
return `${[...parsed.headerLines, ...hunks].join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySelected(action: PatchApplyAction) {
|
||||||
|
if (isBusy || isLoading || selectedCount === 0) return;
|
||||||
|
await onApply(action, buildSelectedPatch(), "lines");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hunkPosition(index: number): number {
|
||||||
|
const totalLines = parsed.hunks.reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
|
||||||
|
const precedingLines = parsed.hunks.slice(0, index).reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
|
||||||
|
return (precedingLines / Math.max(totalLines - 1, 1)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hunkKind(hunk: PatchHunk): "add" | "delete" | "mixed" {
|
||||||
|
const hasAdd = hunk.lines.some((line) => line.kind === "add");
|
||||||
|
const hasDelete = hunk.lines.some((line) => line.kind === "delete");
|
||||||
|
return hasAdd && hasDelete ? "mixed" : hasAdd ? "add" : "delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToHunk(hunkId: string) {
|
||||||
|
const target = patchScroll?.querySelector<HTMLElement>(`[data-hunk-id="${hunkId}"]`);
|
||||||
|
if (patchScroll && target) patchScroll.scrollTop = Math.max(target.offsetTop - 8, 0);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -127,7 +277,16 @@
|
|||||||
<span class="eyebrow">{scopeLabel}</span>
|
<span class="eyebrow">{scopeLabel}</span>
|
||||||
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-header-actions">
|
<div class="dialog-header-actions 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" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
|
||||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||||
<X size={16} aria-hidden="true" />
|
<X size={16} aria-hidden="true" />
|
||||||
@@ -148,40 +307,106 @@
|
|||||||
{:else if parsed.binary || parsed.hunks.length === 0}
|
{:else if parsed.binary || parsed.hunks.length === 0}
|
||||||
<div class="blank-state">This change cannot be split into text lines.</div>
|
<div class="blank-state">This change cannot be split into text lines.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="line-patch-scroll">
|
<div class:active={selectedCount > 0} class="line-patch-selection-bar">
|
||||||
{#each parsed.hunks as hunk (hunk.id)}
|
<div>
|
||||||
<section class="line-patch-hunk">
|
<MousePointer2 size={14} aria-hidden="true" />
|
||||||
<div class="line-patch-hunk-head">
|
{#if selectedCount > 0}
|
||||||
<code>{hunk.header}</code>
|
<strong>{selectedCount} {selectedCount === 1 ? "line" : "lines"} selected</strong>
|
||||||
<div class="line-patch-hunk-actions">
|
<span>Shift-click to select a range.</span>
|
||||||
{#if staged}
|
{:else}
|
||||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
|
<strong>Select changed lines</strong>
|
||||||
Discard Hunk
|
<span>Choose individual additions or deletions below.</span>
|
||||||
</button>
|
{/if}
|
||||||
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
|
</div>
|
||||||
Unstage Hunk
|
{#if selectedCount > 0}
|
||||||
</button>
|
<div class="line-patch-selected-actions">
|
||||||
{:else}
|
<button class="line-patch-hunk-button discard" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy}>
|
||||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
|
Discard selected
|
||||||
Discard Hunk
|
</button>
|
||||||
</button>
|
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy}>
|
||||||
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
|
{staged ? "Unstage selected" : "Stage selected"}
|
||||||
Stage Hunk
|
</button>
|
||||||
</button>
|
<button class="btn-sm" type="button" onclick={() => { selectedLineIds = new Set(); }} disabled={isBusy}>Clear</button>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="line-patch-lines">
|
<div class="line-patch-workspace">
|
||||||
{#each hunk.lines as line (line.id)}
|
<div class="line-patch-scroll" bind:this={patchScroll}>
|
||||||
<div class={`line-patch-row ${line.kind}`}>
|
{#each parsed.hunks as hunk (hunk.id)}
|
||||||
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
|
||||||
<code>{lineBody(line)}</code>
|
<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>
|
</div>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
<div class="line-patch-lines">
|
||||||
{/each}
|
{#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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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("");
|
let name = $state("");
|
||||||
|
|
||||||
|
function remoteName(branch: GitBranchInfo): string {
|
||||||
|
if (!branch.remote) return "";
|
||||||
|
const slash = branch.name.indexOf("/");
|
||||||
|
return slash > 0 ? branch.name.slice(0, slash) : branch.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editableName(branch: GitBranchInfo): string {
|
||||||
|
if (!branch.remote) return branch.name;
|
||||||
|
const slash = branch.name.indexOf("/");
|
||||||
|
return slash >= 0 ? branch.name.slice(slash + 1) : branch.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
let originalName = $derived(editableName(branch));
|
||||||
|
let remote = $derived(remoteName(branch));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
name = branch.name;
|
name = originalName;
|
||||||
});
|
});
|
||||||
|
|
||||||
function submit(event: SubmitEvent) {
|
function submit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const value = name.trim();
|
const value = name.trim();
|
||||||
if (!value || value === branch.name) return;
|
if (!value || value === originalName) return;
|
||||||
onRename(value);
|
onRename(value);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -34,10 +49,10 @@
|
|||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label={branch.remote ? "Rename remote branch" : "Rename branch"} tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Rename branch</span>
|
<span class="eyebrow">{branch.remote ? "Rename remote branch" : "Rename branch"}</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||||
@@ -48,27 +63,37 @@
|
|||||||
<form class="rename-branch-form" onsubmit={submit}>
|
<form class="rename-branch-form" onsubmit={submit}>
|
||||||
<label class="new-branch-field">
|
<label class="new-branch-field">
|
||||||
<span>Branch name</span>
|
<span>Branch name</span>
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
<div class:remote-branch-name-field={branch.remote}>
|
||||||
<input
|
{#if branch.remote}<strong>{remote}/</strong>{/if}
|
||||||
bind:value={name}
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
autocomplete="off"
|
<input
|
||||||
spellcheck="false"
|
bind:value={name}
|
||||||
disabled={isBusy}
|
autocomplete="off"
|
||||||
autofocus
|
spellcheck="false"
|
||||||
/>
|
disabled={isBusy}
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{#if branch.remote}
|
||||||
|
<p class="rename-remote-note">
|
||||||
|
Gitty creates <strong>{remote}/{name.trim() || "new-name"}</strong> and removes
|
||||||
|
<strong>{branch.name}</strong> in one atomic push. The operation stops if the remote changed in the meantime.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="new-branch-actions">
|
<div class="new-branch-actions">
|
||||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === branch.name}>
|
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === originalName}>
|
||||||
{#if isBusy}
|
{#if isBusy}
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<GitBranch size={16} aria-hidden="true" />
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
Rename
|
{branch.remote ? "Rename on remote" : "Rename"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.repo-loading {
|
.repo-loading {
|
||||||
position: fixed;
|
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;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -42,18 +42,23 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 50% 42%, rgba(137, 92, 255, 0.18), transparent 34%),
|
radial-gradient(
|
||||||
linear-gradient(135deg, #050712 0%, #080b16 46%, #120b18 100%);
|
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;
|
animation: overlay-in 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-loading-grid {
|
.repo-loading-grid {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
opacity: 0.28;
|
opacity: 0.34;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(151, 118, 255, 0.12) 1px, transparent 1px),
|
linear-gradient(color-mix(in srgb, var(--color-primary) 9%, transparent) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, rgba(255, 109, 38, 0.08) 1px, transparent 1px);
|
linear-gradient(90deg, color-mix(in srgb, var(--color-accent) 8%, transparent) 1px, transparent 1px);
|
||||||
background-size: 42px 42px;
|
background-size: 42px 42px;
|
||||||
mask-image: radial-gradient(circle at center, black 0%, transparent 68%);
|
mask-image: radial-gradient(circle at center, black 0%, transparent 68%);
|
||||||
animation: grid-drift 10s linear infinite;
|
animation: grid-drift 10s linear infinite;
|
||||||
@@ -64,23 +69,21 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 18px;
|
gap: 14px;
|
||||||
width: min(420px, calc(100vw - 42px));
|
width: min(360px, calc(100vw - 32px));
|
||||||
padding: 30px 34px 28px;
|
padding: 26px 30px 24px;
|
||||||
border: 1px solid rgba(160, 124, 255, 0.24);
|
border: 1px solid var(--color-border-input);
|
||||||
border-radius: 16px;
|
border-radius: 14px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(23, 26, 43, 0.9), rgba(12, 15, 27, 0.92)),
|
var(--app-panel-highlight),
|
||||||
var(--color-surface-raised);
|
var(--app-dialog-bg);
|
||||||
box-shadow:
|
box-shadow: var(--app-dialog-shadow);
|
||||||
0 26px 90px rgba(0, 0, 0, 0.62),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-loading-mark {
|
.repo-loading-mark {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 168px;
|
width: 116px;
|
||||||
height: 168px;
|
height: 116px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
@@ -88,25 +91,25 @@
|
|||||||
|
|
||||||
.repo-loading-halo {
|
.repo-loading-halo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 10px;
|
inset: 8px;
|
||||||
border: 1px solid rgba(151, 118, 255, 0.24);
|
border: 1px solid color-mix(in srgb, var(--color-primary) 34%, transparent);
|
||||||
border-radius: 34px;
|
border-radius: 24px;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
.repo-loading-halo.halo-one {
|
.repo-loading-halo.halo-one {
|
||||||
animation: halo-breathe 2.4s ease-in-out infinite;
|
animation: halo-breathe 2.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.repo-loading-halo.halo-two {
|
.repo-loading-halo.halo-two {
|
||||||
inset: 24px;
|
inset: 18px;
|
||||||
border-color: rgba(255, 109, 38, 0.26);
|
border-color: color-mix(in srgb, var(--color-accent) 38%, transparent);
|
||||||
animation: halo-breathe 2.4s ease-in-out infinite reverse;
|
animation: halo-breathe 2.4s ease-in-out infinite reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-loading-traces {
|
.repo-loading-traces {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -18px;
|
inset: -14px;
|
||||||
width: 204px;
|
width: 144px;
|
||||||
height: 204px;
|
height: 144px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -116,18 +119,18 @@
|
|||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-dasharray: 165;
|
stroke-dasharray: 165;
|
||||||
stroke-dashoffset: 165;
|
stroke-dashoffset: 165;
|
||||||
filter: drop-shadow(0 0 8px rgba(151, 118, 255, 0.55));
|
filter: drop-shadow(0 0 7px color-mix(in srgb, var(--color-accent) 38%, transparent));
|
||||||
animation: trace-draw 2.6s ease-in-out infinite;
|
animation: trace-draw 2.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.repo-loading-traces .trace-main {
|
.repo-loading-traces .trace-main {
|
||||||
stroke: #9b70ff;
|
stroke: var(--color-primary);
|
||||||
}
|
}
|
||||||
.repo-loading-traces .trace-branch {
|
.repo-loading-traces .trace-branch {
|
||||||
stroke: #ff6d26;
|
stroke: var(--color-accent);
|
||||||
animation-delay: 0.28s;
|
animation-delay: 0.28s;
|
||||||
}
|
}
|
||||||
.repo-loading-traces .trace-cut {
|
.repo-loading-traces .trace-cut {
|
||||||
stroke: rgba(255, 255, 255, 0.42);
|
stroke: color-mix(in srgb, var(--color-ink-muted) 52%, transparent);
|
||||||
stroke-dasharray: 128;
|
stroke-dasharray: 128;
|
||||||
stroke-dashoffset: 128;
|
stroke-dashoffset: 128;
|
||||||
animation-delay: 0.55s;
|
animation-delay: 0.55s;
|
||||||
@@ -136,12 +139,12 @@
|
|||||||
.repo-loading-icon {
|
.repo-loading-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 132px;
|
width: 82px;
|
||||||
height: 132px;
|
height: 82px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
filter:
|
filter:
|
||||||
drop-shadow(0 18px 24px rgba(0, 0, 0, 0.55))
|
drop-shadow(0 10px 16px color-mix(in srgb, var(--color-ink) 24%, transparent))
|
||||||
drop-shadow(0 0 18px rgba(151, 118, 255, 0.28));
|
drop-shadow(0 0 14px color-mix(in srgb, var(--color-accent) 24%, transparent));
|
||||||
animation: icon-float 2.4s ease-in-out infinite;
|
animation: icon-float 2.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,16 +158,16 @@
|
|||||||
|
|
||||||
.repo-loading-label {
|
.repo-loading-label {
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 800;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-loading-name {
|
.repo-loading-name {
|
||||||
max-width: 260px;
|
max-width: 250px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--color-ink-faint);
|
color: var(--color-ink-faint);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12.5px;
|
font-size: 12px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -174,16 +177,17 @@
|
|||||||
width: min(230px, 100%);
|
width: min(230px, 100%);
|
||||||
height: 4px;
|
height: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-radius: 999px;
|
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 {
|
.repo-loading-bar span {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 46%;
|
width: 46%;
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
background: linear-gradient(90deg, transparent, #9b70ff 40%, #ff6d26 74%, transparent);
|
background: linear-gradient(90deg, transparent, var(--color-primary) 40%, var(--color-accent) 74%, transparent);
|
||||||
box-shadow: 0 0 16px rgba(255, 109, 38, 0.32);
|
box-shadow: 0 0 14px color-mix(in srgb, var(--color-accent) 32%, transparent);
|
||||||
animation: bar-slide 1.35s ease-in-out infinite;
|
animation: bar-slide 1.35s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,4 +219,11 @@
|
|||||||
.repo-loading-bar span { animation: none; }
|
.repo-loading-bar span { animation: none; }
|
||||||
.repo-loading-traces .trace { stroke-dashoffset: 0; opacity: 0.72; }
|
.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>
|
</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">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { AlertCircle, Check, LoaderCircle, X } from "@lucide/svelte";
|
import { AlertCircle, Check, ExternalLink, GitMerge, LoaderCircle, X } from "@lucide/svelte";
|
||||||
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
|
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
|
||||||
|
|
||||||
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
|
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
|
||||||
@@ -24,6 +24,9 @@
|
|||||||
onSelectFile: (path: string) => void;
|
onSelectFile: (path: string) => void;
|
||||||
onMarkResolved: (path: string, resolution: PreparedResolution) => void;
|
onMarkResolved: (path: string, resolution: PreparedResolution) => void;
|
||||||
onApply: () => void;
|
onApply: () => void;
|
||||||
|
onExternalMerge: (path: string) => void | Promise<void>;
|
||||||
|
language?: "en" | "de";
|
||||||
|
mergeName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -37,7 +40,11 @@
|
|||||||
onSelectFile = () => {},
|
onSelectFile = () => {},
|
||||||
onMarkResolved = () => {},
|
onMarkResolved = () => {},
|
||||||
onApply = () => {},
|
onApply = () => {},
|
||||||
|
onExternalMerge = () => {},
|
||||||
|
language = "en",
|
||||||
|
mergeName = "merge tool",
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
const isGerman = $derived(language === "de");
|
||||||
|
|
||||||
let conflictParts = $derived<ConflictPart[]>(
|
let conflictParts = $derived<ConflictPart[]>(
|
||||||
conflict && !conflict.binary ? parseConflicts(conflict.content) : [],
|
conflict && !conflict.binary ? parseConflicts(conflict.content) : [],
|
||||||
@@ -268,9 +275,22 @@
|
|||||||
<span class="eyebrow">Resolve</span>
|
<span class="eyebrow">Resolve</span>
|
||||||
<h2 class="dialog-title">Conflicts</h2>
|
<h2 class="dialog-title">Conflicts</h2>
|
||||||
</div>
|
</div>
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
<div class="dialog-header-actions">
|
||||||
<X size={18} aria-hidden="true" />
|
{#if conflictTarget}
|
||||||
</button>
|
<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>
|
</header>
|
||||||
|
|
||||||
{#if conflictedFiles.length === 0}
|
{#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">
|
<script lang="ts">
|
||||||
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
|
import {
|
||||||
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
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 {
|
interface Props {
|
||||||
changedFiles: GitFileStatus[];
|
changedFiles: GitFileStatus[];
|
||||||
@@ -8,29 +23,68 @@
|
|||||||
unstagedCount: number;
|
unstagedCount: number;
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
operation: string;
|
||||||
status: GitStatus | null;
|
status: GitStatus | null;
|
||||||
selectedFilePath: string;
|
selectedFilePath: string;
|
||||||
onSelectFile: (file: GitFileStatus) => void;
|
onSelectFile: (file: GitFileStatus) => void;
|
||||||
onStage: (files: GitFileStatus[]) => void;
|
onStage: (files: GitFileStatus[]) => void;
|
||||||
onUnstage: (files: GitFileStatus[]) => void;
|
onUnstage: (files: GitFileStatus[]) => void;
|
||||||
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
|
||||||
|
onDiscardMany: (files: GitFileStatus[]) => void;
|
||||||
|
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;
|
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
||||||
onStageAll: () => void;
|
onStageAll: () => void;
|
||||||
onUnstageAll: () => void;
|
onUnstageAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StatusViewMode = "list" | "tree";
|
||||||
|
type StatusLaneKind = "unstaged" | "staged";
|
||||||
|
|
||||||
|
interface StatusFolderNode {
|
||||||
|
kind: "folder";
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
depth: number;
|
||||||
|
fileCount: number;
|
||||||
|
children: StatusTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusFileNode {
|
||||||
|
kind: "file";
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
depth: number;
|
||||||
|
file: GitFileStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusTreeNode = StatusFolderNode | StatusFileNode;
|
||||||
|
|
||||||
|
interface StatusContextTarget {
|
||||||
|
lane: StatusLaneKind;
|
||||||
|
kind: "file" | "folder";
|
||||||
|
label: string;
|
||||||
|
files: GitFileStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
changedFiles = [],
|
changedFiles = [],
|
||||||
stagedCount = 0,
|
stagedCount = 0,
|
||||||
unstagedCount = 0,
|
unstagedCount = 0,
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
operation = "",
|
||||||
status = null,
|
status = null,
|
||||||
selectedFilePath = "",
|
selectedFilePath = "",
|
||||||
onSelectFile = () => {},
|
onSelectFile = () => {},
|
||||||
onStage = () => {},
|
onStage = () => {},
|
||||||
onUnstage = () => {},
|
onUnstage = () => {},
|
||||||
onDiscard = () => {},
|
onDiscard = () => {},
|
||||||
|
onDiscardMany = () => {},
|
||||||
|
onStash = () => {},
|
||||||
|
onIgnore = () => {},
|
||||||
|
onStopTracking = () => {},
|
||||||
onPatch = () => {},
|
onPatch = () => {},
|
||||||
onStageAll = () => {},
|
onStageAll = () => {},
|
||||||
onUnstageAll = () => {},
|
onUnstageAll = () => {},
|
||||||
@@ -60,8 +114,202 @@
|
|||||||
return `${file.old_path ?? ""}:${file.path}`;
|
return `${file.old_path ?? ""}:${file.path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortStatusTree(nodes: StatusTreeNode[]) {
|
||||||
|
nodes.sort((a, b) => {
|
||||||
|
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.kind === "folder") sortStatusTree(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStatusTree(files: GitFileStatus[]): StatusTreeNode[] {
|
||||||
|
const roots: StatusTreeNode[] = [];
|
||||||
|
const folders = new Map<string, StatusFolderNode>();
|
||||||
|
|
||||||
|
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
||||||
|
const parts = file.path.split(/[\\/]+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) continue;
|
||||||
|
|
||||||
|
let currentPath = "";
|
||||||
|
let siblings = roots;
|
||||||
|
const ancestors: StatusFolderNode[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < parts.length - 1; index++) {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${parts[index]}` : parts[index];
|
||||||
|
let folder = folders.get(currentPath);
|
||||||
|
if (!folder) {
|
||||||
|
folder = {
|
||||||
|
kind: "folder",
|
||||||
|
name: parts[index],
|
||||||
|
path: currentPath,
|
||||||
|
depth: index,
|
||||||
|
fileCount: 0,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
folders.set(currentPath, folder);
|
||||||
|
siblings.push(folder);
|
||||||
|
}
|
||||||
|
ancestors.push(folder);
|
||||||
|
siblings = folder.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
siblings.push({
|
||||||
|
kind: "file",
|
||||||
|
name: parts[parts.length - 1] ?? file.path,
|
||||||
|
path: file.path,
|
||||||
|
depth: Math.max(parts.length - 1, 0),
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
for (const folder of ancestors) folder.fileCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
sortStatusTree(roots);
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenStatusTree(nodes: StatusTreeNode[], lane: StatusLaneKind): StatusTreeNode[] {
|
||||||
|
const visible: StatusTreeNode[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
visible.push(node);
|
||||||
|
if (node.kind === "folder" && !collapsedStatusFolders.has(`${lane}:${node.path}`)) {
|
||||||
|
visible.push(...flattenStatusTree(node.children, lane));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listStatusRows(files: GitFileStatus[]): StatusFileNode[] {
|
||||||
|
return files.map((file) => ({
|
||||||
|
kind: "file",
|
||||||
|
name: fileName(file),
|
||||||
|
path: file.path,
|
||||||
|
depth: 0,
|
||||||
|
file,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
let selectedStatusPaths = $state<Set<string>>(new Set());
|
let selectedStatusPaths = $state<Set<string>>(new Set());
|
||||||
let selectionAnchorKey = $state("");
|
let selectionAnchorKey = $state("");
|
||||||
|
let statusView = $state<StatusViewMode>("list");
|
||||||
|
let collapsedStatusFolders = $state<Set<string>>(new Set());
|
||||||
|
let statusContextTarget = $state<StatusContextTarget | null>(null);
|
||||||
|
let statusContextMenuX = $state(0);
|
||||||
|
let statusContextMenuY = $state(0);
|
||||||
|
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 {
|
function isStatusSelected(file: GitFileStatus): boolean {
|
||||||
return selectedStatusPaths.has(fileKey(file));
|
return selectedStatusPaths.has(fileKey(file));
|
||||||
@@ -122,11 +370,35 @@
|
|||||||
onUnstage(targets);
|
onUnstage(targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discard mirrors the stage/unstage target selection: if the clicked row is
|
||||||
|
// part of the current multi-selection, the whole selection (filtered to the
|
||||||
|
// relevant lane) is discarded; otherwise just that one file.
|
||||||
|
function discardStagedFromFile(file: GitFileStatus) {
|
||||||
|
const targets = selectedUnstageTargets(file);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
onDiscard(targets, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardUnstagedFromFile(file: GitFileStatus) {
|
||||||
|
const targets = selectedStageTargets(file);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
onDiscard(targets, false);
|
||||||
|
}
|
||||||
|
|
||||||
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
||||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
||||||
let selectedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f))).length);
|
let unstagedFiles = $derived(changedFiles.filter((file) => file.unstaged !== null));
|
||||||
|
let stagedFiles = $derived(changedFiles.filter((file) => file.staged !== null));
|
||||||
|
let unstagedTree = $derived(buildStatusTree(unstagedFiles));
|
||||||
|
let stagedTree = $derived(buildStatusTree(stagedFiles));
|
||||||
|
let visibleUnstagedRows = $derived(statusView === "tree" ? flattenStatusTree(unstagedTree, "unstaged") : listStatusRows(unstagedFiles));
|
||||||
|
let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles));
|
||||||
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
|
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
|
||||||
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
|
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
|
||||||
|
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(() => {
|
$effect(() => {
|
||||||
const validKeys = new Set(changedFiles.map(fileKey));
|
const validKeys = new Set(changedFiles.map(fileKey));
|
||||||
@@ -136,65 +408,32 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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 class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Working tree</span>
|
<span class="eyebrow">Workspace</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Status</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="status-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">{stagedCount} staged</span>
|
||||||
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
||||||
</div>
|
{#if hasRepository && changedFiles.length > 0}
|
||||||
</div>
|
<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
|
||||||
{#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
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
<div class="blank-state">No repository loaded.</div>
|
<div class="blank-state">No repository loaded.</div>
|
||||||
@@ -203,79 +442,371 @@
|
|||||||
{:else if changedFiles.length === 0}
|
{:else if changedFiles.length === 0}
|
||||||
<div class="blank-state">No file changes returned.</div>
|
<div class="blank-state">No file changes returned.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-auto p-2">
|
<div class="status-lanes">
|
||||||
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
<section class="status-lane unstaged-lane" aria-label="Unstaged changes">
|
||||||
<article
|
<header class="status-lane-head">
|
||||||
class="file-row"
|
<div class="status-lane-title">
|
||||||
class:selected={isStatusSelected(file)}
|
<div class="status-lane-copy"><strong>Unstaged</strong><small>Working tree</small></div>
|
||||||
class:active={selectedFilePath === file.path}
|
<span class="status-lane-count">{unstagedCount}</span>
|
||||||
>
|
</div>
|
||||||
<div class="file-title">
|
<div class="status-lane-actions">
|
||||||
<button
|
{#if selectedUnstagedCount > 1}
|
||||||
class="file-title-button"
|
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
|
||||||
type="button"
|
<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`}>
|
||||||
onclick={(event) => handleFileSelect(event, file)}
|
<ArrowRight size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
|
||||||
title={`Select ${displayPath(file)} in Explorer`}
|
</button>
|
||||||
>
|
{/if}
|
||||||
<strong>{fileName(file)}</strong>
|
<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>
|
</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>
|
||||||
|
</header>
|
||||||
<div class="change-lanes">
|
<div class="status-file-list">
|
||||||
<div class="change-lane" class:inactive={!file.staged}>
|
{#each visibleUnstagedRows as row (`unstaged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
|
||||||
<div class="lane-header">
|
{#if row.kind === "folder"}
|
||||||
<span class="lane-name">Staged</span>
|
<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}`)}>
|
||||||
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
<i class:expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
|
||||||
</div>
|
{#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}
|
||||||
<div class="lane-actions">
|
<strong>{row.name}</strong>
|
||||||
{#if file.staged}
|
<span class="status-folder-count">{row.fileCount}</span>
|
||||||
{@const unstageTargets = selectedUnstageTargets(file)}
|
</button>
|
||||||
<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"}>
|
{:else}
|
||||||
<Undo2 size={14} aria-hidden="true" />
|
{@const file = row.file}
|
||||||
{unstageTargets.length > 1 ? `Unstage ${unstageTargets.length}` : "Unstage"}
|
{@const stageTargets = selectedStageTargets(file)}
|
||||||
</button>
|
<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="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
|
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||||
<FileDiff size={14} aria-hidden="true" />
|
<strong>{fileName(file)}</strong>
|
||||||
Details
|
<span>{displayPath(file)}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
|
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
|
||||||
Discard
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span class="quiet">No staged change</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="change-lane" class:inactive={!file.unstaged}>
|
|
||||||
<div class="lane-header">
|
|
||||||
<span class="lane-name">Unstaged</span>
|
|
||||||
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
||||||
</div>
|
<div class="status-file-actions">
|
||||||
<div class="lane-actions">
|
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><ArrowRight size={14} aria-hidden="true" /></button>
|
||||||
{#if file.unstaged}
|
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||||
{@const stageTargets = selectedStageTargets(file)}
|
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||||
<button class="btn-sm" type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Stage ${stageTargets.length} selected files` : "Stage file"}>
|
</div>
|
||||||
<Check size={14} aria-hidden="true" />
|
</article>
|
||||||
{stageTargets.length > 1 ? `Stage ${stageTargets.length}` : "Stage"}
|
{/if}
|
||||||
</button>
|
{/each}
|
||||||
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
|
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
|
||||||
<FileDiff size={14} aria-hidden="true" />
|
</div>
|
||||||
Details
|
</section>
|
||||||
</button>
|
|
||||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
|
<div class="status-flow-divider" aria-hidden="true">
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
<span><ArrowRight size={12} /></span>
|
||||||
Discard
|
</div>
|
||||||
</button>
|
|
||||||
{:else}
|
<section class="status-lane staged-lane" aria-label="Staged changes">
|
||||||
<span class="quiet">No unstaged change</span>
|
<header class="status-lane-head">
|
||||||
{/if}
|
<div class="status-lane-title">
|
||||||
</div>
|
<div class="status-lane-copy"><strong>Staged</strong><small>Next commit</small></div>
|
||||||
</div>
|
<span class="status-lane-count">{stagedCount}</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
<div class="status-lane-actions">
|
||||||
{/each}
|
{#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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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(
|
const versionLabel = $derived(
|
||||||
version && currentVersion
|
version && currentVersion
|
||||||
? `${currentVersion} -> ${version}`
|
? `${currentVersion} → ${version}`
|
||||||
: version
|
: version
|
||||||
? `Version ${version}`
|
? `Version ${version}`
|
||||||
: "New version",
|
: "New version",
|
||||||
@@ -69,34 +69,32 @@
|
|||||||
role={state === "error" ? "alert" : "status"}
|
role={state === "error" ? "alert" : "status"}
|
||||||
aria-live={state === "error" ? "assertive" : "polite"}
|
aria-live={state === "error" ? "assertive" : "polite"}
|
||||||
>
|
>
|
||||||
<div class="update-toast-glow" aria-hidden="true"></div>
|
<header class="update-toast-header">
|
||||||
|
<div class="update-toast-icon" class:busy={isBusy}>
|
||||||
<div class="update-toast-icon" class:busy={isBusy}>
|
{#if state === "downloading"}
|
||||||
{#if state === "downloading"}
|
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||||
<LoaderCircle class="spin" size={21} aria-hidden="true" />
|
{:else if state === "installed"}
|
||||||
{:else if state === "installed"}
|
<PackageCheck size={18} aria-hidden="true" />
|
||||||
<PackageCheck size={21} aria-hidden="true" />
|
{:else if state === "error"}
|
||||||
{:else if state === "error"}
|
<AlertCircle size={18} aria-hidden="true" />
|
||||||
<AlertCircle size={21} aria-hidden="true" />
|
{:else}
|
||||||
{:else}
|
<Sparkles size={18} aria-hidden="true" />
|
||||||
<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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
<p>{description}</p>
|
||||||
|
|
||||||
{#if showProgress}
|
{#if showProgress}
|
||||||
@@ -115,33 +113,34 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
</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>
|
</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 —
|
* Derives a credential key from a remote URL, scoped to host + organisation —
|
||||||
* the same granularity Azure DevOps / GitHub use. Examples:
|
* the same granularity Azure DevOps / GitHub use. Examples:
|
||||||
@@ -37,13 +35,6 @@ export function orgKeyFromUrl(raw: string): string | null {
|
|||||||
return org ? `${host}/${org}` : host;
|
return org ? `${host}/${org}` : host;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A stored credential is expired only if it carries a past expiry date. */
|
|
||||||
export function isCredentialExpired(cred: StoredCredential): boolean {
|
|
||||||
if (!cred.expiresAt) return false;
|
|
||||||
const time = new Date(cred.expiresAt).getTime();
|
|
||||||
return !Number.isNaN(time) && time < Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
const AUTH_PREFIX = "AUTH_FAILED:";
|
const AUTH_PREFIX = "AUTH_FAILED:";
|
||||||
|
|
||||||
export function isAuthError(message: string): boolean {
|
export function isAuthError(message: string): boolean {
|
||||||
@@ -55,3 +46,17 @@ export function stripAuthPrefix(message: string): string {
|
|||||||
? message.slice(AUTH_PREFIX.length).trim()
|
? message.slice(AUTH_PREFIX.length).trim()
|
||||||
: message;
|
: message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condenses raw git stderr to the single most relevant line for the
|
||||||
|
* credential dialog. Prefers the server's own explanation ("remote: ...")
|
||||||
|
* over git's generic wrapper ("fatal: Authentication failed for ...").
|
||||||
|
*/
|
||||||
|
export function summarizeGitError(message: string): string {
|
||||||
|
const lines = message
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const remote = lines.find((line) => line.toLowerCase().startsWith("remote:"));
|
||||||
|
return remote ?? lines[0] ?? "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import type {
|
||||||
|
DetectedExternalTool,
|
||||||
|
ExternalToolKind,
|
||||||
|
ExternalToolSetting,
|
||||||
|
ExternalToolsSettings,
|
||||||
|
ToolOpenMode,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export type { ExternalToolKind } from "./types";
|
||||||
|
|
||||||
|
export interface ExternalToolPreset {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
program: string;
|
||||||
|
args: string[];
|
||||||
|
builtIn?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolIdentity = Pick<ExternalToolPreset, "id" | "label" | "program">;
|
||||||
|
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
export const externalToolsPlatform = userAgent.includes("Windows")
|
||||||
|
? "windows"
|
||||||
|
: userAgent.includes("Mac")
|
||||||
|
? "macos"
|
||||||
|
: "linux";
|
||||||
|
|
||||||
|
const codeFamily: ToolIdentity[] = externalToolsPlatform === "windows"
|
||||||
|
? [
|
||||||
|
{ id: "vscode", label: "Visual Studio Code", program: "Code.exe" },
|
||||||
|
{ id: "vscode-insiders", label: "Visual Studio Code Insiders", program: "Code - Insiders.exe" },
|
||||||
|
{ id: "cursor", label: "Cursor", program: "Cursor.exe" },
|
||||||
|
{ id: "windsurf", label: "Windsurf", program: "Windsurf.exe" },
|
||||||
|
{ id: "vscodium", label: "VSCodium", program: "VSCodium.exe" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ id: "vscode", label: "Visual Studio Code", program: "code" },
|
||||||
|
{ id: "vscode-insiders", label: "Visual Studio Code Insiders", program: "code-insiders" },
|
||||||
|
{ id: "cursor", label: "Cursor", program: "cursor" },
|
||||||
|
{ id: "windsurf", label: "Windsurf", program: "windsurf" },
|
||||||
|
{ id: "vscodium", label: "VSCodium", program: "codium" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const jetBrains: ToolIdentity[] = [
|
||||||
|
{ id: "intellij-idea", label: "IntelliJ IDEA", program: externalToolsPlatform === "windows" ? "idea64.exe" : "idea" },
|
||||||
|
{ id: "webstorm", label: "WebStorm", program: externalToolsPlatform === "windows" ? "webstorm64.exe" : "webstorm" },
|
||||||
|
{ id: "pycharm", label: "PyCharm", program: externalToolsPlatform === "windows" ? "pycharm64.exe" : "pycharm" },
|
||||||
|
{ id: "phpstorm", label: "PhpStorm", program: externalToolsPlatform === "windows" ? "phpstorm64.exe" : "phpstorm" },
|
||||||
|
{ id: "rider", label: "JetBrains Rider", program: externalToolsPlatform === "windows" ? "rider64.exe" : "rider" },
|
||||||
|
{ id: "clion", label: "CLion", program: externalToolsPlatform === "windows" ? "clion64.exe" : "clion" },
|
||||||
|
{ id: "rustrover", label: "RustRover", program: externalToolsPlatform === "windows" ? "rustrover64.exe" : "rustrover" },
|
||||||
|
{ id: "goland", label: "GoLand", program: externalToolsPlatform === "windows" ? "goland64.exe" : "goland" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const editor = (tool: ToolIdentity, args = ["{file}"]): ExternalToolPreset => ({ ...tool, args });
|
||||||
|
const codeDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--new-window", "--wait", "--diff", "{left}", "{right}"] });
|
||||||
|
const codeMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--new-window", "--wait", "--merge", "{ours}", "{theirs}", "{base}", "{result}"] });
|
||||||
|
const jetBrainsDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["diff", "{left}", "{right}"] });
|
||||||
|
const jetBrainsMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["merge", "{ours}", "{theirs}", "{base}", "{result}"] });
|
||||||
|
|
||||||
|
const commonEditors: ExternalToolPreset[] = [
|
||||||
|
...codeFamily.map((item) => editor(item, ["--new-window", "{file}"])),
|
||||||
|
editor({ id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed" }, ["--new", "{file}"]),
|
||||||
|
editor({ id: "sublime-text", label: "Sublime Text", program: externalToolsPlatform === "windows" ? "subl.exe" : "subl" }, ["--new-window", "{file}"]),
|
||||||
|
...jetBrains.map((item) => editor(item)),
|
||||||
|
editor({ id: "neovim", label: "Neovim", program: externalToolsPlatform === "windows" ? "nvim.exe" : "nvim" }),
|
||||||
|
editor({ id: "vim", label: "Vim", program: externalToolsPlatform === "windows" ? "gvim.exe" : "gvim" }),
|
||||||
|
editor({ id: "emacs", label: "Emacs", program: externalToolsPlatform === "windows" ? "runemacs.exe" : "emacs" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const platformEditors: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||||
|
? [
|
||||||
|
editor({ id: "notepad-plus-plus", label: "Notepad++", program: "notepad++.exe" }, ["-multiInst", "{file}"]),
|
||||||
|
{ ...editor({ id: "notepad", label: "Windows Notepad", program: "notepad.exe" }), builtIn: true },
|
||||||
|
]
|
||||||
|
: externalToolsPlatform === "macos"
|
||||||
|
? [
|
||||||
|
editor({ id: "nova", label: "Nova", program: "nova" }),
|
||||||
|
editor({ id: "textmate", label: "TextMate", program: "mate" }),
|
||||||
|
editor({ id: "bbedit", label: "BBEdit", program: "bbedit" }),
|
||||||
|
editor({ id: "xcode", label: "Xcode", program: "xed" }),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
editor({ id: "lapce", label: "Lapce", program: "lapce" }),
|
||||||
|
editor({ id: "kate", label: "Kate", program: "kate" }, ["--new", "{file}"]),
|
||||||
|
editor({ id: "gedit", label: "GNOME Text Editor", program: "gnome-text-editor" }),
|
||||||
|
editor({ id: "geany", label: "Geany", program: "geany" }, ["--new-instance", "{file}"]),
|
||||||
|
editor({ id: "helix", label: "Helix", program: "hx" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const dedicatedDiff: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||||
|
? [
|
||||||
|
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/solo", "/readonly", "{left}", "{right}"] },
|
||||||
|
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/s-", "/u", "/e", "/wl", "/wr", "{left}", "{right}"] },
|
||||||
|
{ id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "{left}", "{right}"] },
|
||||||
|
{ id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{left}", "{right}"] },
|
||||||
|
{ id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{left}", "{right}"] },
|
||||||
|
{ id: "araxis-merge", label: "Araxis Merge", program: "Compare.exe", args: ["/wait", "{left}", "{right}"] },
|
||||||
|
{ id: "tortoisegitmerge", label: "TortoiseGitMerge", program: "TortoiseGitMerge.exe", args: ["/base:{left}", "/mine:{right}"] },
|
||||||
|
]
|
||||||
|
: externalToolsPlatform === "macos"
|
||||||
|
? [
|
||||||
|
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{left}", "{right}"] },
|
||||||
|
{ id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--wait", "{left}", "{right}"] },
|
||||||
|
{ id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{left}", "{right}"] },
|
||||||
|
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] },
|
||||||
|
{ id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{left}", "{right}"] },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{left}", "{right}"] },
|
||||||
|
{ id: "meld", label: "Meld", program: "meld", args: ["--wait", "{left}", "{right}"] },
|
||||||
|
{ id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{left}", "{right}"] },
|
||||||
|
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] },
|
||||||
|
{ id: "kompare", label: "Kompare", program: "kompare", args: ["{left}", "{right}"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const dedicatedMerge: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||||
|
? [
|
||||||
|
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||||
|
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/s-", "/u", "/e", "{ours}", "{theirs}", "{base}", "/o", "{result}"] },
|
||||||
|
{ id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] },
|
||||||
|
{ id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] },
|
||||||
|
{ id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||||
|
{ id: "araxis-merge", label: "Araxis Merge", program: "Compare.exe", args: ["/wait", "{ours}", "{base}", "{theirs}", "{result}"] },
|
||||||
|
{ id: "tortoisegitmerge", label: "TortoiseGitMerge", program: "TortoiseGitMerge.exe", args: ["/base:{base}", "/theirs:{theirs}", "/mine:{ours}", "/merged:{result}"] },
|
||||||
|
]
|
||||||
|
: externalToolsPlatform === "macos"
|
||||||
|
? [
|
||||||
|
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||||
|
{ id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--merge", "--output", "{result}", "{base}", "{ours}", "{theirs}"] },
|
||||||
|
{ id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{ours}", "{base}", "{theirs}", "{result}"] },
|
||||||
|
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||||
|
{ id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{ours}", "{theirs}", "-ancestor", "{base}", "-merge", "{result}"] },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||||
|
{ id: "meld", label: "Meld", program: "meld", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] },
|
||||||
|
{ id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] },
|
||||||
|
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const terminalPresets: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||||
|
? [
|
||||||
|
{ id: "windows-terminal", label: "Windows Terminal", program: "wt.exe", args: ["-w", "new", "-d", "{repo}"] },
|
||||||
|
{ id: "powershell", label: "PowerShell 7", program: "pwsh.exe", args: ["-NoExit", "-WorkingDirectory", "{repo}"] },
|
||||||
|
{ id: "windows-powershell", label: "Windows PowerShell", program: "powershell.exe", args: ["-NoExit"], builtIn: true },
|
||||||
|
{ id: "git-bash", label: "Git Bash", program: "git-bash.exe", args: ["--login", "-i"] },
|
||||||
|
{ id: "cmd", label: "Command Prompt", program: "cmd.exe", args: ["/K"], builtIn: true },
|
||||||
|
{ id: "wezterm", label: "WezTerm", program: "wezterm-gui.exe", args: ["start", "--cwd", "{repo}"] },
|
||||||
|
{ id: "alacritty", label: "Alacritty", program: "alacritty.exe", args: ["--working-directory", "{repo}"] },
|
||||||
|
{ id: "kitty-terminal", label: "kitty", program: "kitty.exe", args: ["--directory", "{repo}"] },
|
||||||
|
]
|
||||||
|
: externalToolsPlatform === "macos"
|
||||||
|
? [
|
||||||
|
{ id: "terminal", label: "Terminal", program: "open", args: ["-n", "-a", "Terminal", "{repo}"], builtIn: true },
|
||||||
|
{ id: "iterm2", label: "iTerm2", program: "open", args: ["-n", "-a", "iTerm", "{repo}"] },
|
||||||
|
{ id: "warp", label: "Warp", program: "open", args: ["-n", "-a", "Warp", "{repo}"] },
|
||||||
|
{ id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] },
|
||||||
|
{ id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ id: "x-terminal", label: "System terminal", program: "x-terminal-emulator", args: ["--working-directory={repo}"] },
|
||||||
|
{ id: "gnome-terminal", label: "GNOME Terminal", program: "gnome-terminal", args: ["--working-directory={repo}"] },
|
||||||
|
{ id: "konsole", label: "Konsole", program: "konsole", args: ["--workdir", "{repo}"] },
|
||||||
|
{ id: "kitty-terminal", label: "kitty", program: "kitty", args: ["--directory", "{repo}"] },
|
||||||
|
{ id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] },
|
||||||
|
{ id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] },
|
||||||
|
{ id: "xfce-terminal", label: "Xfce Terminal", program: "xfce4-terminal", args: ["--working-directory={repo}"] },
|
||||||
|
{ id: "tilix", label: "Tilix", program: "tilix", args: ["--working-directory={repo}"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fileManagerPresets: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||||
|
? [
|
||||||
|
{ id: "explorer", label: "Windows Explorer", program: "explorer.exe", args: ["/n,", "{repo}"], builtIn: true },
|
||||||
|
{ id: "total-commander", label: "Total Commander", program: "TOTALCMD64.EXE", args: ["/N", "/T", "{repo}"] },
|
||||||
|
{ id: "directory-opus", label: "Directory Opus", program: "dopus.exe", args: ["{repo}"] },
|
||||||
|
{ id: "double-commander", label: "Double Commander", program: "doublecmd.exe", args: ["{repo}"] },
|
||||||
|
{ id: "freecommander", label: "FreeCommander XE", program: "FreeCommander.exe", args: ["/L={repo}"] },
|
||||||
|
{ id: "xyplorer", label: "XYplorer", program: "XYplorer.exe", args: ["{repo}"] },
|
||||||
|
]
|
||||||
|
: externalToolsPlatform === "macos"
|
||||||
|
? [
|
||||||
|
{ id: "finder", label: "Finder", program: "open", args: ["-n", "{repo}"], builtIn: true },
|
||||||
|
{ id: "forklift", label: "ForkLift", program: "forklift", args: ["{repo}"] },
|
||||||
|
{ id: "path-finder", label: "Path Finder", program: "Path Finder", args: ["{repo}"] },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ id: "system-file-manager", label: "System file manager", program: "xdg-open", args: ["{repo}"], builtIn: true },
|
||||||
|
{ id: "nautilus", label: "GNOME Files", program: "nautilus", args: ["{repo}"] },
|
||||||
|
{ id: "dolphin", label: "Dolphin", program: "dolphin", args: ["{repo}"] },
|
||||||
|
{ id: "thunar", label: "Thunar", program: "thunar", args: ["{repo}"] },
|
||||||
|
{ id: "nemo", label: "Nemo", program: "nemo", args: ["{repo}"] },
|
||||||
|
{ id: "pcmanfm", label: "PCManFM", program: "pcmanfm", args: ["{repo}"] },
|
||||||
|
{ id: "double-commander", label: "Double Commander", program: "doublecmd", args: ["{repo}"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const externalToolPresets: Record<ExternalToolKind, ExternalToolPreset[]> = {
|
||||||
|
editor: [...commonEditors, ...platformEditors],
|
||||||
|
diff: [...dedicatedDiff, ...codeFamily.map(codeDiff), { id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed", args: ["--new", "--diff", "{left}", "{right}"] }, ...jetBrains.map(jetBrainsDiff)],
|
||||||
|
merge: [...dedicatedMerge, ...codeFamily.map(codeMerge), ...jetBrains.map(jetBrainsMerge)],
|
||||||
|
terminal: terminalPresets,
|
||||||
|
fileManager: fileManagerPresets,
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectedPreset(kind: ExternalToolKind, id: string, detectedTools: DetectedExternalTool[]): DetectedExternalTool | undefined {
|
||||||
|
return detectedTools.find((tool) => tool.id === id && tool.kinds.includes(kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExternalToolPresetAvailable(kind: ExternalToolKind, preset: ExternalToolPreset, detectedTools: DetectedExternalTool[]): boolean {
|
||||||
|
return preset.builtIn === true || detectedPreset(kind, preset.id, detectedTools) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyExternalToolPreset(kind: ExternalToolKind, id: string, detectedTools: DetectedExternalTool[] = []): ExternalToolSetting {
|
||||||
|
const value = externalToolPresets[kind].find((item) => item.id === id) ?? externalToolPresets[kind][0];
|
||||||
|
const detected = detectedPreset(kind, value.id, detectedTools);
|
||||||
|
return { preset: value.id, program: detected?.program ?? value.program, args: [...value.args] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferredPreset(kind: ExternalToolKind, detectedTools: DetectedExternalTool[]): ExternalToolPreset {
|
||||||
|
return externalToolPresets[kind].find((item) => isExternalToolPresetAvailable(kind, item, detectedTools))
|
||||||
|
?? externalToolPresets[kind][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultExternalToolsSettings(detectedTools: DetectedExternalTool[] = []): ExternalToolsSettings {
|
||||||
|
return {
|
||||||
|
editor: applyExternalToolPreset("editor", preferredPreset("editor", detectedTools).id, detectedTools),
|
||||||
|
diff: applyExternalToolPreset("diff", preferredPreset("diff", detectedTools).id, detectedTools),
|
||||||
|
merge: applyExternalToolPreset("merge", preferredPreset("merge", detectedTools).id, detectedTools),
|
||||||
|
terminal: applyExternalToolPreset("terminal", preferredPreset("terminal", detectedTools).id, detectedTools),
|
||||||
|
fileManager: applyExternalToolPreset("fileManager", preferredPreset("fileManager", detectedTools).id, detectedTools),
|
||||||
|
diffOpenMode: "gitty",
|
||||||
|
mergeOpenMode: "gitty",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseSetting(kind: ExternalToolKind, value: unknown, fallback: ExternalToolSetting): ExternalToolSetting {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback;
|
||||||
|
const candidate = value as Partial<ExternalToolSetting>;
|
||||||
|
const program = typeof candidate.program === "string" && candidate.program.trim() ? candidate.program : fallback.program;
|
||||||
|
const storedArgs = Array.isArray(candidate.args)
|
||||||
|
? candidate.args.filter((item): item is string => typeof item === "string").slice(0, 64)
|
||||||
|
: fallback.args;
|
||||||
|
const preset = typeof candidate.preset === "string" && candidate.preset ? candidate.preset : fallback.preset;
|
||||||
|
// Preset arguments are application-owned and can be upgraded safely. Editing either
|
||||||
|
// the executable or arguments in Settings changes the preset to "custom", which keeps
|
||||||
|
// genuine user commands untouched.
|
||||||
|
const currentPreset = preset === "custom"
|
||||||
|
? undefined
|
||||||
|
: externalToolPresets[kind].find((item) => item.id === preset);
|
||||||
|
const args = currentPreset ? [...currentPreset.args] : storedArgs;
|
||||||
|
return { preset, program, args };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normaliseExternalToolsSettings(value: unknown): ExternalToolsSettings {
|
||||||
|
const defaults = defaultExternalToolsSettings();
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return defaults;
|
||||||
|
const candidate = value as Partial<ExternalToolsSettings>;
|
||||||
|
return {
|
||||||
|
editor: normaliseSetting("editor", candidate.editor, defaults.editor),
|
||||||
|
diff: normaliseSetting("diff", candidate.diff, defaults.diff),
|
||||||
|
merge: normaliseSetting("merge", candidate.merge, defaults.merge),
|
||||||
|
terminal: normaliseSetting("terminal", candidate.terminal, defaults.terminal),
|
||||||
|
fileManager: normaliseSetting("fileManager", candidate.fileManager, defaults.fileManager),
|
||||||
|
diffOpenMode: normaliseOpenMode(candidate.diffOpenMode, defaults.diffOpenMode),
|
||||||
|
mergeOpenMode: normaliseOpenMode(candidate.mergeOpenMode, defaults.mergeOpenMode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseOpenMode(value: unknown, fallback: ToolOpenMode): ToolOpenMode {
|
||||||
|
return value === "gitty" || value === "external" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDetectedExternalToolPrograms(settings: ExternalToolsSettings, detectedTools: DetectedExternalTool[]): ExternalToolsSettings {
|
||||||
|
const resolved = structuredClone(settings);
|
||||||
|
for (const kind of ["editor", "diff", "merge", "terminal", "fileManager"] as ExternalToolKind[]) {
|
||||||
|
if (resolved[kind].preset === "custom") continue;
|
||||||
|
const detected = detectedPreset(kind, resolved[kind].preset, detectedTools);
|
||||||
|
if (detected) resolved[kind].program = detected.program;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function externalToolDisplayName(kind: ExternalToolKind, setting: ExternalToolSetting, detectedTools: DetectedExternalTool[] = []): string {
|
||||||
|
if (setting.preset === "custom") return setting.program.split(/[\\/]/).pop() || setting.program;
|
||||||
|
return detectedPreset(kind, setting.preset, detectedTools)?.label
|
||||||
|
?? externalToolPresets[kind].find((item) => item.id === setting.preset)?.label
|
||||||
|
?? setting.program;
|
||||||
|
}
|
||||||
+313
-37
@@ -1,20 +1,37 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { tracedInvoke as invoke } from "./telemetry";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CommitAiLocalProfile,
|
AiReviewResult,
|
||||||
|
AiCommitPlan,
|
||||||
|
BisectState,
|
||||||
CommitAiProvider,
|
CommitAiProvider,
|
||||||
CommitAiStatus,
|
CloneOptions,
|
||||||
ConflictFile,
|
ConflictFile,
|
||||||
|
DetectedExternalTool,
|
||||||
|
ExternalToolCommand,
|
||||||
|
ExternalDiffScope,
|
||||||
GitBlameResult,
|
GitBlameResult,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
GitCommitComparison,
|
GitCommitComparison,
|
||||||
|
GitIgnoreKind,
|
||||||
|
GitIntegrationProvider,
|
||||||
|
GitIntegrationRepository,
|
||||||
|
IntegrationReviewRequest,
|
||||||
|
IntegrationReviewAction,
|
||||||
|
GitLfsStatus,
|
||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
|
GitRemote,
|
||||||
|
MergeStrategy,
|
||||||
|
PullStrategy,
|
||||||
|
RebaseCommit,
|
||||||
|
RebasePlanItem,
|
||||||
|
ReflogEntry,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStash,
|
GitStash,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
GitTag,
|
GitTag,
|
||||||
LocalModelOption,
|
GitWorktree,
|
||||||
PatchApplyAction,
|
PatchApplyAction,
|
||||||
RepositoryBundle,
|
RepositoryBundle,
|
||||||
StoredCredential,
|
StoredCredential,
|
||||||
@@ -24,6 +41,10 @@ export function openRepository(path: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("open_repository", { path });
|
return invoke<GitStatus>("open_repository", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function initRepository(path: string, initialBranch = "main"): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("init_repository", { path, initialBranch });
|
||||||
|
}
|
||||||
|
|
||||||
export function openRepoInExplorer(path: string): Promise<void> {
|
export function openRepoInExplorer(path: string): Promise<void> {
|
||||||
return invoke<void>("open_repo_in_explorer", { path });
|
return invoke<void>("open_repo_in_explorer", { path });
|
||||||
}
|
}
|
||||||
@@ -32,6 +53,46 @@ export function openRepositoryFile(path: string, file: string): Promise<void> {
|
|||||||
return invoke<void>("open_repository_file", { path, file });
|
return invoke<void>("open_repository_file", { path, file });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function detectExternalTools(): Promise<DetectedExternalTool[]> {
|
||||||
|
return invoke<DetectedExternalTool[]>("detect_external_tools");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 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> {
|
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
|
||||||
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
||||||
}
|
}
|
||||||
@@ -43,6 +104,7 @@ export function cloneRepository(
|
|||||||
username?: string,
|
username?: string,
|
||||||
password?: string,
|
password?: string,
|
||||||
commitLimit = 100,
|
commitLimit = 100,
|
||||||
|
options: CloneOptions = { branch: null, blobless: false, customFlags: "", shallowDepth: null, shallowSince: null, sparse: false, sparsePaths: [] },
|
||||||
): Promise<RepositoryBundle> {
|
): Promise<RepositoryBundle> {
|
||||||
return invoke<RepositoryBundle>("clone_repository", {
|
return invoke<RepositoryBundle>("clone_repository", {
|
||||||
remoteUrl,
|
remoteUrl,
|
||||||
@@ -51,6 +113,13 @@ export function cloneRepository(
|
|||||||
username: username ?? null,
|
username: username ?? null,
|
||||||
password: password ?? null,
|
password: password ?? null,
|
||||||
commitLimit,
|
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 });
|
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
|
// Sets the taskbar icon badge to ahead + behind + changed status files (0 clears it). Windows only — a no-op on
|
||||||
// other platforms, since Windows has no native numeric badge to fall back to.
|
// other platforms, since Windows has no native numeric badge to fall back to.
|
||||||
export function setSyncBadge(ahead: number, behind: number, changes: number): Promise<void> {
|
export function setSyncBadge(ahead: number, behind: number, changes: number): Promise<void> {
|
||||||
@@ -68,6 +182,20 @@ export function listBranches(path: string): Promise<GitBranch[]> {
|
|||||||
return invoke<GitBranch[]>("list_branches", { path });
|
return invoke<GitBranch[]>("list_branches", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listRemotes(path: string): Promise<GitRemote[]> { return invoke("list_remotes", { path }); }
|
||||||
|
export function addRemote(path: string, name: string, url: string): Promise<GitRemote[]> { return invoke("add_remote", { path, name, url }); }
|
||||||
|
export function updateRemote(path: string, name: string, url: string): Promise<GitRemote[]> { return invoke("update_remote", { path, name, url }); }
|
||||||
|
export function removeRemote(path: string, name: string): Promise<GitRemote[]> {
|
||||||
|
console.log("remove_remote")
|
||||||
|
return invoke("remove_remote", { path, name }); }
|
||||||
|
export function setBranchUpstream(path: string, branch: string, upstream?: string): Promise<GitStatus> { return invoke("set_branch_upstream", { path, branch, upstream: upstream || null }); }
|
||||||
|
export function deleteRemoteBranch(path: string, remote: string, branch: string, username?: string, password?: string): Promise<GitStatus> {
|
||||||
|
return invoke("delete_remote_branch", { path, remote, branch, username: username ?? null, password: password ?? null });
|
||||||
|
}
|
||||||
|
export function deleteRemoteBranches(path: string, remote: string, branches: string[], username?: string, password?: string): Promise<GitStatus> {
|
||||||
|
return invoke("delete_remote_branches", { path, remote, branches, username: username ?? null, password: password ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
export function listStashes(path: string): Promise<GitStash[]> {
|
export function listStashes(path: string): Promise<GitStash[]> {
|
||||||
return invoke<GitStash[]>("list_stashes", { path });
|
return invoke<GitStash[]>("list_stashes", { path });
|
||||||
}
|
}
|
||||||
@@ -92,10 +220,78 @@ export function renameBranch(
|
|||||||
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
|
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renameRemoteBranch(
|
||||||
|
path: string,
|
||||||
|
remote: string,
|
||||||
|
oldBranch: string,
|
||||||
|
newBranch: string,
|
||||||
|
username?: string,
|
||||||
|
password?: string,
|
||||||
|
): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("rename_remote_branch", {
|
||||||
|
path,
|
||||||
|
remote,
|
||||||
|
oldBranch,
|
||||||
|
newBranch,
|
||||||
|
username: username ?? null,
|
||||||
|
password: password ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
|
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listWorktrees(path: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("list_worktrees", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addWorktree(
|
||||||
|
path: string,
|
||||||
|
worktreePath: string,
|
||||||
|
options: {
|
||||||
|
branch?: string;
|
||||||
|
newBranch?: string;
|
||||||
|
startPoint?: string;
|
||||||
|
detached?: boolean;
|
||||||
|
lock?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("add_worktree", {
|
||||||
|
path,
|
||||||
|
worktreePath,
|
||||||
|
branch: options.branch ?? null,
|
||||||
|
newBranch: options.newBranch ?? null,
|
||||||
|
startPoint: options.startPoint ?? null,
|
||||||
|
detached: options.detached ?? false,
|
||||||
|
lock: options.lock ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeWorktree(path: string, worktreePath: string, force = false): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("remove_worktree", { path, worktreePath, force });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveWorktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("move_worktree", { path, worktreePath, destination });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockWorktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("lock_worktree", { path, worktreePath, reason: reason?.trim() || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlockWorktree(path: string, worktreePath: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("unlock_worktree", { path, worktreePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneWorktrees(path: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("prune_worktrees", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function repairWorktree(path: string, worktreePath: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("repair_worktree", { path, worktreePath });
|
||||||
|
}
|
||||||
|
|
||||||
export function listTags(path: string): Promise<GitTag[]> {
|
export function listTags(path: string): Promise<GitTag[]> {
|
||||||
return invoke<GitTag[]>("list_tags", { path });
|
return invoke<GitTag[]>("list_tags", { path });
|
||||||
}
|
}
|
||||||
@@ -147,6 +343,14 @@ export function unstageFiles(path: string, files: string[]): Promise<GitStatus>
|
|||||||
return invoke<GitStatus>("unstage_files", { path, files });
|
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(
|
export function restoreFiles(
|
||||||
path: string,
|
path: string,
|
||||||
files: string[],
|
files: string[],
|
||||||
@@ -188,11 +392,13 @@ export function stashPush(
|
|||||||
path: string,
|
path: string,
|
||||||
message?: string,
|
message?: string,
|
||||||
includeUntracked = true,
|
includeUntracked = true,
|
||||||
|
paths?: string[],
|
||||||
): Promise<GitStatus> {
|
): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("stash_push", {
|
return invoke<GitStatus>("stash_push", {
|
||||||
path,
|
path,
|
||||||
message: message?.trim() ? message.trim() : null,
|
message: message?.trim() ? message.trim() : null,
|
||||||
includeUntracked,
|
includeUntracked,
|
||||||
|
paths: paths?.length ? paths : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,22 +414,9 @@ export function stashDrop(path: string, selector: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("stash_drop", { path, selector });
|
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 {
|
export interface CommitAiGenerateOptions {
|
||||||
notes?: string;
|
notes?: string;
|
||||||
provider: CommitAiProvider;
|
provider: CommitAiProvider;
|
||||||
localProfile?: CommitAiLocalProfile;
|
|
||||||
model?: string;
|
model?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
@@ -234,48 +427,108 @@ export function commitAiGenerate(path: string, options: CommitAiGenerateOptions)
|
|||||||
path,
|
path,
|
||||||
notes: options.notes,
|
notes: options.notes,
|
||||||
provider: options.provider,
|
provider: options.provider,
|
||||||
localProfile: options.localProfile,
|
|
||||||
model: options.model,
|
model: options.model,
|
||||||
apiKey: options.apiKey,
|
apiKey: options.apiKey,
|
||||||
baseUrl: options.baseUrl,
|
baseUrl: options.baseUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
|
export function commitAiReview(path: string, options: CommitAiGenerateOptions): Promise<AiReviewResult> {
|
||||||
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
|
return invoke<AiReviewResult>("commit_ai_review", {
|
||||||
|
path,
|
||||||
|
provider: options.provider,
|
||||||
|
model: options.model,
|
||||||
|
apiKey: options.apiKey,
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchRemote(path: string, username?: string, password?: string): Promise<GitStatus> {
|
export function commitAiSplit(path: string, options: CommitAiGenerateOptions): Promise<AiCommitPlan> {
|
||||||
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null });
|
return invoke<AiCommitPlan>("commit_ai_split", {
|
||||||
|
path,
|
||||||
|
provider: options.provider,
|
||||||
|
model: options.model,
|
||||||
|
apiKey: options.apiKey,
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function push(path: string, username?: string, password?: string): Promise<GitStatus> {
|
export function pull(
|
||||||
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null });
|
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> {
|
export function fetchRemote(path: string, username?: string, password?: string, prune = false, remote?: string): Promise<GitStatus> {
|
||||||
return invoke<string | null>("get_remote_url", { path });
|
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null, prune, remote: remote || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function push(path: string, username?: string, password?: string, forceWithLease = false, remote?: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRemoteUrl(path: string, remote?: string, push = false): Promise<string | null> {
|
||||||
|
return invoke<string | null>("get_remote_url", { path, remote: remote || null, push });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function credLoad(key: string): Promise<StoredCredential | null> {
|
export function credLoad(key: string): Promise<StoredCredential | null> {
|
||||||
return invoke<StoredCredential | null>("cred_load", { key });
|
return invoke<StoredCredential | null>("cred_load", { key });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function credSave(
|
export function credSave(key: string, username: string, password: string, mode: "credentials" | "token" = "credentials"): Promise<void> {
|
||||||
key: string,
|
return invoke<void>("cred_save", { key, username, password, mode });
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
expiresAt: string | null,
|
|
||||||
): Promise<void> {
|
|
||||||
return invoke<void>("cred_save", { key, username, password, expiresAt });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function credDelete(key: string): Promise<void> {
|
export function credDelete(key: string): Promise<void> {
|
||||||
return invoke<void>("cred_delete", { key });
|
return invoke<void>("cred_delete", { key });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listCommits(path: string, limit = 100): Promise<GitCommit[]> {
|
export function listCommits(path: string, limit = 100, skip = 0): Promise<GitCommit[]> {
|
||||||
return invoke<GitCommit[]>("list_commits", { path, limit });
|
return invoke<GitCommit[]>("list_commits", { path, limit, skip });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCommitNote(path: string, commit: string): Promise<string | null> {
|
||||||
|
return invoke<string | null>("get_commit_note", { path, commit });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCommitNote(path: string, commit: string, note: string): Promise<void> {
|
||||||
|
return invoke<void>("set_commit_note", { path, commit, note });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCommitNote(path: string, commit: string): Promise<void> {
|
||||||
|
return invoke<void>("delete_commit_note", { path, commit });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCommitNotes(path: string, remote: string, username?: string, password?: string): Promise<void> {
|
||||||
|
return invoke<void>("fetch_commit_notes", {
|
||||||
|
path,
|
||||||
|
remote,
|
||||||
|
username: username ?? null,
|
||||||
|
password: password ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushCommitNotes(path: string, remote: string, username?: string, password?: string): Promise<void> {
|
||||||
|
return invoke<void>("push_commit_notes", {
|
||||||
|
path,
|
||||||
|
remote,
|
||||||
|
username: username ?? null,
|
||||||
|
password: password ?? null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {
|
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {
|
||||||
@@ -290,9 +543,12 @@ export function restoreFileFromCommit(
|
|||||||
return invoke<GitStatus>("restore_file_from_commit", { path, commit, file });
|
return invoke<GitStatus>("restore_file_from_commit", { path, commit, file });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeBranch(path: string, branch: string): Promise<GitStatus> {
|
export function mergeBranch(path: string, branch: string, strategy: MergeStrategy = "default"): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("merge_branch", { path, branch });
|
return invoke<GitStatus>("merge_branch", { path, branch, strategy });
|
||||||
}
|
}
|
||||||
|
export function mergeContinue(path: string): Promise<GitStatus> { return invoke("merge_continue", { path }); }
|
||||||
|
export function mergeAbort(path: string): Promise<GitStatus> { return invoke("merge_abort", { path }); }
|
||||||
|
export function revertCommit(path: string, commit: string): Promise<GitStatus> { return invoke("revert_commit", { path, commit }); }
|
||||||
|
|
||||||
export function rebaseBranch(path: string, branch: string): Promise<GitStatus> {
|
export function rebaseBranch(path: string, branch: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("rebase_branch", { path, branch });
|
return invoke<GitStatus>("rebase_branch", { path, branch });
|
||||||
@@ -306,6 +562,26 @@ export function rebaseAbort(path: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("rebase_abort", { path });
|
return invoke<GitStatus>("rebase_abort", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listInteractiveRebaseCommits(path: string, base: string): Promise<RebaseCommit[]> {
|
||||||
|
return invoke<RebaseCommit[]>("list_interactive_rebase_commits", { path, base });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startInteractiveRebase(
|
||||||
|
path: string,
|
||||||
|
base: string,
|
||||||
|
plan: RebasePlanItem[],
|
||||||
|
): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("start_interactive_rebase", { path, base, plan });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listReflog(path: string, limit = 250): Promise<ReflogEntry[]> {
|
||||||
|
return invoke<ReflogEntry[]>("list_reflog", { path, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreReflogEntry(path: string, commit: string, branch: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("restore_reflog_entry", { path, commit, branch });
|
||||||
|
}
|
||||||
|
|
||||||
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
|
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
|
||||||
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
|
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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"
|
| "conflicted"
|
||||||
| "unknown";
|
| "unknown";
|
||||||
|
|
||||||
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
export type GitIgnoreKind = "file" | "extension" | "folder";
|
||||||
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
|
||||||
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
|
||||||
|
|
||||||
export interface CommitAiStatus {
|
export type CommitAiProvider = "openai" | "anthropic" | "custom";
|
||||||
phase: CommitAiPhase;
|
export type AppTheme = "system" | "light" | "dark";
|
||||||
model_id: string | null;
|
export type AppAppearance = "modern" | "classic" | "custom";
|
||||||
error: string | null;
|
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;
|
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;
|
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 {
|
export interface AiSettings {
|
||||||
provider: CommitAiProvider;
|
provider: CommitAiProvider;
|
||||||
localModelId: string;
|
|
||||||
localProfile: CommitAiLocalProfile;
|
|
||||||
openaiModel: string;
|
openaiModel: string;
|
||||||
anthropicModel: string;
|
anthropicModel: string;
|
||||||
customBaseUrl: string;
|
customBaseUrl: string;
|
||||||
@@ -38,6 +147,36 @@ export interface AnalyticsSettings {
|
|||||||
noticeSeen: boolean;
|
noticeSeen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExternalToolCommand {
|
||||||
|
program: string;
|
||||||
|
args: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalToolSetting extends ExternalToolCommand {
|
||||||
|
preset: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExternalToolKind = "editor" | "diff" | "merge" | "terminal" | "fileManager";
|
||||||
|
export type ToolOpenMode = "gitty" | "external";
|
||||||
|
export type ExternalDiffScope = "head" | "staged" | "unstaged";
|
||||||
|
|
||||||
|
export interface ExternalToolsSettings {
|
||||||
|
editor: ExternalToolSetting;
|
||||||
|
diff: ExternalToolSetting;
|
||||||
|
merge: ExternalToolSetting;
|
||||||
|
terminal: ExternalToolSetting;
|
||||||
|
fileManager: ExternalToolSetting;
|
||||||
|
diffOpenMode: ToolOpenMode;
|
||||||
|
mergeOpenMode: ToolOpenMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetectedExternalTool {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
program: string;
|
||||||
|
kinds: ExternalToolKind[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitStatus {
|
export interface GitStatus {
|
||||||
repo_path: string;
|
repo_path: string;
|
||||||
current_branch: string | null;
|
current_branch: string | null;
|
||||||
@@ -48,6 +187,21 @@ export interface GitStatus {
|
|||||||
clean: boolean;
|
clean: boolean;
|
||||||
rebase_in_progress: boolean;
|
rebase_in_progress: boolean;
|
||||||
cherry_pick_in_progress: boolean;
|
cherry_pick_in_progress: boolean;
|
||||||
|
merge_in_progress: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitRemote { name: string; fetch_url: string; push_url: string; }
|
||||||
|
export type PullStrategy = "merge" | "rebase" | "ff-only";
|
||||||
|
export type MergeStrategy = "default" | "squash" | "ff-only" | "no-ff";
|
||||||
|
|
||||||
|
export interface CloneOptions {
|
||||||
|
branch: string | null;
|
||||||
|
blobless: boolean;
|
||||||
|
customFlags: string;
|
||||||
|
shallowDepth: number | null;
|
||||||
|
shallowSince: string | null;
|
||||||
|
sparse: boolean;
|
||||||
|
sparsePaths: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitFileStatus {
|
export interface GitFileStatus {
|
||||||
@@ -63,6 +217,25 @@ export interface GitBranch {
|
|||||||
name: string;
|
name: string;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
remote: boolean;
|
remote: boolean;
|
||||||
|
upstream: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitWorktree {
|
||||||
|
path: string;
|
||||||
|
head: string | null;
|
||||||
|
short_head: string | null;
|
||||||
|
branch: string | null;
|
||||||
|
bare: boolean;
|
||||||
|
detached: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
lock_reason: string | null;
|
||||||
|
prunable: boolean;
|
||||||
|
prune_reason: string | null;
|
||||||
|
missing: boolean;
|
||||||
|
is_main: boolean;
|
||||||
|
is_current: boolean;
|
||||||
|
clean: boolean;
|
||||||
|
changed_files: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitTag {
|
export interface GitTag {
|
||||||
@@ -93,6 +266,25 @@ export interface GitCommit {
|
|||||||
refs: string[];
|
refs: string[];
|
||||||
parents: string[];
|
parents: string[];
|
||||||
files: GitCommitFile[];
|
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 {
|
export interface GitCommitFile {
|
||||||
@@ -107,6 +299,34 @@ export interface GitRepositoryFile {
|
|||||||
status: FileStatusKind | null;
|
status: FileStatusKind | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitLfsPattern {
|
||||||
|
pattern: string;
|
||||||
|
source: string;
|
||||||
|
lockable: boolean;
|
||||||
|
tracked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitLfsFile {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
checkout: boolean;
|
||||||
|
downloaded: boolean;
|
||||||
|
oid_type: string;
|
||||||
|
oid: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitLfsStatus {
|
||||||
|
available: boolean;
|
||||||
|
bundled: boolean;
|
||||||
|
version: string | null;
|
||||||
|
filters_configured: boolean;
|
||||||
|
hook_installed: boolean;
|
||||||
|
repository_uses_lfs: boolean;
|
||||||
|
patterns: GitLfsPattern[];
|
||||||
|
files: GitLfsFile[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RepositoryBundle {
|
export interface RepositoryBundle {
|
||||||
status: GitStatus;
|
status: GitStatus;
|
||||||
branches: GitBranch[];
|
branches: GitBranch[];
|
||||||
@@ -114,6 +334,7 @@ export interface RepositoryBundle {
|
|||||||
stashes: GitStash[];
|
stashes: GitStash[];
|
||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
files: GitRepositoryFile[];
|
files: GitRepositoryFile[];
|
||||||
|
warning: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitDiffFile {
|
export interface GitDiffFile {
|
||||||
@@ -197,8 +418,33 @@ export interface GitBlameResult {
|
|||||||
lines: GitBlameLine[];
|
lines: GitBlameLine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RebaseAction = "pick" | "reword" | "squash" | "fixup" | "drop";
|
||||||
|
|
||||||
|
export interface RebaseCommit {
|
||||||
|
hash: string;
|
||||||
|
short_hash: string;
|
||||||
|
summary: string;
|
||||||
|
author_name: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RebasePlanItem {
|
||||||
|
hash: string;
|
||||||
|
action: RebaseAction;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReflogEntry {
|
||||||
|
hash: string;
|
||||||
|
short_hash: string;
|
||||||
|
selector: string;
|
||||||
|
action: string;
|
||||||
|
author_name: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoredCredential {
|
export interface StoredCredential {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
expiresAt?: string | null;
|
mode?: "credentials" | "token";
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -2,13 +2,33 @@ import { mount } from "svelte";
|
|||||||
|
|
||||||
import App from "./App.svelte";
|
import App from "./App.svelte";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
import { installGlobalErrorTelemetry } from "./lib/telemetry";
|
||||||
|
|
||||||
|
const THEME_KEY = "gitlite.theme.v1";
|
||||||
const target = document.getElementById("app");
|
const target = document.getElementById("app");
|
||||||
|
|
||||||
|
installGlobalErrorTelemetry();
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw new Error("App target element was not found.");
|
throw new Error("App target element was not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedTheme = localStorage.getItem(THEME_KEY);
|
||||||
|
const preference = storedTheme === "light" || storedTheme === "dark" || storedTheme === "system"
|
||||||
|
? storedTheme
|
||||||
|
: "system";
|
||||||
|
const resolved = preference === "system" && window.matchMedia("(prefers-color-scheme: light)").matches
|
||||||
|
? "light"
|
||||||
|
: preference === "system"
|
||||||
|
? "dark"
|
||||||
|
: preference;
|
||||||
|
document.documentElement.dataset.themePreference = preference;
|
||||||
|
document.documentElement.dataset.theme = resolved;
|
||||||
|
} catch {
|
||||||
|
// Storage is best-effort; the CSS system theme fallback still applies.
|
||||||
|
}
|
||||||
|
|
||||||
const app = mount(App, { target });
|
const app = mount(App, { target });
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
Reference in New Issue
Block a user