Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
0ba7169efd | ||
|
|
c087171735 | ||
|
|
2d3c30b553 | ||
|
|
8409556158 | ||
|
|
db60f57544 | ||
|
|
cb5865094f | ||
|
|
c74db60daf | ||
|
|
812027abcf | ||
|
|
c8d5553214 | ||
|
|
8d9dedf93c | ||
|
|
2c9b8adb9e | ||
|
|
e6d9e60b8f | ||
|
|
8627012d93 | ||
|
|
a21fa9e54c | ||
|
|
adfb5f4158 | ||
|
|
6f3f438f15 | ||
|
|
b495313a51 | ||
|
|
eed7325719 | ||
|
|
51482c497f | ||
|
|
8d0a7b4c71 | ||
|
|
a26e164d95 | ||
|
|
784f133abd | ||
|
|
3021c7c345 | ||
|
|
e0a77607f5 | ||
|
|
2383abb496 | ||
|
|
a7faaceb83 | ||
|
|
ea49380138 | ||
|
|
a072bba2e6 | ||
|
|
c45a43b866 | ||
|
|
7e7c1b0b69 | ||
|
|
218907a946 | ||
|
|
0d4f9ca630 | ||
|
|
44be7afff2 | ||
|
|
8793d3de40 | ||
|
|
cc179e9eef | ||
|
|
61a22987ba | ||
|
|
ba2065d3c4 | ||
|
|
c9a93f1d0a | ||
|
|
de6f497e35 | ||
|
|
7589a38564 | ||
|
|
5e46995c89 | ||
|
|
82f738c15e | ||
|
|
6f1771f8b6 | ||
|
|
a57e52a38b | ||
|
|
4befe8b189 | ||
|
|
9d0b9de142 | ||
|
|
e503786211 | ||
|
|
d4ac449074 | ||
|
|
a67663f3c5 | ||
|
|
9e48727fce | ||
|
|
a09335b729 | ||
|
|
da05a8461f | ||
|
|
21366cb26b | ||
|
|
a6312af6d7 | ||
|
|
899f024a4f | ||
|
|
aa61199b43 | ||
|
|
98f3902839 | ||
|
|
d1d3604a6d | ||
|
|
ce0cbc0786 | ||
|
|
0bad722c7a | ||
|
|
247c118bdf | ||
|
|
c59cb412dd | ||
|
|
1b83429c06 | ||
|
|
3e2885f64d | ||
|
|
eac0f059fa | ||
|
|
aaa4228b82 | ||
|
|
e5c26ea502 | ||
|
|
38b4f98233 | ||
|
|
d592894f28 | ||
|
|
9ee2f71dc3 | ||
|
|
283779610a | ||
|
|
d33f576212 | ||
|
|
a3fd8f1ce8 | ||
|
|
de06e7a15d | ||
|
|
0105e0d46e | ||
|
|
0732f9ca6d | ||
|
|
fcaaa770d6 | ||
|
|
379350b9ee | ||
|
|
5f7899bcee | ||
|
|
b646a2c647 | ||
|
|
32497d53df | ||
|
|
6365e164aa | ||
|
|
f6af156fbd | ||
|
|
33cef059e8 | ||
|
|
9a4c6e5b9b | ||
|
|
c2d7fefb47 | ||
|
|
ede2e46d50 | ||
|
|
3c4425a408 | ||
|
|
1d67312ee4 | ||
|
|
201bb90bf7 | ||
|
|
9c371ec520 | ||
|
|
835bfae254 | ||
|
|
2bacd473fc | ||
|
|
65e5508d4b | ||
|
|
1576f61234 | ||
|
|
08de211ba0 | ||
|
|
7001616779 | ||
|
|
9228cd1e08 | ||
|
|
399ba80550 | ||
|
|
b69538de85 | ||
|
|
7b90f4bcc2 | ||
|
|
96eb8c109c | ||
|
|
276dfbab07 | ||
|
|
883cf3ebd1 | ||
|
|
6e283ddf83 | ||
|
|
979d5aed80 | ||
|
|
791d686c48 | ||
|
|
7ca6abf962 |
@@ -71,7 +71,69 @@
|
|||||||
"Bash(echo \"EXIT:$?\")",
|
"Bash(echo \"EXIT:$?\")",
|
||||||
"Bash(ls target/)",
|
"Bash(ls target/)",
|
||||||
"Bash(rustup target *)",
|
"Bash(rustup target *)",
|
||||||
"Bash(echo \"exit code: $?\")"
|
"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)",
|
||||||
|
"Bash(pkg-config --exists openssl)",
|
||||||
|
"Bash(sudo apt-get install -y libssl-dev pkg-config)",
|
||||||
|
"Bash(dpkg -L libssl3t64)",
|
||||||
|
"Bash(grep *)",
|
||||||
|
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/**)",
|
||||||
|
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-runtime-wry-2.11.4/src/**)",
|
||||||
|
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/window/**)",
|
||||||
|
"WebSearch",
|
||||||
|
"Bash(node -p \"require\\('./package.json'\\).version\")",
|
||||||
|
"Bash(bash -n PKGBUILD)",
|
||||||
|
"Bash(makepkg --printsrcinfo)",
|
||||||
|
"Bash(npx tauri *)",
|
||||||
|
"Bash(echo \"---exit code:$?---\")",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite status --short)",
|
||||||
|
"Bash(makepkg -f --noconfirm)",
|
||||||
|
"Bash(tar -tf gitty-2026.7.19-1-x86_64.pkg.tar.zst)",
|
||||||
|
"Bash(cp /mnt/data/Development/GitLite/PKGBUILD .)",
|
||||||
|
"Bash(sed -i 's/^pkgrel=1/pkgrel=3/' PKGBUILD)",
|
||||||
|
"Bash(node -e \"const fs=require\\('fs'\\); const version='2026.8.1'; const pkgbuild=fs.readFileSync\\('PKGBUILD','utf8'\\).replace\\(/^pkgver=.*\\\\$/m, 'pkgver='+version\\).replace\\(/^pkgrel=.*\\\\$/m, 'pkgrel=1'\\); fs.writeFileSync\\('PKGBUILD', pkgbuild\\);\")",
|
||||||
|
"Bash(rm PKGBUILD)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite stash)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite stash pop)",
|
||||||
|
"Bash(command -v docker)",
|
||||||
|
"Read(//home/christoph/.docker/**)",
|
||||||
|
"Bash(docker info *)",
|
||||||
|
"Bash(sudo -n test -f /root/.docker/config.json)",
|
||||||
|
"Bash(systemctl list-units *)",
|
||||||
|
"Bash(find / -maxdepth 4 -iname \"act_runner*\")",
|
||||||
|
"Bash(find / -maxdepth 6 -iname \"config.yaml\" -path \"*runner*\")",
|
||||||
|
"Read(//home/**)",
|
||||||
|
"Bash(getent passwd *)",
|
||||||
|
"Bash(docker context *)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite log --oneline -1 -- PKGBUILD)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite show --stat HEAD -- PKGBUILD)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite log --oneline -10)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite status)",
|
||||||
|
"Bash(git *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ on:
|
|||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-tauri:
|
publish-windows:
|
||||||
|
name: Build and publish Windows installer
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
environment: production
|
environment: production
|
||||||
@@ -30,20 +31,7 @@ jobs:
|
|||||||
run:
|
run:
|
||||||
working-directory: GitLite
|
working-directory: GitLite
|
||||||
|
|
||||||
strategy:
|
runs-on: windows-latest
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
# - platform: 'macos-latest'
|
|
||||||
# args: '--target aarch64-apple-darwin'
|
|
||||||
# - platform: 'macos-latest'
|
|
||||||
# args: '--target x86_64-apple-darwin'
|
|
||||||
# - platform: 'ubuntu-22.04'
|
|
||||||
# args: ''
|
|
||||||
- platform: "windows-latest"
|
|
||||||
args: ""
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -51,12 +39,6 @@ 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 libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
|
||||||
|
|
||||||
- name: setup node
|
- name: setup node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -69,16 +51,10 @@ jobs:
|
|||||||
run: npm version ${{ github.ref_name }} --no-git-tag-version --allow-same-version
|
run: npm version ${{ github.ref_name }} --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Update tauri.conf.json Version
|
- name: Update tauri.conf.json Version
|
||||||
shell: powershell
|
env:
|
||||||
|
RELEASE_VERSION: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
$path = ".\src-tauri\tauri.conf.json"
|
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');"
|
||||||
$version = '${{ github.ref_name }}'
|
|
||||||
$content = Get-Content -Raw -Encoding UTF8 $path
|
|
||||||
$regex = [regex]'"version"\s*:\s*"[^"]*"'
|
|
||||||
$replacement = '"version": "' + $version + '"'
|
|
||||||
$updated = $regex.Replace($content, $replacement, 1)
|
|
||||||
$encoding = New-Object System.Text.UTF8Encoding($false)
|
|
||||||
[System.IO.File]::WriteAllText($path, $updated, $encoding)
|
|
||||||
|
|
||||||
- name: Commit and Push Changes
|
- name: Commit and Push Changes
|
||||||
shell: powershell
|
shell: powershell
|
||||||
@@ -131,12 +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}}
|
||||||
run: npm run tauri build
|
NO_STRIP: ""
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# 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.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.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
|
||||||
|
}
|
||||||
@@ -1,45 +1,140 @@
|
|||||||
# Tauri Git Lite
|
<div align="center">
|
||||||
|
|
||||||
Eine kleine Git-Desktop-App mit Tauri, Rust und Svelte.
|
<img src="public/splash-icon.png" alt="Gitty Logo" width="360">
|
||||||
|
|
||||||
## Funktionen
|
# Gitty
|
||||||
|
|
||||||
- Repository per lokalem Pfad öffnen
|
**A lightweight, modern Git client built with Tauri.**
|
||||||
- aktuellen Branch, Upstream, Ahead/Behind und Arbeitsbaumstatus anzeigen
|
|
||||||
- Branches auflisten und auschecken
|
|
||||||
- Branches in den aktuellen Branch mergen
|
|
||||||
- Commit-History mit geänderten Dateien anzeigen
|
|
||||||
- Explorer-Ansicht mit Ordnerbaum sowie getrackten und ungetrackten Dateien
|
|
||||||
- Datei- und Ordner-History direkt aus dem Explorer anzeigen
|
|
||||||
- einzelne Dateien stagen, unstagen und wiederherstellen
|
|
||||||
- einzelne Dateien oder ganze Ordner aus einem History-Commit wiederherstellen
|
|
||||||
- aktuellen Branch auf einen ausgewählten Commit zurücksetzen
|
|
||||||
- Commit mit Message erstellen
|
|
||||||
- Pull mit Fast-Forward-Strategie
|
|
||||||
- Push auf den konfigurierten Upstream
|
|
||||||
|
|
||||||
## Entwicklung
|
Fast, simple, and designed for developers who want a clean Git experience without unnecessary complexity.
|
||||||
|
|
||||||
```powershell
|
</div>
|
||||||
npm install
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- 🚀 Fast native performance
|
||||||
|
- 🌿 Branch management
|
||||||
|
- 📝 Commit history
|
||||||
|
- 🔄 Pull, Push & Fetch
|
||||||
|
- 🔀 Merge & Rebase
|
||||||
|
- 📦 Repository management
|
||||||
|
- 🗄️ Git LFS detection, tracking and object management
|
||||||
|
- 🎨 Modern and intuitive UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📸 Preview
|
||||||
|
|
||||||
|
> Screenshots coming soon.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Built With
|
||||||
|
|
||||||
|
- **Tauri**
|
||||||
|
- **Rust**
|
||||||
|
- **Svelte**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 Status
|
||||||
|
|
||||||
|
Gitty is currently under active development.
|
||||||
|
|
||||||
|
Contributions, feedback and feature requests are always welcome!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Start the complete desktop application in development mode with:
|
||||||
|
|
||||||
|
```bash
|
||||||
npm run tauri:dev
|
npm run tauri:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend allein:
|
The development launcher preserves normal proxy settings. If a sandboxed
|
||||||
|
development terminal injects the non-routing `127.0.0.1:9` proxy or its
|
||||||
|
blocking SSH placeholder, those values are removed only for the Tauri child
|
||||||
|
process so Git remotes and Git LFS remain testable in the debug application.
|
||||||
|
|
||||||
```powershell
|
---
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Checks:
|
## Git LFS
|
||||||
|
|
||||||
```powershell
|
Gitty bundles the `git-lfs` executable in its desktop installers and checks it
|
||||||
npm run check
|
at runtime before offering LFS actions. Arch packages also declare `git-lfs` as
|
||||||
npm run build
|
a dependency so Git hooks and command-line workflows outside Gitty use the same
|
||||||
cd src-tauri
|
extension. The repository toolbar exposes LFS setup, tracked patterns, object
|
||||||
cargo check
|
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.
|
||||||
|
|
||||||
## Hinweis
|
---
|
||||||
|
|
||||||
Die App nutzt das lokal installierte `git` CLI. Ein Repository muss daher bereits auf der Maschine vorhanden sein, und Push/Pull verwenden die Credentials, die Git lokal kennt.
|
## SigNoz telemetry
|
||||||
|
|
||||||
|
After the user opts in, Gitty sends privacy-filtered product events and technical errors as OTLP/HTTP JSON logs. Every Git/Tauri command is also captured as a trace span with its command name, duration and success state, but without command arguments or results. CPU utilization and resident memory usage of the Gitty process are sampled every 30 seconds and exported as OpenTelemetry gauges. The default endpoints are `https://telemetry.cbsk-tech.de/v1/logs`, `https://telemetry.cbsk-tech.de/v1/traces`, and `https://telemetry.cbsk-tech.de/v1/metrics`.
|
||||||
|
|
||||||
|
The SigNoz reverse proxy must forward all three paths to the Collector's OTLP/HTTP receiver (normally port `4318`). For development or a separate collector hostname, set `GITTY_OTLP_LOGS_ENDPOINT`, `GITTY_OTLP_TRACES_ENDPOINT`, and `GITTY_OTLP_METRICS_ENDPOINT` to the full signal URLs before starting Gitty.
|
||||||
|
|
||||||
|
Telemetry is non-blocking, bounded, disabled until consent is stored, and never includes repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, source code, URLs, or email addresses.
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
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 Iterable, List, 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
|
||||||
|
|
||||||
|
|
||||||
# cicd_tool lives inside the GitLite repo, so its parent dir is the app root.
|
# cicd_tool lives inside the Gitty repo, so its parent dir is the app root.
|
||||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
ARTIFACT_ROOT = PROJECT_DIR / "src-tauri" / "target" / "release" / "bundle"
|
ARTIFACT_ROOT = PROJECT_DIR / "src-tauri" / "target" / "release" / "bundle"
|
||||||
|
|
||||||
@@ -20,6 +24,11 @@ class ConfigurationError(RuntimeError):
|
|||||||
"""Raised when required configuration values are missing or invalid."""
|
"""Raised when required configuration values are missing or invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
class PublishTarget(NamedTuple):
|
||||||
|
bucket_name: str
|
||||||
|
app_dir: str
|
||||||
|
|
||||||
|
|
||||||
def _resolve_version(version_arg: Optional[str]) -> str:
|
def _resolve_version(version_arg: Optional[str]) -> str:
|
||||||
"""Resolve the version to publish, falling back to tauri.conf.json."""
|
"""Resolve the version to publish, falling back to tauri.conf.json."""
|
||||||
if version_arg and version_arg.lower() not in {"auto"}:
|
if version_arg and version_arg.lower() not in {"auto"}:
|
||||||
@@ -115,23 +124,57 @@ def _ensure_bucket(client: Minio, bucket_name: str) -> None:
|
|||||||
|
|
||||||
def _collect_artifacts() -> List[Path]:
|
def _collect_artifacts() -> List[Path]:
|
||||||
artifacts: List[Path] = []
|
artifacts: List[Path] = []
|
||||||
for directory in (
|
bundle_dirs = {
|
||||||
ARTIFACT_ROOT / "msi",
|
ARTIFACT_ROOT / "msi": (".msi", ".sig"),
|
||||||
ARTIFACT_ROOT / "nsis",
|
ARTIFACT_ROOT / "nsis": (".exe", ".sig"),
|
||||||
ARTIFACT_ROOT / "app",
|
ARTIFACT_ROOT / "appimage": (".appimage", ".sig"),
|
||||||
):
|
ARTIFACT_ROOT / "deb": (".deb", ".sig"),
|
||||||
|
ARTIFACT_ROOT / "rpm": (".rpm", ".sig"),
|
||||||
|
ARTIFACT_ROOT / "app": (".dmg", ".zip", ".sig"),
|
||||||
|
}
|
||||||
|
for directory, suffixes in bundle_dirs.items():
|
||||||
if directory.exists():
|
if directory.exists():
|
||||||
artifacts.extend(p for p in directory.rglob("*") if p.is_file())
|
artifacts.extend(
|
||||||
|
p
|
||||||
|
for p in directory.iterdir()
|
||||||
|
if p.is_file() and p.suffix.lower() in suffixes
|
||||||
|
)
|
||||||
return artifacts
|
return artifacts
|
||||||
|
|
||||||
|
|
||||||
def _select_primary_artifact(artifacts: Iterable[Path]) -> Optional[Path]:
|
def _select_primary_artifact(
|
||||||
priority = (".exe", ".msi", ".zip", ".dmg")
|
artifacts: Iterable[Path], platform: Optional[str] = None
|
||||||
|
) -> Optional[Path]:
|
||||||
|
artifact_list = list(artifacts)
|
||||||
|
priorities = {
|
||||||
|
"windows-x86_64": (".exe", ".msi"),
|
||||||
|
"linux-x86_64": (".appimage", ".deb", ".rpm"),
|
||||||
|
"darwin-x86_64": (".dmg", ".zip"),
|
||||||
|
"darwin-aarch64": (".dmg", ".zip"),
|
||||||
|
}
|
||||||
|
priority = priorities.get(platform or "", ())
|
||||||
|
priority += (".exe", ".msi", ".appimage", ".deb", ".rpm", ".zip", ".dmg")
|
||||||
for ext in priority:
|
for ext in priority:
|
||||||
for artifact in artifacts:
|
for artifact in artifact_list:
|
||||||
if artifact.suffix.lower() == ext:
|
if artifact.suffix.lower() == ext:
|
||||||
return artifact
|
return artifact
|
||||||
return next(iter(artifacts), None)
|
return next(
|
||||||
|
(artifact for artifact in artifact_list if artifact.suffix != ".sig"), None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_platform(artifact: Path) -> str:
|
||||||
|
suffix = artifact.suffix.lower()
|
||||||
|
if suffix in {".exe", ".msi"}:
|
||||||
|
return "windows-x86_64"
|
||||||
|
if suffix in {".appimage", ".deb", ".rpm"}:
|
||||||
|
return "linux-x86_64"
|
||||||
|
if suffix in {".dmg", ".zip"}:
|
||||||
|
return "darwin-x86_64"
|
||||||
|
raise ConfigurationError(
|
||||||
|
f"Cannot infer updater platform from artifact: {artifact.name}. "
|
||||||
|
"Set PUBLISH_PLATFORM explicitly."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _read_signature(artifact: Path) -> str:
|
def _read_signature(artifact: Path) -> str:
|
||||||
@@ -145,16 +188,55 @@ def _read_signature(artifact: Path) -> str:
|
|||||||
raise FileNotFoundError(f"Signature not found for {artifact.name}")
|
raise FileNotFoundError(f"Signature not found for {artifact.name}")
|
||||||
|
|
||||||
|
|
||||||
def _build_latest_json(
|
def _build_artifact_url(
|
||||||
version: str,
|
version: str,
|
||||||
primary_artifact: Path,
|
primary_artifact: Path,
|
||||||
signature: str,
|
|
||||||
artifact_base_url: str,
|
artifact_base_url: str,
|
||||||
app_dir: str,
|
app_dir: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
base_url = artifact_base_url.rstrip("/")
|
base_url = artifact_base_url.rstrip("/")
|
||||||
path_parts = [app_dir.strip("/"), version, primary_artifact.name]
|
path_parts = [app_dir.strip("/"), version, primary_artifact.name]
|
||||||
artifact_url = "/".join([base_url, *filter(None, path_parts)])
|
return "/".join([base_url, *filter(None, path_parts)])
|
||||||
|
|
||||||
|
|
||||||
|
def _read_existing_latest_json(
|
||||||
|
client: Minio, bucket_name: str
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
response = client.get_object(bucket_name, "latest.json")
|
||||||
|
except S3Error as exc:
|
||||||
|
if exc.code in {"NoSuchKey", "NoSuchObject"}:
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ConfigurationError("Existing latest.json is invalid JSON") from exc
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
response.release_conn()
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ConfigurationError("Existing latest.json must be a JSON object")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _build_latest_payload(
|
||||||
|
version: str,
|
||||||
|
platform: str,
|
||||||
|
primary_artifact: Path,
|
||||||
|
signature: str,
|
||||||
|
artifact_base_url: str,
|
||||||
|
app_dir: str,
|
||||||
|
existing_payload: Optional[dict[str, Any]] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
artifact_url = _build_artifact_url(
|
||||||
|
version=version,
|
||||||
|
primary_artifact=primary_artifact,
|
||||||
|
artifact_base_url=artifact_base_url,
|
||||||
|
app_dir=app_dir,
|
||||||
|
)
|
||||||
|
|
||||||
pub_date = (
|
pub_date = (
|
||||||
datetime.now(timezone.utc)
|
datetime.now(timezone.utc)
|
||||||
@@ -163,18 +245,151 @@ def _build_latest_json(
|
|||||||
.replace("+00:00", "Z")
|
.replace("+00:00", "Z")
|
||||||
)
|
)
|
||||||
|
|
||||||
latest_payload = {
|
platforms: dict[str, Any] = {}
|
||||||
|
existing_notes = None
|
||||||
|
if existing_payload and existing_payload.get("version") == version:
|
||||||
|
existing_platforms = existing_payload.get("platforms")
|
||||||
|
if isinstance(existing_platforms, dict):
|
||||||
|
platforms.update(existing_platforms)
|
||||||
|
existing_notes = existing_payload.get("notes")
|
||||||
|
|
||||||
|
platforms[platform] = {
|
||||||
|
"signature": signature,
|
||||||
|
"url": artifact_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
"version": version,
|
"version": version,
|
||||||
"pub_date": pub_date,
|
"pub_date": pub_date,
|
||||||
"notes": os.environ.get("RELEASE_NOTES", "Automated release"),
|
"notes": os.environ.get("RELEASE_NOTES")
|
||||||
"platforms": {
|
or existing_notes
|
||||||
"windows-x86_64": {
|
or "Automated release",
|
||||||
"signature": signature,
|
"platforms": platforms,
|
||||||
"url": artifact_url,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return json.dumps(latest_payload, separators=(",", ":"))
|
|
||||||
|
|
||||||
|
def _parse_publish_targets(primary_bucket: str, primary_app_dir: str) -> list[PublishTarget]:
|
||||||
|
targets = [PublishTarget(primary_bucket, primary_app_dir)]
|
||||||
|
raw_legacy_targets = os.environ.get("LEGACY_UPDATE_TARGETS", "gitlite:gitlite")
|
||||||
|
|
||||||
|
for raw_target in raw_legacy_targets.split(","):
|
||||||
|
value = raw_target.strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ":" in value:
|
||||||
|
bucket_name, app_dir = value.split(":", 1)
|
||||||
|
else:
|
||||||
|
bucket_name = value
|
||||||
|
app_dir = value
|
||||||
|
|
||||||
|
target = PublishTarget(bucket_name.strip().lower(), app_dir.strip("/"))
|
||||||
|
if target.bucket_name and target not in targets:
|
||||||
|
targets.append(target)
|
||||||
|
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_target(
|
||||||
|
client: Minio,
|
||||||
|
target: PublishTarget,
|
||||||
|
version: str,
|
||||||
|
platform: str,
|
||||||
|
artifacts: Iterable[Path],
|
||||||
|
primary_artifact: Path,
|
||||||
|
signature: str,
|
||||||
|
artifact_base_url: str,
|
||||||
|
) -> None:
|
||||||
|
_ensure_bucket(client, target.bucket_name)
|
||||||
|
existing_latest_json = _read_existing_latest_json(client, target.bucket_name)
|
||||||
|
latest_payload = _build_latest_payload(
|
||||||
|
version=version,
|
||||||
|
platform=platform,
|
||||||
|
primary_artifact=primary_artifact,
|
||||||
|
signature=signature,
|
||||||
|
artifact_base_url=artifact_base_url,
|
||||||
|
app_dir=target.app_dir,
|
||||||
|
existing_payload=existing_latest_json,
|
||||||
|
)
|
||||||
|
latest_json = json.dumps(latest_payload, separators=(",", ":"))
|
||||||
|
|
||||||
|
for artifact in artifacts:
|
||||||
|
object_segments = [part for part in (version, artifact.name) if part]
|
||||||
|
object_name = "/".join(object_segments)
|
||||||
|
client.fput_object(target.bucket_name, object_name, str(artifact))
|
||||||
|
print(f"Uploaded {artifact.name} -> s3://{target.bucket_name}/{object_name}")
|
||||||
|
|
||||||
|
latest_bytes = latest_json.encode("utf-8")
|
||||||
|
client.put_object(
|
||||||
|
bucket_name=target.bucket_name,
|
||||||
|
object_name="latest.json",
|
||||||
|
data=io.BytesIO(latest_bytes),
|
||||||
|
length=len(latest_bytes),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"Uploaded latest.json for {platform} -> "
|
||||||
|
f"s3://{target.bucket_name}/latest.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
@@ -184,9 +399,10 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
|||||||
raise ConfigurationError("ARTIFACT_BASE_URL is required")
|
raise ConfigurationError("ARTIFACT_BASE_URL is required")
|
||||||
|
|
||||||
# S3/MinIO bucket names must be lowercase, so normalise regardless of how the
|
# S3/MinIO bucket names must be lowercase, so normalise regardless of how the
|
||||||
# S3_BUCKET variable is cased (e.g. "GitLite" -> "gitlite").
|
# S3_BUCKET variable is cased (e.g. "Gitty" -> "gitty").
|
||||||
bucket_name = (os.environ.get("S3_BUCKET") or "gitlite").lower()
|
bucket_name = (os.environ.get("S3_BUCKET") or "gitty").lower()
|
||||||
bucket_prefix = (app_dir or os.environ.get("APP_DIR") or "gitlite").strip("/")
|
bucket_prefix = (app_dir or os.environ.get("APP_DIR") or "gitty").strip("/")
|
||||||
|
publish_targets = _parse_publish_targets(bucket_name, bucket_prefix)
|
||||||
|
|
||||||
artifacts = _collect_artifacts()
|
artifacts = _collect_artifacts()
|
||||||
if not artifacts:
|
if not artifacts:
|
||||||
@@ -194,49 +410,50 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
|||||||
"No release artifacts found in src-tauri/target/release/bundle."
|
"No release artifacts found in src-tauri/target/release/bundle."
|
||||||
)
|
)
|
||||||
|
|
||||||
primary = _select_primary_artifact(artifacts)
|
publish_platform = os.environ.get("PUBLISH_PLATFORM") or os.environ.get(
|
||||||
|
"UPDATER_PLATFORM"
|
||||||
|
)
|
||||||
|
primary = _select_primary_artifact(artifacts, publish_platform)
|
||||||
if not primary:
|
if not primary:
|
||||||
raise FileNotFoundError("Unable to determine primary artifact to publish.")
|
raise FileNotFoundError("Unable to determine primary artifact to publish.")
|
||||||
|
|
||||||
|
platform = publish_platform or _infer_platform(primary)
|
||||||
signature = _read_signature(primary)
|
signature = _read_signature(primary)
|
||||||
|
|
||||||
latest_json = _build_latest_json(
|
client = _create_minio_client()
|
||||||
|
|
||||||
|
local_dist_dir = PROJECT_DIR / "dist"
|
||||||
|
local_dist_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
latest_json_path = local_dist_dir / "latest.json"
|
||||||
|
latest_payload = _build_latest_payload(
|
||||||
version=resolved_version,
|
version=resolved_version,
|
||||||
|
platform=platform,
|
||||||
primary_artifact=primary,
|
primary_artifact=primary,
|
||||||
signature=signature,
|
signature=signature,
|
||||||
artifact_base_url=artifact_base_url,
|
artifact_base_url=artifact_base_url,
|
||||||
app_dir=bucket_prefix,
|
app_dir=bucket_prefix,
|
||||||
)
|
)
|
||||||
|
latest_json = json.dumps(latest_payload, separators=(",", ":"))
|
||||||
local_dist_dir = PROJECT_DIR / "dist"
|
|
||||||
local_dist_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
latest_json_path = local_dist_dir / "latest.json"
|
|
||||||
latest_json_path.write_text(latest_json + "\n", encoding="utf-8")
|
latest_json_path.write_text(latest_json + "\n", encoding="utf-8")
|
||||||
|
|
||||||
client = _create_minio_client()
|
for target in publish_targets:
|
||||||
_ensure_bucket(client, bucket_name)
|
_publish_target(
|
||||||
|
client=client,
|
||||||
|
target=target,
|
||||||
|
version=resolved_version,
|
||||||
|
platform=platform,
|
||||||
|
artifacts=artifacts,
|
||||||
|
primary_artifact=primary,
|
||||||
|
signature=signature,
|
||||||
|
artifact_base_url=artifact_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
for artifact in artifacts:
|
_upload_gitea_release_asset(resolved_version, primary)
|
||||||
object_segments = [part for part in (resolved_version, artifact.name) if part]
|
|
||||||
object_name = "/".join(object_segments)
|
|
||||||
client.fput_object(bucket_name, object_name, str(artifact))
|
|
||||||
print(f"Uploaded {artifact.name} -> s3://{bucket_name}/{object_name}")
|
|
||||||
|
|
||||||
latest_object_name = "latest.json"
|
|
||||||
latest_bytes = latest_json.encode("utf-8")
|
|
||||||
client.put_object(
|
|
||||||
bucket_name=bucket_name,
|
|
||||||
object_name=latest_object_name,
|
|
||||||
data=io.BytesIO(latest_bytes),
|
|
||||||
length=len(latest_bytes),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
print(f"Uploaded latest.json -> s3://{bucket_name}/{latest_object_name}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Upload GitLite release artifacts to MinIO."
|
description="Upload Gitty release artifacts to MinIO."
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-v",
|
"-v",
|
||||||
@@ -247,7 +464,7 @@ if __name__ == "__main__":
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--app-dir",
|
"--app-dir",
|
||||||
default=None,
|
default=None,
|
||||||
help="App directory prefix inside the bucket (defaults to gitlite).",
|
help="App directory prefix inside the bucket (defaults to gitty).",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -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,84 @@ interface GitRepositoryFile {
|
|||||||
tracked: boolean;
|
tracked: boolean;
|
||||||
status: FileStatusKind | null;
|
status: FileStatusKind | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GitLfsStatus {
|
||||||
|
available: boolean;
|
||||||
|
bundled: boolean;
|
||||||
|
version: string | null;
|
||||||
|
filters_configured: boolean;
|
||||||
|
hook_installed: boolean;
|
||||||
|
repository_uses_lfs: boolean;
|
||||||
|
patterns: GitLfsPattern[];
|
||||||
|
files: GitLfsFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitLfsPattern {
|
||||||
|
pattern: string;
|
||||||
|
source: string;
|
||||||
|
lockable: boolean;
|
||||||
|
tracked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitLfsFile {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
checkout: boolean;
|
||||||
|
downloaded: boolean;
|
||||||
|
oid_type: string;
|
||||||
|
oid: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
The command list below includes the repository-management and synchronization API. The TypeScript wrappers in `src/lib/git.ts` are the authoritative full list.
|
||||||
|
|
||||||
- `open_repository(path: string): Promise<GitStatus>`
|
- `open_repository(path: string): Promise<GitStatus>`
|
||||||
|
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
|
||||||
|
- `clone_repository(...): Promise<RepositoryBundle>`
|
||||||
- `get_status(path: string): Promise<GitStatus>`
|
- `get_status(path: string): Promise<GitStatus>`
|
||||||
|
- `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)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tauri Git Lite</title>
|
<title>Gitty</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "tauri-git-lite",
|
"name": "gitty",
|
||||||
"version": "2026.7.6",
|
"version": "2026.8.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tauri-git-lite",
|
"name": "gitty",
|
||||||
"version": "2026.7.6",
|
"version": "2026.8.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^1.21.0",
|
"@lucide/svelte": "^1.21.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "git-lite",
|
"name": "gitty",
|
||||||
"version": "2026.7.6",
|
"version": "2026.8.7",
|
||||||
"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",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
|
After Width: | Height: | Size: 253 KiB |
@@ -0,0 +1,241 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Gitty</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--ink: #f3f5ff;
|
||||||
|
--muted: #9aa3c5;
|
||||||
|
--purple: #9b70ff;
|
||||||
|
--orange: #ff6d26;
|
||||||
|
--panel: rgba(15, 18, 31, 0.9);
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #050712;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 38%, rgba(137, 92, 255, 0.22), transparent 36%),
|
||||||
|
linear-gradient(135deg, #050712 0%, #080b16 48%, #140b18 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.28;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(151, 118, 255, 0.12) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 109, 38, 0.08) 1px, transparent 1px);
|
||||||
|
background-size: 42px 42px;
|
||||||
|
mask-image: radial-gradient(circle at center, black 0%, transparent 70%);
|
||||||
|
animation: grid-drift 10s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
align-content: center;
|
||||||
|
padding: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(23, 26, 43, 0.92), rgba(12, 15, 27, 0.94)),
|
||||||
|
var(--panel);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 174px;
|
||||||
|
height: 174px;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.halo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 10px;
|
||||||
|
border: 1px solid rgba(151, 118, 255, 0.25);
|
||||||
|
border-radius: 36px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
animation: halo-breathe 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.halo.secondary {
|
||||||
|
inset: 25px;
|
||||||
|
border-color: rgba(255, 109, 38, 0.28);
|
||||||
|
animation-direction: reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traces {
|
||||||
|
position: absolute;
|
||||||
|
inset: -18px;
|
||||||
|
width: 210px;
|
||||||
|
height: 210px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-dasharray: 165;
|
||||||
|
stroke-dashoffset: 165;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(151, 118, 255, 0.55));
|
||||||
|
animation: trace-draw 2.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace.main {
|
||||||
|
stroke: var(--purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace.branch {
|
||||||
|
stroke: var(--orange);
|
||||||
|
animation-delay: 0.28s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace.base {
|
||||||
|
stroke: rgba(255, 255, 255, 0.42);
|
||||||
|
stroke-dasharray: 128;
|
||||||
|
stroke-dashoffset: 128;
|
||||||
|
animation-delay: 0.55s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 136px;
|
||||||
|
height: 136px;
|
||||||
|
object-fit: contain;
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 18px 24px rgba(0, 0, 0, 0.55))
|
||||||
|
drop-shadow(0 0 18px rgba(151, 118, 255, 0.3));
|
||||||
|
animation: icon-float 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
position: relative;
|
||||||
|
width: 230px;
|
||||||
|
height: 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(151, 118, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar span {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 46%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--purple) 40%, var(--orange) 74%, transparent);
|
||||||
|
box-shadow: 0 0 16px rgba(255, 109, 38, 0.32);
|
||||||
|
animation: bar-slide 1.35s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes grid-drift {
|
||||||
|
to { transform: translate3d(42px, 42px, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes icon-float {
|
||||||
|
0%, 100% { transform: translateY(0) scale(1); }
|
||||||
|
50% { transform: translateY(-5px) scale(1.015); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes halo-breathe {
|
||||||
|
0%, 100% { opacity: 0.35; transform: rotate(45deg) scale(0.95); }
|
||||||
|
50% { opacity: 0.8; transform: rotate(45deg) scale(1.04); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes trace-draw {
|
||||||
|
0% { stroke-dashoffset: 165; opacity: 0; }
|
||||||
|
36% { opacity: 1; }
|
||||||
|
64%, 100% { stroke-dashoffset: 0; opacity: 0.72; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bar-slide {
|
||||||
|
0% { transform: translateX(-120%); }
|
||||||
|
100% { transform: translateX(320%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.grid,
|
||||||
|
.halo,
|
||||||
|
.trace,
|
||||||
|
.icon,
|
||||||
|
.bar span {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="grid" aria-hidden="true"></div>
|
||||||
|
<main class="splash" role="status" aria-live="polite">
|
||||||
|
<div class="mark" aria-hidden="true">
|
||||||
|
<span class="halo"></span>
|
||||||
|
<span class="halo secondary"></span>
|
||||||
|
<svg class="traces" viewBox="0 0 220 220">
|
||||||
|
<path class="trace main" d="M28 154 C72 114, 88 108, 110 110 S156 116, 192 68" />
|
||||||
|
<path class="trace branch" d="M62 74 C100 88, 126 124, 158 166" />
|
||||||
|
<path class="trace base" d="M46 180 L174 180" />
|
||||||
|
</svg>
|
||||||
|
<img class="icon" src="/splash-icon.png" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="copy">
|
||||||
|
<strong>Gitty</strong>
|
||||||
|
<span>Starting workspace</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar" aria-hidden="true"><span></span></div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { deflateSync, inflateSync } from "node:zlib";
|
||||||
|
|
||||||
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
|
const iconsDir = join(root, "src-tauri", "icons");
|
||||||
|
const sourcePath = join(iconsDir, "GitCat_org.png");
|
||||||
|
|
||||||
|
const pngSizes = [32, 64, 128, 512];
|
||||||
|
const icoSizes = [16, 24, 32, 48, 64, 128, 256];
|
||||||
|
|
||||||
|
function decodePng(buffer) {
|
||||||
|
if (buffer.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") {
|
||||||
|
throw new Error("Source icon must be a PNG file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
const idatChunks = [];
|
||||||
|
|
||||||
|
for (let offset = 8; offset < buffer.length; ) {
|
||||||
|
const length = buffer.readUInt32BE(offset);
|
||||||
|
const type = buffer.subarray(offset + 4, offset + 8).toString("ascii");
|
||||||
|
const dataStart = offset + 8;
|
||||||
|
const dataEnd = dataStart + length;
|
||||||
|
const data = buffer.subarray(dataStart, dataEnd);
|
||||||
|
|
||||||
|
if (type === "IHDR") {
|
||||||
|
width = data.readUInt32BE(0);
|
||||||
|
height = data.readUInt32BE(4);
|
||||||
|
if (data[8] !== 8 || data[9] !== 6) {
|
||||||
|
throw new Error("Source icon must be an 8-bit RGBA PNG.");
|
||||||
|
}
|
||||||
|
} else if (type === "IDAT") {
|
||||||
|
idatChunks.push(data);
|
||||||
|
} else if (type === "IEND") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = dataEnd + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stride = width * 4;
|
||||||
|
const raw = inflateSync(Buffer.concat(idatChunks));
|
||||||
|
const rgba = Buffer.alloc(width * height * 4);
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
const rowStart = y * (stride + 1);
|
||||||
|
const filter = raw[rowStart];
|
||||||
|
const input = raw.subarray(rowStart + 1, rowStart + 1 + stride);
|
||||||
|
const priorOffset = (y - 1) * stride;
|
||||||
|
const outputOffset = y * stride;
|
||||||
|
|
||||||
|
for (let x = 0; x < stride; x += 1) {
|
||||||
|
const left = x >= 4 ? rgba[outputOffset + x - 4] : 0;
|
||||||
|
const up = y > 0 ? rgba[priorOffset + x] : 0;
|
||||||
|
const upLeft = y > 0 && x >= 4 ? rgba[priorOffset + x - 4] : 0;
|
||||||
|
let value = input[x];
|
||||||
|
|
||||||
|
if (filter === 1) value += left;
|
||||||
|
else if (filter === 2) value += up;
|
||||||
|
else if (filter === 3) value += Math.floor((left + up) / 2);
|
||||||
|
else if (filter === 4) value += paeth(left, up, upLeft);
|
||||||
|
else if (filter !== 0) throw new Error(`Unsupported PNG filter: ${filter}`);
|
||||||
|
|
||||||
|
rgba[outputOffset + x] = value & 0xff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width, height, rgba };
|
||||||
|
}
|
||||||
|
|
||||||
|
function paeth(left, up, upLeft) {
|
||||||
|
const estimate = left + up - upLeft;
|
||||||
|
const distanceLeft = Math.abs(estimate - left);
|
||||||
|
const distanceUp = Math.abs(estimate - up);
|
||||||
|
const distanceUpLeft = Math.abs(estimate - upLeft);
|
||||||
|
|
||||||
|
if (distanceLeft <= distanceUp && distanceLeft <= distanceUpLeft) return left;
|
||||||
|
if (distanceUp <= distanceUpLeft) return up;
|
||||||
|
return upLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleSquare(image, alphaThreshold = 8) {
|
||||||
|
let minX = image.width;
|
||||||
|
let minY = image.height;
|
||||||
|
let maxX = -1;
|
||||||
|
let maxY = -1;
|
||||||
|
|
||||||
|
for (let y = 0; y < image.height; y += 1) {
|
||||||
|
for (let x = 0; x < image.width; x += 1) {
|
||||||
|
const alpha = image.rgba[(y * image.width + x) * 4 + 3];
|
||||||
|
if (alpha > alphaThreshold) {
|
||||||
|
minX = Math.min(minX, x);
|
||||||
|
minY = Math.min(minY, y);
|
||||||
|
maxX = Math.max(maxX, x);
|
||||||
|
maxY = Math.max(maxY, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxX < 0 || maxY < 0) {
|
||||||
|
throw new Error("Source icon has no visible pixels.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleWidth = maxX - minX + 1;
|
||||||
|
const visibleHeight = maxY - minY + 1;
|
||||||
|
const side = Math.max(visibleWidth, visibleHeight);
|
||||||
|
const centerX = (minX + maxX) / 2;
|
||||||
|
const centerY = (minY + maxY) / 2;
|
||||||
|
const left = Math.max(0, Math.min(image.width - side, Math.round(centerX - side / 2)));
|
||||||
|
const top = Math.max(0, Math.min(image.height - side, Math.round(centerY - side / 2)));
|
||||||
|
|
||||||
|
return { left, top, side };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizePng(image, crop, size) {
|
||||||
|
const rgba = Buffer.alloc(size * size * 4);
|
||||||
|
const scale = crop.side / size;
|
||||||
|
|
||||||
|
for (let y = 0; y < size; y += 1) {
|
||||||
|
for (let x = 0; x < size; x += 1) {
|
||||||
|
const sourceX = crop.left + (x + 0.5) * scale - 0.5;
|
||||||
|
const sourceY = crop.top + (y + 0.5) * scale - 0.5;
|
||||||
|
const pixel = sampleBilinear(image, sourceX, sourceY);
|
||||||
|
const offset = (y * size + x) * 4;
|
||||||
|
rgba[offset] = pixel.r;
|
||||||
|
rgba[offset + 1] = pixel.g;
|
||||||
|
rgba[offset + 2] = pixel.b;
|
||||||
|
rgba[offset + 3] = pixel.a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodePng(size, size, rgba);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleBilinear(image, x, y) {
|
||||||
|
const x0 = Math.max(0, Math.min(image.width - 1, Math.floor(x)));
|
||||||
|
const y0 = Math.max(0, Math.min(image.height - 1, Math.floor(y)));
|
||||||
|
const x1 = Math.max(0, Math.min(image.width - 1, x0 + 1));
|
||||||
|
const y1 = Math.max(0, Math.min(image.height - 1, y0 + 1));
|
||||||
|
const tx = x - x0;
|
||||||
|
const ty = y - y0;
|
||||||
|
const top = mixPixels(getPixel(image, x0, y0), getPixel(image, x1, y0), tx);
|
||||||
|
const bottom = mixPixels(getPixel(image, x0, y1), getPixel(image, x1, y1), tx);
|
||||||
|
|
||||||
|
return mixPixels(top, bottom, ty);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPixel(image, x, y) {
|
||||||
|
const offset = (y * image.width + x) * 4;
|
||||||
|
return {
|
||||||
|
r: image.rgba[offset],
|
||||||
|
g: image.rgba[offset + 1],
|
||||||
|
b: image.rgba[offset + 2],
|
||||||
|
a: image.rgba[offset + 3],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mixPixels(a, b, t) {
|
||||||
|
return {
|
||||||
|
r: Math.round(a.r + (b.r - a.r) * t),
|
||||||
|
g: Math.round(a.g + (b.g - a.g) * t),
|
||||||
|
b: Math.round(a.b + (b.b - a.b) * t),
|
||||||
|
a: Math.round(a.a + (b.a - a.a) * t),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const crcTable = new Uint32Array(256);
|
||||||
|
for (let n = 0; n < 256; n += 1) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k += 1) {
|
||||||
|
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
}
|
||||||
|
crcTable[n] = c >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function crc32(buffer) {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (const byte of buffer) {
|
||||||
|
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(type, payload) {
|
||||||
|
const typeBytes = Buffer.from(type);
|
||||||
|
const out = Buffer.alloc(12 + payload.length);
|
||||||
|
out.writeUInt32BE(payload.length, 0);
|
||||||
|
typeBytes.copy(out, 4);
|
||||||
|
payload.copy(out, 8);
|
||||||
|
out.writeUInt32BE(crc32(Buffer.concat([typeBytes, payload])), out.length - 4);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodePng(width, height, rgba) {
|
||||||
|
const header = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(width, 0);
|
||||||
|
ihdr.writeUInt32BE(height, 4);
|
||||||
|
ihdr[8] = 8;
|
||||||
|
ihdr[9] = 6;
|
||||||
|
|
||||||
|
const raw = Buffer.alloc((width * 4 + 1) * height);
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
const rowStart = y * (width * 4 + 1);
|
||||||
|
raw[rowStart] = 0;
|
||||||
|
rgba.copy(raw, rowStart + 1, y * width * 4, (y + 1) * width * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat([
|
||||||
|
header,
|
||||||
|
chunk("IHDR", ihdr),
|
||||||
|
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
||||||
|
chunk("IEND", Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeIco(images) {
|
||||||
|
const header = Buffer.alloc(6);
|
||||||
|
header.writeUInt16LE(0, 0);
|
||||||
|
header.writeUInt16LE(1, 2);
|
||||||
|
header.writeUInt16LE(images.length, 4);
|
||||||
|
|
||||||
|
const entries = Buffer.alloc(images.length * 16);
|
||||||
|
let offset = 6 + images.length * 16;
|
||||||
|
|
||||||
|
images.forEach((image, index) => {
|
||||||
|
const entry = index * 16;
|
||||||
|
entries[entry] = image.size >= 256 ? 0 : image.size;
|
||||||
|
entries[entry + 1] = image.size >= 256 ? 0 : image.size;
|
||||||
|
entries[entry + 2] = 0;
|
||||||
|
entries[entry + 3] = 0;
|
||||||
|
entries.writeUInt16LE(1, entry + 4);
|
||||||
|
entries.writeUInt16LE(32, entry + 6);
|
||||||
|
entries.writeUInt32LE(image.png.length, entry + 8);
|
||||||
|
entries.writeUInt32LE(offset, entry + 12);
|
||||||
|
offset += image.png.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Buffer.concat([header, entries, ...images.map((image) => image.png)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(iconsDir, { recursive: true });
|
||||||
|
|
||||||
|
const source = decodePng(readFileSync(sourcePath));
|
||||||
|
const crop = visibleSquare(source);
|
||||||
|
const rendered = new Map(
|
||||||
|
[...new Set([...pngSizes, ...icoSizes])].map((size) => [size, resizePng(source, crop, size)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(join(iconsDir, "32x32.png"), rendered.get(32));
|
||||||
|
writeFileSync(join(iconsDir, "64x64.png"), rendered.get(64));
|
||||||
|
writeFileSync(join(iconsDir, "128x128.png"), rendered.get(128));
|
||||||
|
writeFileSync(join(iconsDir, "icon.png"), rendered.get(512));
|
||||||
|
writeFileSync(join(iconsDir, "icon.ico"), makeIco(icoSizes.map((size) => ({ size, png: rendered.get(size) }))));
|
||||||
|
|
||||||
|
console.log(`Read ${join("src-tauri", "icons", "GitCat_org.png")}`);
|
||||||
|
console.log(`Cropped visible icon area to ${crop.side}x${crop.side} at ${crop.left},${crop.top}`);
|
||||||
|
console.log(`Wrote ${join("src-tauri", "icons", "32x32.png")}`);
|
||||||
|
console.log(`Wrote ${join("src-tauri", "icons", "64x64.png")}`);
|
||||||
|
console.log(`Wrote ${join("src-tauri", "icons", "128x128.png")}`);
|
||||||
|
console.log(`Wrote ${join("src-tauri", "icons", "icon.png")}`);
|
||||||
|
console.log(`Wrote ${join("src-tauri", "icons", "icon.ico")} (${icoSizes.length} sizes)`);
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import "./generate-app-icons.mjs";
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
import { Buffer } from "node:buffer";
|
import { Buffer } from "node:buffer";
|
||||||
import { mkdirSync, writeFileSync } from "node:fs";
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "git_lite"
|
name = "gitty"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Rust backend for a lightweight Git desktop client"
|
description = "Rust backend for a lightweight Git desktop client"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
@@ -17,13 +17,25 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "=2.7.0"
|
tauri-plugin-dialog = "=2.7.0"
|
||||||
|
tauri-plugin-aptabase = "1.0"
|
||||||
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
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"
|
||||||
|
log = "0.4"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
|
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
|
|
||||||
|
# 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.
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-env-changed=APTABASE_KEY");
|
||||||
tauri_build::build();
|
tauri_build::build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
"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",
|
||||||
|
"aptabase:allow-track-event"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
"identifier": "desktop-capability",
|
"identifier": "desktop-capability",
|
||||||
"platforms": [
|
"platforms": [
|
||||||
"macOS",
|
"macOS",
|
||||||
"windows",
|
"windows"
|
||||||
"linux"
|
|
||||||
],
|
],
|
||||||
"windows": [
|
"windows": [
|
||||||
"main"
|
"main"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{build_messages, 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;
|
||||||
@@ -56,8 +56,14 @@ async fn openai_compatible_request(
|
|||||||
let body = OpenAiRequest {
|
let body = OpenAiRequest {
|
||||||
model: model.to_string(),
|
model: model.to_string(),
|
||||||
messages: vec![
|
messages: vec![
|
||||||
OpenAiMessage { role: "system", content: system },
|
OpenAiMessage {
|
||||||
OpenAiMessage { role: "user", content: user },
|
role: "system",
|
||||||
|
content: system,
|
||||||
|
},
|
||||||
|
OpenAiMessage {
|
||||||
|
role: "user",
|
||||||
|
content: user,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
};
|
};
|
||||||
@@ -82,17 +88,22 @@ async fn openai_compatible_request(
|
|||||||
return Err(format!("API error ({status}): {text}"));
|
return Err(format!("API error ({status}): {text}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: OpenAiResponse = serde_json::from_str(&text)
|
let parsed: OpenAiResponse =
|
||||||
.map_err(|err| format!("Could not process response: {err}"))?;
|
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
|
||||||
parsed
|
let message = parsed
|
||||||
.choices
|
.choices
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
.and_then(|choice| choice.message.content)
|
.and_then(|choice| choice.message.content)
|
||||||
.map(|content| sanitize_message(&content))
|
.map(|content| sanitize_message(&content))
|
||||||
.filter(|content| !content.is_empty())
|
.filter(|content| !content.is_empty())
|
||||||
.ok_or_else(|| "The model did not return a response.".to_string())
|
.ok_or_else(|| "The model did not return a response.".to_string())?;
|
||||||
|
|
||||||
|
if looks_like_diff_echo(&message) {
|
||||||
|
return Err("The model returned the diff instead of a commit message.".to_string());
|
||||||
|
}
|
||||||
|
Ok(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn generate_openai(
|
pub async fn generate_openai(
|
||||||
@@ -128,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,
|
||||||
@@ -168,7 +338,10 @@ pub async fn generate_anthropic(
|
|||||||
model: model.to_string(),
|
model: model.to_string(),
|
||||||
max_tokens: DEFAULT_MAX_TOKENS,
|
max_tokens: DEFAULT_MAX_TOKENS,
|
||||||
system,
|
system,
|
||||||
messages: vec![AnthropicMessage { role: "user", content: user }],
|
messages: vec![AnthropicMessage {
|
||||||
|
role: "user",
|
||||||
|
content: user,
|
||||||
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = http_client()?;
|
let client = http_client()?;
|
||||||
@@ -190,14 +363,105 @@ pub async fn generate_anthropic(
|
|||||||
return Err(format!("API error ({status}): {text}"));
|
return Err(format!("API error ({status}): {text}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: AnthropicResponse = serde_json::from_str(&text)
|
let parsed: AnthropicResponse =
|
||||||
.map_err(|err| format!("Could not process response: {err}"))?;
|
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
|
||||||
|
let message = 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 response.".to_string())?;
|
||||||
|
|
||||||
|
if looks_like_diff_echo(&message) {
|
||||||
|
return Err("The model returned the diff instead of a commit message.".to_string());
|
||||||
|
}
|
||||||
|
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
|
parsed
|
||||||
.content
|
.content
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find_map(|block| block.text)
|
.find_map(|block| block.text)
|
||||||
.map(|text| sanitize_message(&text))
|
.map(|text| sanitize_message(&text))
|
||||||
.filter(|text| !text.is_empty())
|
.filter(|text| !text.is_empty())
|
||||||
.ok_or_else(|| "The model did not return a response.".to_string())
|
.ok_or_else(|| "The model did not return a review.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn split_anthropic(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return Err("Anthropic API key is missing.".to_string());
|
||||||
|
}
|
||||||
|
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.".to_string();
|
||||||
|
let body = AnthropicRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
max_tokens: 2400,
|
||||||
|
system,
|
||||||
|
messages: vec![AnthropicMessage {
|
||||||
|
role: "user",
|
||||||
|
content: format!(
|
||||||
|
"Analyze these staged changes and propose an ordered commit plan:\n\n{diff}"
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let client = http_client()?;
|
||||||
|
let response = client
|
||||||
|
.post("https://api.anthropic.com/v1/messages")
|
||||||
|
.header("x-api-key", api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("API error ({status}): {text}"));
|
||||||
|
}
|
||||||
|
let parsed: AnthropicResponse =
|
||||||
|
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
parsed
|
||||||
|
.content
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|block| block.text)
|
||||||
|
.map(|text| sanitize_message(&text))
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.ok_or_else(|| "The model did not return a commit plan.".to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
mod cloud;
|
mod cloud;
|
||||||
|
|
||||||
pub use cloud::{generate_anthropic, generate_custom, generate_openai};
|
pub use cloud::{
|
||||||
|
generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom,
|
||||||
|
review_openai, split_anthropic, split_custom, split_openai,
|
||||||
|
};
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::{
|
||||||
|
collections::hash_map::DefaultHasher,
|
||||||
|
hash::{Hash, Hasher},
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
use mistralrs::{GgufModelBuilder, Model, TextMessageRole, TextMessages};
|
use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
/// One selectable local (on-device) model. Larger models produce better commit messages
|
/// One selectable local (on-device) model. Larger models produce better commit messages
|
||||||
@@ -19,7 +26,7 @@ pub struct LocalModelOption {
|
|||||||
tokenizer_repo: &'static str,
|
tokenizer_repo: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-1.5b";
|
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-0.5b";
|
||||||
|
|
||||||
pub const LOCAL_MODELS: &[LocalModelOption] = &[
|
pub const LOCAL_MODELS: &[LocalModelOption] = &[
|
||||||
LocalModelOption {
|
LocalModelOption {
|
||||||
@@ -52,6 +59,59 @@ fn find_local_model(model_id: &str) -> Option<&'static LocalModelOption> {
|
|||||||
LOCAL_MODELS.iter().find(|option| option.id == model_id)
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum CommitAiPhase {
|
pub enum CommitAiPhase {
|
||||||
@@ -75,6 +135,20 @@ struct Inner {
|
|||||||
model_id: Option<String>,
|
model_id: Option<String>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
model: Option<Arc<Model>>,
|
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
|
/// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls
|
||||||
@@ -92,6 +166,7 @@ impl Default for CommitAiEngine {
|
|||||||
model_id: None,
|
model_id: None,
|
||||||
error: None,
|
error: None,
|
||||||
model: None,
|
model: None,
|
||||||
|
cache: None,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +204,7 @@ impl CommitAiEngine {
|
|||||||
guard.phase = CommitAiPhase::Error;
|
guard.phase = CommitAiPhase::Error;
|
||||||
guard.model_id = Some(model_id.to_string());
|
guard.model_id = Some(model_id.to_string());
|
||||||
guard.error = Some(format!("Unknown local model: {model_id}"));
|
guard.error = Some(format!("Unknown local model: {model_id}"));
|
||||||
|
guard.cache = None;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,6 +214,7 @@ impl CommitAiEngine {
|
|||||||
guard.model_id = Some(model_id.to_string());
|
guard.model_id = Some(model_id.to_string());
|
||||||
guard.error = None;
|
guard.error = None;
|
||||||
guard.model = None;
|
guard.model = None;
|
||||||
|
guard.cache = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = GgufModelBuilder::new(option.repo, vec![option.file])
|
let result = GgufModelBuilder::new(option.repo, vec![option.file])
|
||||||
@@ -157,10 +234,12 @@ impl CommitAiEngine {
|
|||||||
guard.model = Some(Arc::new(model));
|
guard.model = Some(Arc::new(model));
|
||||||
guard.phase = CommitAiPhase::Ready;
|
guard.phase = CommitAiPhase::Ready;
|
||||||
guard.error = None;
|
guard.error = None;
|
||||||
|
guard.cache = None;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
guard.phase = CommitAiPhase::Error;
|
guard.phase = CommitAiPhase::Error;
|
||||||
guard.error = Some(err.to_string());
|
guard.error = Some(err.to_string());
|
||||||
|
guard.cache = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,22 +248,36 @@ impl CommitAiEngine {
|
|||||||
&self,
|
&self,
|
||||||
diff: &str,
|
diff: &str,
|
||||||
notes: Option<&str>,
|
notes: Option<&str>,
|
||||||
|
profile: LocalGenerationProfile,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let model = {
|
let (model, cache_key) = {
|
||||||
let guard = self.inner.read().await;
|
let guard = self.inner.read().await;
|
||||||
match (guard.phase, &guard.model) {
|
match (guard.phase, &guard.model) {
|
||||||
(CommitAiPhase::Ready, Some(model)) => model.clone(),
|
(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()),
|
_ => return Err("The local AI model is not ready yet.".to_string()),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (system, user) = build_messages(diff, notes)?;
|
let (system, user) = build_local_messages(diff, notes, profile)?;
|
||||||
let messages = TextMessages::new()
|
let request = RequestBuilder::new()
|
||||||
|
.set_sampler_max_len(profile.max_output_tokens())
|
||||||
.add_message(TextMessageRole::System, system)
|
.add_message(TextMessageRole::System, system)
|
||||||
.add_message(TextMessageRole::User, user);
|
.add_message(TextMessageRole::User, user);
|
||||||
|
|
||||||
let response = model
|
let response = model
|
||||||
.send_chat_request(messages)
|
.send_chat_request(request)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
@@ -198,10 +291,29 @@ impl CommitAiEngine {
|
|||||||
if message.is_empty() {
|
if message.is_empty() {
|
||||||
return Err("The model did not return a response.".to_string());
|
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)
|
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
|
/// Models occasionally ignore the "no code fences" instruction (small local models
|
||||||
/// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go
|
/// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go
|
||||||
/// straight into the commit-message box.
|
/// straight into the commit-message box.
|
||||||
@@ -221,6 +333,74 @@ 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
|
||||||
|
/// sections back instead of writing a commit message. Catch that so the UI can show a
|
||||||
|
/// clear error instead of dumping raw diff text into the commit-message box.
|
||||||
|
pub(crate) fn looks_like_diff_echo(message: &str) -> bool {
|
||||||
|
let lower = message.to_ascii_lowercase();
|
||||||
|
lower.contains("diff --git")
|
||||||
|
|| lower.contains("staged files:")
|
||||||
|
|| lower.contains("staged changes:")
|
||||||
|
|| lower.contains("diff stat:")
|
||||||
|
|| lower.contains("detailed diff:")
|
||||||
|
|| message.lines().any(|line| line.starts_with("@@ "))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_at_char_boundary(input: &str, max_chars: usize) -> String {
|
||||||
|
if input.len() <= max_chars {
|
||||||
|
return input.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cut = max_chars;
|
||||||
|
while !input.is_char_boundary(cut) {
|
||||||
|
cut -= 1;
|
||||||
|
}
|
||||||
|
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());
|
||||||
@@ -228,26 +408,35 @@ pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String,
|
|||||||
|
|
||||||
// Rough token estimate — small models often have an 8-32k context window.
|
// Rough token estimate — small models often have an 8-32k context window.
|
||||||
const MAX_CHARS: usize = 24_000;
|
const MAX_CHARS: usize = 24_000;
|
||||||
let diff = if diff.len() > MAX_CHARS {
|
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
|
||||||
// Pull the byte index back to a valid UTF-8 char boundary, otherwise
|
|
||||||
// slicing mid-multi-byte-character would panic.
|
|
||||||
let mut cut = MAX_CHARS;
|
|
||||||
while !diff.is_char_boundary(cut) {
|
|
||||||
cut -= 1;
|
|
||||||
}
|
|
||||||
format!("{}\n\n[... diff truncated ...]", &diff[..cut])
|
|
||||||
} else {
|
|
||||||
diff.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let system = "You are a tool that generates Git commit messages. \
|
let system = "You are a tool that writes a Git commit message describing a staged diff. \
|
||||||
Respond only with the commit message in Conventional Commits format \
|
Output ONLY the commit message text. Never quote, restate, or paraphrase the diff itself: \
|
||||||
(<type>(<scope>): <subject>), followed by a body after a blank line. \
|
do not include lines starting with 'diff --git', '@@', '+', '-', 'index ', 'Staged files:', \
|
||||||
Subject in imperative mood, max. 72 characters. \
|
or 'Diff stat:' anywhere in your answer. \
|
||||||
The body is required: summarize in a short paragraph what changed and why, \
|
Format: a Conventional Commits header (<type>(<scope>): <subject>) in imperative mood, \
|
||||||
then list the key changes as bullet points (- ...), \
|
max. 72 characters, then a blank line, then a body. \
|
||||||
grouped by affected area/file. Lines in the body max. 72 characters. \
|
The body is required: a short, general paragraph (2-4 sentences) summarizing what changed \
|
||||||
No preamble, no explanation, no code fences, answer in English"
|
and why at a high level — do NOT enumerate every changed file individually. \
|
||||||
|
You may optionally add up to 3 bullet points (- ...) afterward, but only for the most \
|
||||||
|
significant changes overall, never one bullet or heading per file. \
|
||||||
|
Never use bold text, backticks, or markdown headings for file names. \
|
||||||
|
Lines in the body max. 72 characters. \
|
||||||
|
No preamble, no explanation, no code fences, answer in English.\n\n\
|
||||||
|
Example:\n\
|
||||||
|
Diff:\n\
|
||||||
|
diff --git a/src/auth.py b/src/auth.py\n\
|
||||||
|
+def hash_password(pw):\n\
|
||||||
|
+ return bcrypt.hash(pw)\n\
|
||||||
|
diff --git a/src/routes.py b/src/routes.py\n\
|
||||||
|
-if password == stored_password:\n\
|
||||||
|
+if bcrypt.check(password, stored_password):\n\n\
|
||||||
|
Commit message:\n\
|
||||||
|
feat(auth): hash and verify passwords with bcrypt\n\n\
|
||||||
|
Passwords were previously compared as plain text. This adds a bcrypt-based\n\
|
||||||
|
hashing helper and updates the login check to verify against the hash\n\
|
||||||
|
instead of a direct string comparison.\n\n\
|
||||||
|
- Hash passwords on write, verify with bcrypt on login"
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let mut user = String::new();
|
let mut user = String::new();
|
||||||
@@ -257,3 +446,27 @@ No preamble, no explanation, no code fences, answer in English"
|
|||||||
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))
|
||||||
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 755 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,72 @@
|
|||||||
|
!macro GITTY_MIGRATE_LEGACY_DATA_DIR oldDir newDir
|
||||||
|
${IfNot} ${FileExists} "${newDir}\*.*"
|
||||||
|
${AndIf} ${FileExists} "${oldDir}\*.*"
|
||||||
|
Rename "${oldDir}" "${newDir}"
|
||||||
|
${EndIf}
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro GITTY_UNINSTALL_LEGACY_GITLITE rootKey
|
||||||
|
ClearErrors
|
||||||
|
ReadRegStr $R0 ${rootKey} "Software\Microsoft\Windows\CurrentVersion\Uninstall\GitLite" "UninstallString"
|
||||||
|
${If} "$R0" != ""
|
||||||
|
ReadRegStr $R1 ${rootKey} "Software\git-lite\GitLite" ""
|
||||||
|
${If} "$R1" == ""
|
||||||
|
ReadRegStr $R1 ${rootKey} "Software\Microsoft\Windows\CurrentVersion\Uninstall\GitLite" "InstallLocation"
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
DetailPrint "Removing legacy GitLite installation"
|
||||||
|
${If} "$R1" != ""
|
||||||
|
ExecWait '$R0 /S _?=$R1' $R2
|
||||||
|
${Else}
|
||||||
|
ExecWait '$R0 /S' $R2
|
||||||
|
${EndIf}
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
DeleteRegKey ${rootKey} "Software\Microsoft\Windows\CurrentVersion\Uninstall\GitLite"
|
||||||
|
DeleteRegKey ${rootKey} "Software\git-lite\GitLite"
|
||||||
|
DeleteRegKey /ifempty ${rootKey} "Software\git-lite"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro GITTY_CLEANUP_LEGACY_SHORTCUTS
|
||||||
|
Delete "$DESKTOP\GitLite.lnk"
|
||||||
|
Delete "$SMPROGRAMS\GitLite.lnk"
|
||||||
|
RMDir /r "$SMPROGRAMS\GitLite"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro GITTY_REGISTER_INSTALL
|
||||||
|
WriteRegStr HKCU "${MANUPRODUCTKEY}" "" "$INSTDIR"
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "MainBinaryName" "${MAINBINARYNAME}.exe"
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "DisplayName" "${PRODUCTNAME}"
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "DisplayIcon" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\""
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "DisplayVersion" "${VERSION}"
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "Publisher" "${MANUFACTURER}"
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "InstallLocation" "$\"$INSTDIR$\""
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||||
|
WriteRegDWORD HKCU "${UNINSTKEY}" "NoModify" "1"
|
||||||
|
WriteRegDWORD HKCU "${UNINSTKEY}" "NoRepair" "1"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro GITTY_CREATE_UPDATE_SHORTCUTS
|
||||||
|
CreateShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
|
||||||
|
!insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk"
|
||||||
|
CreateShortcut "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
|
||||||
|
!insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro NSIS_HOOK_PREINSTALL
|
||||||
|
!insertmacro GITTY_MIGRATE_LEGACY_DATA_DIR "$APPDATA\com.git-lite" "$APPDATA\com.gitty"
|
||||||
|
!insertmacro GITTY_MIGRATE_LEGACY_DATA_DIR "$LOCALAPPDATA\com.git-lite" "$LOCALAPPDATA\com.gitty"
|
||||||
|
!insertmacro GITTY_UNINSTALL_LEGACY_GITLITE HKCU
|
||||||
|
!insertmacro GITTY_UNINSTALL_LEGACY_GITLITE HKLM
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro NSIS_HOOK_POSTINSTALL
|
||||||
|
!insertmacro GITTY_CLEANUP_LEGACY_SHORTCUTS
|
||||||
|
!insertmacro GITTY_REGISTER_INSTALL
|
||||||
|
!insertmacro GITTY_CREATE_UPDATE_SHORTCUTS
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro NSIS_HOOK_POSTUNINSTALL
|
||||||
|
!insertmacro GITTY_CLEANUP_LEGACY_SHORTCUTS
|
||||||
|
!macroend
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
//! Renders the combined repository attention count as a taskbar badge and applies it as the
|
||||||
|
//! Windows taskbar overlay. Windows has no native numeric taskbar badge (unlike
|
||||||
|
//! macOS/Linux, which support `Window::set_badge_count`), so the numbers have to be drawn
|
||||||
|
//! into a plain RGBA icon ourselves and set via `Window::set_overlay_icon`.
|
||||||
|
|
||||||
|
use tauri::Manager;
|
||||||
|
use tauri::image::Image;
|
||||||
|
|
||||||
|
const GLYPH_WIDTH: usize = 3;
|
||||||
|
const GLYPH_HEIGHT: usize = 5;
|
||||||
|
|
||||||
|
/// A minimal 3x5 pixel-grid font, just enough to draw digits and "+" legibly at badge size.
|
||||||
|
fn glyph_rows(ch: char) -> [&'static str; GLYPH_HEIGHT] {
|
||||||
|
match ch {
|
||||||
|
'0' => ["111", "101", "101", "101", "111"],
|
||||||
|
'1' => ["010", "110", "010", "010", "111"],
|
||||||
|
'2' => ["111", "001", "111", "100", "111"],
|
||||||
|
'3' => ["111", "001", "111", "001", "111"],
|
||||||
|
'4' => ["101", "101", "111", "001", "001"],
|
||||||
|
'5' => ["111", "100", "111", "001", "111"],
|
||||||
|
'6' => ["111", "100", "111", "101", "111"],
|
||||||
|
'7' => ["111", "001", "010", "010", "010"],
|
||||||
|
'8' => ["111", "101", "111", "101", "111"],
|
||||||
|
'9' => ["111", "101", "111", "001", "111"],
|
||||||
|
'+' => ["000", "010", "111", "010", "000"],
|
||||||
|
_ => ["000", "000", "000", "000", "000"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BADGE_FILL: [u8; 4] = [224, 160, 64, 255];
|
||||||
|
const BADGE_BORDER: [u8; 4] = [176, 118, 40, 255];
|
||||||
|
const TEXT_FILL: [u8; 4] = [255, 255, 255, 255];
|
||||||
|
const TEXT_SHADOW: [u8; 4] = [10, 12, 24, 190];
|
||||||
|
const CIRCLE_BORDER_WIDTH: f32 = 3.0;
|
||||||
|
|
||||||
|
/// Caps the displayed text at three characters ("99+") so it always fits legibly.
|
||||||
|
fn cap_text(count: u32) -> String {
|
||||||
|
if count > 99 {
|
||||||
|
"99+".to_string()
|
||||||
|
} else {
|
||||||
|
count.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws a filled circle (with a slightly darker rim) centered at `(cx, cy)`.
|
||||||
|
fn draw_circle(
|
||||||
|
rgba: &mut [u8],
|
||||||
|
size: usize,
|
||||||
|
cx: f32,
|
||||||
|
cy: f32,
|
||||||
|
radius: f32,
|
||||||
|
fill: [u8; 4],
|
||||||
|
border: [u8; 4],
|
||||||
|
) {
|
||||||
|
for y in 0..size {
|
||||||
|
for x in 0..size {
|
||||||
|
let dx = x as f32 + 0.5 - cx;
|
||||||
|
let dy = y as f32 + 0.5 - cy;
|
||||||
|
let dist = (dx * dx + dy * dy).sqrt();
|
||||||
|
if dist <= radius {
|
||||||
|
let color = if dist >= radius - CIRCLE_BORDER_WIDTH {
|
||||||
|
border
|
||||||
|
} else {
|
||||||
|
fill
|
||||||
|
};
|
||||||
|
let idx = (y * size + x) * 4;
|
||||||
|
rgba[idx..idx + 4].copy_from_slice(&color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_text_layer(
|
||||||
|
rgba: &mut [u8],
|
||||||
|
size: usize,
|
||||||
|
text: &str,
|
||||||
|
cx: f32,
|
||||||
|
cy: f32,
|
||||||
|
scale: usize,
|
||||||
|
offset: (isize, isize),
|
||||||
|
color: [u8; 4],
|
||||||
|
) {
|
||||||
|
let glyphs: Vec<[&'static str; GLYPH_HEIGHT]> = text.chars().map(glyph_rows).collect();
|
||||||
|
let glyph_px_w = GLYPH_WIDTH * scale;
|
||||||
|
let glyph_px_h = GLYPH_HEIGHT * scale;
|
||||||
|
let gap = scale.max(1);
|
||||||
|
let total_w = glyphs.len() * glyph_px_w + gap * glyphs.len().saturating_sub(1);
|
||||||
|
let start_x = (cx - total_w as f32 / 2.0).round() as isize + offset.0;
|
||||||
|
let start_y = (cy - glyph_px_h as f32 / 2.0).round() as isize + offset.1;
|
||||||
|
|
||||||
|
for (gi, rows) in glyphs.iter().enumerate() {
|
||||||
|
let glyph_x = start_x + (gi * (glyph_px_w + gap)) as isize;
|
||||||
|
for (gy, row) in rows.iter().enumerate() {
|
||||||
|
for (gx, pixel) in row.chars().enumerate() {
|
||||||
|
if pixel != '1' {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for py in 0..scale {
|
||||||
|
for px in 0..scale {
|
||||||
|
let x = glyph_x + (gx * scale + px) as isize;
|
||||||
|
let y = start_y + (gy * scale + py) as isize;
|
||||||
|
if x >= 0 && y >= 0 && (x as usize) < size && (y as usize) < size {
|
||||||
|
let idx = (y as usize * size + x as usize) * 4;
|
||||||
|
rgba[idx..idx + 4].copy_from_slice(&color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws white `text` centered at `(cx, cy)`, scaling each font pixel up by `scale`.
|
||||||
|
fn draw_text(rgba: &mut [u8], size: usize, text: &str, cx: f32, cy: f32, scale: usize) {
|
||||||
|
for offset in [(-1, 0), (1, 0), (0, -1), (0, 1)] {
|
||||||
|
draw_text_layer(rgba, size, text, cx, cy, scale, offset, TEXT_SHADOW);
|
||||||
|
}
|
||||||
|
draw_text_layer(rgba, size, text, cx, cy, scale, (0, 0), TEXT_FILL);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_scale(text: &str) -> usize {
|
||||||
|
match text.len() {
|
||||||
|
0 | 1 => 7,
|
||||||
|
2 => 5,
|
||||||
|
_ => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a single large badge with `count`. Returns `None` when there's nothing to show,
|
||||||
|
/// so the caller can clear the overlay icon.
|
||||||
|
fn render_badge_icon(count: u32) -> Option<Image<'static>> {
|
||||||
|
if count == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE: usize = 64;
|
||||||
|
const RADIUS: f32 = 29.0;
|
||||||
|
let mut rgba = vec![0u8; SIZE * SIZE * 4];
|
||||||
|
let center = SIZE as f32 / 2.0;
|
||||||
|
|
||||||
|
let text = cap_text(count);
|
||||||
|
draw_circle(
|
||||||
|
&mut rgba,
|
||||||
|
SIZE,
|
||||||
|
center,
|
||||||
|
center,
|
||||||
|
RADIUS,
|
||||||
|
BADGE_FILL,
|
||||||
|
BADGE_BORDER,
|
||||||
|
);
|
||||||
|
draw_text(&mut rgba, SIZE, &text, center, center, text_scale(&text));
|
||||||
|
|
||||||
|
Some(Image::new_owned(rgba, SIZE as u32, SIZE as u32))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the taskbar badge to `ahead + behind + changes` (0 clears it). Windows-only: Windows
|
||||||
|
/// has no native numeric badge API, so this draws and applies a small overlay icon instead.
|
||||||
|
/// No-op on other platforms: non-Windows desktops should use `Window::set_badge_count`
|
||||||
|
/// for a real native badge instead, which this app doesn't currently wire up.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_sync_badge(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
ahead: u32,
|
||||||
|
behind: u32,
|
||||||
|
changes: u32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let count = ahead.saturating_add(behind).saturating_add(changes);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let Some(window) = app.get_webview_window("main") else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let icon = render_badge_icon(count);
|
||||||
|
window
|
||||||
|
.set_overlay_icon(icon)
|
||||||
|
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,55 +1,424 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod badge;
|
||||||
|
mod external_tools;
|
||||||
mod git;
|
mod git;
|
||||||
|
mod telemetry;
|
||||||
|
|
||||||
use git::{
|
use badge::set_sync_badge;
|
||||||
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
|
use external_tools::{
|
||||||
checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models,
|
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
|
||||||
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent,
|
|
||||||
create_branch, cred_delete, cred_load, cred_save, delete_branch,
|
|
||||||
diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches,
|
|
||||||
list_commits, list_file_history, list_repository_files, merge_branch, open_repo_in_explorer,
|
|
||||||
open_repository, open_repository_bundle, open_repository_file, pull, push, read_conflict,
|
|
||||||
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
|
||||||
restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
|
||||||
};
|
};
|
||||||
|
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_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
|
||||||
|
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
|
||||||
|
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
|
||||||
|
delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes,
|
||||||
|
get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, 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, merge_abort, merge_branch,
|
||||||
|
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
|
||||||
|
open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
|
||||||
|
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
|
||||||
|
rename_remote_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
|
||||||
|
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
|
||||||
|
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
||||||
|
set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
|
||||||
|
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
|
||||||
|
untrack_paths, update_remote,
|
||||||
|
};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
|
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
||||||
|
|
||||||
fn main() {
|
struct StartupRepository(Mutex<Option<String>>);
|
||||||
tauri::Builder::default()
|
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct StartupCloneRequest {
|
||||||
|
remote_url: String,
|
||||||
|
parent_path: String,
|
||||||
|
directory_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StartupClone(Mutex<Option<StartupCloneRequest>>);
|
||||||
|
|
||||||
|
fn resolve_startup_path(path: &str, cwd: &Path) -> Option<PathBuf> {
|
||||||
|
let path = path.trim();
|
||||||
|
if path.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = PathBuf::from(path);
|
||||||
|
Some(if path.is_absolute() {
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
cwd.join(path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_request_from_args(
|
||||||
|
args: impl IntoIterator<Item = String>,
|
||||||
|
cwd: &Path,
|
||||||
|
) -> Option<StartupCloneRequest> {
|
||||||
|
let mut args = args.into_iter();
|
||||||
|
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
let remote_url = if arg == "clone" || arg == "--clone" {
|
||||||
|
args.next()
|
||||||
|
} else {
|
||||||
|
arg.strip_prefix("--clone=").map(ToString::to_string)
|
||||||
|
};
|
||||||
|
let Some(remote_url) = remote_url.filter(|value| !value.trim().is_empty()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let target = resolve_startup_path(&args.next()?, cwd)?;
|
||||||
|
let directory_name = target.file_name()?.to_string_lossy().trim().to_string();
|
||||||
|
let parent_path = target.parent()?.to_string_lossy().into_owned();
|
||||||
|
if directory_name.is_empty() || parent_path.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return Some(StartupCloneRequest {
|
||||||
|
remote_url,
|
||||||
|
parent_path,
|
||||||
|
directory_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repository_path_from_args(args: impl IntoIterator<Item = String>, cwd: &Path) -> Option<String> {
|
||||||
|
let mut args = args.into_iter();
|
||||||
|
let mut repository = None;
|
||||||
|
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
if arg == "clone" || arg == "--clone" || arg.starts_with("--clone=") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if arg == "--repo" {
|
||||||
|
repository = args.next();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(path) = arg.strip_prefix("--repo=") {
|
||||||
|
repository = Some(path.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !arg.starts_with('-') {
|
||||||
|
repository = Some(arg);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repository
|
||||||
|
.and_then(|path| resolve_startup_path(&path, cwd))
|
||||||
|
.map(|path| path.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod startup_repository_tests {
|
||||||
|
use super::{clone_request_from_args, repository_path_from_args};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_direct_relative_repository_path() {
|
||||||
|
let path =
|
||||||
|
repository_path_from_args(["projects/repo".to_string()], Path::new("/home/user"));
|
||||||
|
assert_eq!(
|
||||||
|
path.map(|value| value.replace('\\', "/")),
|
||||||
|
Some("/home/user/projects/repo".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_repo_option() {
|
||||||
|
let path = repository_path_from_args(
|
||||||
|
["--repo".to_string(), "/projects/repo".to_string()],
|
||||||
|
Path::new("/home/user"),
|
||||||
|
);
|
||||||
|
assert_eq!(path.as_deref(), Some("/projects/repo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_repo_equals_option() {
|
||||||
|
let path = repository_path_from_args(
|
||||||
|
["--repo=projects/repo".to_string()],
|
||||||
|
Path::new("/home/user"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
path.map(|value| value.replace('\\', "/")),
|
||||||
|
Some("/home/user/projects/repo".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_clone_command_with_exact_relative_target() {
|
||||||
|
let request = clone_request_from_args(
|
||||||
|
[
|
||||||
|
"clone".to_string(),
|
||||||
|
"https://example.com/team/project.git".to_string(),
|
||||||
|
"clones/local-copy".to_string(),
|
||||||
|
],
|
||||||
|
Path::new("/home/user"),
|
||||||
|
)
|
||||||
|
.expect("clone request should be parsed");
|
||||||
|
|
||||||
|
assert_eq!(request.remote_url, "https://example.com/team/project.git");
|
||||||
|
assert_eq!(request.directory_name, "local-copy");
|
||||||
|
assert_eq!(request.parent_path.replace('\\', "/"), "/home/user/clones");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_clone_option_and_clone_equals_option() {
|
||||||
|
for args in [
|
||||||
|
vec![
|
||||||
|
"--clone".to_string(),
|
||||||
|
"git@example.com:team/project.git".to_string(),
|
||||||
|
"/projects/project".to_string(),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
"--clone=git@example.com:team/project.git".to_string(),
|
||||||
|
"/projects/project".to_string(),
|
||||||
|
],
|
||||||
|
] {
|
||||||
|
let request = clone_request_from_args(args, Path::new("/home/user"))
|
||||||
|
.expect("clone option should be parsed");
|
||||||
|
assert_eq!(request.directory_name, "project");
|
||||||
|
assert_eq!(request.parent_path.replace('\\', "/"), "/projects");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_parser_does_not_treat_clone_values_as_repository_paths() {
|
||||||
|
let path = repository_path_from_args(
|
||||||
|
[
|
||||||
|
"--clone".to_string(),
|
||||||
|
"https://example.com/team/project.git".to_string(),
|
||||||
|
"clones/project".to_string(),
|
||||||
|
],
|
||||||
|
Path::new("/home/user"),
|
||||||
|
);
|
||||||
|
assert!(path.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn take_startup_repository(state: tauri::State<'_, StartupRepository>) -> Option<String> {
|
||||||
|
state.0.lock().ok()?.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn take_startup_clone(state: tauri::State<'_, StartupClone>) -> Option<StartupCloneRequest> {
|
||||||
|
state.0.lock().ok()?.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConsoleLogger;
|
||||||
|
|
||||||
|
impl log::Log for ConsoleLogger {
|
||||||
|
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||||
|
metadata.level() <= log::Level::Info
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &log::Record<'_>) {
|
||||||
|
if self.enabled(record.metadata()) {
|
||||||
|
eprintln!(
|
||||||
|
"[{}] [{}] {}",
|
||||||
|
record.level(),
|
||||||
|
record.target(),
|
||||||
|
record.args()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static CONSOLE_LOGGER: ConsoleLogger = ConsoleLogger;
|
||||||
|
|
||||||
|
fn init_console_logging() {
|
||||||
|
if log::set_logger(&CONSOLE_LOGGER).is_ok() {
|
||||||
|
log::set_max_level(log::LevelFilter::Info);
|
||||||
|
log::info!(target: "gitty", "Rust console logging initialized");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
if let Some(window) = app.get_webview_window("splashscreen") {
|
||||||
|
let _ = window.hide();
|
||||||
|
window
|
||||||
|
.destroy()
|
||||||
|
.map_err(|error| format!("failed to destroy splashscreen: {error}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.maximize();
|
||||||
|
window
|
||||||
|
.show()
|
||||||
|
.map_err(|error| format!("failed to show main window: {error}"))?;
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
init_console_logging();
|
||||||
|
telemetry::init();
|
||||||
|
if let Some(result) = run_sequence_editor_if_requested() {
|
||||||
|
if let Err(error) = result {
|
||||||
|
eprintln!("{error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let startup_args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let startup_cwd = std::env::current_dir().unwrap_or_default();
|
||||||
|
let startup_clone = clone_request_from_args(startup_args.clone(), &startup_cwd);
|
||||||
|
let startup_repository = repository_path_from_args(startup_args, &startup_cwd);
|
||||||
|
|
||||||
|
let builder = tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
|
||||||
|
let args: Vec<String> = args.into_iter().skip(1).collect();
|
||||||
|
if let Some(request) = clone_request_from_args(args.clone(), Path::new(&cwd)) {
|
||||||
|
if let Ok(mut pending) = app.state::<StartupClone>().0.lock() {
|
||||||
|
*pending = Some(request);
|
||||||
|
}
|
||||||
|
let _ = app.emit("open-startup-repository", ());
|
||||||
|
} else if let Some(path) = repository_path_from_args(args, Path::new(&cwd)) {
|
||||||
|
if let Ok(mut pending) = app.state::<StartupRepository>().0.lock() {
|
||||||
|
*pending = Some(path);
|
||||||
|
}
|
||||||
|
let _ = app.emit("open-startup-repository", ());
|
||||||
|
}
|
||||||
|
#[cfg(desktop)]
|
||||||
|
let _ = app
|
||||||
|
.get_webview_window("main")
|
||||||
|
.expect("no main window")
|
||||||
|
.set_focus();
|
||||||
|
}))
|
||||||
|
.plugin(
|
||||||
|
tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
|
||||||
|
.with_options(tauri_plugin_aptabase::InitOptions {
|
||||||
|
host: Some("https://aptabase.cbsk-tech.de".to_string()),
|
||||||
|
flush_interval: None,
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.manage(StartupRepository(Mutex::new(startup_repository)))
|
||||||
|
.manage(StartupClone(Mutex::new(startup_clone)))
|
||||||
.manage(SearchCancellationState::default())
|
.manage(SearchCancellationState::default())
|
||||||
.manage(commit_ai::CommitAiEngine::new())
|
.manage(commit_ai::CommitAiEngine::new())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init());
|
||||||
|
|
||||||
|
// Linux installs are expected to come from the system package manager (see the
|
||||||
|
// PKGBUILD), which owns updates itself — the self-updater is only wired up for
|
||||||
|
// the platforms whose install method this app ships (NSIS/Windows, .app/macOS).
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||||
|
|
||||||
|
builder
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
open_repository,
|
open_repository,
|
||||||
|
init_repository,
|
||||||
|
clone_repository,
|
||||||
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,
|
||||||
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,
|
||||||
|
create_tag,
|
||||||
|
delete_tag,
|
||||||
|
push_tag,
|
||||||
|
cherry_pick_commit,
|
||||||
|
cherry_pick_continue,
|
||||||
|
cherry_pick_abort,
|
||||||
stage_files,
|
stage_files,
|
||||||
unstage_files,
|
unstage_files,
|
||||||
|
add_to_gitignore,
|
||||||
|
untrack_paths,
|
||||||
|
stash_push,
|
||||||
|
stash_apply,
|
||||||
|
stash_pop,
|
||||||
|
stash_drop,
|
||||||
restore_files,
|
restore_files,
|
||||||
get_file_patch,
|
get_file_patch,
|
||||||
apply_file_patch,
|
apply_file_patch,
|
||||||
commit,
|
commit,
|
||||||
|
amend_commit,
|
||||||
|
undo_last_commit,
|
||||||
|
last_commit_message,
|
||||||
commit_ai_status,
|
commit_ai_status,
|
||||||
commit_ai_load,
|
commit_ai_load,
|
||||||
commit_ai_local_models,
|
commit_ai_local_models,
|
||||||
commit_ai_generate,
|
commit_ai_generate,
|
||||||
|
commit_ai_review,
|
||||||
|
commit_ai_split,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
|
fetch,
|
||||||
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_continue,
|
||||||
|
rebase_abort,
|
||||||
|
list_interactive_rebase_commits,
|
||||||
|
start_interactive_rebase,
|
||||||
|
list_reflog,
|
||||||
|
restore_reflog_entry,
|
||||||
list_repository_files,
|
list_repository_files,
|
||||||
open_repository_bundle,
|
open_repository_bundle,
|
||||||
list_file_history,
|
list_file_history,
|
||||||
cancel_file_history,
|
cancel_file_history,
|
||||||
|
get_file_blame,
|
||||||
compare_commits,
|
compare_commits,
|
||||||
compare_file_to_head,
|
compare_file_to_head,
|
||||||
compare_file_to_parent,
|
compare_file_to_parent,
|
||||||
@@ -62,7 +431,14 @@ fn main() {
|
|||||||
get_remote_url,
|
get_remote_url,
|
||||||
cred_load,
|
cred_load,
|
||||||
cred_save,
|
cred_save,
|
||||||
cred_delete
|
cred_delete,
|
||||||
|
set_sync_badge,
|
||||||
|
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,25 +1,38 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "GitLite",
|
"productName": "Gitty",
|
||||||
"version": "2026.7.6",
|
"version": "2026.8.7",
|
||||||
"identifier": "com.git-lite",
|
"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": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
"title": "GitLite",
|
"title": "Gitty",
|
||||||
"width": 1200,
|
"width": 1200,
|
||||||
"height": 800,
|
"height": 800,
|
||||||
"minWidth": 900,
|
"minWidth": 900,
|
||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
|
"visible": false,
|
||||||
"decorations": false,
|
"decorations": false,
|
||||||
"shadow": true
|
"shadow": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "splashscreen",
|
||||||
|
"url": "splashscreen.html",
|
||||||
|
"title": "Gitty",
|
||||||
|
"width": 460,
|
||||||
|
"height": 420,
|
||||||
|
"resizable": false,
|
||||||
|
"decorations": false,
|
||||||
|
"center": true,
|
||||||
|
"skipTaskbar": true,
|
||||||
|
"shadow": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
@@ -28,19 +41,36 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["nsis"],
|
"externalBin": [
|
||||||
"icon": ["icons/icon.ico"],
|
"binaries/git-lfs"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
"binaries/git-lfs-LICENSE.txt"
|
||||||
|
],
|
||||||
|
"targets": [
|
||||||
|
"nsis"
|
||||||
|
],
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
"createUpdaterArtifacts": true,
|
"createUpdaterArtifacts": true,
|
||||||
"windows": {
|
"windows": {
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"installMode": "currentUser"
|
"installMode": "currentUser",
|
||||||
|
"installerIcon": "icons/icon.ico",
|
||||||
|
"installerHooks": "nsis-hooks.nsh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdDNEJGRTZERTREOTc5QjkKUldTNWVkbmtiZjVMZk02YzdpenVLOVltREJpRkFrc2F4dUwzaTV5M3pacFRVR0tmd25xSGtjK1MK",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdDNEJGRTZERTREOTc5QjkKUldTNWVkbmtiZjVMZk02YzdpenVLOVltREJpRkFrc2F4dUwzaTV5M3pacFRVR0tmd25xSGtjK1MK",
|
||||||
"endpoints" : [
|
"endpoints": [
|
||||||
|
"https://cdn.cbsk-tech.de/gitty/latest.json",
|
||||||
"https://cdn.cbsk-tech.de/gitlite/latest.json"
|
"https://cdn.cbsk-tech.de/gitlite/latest.json"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"bundle": {
|
||||||
|
"targets": ["appimage"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { BookOpen, Database, Plus, X } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface RepositoryTabItem {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
branch: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export let activeView: "management" | "repository" = "management";
|
||||||
|
export let repoTabs: RepositoryTabItem[] = [];
|
||||||
|
export let isBusy: boolean = false;
|
||||||
|
export let language: "en" | "de" = "en";
|
||||||
|
export let onOpenManagement: () => void = () => {};
|
||||||
|
export let isActive: (path: string) => boolean = () => false;
|
||||||
|
export let onSelect: (path: string) => void | Promise<void> = () => {};
|
||||||
|
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
|
||||||
|
export let onContextMenu: (path: string, event: MouseEvent) => void = () => {};
|
||||||
|
export let onAdd: () => void | Promise<void> = () => {};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="repo-tabbar" aria-label={language === "de" ? "Repository-Reiter" : "Repository tabs"}>
|
||||||
|
<button
|
||||||
|
class="repo-tab management"
|
||||||
|
class:active={activeView === "management"}
|
||||||
|
type="button"
|
||||||
|
onclick={onOpenManagement}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
||||||
|
>
|
||||||
|
<BookOpen size={14} aria-hidden="true" />
|
||||||
|
<span>{language === "de" ? "Repository-Verwaltung" : "Repository Management"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="repo-tabs-scroll">
|
||||||
|
{#each repoTabs as repo (repo.path)}
|
||||||
|
<div
|
||||||
|
class="repo-tab-wrap"
|
||||||
|
class:active={isActive(repo.path)}
|
||||||
|
role="presentation"
|
||||||
|
oncontextmenu={(event) => onContextMenu(repo.path, event)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="repo-tab"
|
||||||
|
type="button"
|
||||||
|
onclick={() => onSelect(repo.path)}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={repo.path}
|
||||||
|
>
|
||||||
|
<Database size={15} aria-hidden="true" />
|
||||||
|
<span>{repo.name}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="repo-tab-close"
|
||||||
|
type="button"
|
||||||
|
onclick={(event) => onClose(repo.path, event)}
|
||||||
|
disabled={isBusy}
|
||||||
|
aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
|
||||||
|
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
|
||||||
|
>
|
||||||
|
<X size={11} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="repo-tab-add"
|
||||||
|
type="button"
|
||||||
|
onclick={onAdd}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
|
||||||
|
aria-label={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
|
||||||
|
>
|
||||||
|
<Plus size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
ChevronDown,
|
||||||
|
Code2,
|
||||||
|
CloudDownload,
|
||||||
|
CloudOff,
|
||||||
|
Download,
|
||||||
|
FolderOpen,
|
||||||
|
GitCompare,
|
||||||
|
History,
|
||||||
|
ListRestart,
|
||||||
|
LoaderCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Upload,
|
||||||
|
Settings2,
|
||||||
|
Terminal,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
|
export let hasRepository: boolean = false;
|
||||||
|
export let isBusy: boolean = false;
|
||||||
|
export let operation: string = "";
|
||||||
|
export let ahead: number = 0;
|
||||||
|
export let behind: number = 0;
|
||||||
|
export let localOnly: boolean = false;
|
||||||
|
export let language: "en" | "de" = "en";
|
||||||
|
export let editorName: string = "Editor";
|
||||||
|
export let terminalName: string = "Terminal";
|
||||||
|
export let fileManagerName: string = "Explorer";
|
||||||
|
export let onFetch: () => void = () => {};
|
||||||
|
export let onPull: () => void = () => {};
|
||||||
|
export let onPush: () => void = () => {};
|
||||||
|
export let onRefresh: () => void = () => {};
|
||||||
|
export let onSearch: () => void = () => {};
|
||||||
|
export let onCompare: () => void = () => {};
|
||||||
|
export let onInteractiveRebase: () => void = () => {};
|
||||||
|
export let onReflog: () => void = () => {};
|
||||||
|
export let onOpenInExplorer: () => void = () => {};
|
||||||
|
export let onOpenInEditor: () => void = () => {};
|
||||||
|
export let onOpenTerminal: () => void = () => {};
|
||||||
|
export let onFetchPrune: () => void = () => {};
|
||||||
|
export let onForcePush: () => void = () => {};
|
||||||
|
export let onSyncOptions: () => void = () => {};
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
@@ -1,215 +1,100 @@
|
|||||||
<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 { Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
import { CircleHelp, Minus, Settings, X } from "@lucide/svelte";
|
||||||
|
import iconUrl from "../../src-tauri/icons/icon.png";
|
||||||
|
|
||||||
export let branch: string = "";
|
export let onOpenHelp: () => void = () => {};
|
||||||
export let ahead: number = 0;
|
export let onOpenSettings: () => void = () => {};
|
||||||
export let behind: number = 0;
|
export let language: "en" | "de" = "en";
|
||||||
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 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 = () => {};
|
|
||||||
|
|
||||||
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>
|
||||||
<!-- Brand -->
|
<!-- Brand -->
|
||||||
<div class="titlebar-brand" data-tauri-drag-region>
|
<div class="titlebar-brand" data-tauri-drag-region>
|
||||||
<svg
|
<span class="titlebar-brand-icon" data-tauri-drag-region>
|
||||||
width="15"
|
<img src={iconUrl} alt="" data-tauri-drag-region />
|
||||||
height="15"
|
</span>
|
||||||
viewBox="0 0 24 24"
|
<span data-tauri-drag-region>Gitty</span>
|
||||||
fill="none"
|
</div>
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
<div class="titlebar-drag" data-tauri-drag-region aria-hidden="true"></div>
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
<!-- Right: app-global actions + window controls -->
|
||||||
aria-hidden="true"
|
<div class="titlebar-globals">
|
||||||
data-tauri-drag-region
|
<button
|
||||||
|
class="tb-action"
|
||||||
|
onclick={onOpenHelp}
|
||||||
|
title={language === "de" ? "Hilfe (Ctrl+/)" : "Help (Ctrl+/)"}
|
||||||
|
aria-label={language === "de" ? "Hilfe öffnen" : "Open help"}
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="18" r="3" />
|
<CircleHelp size={14} aria-hidden="true" />
|
||||||
<circle cx="6" cy="6" r="3" />
|
</button>
|
||||||
<circle cx="18" cy="6" r="3" />
|
<button
|
||||||
<path d="M18 9v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9" />
|
class="tb-action"
|
||||||
<line x1="12" y1="12" x2="12" y2="15" />
|
onclick={onOpenSettings}
|
||||||
</svg>
|
title={language === "de" ? "Einstellungen" : "Settings"}
|
||||||
<span data-tauri-drag-region>GitLite</span>
|
aria-label={language === "de" ? "Einstellungen" : "Settings"}
|
||||||
{#if appVersion}
|
>
|
||||||
<span class="tb-version" data-tauri-drag-region title="Version {appVersion}">v{appVersion}</span>
|
<Settings size={14} aria-hidden="true" />
|
||||||
{/if}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: repo + branch info -->
|
<div class="titlebar-controls">
|
||||||
<div class="titlebar-info" data-tauri-drag-region>
|
<button class="tb-btn" onclick={minimizeWindow} title="Minimize" aria-label="Minimize">
|
||||||
{#if hasRepository}
|
<Minus size={12} aria-hidden="true" />
|
||||||
{#if repoName}
|
</button>
|
||||||
<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-btn"
|
||||||
|
onclick={toggleMaximizeWindow}
|
||||||
|
title={isMaximized ? "Restore" : "Maximize"}
|
||||||
|
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||||
|
>
|
||||||
|
{#if isMaximized}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
|
<rect x="3" y="1" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<path d="M1 3v7a1 1 0 0 0 1 1h7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
|
<rect x="1" y="1" width="10" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
<GitBranch size={12} aria-hidden="true" />
|
</button>
|
||||||
<span class="tb-branch" data-tauri-drag-region>{branch}</span>
|
<button class="tb-btn close" onclick={closeWindow} title="Close" aria-label="Close">
|
||||||
{#if ahead > 0}
|
<X size={12} aria-hidden="true" />
|
||||||
<span class="tb-sync ahead" title="{ahead} commits ahead">↑{ahead}</span>
|
</button>
|
||||||
{/if}
|
|
||||||
{#if behind > 0}
|
|
||||||
<span class="tb-sync behind" title="{behind} commits behind">↓{behind}</span>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<span class="tb-no-repo" data-tauri-drag-region>No repository open</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: actions + window controls -->
|
|
||||||
<div class="titlebar-right">
|
|
||||||
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onOpenInExplorer}
|
|
||||||
disabled={!hasRepository || isBusy}
|
|
||||||
title="Open repository in Explorer"
|
|
||||||
aria-label="Open repository in Explorer"
|
|
||||||
>
|
|
||||||
<FolderOpen size={14} aria-hidden="true" />
|
|
||||||
<span class="tb-action-label">Explorer</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onSearch}
|
|
||||||
disabled={!hasRepository || isBusy}
|
|
||||||
title="Global search"
|
|
||||||
aria-label="Global search"
|
|
||||||
>
|
|
||||||
<Search size={14} aria-hidden="true" />
|
|
||||||
<span class="tb-action-label">Search</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onCompare}
|
|
||||||
disabled={!hasRepository || isBusy}
|
|
||||||
title="Compare commits"
|
|
||||||
aria-label="Compare commits"
|
|
||||||
>
|
|
||||||
<GitCompare size={14} aria-hidden="true" />
|
|
||||||
<span class="tb-action-label">Compare</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={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>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { telemetryLog } from "./telemetry";
|
||||||
|
|
||||||
|
export type AnalyticsEventProperties = Record<string, string | number>;
|
||||||
|
|
||||||
|
export function trackAnalyticsEvent(name: string, props?: AnalyticsEventProperties) {
|
||||||
|
const details = props && Object.keys(props).length > 0 ? ` ${JSON.stringify(props)}` : "";
|
||||||
|
telemetryLog("info", `${name}${details}`, `product.${name}`);
|
||||||
|
void invoke("plugin:aptabase|track_event", {
|
||||||
|
name,
|
||||||
|
props: props && Object.keys(props).length > 0 ? props : undefined,
|
||||||
|
}).catch(() => {
|
||||||
|
// Analytics must never interrupt the Git workflow.
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { GitCommitHorizontal, LoaderCircle, Sparkles, X } from "@lucide/svelte";
|
||||||
|
import type { AiCommitPlan } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
plan: AiCommitPlan;
|
||||||
|
isApplying: boolean;
|
||||||
|
onApply: (plan: AiCommitPlan) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { plan, isApplying = false, onApply, onClose }: Props = $props();
|
||||||
|
let draft = $state<AiCommitPlan>({ summary: "", groups: [] });
|
||||||
|
$effect.pre(() => {
|
||||||
|
if (draft.groups.length === 0) draft = structuredClone(plan);
|
||||||
|
});
|
||||||
|
let valid = $derived(draft.groups.length > 1 && draft.groups.every((group) => group.message.trim() && group.files.length));
|
||||||
|
let validationIssue = $derived(
|
||||||
|
draft.groups.findIndex((group) => group.files.length === 0) >= 0
|
||||||
|
? "Every commit needs at least one file."
|
||||||
|
: draft.groups.findIndex((group) => !group.message.trim()) >= 0
|
||||||
|
? "Every commit needs a message."
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
|
||||||
|
function setMessage(index: number, message: string) {
|
||||||
|
draft.groups[index].message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveFile(file: string, from: number, to: number) {
|
||||||
|
if (from === to) return;
|
||||||
|
draft.groups[from].files = draft.groups[from].files.filter((path) => path !== file);
|
||||||
|
draft.groups[to].files = [...draft.groups[to].files, file];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDraft() {
|
||||||
|
// `draft` is a deeply reactive Svelte proxy. `structuredClone(draft)`
|
||||||
|
// throws a DataCloneError before the callback runs, which made the button
|
||||||
|
// appear to do nothing. A state snapshot is a plain, cloneable object.
|
||||||
|
onApply($state.snapshot(draft));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={(event) => { if (event.key === "Escape" && !isApplying) onClose(); }} />
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog split-dialog" role="dialog" aria-modal="true" aria-label="AI commit split">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div><span class="eyebrow">Staged changes</span><h2>Split into logical commits</h2></div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isApplying} aria-label="Close"><X size={18} /></button>
|
||||||
|
</header>
|
||||||
|
<div class="split-intro"><Sparkles size={18} /><p>{draft.summary}</p></div>
|
||||||
|
<div class="split-groups">
|
||||||
|
{#each draft.groups as group, groupIndex}
|
||||||
|
<article class:empty={group.files.length === 0}>
|
||||||
|
<header><GitCommitHorizontal size={17} /><strong>Commit {groupIndex + 1}</strong><span>{group.files.length} files</span></header>
|
||||||
|
<label>
|
||||||
|
<span>Commit message <em>AI-generated · editable</em></span>
|
||||||
|
<input value={group.message} oninput={(event) => setMessage(groupIndex, event.currentTarget.value)} disabled={isApplying} />
|
||||||
|
</label>
|
||||||
|
{#if group.reason}<p>{group.reason}</p>{/if}
|
||||||
|
<div class="split-files">
|
||||||
|
{#each group.files as file}
|
||||||
|
<div><code>{file}</code>
|
||||||
|
<SelectMenu class="split-file-target" value={String(groupIndex)} options={draft.groups.map((_, target) => ({ value: String(target), label: `Commit ${target + 1}` }))} disabled={isApplying} onChange={(value) => moveFile(file, groupIndex, Number(value))} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<p class:invalid={!!validationIssue}>{validationIssue || "Messages are generated automatically. All commits are created in this order."}</p>
|
||||||
|
<div><button class="btn-secondary" type="button" onclick={onClose} disabled={isApplying}>Cancel</button>
|
||||||
|
<button class="btn-primary" type="button" onclick={applyDraft} disabled={!valid || isApplying}>
|
||||||
|
{#if isApplying}<LoaderCircle class="spin" size={14} />{/if}Commit all ({draft.groups.length})
|
||||||
|
</button></div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.split-dialog{width:min(820px,calc(100vw - 32px));max-height:min(820px,calc(100vh - 32px));display:flex;flex-direction:column}
|
||||||
|
.split-intro{display:flex;gap:10px;align-items:flex-start;padding:14px 18px;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
|
||||||
|
.split-intro p{margin:0;line-height:1.5}
|
||||||
|
.split-groups{display:grid;gap:10px;padding:14px 18px;overflow:auto}
|
||||||
|
article{display:grid;gap:10px;padding:13px;border:1px solid var(--color-border-subtle);border-radius:9px;background:var(--color-surface-raised)}
|
||||||
|
article.empty{border-color:#d88a45}
|
||||||
|
article>header{display:flex;align-items:center;gap:8px;color:var(--color-ink)}
|
||||||
|
article>header span{margin-left:auto;color:var(--color-ink-faint);font-size:11px}
|
||||||
|
label{display:grid;gap:5px;color:var(--color-ink-faint);font-size:10px;font-weight:800;text-transform:uppercase}
|
||||||
|
label span{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||||
|
label em{color:var(--color-accent);font-size:9px;font-style:normal;font-weight:700;text-transform:none}
|
||||||
|
input{height:34px;padding:0 10px;border:1px solid var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-ink);font-family:var(--font-mono)}
|
||||||
|
article>p{margin:0;color:var(--color-ink-muted);font-size:12px;line-height:1.45}
|
||||||
|
.split-files{display:grid;gap:5px}
|
||||||
|
.split-files>div{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;background:var(--color-surface)}
|
||||||
|
code{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;color:var(--color-ink-muted);font-size:11px;white-space:nowrap}
|
||||||
|
:global(.split-file-target){width:110px;flex:0 0 110px}
|
||||||
|
:global(.split-file-target .select-menu-trigger){height:28px;min-height:28px;font-size:11px}
|
||||||
|
.dialog-footer p.invalid{color:#e0a040}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertTriangle, CircleAlert, FileCode, Info, LoaderCircle, RotateCw, ShieldCheck, X } from "@lucide/svelte";
|
||||||
|
import type { AiReviewFinding, AiReviewResult, CommitAiProvider } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
result: AiReviewResult;
|
||||||
|
provider: CommitAiProvider;
|
||||||
|
isReviewing: boolean;
|
||||||
|
onRerun: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { result, provider, isReviewing = false, onRerun, onClose }: Props = $props();
|
||||||
|
|
||||||
|
function providerLabel(value: CommitAiProvider): string {
|
||||||
|
if (value === "openai") return "OpenAI";
|
||||||
|
if (value === "anthropic") return "Anthropic";
|
||||||
|
if (value === "custom") return "Custom endpoint";
|
||||||
|
return "Local AI";
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationLabel(finding: AiReviewFinding): string {
|
||||||
|
if (!finding.file) return "Repository-wide";
|
||||||
|
return finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={(event) => { if (event.key === "Escape" && !isReviewing) onClose(); }} />
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog ai-review-dialog" role="dialog" aria-modal="true" aria-label="AI pre-commit review" tabindex="-1">
|
||||||
|
<header class="dialog-header ai-review-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Staged changes</span>
|
||||||
|
<h2>AI pre-commit review</h2>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-header-actions">
|
||||||
|
<span class="ai-review-provider">{providerLabel(provider)}</span>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isReviewing} aria-label="Close review"><X size={18} aria-hidden="true" /></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="ai-review-summary">
|
||||||
|
<div class="ai-review-summary-icon" class:clean={result.findings.length === 0}>
|
||||||
|
{#if result.findings.length === 0}<ShieldCheck size={22} aria-hidden="true" />{:else}<AlertTriangle size={22} aria-hidden="true" />{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="ai-review-summary-line">
|
||||||
|
<strong>{result.findings.length === 0 ? "No actionable issues found" : `${result.findings.length} review ${result.findings.length === 1 ? "finding" : "findings"}`}</strong>
|
||||||
|
<span class="ai-review-risk {result.risk}">{result.risk} risk</span>
|
||||||
|
</div>
|
||||||
|
<p>{result.summary}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-review-findings">
|
||||||
|
{#if result.findings.length === 0}
|
||||||
|
<div class="ai-review-clean-state">
|
||||||
|
<ShieldCheck size={30} aria-hidden="true" />
|
||||||
|
<strong>The staged diff looks ready for human verification.</strong>
|
||||||
|
<span>AI reviews can miss issues. Run the relevant tests before committing.</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each result.findings as finding, index (`${finding.file ?? "repo"}:${finding.line ?? 0}:${finding.title}:${index}`)}
|
||||||
|
<article class="ai-review-finding {finding.severity}">
|
||||||
|
<div class="ai-review-finding-icon">
|
||||||
|
{#if finding.severity === "critical"}<CircleAlert size={17} aria-hidden="true" />{:else if finding.severity === "warning"}<AlertTriangle size={17} aria-hidden="true" />{:else}<Info size={17} aria-hidden="true" />{/if}
|
||||||
|
</div>
|
||||||
|
<div class="ai-review-finding-body">
|
||||||
|
<div class="ai-review-finding-title">
|
||||||
|
<span>{finding.severity}</span>
|
||||||
|
<strong>{finding.title}</strong>
|
||||||
|
</div>
|
||||||
|
<p>{finding.description}</p>
|
||||||
|
<div class="ai-review-location"><FileCode size={13} aria-hidden="true" /><code>{locationLabel(finding)}</code></div>
|
||||||
|
{#if finding.suggestion}<div class="ai-review-suggestion"><strong>Suggested next step</strong><span>{finding.suggestion}</span></div>{/if}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="dialog-footer ai-review-footer">
|
||||||
|
<p>Review suggestions are advisory and never modify files automatically.</p>
|
||||||
|
<div>
|
||||||
|
<button class="btn-secondary" type="button" onclick={onRerun} disabled={isReviewing}>
|
||||||
|
{#if isReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<RotateCw size={14} aria-hidden="true" />{/if}
|
||||||
|
Review again
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" type="button" onclick={onClose} disabled={isReviewing}>Done</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Globe, Key, LoaderCircle, X } from "@lucide/svelte";
|
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
|
||||||
import { credDelete, credLoad, credSave } from "../git";
|
import { credDelete, credLoad, credSave } from "../git";
|
||||||
import type { AiSettings, CommitAiProvider, LocalModelOption } from "../types";
|
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: AiSettings;
|
settings: AiSettings;
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
|
|
||||||
let provider = $state<CommitAiProvider>("local");
|
let provider = $state<CommitAiProvider>("local");
|
||||||
let localModelId = $state("");
|
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("");
|
||||||
@@ -35,16 +37,28 @@
|
|||||||
let loadingKeys = $state(true);
|
let loadingKeys = $state(true);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
|
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
provider = settings.provider;
|
provider = settings.provider;
|
||||||
localModelId = settings.localModelId;
|
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;
|
||||||
customModel = settings.customModel;
|
customModel = settings.customModel;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||||
|
const currentError = error;
|
||||||
|
if (currentError) {
|
||||||
|
errorHideTimer = setTimeout(() => {
|
||||||
|
if (error === currentError) error = "";
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -64,11 +78,15 @@
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||||
|
});
|
||||||
|
|
||||||
async function persistKey(target: CloudProvider, value: string) {
|
async function persistKey(target: CloudProvider, value: string) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -86,6 +104,7 @@
|
|||||||
onSave({
|
onSave({
|
||||||
provider,
|
provider,
|
||||||
localModelId,
|
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(),
|
||||||
@@ -102,13 +121,27 @@
|
|||||||
return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`;
|
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));
|
let selectedLocalModel = $derived(localModels.find((option) => option.id === localModelId));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="dialog-backdrop"
|
class="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
>
|
||||||
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
|
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
@@ -123,9 +156,16 @@
|
|||||||
|
|
||||||
<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" class:active={provider === "local"} onclick={() => { provider = "local"; }}>
|
<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" />
|
<Cpu size={16} aria-hidden="true" />
|
||||||
Local AI
|
Local AI
|
||||||
|
<span class="ai-provider-badge">In development</span>
|
||||||
</button>
|
</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" />
|
||||||
@@ -142,13 +182,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if provider === "local"}
|
{#if provider === "local"}
|
||||||
|
<div class="cred-field">
|
||||||
|
<span class="cred-field-label">Local speed</span>
|
||||||
|
<div class="ai-local-profile-options" role="radiogroup" aria-label="Local AI speed">
|
||||||
|
<button type="button" class="ai-local-profile-option" class:active={localProfile === "fast"} onclick={() => selectLocalProfile("fast")}>
|
||||||
|
<Zap size={15} aria-hidden="true" />
|
||||||
|
Fast
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ai-local-profile-option" class:active={localProfile === "balanced"} onclick={() => selectLocalProfile("balanced")}>
|
||||||
|
<Gauge size={15} aria-hidden="true" />
|
||||||
|
Balanced
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ai-local-profile-option" class:active={localProfile === "detailed"} onclick={() => selectLocalProfile("detailed")}>
|
||||||
|
<Sparkles size={15} aria-hidden="true" />
|
||||||
|
Detailed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="cred-field">
|
<label class="cred-field">
|
||||||
<span class="cred-field-label">Model</span>
|
<span class="cred-field-label">Model</span>
|
||||||
<select bind:value={localModelId}>
|
<SelectMenu value={localModelId} options={localModels.map((option) => ({ value: option.id, label: `${option.label} - ${formatSize(option.approx_size_mb)}` }))} onChange={(value) => { localModelId = value; }} />
|
||||||
{#each localModels as option (option.id)}
|
|
||||||
<option value={option.id}>{option.label} — {formatSize(option.approx_size_mb)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<div class="cred-token-hint">
|
<div class="cred-token-hint">
|
||||||
<AlertCircle size={13} aria-hidden="true" />
|
<AlertCircle size={13} aria-hidden="true" />
|
||||||
@@ -156,6 +209,7 @@
|
|||||||
Switching downloads the model{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""}
|
Switching downloads the model{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""}
|
||||||
in the background — depending on your internet connection this can take several minutes.
|
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.
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if provider === "openai"}
|
{:else if provider === "openai"}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { BarChart3, Check, ShieldCheck } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
enabled: boolean;
|
||||||
|
onContinue: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { enabled = true, onContinue = () => {} }: Props = $props();
|
||||||
|
let allowAnalytics = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
allowAnalytics = enabled;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||||
|
<div class="dialog analytics-notice-dialog" role="dialog" aria-modal="true" aria-label="Analytics notice" tabindex="-1">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Privacy</span>
|
||||||
|
<h2 class="dialog-title">Anonymous usage analytics</h2>
|
||||||
|
</div>
|
||||||
|
<ShieldCheck size={20} aria-hidden="true" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="analytics-notice-body">
|
||||||
|
<div class="analytics-notice-icon">
|
||||||
|
<BarChart3 size={24} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div class="analytics-notice-copy">
|
||||||
|
<p>
|
||||||
|
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>
|
||||||
|
Telemetry does not include repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, or source code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="settings-toggle-row analytics-notice-toggle">
|
||||||
|
<input type="checkbox" bind:checked={allowAnalytics} />
|
||||||
|
<span>
|
||||||
|
<strong>Allow anonymous analytics</strong>
|
||||||
|
<small>You can change this later in Settings.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="dialog-footer analytics-notice-footer">
|
||||||
|
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage and technical errors.</span>
|
||||||
|
<button class="btn-primary" type="button" onclick={() => onContinue(allowAnalytics)}>
|
||||||
|
<Check size={16} aria-hidden="true" />
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,675 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
CircleDashed,
|
||||||
|
Code2,
|
||||||
|
FolderOpen,
|
||||||
|
GitCompare,
|
||||||
|
GitMerge,
|
||||||
|
Languages,
|
||||||
|
Palette,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCw,
|
||||||
|
Settings2,
|
||||||
|
ShieldCheck,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Terminal,
|
||||||
|
Wrench,
|
||||||
|
X,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import {
|
||||||
|
applyExternalToolPreset,
|
||||||
|
defaultExternalToolsSettings,
|
||||||
|
externalToolPresets,
|
||||||
|
isExternalToolPresetAvailable,
|
||||||
|
type ExternalToolKind,
|
||||||
|
type ExternalToolPreset,
|
||||||
|
} from "../externalTools";
|
||||||
|
import type {
|
||||||
|
AnalyticsSettings,
|
||||||
|
AppAppearance,
|
||||||
|
AppLanguage,
|
||||||
|
AppTheme,
|
||||||
|
CustomThemeColors,
|
||||||
|
DetectedExternalTool,
|
||||||
|
ExternalToolsSettings,
|
||||||
|
ToolOpenMode,
|
||||||
|
} from "../types";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
|
type SettingsPage = "general" | "tools";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
analytics: AnalyticsSettings;
|
||||||
|
theme: AppTheme;
|
||||||
|
appearance: AppAppearance;
|
||||||
|
customTheme: CustomThemeColors;
|
||||||
|
language: AppLanguage;
|
||||||
|
autoRefresh: boolean;
|
||||||
|
externalTools: ExternalToolsSettings;
|
||||||
|
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) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
analytics,
|
||||||
|
theme = "system",
|
||||||
|
appearance = "modern",
|
||||||
|
customTheme = { background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" },
|
||||||
|
language = "en",
|
||||||
|
autoRefresh = true,
|
||||||
|
externalTools,
|
||||||
|
detectedTools = [],
|
||||||
|
detectionPending = false,
|
||||||
|
detectionUnavailable = false,
|
||||||
|
onRefreshDetectedTools = () => {},
|
||||||
|
onSave = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const toolKinds: ExternalToolKind[] = ["editor", "diff", "merge", "terminal", "fileManager"];
|
||||||
|
|
||||||
|
let activePage = $state<SettingsPage>("tools");
|
||||||
|
let activeToolKind = $state<ExternalToolKind>("editor");
|
||||||
|
let advancedOpen = $state(false);
|
||||||
|
let analyticsEnabled = $state(true);
|
||||||
|
let selectedTheme = $state<AppTheme>("system");
|
||||||
|
let 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());
|
||||||
|
const isGerman = $derived(selectedLanguage === "de");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
analyticsEnabled = analytics.enabled;
|
||||||
|
selectedTheme = theme;
|
||||||
|
selectedAppearance = appearance;
|
||||||
|
customColors = structuredClone(customTheme);
|
||||||
|
selectedLanguage = language;
|
||||||
|
autoRefreshEnabled = autoRefresh;
|
||||||
|
tools = structuredClone(externalTools);
|
||||||
|
});
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
onSave({
|
||||||
|
...analytics,
|
||||||
|
enabled: analyticsEnabled,
|
||||||
|
noticeSeen: true,
|
||||||
|
}, selectedTheme, selectedAppearance, $state.snapshot(customColors), selectedLanguage, autoRefreshEnabled, $state.snapshot(tools));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCustomColors() {
|
||||||
|
customColors = selectedTheme === "dark"
|
||||||
|
? { background: "#222328", surface: "#2b2e34", accent: "#2eb5d1", text: "#f0f1f2" }
|
||||||
|
: { background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssColorToHex(value: string, fallback: string): string {
|
||||||
|
const color = value.trim();
|
||||||
|
if (/^#[0-9a-f]{6}$/i.test(color)) return color.toLowerCase();
|
||||||
|
if (!color || typeof document === "undefined") return fallback;
|
||||||
|
const probe = document.createElement("span");
|
||||||
|
probe.style.color = color;
|
||||||
|
if (!probe.style.color) return fallback;
|
||||||
|
probe.style.display = "none";
|
||||||
|
document.body.appendChild(probe);
|
||||||
|
const match = getComputedStyle(probe).color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
||||||
|
probe.remove();
|
||||||
|
if (!match) return fallback;
|
||||||
|
return `#${match.slice(1, 4).map((part) => Number(part).toString(16).padStart(2, "0")).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentThemeColors(): CustomThemeColors {
|
||||||
|
const styles = getComputedStyle(document.documentElement);
|
||||||
|
const fallback = selectedTheme === "dark"
|
||||||
|
? { background: "#222328", surface: "#2b2e34", accent: "#2eb5d1", text: "#f0f1f2" }
|
||||||
|
: { background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" };
|
||||||
|
return {
|
||||||
|
background: cssColorToHex(styles.getPropertyValue("--app-bg"), fallback.background),
|
||||||
|
surface: cssColorToHex(styles.getPropertyValue("--color-surface"), fallback.surface),
|
||||||
|
accent: cssColorToHex(styles.getPropertyValue("--color-accent"), fallback.accent),
|
||||||
|
text: cssColorToHex(styles.getPropertyValue("--color-ink"), fallback.text),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAppearance(next: AppAppearance) {
|
||||||
|
if (next === "custom" && selectedAppearance !== "custom") customColors = currentThemeColors();
|
||||||
|
selectedAppearance = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolLabel(kind: ExternalToolKind): string {
|
||||||
|
const labels = {
|
||||||
|
editor: "Editor",
|
||||||
|
diff: isGerman ? "Diff-Tool" : "Diff tool",
|
||||||
|
merge: isGerman ? "Merge-Tool" : "Merge tool",
|
||||||
|
terminal: "Terminal",
|
||||||
|
fileManager: isGerman ? "Dateimanager" : "File manager",
|
||||||
|
};
|
||||||
|
return labels[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolDescription(kind: ExternalToolKind): string {
|
||||||
|
const descriptions = isGerman
|
||||||
|
? {
|
||||||
|
editor: "Öffnet Repositories und einzelne Dateien zum Bearbeiten.",
|
||||||
|
diff: "Vergleicht eine Arbeitsdatei mit ihrer Version aus HEAD.",
|
||||||
|
merge: "Übergibt Base, Current, Incoming und Ergebnis an einen 3-Wege-Merger.",
|
||||||
|
terminal: "Startet eine Shell direkt im Repository-Verzeichnis.",
|
||||||
|
fileManager: "Öffnet das Repository im bevorzugten Dateimanager.",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
editor: "Opens repositories and individual files for editing.",
|
||||||
|
diff: "Compares a working file with its version from HEAD.",
|
||||||
|
merge: "Passes base, current, incoming, and result to a three-way merger.",
|
||||||
|
terminal: "Starts a shell directly in the repository directory.",
|
||||||
|
fileManager: "Opens the repository in your preferred file manager.",
|
||||||
|
};
|
||||||
|
return descriptions[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolUsage(kind: ExternalToolKind): string {
|
||||||
|
const usage = isGerman
|
||||||
|
? {
|
||||||
|
editor: "Oben in der Repository-Leiste oder über das Code-Symbol im Datei-Explorer.",
|
||||||
|
diff: "Datei im Explorer markieren und das Vergleichs-Symbol anklicken – alternativ Rechtsklick auf die Datei.",
|
||||||
|
merge: "Bei einem Konflikt „Konflikte lösen“ öffnen und anschließend dieses Merge-Tool starten.",
|
||||||
|
terminal: "Oben in der Repository-Leiste über den Terminal-Button.",
|
||||||
|
fileManager: "Oben in der Repository-Leiste über den Ordner-Button.",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
editor: "Use the repository toolbar or the code button in the file explorer.",
|
||||||
|
diff: "Select a file in Explorer and click the compare button, or right-click the file.",
|
||||||
|
merge: "Open Resolve conflicts and start this merge tool from the conflict view.",
|
||||||
|
terminal: "Use the terminal button in the repository toolbar.",
|
||||||
|
fileManager: "Use the folder button in the repository toolbar.",
|
||||||
|
};
|
||||||
|
return usage[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
function presetAvailable(kind: ExternalToolKind, preset: ExternalToolPreset): boolean {
|
||||||
|
return isExternalToolPresetAvailable(kind, preset, detectedTools);
|
||||||
|
}
|
||||||
|
|
||||||
|
function availablePresets(kind: ExternalToolKind): ExternalToolPreset[] {
|
||||||
|
return externalToolPresets[kind].filter((preset) => presetAvailable(kind, preset));
|
||||||
|
}
|
||||||
|
|
||||||
|
function otherPresets(kind: ExternalToolKind): ExternalToolPreset[] {
|
||||||
|
return externalToolPresets[kind].filter((preset) => !presetAvailable(kind, preset));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedPreset(kind: ExternalToolKind): ExternalToolPreset | undefined {
|
||||||
|
return externalToolPresets[kind].find((preset) => preset.id === tools[kind].preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedToolName(kind: ExternalToolKind): string {
|
||||||
|
return tools[kind].preset === "custom"
|
||||||
|
? tools[kind].program.split(/[\\/]/).pop() || (isGerman ? "Eigenes Programm" : "Custom application")
|
||||||
|
: selectedPreset(kind)?.label ?? tools[kind].program;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMode(kind: "diff" | "merge"): ToolOpenMode {
|
||||||
|
return kind === "diff" ? tools.diffOpenMode : tools.mergeOpenMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOpenMode(kind: "diff" | "merge", mode: ToolOpenMode) {
|
||||||
|
if (kind === "diff") tools.diffOpenMode = mode;
|
||||||
|
else tools.mergeOpenMode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionAvailable(kind: ExternalToolKind): boolean {
|
||||||
|
if (tools[kind].preset === "custom") return tools[kind].program.trim().length > 0;
|
||||||
|
const preset = selectedPreset(kind);
|
||||||
|
return preset ? presetAvailable(kind, preset) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionStatus(kind: ExternalToolKind): string {
|
||||||
|
if (tools[kind].preset === "custom") {
|
||||||
|
return tools[kind].program.trim()
|
||||||
|
? (isGerman ? "Manuell konfiguriert" : "Manually configured")
|
||||||
|
: (isGerman ? "Programmpfad fehlt" : "Application path missing");
|
||||||
|
}
|
||||||
|
return selectionAvailable(kind)
|
||||||
|
? (isGerman ? "Installiert und verfügbar" : "Installed and available")
|
||||||
|
: (isGerman ? "Nicht automatisch erkannt" : "Not automatically detected");
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePreset(kind: ExternalToolKind, id: string) {
|
||||||
|
if (id === "custom") {
|
||||||
|
tools[kind] = { ...tools[kind], preset: "custom" };
|
||||||
|
advancedOpen = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tools[kind] = applyExternalToolPreset(kind, id, detectedTools);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectToolKind(kind: ExternalToolKind) {
|
||||||
|
activeToolKind = kind;
|
||||||
|
advancedOpen = tools[kind].preset === "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgram(kind: ExternalToolKind, program: string) {
|
||||||
|
tools[kind] = { ...tools[kind], preset: "custom", program };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateArgs(kind: ExternalToolKind, value: string) {
|
||||||
|
tools[kind] = {
|
||||||
|
...tools[kind],
|
||||||
|
preset: "custom",
|
||||||
|
args: value.split("\n").map((arg) => arg.trim()).filter(Boolean),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browseProgram(kind: ExternalToolKind) {
|
||||||
|
const selected = await open({
|
||||||
|
title: isGerman ? `${toolLabel(kind)} auswählen` : `Choose ${toolLabel(kind)}`,
|
||||||
|
multiple: false,
|
||||||
|
directory: false,
|
||||||
|
});
|
||||||
|
if (typeof selected === "string") {
|
||||||
|
updateProgram(kind, selected);
|
||||||
|
advancedOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
|
||||||
|
<header class="app-settings-head">
|
||||||
|
<div class="app-settings-title">
|
||||||
|
<span class="app-settings-mark"><Settings2 size={18} aria-hidden="true" /></span>
|
||||||
|
<div>
|
||||||
|
<h2>{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||||
|
<p>{isGerman ? "Gitty an deinen Workflow anpassen" : "Make Gitty fit your workflow"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Einstellungen schließen" : "Close settings"}>
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="app-settings-shell" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
||||||
|
<div class="app-settings-body">
|
||||||
|
<nav class="settings-nav" aria-label={isGerman ? "Einstellungsbereiche" : "Settings sections"}>
|
||||||
|
<button type="button" class:active={activePage === "general"} onclick={() => { activePage = "general"; }}>
|
||||||
|
<SlidersHorizontal size={16} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>{isGerman ? "Allgemein" : "General"}</strong>
|
||||||
|
<small>{isGerman ? "Darstellung & Verhalten" : "Appearance & behavior"}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class:active={activePage === "tools"} onclick={() => { activePage = "tools"; }}>
|
||||||
|
<Wrench size={16} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>{isGerman ? "Externe Tools" : "External tools"}</strong>
|
||||||
|
<small>{isGerman ? "Editor, Diff & Terminal" : "Editor, diff & terminal"}</small>
|
||||||
|
</span>
|
||||||
|
{#if !detectionUnavailable}<em>{detectedTools.length}</em>{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="settings-nav-note">
|
||||||
|
<ShieldCheck size={15} aria-hidden="true" />
|
||||||
|
<p>{isGerman ? "Tool-Argumente werden direkt und ohne zusätzliche Shell übergeben." : "Tool arguments are passed directly without an extra shell."}</p>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="settings-content">
|
||||||
|
{#if activePage === "general"}
|
||||||
|
<div class="settings-page-head">
|
||||||
|
<div>
|
||||||
|
<h3>{isGerman ? "Allgemein" : "General"}</h3>
|
||||||
|
<p>{isGerman ? "Darstellung, Sprache und Hintergrundverhalten." : "Appearance, language, and background behavior."}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="general-settings-grid">
|
||||||
|
<section class="general-setting-panel">
|
||||||
|
<header><Palette size={16} /><div><h4>{isGerman ? "Farbschema" : "Theme"}</h4><p>{isGerman ? "Passend zu deiner Umgebung." : "Match your environment."}</p></div></header>
|
||||||
|
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}>
|
||||||
|
<label class:active={selectedTheme === "system"}><input type="radio" bind:group={selectedTheme} value="system" /><span>System</span></label>
|
||||||
|
<label class:active={selectedTheme === "light"}><input type="radio" bind:group={selectedTheme} value="light" /><span>{isGerman ? "Hell" : "Light"}</span></label>
|
||||||
|
<label class:active={selectedTheme === "dark"}><input type="radio" bind:group={selectedTheme} value="dark" /><span>{isGerman ? "Dunkel" : "Dark"}</span></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="general-setting-panel">
|
||||||
|
<header><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}
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="app-settings-footer">
|
||||||
|
<span>{isGerman ? "Änderungen werden erst beim Speichern übernommen." : "Changes are applied only after saving."}</span>
|
||||||
|
<div>
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
||||||
|
<button class="btn-primary" type="submit"><Check size={16} aria-hidden="true" />{isGerman ? "Änderungen speichern" : "Save changes"}</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</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); }
|
||||||
|
.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>
|
||||||