Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -85,7 +85,55 @@
|
||||
"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(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]
|
||||
|
||||
jobs:
|
||||
publish-tauri:
|
||||
publish-windows:
|
||||
name: Build and publish Windows installer
|
||||
permissions:
|
||||
contents: write
|
||||
environment: production
|
||||
@@ -30,25 +31,7 @@ jobs:
|
||||
run:
|
||||
working-directory: GitLite
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
include:
|
||||
# - platform: 'macos-latest'
|
||||
# args: '--target aarch64-apple-darwin'
|
||||
# - platform: 'macos-latest'
|
||||
# args: '--target x86_64-apple-darwin'
|
||||
- platform: "windows-latest"
|
||||
bundles: "nsis"
|
||||
updater_platform: "windows-x86_64"
|
||||
no_strip: ""
|
||||
- platform: "ubuntu-22.04"
|
||||
bundles: "appimage"
|
||||
updater_platform: "linux-x86_64"
|
||||
no_strip: "1"
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
# cicd_tool/main.py expects the repo at <workspace>/GitLite, so check out there.
|
||||
@@ -56,25 +39,11 @@ jobs:
|
||||
with:
|
||||
path: GitLite
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Install uv
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
|
||||
|
||||
- name: install rust toolchain
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -88,7 +57,6 @@ jobs:
|
||||
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
|
||||
|
||||
- name: Commit and Push Changes
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: powershell
|
||||
run: |
|
||||
git config user.name '${{ vars.USERNAME_GIT }}'
|
||||
@@ -139,15 +107,292 @@ jobs:
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
|
||||
NO_STRIP: ${{ matrix.no_strip }}
|
||||
run: npm run tauri -- build --bundles ${{ matrix.bundles }}
|
||||
NO_STRIP: ""
|
||||
run: npm run tauri -- build --bundles nsis
|
||||
# ----------------------
|
||||
# Upload to MinIO (via mc) and create latest.json
|
||||
# ----------------------
|
||||
- name: Upload artifacts to MinIO with cicd_tool
|
||||
working-directory: GitLite/cicd_tool
|
||||
env:
|
||||
PUBLISH_PLATFORM: ${{ matrix.updater_platform }}
|
||||
PUBLISH_PLATFORM: windows-x86_64
|
||||
run: |
|
||||
uv sync
|
||||
uv run main.py
|
||||
|
||||
publish-arch:
|
||||
name: Build and publish gitty-desktop to AUR
|
||||
runs-on: archlinux
|
||||
environment: production
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: GitLite
|
||||
|
||||
steps:
|
||||
- name: Prepare Arch job container
|
||||
working-directory: /
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# The runner image may contain only a single geo mirror. Seed several
|
||||
# currently active HTTPS mirrors and retain the image's list as a
|
||||
# fallback so one slow CDN cannot fail the complete release.
|
||||
ARCH_MIRROR_BACKUP="$(mktemp)"
|
||||
cp /etc/pacman.d/mirrorlist "$ARCH_MIRROR_BACKUP"
|
||||
printf '%s\n' \
|
||||
'Server = https://frankfurt.mirror.pkgbuild.com/$repo/os/$arch' \
|
||||
'Server = https://mirror.osbeck.com/archlinux/$repo/os/$arch' \
|
||||
'Server = https://mirror.ubrco.de/archlinux/$repo/os/$arch' \
|
||||
'Server = https://ftp.halifax.rwth-aachen.de/archlinux/$repo/os/$arch' \
|
||||
'Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch' \
|
||||
> /etc/pacman.d/mirrorlist
|
||||
sed -n '/^[[:space:]]*Server[[:space:]]*=/p' "$ARCH_MIRROR_BACKUP" \
|
||||
>> /etc/pacman.d/mirrorlist
|
||||
|
||||
ARCH_PACKAGES=(
|
||||
base-devel curl git nodejs npm openssh rust
|
||||
webkit2gtk-4.1 gtk3 hicolor-icon-theme
|
||||
libappindicator-gtk3 librsvg xdotool
|
||||
)
|
||||
|
||||
for ARCH_INSTALL_ATTEMPT in 1 2 3; do
|
||||
echo "pacman attempt $ARCH_INSTALL_ATTEMPT of 3"
|
||||
timeout --signal=TERM 30m \
|
||||
pacman -Syu --needed --noconfirm --noprogressbar \
|
||||
"${ARCH_PACKAGES[@]}" \
|
||||
</dev/null &
|
||||
ARCH_PACMAN_PID=$!
|
||||
(
|
||||
ARCH_WAIT_SECONDS=0
|
||||
while true; do
|
||||
sleep 30
|
||||
((ARCH_WAIT_SECONDS += 30))
|
||||
echo "pacman is still running (${ARCH_WAIT_SECONDS}s elapsed)"
|
||||
done
|
||||
) &
|
||||
ARCH_HEARTBEAT_PID=$!
|
||||
|
||||
ARCH_PACMAN_STATUS=0
|
||||
wait "$ARCH_PACMAN_PID" || ARCH_PACMAN_STATUS=$?
|
||||
kill "$ARCH_HEARTBEAT_PID" 2>/dev/null || true
|
||||
wait "$ARCH_HEARTBEAT_PID" 2>/dev/null || true
|
||||
|
||||
if [ "$ARCH_PACMAN_STATUS" -eq 0 ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$ARCH_PACMAN_STATUS" -eq 124 ]; then
|
||||
echo "pacman attempt timed out after 30 minutes" >&2
|
||||
fi
|
||||
|
||||
if [ "$ARCH_INSTALL_ATTEMPT" -eq 3 ]; then
|
||||
echo "pacman failed after 3 attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep "$((ARCH_INSTALL_ATTEMPT * 5))"
|
||||
done
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: GitLite
|
||||
|
||||
- name: Set release version
|
||||
env:
|
||||
RELEASE_VERSION: ${{ github.ref_name }}
|
||||
run: |
|
||||
npm version "$RELEASE_VERSION" --no-git-tag-version --allow-same-version
|
||||
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
|
||||
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_TAG=$RELEASE_VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate and build AUR package
|
||||
run: |
|
||||
if ! id -u builder >/dev/null 2>&1; then
|
||||
useradd --create-home builder
|
||||
fi
|
||||
|
||||
AUR_SOURCE_DIR="$(mktemp -d)"
|
||||
cp PKGBUILD "$AUR_SOURCE_DIR/PKGBUILD"
|
||||
SOURCE_ARCHIVE="$AUR_SOURCE_DIR/gitty-desktop-$PACKAGE_VERSION.tar.gz"
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "$SOURCE_ARCHIVE" \
|
||||
"https://git.cbsk-tech.de/Christoph/GitLite/archive/$RELEASE_TAG.tar.gz"
|
||||
CHECKSUM="$(sha256sum "$SOURCE_ARCHIVE" | cut -d ' ' -f 1)"
|
||||
|
||||
sed -i \
|
||||
-e "s/^pkgver=.*/pkgver=$PACKAGE_VERSION/" \
|
||||
-e "s/^pkgrel=.*/pkgrel=1/" \
|
||||
-e "s/^_tag=.*/_tag=$RELEASE_TAG/" \
|
||||
-e "s/^sha256sums=.*/sha256sums=('$CHECKSUM')/" \
|
||||
"$AUR_SOURCE_DIR/PKGBUILD"
|
||||
|
||||
chown -R builder:builder "$AUR_SOURCE_DIR"
|
||||
runuser -u builder -- \
|
||||
bash -lc "cd '$AUR_SOURCE_DIR' && makepkg --cleanbuild --noconfirm && makepkg --printsrcinfo > .SRCINFO"
|
||||
|
||||
echo "AUR_SOURCE_DIR=$AUR_SOURCE_DIR" >> "$GITHUB_ENV"
|
||||
|
||||
- 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"
|
||||
|
||||
AUR_CHECKOUT_ROOT="$(mktemp -d)"
|
||||
AUR_CHECKOUT=""
|
||||
for AUR_ATTEMPT in 1 2 3 4 5; do
|
||||
AUR_CLONE_CANDIDATE="$AUR_CHECKOUT_ROOT/attempt-$AUR_ATTEMPT"
|
||||
echo "AUR clone attempt $AUR_ATTEMPT of 5"
|
||||
if timeout 3m git -c init.defaultBranch=master clone \
|
||||
ssh://aur@aur.archlinux.org/gitty-desktop.git "$AUR_CLONE_CANDIDATE"; then
|
||||
AUR_CHECKOUT="$AUR_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 "$AUR_CHECKOUT" ]; then
|
||||
echo "Could not clone the AUR repository after 5 attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$AUR_SOURCE_DIR/PKGBUILD" "$AUR_SOURCE_DIR/.SRCINFO" "$AUR_CHECKOUT/"
|
||||
|
||||
cd "$AUR_CHECKOUT"
|
||||
git config user.name "${AUR_GIT_NAME:-Gitty Release Bot}"
|
||||
git config user.email "${AUR_GIT_EMAIL:-aur@localhost}"
|
||||
git add PKGBUILD .SRCINFO
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "AUR metadata already matches release $PACKAGE_VERSION"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "Update to $PACKAGE_VERSION"
|
||||
AUR_PUSHED=0
|
||||
for AUR_ATTEMPT in 1 2 3 4 5; do
|
||||
echo "AUR push attempt $AUR_ATTEMPT of 5"
|
||||
if timeout 3m git push origin HEAD:master; then
|
||||
AUR_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 [ "$AUR_PUSHED" -ne 1 ]; then
|
||||
echo "Could not publish to AUR after 5 attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
publish-ubuntu:
|
||||
name: Build and publish Ubuntu AppImage
|
||||
permissions:
|
||||
contents: write
|
||||
environment: production
|
||||
env:
|
||||
GITEA_API: https://git.cbsk-tech.de/api/v1
|
||||
OWNER: Christoph
|
||||
REPO: GitLite
|
||||
GITEA_TOKEN: ${{ secrets.ACTIONS_TOKEN }}
|
||||
GITEA_FALLBACK_TOKEN: ${{ github.token }}
|
||||
|
||||
MINIO_ENDPOINT: ${{ vars.MINIO_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
ARTIFACT_BASE_URL: ${{ vars.ARTIFACT_BASE_URL }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: GitLite
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: GitLite
|
||||
|
||||
- name: Install Ubuntu dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Update package.json version
|
||||
run: npm version ${{ github.ref_name }} --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Update tauri.conf.json version
|
||||
env:
|
||||
RELEASE_VERSION: ${{ github.ref_name }}
|
||||
run: |
|
||||
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
|
||||
|
||||
- name: Build Tauri App
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
|
||||
NO_STRIP: "1"
|
||||
run: npm run tauri -- build --bundles appimage
|
||||
|
||||
- name: Upload artifacts to MinIO with cicd_tool
|
||||
working-directory: GitLite/cicd_tool
|
||||
env:
|
||||
PUBLISH_PLATFORM: linux-x86_64
|
||||
run: |
|
||||
uv sync
|
||||
uv run main.py
|
||||
|
||||
@@ -7,3 +7,6 @@
|
||||
~
|
||||
.codex*
|
||||
target
|
||||
|
||||
*.pkg.tar.zst
|
||||
pkg
|
||||
@@ -0,0 +1,90 @@
|
||||
# Changelog
|
||||
|
||||
All notable user-facing changes to Gitty are documented in this file.
|
||||
|
||||
The project uses calendar versions. Displayed release names use `YYYY.MM.DD`;
|
||||
package metadata uses the equivalent numeric form without leading zeroes where
|
||||
required by the package manager.
|
||||
|
||||
## [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
|
||||
@@ -0,0 +1,69 @@
|
||||
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
|
||||
|
||||
pkgname=gitty-desktop
|
||||
pkgver=2026.7.22
|
||||
pkgrel=1
|
||||
pkgdesc="A lightweight, modern Git client built with Tauri"
|
||||
arch=('x86_64')
|
||||
url="https://git.cbsk-tech.de/Christoph/GitLite"
|
||||
license=('MIT')
|
||||
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
||||
makedepends=('rust' 'nodejs' 'npm')
|
||||
options=('!lto' '!debug')
|
||||
|
||||
_tag=2026.7.22
|
||||
source=("gitty-desktop-${pkgver}.tar.gz::${url}/archive/${_tag}.tar.gz")
|
||||
sha256sums=('bd6c7d9da54917cbea98ce31172934b4657cc2fe2020f49d27ea2acb51348e9c')
|
||||
|
||||
prepare() {
|
||||
cd "$srcdir/gitlite"
|
||||
|
||||
# Keep application metadata aligned even when a release tag contains
|
||||
# leading zeroes that npm normalizes (for example 2026.7.01 -> 2026.7.1).
|
||||
npm version "$pkgver" --no-git-tag-version --allow-same-version
|
||||
RELEASE_VERSION="$pkgver" node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path,JSON.stringify(config,null,2)+'\n');"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$srcdir/gitlite"
|
||||
|
||||
# Keep the large Rust release build within the Arch runner's memory limit.
|
||||
export CARGO_BUILD_JOBS=1
|
||||
export CARGO_PROFILE_RELEASE_OPT_LEVEL=2
|
||||
export CARGO_PROFILE_RELEASE_LTO=false
|
||||
export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8
|
||||
export CARGO_PROFILE_RELEASE_DEBUG=0
|
||||
|
||||
npm ci
|
||||
npm run tauri -- build --no-bundle
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$srcdir/gitlite"
|
||||
|
||||
install -Dm755 "src-tauri/target/release/gitty" "$pkgdir/usr/bin/gitty-desktop"
|
||||
|
||||
install -Dm644 "src-tauri/icons/32x32.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/gitty-desktop.png"
|
||||
install -Dm644 "src-tauri/icons/128x128.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/gitty-desktop.png"
|
||||
install -Dm644 "src-tauri/icons/128x128@2x.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256@2/apps/gitty-desktop.png"
|
||||
install -Dm644 "src-tauri/icons/icon.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty-desktop.png"
|
||||
|
||||
install -d "$pkgdir/usr/share/applications"
|
||||
cat > "$pkgdir/usr/share/applications/gitty-desktop.desktop" <<-EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Gitty
|
||||
Comment=$pkgdesc
|
||||
Exec=gitty-desktop
|
||||
Icon=gitty-desktop
|
||||
Terminal=false
|
||||
Categories=Development;RevisionControl;
|
||||
StartupWMClass=gitty
|
||||
EOF
|
||||
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
}
|
||||
@@ -1 +1,82 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="public/splash-icon.png" alt="Gitty Logo" width="360">
|
||||
|
||||
# Gitty
|
||||
|
||||
**A lightweight, modern Git client built with Tauri.**
|
||||
|
||||
Fast, simple, and designed for developers who want a clean Git experience without unnecessary complexity.
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🚀 Fast native performance
|
||||
- 🌿 Branch management
|
||||
- 📝 Commit history
|
||||
- 🔄 Pull, Push & Fetch
|
||||
- 🔀 Merge & Rebase
|
||||
- 📦 Repository 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
|
||||
```
|
||||
|
||||
Or build the AUR package manually:
|
||||
|
||||
```bash
|
||||
git clone https://aur.archlinux.org/gitty-desktop.git
|
||||
cd gitty-desktop
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
The AUR recipe downloads the public Gitea release archive and builds Gitty from
|
||||
source. The release pipeline updates its version, checksum, and `.SRCINFO`.
|
||||
|
||||
---
|
||||
|
||||
## 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 io
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, List, Optional
|
||||
from typing import Any, Iterable, List, NamedTuple, Optional
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import quote, urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
|
||||
|
||||
# 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
|
||||
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."""
|
||||
|
||||
|
||||
class PublishTarget(NamedTuple):
|
||||
bucket_name: str
|
||||
app_dir: str
|
||||
|
||||
|
||||
def _resolve_version(version_arg: Optional[str]) -> str:
|
||||
"""Resolve the version to publish, falling back to tauri.conf.json."""
|
||||
if version_arg and version_arg.lower() not in {"auto"}:
|
||||
@@ -259,6 +268,130 @@ def _build_latest_payload(
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
resolved_version = _resolve_version(version)
|
||||
artifact_base_url = os.environ.get("ARTIFACT_BASE_URL", "")
|
||||
@@ -266,9 +399,10 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
||||
raise ConfigurationError("ARTIFACT_BASE_URL is required")
|
||||
|
||||
# S3/MinIO bucket names must be lowercase, so normalise regardless of how the
|
||||
# S3_BUCKET variable is cased (e.g. "GitLite" -> "gitlite").
|
||||
bucket_name = (os.environ.get("S3_BUCKET") or "gitlite").lower()
|
||||
bucket_prefix = (app_dir or os.environ.get("APP_DIR") or "gitlite").strip("/")
|
||||
# S3_BUCKET variable is cased (e.g. "Gitty" -> "gitty").
|
||||
bucket_name = (os.environ.get("S3_BUCKET") or "gitty").lower()
|
||||
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()
|
||||
if not artifacts:
|
||||
@@ -287,9 +421,10 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
||||
signature = _read_signature(primary)
|
||||
|
||||
client = _create_minio_client()
|
||||
_ensure_bucket(client, bucket_name)
|
||||
existing_latest_json = _read_existing_latest_json(client, bucket_name)
|
||||
|
||||
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,
|
||||
platform=platform,
|
||||
@@ -297,39 +432,28 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
||||
signature=signature,
|
||||
artifact_base_url=artifact_base_url,
|
||||
app_dir=bucket_prefix,
|
||||
existing_payload=existing_latest_json,
|
||||
)
|
||||
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")
|
||||
|
||||
for artifact in artifacts:
|
||||
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}")
|
||||
for target in publish_targets:
|
||||
_publish_target(
|
||||
client=client,
|
||||
target=target,
|
||||
version=resolved_version,
|
||||
platform=platform,
|
||||
artifacts=artifacts,
|
||||
primary_artifact=primary,
|
||||
signature=signature,
|
||||
artifact_base_url=artifact_base_url,
|
||||
)
|
||||
|
||||
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 for {platform} -> "
|
||||
f"s3://{bucket_name}/{latest_object_name}"
|
||||
)
|
||||
_upload_gitea_release_asset(resolved_version, primary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Upload GitLite release artifacts to MinIO."
|
||||
description="Upload Gitty release artifacts to MinIO."
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
@@ -340,7 +464,7 @@ if __name__ == "__main__":
|
||||
parser.add_argument(
|
||||
"--app-dir",
|
||||
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()
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ interface GitStatus {
|
||||
behind: number;
|
||||
files: GitFileStatus[];
|
||||
clean: boolean;
|
||||
rebase_in_progress: boolean;
|
||||
cherry_pick_in_progress: boolean;
|
||||
merge_in_progress: boolean;
|
||||
}
|
||||
|
||||
interface GitFileStatus {
|
||||
@@ -30,6 +33,24 @@ interface GitBranch {
|
||||
remote: boolean;
|
||||
}
|
||||
|
||||
interface GitWorktree {
|
||||
path: string;
|
||||
head: string | null;
|
||||
short_head: string | null;
|
||||
branch: string | null;
|
||||
bare: boolean;
|
||||
detached: boolean;
|
||||
locked: boolean;
|
||||
lock_reason: string | null;
|
||||
prunable: boolean;
|
||||
prune_reason: string | null;
|
||||
missing: boolean;
|
||||
is_main: boolean;
|
||||
is_current: boolean;
|
||||
clean: boolean;
|
||||
changed_files: number;
|
||||
}
|
||||
|
||||
interface GitCommit {
|
||||
hash: string;
|
||||
short_hash: string;
|
||||
@@ -56,19 +77,41 @@ interface GitRepositoryFile {
|
||||
|
||||
## Commands
|
||||
|
||||
The command list below includes the repository-management and synchronization API. The TypeScript wrappers in `src/lib/git.ts` are the authoritative full list.
|
||||
|
||||
- `open_repository(path: string): Promise<GitStatus>`
|
||||
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
|
||||
- `clone_repository(...): Promise<RepositoryBundle>`
|
||||
- `get_status(path: string): Promise<GitStatus>`
|
||||
- `list_branches(path: string): Promise<GitBranch[]>`
|
||||
- `list_remotes(path: string): Promise<GitRemote[]>`
|
||||
- `add_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
|
||||
- `update_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
|
||||
- `remove_remote(path: string, name: string): Promise<GitRemote[]>`
|
||||
- `set_branch_upstream(path: string, branch: string, upstream?: string): Promise<GitStatus>`
|
||||
- `delete_remote_branch(path: string, remote: string, branch: string): Promise<GitStatus>`
|
||||
- `checkout_branch(path: string, branch: string): Promise<GitStatus>`
|
||||
- `list_worktrees(path: string): Promise<GitWorktree[]>`
|
||||
- `add_worktree(path: string, worktreePath: string, ...): Promise<GitWorktree[]>`
|
||||
- `remove_worktree(path: string, worktreePath: string, force?: boolean): Promise<GitWorktree[]>`
|
||||
- `move_worktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]>`
|
||||
- `lock_worktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]>`
|
||||
- `unlock_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
|
||||
- `prune_worktrees(path: string): Promise<GitWorktree[]>`
|
||||
- `repair_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
|
||||
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
|
||||
- `commit(path: string, message: string): Promise<GitStatus>`
|
||||
- `pull(path: string): Promise<GitStatus>`
|
||||
- `push(path: string): Promise<GitStatus>`
|
||||
- `list_commits(path: string, limit?: number): Promise<GitCommit[]>`
|
||||
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
|
||||
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
|
||||
- `push(path: string, forceWithLease?: boolean, remote?: string): Promise<GitStatus>`
|
||||
- `list_commits(path: string, limit?: number, skip?: number): Promise<GitCommit[]>`
|
||||
- `restore_to_commit(path: string, commit: string): Promise<GitStatus>`
|
||||
- `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path)
|
||||
- `merge_branch(path: string, branch: string): Promise<GitStatus>`
|
||||
- `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise<GitStatus>`
|
||||
- `merge_continue(path: string): Promise<GitStatus>`
|
||||
- `merge_abort(path: string): Promise<GitStatus>`
|
||||
- `revert_commit(path: string, commit: string): Promise<GitStatus>`
|
||||
- `list_repository_files(path: string): Promise<GitRepositoryFile[]>`
|
||||
- `list_file_history(path: string, file: string, limit?: number): Promise<GitCommit[]>` (the `file` argument can also be a folder path)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri Git Lite</title>
|
||||
<title>Gitty</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "git-lite",
|
||||
"version": "2026.7.13",
|
||||
"name": "gitty",
|
||||
"version": "2026.8.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "git-lite",
|
||||
"version": "2026.7.13",
|
||||
"name": "gitty",
|
||||
"version": "2026.8.1",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "git-lite",
|
||||
"version": "2026.7.13",
|
||||
"name": "gitty",
|
||||
"version": "2026.8.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"icons": "node scripts/generate-app-icons.mjs",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
|
||||
|
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 { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "git_lite"
|
||||
name = "gitty"
|
||||
version = "0.1.0"
|
||||
description = "Rust backend for a lightweight Git desktop client"
|
||||
edition = "2024"
|
||||
@@ -17,13 +17,25 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "=2.7.0"
|
||||
tauri-plugin-aptabase = "1.0"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
||||
commit_ai = { path = "crates/commit_ai" }
|
||||
tokio = "1.52.3"
|
||||
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]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
# Self-updating only makes sense for install methods this app ships itself
|
||||
# (NSIS on Windows, the .app bundle on macOS). On Linux the app is meant to be
|
||||
# installed via the system package manager (e.g. the PKGBUILD), which owns
|
||||
# updates instead — so the updater plugin isn't even compiled in there.
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios", target_os = "linux")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
# ── Build profiles ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=APTABASE_KEY");
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:allow-open"
|
||||
"dialog:allow-open",
|
||||
"aptabase:allow-track-event"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"identifier": "desktop-capability",
|
||||
"platforms": [
|
||||
"macOS",
|
||||
"windows",
|
||||
"linux"
|
||||
"windows"
|
||||
],
|
||||
"windows": [
|
||||
"main"
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{build_messages, looks_like_diff_echo, sanitize_message};
|
||||
use crate::{build_messages, build_review_messages, looks_like_diff_echo, sanitize_message};
|
||||
|
||||
// Generous sizing so a detailed body with bullet points isn't cut off.
|
||||
const DEFAULT_MAX_TOKENS: u32 = 1500;
|
||||
@@ -139,6 +139,165 @@ pub async fn generate_custom(
|
||||
openai_compatible_request(url, api_key, model, diff, notes).await
|
||||
}
|
||||
|
||||
async fn openai_compatible_review_request(
|
||||
url: String,
|
||||
bearer: Option<&str>,
|
||||
model: &str,
|
||||
diff: &str,
|
||||
) -> Result<String, String> {
|
||||
let (system, user) = build_review_messages(diff)?;
|
||||
let body = OpenAiRequest {
|
||||
model: model.to_string(),
|
||||
messages: vec![
|
||||
OpenAiMessage {
|
||||
role: "system",
|
||||
content: system,
|
||||
},
|
||||
OpenAiMessage {
|
||||
role: "user",
|
||||
content: user,
|
||||
},
|
||||
],
|
||||
temperature: 0.1,
|
||||
};
|
||||
let client = http_client()?;
|
||||
let mut request = client.post(url).json(&body);
|
||||
if let Some(key) = bearer.filter(|key| !key.trim().is_empty()) {
|
||||
request = request.bearer_auth(key);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Request to the AI model failed: {err}"))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("API error ({status}): {text}"));
|
||||
}
|
||||
let parsed: OpenAiResponse =
|
||||
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||
parsed
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|choice| choice.message.content)
|
||||
.map(|content| sanitize_message(&content))
|
||||
.filter(|content| !content.is_empty())
|
||||
.ok_or_else(|| "The model did not return a review.".to_string())
|
||||
}
|
||||
|
||||
pub async fn review_openai(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||
if api_key.trim().is_empty() {
|
||||
return Err("OpenAI API key is missing.".to_string());
|
||||
}
|
||||
openai_compatible_review_request(
|
||||
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||
Some(api_key),
|
||||
model,
|
||||
diff,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn review_custom(
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
model: &str,
|
||||
diff: &str,
|
||||
) -> Result<String, String> {
|
||||
if base_url.trim().is_empty() {
|
||||
return Err("Endpoint URL is missing.".to_string());
|
||||
}
|
||||
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
|
||||
openai_compatible_review_request(url, api_key, model, diff).await
|
||||
}
|
||||
|
||||
async fn openai_compatible_split_request(
|
||||
url: String,
|
||||
bearer: Option<&str>,
|
||||
model: &str,
|
||||
diff: &str,
|
||||
) -> Result<String, String> {
|
||||
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.";
|
||||
let user =
|
||||
format!("Analyze these staged changes and propose an ordered commit plan:\n\n{diff}");
|
||||
let body = OpenAiRequest {
|
||||
model: model.to_string(),
|
||||
messages: vec![
|
||||
OpenAiMessage {
|
||||
role: "system",
|
||||
content: system.to_string(),
|
||||
},
|
||||
OpenAiMessage {
|
||||
role: "user",
|
||||
content: user,
|
||||
},
|
||||
],
|
||||
temperature: 0.1,
|
||||
};
|
||||
let client = http_client()?;
|
||||
let mut request = client.post(url).json(&body);
|
||||
if let Some(key) = bearer.filter(|key| !key.trim().is_empty()) {
|
||||
request = request.bearer_auth(key);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Request to the AI model failed: {err}"))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("API error ({status}): {text}"));
|
||||
}
|
||||
let parsed: OpenAiResponse =
|
||||
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||
parsed
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|choice| choice.message.content)
|
||||
.map(|content| sanitize_message(&content))
|
||||
.filter(|content| !content.is_empty())
|
||||
.ok_or_else(|| "The model did not return a commit plan.".to_string())
|
||||
}
|
||||
|
||||
pub async fn split_openai(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||
if api_key.trim().is_empty() {
|
||||
return Err("OpenAI API key is missing.".to_string());
|
||||
}
|
||||
openai_compatible_split_request(
|
||||
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||
Some(api_key),
|
||||
model,
|
||||
diff,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn split_custom(
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
model: &str,
|
||||
diff: &str,
|
||||
) -> Result<String, String> {
|
||||
if base_url.trim().is_empty() {
|
||||
return Err("Endpoint URL is missing.".to_string());
|
||||
}
|
||||
openai_compatible_split_request(
|
||||
format!("{}/chat/completions", base_url.trim_end_matches('/')),
|
||||
api_key,
|
||||
model,
|
||||
diff,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AnthropicMessage {
|
||||
role: &'static str,
|
||||
@@ -220,3 +379,89 @@ pub async fn generate_anthropic(
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
pub async fn review_anthropic(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||
if api_key.trim().is_empty() {
|
||||
return Err("Anthropic API key is missing.".to_string());
|
||||
}
|
||||
let (system, user) = build_review_messages(diff)?;
|
||||
let body = AnthropicRequest {
|
||||
model: model.to_string(),
|
||||
max_tokens: 2400,
|
||||
system,
|
||||
messages: vec![AnthropicMessage {
|
||||
role: "user",
|
||||
content: user,
|
||||
}],
|
||||
};
|
||||
let client = http_client()?;
|
||||
let response = client
|
||||
.post("https://api.anthropic.com/v1/messages")
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("API error ({status}): {text}"));
|
||||
}
|
||||
let parsed: AnthropicResponse =
|
||||
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||
parsed
|
||||
.content
|
||||
.into_iter()
|
||||
.find_map(|block| block.text)
|
||||
.map(|text| sanitize_message(&text))
|
||||
.filter(|text| !text.is_empty())
|
||||
.ok_or_else(|| "The model did not return a review.".to_string())
|
||||
}
|
||||
|
||||
pub async fn split_anthropic(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||
if api_key.trim().is_empty() {
|
||||
return Err("Anthropic API key is missing.".to_string());
|
||||
}
|
||||
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.".to_string();
|
||||
let body = AnthropicRequest {
|
||||
model: model.to_string(),
|
||||
max_tokens: 2400,
|
||||
system,
|
||||
messages: vec![AnthropicMessage {
|
||||
role: "user",
|
||||
content: format!(
|
||||
"Analyze these staged changes and propose an ordered commit plan:\n\n{diff}"
|
||||
),
|
||||
}],
|
||||
};
|
||||
let client = http_client()?;
|
||||
let response = client
|
||||
.post("https://api.anthropic.com/v1/messages")
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("API error ({status}): {text}"));
|
||||
}
|
||||
let parsed: AnthropicResponse =
|
||||
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||
parsed
|
||||
.content
|
||||
.into_iter()
|
||||
.find_map(|block| block.text)
|
||||
.map(|text| sanitize_message(&text))
|
||||
.filter(|text| !text.is_empty())
|
||||
.ok_or_else(|| "The model did not return a commit plan.".to_string())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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::{
|
||||
collections::hash_map::DefaultHasher,
|
||||
@@ -443,3 +446,27 @@ instead of a direct string comparison.\n\n\
|
||||
user.push_str(&format!("Staged diff:\n{diff}"));
|
||||
Ok((system, user))
|
||||
}
|
||||
|
||||
pub(crate) fn build_review_messages(diff: &str) -> Result<(String, String), String> {
|
||||
if diff.trim().is_empty() {
|
||||
return Err("No staged changes available for review.".to_string());
|
||||
}
|
||||
|
||||
const MAX_CHARS: usize = 36_000;
|
||||
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
|
||||
let system = r#"You are a senior software engineer performing a focused pre-commit review.
|
||||
Review only the supplied staged Git diff. Look for concrete correctness bugs, security issues,
|
||||
data loss, regressions, broken edge cases, unsafe error handling, and meaningful performance or
|
||||
maintainability risks. Do not report formatting preferences or speculative nitpicks.
|
||||
|
||||
Return ONLY valid JSON with this exact shape:
|
||||
{"summary":"one concise overall assessment","risk":"low|medium|high","findings":[{"severity":"critical|warning|info","title":"short title","description":"clear evidence and impact","file":"path or null","line":123,"suggestion":"specific safe next step"}]}
|
||||
|
||||
Use the new-file line number from the diff when it is known; otherwise use null. Use null for file
|
||||
when the issue is repository-wide. Maximum 12 findings, ordered critical then warning then info.
|
||||
If no actionable issue exists, return an empty findings array and risk low. Never use markdown,
|
||||
code fences, commentary outside the JSON, or claim that tests were executed."#
|
||||
.to_string();
|
||||
let user = format!("Staged diff to review:\n{diff}");
|
||||
Ok((system, user))
|
||||
}
|
||||
|
||||
|
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
|
||||
@@ -177,7 +177,21 @@ pub fn set_sync_badge(
|
||||
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
// Native badge count: works on Linux (via the desktop's launcher API, e.g. Unity's
|
||||
// LauncherEntry, also honored by GNOME/KDE) and macOS (dock badge). Matched against the
|
||||
// app's `<product-name>.desktop` file on Linux, so it's a silent no-op on launchers that
|
||||
// don't implement the protocol.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
{
|
||||
let Some(window) = app.get_webview_window("main") else {
|
||||
return Ok(());
|
||||
};
|
||||
window
|
||||
.set_badge_count(if count > 0 { Some(count as i64) } else { None })
|
||||
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
let _ = (app, count);
|
||||
}
|
||||
|
||||
@@ -2,42 +2,148 @@
|
||||
|
||||
mod badge;
|
||||
mod git;
|
||||
mod telemetry;
|
||||
|
||||
use badge::set_sync_badge;
|
||||
use git::{
|
||||
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
|
||||
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
|
||||
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
||||
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
|
||||
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
|
||||
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_patch,
|
||||
get_remote_url, get_status, last_commit_message, list_branches, list_commits,
|
||||
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
|
||||
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
|
||||
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
|
||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
||||
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop,
|
||||
stash_pop, stash_push, undo_last_commit, unstage_files,
|
||||
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
|
||||
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
|
||||
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
|
||||
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
|
||||
cred_delete, cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
|
||||
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
|
||||
get_status, init_repository, last_commit_message, list_branches, list_commits,
|
||||
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
|
||||
list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort,
|
||||
merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository,
|
||||
open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag,
|
||||
read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree,
|
||||
rename_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, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop,
|
||||
stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote,
|
||||
};
|
||||
use tauri::Manager;
|
||||
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
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 builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
||||
#[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(SearchCancellationState::default())
|
||||
.manage(commit_ai::CommitAiEngine::new())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_dialog::init());
|
||||
|
||||
// Linux installs are expected to come from the system package manager (see the
|
||||
// PKGBUILD), which owns updates itself — the self-updater is only wired up for
|
||||
// the platforms whose install method this app ships (NSIS/Windows, .app/macOS).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
|
||||
builder
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_repository,
|
||||
init_repository,
|
||||
clone_repository,
|
||||
open_repo_in_explorer,
|
||||
open_repository_file,
|
||||
get_status,
|
||||
list_branches,
|
||||
list_remotes,
|
||||
add_remote,
|
||||
update_remote,
|
||||
remove_remote,
|
||||
set_branch_upstream,
|
||||
delete_remote_branch,
|
||||
list_stashes,
|
||||
checkout_branch,
|
||||
create_branch,
|
||||
rename_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,
|
||||
@@ -62,6 +168,8 @@ fn main() {
|
||||
commit_ai_load,
|
||||
commit_ai_local_models,
|
||||
commit_ai_generate,
|
||||
commit_ai_review,
|
||||
commit_ai_split,
|
||||
pull,
|
||||
push,
|
||||
fetch,
|
||||
@@ -69,13 +177,21 @@ fn main() {
|
||||
restore_to_commit,
|
||||
restore_file_from_commit,
|
||||
merge_branch,
|
||||
merge_continue,
|
||||
merge_abort,
|
||||
revert_commit,
|
||||
rebase_branch,
|
||||
rebase_continue,
|
||||
rebase_abort,
|
||||
list_interactive_rebase_commits,
|
||||
start_interactive_rebase,
|
||||
list_reflog,
|
||||
restore_reflog_entry,
|
||||
list_repository_files,
|
||||
open_repository_bundle,
|
||||
list_file_history,
|
||||
cancel_file_history,
|
||||
get_file_blame,
|
||||
compare_commits,
|
||||
compare_file_to_head,
|
||||
compare_file_to_parent,
|
||||
@@ -89,7 +205,11 @@ fn main() {
|
||||
cred_load,
|
||||
cred_save,
|
||||
cred_delete,
|
||||
set_sync_badge
|
||||
set_sync_badge,
|
||||
close_splashscreen,
|
||||
set_telemetry_enabled,
|
||||
emit_frontend_log,
|
||||
emit_frontend_span
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
use std::{
|
||||
sync::{
|
||||
OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{SyncSender, sync_channel},
|
||||
},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
const DEFAULT_OTLP_LOGS_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/logs";
|
||||
const DEFAULT_OTLP_TRACES_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/traces";
|
||||
const DEFAULT_OTLP_METRICS_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/metrics";
|
||||
const METRICS_INTERVAL: Duration = Duration::from_secs(30);
|
||||
const MAX_BODY_BYTES: usize = 2_048;
|
||||
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
static SENDER: OnceLock<SyncSender<TelemetrySignal>> = OnceLock::new();
|
||||
|
||||
#[derive(Debug)]
|
||||
enum TelemetrySignal {
|
||||
Log(TelemetryRecord),
|
||||
Span(FrontendSpan),
|
||||
Metrics(ProcessMetrics),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ProcessMetrics {
|
||||
cpu_utilization: f64,
|
||||
memory_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TelemetryRecord {
|
||||
severity: &'static str,
|
||||
severity_number: u8,
|
||||
body: String,
|
||||
event_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrontendLog {
|
||||
level: String,
|
||||
message: String,
|
||||
event_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrontendSpan {
|
||||
name: String,
|
||||
trace_id: String,
|
||||
span_id: String,
|
||||
started_at_ms: u64,
|
||||
duration_ms: f64,
|
||||
success: bool,
|
||||
}
|
||||
|
||||
pub fn init() {
|
||||
let endpoint = std::env::var("GITTY_OTLP_LOGS_ENDPOINT")
|
||||
.unwrap_or_else(|_| DEFAULT_OTLP_LOGS_ENDPOINT.to_string());
|
||||
let traces_endpoint = std::env::var("GITTY_OTLP_TRACES_ENDPOINT")
|
||||
.unwrap_or_else(|_| DEFAULT_OTLP_TRACES_ENDPOINT.to_string());
|
||||
let metrics_endpoint = std::env::var("GITTY_OTLP_METRICS_ENDPOINT")
|
||||
.unwrap_or_else(|_| DEFAULT_OTLP_METRICS_ENDPOINT.to_string());
|
||||
let (sender, receiver) = sync_channel::<TelemetrySignal>(256);
|
||||
if SENDER.set(sender.clone()).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
start_process_metrics(sender);
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("gitty-telemetry".into())
|
||||
.spawn(move || {
|
||||
let client = match reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
eprintln!("[WARN] [gitty::telemetry] could not create OTLP client: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut export_warning_shown = false;
|
||||
while let Ok(signal) = receiver.recv() {
|
||||
let (target_endpoint, payload) = match signal {
|
||||
TelemetrySignal::Log(record) => (&endpoint, log_payload(record)),
|
||||
TelemetrySignal::Span(span) => (&traces_endpoint, span_payload(span)),
|
||||
TelemetrySignal::Metrics(metrics) => {
|
||||
(&metrics_endpoint, metrics_payload(metrics))
|
||||
}
|
||||
};
|
||||
export(
|
||||
&client,
|
||||
target_endpoint,
|
||||
&payload,
|
||||
&mut export_warning_shown,
|
||||
);
|
||||
}
|
||||
})
|
||||
.expect("failed to start telemetry worker");
|
||||
}
|
||||
|
||||
fn start_process_metrics(sender: SyncSender<TelemetrySignal>) {
|
||||
std::thread::Builder::new()
|
||||
.name("gitty-process-metrics".into())
|
||||
.spawn(move || {
|
||||
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
|
||||
|
||||
let Ok(pid) = sysinfo::get_current_pid() else {
|
||||
return;
|
||||
};
|
||||
let refresh_kind = ProcessRefreshKind::nothing().with_cpu().with_memory();
|
||||
let cpu_count = std::thread::available_parallelism()
|
||||
.map(|count| count.get())
|
||||
.unwrap_or(1) as f64;
|
||||
let mut system = System::new();
|
||||
let mut primed = false;
|
||||
|
||||
loop {
|
||||
std::thread::sleep(METRICS_INTERVAL);
|
||||
if !ENABLED.load(Ordering::Relaxed) {
|
||||
primed = false;
|
||||
continue;
|
||||
}
|
||||
system.refresh_processes_specifics(
|
||||
ProcessesToUpdate::Some(&[pid]),
|
||||
true,
|
||||
refresh_kind,
|
||||
);
|
||||
let Some(process) = system.process(pid) else {
|
||||
continue;
|
||||
};
|
||||
if !primed {
|
||||
primed = true;
|
||||
continue;
|
||||
}
|
||||
let metrics = ProcessMetrics {
|
||||
cpu_utilization: (f64::from(process.cpu_usage()) / (100.0 * cpu_count))
|
||||
.clamp(0.0, 1.0),
|
||||
memory_bytes: process.memory(),
|
||||
};
|
||||
let _ = sender.try_send(TelemetrySignal::Metrics(metrics));
|
||||
}
|
||||
})
|
||||
.expect("failed to start process metrics worker");
|
||||
}
|
||||
|
||||
fn resource_attributes() -> serde_json::Value {
|
||||
json!([
|
||||
{ "key": "service.name", "value": { "stringValue": "gitty-desktop" } },
|
||||
{ "key": "service.version", "value": { "stringValue": env!("CARGO_PKG_VERSION") } },
|
||||
{ "key": "deployment.environment.name", "value": { "stringValue": if cfg!(debug_assertions) { "development" } else { "production" } } },
|
||||
{ "key": "os.type", "value": { "stringValue": std::env::consts::OS } },
|
||||
{ "key": "host.arch", "value": { "stringValue": std::env::consts::ARCH } }
|
||||
])
|
||||
}
|
||||
|
||||
fn log_payload(record: TelemetryRecord) -> serde_json::Value {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
.to_string();
|
||||
let mut attributes = vec![json!({
|
||||
"key": "telemetry.sdk.language",
|
||||
"value": { "stringValue": "rust" }
|
||||
})];
|
||||
if let Some(event_name) = record.event_name {
|
||||
attributes.push(json!({
|
||||
"key": "event.name",
|
||||
"value": { "stringValue": event_name }
|
||||
}));
|
||||
}
|
||||
let payload = json!({
|
||||
"resourceLogs": [{
|
||||
"resource": { "attributes": resource_attributes() },
|
||||
"scopeLogs": [{
|
||||
"scope": { "name": "gitty.telemetry" },
|
||||
"logRecords": [{
|
||||
"timeUnixNano": timestamp,
|
||||
"observedTimeUnixNano": timestamp,
|
||||
"severityNumber": record.severity_number,
|
||||
"severityText": record.severity,
|
||||
"body": { "stringValue": record.body },
|
||||
"attributes": attributes
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
});
|
||||
payload
|
||||
}
|
||||
|
||||
fn span_payload(mut span: FrontendSpan) -> serde_json::Value {
|
||||
if span.name.len() > 128 {
|
||||
span.name.truncate(128);
|
||||
}
|
||||
let start = u128::from(span.started_at_ms) * 1_000_000;
|
||||
let duration = (span.duration_ms.max(0.0) * 1_000_000.0) as u128;
|
||||
let attributes = vec![
|
||||
json!({ "key": "rpc.system", "value": { "stringValue": "tauri" } }),
|
||||
json!({ "key": "rpc.method", "value": { "stringValue": span.name } }),
|
||||
];
|
||||
json!({
|
||||
"resourceSpans": [{
|
||||
"resource": { "attributes": resource_attributes() },
|
||||
"scopeSpans": [{
|
||||
"scope": { "name": "gitty.tauri", "version": env!("CARGO_PKG_VERSION") },
|
||||
"spans": [{
|
||||
"traceId": span.trace_id,
|
||||
"spanId": span.span_id,
|
||||
"name": format!("tauri.{}", span.name),
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": start.to_string(),
|
||||
"endTimeUnixNano": (start + duration).to_string(),
|
||||
"attributes": attributes,
|
||||
"status": {
|
||||
"code": if span.success { 1 } else { 2 },
|
||||
"message": if span.success { "" } else { "Tauri command failed" }
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn metrics_payload(metrics: ProcessMetrics) -> serde_json::Value {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
.to_string();
|
||||
json!({
|
||||
"resourceMetrics": [{
|
||||
"resource": { "attributes": resource_attributes() },
|
||||
"scopeMetrics": [{
|
||||
"scope": { "name": "gitty.process", "version": env!("CARGO_PKG_VERSION") },
|
||||
"metrics": [
|
||||
{
|
||||
"name": "process.cpu.utilization",
|
||||
"description": "Normalized CPU utilization of the Gitty process",
|
||||
"unit": "1",
|
||||
"gauge": { "dataPoints": [{
|
||||
"timeUnixNano": timestamp,
|
||||
"asDouble": metrics.cpu_utilization,
|
||||
"attributes": []
|
||||
}] }
|
||||
},
|
||||
{
|
||||
"name": "process.memory.usage",
|
||||
"description": "Resident memory used by the Gitty process",
|
||||
"unit": "By",
|
||||
"gauge": { "dataPoints": [{
|
||||
"timeUnixNano": timestamp,
|
||||
"asInt": metrics.memory_bytes.to_string(),
|
||||
"attributes": []
|
||||
}] }
|
||||
}
|
||||
]
|
||||
}]
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn export(
|
||||
client: &reqwest::blocking::Client,
|
||||
endpoint: &str,
|
||||
payload: &serde_json::Value,
|
||||
export_warning_shown: &mut bool,
|
||||
) {
|
||||
match client.post(endpoint).json(payload).send() {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
if response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.starts_with("text/html"))
|
||||
&& !*export_warning_shown
|
||||
{
|
||||
eprintln!(
|
||||
"[WARN] [gitty::telemetry] OTLP endpoint returned HTML; configure the collector endpoint with GITTY_OTLP_LOGS_ENDPOINT"
|
||||
);
|
||||
*export_warning_shown = true;
|
||||
}
|
||||
}
|
||||
Ok(response) if !*export_warning_shown => {
|
||||
eprintln!(
|
||||
"[WARN] [gitty::telemetry] OTLP export failed with status {}",
|
||||
response.status()
|
||||
);
|
||||
*export_warning_shown = true;
|
||||
}
|
||||
Err(error) if !*export_warning_shown => {
|
||||
eprintln!("[WARN] [gitty::telemetry] OTLP export failed: {error}");
|
||||
*export_warning_shown = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(record: TelemetryRecord) {
|
||||
if !ENABLED.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Some(sender) = SENDER.get() {
|
||||
let _ = sender.try_send(TelemetrySignal::Log(record));
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_telemetry_enabled(enabled: bool) {
|
||||
ENABLED.store(enabled, Ordering::Relaxed);
|
||||
if enabled {
|
||||
emit(TelemetryRecord {
|
||||
severity: "INFO",
|
||||
severity_number: 9,
|
||||
body: "Telemetry enabled".into(),
|
||||
event_name: Some("telemetry.enabled".into()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn emit_frontend_log(log: FrontendLog) {
|
||||
let (severity, severity_number) = match log.level.as_str() {
|
||||
"error" => ("ERROR", 17),
|
||||
"warn" => ("WARN", 13),
|
||||
_ => ("INFO", 9),
|
||||
};
|
||||
let mut body = log.message;
|
||||
if body.len() > MAX_BODY_BYTES {
|
||||
body.truncate(MAX_BODY_BYTES);
|
||||
}
|
||||
emit(TelemetryRecord {
|
||||
severity,
|
||||
severity_number,
|
||||
body,
|
||||
event_name: log.event_name,
|
||||
});
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn emit_frontend_span(span: FrontendSpan) {
|
||||
if !ENABLED.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if span.trace_id.len() != 32
|
||||
|| span.span_id.len() != 16
|
||||
|| !span.trace_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
|| !span.span_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(sender) = SENDER.get() {
|
||||
let _ = sender.try_send(TelemetrySignal::Span(span));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "GitLite",
|
||||
"version": "2026.7.13",
|
||||
"identifier": "com.git-lite",
|
||||
"productName": "Gitty",
|
||||
"version": "2026.8.1",
|
||||
"identifier": "com.gitty",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
@@ -13,13 +13,26 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "GitLite",
|
||||
"title": "Gitty",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"visible": false,
|
||||
"decorations": false,
|
||||
"shadow": true
|
||||
},
|
||||
{
|
||||
"label": "splashscreen",
|
||||
"url": "splashscreen.html",
|
||||
"title": "Gitty",
|
||||
"width": 460,
|
||||
"height": 420,
|
||||
"resizable": false,
|
||||
"decorations": false,
|
||||
"center": true,
|
||||
"skipTaskbar": true,
|
||||
"shadow": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -32,13 +45,18 @@
|
||||
"nsis"
|
||||
],
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"createUpdaterArtifacts": true,
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser"
|
||||
"installMode": "currentUser",
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"installerHooks": "nsis-hooks.nsh"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -46,6 +64,7 @@
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdDNEJGRTZERTREOTc5QjkKUldTNWVkbmtiZjVMZk02YzdpenVLOVltREJpRkFrc2F4dUwzaTV5M3pacFRVR0tmd25xSGtjK1MK",
|
||||
"endpoints": [
|
||||
"https://cdn.cbsk-tech.de/gitty/latest.json",
|
||||
"https://cdn.cbsk-tech.de/gitlite/latest.json"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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={13} 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,217 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ChevronDown,
|
||||
CloudDownload,
|
||||
Download,
|
||||
FolderOpen,
|
||||
GitCompare,
|
||||
History,
|
||||
ListRestart,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Upload,
|
||||
Settings2,
|
||||
} 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 language: "en" | "de" = "en";
|
||||
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 onFetchPrune: () => void = () => {};
|
||||
export let onForcePush: () => void = () => {};
|
||||
export let onSyncOptions: () => void = () => {};
|
||||
|
||||
let historyOpen = false;
|
||||
let syncOpen = false;
|
||||
let toolbarElement: HTMLDivElement;
|
||||
|
||||
$: isGerman = language === "de";
|
||||
|
||||
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"
|
||||
onclick={onPush}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Push"
|
||||
aria-label={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">Push</span>
|
||||
{#if ahead > 0}<span class="repo-action-count ahead">↑{ahead}</span>{/if}
|
||||
</button>
|
||||
<div class="repo-history-wrap">
|
||||
<button class="repo-action" type="button" onclick={() => { syncOpen = !syncOpen; historyOpen = false; }} disabled={!hasRepository || isBusy} aria-label={isGerman ? "Sync-Optionen" : "Sync options"} aria-haspopup="menu">
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
</button>
|
||||
{#if syncOpen}
|
||||
<div class="repo-history-menu" role="menu">
|
||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onFetchPrune(); }}><CloudDownload size={15} /><span><strong>Fetch + Prune</strong><small>{isGerman ? "Veraltete Remote-Branches entfernen" : "Remove stale remote branches"}</small></span></button>
|
||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onForcePush(); }}><Upload size={15} /><span><strong>Force with lease</strong><small>{isGerman ? "Sicheres Pushen nach Rebase" : "Safe push after rebase"}</small></span></button>
|
||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onSyncOptions(); }}><Settings2 size={15} /><span><strong>{isGerman ? "Remotes & Strategien" : "Remotes & strategies"}</strong><small>{isGerman ? "Upstream, Pull und Remote verwalten" : "Manage upstream, pull and remotes"}</small></span></button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="repo-toolbar-divider" aria-hidden="true"></div>
|
||||
|
||||
<div class="repo-action-group repo-inspect-actions">
|
||||
<button
|
||||
class="repo-action"
|
||||
onclick={onSearch}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title={isGerman ? "Globale Suche" : "Global search"}
|
||||
aria-label={isGerman ? "Globale Suche" : "Global search"}
|
||||
>
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<span class="repo-action-label">{isGerman ? "Suchen" : "Search"}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="repo-action"
|
||||
onclick={onCompare}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title={isGerman ? "Commits vergleichen" : "Compare commits"}
|
||||
aria-label={isGerman ? "Commits vergleichen" : "Compare 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={onOpenInExplorer}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
|
||||
aria-label={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
|
||||
>
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
<span class="repo-action-label utility-label">Explorer</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,231 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
||||
import { CircleHelp, Minus, Settings, X } from "@lucide/svelte";
|
||||
import iconUrl from "../../src-tauri/icons/icon.png";
|
||||
|
||||
export let branch: string = "";
|
||||
export let ahead: number = 0;
|
||||
export let behind: number = 0;
|
||||
export let repoName: string = "";
|
||||
export let hasRepository: boolean = false;
|
||||
export let isBusy: boolean = false;
|
||||
export let operation: string = "";
|
||||
export let autoRefreshEnabled: boolean = true;
|
||||
export let autoRefreshInFlight: boolean = false;
|
||||
export let onFetch: () => void = () => {};
|
||||
export let onPull: () => void = () => {};
|
||||
export let onPush: () => void = () => {};
|
||||
export let onRefresh: () => void = () => {};
|
||||
export let onSearch: () => void = () => {};
|
||||
export let onCompare: () => void = () => {};
|
||||
export let onOpenInExplorer: () => void = () => {};
|
||||
export let onToggleAutoRefresh: () => void = () => {};
|
||||
export let onOpenHelp: () => void = () => {};
|
||||
export let onOpenSettings: () => void = () => {};
|
||||
export let language: "en" | "de" = "en";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
let win: ReturnType<typeof getCurrentWindow> | null = null;
|
||||
let isMaximized = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
let appVersion = "";
|
||||
|
||||
onMount(async () => {
|
||||
isMaximized = await win.isMaximized();
|
||||
unlisten = await win.onResized(async () => {
|
||||
try {
|
||||
win = getCurrentWindow();
|
||||
isMaximized = await win.isMaximized();
|
||||
});
|
||||
appVersion = await getVersion();
|
||||
unlisten = await win.onResized(async () => {
|
||||
isMaximized = await win?.isMaximized() ?? false;
|
||||
});
|
||||
} catch {
|
||||
win = null;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unlisten?.();
|
||||
});
|
||||
|
||||
function minimizeWindow() {
|
||||
void win?.minimize();
|
||||
}
|
||||
|
||||
function toggleMaximizeWindow() {
|
||||
void win?.toggleMaximize();
|
||||
}
|
||||
|
||||
function closeWindow() {
|
||||
void win?.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="titlebar" data-tauri-drag-region>
|
||||
<!-- Brand -->
|
||||
<div class="titlebar-brand" data-tauri-drag-region>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
data-tauri-drag-region
|
||||
<span class="titlebar-brand-icon" data-tauri-drag-region>
|
||||
<img src={iconUrl} alt="" data-tauri-drag-region />
|
||||
</span>
|
||||
<span data-tauri-drag-region>Gitty</span>
|
||||
</div>
|
||||
|
||||
<div class="titlebar-drag" data-tauri-drag-region aria-hidden="true"></div>
|
||||
|
||||
<!-- Right: app-global actions + window controls -->
|
||||
<div class="titlebar-globals">
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenHelp}
|
||||
title={language === "de" ? "Hilfe (Ctrl+/)" : "Help (Ctrl+/)"}
|
||||
aria-label={language === "de" ? "Hilfe öffnen" : "Open help"}
|
||||
>
|
||||
<circle cx="12" cy="18" r="3" />
|
||||
<circle cx="6" cy="6" r="3" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<path d="M18 9v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9" />
|
||||
<line x1="12" y1="12" x2="12" y2="15" />
|
||||
</svg>
|
||||
<span data-tauri-drag-region>GitLite</span>
|
||||
{#if appVersion}
|
||||
<span class="tb-version" data-tauri-drag-region title="Version {appVersion}">v{appVersion}</span>
|
||||
{/if}
|
||||
<CircleHelp size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenSettings}
|
||||
title={language === "de" ? "Einstellungen" : "Settings"}
|
||||
aria-label={language === "de" ? "Einstellungen" : "Settings"}
|
||||
>
|
||||
<Settings size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Center: repo + branch info -->
|
||||
<div class="titlebar-info" data-tauri-drag-region>
|
||||
{#if hasRepository}
|
||||
{#if repoName}
|
||||
<span class="tb-repo" data-tauri-drag-region>{repoName}</span>
|
||||
<span class="tb-sep" data-tauri-drag-region aria-hidden="true">/</span>
|
||||
<div class="titlebar-controls">
|
||||
<button class="tb-btn" onclick={minimizeWindow} title="Minimize" aria-label="Minimize">
|
||||
<Minus size={12} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="tb-btn"
|
||||
onclick={toggleMaximizeWindow}
|
||||
title={isMaximized ? "Restore" : "Maximize"}
|
||||
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{#if isMaximized}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="1" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M1 3v7a1 1 0 0 0 1 1h7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="1" width="10" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
<GitBranch size={12} aria-hidden="true" />
|
||||
<span class="tb-branch" data-tauri-drag-region>{branch}</span>
|
||||
{#if ahead > 0}
|
||||
<span class="tb-sync ahead" title="{ahead} commits ahead">↑{ahead}</span>
|
||||
{/if}
|
||||
{#if behind > 0}
|
||||
<span class="tb-sync behind" title="{behind} commits behind">↓{behind}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="tb-no-repo" data-tauri-drag-region>No repository open</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: actions + window controls -->
|
||||
<div class="titlebar-right">
|
||||
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenInExplorer}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Open repository in Explorer"
|
||||
aria-label="Open repository in Explorer"
|
||||
>
|
||||
<FolderOpen size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Explorer</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onSearch}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Global search"
|
||||
aria-label="Global search"
|
||||
>
|
||||
<Search size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Search</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onCompare}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Compare commits"
|
||||
aria-label="Compare commits"
|
||||
>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Compare</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onFetch}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Fetch"
|
||||
aria-label="Fetch"
|
||||
>
|
||||
{#if operation === "Fetching"}
|
||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<CloudDownload size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="tb-action-label">Fetch</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onPull}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Pull"
|
||||
aria-label="Pull"
|
||||
>
|
||||
{#if operation === "Pulling"}
|
||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<Download size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="tb-action-label">Pull</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onPush}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Push"
|
||||
aria-label="Push"
|
||||
>
|
||||
{#if operation === "Pushing"}
|
||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<Upload size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="tb-action-label">Push</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onRefresh}
|
||||
disabled={isBusy || !hasRepository}
|
||||
title="Refresh"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
class={operation === "Refreshing" ? "spin" : ""}
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="tb-action-label">Refresh</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action auto-toggle"
|
||||
class:active={autoRefreshEnabled}
|
||||
onclick={onToggleAutoRefresh}
|
||||
aria-pressed={autoRefreshEnabled}
|
||||
title={autoRefreshEnabled ? "Auto-refresh on — click to disable" : "Auto-refresh off — click to enable"}
|
||||
aria-label="Toggle auto-refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
class={autoRefreshInFlight ? "spin" : ""}
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="tb-action-label">Auto</span>
|
||||
</button>
|
||||
</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>
|
||||
</button>
|
||||
<button class="tb-btn close" onclick={closeWindow} title="Close" aria-label="Close">
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</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";
|
||||
|
||||
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>
|
||||
<select value={groupIndex} onchange={(event) => moveFile(file, groupIndex, Number(event.currentTarget.value))} disabled={isApplying}>
|
||||
{#each draft.groups as _, target}<option value={target}>Commit {target + 1}</option>{/each}
|
||||
</select>
|
||||
</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}
|
||||
select{height:28px;border:1px solid var(--color-border-subtle);border-radius:5px;background:var(--color-surface-raised);color:var(--color-ink);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>
|
||||
@@ -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,142 @@
|
||||
<script lang="ts">
|
||||
import { Check, Languages, RefreshCw, Settings, X } from "@lucide/svelte";
|
||||
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types";
|
||||
|
||||
interface Props {
|
||||
analytics: AnalyticsSettings;
|
||||
theme: AppTheme;
|
||||
language: AppLanguage;
|
||||
autoRefresh: boolean;
|
||||
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { analytics, theme = "system", language = "en", autoRefresh = true, onSave = () => {}, onClose = () => {} }: Props = $props();
|
||||
|
||||
let analyticsEnabled = $state(true);
|
||||
let selectedTheme = $state<AppTheme>("system");
|
||||
let selectedLanguage = $state<AppLanguage>("en");
|
||||
let autoRefreshEnabled = $state(true);
|
||||
const isGerman = $derived(selectedLanguage === "de");
|
||||
|
||||
$effect(() => {
|
||||
analyticsEnabled = analytics.enabled;
|
||||
selectedTheme = theme;
|
||||
selectedLanguage = language;
|
||||
autoRefreshEnabled = autoRefresh;
|
||||
});
|
||||
|
||||
function save() {
|
||||
onSave({
|
||||
...analytics,
|
||||
enabled: analyticsEnabled,
|
||||
noticeSeen: true,
|
||||
}, selectedTheme, selectedLanguage, autoRefreshEnabled);
|
||||
}
|
||||
</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="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Gitty</span>
|
||||
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">{isGerman ? "Darstellung" : "Appearance"}</span>
|
||||
<h3>{isGerman ? "Farbschema" : "Theme"}</h3>
|
||||
</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="settings-section">
|
||||
<header>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">Repository</span>
|
||||
<h3>{isGerman ? "Automatische Aktualisierung" : "Auto refresh"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<label class="settings-toggle-row">
|
||||
<input type="checkbox" bind:checked={autoRefreshEnabled} />
|
||||
<span>
|
||||
<strong>{isGerman ? "Repositories automatisch aktualisieren" : "Refresh repositories automatically"}</strong>
|
||||
<small>{isGerman ? "Aktualisiert Arbeitsbereich, Branch-Status und Remotes regelmäßig im Hintergrund." : "Periodically refreshes the working tree, branch status, and remotes in the background."}</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<Languages size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">{isGerman ? "Sprache" : "Language"}</span>
|
||||
<h3>{isGerman ? "App-Sprache" : "App language"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
|
||||
<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="settings-section">
|
||||
<header>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">Analytics</span>
|
||||
<h3>{isGerman ? "Anonyme Nutzungsanalyse" : "Anonymous usage analytics"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<label class="settings-toggle-row">
|
||||
<input type="checkbox" bind:checked={analyticsEnabled} />
|
||||
<span>
|
||||
<strong>{isGerman ? "Anonyme Analytics und Fehlerberichte erlauben" : "Allow anonymous analytics and error reports"}</strong>
|
||||
<small>{isGerman ? "Es werden keine Repository-Pfade, Remotes, Branches, Commit-Nachrichten, Dateinamen, Diffs, Zugangsdaten oder Code übertragen." : "No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent."}</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="new-branch-actions">
|
||||
<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 ? "Speichern" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,235 @@
|
||||
<script lang="ts">
|
||||
import { FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
|
||||
import type { GitBlameLine } from "../types";
|
||||
|
||||
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
interface BlameGroup {
|
||||
id: string;
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
authorName: string;
|
||||
authorEmail: string;
|
||||
summary: string;
|
||||
authorTime: number;
|
||||
isUncommitted: boolean;
|
||||
lines: GitBlameLine[];
|
||||
}
|
||||
|
||||
interface TextSegment {
|
||||
text: string;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filePath: string;
|
||||
lines: GitBlameLine[];
|
||||
isBusy: boolean;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
filePath = "",
|
||||
lines = [],
|
||||
isBusy = false,
|
||||
isLoading = false,
|
||||
error = "",
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let blameSearch = $state("");
|
||||
|
||||
function groupBlameLines(source: GitBlameLine[]): BlameGroup[] {
|
||||
const groups: BlameGroup[] = [];
|
||||
for (const line of source) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.hash === line.commit_hash) {
|
||||
last.lines.push(line);
|
||||
continue;
|
||||
}
|
||||
groups.push({
|
||||
id: `${line.commit_hash}-${line.line_number}`,
|
||||
hash: line.commit_hash,
|
||||
shortHash: line.short_hash,
|
||||
authorName: line.author_name,
|
||||
authorEmail: line.author_email,
|
||||
summary: line.summary,
|
||||
authorTime: line.author_time,
|
||||
isUncommitted: line.is_uncommitted,
|
||||
lines: [line],
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function formatBlameDate(seconds: number): string {
|
||||
if (!seconds) return "";
|
||||
const date = new Date(seconds * 1000);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return commitDateFormatter.format(date);
|
||||
}
|
||||
|
||||
function groupTooltip(group: BlameGroup): string {
|
||||
if (group.isUncommitted) return "Not committed yet";
|
||||
return `${group.authorName} <${group.authorEmail}>\n${group.summary}\n${group.hash}`;
|
||||
}
|
||||
|
||||
function blameLineSearchText(line: GitBlameLine): string {
|
||||
return [
|
||||
line.line_number,
|
||||
line.content,
|
||||
line.commit_hash,
|
||||
line.short_hash,
|
||||
line.author_name,
|
||||
line.author_email,
|
||||
formatBlameDate(line.author_time),
|
||||
line.summary,
|
||||
line.is_uncommitted ? "uncommitted not committed yet" : "",
|
||||
].join("\n").toLowerCase();
|
||||
}
|
||||
|
||||
function lineMatchesSearch(line: GitBlameLine, query: string): boolean {
|
||||
return !query || blameLineSearchText(line).includes(query);
|
||||
}
|
||||
|
||||
function textSegments(value: string | number): TextSegment[] {
|
||||
const text = String(value ?? "");
|
||||
const query = blameSearch.trim();
|
||||
if (!query || !text) return [{ text, matched: false }];
|
||||
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const segments: TextSegment[] = [];
|
||||
let cursor = 0;
|
||||
let matchIndex = lowerText.indexOf(lowerQuery);
|
||||
|
||||
while (matchIndex !== -1) {
|
||||
if (matchIndex > cursor) {
|
||||
segments.push({ text: text.slice(cursor, matchIndex), matched: false });
|
||||
}
|
||||
const end = matchIndex + query.length;
|
||||
segments.push({ text: text.slice(matchIndex, end), matched: true });
|
||||
cursor = end;
|
||||
matchIndex = lowerText.indexOf(lowerQuery, cursor);
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
segments.push({ text: text.slice(cursor), matched: false });
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [{ text, matched: false }];
|
||||
}
|
||||
|
||||
let searchQuery = $derived(blameSearch.trim().toLowerCase());
|
||||
let searchActive = $derived(searchQuery.length > 0);
|
||||
let visibleLines = $derived(searchActive ? lines.filter((line) => lineMatchesSearch(line, searchQuery)) : lines);
|
||||
let groups = $derived(groupBlameLines(visibleLines));
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog blame-dialog" role="dialog" aria-modal="true" aria-label="File blame">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Blame</span>
|
||||
<p class="dialog-title" title={filePath}>{filePath}</p>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
<span class="pill pill-count">{lines.length}</span>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dialog-diff blame-body">
|
||||
{#if isLoading}
|
||||
<div class="blank-state">
|
||||
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||
Loading blame...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="blank-state">{error}</div>
|
||||
{:else if lines.length === 0}
|
||||
<div class="blank-state">No blame information available for this file.</div>
|
||||
{:else}
|
||||
<div class="diff-header blame-code-header">
|
||||
<FileCode size={13} aria-hidden="true" />
|
||||
<span title={filePath}>{filePath}</span>
|
||||
<strong>{searchActive ? `${visibleLines.length} of ${lines.length} lines` : `${lines.length} lines`}</strong>
|
||||
</div>
|
||||
<div class="blame-search-bar">
|
||||
<Search size={14} aria-hidden="true" />
|
||||
<input
|
||||
bind:value={blameSearch}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="Search blame"
|
||||
aria-label="Search blame"
|
||||
/>
|
||||
{#if searchActive}
|
||||
<button class="btn-sm blame-search-clear" type="button" onclick={() => { blameSearch = ""; }} aria-label="Clear blame search">
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="split-col-headers blame-column-headers">
|
||||
<div class="split-col-label blame-commit-col-label">Commit</div>
|
||||
<div class="split-col-label blame-code-col-label">Code</div>
|
||||
</div>
|
||||
<div class="split-diff blame-diff" role="table" aria-label="File blame">
|
||||
<div class="split-pane blame-scroll">
|
||||
{#if groups.length === 0}
|
||||
<div class="blank-state">No matches found.</div>
|
||||
{:else}
|
||||
<div class="blame-code-table">
|
||||
{#each groups as group (group.id)}
|
||||
<div class="blame-group" class:uncommitted={group.isUncommitted} title={groupTooltip(group)}>
|
||||
<div class="blame-meta">
|
||||
<span class="blame-hash">
|
||||
{#each textSegments(group.isUncommitted ? "Uncommitted" : group.shortHash) as segment, index (`hash-${group.id}-${index}`)}
|
||||
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
|
||||
{/each}
|
||||
</span>
|
||||
<span class="blame-author">
|
||||
{#each textSegments(group.isUncommitted ? "Not committed yet" : group.authorName) as segment, index (`author-${group.id}-${index}`)}
|
||||
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
|
||||
{/each}
|
||||
</span>
|
||||
<span class="blame-summary">
|
||||
{#each textSegments(group.summary || "No commit message") as segment, index (`summary-${group.id}-${index}`)}
|
||||
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
|
||||
{/each}
|
||||
</span>
|
||||
{#if !group.isUncommitted}
|
||||
<span class="blame-date">
|
||||
{#each textSegments(formatBlameDate(group.authorTime)) as segment, index (`date-${group.id}-${index}`)}
|
||||
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="split-pane-grid blame-lines">
|
||||
{#each group.lines as line (line.line_number)}
|
||||
<span class="split-num blame-line-number">{line.line_number}</span>
|
||||
<code class="split-cell blame-line-code">
|
||||
{#each textSegments(line.content) as segment, index (`line-${line.line_number}-${index}`)}
|
||||
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
|
||||
{/each}
|
||||
</code>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,16 +18,24 @@
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
|
||||
let title = $derived(branch.remote ? "Delete remote branch?" : force ? "Force delete branch?" : "Delete branch?");
|
||||
let remoteParts = $derived(branch.remote ? branch.name.split(/\/(.+)/) : []);
|
||||
let branchName = $derived(branch.remote ? remoteParts[1] || branch.name : branch.name);
|
||||
let branchLocation = $derived(branch.remote ? remoteParts[0] || "Remote" : "Local repository");
|
||||
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">{force ? "Force delete" : "Delete branch"}</span>
|
||||
<p class="dialog-title">{title}</p>
|
||||
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="branch-delete-title">
|
||||
<header class="dialog-header branch-delete-header">
|
||||
<div class="branch-delete-heading">
|
||||
<span class:force class="branch-delete-heading-icon" aria-hidden="true">
|
||||
<Trash2 size={16} />
|
||||
</span>
|
||||
<div>
|
||||
<span class="eyebrow">{branch.remote ? "Remote branch" : force ? "Force delete" : "Delete branch"}</span>
|
||||
<p class="dialog-title" id="branch-delete-title">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<X size={16} aria-hidden="true" />
|
||||
@@ -35,27 +43,35 @@
|
||||
</header>
|
||||
|
||||
<div class="branch-delete-body">
|
||||
<div class="discard-warning-icon" aria-hidden="true">
|
||||
<div class:force class="discard-warning-icon branch-delete-warning-icon" aria-hidden="true">
|
||||
<AlertTriangle size={22} />
|
||||
</div>
|
||||
|
||||
<div class="discard-confirm-copy">
|
||||
<p>
|
||||
{#if force}
|
||||
This branch is not fully merged. Force deleting removes the branch pointer even if some commits are only reachable from this branch.
|
||||
<p class="branch-delete-lead">
|
||||
{#if branch.remote}
|
||||
This branch will be removed from the shared remote repository.
|
||||
{:else if force}
|
||||
This branch is not fully merged. Some commits may only exist here.
|
||||
{:else}
|
||||
Delete this local branch from the repository?
|
||||
This branch will be removed from your local repository.
|
||||
{/if}
|
||||
</p>
|
||||
<code class="discard-target" title={branch.name}>
|
||||
<GitBranch size={13} aria-hidden="true" />
|
||||
{branch.name}
|
||||
</code>
|
||||
<div class="branch-delete-target" title={branch.name}>
|
||||
<span class="branch-delete-target-icon" aria-hidden="true"><GitBranch size={16} /></span>
|
||||
<span class="branch-delete-target-copy">
|
||||
<code>{branchName}</code>
|
||||
<span>{branchLocation}</span>
|
||||
</span>
|
||||
<span class:remote={branch.remote} class="branch-delete-scope">{branch.remote ? "Remote" : "Local"}</span>
|
||||
</div>
|
||||
<p class="discard-warning-text">
|
||||
{#if force}
|
||||
Make sure you no longer need the unique commits on this branch.
|
||||
{#if branch.remote}
|
||||
This affects everyone using <strong>{remoteParts[0] || "the remote"}</strong>. Your local branch is kept.
|
||||
{:else if force}
|
||||
Force deletion can make unmerged commits difficult to recover.
|
||||
{:else}
|
||||
Git will refuse if the branch is not fully merged.
|
||||
Git will stop the deletion if the branch contains unmerged commits.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -63,13 +79,13 @@
|
||||
|
||||
<footer class="discard-confirm-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
|
||||
<button class="btn-danger" type="button" onclick={onConfirm} disabled={isBusy}>
|
||||
<button class="btn-danger branch-delete-confirm" type="button" onclick={onConfirm} disabled={isBusy}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
{force ? "Force delete" : "Delete"}
|
||||
{branch.remote ? "Delete from remote" : force ? "Force delete" : "Delete"}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
|
||||
|
||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||
@@ -54,9 +54,14 @@
|
||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
onCreateTag: (name: string, message: string) => void | Promise<void>;
|
||||
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
||||
onPushTag: (tag: GitTag) => void | Promise<void>;
|
||||
onManageWorktrees: () => void;
|
||||
onCreateWorktree: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -72,9 +77,14 @@
|
||||
onCreateBranch = () => {},
|
||||
onRenameBranch = () => {},
|
||||
onDeleteBranch = () => {},
|
||||
onDeleteRemoteBranch = () => {},
|
||||
onCreateTag = () => {},
|
||||
onDeleteTag = () => {},
|
||||
onPushTag = () => {},
|
||||
onManageWorktrees = () => {},
|
||||
onCreateWorktree = () => {},
|
||||
collapsed = false,
|
||||
onToggleCollapsed = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let localOpen = $state(true);
|
||||
@@ -260,9 +270,16 @@
|
||||
|
||||
async function deleteContextBranch() {
|
||||
const branch = contextBranch;
|
||||
if (!branch || branch.current || branch.remote || isBusy) return;
|
||||
if (!branch || branch.current || isBusy) return;
|
||||
closeBranchContextMenu();
|
||||
await onDeleteBranch(branch);
|
||||
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
|
||||
}
|
||||
|
||||
async function createContextWorktree() {
|
||||
const branch = contextBranch;
|
||||
closeBranchContextMenu();
|
||||
if (!branch) return;
|
||||
await onCreateWorktree(branch);
|
||||
}
|
||||
|
||||
async function checkoutContextBranch() {
|
||||
@@ -355,7 +372,7 @@
|
||||
|
||||
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
|
||||
|
||||
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
||||
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Branches</span>
|
||||
@@ -373,13 +390,27 @@
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<span class="pill pill-count">{branches.length}</span>
|
||||
<button
|
||||
class="branch-create-toggle panel-collapse-toggle"
|
||||
type="button"
|
||||
onclick={onToggleCollapsed}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? "Expand branches" : "Collapse branches"}
|
||||
aria-label={collapsed ? "Expand branches panel" : "Collapse branches panel"}
|
||||
>
|
||||
{#if collapsed}
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
{#if collapsed}
|
||||
<!-- collapsed -->
|
||||
{:else if !hasRepository}
|
||||
<p class="blank-state">Open a repository to list branches.</p>
|
||||
{:else if branches.length === 0 && tags.length === 0}
|
||||
<p class="blank-state">No branches returned.</p>
|
||||
{:else}
|
||||
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
||||
{#if createOpen}
|
||||
@@ -616,6 +647,21 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="branch-group">
|
||||
<button
|
||||
class="branch-group-toggle worktree-group-toggle"
|
||||
type="button"
|
||||
onclick={onManageWorktrees}
|
||||
disabled={isBusy}
|
||||
aria-haspopup="dialog"
|
||||
title="Manage repository worktrees"
|
||||
>
|
||||
<HardDrive size={14} aria-hidden="true" />
|
||||
<span>Worktrees</span>
|
||||
<span class="branch-group-count">Manage</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -639,6 +685,10 @@
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
Rebase current onto this
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={createContextWorktree} disabled={isBusy || contextBranch.remote || contextBranch.current}>
|
||||
<HardDrive size={14} aria-hidden="true" />
|
||||
Open in new worktree
|
||||
</button>
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
@@ -649,11 +699,11 @@
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={deleteContextBranch}
|
||||
disabled={isBusy || contextBranch.current || contextBranch.remote}
|
||||
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Remote branch cannot be deleted here" : "Delete local branch"}
|
||||
disabled={isBusy || contextBranch.current}
|
||||
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Delete remote branch" : "Delete local branch"}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
Delete
|
||||
{contextBranch.remote ? "Delete remote" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Check, LoaderCircle, RotateCcw, Settings, Sparkles } from "@lucide/svelte";
|
||||
import { Check, GitCommitHorizontal, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
|
||||
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
||||
|
||||
interface Props {
|
||||
@@ -13,11 +13,15 @@
|
||||
commitAiProvider: CommitAiProvider;
|
||||
commitAiPhase: CommitAiPhase;
|
||||
commitAiGenerating: boolean;
|
||||
commitAiReviewing: boolean;
|
||||
commitAiSplitting: boolean;
|
||||
canAmend: boolean;
|
||||
amendMode: boolean;
|
||||
onCommit: () => void;
|
||||
onCommitMessageChange: (msg: string) => void;
|
||||
onGenerateCommitMessage: () => void;
|
||||
onReviewStaged: () => void;
|
||||
onSplitStaged: () => void;
|
||||
onOpenAiSettings: () => void;
|
||||
onToggleAmend: (checked: boolean) => void;
|
||||
onUndoLastCommit: () => void;
|
||||
@@ -34,11 +38,15 @@
|
||||
commitAiProvider = "local",
|
||||
commitAiPhase = "idle",
|
||||
commitAiGenerating = false,
|
||||
commitAiReviewing = false,
|
||||
commitAiSplitting = false,
|
||||
canAmend = false,
|
||||
amendMode = false,
|
||||
onCommit = () => {},
|
||||
onCommitMessageChange = () => {},
|
||||
onGenerateCommitMessage = () => {},
|
||||
onReviewStaged = () => {},
|
||||
onSplitStaged = () => {},
|
||||
onOpenAiSettings = () => {},
|
||||
onToggleAmend = () => {},
|
||||
onUndoLastCommit = () => {},
|
||||
@@ -61,25 +69,63 @@
|
||||
hasRepository &&
|
||||
!isBusy &&
|
||||
!commitAiGenerating &&
|
||||
!commitAiReviewing &&
|
||||
stagedCount > 0 &&
|
||||
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
||||
);
|
||||
let canReview = $derived(canGenerate && commitAiProvider !== "local");
|
||||
let canSplit = $derived(canReview && stagedCount > 1 && !commitAiSplitting);
|
||||
</script>
|
||||
|
||||
<section class="panel commit-panel" aria-label="Commit">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Commit</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Message</h2>
|
||||
<span class="eyebrow">Create revision</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commit</h2>
|
||||
</div>
|
||||
<div class="commit-head-actions">
|
||||
<span class="pill pill-count">{stagedCount} staged</span>
|
||||
<button
|
||||
class="commit-review-button"
|
||||
type="button"
|
||||
onclick={onSplitStaged}
|
||||
disabled={!canSplit}
|
||||
title={commitAiProvider === "local" ? "Commit splitting currently requires an API provider" : "Suggest logical commits for the staged files"}
|
||||
>
|
||||
{#if commitAiSplitting}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<GitCommitHorizontal size={14} aria-hidden="true" />{/if}
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
class="commit-review-button"
|
||||
type="button"
|
||||
onclick={onReviewStaged}
|
||||
disabled={!canReview}
|
||||
title={commitAiProvider === "local" ? "Pre-commit review currently requires an API provider" : stagedCount === 0 ? "Stage changes first" : "Review staged changes for bugs and risks"}
|
||||
>
|
||||
{#if commitAiReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<ShieldCheck size={14} aria-hidden="true" />{/if}
|
||||
Review
|
||||
</button>
|
||||
<button
|
||||
class="commit-generate-button"
|
||||
type="button"
|
||||
onclick={onGenerateCommitMessage}
|
||||
disabled={!canGenerate}
|
||||
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
|
||||
>
|
||||
{#if commitAiGenerating || localModelLoading}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<Sparkles size={14} aria-hidden="true" />{/if}
|
||||
Generate
|
||||
</button>
|
||||
<button class="commit-settings-button" type="button" onclick={onOpenAiSettings} disabled={isBusy} title="AI settings" aria-label="AI settings">
|
||||
<Settings size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<span class="pill pill-count">{stagedCount} staged</span>
|
||||
</div>
|
||||
|
||||
<form class="commit-form" onsubmit={handleSubmit}>
|
||||
<textarea
|
||||
value={commitMessage}
|
||||
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder="Commit message..."
|
||||
placeholder="Commit message"
|
||||
disabled={!hasRepository || isBusy}
|
||||
></textarea>
|
||||
{#if commitBlockReason}
|
||||
@@ -117,30 +163,6 @@
|
||||
{/if}
|
||||
{amendMode ? "Amend" : "Commit"}
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary commit-ai-button"
|
||||
type="button"
|
||||
onclick={onGenerateCommitMessage}
|
||||
disabled={!canGenerate}
|
||||
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
|
||||
>
|
||||
{#if commitAiGenerating || localModelLoading}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Sparkles size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
AI
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary commit-ai-settings-button"
|
||||
type="button"
|
||||
onclick={onOpenAiSettings}
|
||||
disabled={isBusy}
|
||||
title="AI settings"
|
||||
aria-label="AI settings"
|
||||
>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
|
||||
};
|
||||
|
||||
interface DiffMarker {
|
||||
start: number;
|
||||
end: number;
|
||||
kind: "add" | "delete" | "mixed";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
comparison: GitCommitComparison;
|
||||
selectedDiffPath: string;
|
||||
@@ -173,14 +179,44 @@
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildDiffMarkers(rows: SplitRow[]): DiffMarker[] {
|
||||
const markers: DiffMarker[] = [];
|
||||
let current: DiffMarker | null = null;
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const row = rows[index];
|
||||
if (row.type !== "pair" || (row.leftKind === "context" && row.rightKind === "context")) {
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
const kind = row.leftKind === "del" && row.rightKind === "add"
|
||||
? "mixed"
|
||||
: row.rightKind === "add" ? "add" : "delete";
|
||||
if (current && current.end === index - 1 && current.kind === kind) {
|
||||
current.end = index;
|
||||
} else {
|
||||
current = { start: index, end: index, kind };
|
||||
markers.push(current);
|
||||
}
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
function scrollToDiffMarker(rowIndex: number) {
|
||||
const ratio = rowIndex / Math.max(splitRows.length - 1, 1);
|
||||
for (const pane of [beforePane, afterPane]) {
|
||||
if (pane) pane.scrollTop = ratio * Math.max(pane.scrollHeight - pane.clientHeight, 0);
|
||||
}
|
||||
}
|
||||
|
||||
let diffByPath = $derived(buildDiffByPath(comparison.patch));
|
||||
let selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
|
||||
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
|
||||
let splitRows = $derived(buildSplitRows(selectedPatch));
|
||||
let diffMarkers = $derived(buildDiffMarkers(splitRows));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
class="dialog-backdrop compare-dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
|
||||
@@ -262,6 +298,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Split diff grid -->
|
||||
<div class="split-diff-shell">
|
||||
<div class="split-diff" role="table" aria-label="Side-by-side diff">
|
||||
<div
|
||||
class="split-pane"
|
||||
@@ -298,6 +335,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="diff-overview" aria-label="Change overview">
|
||||
{#each diffMarkers as marker, index (`${marker.start}-${marker.end}-${marker.kind}`)}
|
||||
<button
|
||||
class="diff-overview-marker {marker.kind}"
|
||||
type="button"
|
||||
style={`--marker-position: ${(marker.start / Math.max(splitRows.length - 1, 1)) * 100}%`}
|
||||
onclick={() => scrollToDiffMarker(marker.start)}
|
||||
title={`Jump to change ${index + 1} of ${diffMarkers.length}`}
|
||||
aria-label={`Jump to change ${index + 1} of ${diffMarkers.length}`}
|
||||
></button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
import type { GitFileStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
file: GitFileStatus;
|
||||
staged: boolean;
|
||||
scope: "file" | "hunk";
|
||||
files: GitFileStatus[];
|
||||
staged: boolean | null;
|
||||
scope: "file" | "hunk" | "lines";
|
||||
isBusy: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
file,
|
||||
files,
|
||||
staged = false,
|
||||
scope = "file",
|
||||
isBusy = false,
|
||||
@@ -20,10 +20,16 @@
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let targetPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||
let title = $derived(scope === "hunk" ? "Discard hunk?" : "Discard file changes?");
|
||||
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
||||
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
||||
function targetPath(file: GitFileStatus): string {
|
||||
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
||||
}
|
||||
|
||||
let count = $derived(files.length);
|
||||
let title = $derived(
|
||||
scope === "hunk" ? "Discard hunk?" : scope === "lines" ? "Discard selected lines?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
|
||||
);
|
||||
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : scope === "lines" ? "selected lines" : count > 1 ? `${count} files` : "file");
|
||||
let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
|
||||
|
||||
</script>
|
||||
|
||||
@@ -46,11 +52,22 @@
|
||||
|
||||
<div class="discard-confirm-copy">
|
||||
<p>
|
||||
This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below.
|
||||
This will reset the {sourceLabel} for the {scopeLabel} below.
|
||||
</p>
|
||||
<code class="discard-target" title={targetPath}>{targetPath}</code>
|
||||
{#if count > 1}
|
||||
<ul class="discard-target-list">
|
||||
{#each files.slice(0, 8) as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||
<li><code class="discard-target" title={targetPath(file)}>{targetPath(file)}</code></li>
|
||||
{/each}
|
||||
{#if files.length > 8}
|
||||
<li class="discard-target-more">+{files.length - 8} more</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{:else if count === 1}
|
||||
<code class="discard-target" title={targetPath(files[0])}>{targetPath(files[0])}</code>
|
||||
{/if}
|
||||
<p class="discard-warning-text">
|
||||
This cannot be undone. If the file only exists in your working tree, it can be deleted entirely.
|
||||
This cannot be undone. If a file only exists in your working tree, it can be deleted entirely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
FileCog,
|
||||
FileImage,
|
||||
FileJson,
|
||||
FileSearch,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType,
|
||||
@@ -18,6 +19,7 @@
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ExternalLink,
|
||||
History,
|
||||
Terminal,
|
||||
} from "@lucide/svelte";
|
||||
import { languageIconForPath } from "../languageIcons";
|
||||
@@ -36,6 +38,10 @@
|
||||
onCollapseAllFolders: () => void;
|
||||
onSelectNode: (node: ExplorerNode) => void;
|
||||
onOpenFile: (node: ExplorerNode) => void;
|
||||
onFileHistory: (node: ExplorerNode) => void;
|
||||
onBlame: (node: ExplorerNode) => void;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -50,6 +56,10 @@
|
||||
onCollapseAllFolders = () => {},
|
||||
onSelectNode = () => {},
|
||||
onOpenFile = () => {},
|
||||
onFileHistory = () => {},
|
||||
onBlame = () => {},
|
||||
collapsed = false,
|
||||
onToggleCollapsed = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let contextNode = $state<ExplorerNode | null>(null);
|
||||
@@ -168,7 +178,7 @@
|
||||
|
||||
contextNode = node;
|
||||
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
|
||||
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 56));
|
||||
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 132));
|
||||
}
|
||||
|
||||
function closeFileContextMenu() {
|
||||
@@ -182,6 +192,20 @@
|
||||
onOpenFile(node);
|
||||
}
|
||||
|
||||
function openContextBlame() {
|
||||
const node = contextNode;
|
||||
if (!node || node.kind !== "file") return;
|
||||
closeFileContextMenu();
|
||||
onBlame(node);
|
||||
}
|
||||
|
||||
function openContextFileHistory() {
|
||||
const node = contextNode;
|
||||
if (!node || node.kind !== "file" || !node.tracked) return;
|
||||
closeFileContextMenu();
|
||||
onFileHistory(node);
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeFileContextMenu();
|
||||
}
|
||||
@@ -193,7 +217,7 @@
|
||||
|
||||
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
|
||||
|
||||
<section class="panel explorer-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="File explorer">
|
||||
<section class="panel explorer-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="File explorer">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Explorer</span>
|
||||
@@ -221,22 +245,40 @@
|
||||
<Folder size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<span class="pill pill-count">{repoFiles.length}</span>
|
||||
<button
|
||||
class="explorer-bulk-button panel-collapse-toggle"
|
||||
type="button"
|
||||
onclick={onToggleCollapsed}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? "Expand file explorer" : "Collapse file explorer"}
|
||||
aria-label={collapsed ? "Expand file explorer panel" : "Collapse file explorer panel"}
|
||||
>
|
||||
{#if collapsed}
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
{#if collapsed}
|
||||
<!-- collapsed -->
|
||||
{:else if !hasRepository}
|
||||
<p class="blank-state">Open a repository to browse files.</p>
|
||||
{:else if repoFiles.length === 0}
|
||||
<p class="blank-state">No files returned.</p>
|
||||
{:else}
|
||||
<div class="explorer-list overflow-auto p-2">
|
||||
{#each visibleNodes as node (`${node.kind}:${node.path}`)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="explorer-row"
|
||||
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
|
||||
class:folder={node.kind === "folder"}
|
||||
style={`--depth: ${node.depth}`}
|
||||
title={node.path}
|
||||
oncontextmenu={(event) => openFileContextMenu(event, node)}
|
||||
>
|
||||
{#if node.kind === "folder"}
|
||||
<button
|
||||
@@ -298,9 +340,8 @@
|
||||
class="explorer-select"
|
||||
type="button"
|
||||
onclick={() => onSelectNode(node)}
|
||||
oncontextmenu={(event) => openFileContextMenu(event, node)}
|
||||
disabled={isBusy}
|
||||
title={`Show history for ${node.path}`}
|
||||
title={`Select ${node.path}`}
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
</button>
|
||||
@@ -325,6 +366,16 @@
|
||||
tabindex="-1"
|
||||
aria-label={`Actions for ${contextNode.path}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={openContextFileHistory}
|
||||
disabled={!contextNode.tracked}
|
||||
title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}
|
||||
>
|
||||
<History size={14} aria-hidden="true" />
|
||||
File history
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -335,5 +386,15 @@
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
Open in Explorer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={openContextBlame}
|
||||
disabled={!contextNode.tracked || contextNode.status === "deleted"}
|
||||
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
|
||||
>
|
||||
<FileSearch size={14} aria-hidden="true" />
|
||||
Blame
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||