Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -100,7 +100,40 @@
|
|||||||
"Bash(pkg-config --exists openssl)",
|
"Bash(pkg-config --exists openssl)",
|
||||||
"Bash(sudo apt-get install -y libssl-dev pkg-config)",
|
"Bash(sudo apt-get install -y libssl-dev pkg-config)",
|
||||||
"Bash(dpkg -L libssl3t64)",
|
"Bash(dpkg -L libssl3t64)",
|
||||||
"Bash(grep *)"
|
"Bash(grep *)",
|
||||||
|
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/**)",
|
||||||
|
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-runtime-wry-2.11.4/src/**)",
|
||||||
|
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/window/**)",
|
||||||
|
"WebSearch",
|
||||||
|
"Bash(node -p \"require\\('./package.json'\\).version\")",
|
||||||
|
"Bash(bash -n PKGBUILD)",
|
||||||
|
"Bash(makepkg --printsrcinfo)",
|
||||||
|
"Bash(npx tauri *)",
|
||||||
|
"Bash(echo \"---exit code:$?---\")",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite status --short)",
|
||||||
|
"Bash(makepkg -f --noconfirm)",
|
||||||
|
"Bash(tar -tf gitty-2026.7.19-1-x86_64.pkg.tar.zst)",
|
||||||
|
"Bash(cp /mnt/data/Development/GitLite/PKGBUILD .)",
|
||||||
|
"Bash(sed -i 's/^pkgrel=1/pkgrel=3/' PKGBUILD)",
|
||||||
|
"Bash(node -e \"const fs=require\\('fs'\\); const version='2026.8.1'; const pkgbuild=fs.readFileSync\\('PKGBUILD','utf8'\\).replace\\(/^pkgver=.*\\\\$/m, 'pkgver='+version\\).replace\\(/^pkgrel=.*\\\\$/m, 'pkgrel=1'\\); fs.writeFileSync\\('PKGBUILD', pkgbuild\\);\")",
|
||||||
|
"Bash(rm PKGBUILD)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite stash)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite stash pop)",
|
||||||
|
"Bash(command -v docker)",
|
||||||
|
"Read(//home/christoph/.docker/**)",
|
||||||
|
"Bash(docker info *)",
|
||||||
|
"Bash(sudo -n test -f /root/.docker/config.json)",
|
||||||
|
"Bash(systemctl list-units *)",
|
||||||
|
"Bash(find / -maxdepth 4 -iname \"act_runner*\")",
|
||||||
|
"Bash(find / -maxdepth 6 -iname \"config.yaml\" -path \"*runner*\")",
|
||||||
|
"Read(//home/**)",
|
||||||
|
"Bash(getent passwd *)",
|
||||||
|
"Bash(docker context *)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite log --oneline -1 -- PKGBUILD)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite show --stat HEAD -- PKGBUILD)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite log --oneline -10)",
|
||||||
|
"Bash(git -C /mnt/data/Development/GitLite status)",
|
||||||
|
"Bash(git *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ jobs:
|
|||||||
run: |
|
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');"
|
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: Update PKGBUILD Version
|
||||||
|
run: |
|
||||||
|
node -e "const fs=require('fs'); const version=require('./package.json').version; const pkgbuild=fs.readFileSync('PKGBUILD','utf8').replace(/^pkgver=.*$/m, 'pkgver='+version).replace(/^pkgrel=.*$/m, 'pkgrel=1'); fs.writeFileSync('PKGBUILD', pkgbuild);"
|
||||||
|
|
||||||
- name: Commit and Push Changes
|
- name: Commit and Push Changes
|
||||||
if: matrix.platform == 'windows-latest'
|
if: matrix.platform == 'windows-latest'
|
||||||
shell: powershell
|
shell: powershell
|
||||||
@@ -95,7 +99,7 @@ jobs:
|
|||||||
git config user.email '${{ vars.EMAIL_GIT }}'
|
git config user.email '${{ vars.EMAIL_GIT }}'
|
||||||
# npm version also bumps the version inside package-lock.json, so stage it
|
# npm version also bumps the version inside package-lock.json, so stage it
|
||||||
# too -- otherwise it stays as an unstaged change and blocks the rebase.
|
# too -- otherwise it stays as an unstaged change and blocks the rebase.
|
||||||
git add package.json package-lock.json src-tauri/tauri.conf.json
|
git add package.json package-lock.json src-tauri/tauri.conf.json PKGBUILD
|
||||||
|
|
||||||
git commit -m 'Update version to ${{ github.ref_name }}'
|
git commit -m 'Update version to ${{ github.ref_name }}'
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
@@ -151,3 +155,77 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
uv sync
|
uv sync
|
||||||
uv run main.py
|
uv run main.py
|
||||||
|
|
||||||
|
publish-arch:
|
||||||
|
name: Build and publish Arch package
|
||||||
|
runs-on: archlinux
|
||||||
|
environment: production
|
||||||
|
env:
|
||||||
|
MINIO_ENDPOINT: ${{ vars.MINIO_ENDPOINT }}
|
||||||
|
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||||
|
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
|
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: GitLite
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Prepare Arch job container
|
||||||
|
working-directory: /
|
||||||
|
run: |
|
||||||
|
pacman -Syu --noconfirm
|
||||||
|
pacman -S --needed --noconfirm nodejs npm git
|
||||||
|
|
||||||
|
- 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
|
||||||
|
node -e "const fs=require('fs'); const version=require('./package.json').version; const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=version; fs.writeFileSync(path,JSON.stringify(config,null,2)+'\n');"
|
||||||
|
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=$PACKAGE_VERSION/; s/^pkgrel=.*/pkgrel=1/" PKGBUILD
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
|
||||||
|
|
||||||
|
- name: Restore pacman repository database
|
||||||
|
working-directory: GitLite/cicd_tool
|
||||||
|
run: |
|
||||||
|
uv sync
|
||||||
|
uv run arch_repo.py download-db --output ../arch-repo/gitty.db.tar.gz
|
||||||
|
|
||||||
|
- name: Build pkg.tar.zst with PKGBUILD
|
||||||
|
run: |
|
||||||
|
pacman -S --needed --noconfirm \
|
||||||
|
base-devel git nodejs npm rust \
|
||||||
|
webkit2gtk-4.1 gtk3 hicolor-icon-theme \
|
||||||
|
libappindicator-gtk3 librsvg xdotool
|
||||||
|
|
||||||
|
mkdir -p arch-output
|
||||||
|
|
||||||
|
BUILD_ROOT="$(mktemp -d)"
|
||||||
|
cp -a . "$BUILD_ROOT/source"
|
||||||
|
mkdir -p "$BUILD_ROOT/makepkg"
|
||||||
|
|
||||||
|
useradd --create-home builder
|
||||||
|
chown -R builder:builder "$BUILD_ROOT"
|
||||||
|
|
||||||
|
runuser -u builder -- \
|
||||||
|
bash -lc "cd '$BUILD_ROOT/source' && BUILDDIR='$BUILD_ROOT/makepkg' makepkg --cleanbuild --noconfirm"
|
||||||
|
|
||||||
|
cp "$BUILD_ROOT"/source/*.pkg.tar.zst arch-output/
|
||||||
|
chmod 0644 arch-output/*.pkg.tar.zst
|
||||||
|
|
||||||
|
- name: Update pacman repository database
|
||||||
|
run: |
|
||||||
|
mkdir -p arch-repo
|
||||||
|
repo-add arch-repo/gitty.db.tar.gz arch-output/*.pkg.tar.zst
|
||||||
|
|
||||||
|
- name: Upload package and repository database to CDN
|
||||||
|
working-directory: GitLite/cicd_tool
|
||||||
|
run: uv run arch_repo.py publish --packages-dir ../arch-output --repo-dir ../arch-repo
|
||||||
|
|||||||
@@ -7,3 +7,6 @@
|
|||||||
~
|
~
|
||||||
.codex*
|
.codex*
|
||||||
target
|
target
|
||||||
|
|
||||||
|
*.pkg.tar.zst
|
||||||
|
pkg
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 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.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.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,76 @@
|
|||||||
|
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
|
||||||
|
#
|
||||||
|
# Local/in-tree PKGBUILD: builds straight from this working directory (no
|
||||||
|
# source download, no tauri-bundler/AppImage step) and installs the raw
|
||||||
|
# binary + desktop entry. Run `makepkg -si` from the repo root.
|
||||||
|
|
||||||
|
pkgname=gitty
|
||||||
|
pkgver=2026.7.20
|
||||||
|
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')
|
||||||
|
|
||||||
|
source=()
|
||||||
|
sha256sums=()
|
||||||
|
|
||||||
|
# Keeps pkgver in sync with package.json (the release CI bumps that file).
|
||||||
|
pkgver() {
|
||||||
|
cd "$startdir"
|
||||||
|
node -p "require('./package.json').version"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$startdir"
|
||||||
|
|
||||||
|
# mistralrs-core is large enough that the regular release profile (thin LTO,
|
||||||
|
# one codegen unit and Arch debug flags) can exhaust memory in the LXC build
|
||||||
|
# runner. Keep the package optimized while bounding peak compiler memory.
|
||||||
|
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
|
||||||
|
# Build through the Tauri CLI (not a raw `cargo build --release`): it enables
|
||||||
|
# the `custom-protocol` cargo feature that makes the binary load the embedded
|
||||||
|
# `dist/` assets instead of the Vite dev server URL (127.0.0.1:1420), and runs
|
||||||
|
# `beforeBuildCommand` (`npm run build`) for us. `--no-bundle` skips deb/rpm/
|
||||||
|
# appimage packaging (and with it linuxdeploy/FUSE) since pacman owns that here.
|
||||||
|
npm run tauri -- build --no-bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$startdir"
|
||||||
|
|
||||||
|
install -Dm755 "src-tauri/target/release/gitty" "$pkgdir/usr/bin/gitty"
|
||||||
|
|
||||||
|
install -Dm644 "src-tauri/icons/32x32.png" \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/32x32/apps/gitty.png"
|
||||||
|
install -Dm644 "src-tauri/icons/128x128.png" \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/128x128/apps/gitty.png"
|
||||||
|
install -Dm644 "src-tauri/icons/128x128@2x.png" \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/256x256@2/apps/gitty.png"
|
||||||
|
install -Dm644 "src-tauri/icons/icon.png" \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty.png"
|
||||||
|
|
||||||
|
install -d "$pkgdir/usr/share/applications"
|
||||||
|
cat > "$pkgdir/usr/share/applications/gitty.desktop" <<-EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Gitty
|
||||||
|
Comment=$pkgdesc
|
||||||
|
Exec=gitty
|
||||||
|
Icon=gitty
|
||||||
|
Terminal=false
|
||||||
|
Categories=Development;RevisionControl;
|
||||||
|
StartupWMClass=gitty
|
||||||
|
EOF
|
||||||
|
|
||||||
|
install -Dm644 "README.md" "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
}
|
||||||
@@ -49,3 +49,35 @@ Contributions, feedback and feature requests are always welcome!
|
|||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arch Linux
|
||||||
|
|
||||||
|
Release builds are published as an `x86_64` pacman repository. Add this block to
|
||||||
|
`/etc/pacman.conf` once:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[gitty]
|
||||||
|
SigLevel = Optional TrustAll
|
||||||
|
Server = https://cdn.cbsk-tech.de/gitty/arch/$arch
|
||||||
|
```
|
||||||
|
|
||||||
|
Then install Gitty and receive future releases through the normal system update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pacman -Syu gitty
|
||||||
|
```
|
||||||
|
|
||||||
|
The repository is currently distributed over HTTPS but is not GPG-signed. A
|
||||||
|
repository signing key should be added before recommending it to third parties.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from minio import Minio
|
||||||
|
from minio.error import S3Error
|
||||||
|
|
||||||
|
from main import (
|
||||||
|
ConfigurationError,
|
||||||
|
_create_minio_client,
|
||||||
|
_ensure_bucket,
|
||||||
|
_parse_publish_targets,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
REPO_PREFIX = "arch/x86_64"
|
||||||
|
DATABASE_NAME = "gitty.db.tar.gz"
|
||||||
|
|
||||||
|
|
||||||
|
def _targets():
|
||||||
|
bucket_name = (os.environ.get("S3_BUCKET") or "gitty").lower()
|
||||||
|
app_dir = (os.environ.get("APP_DIR") or "gitty").strip("/")
|
||||||
|
return _parse_publish_targets(bucket_name, app_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def download_database(client: Minio, output: Path) -> None:
|
||||||
|
target = _targets()[0]
|
||||||
|
object_name = f"{REPO_PREFIX}/{DATABASE_NAME}"
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.fget_object(target.bucket_name, object_name, str(output))
|
||||||
|
print(f"Downloaded s3://{target.bucket_name}/{object_name} -> {output}")
|
||||||
|
except S3Error as exc:
|
||||||
|
if exc.code in {"NoSuchKey", "NoSuchObject"}:
|
||||||
|
print("No existing Arch repository database found; creating a new one.")
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def publish_repository(client: Minio, packages_dir: Path, repo_dir: Path) -> None:
|
||||||
|
packages = sorted(packages_dir.glob("*.pkg.tar.zst"))
|
||||||
|
if not packages:
|
||||||
|
raise FileNotFoundError(f"No .pkg.tar.zst package found in {packages_dir}")
|
||||||
|
|
||||||
|
repository_files = [
|
||||||
|
path
|
||||||
|
for path in repo_dir.iterdir()
|
||||||
|
if path.is_file() and path.name.startswith("gitty.")
|
||||||
|
]
|
||||||
|
if not any(path.name == DATABASE_NAME for path in repository_files):
|
||||||
|
raise FileNotFoundError(f"Missing repository database: {repo_dir / DATABASE_NAME}")
|
||||||
|
|
||||||
|
for target in _targets():
|
||||||
|
_ensure_bucket(client, target.bucket_name)
|
||||||
|
for path in [*packages, *repository_files]:
|
||||||
|
object_name = f"{REPO_PREFIX}/{path.name}"
|
||||||
|
client.fput_object(target.bucket_name, object_name, str(path))
|
||||||
|
print(f"Uploaded {path.name} -> s3://{target.bucket_name}/{object_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Manage the Gitty pacman repository on MinIO.")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
download_parser = subparsers.add_parser("download-db")
|
||||||
|
download_parser.add_argument("--output", type=Path, required=True)
|
||||||
|
|
||||||
|
publish_parser = subparsers.add_parser("publish")
|
||||||
|
publish_parser.add_argument("--packages-dir", type=Path, required=True)
|
||||||
|
publish_parser.add_argument("--repo-dir", type=Path, required=True)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
client = _create_minio_client()
|
||||||
|
|
||||||
|
if args.command == "download-db":
|
||||||
|
download_database(client, args.output)
|
||||||
|
else:
|
||||||
|
publish_repository(client, args.packages_dir, args.repo_dir)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (ConfigurationError, FileNotFoundError, S3Error) as exc:
|
||||||
|
raise SystemExit(f"Arch repository operation failed: {exc}") from exc
|
||||||
+46
-3
@@ -15,6 +15,9 @@ interface GitStatus {
|
|||||||
behind: number;
|
behind: number;
|
||||||
files: GitFileStatus[];
|
files: GitFileStatus[];
|
||||||
clean: boolean;
|
clean: boolean;
|
||||||
|
rebase_in_progress: boolean;
|
||||||
|
cherry_pick_in_progress: boolean;
|
||||||
|
merge_in_progress: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GitFileStatus {
|
interface GitFileStatus {
|
||||||
@@ -30,6 +33,24 @@ interface GitBranch {
|
|||||||
remote: boolean;
|
remote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GitWorktree {
|
||||||
|
path: string;
|
||||||
|
head: string | null;
|
||||||
|
short_head: string | null;
|
||||||
|
branch: string | null;
|
||||||
|
bare: boolean;
|
||||||
|
detached: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
lock_reason: string | null;
|
||||||
|
prunable: boolean;
|
||||||
|
prune_reason: string | null;
|
||||||
|
missing: boolean;
|
||||||
|
is_main: boolean;
|
||||||
|
is_current: boolean;
|
||||||
|
clean: boolean;
|
||||||
|
changed_files: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface GitCommit {
|
interface GitCommit {
|
||||||
hash: string;
|
hash: string;
|
||||||
short_hash: string;
|
short_hash: string;
|
||||||
@@ -56,19 +77,41 @@ interface GitRepositoryFile {
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
The command list below includes the repository-management and synchronization API. The TypeScript wrappers in `src/lib/git.ts` are the authoritative full list.
|
||||||
|
|
||||||
- `open_repository(path: string): Promise<GitStatus>`
|
- `open_repository(path: string): Promise<GitStatus>`
|
||||||
|
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
|
||||||
|
- `clone_repository(...): Promise<RepositoryBundle>`
|
||||||
- `get_status(path: string): Promise<GitStatus>`
|
- `get_status(path: string): Promise<GitStatus>`
|
||||||
- `list_branches(path: string): Promise<GitBranch[]>`
|
- `list_branches(path: string): Promise<GitBranch[]>`
|
||||||
|
- `list_remotes(path: string): Promise<GitRemote[]>`
|
||||||
|
- `add_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
|
||||||
|
- `update_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
|
||||||
|
- `remove_remote(path: string, name: string): Promise<GitRemote[]>`
|
||||||
|
- `set_branch_upstream(path: string, branch: string, upstream?: string): Promise<GitStatus>`
|
||||||
|
- `delete_remote_branch(path: string, remote: string, branch: string): Promise<GitStatus>`
|
||||||
- `checkout_branch(path: string, branch: string): Promise<GitStatus>`
|
- `checkout_branch(path: string, branch: string): Promise<GitStatus>`
|
||||||
|
- `list_worktrees(path: string): Promise<GitWorktree[]>`
|
||||||
|
- `add_worktree(path: string, worktreePath: string, ...): Promise<GitWorktree[]>`
|
||||||
|
- `remove_worktree(path: string, worktreePath: string, force?: boolean): Promise<GitWorktree[]>`
|
||||||
|
- `move_worktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]>`
|
||||||
|
- `lock_worktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]>`
|
||||||
|
- `unlock_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
|
||||||
|
- `prune_worktrees(path: string): Promise<GitWorktree[]>`
|
||||||
|
- `repair_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
|
||||||
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
|
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||||
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
|
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||||
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
|
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
|
||||||
- `commit(path: string, message: string): Promise<GitStatus>`
|
- `commit(path: string, message: string): Promise<GitStatus>`
|
||||||
- `pull(path: string): Promise<GitStatus>`
|
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
|
||||||
- `push(path: string): Promise<GitStatus>`
|
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
|
||||||
|
- `push(path: string, forceWithLease?: boolean, remote?: string): Promise<GitStatus>`
|
||||||
- `list_commits(path: string, limit?: number): Promise<GitCommit[]>`
|
- `list_commits(path: string, limit?: number): Promise<GitCommit[]>`
|
||||||
- `restore_to_commit(path: string, commit: string): Promise<GitStatus>`
|
- `restore_to_commit(path: string, commit: string): Promise<GitStatus>`
|
||||||
- `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path)
|
- `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path)
|
||||||
- `merge_branch(path: string, branch: string): Promise<GitStatus>`
|
- `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise<GitStatus>`
|
||||||
|
- `merge_continue(path: string): Promise<GitStatus>`
|
||||||
|
- `merge_abort(path: string): Promise<GitStatus>`
|
||||||
|
- `revert_commit(path: string, commit: string): Promise<GitStatus>`
|
||||||
- `list_repository_files(path: string): Promise<GitRepositoryFile[]>`
|
- `list_repository_files(path: string): Promise<GitRepositoryFile[]>`
|
||||||
- `list_file_history(path: string, file: string, limit?: number): Promise<GitCommit[]>` (the `file` argument can also be a folder path)
|
- `list_file_history(path: string, file: string, limit?: number): Promise<GitCommit[]>` (the `file` argument can also be a folder path)
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.7.17",
|
"version": "2026.7.20",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.7.17",
|
"version": "2026.7.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^1.21.0",
|
"@lucide/svelte": "^1.21.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.7.17",
|
"version": "2026.7.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+253
-123
@@ -574,13 +574,22 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block2"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
|
||||||
|
dependencies = [
|
||||||
|
"objc2 0.5.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1119,6 +1128,19 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-graphics"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-graphics-types",
|
||||||
|
"foreign-types 0.5.0",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-graphics"
|
name = "core-graphics"
|
||||||
version = "0.25.0"
|
version = "0.25.0"
|
||||||
@@ -1592,9 +1614,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2 0.6.2",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2537,8 +2559,11 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"commit_ai",
|
"commit_ai",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
"log",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sysinfo 0.38.3",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-aptabase",
|
"tauri-plugin-aptabase",
|
||||||
@@ -3883,7 +3908,7 @@ dependencies = [
|
|||||||
"statrs",
|
"statrs",
|
||||||
"strum 0.27.2",
|
"strum 0.27.2",
|
||||||
"symphonia",
|
"symphonia",
|
||||||
"sysinfo",
|
"sysinfo 0.36.1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokenizers 0.21.4",
|
"tokenizers 0.21.4",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -4013,10 +4038,10 @@ dependencies = [
|
|||||||
"dpi",
|
"dpi",
|
||||||
"gtk",
|
"gtk",
|
||||||
"keyboard-types",
|
"keyboard-types",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -4310,6 +4335,22 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc-sys"
|
||||||
|
version = "0.3.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
|
||||||
|
dependencies = [
|
||||||
|
"objc-sys",
|
||||||
|
"objc2-encode",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -4322,92 +4363,80 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-app-kit"
|
name = "objc2-app-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2 0.6.2",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-cloud-kit"
|
name = "objc2-cloud-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-data"
|
name = "objc2-core-data"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-foundation"
|
name = "objc2-core-foundation"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-graphics"
|
name = "objc2-core-graphics"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-io-surface",
|
"objc2-io-surface",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-image"
|
name = "objc2-core-image"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
|
checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-location"
|
name = "objc2-core-location"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
|
checksum = "ac0f75792558aa9d618443bbb5db7426a7a0b6fddf96903f86ef9ad02e135740"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc2-core-text"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.0",
|
|
||||||
"objc2",
|
|
||||||
"objc2-core-foundation",
|
|
||||||
"objc2-core-graphics",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4427,22 +4456,34 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-foundation"
|
name = "objc2-foundation"
|
||||||
version = "0.3.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2 0.5.1",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2",
|
"objc2 0.5.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-foundation"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"block2 0.6.2",
|
||||||
|
"libc",
|
||||||
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-io-kit"
|
name = "objc2-io-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
@@ -4450,82 +4491,106 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-io-surface"
|
name = "objc2-io-surface"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-metal"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"block2 0.5.1",
|
||||||
|
"objc2 0.5.2",
|
||||||
|
"objc2-foundation 0.2.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-osa-kit"
|
name = "objc2-osa-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
checksum = "26bb88504b5a050dbba515d2414607bf5e57dd56b107bc5f0351197a3e7bdc5d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.3.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"objc2",
|
"block2 0.5.1",
|
||||||
|
"objc2 0.5.2",
|
||||||
|
"objc2-foundation 0.2.2",
|
||||||
|
"objc2-metal",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-quartz-core"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-ui-kit"
|
name = "objc2-ui-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2 0.6.2",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-cloud-kit",
|
"objc2-cloud-kit",
|
||||||
"objc2-core-data",
|
"objc2-core-data",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-core-image",
|
"objc2-core-image",
|
||||||
"objc2-core-location",
|
"objc2-core-location",
|
||||||
"objc2-core-text",
|
"objc2-foundation 0.3.1",
|
||||||
"objc2-foundation",
|
"objc2-quartz-core 0.3.1",
|
||||||
"objc2-quartz-core",
|
|
||||||
"objc2-user-notifications",
|
"objc2-user-notifications",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-user-notifications"
|
name = "objc2-user-notifications"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
|
checksum = "2a3f5ec77a81d9e0c5a0b32159b0cb143d7086165e79708351e02bf37dfc65cd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-web-kit"
|
name = "objc2-web-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
|
checksum = "91672909de8b1ce1c2252e95bbee8c1649c9ad9d14b9248b3d7b4c47903c47ad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2 0.6.2",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4663,8 +4728,8 @@ dependencies = [
|
|||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"log",
|
"log",
|
||||||
"nix 0.31.3",
|
"nix 0.31.3",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"serde",
|
"serde",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -4676,8 +4741,8 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"objc2-osa-kit",
|
"objc2-osa-kit",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -5539,17 +5604,17 @@ version = "0.16.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2 0.6.2",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"glib-sys",
|
"glib-sys",
|
||||||
"gobject-sys",
|
"gobject-sys",
|
||||||
"gtk-sys",
|
"gtk-sys",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
@@ -6357,24 +6422,24 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "softbuffer"
|
name = "softbuffer"
|
||||||
version = "0.4.8"
|
version = "0.4.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
|
checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
|
"cfg_aliases",
|
||||||
|
"core-graphics 0.24.0",
|
||||||
|
"foreign-types 0.5.0",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"ndk",
|
"log",
|
||||||
"objc2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-foundation",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-core-graphics",
|
"objc2-quartz-core 0.2.2",
|
||||||
"objc2-foundation",
|
|
||||||
"objc2-quartz-core",
|
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"redox_syscall",
|
"redox_syscall",
|
||||||
"tracing",
|
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6771,7 +6836,21 @@ dependencies = [
|
|||||||
"ntapi",
|
"ntapi",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-io-kit",
|
"objc2-io-kit",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sysinfo"
|
||||||
|
version = "0.38.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d03c61d2a49c649a15c407338afe7accafde9dac869995dccb73e5f7ef7d9034"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"ntapi",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-io-kit",
|
||||||
|
"windows 0.62.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6815,9 +6894,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2 0.6.2",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics 0.25.0",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
@@ -6831,9 +6910,9 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -6842,7 +6921,7 @@ dependencies = [
|
|||||||
"tao-macros",
|
"tao-macros",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
@@ -6898,9 +6977,9 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
"muda",
|
"muda",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -6924,7 +7003,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"window-vibrancy",
|
"window-vibrancy",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7053,7 +7132,7 @@ dependencies = [
|
|||||||
"dunce",
|
"dunce",
|
||||||
"glob",
|
"glob",
|
||||||
"log",
|
"log",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"schemars 0.8.22",
|
"schemars 0.8.22",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -7126,7 +7205,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
@@ -7137,7 +7216,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7150,7 +7229,7 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -7162,7 +7241,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7810,11 +7889,11 @@ dependencies = [
|
|||||||
"dirs",
|
"dirs",
|
||||||
"libappindicator",
|
"libappindicator",
|
||||||
"muda",
|
"muda",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -8388,7 +8467,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"webview2-com-macros",
|
"webview2-com-macros",
|
||||||
"webview2-com-sys",
|
"webview2-com-sys",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
@@ -8412,7 +8491,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8475,10 +8554,10 @@ version = "0.6.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
|
checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
@@ -8490,11 +8569,23 @@ version = "0.61.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-collections",
|
"windows-collections 0.2.0",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-future",
|
"windows-future 0.2.1",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-numerics",
|
"windows-numerics 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||||
|
dependencies = [
|
||||||
|
"windows-collections 0.3.2",
|
||||||
|
"windows-core 0.62.2",
|
||||||
|
"windows-future 0.3.2",
|
||||||
|
"windows-numerics 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8506,6 +8597,15 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-collections"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.62.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -8540,7 +8640,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-threading",
|
"windows-threading 0.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-future"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.62.2",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
"windows-threading 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8587,6 +8698,16 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-numerics"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.62.2",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-registry"
|
name = "windows-registry"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -8760,6 +8881,15 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-threading"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-version"
|
name = "windows-version"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -9002,7 +9132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
|
checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"block2",
|
"block2 0.6.2",
|
||||||
"cookie",
|
"cookie",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -9016,10 +9146,10 @@ dependencies = [
|
|||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk",
|
||||||
"objc2",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation 0.3.1",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -9033,7 +9163,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
|
|||||||
@@ -21,12 +21,21 @@ tauri-plugin-aptabase = "1.0"
|
|||||||
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
||||||
commit_ai = { path = "crates/commit_ai" }
|
commit_ai = { path = "crates/commit_ai" }
|
||||||
tokio = "1.52.3"
|
tokio = "1.52.3"
|
||||||
|
log = "0.4"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
|
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|
||||||
|
# Self-updating only makes sense for install methods this app ships itself
|
||||||
|
# (NSIS on Windows, the .app bundle on macOS). On Linux the app is meant to be
|
||||||
|
# installed via the system package manager (e.g. the PKGBUILD), which owns
|
||||||
|
# updates instead — so the updater plugin isn't even compiled in there.
|
||||||
|
[target.'cfg(not(any(target_os = "android", target_os = "ios", target_os = "linux")))'.dependencies]
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
|
|
||||||
# ── Build profiles ───────────────────────────────────────────────────────────
|
# ── Build profiles ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
"identifier": "desktop-capability",
|
"identifier": "desktop-capability",
|
||||||
"platforms": [
|
"platforms": [
|
||||||
"macOS",
|
"macOS",
|
||||||
"windows",
|
"windows"
|
||||||
"linux"
|
|
||||||
],
|
],
|
||||||
"windows": [
|
"windows": [
|
||||||
"main"
|
"main"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{build_messages, looks_like_diff_echo, sanitize_message};
|
use crate::{build_messages, build_review_messages, looks_like_diff_echo, sanitize_message};
|
||||||
|
|
||||||
// Generous sizing so a detailed body with bullet points isn't cut off.
|
// Generous sizing so a detailed body with bullet points isn't cut off.
|
||||||
const DEFAULT_MAX_TOKENS: u32 = 1500;
|
const DEFAULT_MAX_TOKENS: u32 = 1500;
|
||||||
@@ -139,6 +139,82 @@ pub async fn generate_custom(
|
|||||||
openai_compatible_request(url, api_key, model, diff, notes).await
|
openai_compatible_request(url, api_key, model, diff, notes).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn openai_compatible_review_request(
|
||||||
|
url: String,
|
||||||
|
bearer: Option<&str>,
|
||||||
|
model: &str,
|
||||||
|
diff: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let (system, user) = build_review_messages(diff)?;
|
||||||
|
let body = OpenAiRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
messages: vec![
|
||||||
|
OpenAiMessage {
|
||||||
|
role: "system",
|
||||||
|
content: system,
|
||||||
|
},
|
||||||
|
OpenAiMessage {
|
||||||
|
role: "user",
|
||||||
|
content: user,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.1,
|
||||||
|
};
|
||||||
|
let client = http_client()?;
|
||||||
|
let mut request = client.post(url).json(&body);
|
||||||
|
if let Some(key) = bearer.filter(|key| !key.trim().is_empty()) {
|
||||||
|
request = request.bearer_auth(key);
|
||||||
|
}
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Request to the AI model failed: {err}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("API error ({status}): {text}"));
|
||||||
|
}
|
||||||
|
let parsed: OpenAiResponse =
|
||||||
|
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
parsed
|
||||||
|
.choices
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.and_then(|choice| choice.message.content)
|
||||||
|
.map(|content| sanitize_message(&content))
|
||||||
|
.filter(|content| !content.is_empty())
|
||||||
|
.ok_or_else(|| "The model did not return a review.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn review_openai(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return Err("OpenAI API key is missing.".to_string());
|
||||||
|
}
|
||||||
|
openai_compatible_review_request(
|
||||||
|
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||||
|
Some(api_key),
|
||||||
|
model,
|
||||||
|
diff,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn review_custom(
|
||||||
|
base_url: &str,
|
||||||
|
api_key: Option<&str>,
|
||||||
|
model: &str,
|
||||||
|
diff: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if base_url.trim().is_empty() {
|
||||||
|
return Err("Endpoint URL is missing.".to_string());
|
||||||
|
}
|
||||||
|
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
|
||||||
|
openai_compatible_review_request(url, api_key, model, diff).await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct AnthropicMessage {
|
struct AnthropicMessage {
|
||||||
role: &'static str,
|
role: &'static str,
|
||||||
@@ -220,3 +296,45 @@ pub async fn generate_anthropic(
|
|||||||
}
|
}
|
||||||
Ok(message)
|
Ok(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn review_anthropic(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return Err("Anthropic API key is missing.".to_string());
|
||||||
|
}
|
||||||
|
let (system, user) = build_review_messages(diff)?;
|
||||||
|
let body = AnthropicRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
max_tokens: 2400,
|
||||||
|
system,
|
||||||
|
messages: vec![AnthropicMessage {
|
||||||
|
role: "user",
|
||||||
|
content: user,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let client = http_client()?;
|
||||||
|
let response = client
|
||||||
|
.post("https://api.anthropic.com/v1/messages")
|
||||||
|
.header("x-api-key", api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("API error ({status}): {text}"));
|
||||||
|
}
|
||||||
|
let parsed: AnthropicResponse =
|
||||||
|
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
parsed
|
||||||
|
.content
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|block| block.text)
|
||||||
|
.map(|text| sanitize_message(&text))
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.ok_or_else(|| "The model did not return a review.".to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
mod cloud;
|
mod cloud;
|
||||||
|
|
||||||
pub use cloud::{generate_anthropic, generate_custom, generate_openai};
|
pub use cloud::{
|
||||||
|
generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom,
|
||||||
|
review_openai,
|
||||||
|
};
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::hash_map::DefaultHasher,
|
collections::hash_map::DefaultHasher,
|
||||||
@@ -443,3 +446,27 @@ instead of a direct string comparison.\n\n\
|
|||||||
user.push_str(&format!("Staged diff:\n{diff}"));
|
user.push_str(&format!("Staged diff:\n{diff}"));
|
||||||
Ok((system, user))
|
Ok((system, user))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_review_messages(diff: &str) -> Result<(String, String), String> {
|
||||||
|
if diff.trim().is_empty() {
|
||||||
|
return Err("No staged changes available for review.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CHARS: usize = 36_000;
|
||||||
|
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
|
||||||
|
let system = r#"You are a senior software engineer performing a focused pre-commit review.
|
||||||
|
Review only the supplied staged Git diff. Look for concrete correctness bugs, security issues,
|
||||||
|
data loss, regressions, broken edge cases, unsafe error handling, and meaningful performance or
|
||||||
|
maintainability risks. Do not report formatting preferences or speculative nitpicks.
|
||||||
|
|
||||||
|
Return ONLY valid JSON with this exact shape:
|
||||||
|
{"summary":"one concise overall assessment","risk":"low|medium|high","findings":[{"severity":"critical|warning|info","title":"short title","description":"clear evidence and impact","file":"path or null","line":123,"suggestion":"specific safe next step"}]}
|
||||||
|
|
||||||
|
Use the new-file line number from the diff when it is known; otherwise use null. Use null for file
|
||||||
|
when the issue is repository-wide. Maximum 12 findings, ordered critical then warning then info.
|
||||||
|
If no actionable issue exists, return an empty findings array and risk low. Never use markdown,
|
||||||
|
code fences, commentary outside the JSON, or claim that tests were executed."#
|
||||||
|
.to_string();
|
||||||
|
let user = format!("Staged diff to review:\n{diff}");
|
||||||
|
Ok((system, user))
|
||||||
|
}
|
||||||
|
|||||||
+15
-1
@@ -177,7 +177,21 @@ pub fn set_sync_badge(
|
|||||||
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
|
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
// Native badge count: works on Linux (via the desktop's launcher API, e.g. Unity's
|
||||||
|
// LauncherEntry, also honored by GNOME/KDE) and macOS (dock badge). Matched against the
|
||||||
|
// app's `<product-name>.desktop` file on Linux, so it's a silent no-op on launchers that
|
||||||
|
// don't implement the protocol.
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
{
|
||||||
|
let Some(window) = app.get_webview_window("main") else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
window
|
||||||
|
.set_badge_count(if count > 0 { Some(count as i64) } else { None })
|
||||||
|
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
|
||||||
{
|
{
|
||||||
let _ = (app, count);
|
let _ = (app, count);
|
||||||
}
|
}
|
||||||
|
|||||||
+1487
-22
File diff suppressed because it is too large
Load Diff
+101
-19
@@ -2,24 +2,61 @@
|
|||||||
|
|
||||||
mod badge;
|
mod badge;
|
||||||
mod git;
|
mod git;
|
||||||
|
mod telemetry;
|
||||||
|
|
||||||
use badge::set_sync_badge;
|
use badge::set_sync_badge;
|
||||||
use git::{
|
use git::{
|
||||||
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
|
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
|
||||||
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
|
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||||
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
|
||||||
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
|
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits,
|
||||||
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
|
compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete,
|
||||||
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame,
|
cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
|
||||||
get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits,
|
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
|
||||||
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
|
get_status, init_repository, last_commit_message, list_branches, list_commits,
|
||||||
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
|
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
|
||||||
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
|
list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort,
|
||||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository,
|
||||||
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
|
open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag,
|
||||||
stash_push, undo_last_commit, unstage_files,
|
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, AppHandle};
|
use tauri::Manager;
|
||||||
|
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
||||||
|
|
||||||
|
struct ConsoleLogger;
|
||||||
|
|
||||||
|
impl log::Log for ConsoleLogger {
|
||||||
|
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||||
|
metadata.level() <= log::Level::Info
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &log::Record<'_>) {
|
||||||
|
if self.enabled(record.metadata()) {
|
||||||
|
eprintln!(
|
||||||
|
"[{}] [{}] {}",
|
||||||
|
record.level(),
|
||||||
|
record.target(),
|
||||||
|
record.args()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static CONSOLE_LOGGER: ConsoleLogger = ConsoleLogger;
|
||||||
|
|
||||||
|
fn init_console_logging() {
|
||||||
|
if log::set_logger(&CONSOLE_LOGGER).is_ok() {
|
||||||
|
log::set_max_level(log::LevelFilter::Info);
|
||||||
|
log::info!(target: "gitty", "Rust console logging initialized");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
@@ -31,6 +68,7 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.maximize();
|
||||||
window
|
window
|
||||||
.show()
|
.show()
|
||||||
.map_err(|error| format!("failed to show main window: {error}"))?;
|
.map_err(|error| format!("failed to show main window: {error}"))?;
|
||||||
@@ -42,14 +80,24 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tauri::Builder::default()
|
init_console_logging();
|
||||||
|
telemetry::init();
|
||||||
|
if let Some(result) = run_sequence_editor_if_requested() {
|
||||||
|
if let Err(error) = result {
|
||||||
|
eprintln!("{error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let builder = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
let _ = app.get_webview_window("main")
|
let _ = app
|
||||||
|
.get_webview_window("main")
|
||||||
.expect("no main window")
|
.expect("no main window")
|
||||||
.set_focus();
|
.set_focus();
|
||||||
}))
|
}))
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
|
tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
|
||||||
.with_options(tauri_plugin_aptabase::InitOptions {
|
.with_options(tauri_plugin_aptabase::InitOptions {
|
||||||
@@ -60,19 +108,42 @@ async fn main() {
|
|||||||
)
|
)
|
||||||
.manage(SearchCancellationState::default())
|
.manage(SearchCancellationState::default())
|
||||||
.manage(commit_ai::CommitAiEngine::new())
|
.manage(commit_ai::CommitAiEngine::new())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init());
|
||||||
|
|
||||||
|
// Linux installs are expected to come from the system package manager (see the
|
||||||
|
// PKGBUILD), which owns updates itself — the self-updater is only wired up for
|
||||||
|
// the platforms whose install method this app ships (NSIS/Windows, .app/macOS).
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||||
|
|
||||||
|
builder
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
open_repository,
|
open_repository,
|
||||||
|
init_repository,
|
||||||
clone_repository,
|
clone_repository,
|
||||||
open_repo_in_explorer,
|
open_repo_in_explorer,
|
||||||
open_repository_file,
|
open_repository_file,
|
||||||
get_status,
|
get_status,
|
||||||
list_branches,
|
list_branches,
|
||||||
|
list_remotes,
|
||||||
|
add_remote,
|
||||||
|
update_remote,
|
||||||
|
remove_remote,
|
||||||
|
set_branch_upstream,
|
||||||
|
delete_remote_branch,
|
||||||
list_stashes,
|
list_stashes,
|
||||||
checkout_branch,
|
checkout_branch,
|
||||||
create_branch,
|
create_branch,
|
||||||
rename_branch,
|
rename_branch,
|
||||||
delete_branch,
|
delete_branch,
|
||||||
|
list_worktrees,
|
||||||
|
add_worktree,
|
||||||
|
remove_worktree,
|
||||||
|
move_worktree,
|
||||||
|
lock_worktree,
|
||||||
|
unlock_worktree,
|
||||||
|
prune_worktrees,
|
||||||
|
repair_worktree,
|
||||||
list_tags,
|
list_tags,
|
||||||
create_tag,
|
create_tag,
|
||||||
delete_tag,
|
delete_tag,
|
||||||
@@ -97,6 +168,7 @@ async fn main() {
|
|||||||
commit_ai_load,
|
commit_ai_load,
|
||||||
commit_ai_local_models,
|
commit_ai_local_models,
|
||||||
commit_ai_generate,
|
commit_ai_generate,
|
||||||
|
commit_ai_review,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
fetch,
|
fetch,
|
||||||
@@ -104,9 +176,16 @@ async fn main() {
|
|||||||
restore_to_commit,
|
restore_to_commit,
|
||||||
restore_file_from_commit,
|
restore_file_from_commit,
|
||||||
merge_branch,
|
merge_branch,
|
||||||
|
merge_continue,
|
||||||
|
merge_abort,
|
||||||
|
revert_commit,
|
||||||
rebase_branch,
|
rebase_branch,
|
||||||
rebase_continue,
|
rebase_continue,
|
||||||
rebase_abort,
|
rebase_abort,
|
||||||
|
list_interactive_rebase_commits,
|
||||||
|
start_interactive_rebase,
|
||||||
|
list_reflog,
|
||||||
|
restore_reflog_entry,
|
||||||
list_repository_files,
|
list_repository_files,
|
||||||
open_repository_bundle,
|
open_repository_bundle,
|
||||||
list_file_history,
|
list_file_history,
|
||||||
@@ -126,7 +205,10 @@ async fn main() {
|
|||||||
cred_save,
|
cred_save,
|
||||||
cred_delete,
|
cred_delete,
|
||||||
set_sync_badge,
|
set_sync_badge,
|
||||||
close_splashscreen
|
close_splashscreen,
|
||||||
|
set_telemetry_enabled,
|
||||||
|
emit_frontend_log,
|
||||||
|
emit_frontend_span
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
use std::{
|
||||||
|
sync::{
|
||||||
|
OnceLock,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
mpsc::{SyncSender, sync_channel},
|
||||||
|
},
|
||||||
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
const DEFAULT_OTLP_LOGS_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/logs";
|
||||||
|
const DEFAULT_OTLP_TRACES_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/traces";
|
||||||
|
const DEFAULT_OTLP_METRICS_ENDPOINT: &str = "https://telemetry.cbsk-tech.de/v1/metrics";
|
||||||
|
const METRICS_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
|
const MAX_BODY_BYTES: usize = 2_048;
|
||||||
|
|
||||||
|
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||||
|
static SENDER: OnceLock<SyncSender<TelemetrySignal>> = OnceLock::new();
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum TelemetrySignal {
|
||||||
|
Log(TelemetryRecord),
|
||||||
|
Span(FrontendSpan),
|
||||||
|
Metrics(ProcessMetrics),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ProcessMetrics {
|
||||||
|
cpu_utilization: f64,
|
||||||
|
memory_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TelemetryRecord {
|
||||||
|
severity: &'static str,
|
||||||
|
severity_number: u8,
|
||||||
|
body: String,
|
||||||
|
event_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FrontendLog {
|
||||||
|
level: String,
|
||||||
|
message: String,
|
||||||
|
event_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FrontendSpan {
|
||||||
|
name: String,
|
||||||
|
trace_id: String,
|
||||||
|
span_id: String,
|
||||||
|
started_at_ms: u64,
|
||||||
|
duration_ms: f64,
|
||||||
|
success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init() {
|
||||||
|
let endpoint = std::env::var("GITTY_OTLP_LOGS_ENDPOINT")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_OTLP_LOGS_ENDPOINT.to_string());
|
||||||
|
let traces_endpoint = std::env::var("GITTY_OTLP_TRACES_ENDPOINT")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_OTLP_TRACES_ENDPOINT.to_string());
|
||||||
|
let metrics_endpoint = std::env::var("GITTY_OTLP_METRICS_ENDPOINT")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_OTLP_METRICS_ENDPOINT.to_string());
|
||||||
|
let (sender, receiver) = sync_channel::<TelemetrySignal>(256);
|
||||||
|
if SENDER.set(sender.clone()).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
start_process_metrics(sender);
|
||||||
|
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("gitty-telemetry".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let client = match reqwest::blocking::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("[WARN] [gitty::telemetry] could not create OTLP client: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut export_warning_shown = false;
|
||||||
|
while let Ok(signal) = receiver.recv() {
|
||||||
|
let (target_endpoint, payload) = match signal {
|
||||||
|
TelemetrySignal::Log(record) => (&endpoint, log_payload(record)),
|
||||||
|
TelemetrySignal::Span(span) => (&traces_endpoint, span_payload(span)),
|
||||||
|
TelemetrySignal::Metrics(metrics) => {
|
||||||
|
(&metrics_endpoint, metrics_payload(metrics))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export(
|
||||||
|
&client,
|
||||||
|
target_endpoint,
|
||||||
|
&payload,
|
||||||
|
&mut export_warning_shown,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("failed to start telemetry worker");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_process_metrics(sender: SyncSender<TelemetrySignal>) {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("gitty-process-metrics".into())
|
||||||
|
.spawn(move || {
|
||||||
|
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
|
||||||
|
|
||||||
|
let Ok(pid) = sysinfo::get_current_pid() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let refresh_kind = ProcessRefreshKind::nothing().with_cpu().with_memory();
|
||||||
|
let cpu_count = std::thread::available_parallelism()
|
||||||
|
.map(|count| count.get())
|
||||||
|
.unwrap_or(1) as f64;
|
||||||
|
let mut system = System::new();
|
||||||
|
let mut primed = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(METRICS_INTERVAL);
|
||||||
|
if !ENABLED.load(Ordering::Relaxed) {
|
||||||
|
primed = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
system.refresh_processes_specifics(
|
||||||
|
ProcessesToUpdate::Some(&[pid]),
|
||||||
|
true,
|
||||||
|
refresh_kind,
|
||||||
|
);
|
||||||
|
let Some(process) = system.process(pid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !primed {
|
||||||
|
primed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let metrics = ProcessMetrics {
|
||||||
|
cpu_utilization: (f64::from(process.cpu_usage()) / (100.0 * cpu_count))
|
||||||
|
.clamp(0.0, 1.0),
|
||||||
|
memory_bytes: process.memory(),
|
||||||
|
};
|
||||||
|
let _ = sender.try_send(TelemetrySignal::Metrics(metrics));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("failed to start process metrics worker");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resource_attributes() -> serde_json::Value {
|
||||||
|
json!([
|
||||||
|
{ "key": "service.name", "value": { "stringValue": "gitty-desktop" } },
|
||||||
|
{ "key": "service.version", "value": { "stringValue": env!("CARGO_PKG_VERSION") } },
|
||||||
|
{ "key": "deployment.environment.name", "value": { "stringValue": if cfg!(debug_assertions) { "development" } else { "production" } } },
|
||||||
|
{ "key": "os.type", "value": { "stringValue": std::env::consts::OS } },
|
||||||
|
{ "key": "host.arch", "value": { "stringValue": std::env::consts::ARCH } }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_payload(record: TelemetryRecord) -> serde_json::Value {
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
.to_string();
|
||||||
|
let mut attributes = vec![json!({
|
||||||
|
"key": "telemetry.sdk.language",
|
||||||
|
"value": { "stringValue": "rust" }
|
||||||
|
})];
|
||||||
|
if let Some(event_name) = record.event_name {
|
||||||
|
attributes.push(json!({
|
||||||
|
"key": "event.name",
|
||||||
|
"value": { "stringValue": event_name }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let payload = json!({
|
||||||
|
"resourceLogs": [{
|
||||||
|
"resource": { "attributes": resource_attributes() },
|
||||||
|
"scopeLogs": [{
|
||||||
|
"scope": { "name": "gitty.telemetry" },
|
||||||
|
"logRecords": [{
|
||||||
|
"timeUnixNano": timestamp,
|
||||||
|
"observedTimeUnixNano": timestamp,
|
||||||
|
"severityNumber": record.severity_number,
|
||||||
|
"severityText": record.severity,
|
||||||
|
"body": { "stringValue": record.body },
|
||||||
|
"attributes": attributes
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fn span_payload(mut span: FrontendSpan) -> serde_json::Value {
|
||||||
|
if span.name.len() > 128 {
|
||||||
|
span.name.truncate(128);
|
||||||
|
}
|
||||||
|
let start = u128::from(span.started_at_ms) * 1_000_000;
|
||||||
|
let duration = (span.duration_ms.max(0.0) * 1_000_000.0) as u128;
|
||||||
|
let attributes = vec![
|
||||||
|
json!({ "key": "rpc.system", "value": { "stringValue": "tauri" } }),
|
||||||
|
json!({ "key": "rpc.method", "value": { "stringValue": span.name } }),
|
||||||
|
];
|
||||||
|
json!({
|
||||||
|
"resourceSpans": [{
|
||||||
|
"resource": { "attributes": resource_attributes() },
|
||||||
|
"scopeSpans": [{
|
||||||
|
"scope": { "name": "gitty.tauri", "version": env!("CARGO_PKG_VERSION") },
|
||||||
|
"spans": [{
|
||||||
|
"traceId": span.trace_id,
|
||||||
|
"spanId": span.span_id,
|
||||||
|
"name": format!("tauri.{}", span.name),
|
||||||
|
"kind": 1,
|
||||||
|
"startTimeUnixNano": start.to_string(),
|
||||||
|
"endTimeUnixNano": (start + duration).to_string(),
|
||||||
|
"attributes": attributes,
|
||||||
|
"status": {
|
||||||
|
"code": if span.success { 1 } else { 2 },
|
||||||
|
"message": if span.success { "" } else { "Tauri command failed" }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metrics_payload(metrics: ProcessMetrics) -> serde_json::Value {
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos()
|
||||||
|
.to_string();
|
||||||
|
json!({
|
||||||
|
"resourceMetrics": [{
|
||||||
|
"resource": { "attributes": resource_attributes() },
|
||||||
|
"scopeMetrics": [{
|
||||||
|
"scope": { "name": "gitty.process", "version": env!("CARGO_PKG_VERSION") },
|
||||||
|
"metrics": [
|
||||||
|
{
|
||||||
|
"name": "process.cpu.utilization",
|
||||||
|
"description": "Normalized CPU utilization of the Gitty process",
|
||||||
|
"unit": "1",
|
||||||
|
"gauge": { "dataPoints": [{
|
||||||
|
"timeUnixNano": timestamp,
|
||||||
|
"asDouble": metrics.cpu_utilization,
|
||||||
|
"attributes": []
|
||||||
|
}] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "process.memory.usage",
|
||||||
|
"description": "Resident memory used by the Gitty process",
|
||||||
|
"unit": "By",
|
||||||
|
"gauge": { "dataPoints": [{
|
||||||
|
"timeUnixNano": timestamp,
|
||||||
|
"asInt": metrics.memory_bytes.to_string(),
|
||||||
|
"attributes": []
|
||||||
|
}] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export(
|
||||||
|
client: &reqwest::blocking::Client,
|
||||||
|
endpoint: &str,
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
export_warning_shown: &mut bool,
|
||||||
|
) {
|
||||||
|
match client.post(endpoint).json(payload).send() {
|
||||||
|
Ok(response) if response.status().is_success() => {
|
||||||
|
if response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| value.starts_with("text/html"))
|
||||||
|
&& !*export_warning_shown
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[WARN] [gitty::telemetry] OTLP endpoint returned HTML; configure the collector endpoint with GITTY_OTLP_LOGS_ENDPOINT"
|
||||||
|
);
|
||||||
|
*export_warning_shown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response) if !*export_warning_shown => {
|
||||||
|
eprintln!(
|
||||||
|
"[WARN] [gitty::telemetry] OTLP export failed with status {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
*export_warning_shown = true;
|
||||||
|
}
|
||||||
|
Err(error) if !*export_warning_shown => {
|
||||||
|
eprintln!("[WARN] [gitty::telemetry] OTLP export failed: {error}");
|
||||||
|
*export_warning_shown = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(record: TelemetryRecord) {
|
||||||
|
if !ENABLED.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(sender) = SENDER.get() {
|
||||||
|
let _ = sender.try_send(TelemetrySignal::Log(record));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_telemetry_enabled(enabled: bool) {
|
||||||
|
ENABLED.store(enabled, Ordering::Relaxed);
|
||||||
|
if enabled {
|
||||||
|
emit(TelemetryRecord {
|
||||||
|
severity: "INFO",
|
||||||
|
severity_number: 9,
|
||||||
|
body: "Telemetry enabled".into(),
|
||||||
|
event_name: Some("telemetry.enabled".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn emit_frontend_log(log: FrontendLog) {
|
||||||
|
let (severity, severity_number) = match log.level.as_str() {
|
||||||
|
"error" => ("ERROR", 17),
|
||||||
|
"warn" => ("WARN", 13),
|
||||||
|
_ => ("INFO", 9),
|
||||||
|
};
|
||||||
|
let mut body = log.message;
|
||||||
|
if body.len() > MAX_BODY_BYTES {
|
||||||
|
body.truncate(MAX_BODY_BYTES);
|
||||||
|
}
|
||||||
|
emit(TelemetryRecord {
|
||||||
|
severity,
|
||||||
|
severity_number,
|
||||||
|
body,
|
||||||
|
event_name: log.event_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn emit_frontend_span(span: FrontendSpan) {
|
||||||
|
if !ENABLED.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if span.trace_id.len() != 32
|
||||||
|
|| span.span_id.len() != 16
|
||||||
|
|| !span.trace_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
|| !span.span_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(sender) = SENDER.get() {
|
||||||
|
let _ = sender.try_send(TelemetrySignal::Span(span));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Gitty",
|
"productName": "Gitty",
|
||||||
"version": "2026.7.17",
|
"version": "2026.7.20",
|
||||||
"identifier": "com.gitty",
|
"identifier": "com.gitty",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+810
-133
File diff suppressed because it is too large
Load Diff
+1832
-155
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { BookOpen, Database, Plus, X } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface RepositoryTabItem {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
branch: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export let activeView: "management" | "repository" = "management";
|
||||||
|
export let repoTabs: RepositoryTabItem[] = [];
|
||||||
|
export let isBusy: boolean = false;
|
||||||
|
export let language: "en" | "de" = "en";
|
||||||
|
export let onOpenManagement: () => void = () => {};
|
||||||
|
export let isActive: (path: string) => boolean = () => false;
|
||||||
|
export let onSelect: (path: string) => void | Promise<void> = () => {};
|
||||||
|
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
|
||||||
|
export let onContextMenu: (path: string, event: MouseEvent) => void = () => {};
|
||||||
|
export let onAdd: () => void | Promise<void> = () => {};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="repo-tabbar" aria-label={language === "de" ? "Repository-Reiter" : "Repository tabs"}>
|
||||||
|
<button
|
||||||
|
class="repo-tab management"
|
||||||
|
class:active={activeView === "management"}
|
||||||
|
type="button"
|
||||||
|
onclick={onOpenManagement}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
||||||
|
>
|
||||||
|
<BookOpen size={14} aria-hidden="true" />
|
||||||
|
<span>{language === "de" ? "Repository-Verwaltung" : "Repository Management"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="repo-tabs-scroll">
|
||||||
|
{#each repoTabs as repo (repo.path)}
|
||||||
|
<div
|
||||||
|
class="repo-tab-wrap"
|
||||||
|
class:active={isActive(repo.path)}
|
||||||
|
role="presentation"
|
||||||
|
oncontextmenu={(event) => onContextMenu(repo.path, event)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="repo-tab"
|
||||||
|
type="button"
|
||||||
|
onclick={() => onSelect(repo.path)}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={repo.path}
|
||||||
|
>
|
||||||
|
<Database size={15} aria-hidden="true" />
|
||||||
|
<span>{repo.name}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="repo-tab-close"
|
||||||
|
type="button"
|
||||||
|
onclick={(event) => onClose(repo.path, event)}
|
||||||
|
disabled={isBusy}
|
||||||
|
aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
|
||||||
|
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
|
||||||
|
>
|
||||||
|
<X size={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>
|
||||||
+47
-197
@@ -1,33 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte";
|
import { CircleHelp, Minus, Settings, X } from "@lucide/svelte";
|
||||||
import iconUrl from "../../src-tauri/icons/icon.png";
|
import iconUrl from "../../src-tauri/icons/icon.png";
|
||||||
|
|
||||||
export let branch: string = "";
|
export let onOpenHelp: () => void = () => {};
|
||||||
export let ahead: number = 0;
|
|
||||||
export let behind: number = 0;
|
|
||||||
export let repoName: string = "";
|
|
||||||
export let hasRepository: boolean = false;
|
|
||||||
export let isBusy: boolean = false;
|
|
||||||
export let operation: string = "";
|
|
||||||
export let autoRefreshEnabled: boolean = true;
|
|
||||||
export let autoRefreshInFlight: boolean = false;
|
|
||||||
export let onFetch: () => void = () => {};
|
|
||||||
export let onPull: () => void = () => {};
|
|
||||||
export let onPush: () => void = () => {};
|
|
||||||
export let onRefresh: () => void = () => {};
|
|
||||||
export let onSearch: () => void = () => {};
|
|
||||||
export let onCompare: () => void = () => {};
|
|
||||||
export let onOpenInExplorer: () => void = () => {};
|
|
||||||
export let onToggleAutoRefresh: () => void = () => {};
|
|
||||||
export let onOpenSettings: () => void = () => {};
|
export let onOpenSettings: () => void = () => {};
|
||||||
|
export let language: "en" | "de" = "en";
|
||||||
|
|
||||||
let win: ReturnType<typeof getCurrentWindow> | null = null;
|
let win: ReturnType<typeof getCurrentWindow> | null = null;
|
||||||
let isMaximized = false;
|
let isMaximized = false;
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
let appVersion = "";
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -39,12 +22,6 @@
|
|||||||
} catch {
|
} catch {
|
||||||
win = null;
|
win = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
appVersion = await getVersion();
|
|
||||||
} catch {
|
|
||||||
appVersion = "";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -71,180 +48,53 @@
|
|||||||
<img src={iconUrl} alt="" data-tauri-drag-region />
|
<img src={iconUrl} alt="" data-tauri-drag-region />
|
||||||
</span>
|
</span>
|
||||||
<span data-tauri-drag-region>Gitty</span>
|
<span data-tauri-drag-region>Gitty</span>
|
||||||
{#if appVersion}
|
|
||||||
<span class="tb-version" data-tauri-drag-region title="Version {appVersion}">v{appVersion}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: repo + branch info -->
|
<div class="titlebar-drag" data-tauri-drag-region aria-hidden="true"></div>
|
||||||
<div class="titlebar-info" data-tauri-drag-region>
|
|
||||||
{#if hasRepository}
|
<!-- Right: app-global actions + window controls -->
|
||||||
{#if repoName}
|
<div class="titlebar-globals">
|
||||||
<span class="tb-repo" data-tauri-drag-region>{repoName}</span>
|
<button
|
||||||
<span class="tb-sep" data-tauri-drag-region aria-hidden="true">/</span>
|
class="tb-action"
|
||||||
{/if}
|
onclick={onOpenHelp}
|
||||||
<GitBranch size={12} aria-hidden="true" />
|
title={language === "de" ? "Hilfe (Ctrl+/)" : "Help (Ctrl+/)"}
|
||||||
<span class="tb-branch" data-tauri-drag-region>{branch}</span>
|
aria-label={language === "de" ? "Hilfe öffnen" : "Open help"}
|
||||||
{#if ahead > 0}
|
>
|
||||||
<span class="tb-sync ahead" title="{ahead} commits ahead">↑{ahead}</span>
|
<CircleHelp size={14} aria-hidden="true" />
|
||||||
{/if}
|
</button>
|
||||||
{#if behind > 0}
|
<button
|
||||||
<span class="tb-sync behind" title="{behind} commits behind">↓{behind}</span>
|
class="tb-action"
|
||||||
{/if}
|
onclick={onOpenSettings}
|
||||||
{:else}
|
title={language === "de" ? "Einstellungen" : "Settings"}
|
||||||
<span class="tb-no-repo" data-tauri-drag-region>No repository open</span>
|
aria-label={language === "de" ? "Einstellungen" : "Settings"}
|
||||||
{/if}
|
>
|
||||||
|
<Settings size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: actions + window controls -->
|
<div class="titlebar-controls">
|
||||||
<div class="titlebar-right">
|
<button class="tb-btn" onclick={minimizeWindow} title="Minimize" aria-label="Minimize">
|
||||||
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
|
<Minus size={12} aria-hidden="true" />
|
||||||
<button
|
</button>
|
||||||
class="tb-action"
|
<button
|
||||||
onclick={onOpenInExplorer}
|
class="tb-btn"
|
||||||
disabled={!hasRepository || isBusy}
|
onclick={toggleMaximizeWindow}
|
||||||
title="Open repository in Explorer"
|
title={isMaximized ? "Restore" : "Maximize"}
|
||||||
aria-label="Open repository in Explorer"
|
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||||
>
|
>
|
||||||
<FolderOpen size={14} aria-hidden="true" />
|
{#if isMaximized}
|
||||||
<span class="tb-action-label">Explorer</span>
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
</button>
|
<rect x="3" y="1" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<path d="M1 3v7a1 1 0 0 0 1 1h7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
<button
|
</svg>
|
||||||
class="tb-action"
|
{:else}
|
||||||
onclick={onSearch}
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
disabled={!hasRepository || isBusy}
|
<rect x="1" y="1" width="10" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||||
title="Global search"
|
</svg>
|
||||||
aria-label="Global search"
|
{/if}
|
||||||
>
|
</button>
|
||||||
<Search size={14} aria-hidden="true" />
|
<button class="tb-btn close" onclick={closeWindow} title="Close" aria-label="Close">
|
||||||
<span class="tb-action-label">Search</span>
|
<X size={12} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onCompare}
|
|
||||||
disabled={!hasRepository || isBusy}
|
|
||||||
title="Compare commits"
|
|
||||||
aria-label="Compare commits"
|
|
||||||
>
|
|
||||||
<GitCompare size={14} aria-hidden="true" />
|
|
||||||
<span class="tb-action-label">Compare</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onFetch}
|
|
||||||
disabled={!hasRepository || isBusy}
|
|
||||||
title="Fetch"
|
|
||||||
aria-label="Fetch"
|
|
||||||
>
|
|
||||||
{#if operation === "Fetching"}
|
|
||||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
|
||||||
{:else}
|
|
||||||
<CloudDownload size={14} aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
<span class="tb-action-label">Fetch</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onPull}
|
|
||||||
disabled={!hasRepository || isBusy}
|
|
||||||
title="Pull"
|
|
||||||
aria-label="Pull"
|
|
||||||
>
|
|
||||||
{#if operation === "Pulling"}
|
|
||||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
|
||||||
{:else}
|
|
||||||
<Download size={14} aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
<span class="tb-action-label">Pull</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onPush}
|
|
||||||
disabled={!hasRepository || isBusy}
|
|
||||||
title="Push"
|
|
||||||
aria-label="Push"
|
|
||||||
>
|
|
||||||
{#if operation === "Pushing"}
|
|
||||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
|
||||||
{:else}
|
|
||||||
<Upload size={14} aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
<span class="tb-action-label">Push</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onRefresh}
|
|
||||||
disabled={isBusy || !hasRepository}
|
|
||||||
title="Refresh"
|
|
||||||
aria-label="Refresh"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
class={operation === "Refreshing" ? "spin" : ""}
|
|
||||||
size={14}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span class="tb-action-label">Refresh</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action auto-toggle"
|
|
||||||
class:active={autoRefreshEnabled}
|
|
||||||
onclick={onToggleAutoRefresh}
|
|
||||||
aria-pressed={autoRefreshEnabled}
|
|
||||||
title={autoRefreshEnabled ? "Auto-refresh on — click to disable" : "Auto-refresh off — click to enable"}
|
|
||||||
aria-label="Toggle auto-refresh"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
class={autoRefreshInFlight ? "spin" : ""}
|
|
||||||
size={14}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span class="tb-action-label">Auto</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tb-action"
|
|
||||||
onclick={onOpenSettings}
|
|
||||||
title="Settings"
|
|
||||||
aria-label="Settings"
|
|
||||||
>
|
|
||||||
<Settings size={14} aria-hidden="true" />
|
|
||||||
<span class="tb-action-label">Settings</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="titlebar-divider" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<div class="titlebar-controls">
|
|
||||||
<button class="tb-btn" onclick={minimizeWindow} title="Minimize" aria-label="Minimize">
|
|
||||||
<Minus size={12} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="tb-btn"
|
|
||||||
onclick={toggleMaximizeWindow}
|
|
||||||
title={isMaximized ? "Restore" : "Maximize"}
|
|
||||||
aria-label={isMaximized ? "Restore" : "Maximize"}
|
|
||||||
>
|
|
||||||
{#if isMaximized}
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
||||||
<rect x="3" y="1" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5" />
|
|
||||||
<path d="M1 3v7a1 1 0 0 0 1 1h7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
||||||
<rect x="1" y="1" width="10" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
<button class="tb-btn close" onclick={closeWindow} title="Close" aria-label="Close">
|
|
||||||
<X size={12} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { telemetryLog } from "./telemetry";
|
||||||
|
|
||||||
export type AnalyticsEventProperties = Record<string, string | number>;
|
export type AnalyticsEventProperties = Record<string, string | number>;
|
||||||
|
|
||||||
export function trackAnalyticsEvent(name: string, props?: AnalyticsEventProperties) {
|
export function trackAnalyticsEvent(name: string, props?: AnalyticsEventProperties) {
|
||||||
|
const details = props && Object.keys(props).length > 0 ? ` ${JSON.stringify(props)}` : "";
|
||||||
|
telemetryLog("info", `${name}${details}`, `product.${name}`);
|
||||||
void invoke("plugin:aptabase|track_event", {
|
void invoke("plugin:aptabase|track_event", {
|
||||||
name,
|
name,
|
||||||
props: props && Object.keys(props).length > 0 ? props : undefined,
|
props: props && Object.keys(props).length > 0 ? props : undefined,
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
||||||
@@ -30,10 +30,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="analytics-notice-copy">
|
<div class="analytics-notice-copy">
|
||||||
<p>
|
<p>
|
||||||
Gitty can send anonymous usage events to Aptabase so crashes, rough edges, and commonly used workflows are easier to improve.
|
Gitty can send anonymous usage events and technical error logs to Aptabase and the self-hosted SigNoz service so crashes, rough edges, and commonly used workflows are easier to improve.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Events do not include repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, or source code.
|
Telemetry does not include repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, or source code.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="dialog-footer analytics-notice-footer">
|
<footer class="dialog-footer analytics-notice-footer">
|
||||||
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage events.</span>
|
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage and technical errors.</span>
|
||||||
<button class="btn-primary" type="button" onclick={() => onContinue(allowAnalytics)}>
|
<button class="btn-primary" type="button" onclick={() => onContinue(allowAnalytics)}>
|
||||||
<Check size={16} aria-hidden="true" />
|
<Check size={16} aria-hidden="true" />
|
||||||
Continue
|
Continue
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, Settings, X } from "@lucide/svelte";
|
import { Check, Languages, RefreshCw, Settings, X } from "@lucide/svelte";
|
||||||
import type { AnalyticsSettings, AppTheme } from "../types";
|
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
analytics: AnalyticsSettings;
|
analytics: AnalyticsSettings;
|
||||||
theme: AppTheme;
|
theme: AppTheme;
|
||||||
onSave: (settings: AnalyticsSettings, theme: AppTheme) => void;
|
language: AppLanguage;
|
||||||
|
autoRefresh: boolean;
|
||||||
|
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { analytics, theme = "system", onSave = () => {}, onClose = () => {} }: Props = $props();
|
let { analytics, theme = "system", language = "en", autoRefresh = true, onSave = () => {}, onClose = () => {} }: Props = $props();
|
||||||
|
|
||||||
let analyticsEnabled = $state(true);
|
let analyticsEnabled = $state(true);
|
||||||
let selectedTheme = $state<AppTheme>("system");
|
let selectedTheme = $state<AppTheme>("system");
|
||||||
|
let selectedLanguage = $state<AppLanguage>("en");
|
||||||
|
let autoRefreshEnabled = $state(true);
|
||||||
|
const isGerman = $derived(selectedLanguage === "de");
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
analyticsEnabled = analytics.enabled;
|
analyticsEnabled = analytics.enabled;
|
||||||
selectedTheme = theme;
|
selectedTheme = theme;
|
||||||
|
selectedLanguage = language;
|
||||||
|
autoRefreshEnabled = autoRefresh;
|
||||||
});
|
});
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
@@ -24,18 +31,18 @@
|
|||||||
...analytics,
|
...analytics,
|
||||||
enabled: analyticsEnabled,
|
enabled: analyticsEnabled,
|
||||||
noticeSeen: true,
|
noticeSeen: true,
|
||||||
}, selectedTheme);
|
}, selectedTheme, selectedLanguage, autoRefreshEnabled);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation">
|
<div class="dialog-backdrop" role="presentation">
|
||||||
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1">
|
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Gitty</span>
|
<span class="eyebrow">Gitty</span>
|
||||||
<h2 class="dialog-title">Settings</h2>
|
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"}>
|
||||||
<X size={18} aria-hidden="true" />
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -45,23 +52,62 @@
|
|||||||
<header>
|
<header>
|
||||||
<Settings size={16} aria-hidden="true" />
|
<Settings size={16} aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Appearance</span>
|
<span class="eyebrow">{isGerman ? "Darstellung" : "Appearance"}</span>
|
||||||
<h3>Theme</h3>
|
<h3>{isGerman ? "Farbschema" : "Theme"}</h3>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="settings-segmented" role="radiogroup" aria-label="Theme">
|
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}>
|
||||||
<label class:active={selectedTheme === "system"}>
|
<label class:active={selectedTheme === "system"}>
|
||||||
<input type="radio" bind:group={selectedTheme} value="system" />
|
<input type="radio" bind:group={selectedTheme} value="system" />
|
||||||
<span>System</span>
|
<span>System</span>
|
||||||
</label>
|
</label>
|
||||||
<label class:active={selectedTheme === "light"}>
|
<label class:active={selectedTheme === "light"}>
|
||||||
<input type="radio" bind:group={selectedTheme} value="light" />
|
<input type="radio" bind:group={selectedTheme} value="light" />
|
||||||
<span>Light</span>
|
<span>{isGerman ? "Hell" : "Light"}</span>
|
||||||
</label>
|
</label>
|
||||||
<label class:active={selectedTheme === "dark"}>
|
<label class:active={selectedTheme === "dark"}>
|
||||||
<input type="radio" bind:group={selectedTheme} value="dark" />
|
<input type="radio" bind:group={selectedTheme} value="dark" />
|
||||||
<span>Dark</span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -71,24 +117,24 @@
|
|||||||
<Settings size={16} aria-hidden="true" />
|
<Settings size={16} aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Analytics</span>
|
<span class="eyebrow">Analytics</span>
|
||||||
<h3>Anonymous usage analytics</h3>
|
<h3>{isGerman ? "Anonyme Nutzungsanalyse" : "Anonymous usage analytics"}</h3>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<label class="settings-toggle-row">
|
<label class="settings-toggle-row">
|
||||||
<input type="checkbox" bind:checked={analyticsEnabled} />
|
<input type="checkbox" bind:checked={analyticsEnabled} />
|
||||||
<span>
|
<span>
|
||||||
<strong>Allow anonymous Aptabase events</strong>
|
<strong>{isGerman ? "Anonyme Analytics und Fehlerberichte erlauben" : "Allow anonymous analytics and error reports"}</strong>
|
||||||
<small>No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent.</small>
|
<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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="new-branch-actions">
|
<div class="new-branch-actions">
|
||||||
<button class="btn-secondary" type="button" onclick={onClose}>Cancel</button>
|
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
||||||
<button class="btn-primary" type="submit">
|
<button class="btn-primary" type="submit">
|
||||||
<Check size={16} aria-hidden="true" />
|
<Check size={16} aria-hidden="true" />
|
||||||
Save
|
{isGerman ? "Speichern" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -18,16 +18,24 @@
|
|||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
|
let title = $derived(branch.remote ? "Delete remote branch?" : force ? "Force delete branch?" : "Delete branch?");
|
||||||
|
let remoteParts = $derived(branch.remote ? branch.name.split(/\/(.+)/) : []);
|
||||||
|
let branchName = $derived(branch.remote ? remoteParts[1] || branch.name : branch.name);
|
||||||
|
let branchLocation = $derived(branch.remote ? remoteParts[0] || "Remote" : "Local repository");
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation">
|
<div class="dialog-backdrop" role="presentation">
|
||||||
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="branch-delete-title">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header branch-delete-header">
|
||||||
<div>
|
<div class="branch-delete-heading">
|
||||||
<span class="eyebrow">{force ? "Force delete" : "Delete branch"}</span>
|
<span class:force class="branch-delete-heading-icon" aria-hidden="true">
|
||||||
<p class="dialog-title">{title}</p>
|
<Trash2 size={16} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">{branch.remote ? "Remote branch" : force ? "Force delete" : "Delete branch"}</span>
|
||||||
|
<p class="dialog-title" id="branch-delete-title">{title}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||||
<X size={16} aria-hidden="true" />
|
<X size={16} aria-hidden="true" />
|
||||||
@@ -35,27 +43,35 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="branch-delete-body">
|
<div class="branch-delete-body">
|
||||||
<div class="discard-warning-icon" aria-hidden="true">
|
<div class:force class="discard-warning-icon branch-delete-warning-icon" aria-hidden="true">
|
||||||
<AlertTriangle size={22} />
|
<AlertTriangle size={22} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="discard-confirm-copy">
|
<div class="discard-confirm-copy">
|
||||||
<p>
|
<p class="branch-delete-lead">
|
||||||
{#if force}
|
{#if branch.remote}
|
||||||
This branch is not fully merged. Force deleting removes the branch pointer even if some commits are only reachable from this branch.
|
This branch will be removed from the shared remote repository.
|
||||||
|
{:else if force}
|
||||||
|
This branch is not fully merged. Some commits may only exist here.
|
||||||
{:else}
|
{:else}
|
||||||
Delete this local branch from the repository?
|
This branch will be removed from your local repository.
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
<code class="discard-target" title={branch.name}>
|
<div class="branch-delete-target" title={branch.name}>
|
||||||
<GitBranch size={13} aria-hidden="true" />
|
<span class="branch-delete-target-icon" aria-hidden="true"><GitBranch size={16} /></span>
|
||||||
{branch.name}
|
<span class="branch-delete-target-copy">
|
||||||
</code>
|
<code>{branchName}</code>
|
||||||
|
<span>{branchLocation}</span>
|
||||||
|
</span>
|
||||||
|
<span class:remote={branch.remote} class="branch-delete-scope">{branch.remote ? "Remote" : "Local"}</span>
|
||||||
|
</div>
|
||||||
<p class="discard-warning-text">
|
<p class="discard-warning-text">
|
||||||
{#if force}
|
{#if branch.remote}
|
||||||
Make sure you no longer need the unique commits on this branch.
|
This affects everyone using <strong>{remoteParts[0] || "the remote"}</strong>. Your local branch is kept.
|
||||||
|
{:else if force}
|
||||||
|
Force deletion can make unmerged commits difficult to recover.
|
||||||
{:else}
|
{:else}
|
||||||
Git will refuse if the branch is not fully merged.
|
Git will stop the deletion if the branch contains unmerged commits.
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,13 +79,13 @@
|
|||||||
|
|
||||||
<footer class="discard-confirm-actions">
|
<footer class="discard-confirm-actions">
|
||||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
|
||||||
<button class="btn-danger" type="button" onclick={onConfirm} disabled={isBusy}>
|
<button class="btn-danger branch-delete-confirm" type="button" onclick={onConfirm} disabled={isBusy}>
|
||||||
{#if isBusy}
|
{#if isBusy}
|
||||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<Trash2 size={15} aria-hidden="true" />
|
<Trash2 size={15} aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
{force ? "Force delete" : "Delete"}
|
{branch.remote ? "Delete from remote" : force ? "Force delete" : "Delete"}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||||
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
|
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
|
||||||
|
|
||||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||||
@@ -54,9 +54,12 @@
|
|||||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||||
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
|
onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
onCreateTag: (name: string, message: string) => void | Promise<void>;
|
onCreateTag: (name: string, message: string) => void | Promise<void>;
|
||||||
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
||||||
onPushTag: (tag: GitTag) => void | Promise<void>;
|
onPushTag: (tag: GitTag) => void | Promise<void>;
|
||||||
|
onManageWorktrees: () => void;
|
||||||
|
onCreateWorktree: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
onToggleCollapsed?: () => void;
|
onToggleCollapsed?: () => void;
|
||||||
}
|
}
|
||||||
@@ -74,9 +77,12 @@
|
|||||||
onCreateBranch = () => {},
|
onCreateBranch = () => {},
|
||||||
onRenameBranch = () => {},
|
onRenameBranch = () => {},
|
||||||
onDeleteBranch = () => {},
|
onDeleteBranch = () => {},
|
||||||
|
onDeleteRemoteBranch = () => {},
|
||||||
onCreateTag = () => {},
|
onCreateTag = () => {},
|
||||||
onDeleteTag = () => {},
|
onDeleteTag = () => {},
|
||||||
onPushTag = () => {},
|
onPushTag = () => {},
|
||||||
|
onManageWorktrees = () => {},
|
||||||
|
onCreateWorktree = () => {},
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
onToggleCollapsed = () => {},
|
onToggleCollapsed = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -264,9 +270,16 @@
|
|||||||
|
|
||||||
async function deleteContextBranch() {
|
async function deleteContextBranch() {
|
||||||
const branch = contextBranch;
|
const branch = contextBranch;
|
||||||
if (!branch || branch.current || branch.remote || isBusy) return;
|
if (!branch || branch.current || isBusy) return;
|
||||||
closeBranchContextMenu();
|
closeBranchContextMenu();
|
||||||
await onDeleteBranch(branch);
|
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createContextWorktree() {
|
||||||
|
const branch = contextBranch;
|
||||||
|
closeBranchContextMenu();
|
||||||
|
if (!branch) return;
|
||||||
|
await onCreateWorktree(branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkoutContextBranch() {
|
async function checkoutContextBranch() {
|
||||||
@@ -398,8 +411,6 @@
|
|||||||
<!-- collapsed -->
|
<!-- collapsed -->
|
||||||
{:else if !hasRepository}
|
{:else if !hasRepository}
|
||||||
<p class="blank-state">Open a repository to list branches.</p>
|
<p class="blank-state">Open a repository to list branches.</p>
|
||||||
{:else if branches.length === 0 && tags.length === 0}
|
|
||||||
<p class="blank-state">No branches returned.</p>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
||||||
{#if createOpen}
|
{#if createOpen}
|
||||||
@@ -636,6 +647,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="branch-group">
|
||||||
|
<button
|
||||||
|
class="branch-group-toggle worktree-group-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={onManageWorktrees}
|
||||||
|
disabled={isBusy}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
title="Manage repository worktrees"
|
||||||
|
>
|
||||||
|
<HardDrive size={14} aria-hidden="true" />
|
||||||
|
<span>Worktrees</span>
|
||||||
|
<span class="branch-group-count">Manage</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -659,6 +685,10 @@
|
|||||||
<GitBranch size={14} aria-hidden="true" />
|
<GitBranch size={14} aria-hidden="true" />
|
||||||
Rebase current onto this
|
Rebase current onto this
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={createContextWorktree} disabled={isBusy || contextBranch.remote || contextBranch.current}>
|
||||||
|
<HardDrive size={14} aria-hidden="true" />
|
||||||
|
Open in new worktree
|
||||||
|
</button>
|
||||||
<div class="menu-separator" role="separator"></div>
|
<div class="menu-separator" role="separator"></div>
|
||||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
|
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
|
||||||
<Pencil size={14} aria-hidden="true" />
|
<Pencil size={14} aria-hidden="true" />
|
||||||
@@ -669,11 +699,11 @@
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onclick={deleteContextBranch}
|
onclick={deleteContextBranch}
|
||||||
disabled={isBusy || contextBranch.current || contextBranch.remote}
|
disabled={isBusy || contextBranch.current}
|
||||||
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Remote branch cannot be deleted here" : "Delete local branch"}
|
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Delete remote branch" : "Delete local branch"}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} aria-hidden="true" />
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
Delete
|
{contextBranch.remote ? "Delete remote" : "Delete"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, LoaderCircle, RotateCcw, Settings, Sparkles } from "@lucide/svelte";
|
import { Check, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
|
||||||
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,11 +13,13 @@
|
|||||||
commitAiProvider: CommitAiProvider;
|
commitAiProvider: CommitAiProvider;
|
||||||
commitAiPhase: CommitAiPhase;
|
commitAiPhase: CommitAiPhase;
|
||||||
commitAiGenerating: boolean;
|
commitAiGenerating: boolean;
|
||||||
|
commitAiReviewing: boolean;
|
||||||
canAmend: boolean;
|
canAmend: boolean;
|
||||||
amendMode: boolean;
|
amendMode: boolean;
|
||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
onCommitMessageChange: (msg: string) => void;
|
onCommitMessageChange: (msg: string) => void;
|
||||||
onGenerateCommitMessage: () => void;
|
onGenerateCommitMessage: () => void;
|
||||||
|
onReviewStaged: () => void;
|
||||||
onOpenAiSettings: () => void;
|
onOpenAiSettings: () => void;
|
||||||
onToggleAmend: (checked: boolean) => void;
|
onToggleAmend: (checked: boolean) => void;
|
||||||
onUndoLastCommit: () => void;
|
onUndoLastCommit: () => void;
|
||||||
@@ -34,11 +36,13 @@
|
|||||||
commitAiProvider = "local",
|
commitAiProvider = "local",
|
||||||
commitAiPhase = "idle",
|
commitAiPhase = "idle",
|
||||||
commitAiGenerating = false,
|
commitAiGenerating = false,
|
||||||
|
commitAiReviewing = false,
|
||||||
canAmend = false,
|
canAmend = false,
|
||||||
amendMode = false,
|
amendMode = false,
|
||||||
onCommit = () => {},
|
onCommit = () => {},
|
||||||
onCommitMessageChange = () => {},
|
onCommitMessageChange = () => {},
|
||||||
onGenerateCommitMessage = () => {},
|
onGenerateCommitMessage = () => {},
|
||||||
|
onReviewStaged = () => {},
|
||||||
onOpenAiSettings = () => {},
|
onOpenAiSettings = () => {},
|
||||||
onToggleAmend = () => {},
|
onToggleAmend = () => {},
|
||||||
onUndoLastCommit = () => {},
|
onUndoLastCommit = () => {},
|
||||||
@@ -61,25 +65,52 @@
|
|||||||
hasRepository &&
|
hasRepository &&
|
||||||
!isBusy &&
|
!isBusy &&
|
||||||
!commitAiGenerating &&
|
!commitAiGenerating &&
|
||||||
|
!commitAiReviewing &&
|
||||||
stagedCount > 0 &&
|
stagedCount > 0 &&
|
||||||
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
||||||
);
|
);
|
||||||
|
let canReview = $derived(canGenerate && commitAiProvider !== "local");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel commit-panel" aria-label="Commit">
|
<section class="panel commit-panel" aria-label="Commit">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Commit</span>
|
<span class="eyebrow">Create revision</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Message</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commit</h2>
|
||||||
|
</div>
|
||||||
|
<div class="commit-head-actions">
|
||||||
|
<span class="pill pill-count">{stagedCount} staged</span>
|
||||||
|
<button
|
||||||
|
class="commit-review-button"
|
||||||
|
type="button"
|
||||||
|
onclick={onReviewStaged}
|
||||||
|
disabled={!canReview}
|
||||||
|
title={commitAiProvider === "local" ? "Pre-commit review currently requires an API provider" : stagedCount === 0 ? "Stage changes first" : "Review staged changes for bugs and risks"}
|
||||||
|
>
|
||||||
|
{#if commitAiReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<ShieldCheck size={14} aria-hidden="true" />{/if}
|
||||||
|
Review
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="commit-generate-button"
|
||||||
|
type="button"
|
||||||
|
onclick={onGenerateCommitMessage}
|
||||||
|
disabled={!canGenerate}
|
||||||
|
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
|
||||||
|
>
|
||||||
|
{#if commitAiGenerating || localModelLoading}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<Sparkles size={14} aria-hidden="true" />{/if}
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
<button class="commit-settings-button" type="button" onclick={onOpenAiSettings} disabled={isBusy} title="AI settings" aria-label="AI settings">
|
||||||
|
<Settings size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="pill pill-count">{stagedCount} staged</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="commit-form" onsubmit={handleSubmit}>
|
<form class="commit-form" onsubmit={handleSubmit}>
|
||||||
<textarea
|
<textarea
|
||||||
value={commitMessage}
|
value={commitMessage}
|
||||||
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
|
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
|
||||||
placeholder="Commit message..."
|
placeholder="Commit message"
|
||||||
disabled={!hasRepository || isBusy}
|
disabled={!hasRepository || isBusy}
|
||||||
></textarea>
|
></textarea>
|
||||||
{#if commitBlockReason}
|
{#if commitBlockReason}
|
||||||
@@ -117,30 +148,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{amendMode ? "Amend" : "Commit"}
|
{amendMode ? "Amend" : "Commit"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
class="btn-secondary commit-ai-button"
|
|
||||||
type="button"
|
|
||||||
onclick={onGenerateCommitMessage}
|
|
||||||
disabled={!canGenerate}
|
|
||||||
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
|
|
||||||
>
|
|
||||||
{#if commitAiGenerating || localModelLoading}
|
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
|
||||||
{:else}
|
|
||||||
<Sparkles size={16} aria-hidden="true" />
|
|
||||||
{/if}
|
|
||||||
AI
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-secondary commit-ai-settings-button"
|
|
||||||
type="button"
|
|
||||||
onclick={onOpenAiSettings}
|
|
||||||
disabled={isBusy}
|
|
||||||
title="AI settings"
|
|
||||||
aria-label="AI settings"
|
|
||||||
>
|
|
||||||
<Settings size={16} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
|
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface DiffMarker {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
kind: "add" | "delete" | "mixed";
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
comparison: GitCommitComparison;
|
comparison: GitCommitComparison;
|
||||||
selectedDiffPath: string;
|
selectedDiffPath: string;
|
||||||
@@ -173,10 +179,40 @@
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDiffMarkers(rows: SplitRow[]): DiffMarker[] {
|
||||||
|
const markers: DiffMarker[] = [];
|
||||||
|
let current: DiffMarker | null = null;
|
||||||
|
for (let index = 0; index < rows.length; index++) {
|
||||||
|
const row = rows[index];
|
||||||
|
if (row.type !== "pair" || (row.leftKind === "context" && row.rightKind === "context")) {
|
||||||
|
current = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const kind = row.leftKind === "del" && row.rightKind === "add"
|
||||||
|
? "mixed"
|
||||||
|
: row.rightKind === "add" ? "add" : "delete";
|
||||||
|
if (current && current.end === index - 1 && current.kind === kind) {
|
||||||
|
current.end = index;
|
||||||
|
} else {
|
||||||
|
current = { start: index, end: index, kind };
|
||||||
|
markers.push(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToDiffMarker(rowIndex: number) {
|
||||||
|
const ratio = rowIndex / Math.max(splitRows.length - 1, 1);
|
||||||
|
for (const pane of [beforePane, afterPane]) {
|
||||||
|
if (pane) pane.scrollTop = ratio * Math.max(pane.scrollHeight - pane.clientHeight, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let diffByPath = $derived(buildDiffByPath(comparison.patch));
|
let diffByPath = $derived(buildDiffByPath(comparison.patch));
|
||||||
let selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
|
let selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
|
||||||
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
|
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
|
||||||
let splitRows = $derived(buildSplitRows(selectedPatch));
|
let splitRows = $derived(buildSplitRows(selectedPatch));
|
||||||
|
let diffMarkers = $derived(buildDiffMarkers(splitRows));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -262,6 +298,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Split diff grid -->
|
<!-- Split diff grid -->
|
||||||
|
<div class="split-diff-shell">
|
||||||
<div class="split-diff" role="table" aria-label="Side-by-side diff">
|
<div class="split-diff" role="table" aria-label="Side-by-side diff">
|
||||||
<div
|
<div
|
||||||
class="split-pane"
|
class="split-pane"
|
||||||
@@ -298,6 +335,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="diff-overview" aria-label="Change overview">
|
||||||
|
{#each diffMarkers as marker, index (`${marker.start}-${marker.end}-${marker.kind}`)}
|
||||||
|
<button
|
||||||
|
class="diff-overview-marker {marker.kind}"
|
||||||
|
type="button"
|
||||||
|
style={`--marker-position: ${(marker.start / Math.max(splitRows.length - 1, 1)) * 100}%`}
|
||||||
|
onclick={() => scrollToDiffMarker(marker.start)}
|
||||||
|
title={`Jump to change ${index + 1} of ${diffMarkers.length}`}
|
||||||
|
aria-label={`Jump to change ${index + 1} of ${diffMarkers.length}`}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
files: GitFileStatus[];
|
files: GitFileStatus[];
|
||||||
staged: boolean | null;
|
staged: boolean | null;
|
||||||
scope: "file" | "hunk";
|
scope: "file" | "hunk" | "lines";
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onConfirm: () => void | Promise<void>;
|
onConfirm: () => void | Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -26,9 +26,9 @@
|
|||||||
|
|
||||||
let count = $derived(files.length);
|
let count = $derived(files.length);
|
||||||
let title = $derived(
|
let title = $derived(
|
||||||
scope === "hunk" ? "Discard hunk?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
|
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" : count > 1 ? `${count} files` : "file");
|
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");
|
let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,7 @@
|
|||||||
onToggleCommitFiles: (hash: string) => void;
|
onToggleCommitFiles: (hash: string) => void;
|
||||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||||
onCherryPickCommit: (commit: GitCommit) => void;
|
onCherryPickCommit: (commit: GitCommit) => void;
|
||||||
|
onRevertCommit: (commit: GitCommit) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
onToggleCommitFiles = () => {},
|
onToggleCommitFiles = () => {},
|
||||||
onCreateBranchFromCommit = () => {},
|
onCreateBranchFromCommit = () => {},
|
||||||
onCherryPickCommit = () => {},
|
onCherryPickCommit = () => {},
|
||||||
|
onRevertCommit = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
||||||
@@ -406,6 +408,13 @@
|
|||||||
await onCherryPickCommit(commit);
|
await onCherryPickCommit(commit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function revertContextCommit() {
|
||||||
|
const commit = contextCommit;
|
||||||
|
if (!commit || isBusy) return;
|
||||||
|
closeCommitContextMenu();
|
||||||
|
await onRevertCommit(commit);
|
||||||
|
}
|
||||||
|
|
||||||
function handleWindowKeydown(event: KeyboardEvent) {
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
if (event.key !== "Escape") return;
|
if (event.key !== "Escape") return;
|
||||||
closeCommitContextMenu();
|
closeCommitContextMenu();
|
||||||
@@ -718,6 +727,9 @@
|
|||||||
<Cherry size={14} aria-hidden="true" />
|
<Cherry size={14} aria-hidden="true" />
|
||||||
Cherry-pick
|
Cherry-pick
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title="Create a new commit that reverses this commit">
|
||||||
|
<RotateCcw size={14} aria-hidden="true" /> Revert
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
|
||||||
|
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
|
||||||
|
|
||||||
|
interface PlanRow extends RebaseCommit {
|
||||||
|
action: RebaseAction;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
branches: GitBranchInfo[];
|
||||||
|
currentBranch: string;
|
||||||
|
base: string;
|
||||||
|
commits: RebaseCommit[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
|
operation: string;
|
||||||
|
error: string;
|
||||||
|
onBaseChange: (base: string) => void;
|
||||||
|
onStart: (plan: RebasePlanItem[]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
branches = [], currentBranch = "", base = "", commits = [], isLoading = false,
|
||||||
|
isBusy = false, operation = "", error = "", onBaseChange = () => {},
|
||||||
|
onStart = () => {}, onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let rows = $state<PlanRow[]>([]);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
rows = commits.map((commit) => ({ ...commit, action: "pick", message: commit.summary }));
|
||||||
|
});
|
||||||
|
|
||||||
|
let availableBases = $derived(branches.filter((branch) => !branch.current));
|
||||||
|
let keptCount = $derived(rows.filter((row) => row.action !== "drop").length);
|
||||||
|
let invalidSquash = $derived(rows.some((row, index) =>
|
||||||
|
(row.action === "squash" || row.action === "fixup")
|
||||||
|
&& rows.slice(0, index).every((previous) => previous.action === "drop")
|
||||||
|
));
|
||||||
|
let invalidReword = $derived(rows.some((row) => row.action === "reword" && !row.message.trim()));
|
||||||
|
let canStart = $derived(Boolean(base) && rows.length > 0 && keptCount > 0 && !invalidSquash && !invalidReword && !isLoading && !isBusy);
|
||||||
|
|
||||||
|
function updateAction(index: number, action: RebaseAction) {
|
||||||
|
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, action } : row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMessage(index: number, message: string) {
|
||||||
|
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, message } : row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(index: number, direction: -1 | 1) {
|
||||||
|
const target = index + direction;
|
||||||
|
if (target < 0 || target >= rows.length) return;
|
||||||
|
const next = [...rows];
|
||||||
|
[next[index], next[target]] = [next[target], next[index]];
|
||||||
|
rows = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (!canStart) return;
|
||||||
|
onStart(rows.map((row) => ({
|
||||||
|
hash: row.hash,
|
||||||
|
action: row.action,
|
||||||
|
message: row.action === "reword" ? row.message.trim() : null,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||||
|
<div class="dialog interactive-rebase-dialog" role="dialog" aria-modal="true" aria-label="Interactive rebase" tabindex="-1">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Rewrite local history</span>
|
||||||
|
<h2 class="dialog-title">Interactive rebase</h2>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="interactive-rebase-body">
|
||||||
|
<section class="rebase-base-bar">
|
||||||
|
<label>
|
||||||
|
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
|
||||||
|
<select value={base} onchange={(event) => onBaseChange((event.target as HTMLSelectElement).value)} disabled={isBusy || isLoading}>
|
||||||
|
<option value="" disabled>Select a base branch</option>
|
||||||
|
{#each availableBases as branch (branch.name)}
|
||||||
|
<option value={branch.name}>{branch.remote ? "Remote · " : "Local · "}{branch.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rebase-warning error"><AlertTriangle size={16} aria-hidden="true" /><span>{error}</span></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading rebase range…</div>
|
||||||
|
{:else if !base}
|
||||||
|
<div class="blank-state">Select the branch or commit that should become the new base.</div>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<div class="blank-state">No linear commits are available above this base.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rebase-plan" role="list" aria-label="Interactive rebase plan">
|
||||||
|
{#each rows as row, index (row.hash)}
|
||||||
|
<article class:drop={row.action === "drop"} class="rebase-plan-row" role="listitem">
|
||||||
|
<div class="rebase-order-actions">
|
||||||
|
<button type="button" onclick={() => move(index, -1)} disabled={isBusy || index === 0} title="Move up"><ArrowUp size={14} aria-hidden="true" /></button>
|
||||||
|
<button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title="Move down"><ArrowDown size={14} aria-hidden="true" /></button>
|
||||||
|
</div>
|
||||||
|
<select class={`rebase-action ${row.action}`} value={row.action} onchange={(event) => updateAction(index, (event.target as HTMLSelectElement).value as RebaseAction)} disabled={isBusy} aria-label={`Action for ${row.short_hash}`}>
|
||||||
|
<option value="pick">pick</option><option value="reword">reword</option><option value="squash">squash</option><option value="fixup">fixup</option><option value="drop">drop</option>
|
||||||
|
</select>
|
||||||
|
<code>{row.short_hash}</code>
|
||||||
|
<div class="rebase-commit-copy">
|
||||||
|
{#if row.action === "reword"}
|
||||||
|
<input value={row.message} oninput={(event) => updateMessage(index, (event.target as HTMLInputElement).value)} disabled={isBusy} aria-label={`New message for ${row.short_hash}`} maxlength="240" />
|
||||||
|
{:else}
|
||||||
|
<strong>{row.summary}</strong>
|
||||||
|
{/if}
|
||||||
|
<span>{row.author_name} · {new Date(row.date).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if invalidSquash}
|
||||||
|
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Squash and fixup need an earlier commit that is not dropped.</div>
|
||||||
|
{:else if invalidReword}
|
||||||
|
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Reword messages cannot be empty.</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<span class="dialog-footer-info">{keptCount} of {rows.length} commits kept</span>
|
||||||
|
<div class="rebase-footer-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
|
||||||
|
<button class="btn-primary" type="button" onclick={start} disabled={!canStart}>
|
||||||
|
{#if operation === "Starting interactive rebase"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Play size={16} aria-hidden="true" />{/if}
|
||||||
|
Start rebase
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { LoaderCircle, X } from "@lucide/svelte";
|
import { Check, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
|
||||||
import type { GitFileStatus, PatchApplyAction } from "../types";
|
import type { GitFileStatus, PatchApplyAction } from "../types";
|
||||||
|
|
||||||
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
||||||
@@ -8,12 +8,17 @@
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
kind: PatchLineKind;
|
kind: PatchLineKind;
|
||||||
|
oldLine: number | null;
|
||||||
|
newLine: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PatchHunk {
|
interface PatchHunk {
|
||||||
id: string;
|
id: string;
|
||||||
header: string;
|
header: string;
|
||||||
lines: PatchLine[];
|
lines: PatchLine[];
|
||||||
|
oldStart: number;
|
||||||
|
newStart: number;
|
||||||
|
suffix: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedPatch {
|
interface ParsedPatch {
|
||||||
@@ -31,7 +36,7 @@
|
|||||||
error: string;
|
error: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRefresh: () => void | Promise<void>;
|
onRefresh: () => void | Promise<void>;
|
||||||
onApply: (action: PatchApplyAction, patch: string) => void | Promise<void>;
|
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -47,12 +52,21 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||||
|
let patchScroll = $state<HTMLDivElement | null>(null);
|
||||||
|
let selectedLineIds = $state<Set<string>>(new Set());
|
||||||
|
let lastSelectedLineId = $state("");
|
||||||
|
|
||||||
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
||||||
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||||
|
let selectableLines = $derived(
|
||||||
|
parsed.hunks.flatMap((hunk) => hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete")),
|
||||||
|
);
|
||||||
|
let selectedCount = $derived(selectableLines.filter((line) => selectedLineIds.has(line.id)).length);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
parsed = parsePatch(patch);
|
parsed = parsePatch(patch);
|
||||||
|
selectedLineIds = new Set();
|
||||||
|
lastSelectedLineId = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
function parsePatch(input: string): ParsedPatch {
|
function parsePatch(input: string): ParsedPatch {
|
||||||
@@ -63,10 +77,22 @@
|
|||||||
const headerLines: string[] = [];
|
const headerLines: string[] = [];
|
||||||
const hunks: PatchHunk[] = [];
|
const hunks: PatchHunk[] = [];
|
||||||
let current: PatchHunk | null = null;
|
let current: PatchHunk | null = null;
|
||||||
|
let oldCursor = 0;
|
||||||
|
let newCursor = 0;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith("@@ ")) {
|
if (line.startsWith("@@ ")) {
|
||||||
current = { id: `hunk-${hunks.length}`, header: line, lines: [] };
|
const range = parseHunkHeader(line);
|
||||||
|
current = {
|
||||||
|
id: `hunk-${hunks.length}`,
|
||||||
|
header: line,
|
||||||
|
lines: [],
|
||||||
|
oldStart: range.oldStart,
|
||||||
|
newStart: range.newStart,
|
||||||
|
suffix: range.suffix,
|
||||||
|
};
|
||||||
|
oldCursor = range.oldStart;
|
||||||
|
newCursor = range.newStart;
|
||||||
hunks.push(current);
|
hunks.push(current);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -77,11 +103,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const kind = patchLineKind(line);
|
const kind = patchLineKind(line);
|
||||||
|
const oldLine = kind === "context" || kind === "delete" ? oldCursor : null;
|
||||||
|
const newLine = kind === "context" || kind === "add" ? newCursor : null;
|
||||||
current.lines.push({
|
current.lines.push({
|
||||||
id: `${current.id}-line-${current.lines.length}`,
|
id: `${current.id}-line-${current.lines.length}`,
|
||||||
text: line,
|
text: line,
|
||||||
kind,
|
kind,
|
||||||
|
oldLine,
|
||||||
|
newLine,
|
||||||
});
|
});
|
||||||
|
if (oldLine !== null) oldCursor += 1;
|
||||||
|
if (newLine !== null) newCursor += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -91,6 +123,15 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseHunkHeader(header: string): { oldStart: number; newStart: number; suffix: string } {
|
||||||
|
const match = header.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
|
||||||
|
return {
|
||||||
|
oldStart: Number(match?.[1] ?? 0),
|
||||||
|
newStart: Number(match?.[2] ?? 0),
|
||||||
|
suffix: match?.[3] ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function patchLineKind(line: string): PatchLineKind {
|
function patchLineKind(line: string): PatchLineKind {
|
||||||
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
|
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
|
||||||
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
|
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
|
||||||
@@ -116,7 +157,108 @@
|
|||||||
|
|
||||||
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
|
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
|
||||||
if (isBusy || isLoading) return;
|
if (isBusy || isLoading) return;
|
||||||
await onApply(action, buildHunkPatch(hunk));
|
await onApply(action, buildHunkPatch(hunk), "hunk");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLine(event: MouseEvent, line: PatchLine) {
|
||||||
|
if (isBusy || isLoading || (line.kind !== "add" && line.kind !== "delete")) return;
|
||||||
|
const next = new Set(selectedLineIds);
|
||||||
|
const selecting = !next.has(line.id);
|
||||||
|
if (event.shiftKey && lastSelectedLineId) {
|
||||||
|
const start = selectableLines.findIndex((candidate) => candidate.id === lastSelectedLineId);
|
||||||
|
const end = selectableLines.findIndex((candidate) => candidate.id === line.id);
|
||||||
|
if (start >= 0 && end >= 0) {
|
||||||
|
for (const candidate of selectableLines.slice(Math.min(start, end), Math.max(start, end) + 1)) {
|
||||||
|
if (selecting) next.add(candidate.id);
|
||||||
|
else next.delete(candidate.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (selecting) {
|
||||||
|
next.add(line.id);
|
||||||
|
} else {
|
||||||
|
next.delete(line.id);
|
||||||
|
}
|
||||||
|
selectedLineIds = next;
|
||||||
|
lastSelectedLineId = line.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHunkSelection(hunk: PatchHunk) {
|
||||||
|
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
|
||||||
|
const allSelected = changed.length > 0 && changed.every((line) => selectedLineIds.has(line.id));
|
||||||
|
const next = new Set(selectedLineIds);
|
||||||
|
for (const line of changed) {
|
||||||
|
if (allSelected) next.delete(line.id);
|
||||||
|
else next.add(line.id);
|
||||||
|
}
|
||||||
|
selectedLineIds = next;
|
||||||
|
lastSelectedLineId = changed[changed.length - 1]?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hunkSelectionState(hunk: PatchHunk): "none" | "some" | "all" {
|
||||||
|
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
|
||||||
|
const count = changed.filter((line) => selectedLineIds.has(line.id)).length;
|
||||||
|
return count === 0 ? "none" : count === changed.length ? "all" : "some";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangePart(start: number, count: number): string {
|
||||||
|
return count === 1 ? `${start}` : `${start},${count}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelectedHunk(hunk: PatchHunk): string | null {
|
||||||
|
if (!hunk.lines.some((line) => selectedLineIds.has(line.id))) return null;
|
||||||
|
const output: string[] = [];
|
||||||
|
let previousIncluded = false;
|
||||||
|
|
||||||
|
for (const line of hunk.lines) {
|
||||||
|
if (line.kind === "context") {
|
||||||
|
output.push(line.text);
|
||||||
|
previousIncluded = true;
|
||||||
|
} else if (line.kind === "delete") {
|
||||||
|
output.push(selectedLineIds.has(line.id) ? line.text : ` ${line.text.slice(1)}`);
|
||||||
|
previousIncluded = true;
|
||||||
|
} else if (line.kind === "add") {
|
||||||
|
if (selectedLineIds.has(line.id)) {
|
||||||
|
output.push(line.text);
|
||||||
|
previousIncluded = true;
|
||||||
|
} else {
|
||||||
|
previousIncluded = false;
|
||||||
|
}
|
||||||
|
} else if (previousIncluded) {
|
||||||
|
output.push(line.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldCount = output.filter((line) => line.startsWith(" ") || line.startsWith("-")).length;
|
||||||
|
const newCount = output.filter((line) => line.startsWith(" ") || line.startsWith("+")).length;
|
||||||
|
const header = `@@ -${rangePart(hunk.oldStart, oldCount)} +${rangePart(hunk.newStart, newCount)} @@${hunk.suffix}`;
|
||||||
|
return [header, ...output].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelectedPatch(): string {
|
||||||
|
const hunks = parsed.hunks.map(buildSelectedHunk).filter((hunk): hunk is string => Boolean(hunk));
|
||||||
|
return `${[...parsed.headerLines, ...hunks].join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySelected(action: PatchApplyAction) {
|
||||||
|
if (isBusy || isLoading || selectedCount === 0) return;
|
||||||
|
await onApply(action, buildSelectedPatch(), "lines");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hunkPosition(index: number): number {
|
||||||
|
const totalLines = parsed.hunks.reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
|
||||||
|
const precedingLines = parsed.hunks.slice(0, index).reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
|
||||||
|
return (precedingLines / Math.max(totalLines - 1, 1)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hunkKind(hunk: PatchHunk): "add" | "delete" | "mixed" {
|
||||||
|
const hasAdd = hunk.lines.some((line) => line.kind === "add");
|
||||||
|
const hasDelete = hunk.lines.some((line) => line.kind === "delete");
|
||||||
|
return hasAdd && hasDelete ? "mixed" : hasAdd ? "add" : "delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToHunk(hunkId: string) {
|
||||||
|
const target = patchScroll?.querySelector<HTMLElement>(`[data-hunk-id="${hunkId}"]`);
|
||||||
|
if (patchScroll && target) patchScroll.scrollTop = Math.max(target.offsetTop - 8, 0);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -148,40 +290,106 @@
|
|||||||
{:else if parsed.binary || parsed.hunks.length === 0}
|
{:else if parsed.binary || parsed.hunks.length === 0}
|
||||||
<div class="blank-state">This change cannot be split into text lines.</div>
|
<div class="blank-state">This change cannot be split into text lines.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="line-patch-scroll">
|
<div class:active={selectedCount > 0} class="line-patch-selection-bar">
|
||||||
{#each parsed.hunks as hunk (hunk.id)}
|
<div>
|
||||||
<section class="line-patch-hunk">
|
<MousePointer2 size={14} aria-hidden="true" />
|
||||||
<div class="line-patch-hunk-head">
|
{#if selectedCount > 0}
|
||||||
<code>{hunk.header}</code>
|
<strong>{selectedCount} {selectedCount === 1 ? "line" : "lines"} selected</strong>
|
||||||
<div class="line-patch-hunk-actions">
|
<span>Shift-click to select a range.</span>
|
||||||
{#if staged}
|
{:else}
|
||||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
|
<strong>Select changed lines</strong>
|
||||||
Discard Hunk
|
<span>Choose individual additions or deletions below.</span>
|
||||||
</button>
|
{/if}
|
||||||
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
|
</div>
|
||||||
Unstage Hunk
|
{#if selectedCount > 0}
|
||||||
</button>
|
<div class="line-patch-selected-actions">
|
||||||
{:else}
|
<button class="line-patch-hunk-button discard" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy}>
|
||||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
|
Discard selected
|
||||||
Discard Hunk
|
</button>
|
||||||
</button>
|
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy}>
|
||||||
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
|
{staged ? "Unstage selected" : "Stage selected"}
|
||||||
Stage Hunk
|
</button>
|
||||||
</button>
|
<button class="btn-sm" type="button" onclick={() => { selectedLineIds = new Set(); }} disabled={isBusy}>Clear</button>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="line-patch-lines">
|
<div class="line-patch-workspace">
|
||||||
{#each hunk.lines as line (line.id)}
|
<div class="line-patch-scroll" bind:this={patchScroll}>
|
||||||
<div class={`line-patch-row ${line.kind}`}>
|
{#each parsed.hunks as hunk (hunk.id)}
|
||||||
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
|
||||||
<code>{lineBody(line)}</code>
|
<div class="line-patch-hunk-head">
|
||||||
|
<button
|
||||||
|
class:all={hunkSelectionState(hunk) === "all"}
|
||||||
|
class:some={hunkSelectionState(hunk) === "some"}
|
||||||
|
class="line-patch-select-hunk"
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggleHunkSelection(hunk)}
|
||||||
|
aria-label={`Select changed lines in ${hunk.header}`}
|
||||||
|
aria-pressed={hunkSelectionState(hunk) === "all"}
|
||||||
|
title="Select all changed lines in this hunk"
|
||||||
|
>
|
||||||
|
{#if hunkSelectionState(hunk) !== "none"}<Check size={12} aria-hidden="true" />{/if}
|
||||||
|
</button>
|
||||||
|
<code>{hunk.header}</code>
|
||||||
|
<div class="line-patch-hunk-actions">
|
||||||
|
{#if staged}
|
||||||
|
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
|
||||||
|
Discard Hunk
|
||||||
|
</button>
|
||||||
|
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
|
||||||
|
Unstage Hunk
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
|
||||||
|
Discard Hunk
|
||||||
|
</button>
|
||||||
|
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
|
||||||
|
Stage Hunk
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
<div class="line-patch-lines">
|
||||||
{/each}
|
{#each hunk.lines as line (line.id)}
|
||||||
|
<div class:selected={selectedLineIds.has(line.id)} class:selectable={line.kind === "add" || line.kind === "delete"} class={`line-patch-row ${line.kind}`}>
|
||||||
|
{#if line.kind === "add" || line.kind === "delete"}
|
||||||
|
<button
|
||||||
|
class="line-patch-line-select"
|
||||||
|
type="button"
|
||||||
|
onclick={(event) => toggleLine(event, line)}
|
||||||
|
aria-label={`${selectedLineIds.has(line.id) ? "Deselect" : "Select"} ${line.kind === "add" ? "added" : "deleted"} line ${line.newLine ?? line.oldLine ?? ""}`}
|
||||||
|
aria-pressed={selectedLineIds.has(line.id)}
|
||||||
|
title="Select line (Shift-click for range)"
|
||||||
|
>
|
||||||
|
{#if selectedLineIds.has(line.id)}<Check size={11} aria-hidden="true" />{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="line-patch-line-select-placeholder"></span>
|
||||||
|
{/if}
|
||||||
|
<span class="line-patch-line-number">{line.oldLine ?? ""}</span>
|
||||||
|
<span class="line-patch-line-number">{line.newLine ?? ""}</span>
|
||||||
|
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
||||||
|
<code>{lineBody(line)}</code>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<nav class="diff-overview line-patch-overview" aria-label="Change overview">
|
||||||
|
{#each parsed.hunks as hunk, index (hunk.id)}
|
||||||
|
<button
|
||||||
|
class="diff-overview-marker {hunkKind(hunk)}"
|
||||||
|
type="button"
|
||||||
|
style={`--marker-position: ${hunkPosition(index)}%`}
|
||||||
|
onclick={() => scrollToHunk(hunk.id)}
|
||||||
|
title={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
|
||||||
|
aria-label={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { GitBranch, History, LoaderCircle, Search, ShieldCheck, X } from "@lucide/svelte";
|
||||||
|
import type { ReflogEntry } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entries: ReflogEntry[];
|
||||||
|
currentHash: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
|
operation: string;
|
||||||
|
error: string;
|
||||||
|
onPreview: (entry: ReflogEntry) => void;
|
||||||
|
onRestore: (entry: ReflogEntry, branch: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { entries = [], currentHash = "", isLoading = false, isBusy = false, operation = "", error = "", onPreview = () => {}, onRestore = () => {}, onClose = () => {} }: Props = $props();
|
||||||
|
let query = $state("");
|
||||||
|
let selectedHash = $state("");
|
||||||
|
let recoveryBranch = $state("");
|
||||||
|
let filteredEntries = $derived(entries.filter((entry) => `${entry.selector} ${entry.action} ${entry.short_hash} ${entry.author_name}`.toLowerCase().includes(query.trim().toLowerCase())));
|
||||||
|
let selected = $derived(entries.find((entry) => entry.hash === selectedHash) ?? filteredEntries[0] ?? null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!selectedHash && entries.length > 0) select(entries[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function select(entry: ReflogEntry) {
|
||||||
|
selectedHash = entry.hash;
|
||||||
|
recoveryBranch = `recovery/${entry.short_hash}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||||
|
<div class="dialog reflog-dialog" role="dialog" aria-modal="true" aria-label="Reflog" tabindex="-1">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div><span class="eyebrow">Recovery history</span><h2 class="dialog-title">Reflog</h2></div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
|
||||||
|
</header>
|
||||||
|
<div class="reflog-body">
|
||||||
|
<aside class="reflog-list-pane">
|
||||||
|
<label class="reflog-search"><Search size={15} aria-hidden="true" /><input bind:value={query} placeholder="Search actions, hashes or authors" aria-label="Search reflog" /></label>
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading reflog…</div>
|
||||||
|
{:else if filteredEntries.length === 0}
|
||||||
|
<div class="blank-state">No reflog entries match this search.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="reflog-list" role="listbox" aria-label="Reflog entries">
|
||||||
|
{#each filteredEntries as entry (`${entry.selector}:${entry.hash}`)}
|
||||||
|
<button class:active={selected?.selector === entry.selector} type="button" role="option" aria-selected={selected?.selector === entry.selector} onclick={() => select(entry)}>
|
||||||
|
<span class="reflog-row-top"><code>{entry.selector}</code><span>{new Date(entry.date).toLocaleString()}</span></span>
|
||||||
|
<strong>{entry.action}</strong>
|
||||||
|
<span class="reflog-row-bottom"><code>{entry.short_hash}</code><span>{entry.author_name}</span></span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="reflog-detail">
|
||||||
|
{#if error}<div class="rebase-warning error">{error}</div>{/if}
|
||||||
|
{#if selected}
|
||||||
|
<div class="reflog-detail-head"><History size={20} aria-hidden="true" /><div><span class="eyebrow">{selected.selector}</span><h3>{selected.action}</h3></div></div>
|
||||||
|
<dl><div><dt>Commit</dt><dd><code>{selected.hash}</code></dd></div><div><dt>Author</dt><dd>{selected.author_name}</dd></div><div><dt>Date</dt><dd>{new Date(selected.date).toLocaleString()}</dd></div></dl>
|
||||||
|
<button class="btn-secondary reflog-preview" type="button" onclick={() => onPreview(selected)} disabled={isBusy || selected.hash === currentHash}><History size={15} aria-hidden="true" /> Preview changes to current HEAD</button>
|
||||||
|
<div class="reflog-recovery-card">
|
||||||
|
<div class="reflog-recovery-title"><ShieldCheck size={18} aria-hidden="true" /><div><strong>Safe recovery</strong><span>Create a new branch here. The current branch is not reset or deleted.</span></div></div>
|
||||||
|
<label><span>Recovery branch</span><div><GitBranch size={15} aria-hidden="true" /><input bind:value={recoveryBranch} disabled={isBusy} spellcheck="false" /></div></label>
|
||||||
|
<button class="btn-primary" type="button" onclick={() => onRestore(selected, recoveryBranch.trim())} disabled={isBusy || !recoveryBranch.trim()}>
|
||||||
|
{#if operation === "Restoring reflog entry"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<ShieldCheck size={16} aria-hidden="true" />{/if}
|
||||||
|
Create and checkout recovery branch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="blank-state">Select a reflog entry to inspect or recover it.</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -144,7 +144,6 @@
|
|||||||
|
|
||||||
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
||||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
||||||
let selectedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f))).length);
|
|
||||||
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
|
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
|
||||||
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
|
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
|
||||||
|
|
||||||
@@ -156,85 +155,22 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel relative grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status">
|
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Working tree</span>
|
<span class="eyebrow">Workspace</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Status</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="status-head-actions">
|
||||||
<span class="pill pill-count">{stagedCount} staged</span>
|
<span class="pill pill-count">{stagedCount} staged</span>
|
||||||
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
||||||
</div>
|
{#if hasRepository && changedFiles.length > 0}
|
||||||
</div>
|
<button class="btn-sm status-discard-all" type="button" onclick={() => onDiscardMany(changedFiles)} disabled={isBusy} title="Discard all staged and unstaged changes">
|
||||||
|
<RotateCcw size={13} aria-hidden="true" /> Discard all
|
||||||
{#if hasRepository && changedFiles.length > 0}
|
|
||||||
<div class="status-toolbar">
|
|
||||||
<button
|
|
||||||
class="btn-sm"
|
|
||||||
type="button"
|
|
||||||
onclick={onStageAll}
|
|
||||||
disabled={isBusy || !hasUnstaged}
|
|
||||||
title="Stage all unstaged files"
|
|
||||||
>
|
|
||||||
<Check size={14} aria-hidden="true" />
|
|
||||||
Stage all
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-sm"
|
|
||||||
type="button"
|
|
||||||
onclick={onUnstageAll}
|
|
||||||
disabled={isBusy || !hasStaged}
|
|
||||||
title="Unstage all staged files"
|
|
||||||
>
|
|
||||||
<Undo2 size={14} aria-hidden="true" />
|
|
||||||
Unstage all
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-sm danger"
|
|
||||||
type="button"
|
|
||||||
onclick={() => onDiscardMany(changedFiles)}
|
|
||||||
disabled={isBusy || changedFiles.length === 0}
|
|
||||||
title="Discard all changes"
|
|
||||||
>
|
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
|
||||||
Discard all
|
|
||||||
</button>
|
|
||||||
{#if selectedCount > 1}
|
|
||||||
<span class="status-selection-count">{selectedCount} selected</span>
|
|
||||||
<button
|
|
||||||
class="btn-sm"
|
|
||||||
type="button"
|
|
||||||
onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))}
|
|
||||||
disabled={isBusy || selectedUnstagedCount === 0}
|
|
||||||
title="Stage selected unstaged files"
|
|
||||||
>
|
|
||||||
<Check size={14} aria-hidden="true" />
|
|
||||||
Stage selected
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-sm"
|
|
||||||
type="button"
|
|
||||||
onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))}
|
|
||||||
disabled={isBusy || selectedStagedCount === 0}
|
|
||||||
title="Unstage selected staged files"
|
|
||||||
>
|
|
||||||
<Undo2 size={14} aria-hidden="true" />
|
|
||||||
Unstage selected
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-sm danger"
|
|
||||||
type="button"
|
|
||||||
onclick={() => onDiscardMany(selectedFiles())}
|
|
||||||
disabled={isBusy || selectedCount === 0}
|
|
||||||
title="Discard changes in selected files"
|
|
||||||
>
|
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
|
||||||
Discard selected
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
<div class="blank-state">No repository loaded.</div>
|
<div class="blank-state">No repository loaded.</div>
|
||||||
@@ -243,79 +179,86 @@
|
|||||||
{:else if changedFiles.length === 0}
|
{:else if changedFiles.length === 0}
|
||||||
<div class="blank-state">No file changes returned.</div>
|
<div class="blank-state">No file changes returned.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-auto p-2">
|
<div class="status-lanes overflow-auto">
|
||||||
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
<section class="status-lane" aria-label="Unstaged changes">
|
||||||
<article
|
<header class="status-lane-head">
|
||||||
class="file-row"
|
<div class="status-lane-title"><strong>Unstaged</strong><span>{unstagedCount}</span></div>
|
||||||
class:selected={isStatusSelected(file)}
|
<div class="status-lane-actions">
|
||||||
class:active={selectedFilePath === file.path}
|
{#if selectedUnstagedCount > 1}
|
||||||
>
|
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
|
||||||
<div class="file-title">
|
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={`Stage ${selectedUnstagedCount} selected files`}>
|
||||||
<button
|
<Check size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
|
||||||
class="file-title-button"
|
</button>
|
||||||
type="button"
|
{/if}
|
||||||
onclick={(event) => handleFileSelect(event, file)}
|
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
|
||||||
title={`Select ${displayPath(file)} in Explorer`}
|
<Check size={13} aria-hidden="true" /> Stage all
|
||||||
>
|
|
||||||
<strong>{fileName(file)}</strong>
|
|
||||||
</button>
|
</button>
|
||||||
|
{#if selectedUnstagedCount > 1}
|
||||||
|
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={`Discard unstaged changes in ${selectedUnstagedCount} selected files`}>
|
||||||
|
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedUnstagedCount}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="status-file-list">
|
||||||
|
{#each changedFiles.filter((file) => file.unstaged !== null) as file (`unstaged:${fileKey(file)}`)}
|
||||||
|
{@const stageTargets = selectedStageTargets(file)}
|
||||||
|
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
|
||||||
|
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||||
|
<strong>{fileName(file)}</strong>
|
||||||
|
<span>{displayPath(file)}</span>
|
||||||
|
</button>
|
||||||
|
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
||||||
|
<div class="status-file-actions">
|
||||||
|
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><Check size={14} aria-hidden="true" /></button>
|
||||||
|
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||||
|
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="change-lanes">
|
<section class="status-lane" aria-label="Staged changes">
|
||||||
<div class="change-lane" class:inactive={!file.staged}>
|
<header class="status-lane-head">
|
||||||
<div class="lane-header">
|
<div class="status-lane-title"><strong>Staged</strong><span>{stagedCount}</span></div>
|
||||||
<span class="lane-name">Staged</span>
|
<div class="status-lane-actions">
|
||||||
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
{#if selectedStagedCount > 1}
|
||||||
</div>
|
<span class="status-selection-count">{selectedStagedCount} selected</span>
|
||||||
<div class="lane-actions">
|
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={`Unstage ${selectedStagedCount} selected files`}>
|
||||||
{#if file.staged}
|
<Undo2 size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
|
||||||
{@const unstageTargets = selectedUnstageTargets(file)}
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={unstageTargets.length > 1 ? `Unstage ${unstageTargets.length} selected files` : "Unstage file"}>
|
{/if}
|
||||||
<Undo2 size={14} aria-hidden="true" />
|
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
|
||||||
{unstageTargets.length > 1 ? `Unstage ${unstageTargets.length}` : "Unstage"}
|
<Undo2 size={13} aria-hidden="true" /> Unstage all
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
|
{#if selectedStagedCount > 1}
|
||||||
<FileDiff size={14} aria-hidden="true" />
|
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={`Discard staged changes in ${selectedStagedCount} selected files`}>
|
||||||
Details
|
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedStagedCount}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={unstageTargets.length > 1 ? `Discard staged changes in ${unstageTargets.length} selected files` : "Discard staged changes"}>
|
{/if}
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
|
||||||
{unstageTargets.length > 1 ? `Discard ${unstageTargets.length}` : "Discard"}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span class="quiet">No staged change</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="change-lane" class:inactive={!file.unstaged}>
|
|
||||||
<div class="lane-header">
|
|
||||||
<span class="lane-name">Unstaged</span>
|
|
||||||
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="lane-actions">
|
|
||||||
{#if file.unstaged}
|
|
||||||
{@const stageTargets = selectedStageTargets(file)}
|
|
||||||
<button class="btn-sm" type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Stage ${stageTargets.length} selected files` : "Stage file"}>
|
|
||||||
<Check size={14} aria-hidden="true" />
|
|
||||||
{stageTargets.length > 1 ? `Stage ${stageTargets.length}` : "Stage"}
|
|
||||||
</button>
|
|
||||||
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
|
|
||||||
<FileDiff size={14} aria-hidden="true" />
|
|
||||||
Details
|
|
||||||
</button>
|
|
||||||
<button class="btn-sm" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Discard unstaged changes in ${stageTargets.length} selected files` : "Discard unstaged changes"}>
|
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
|
||||||
{stageTargets.length > 1 ? `Discard ${stageTargets.length}` : "Discard"}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span class="quiet">No unstaged change</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</header>
|
||||||
{/each}
|
<div class="status-file-list">
|
||||||
|
{#each changedFiles.filter((file) => file.staged !== null) as file (`staged:${fileKey(file)}`)}
|
||||||
|
{@const unstageTargets = selectedUnstageTargets(file)}
|
||||||
|
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
|
||||||
|
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||||
|
<strong>{fileName(file)}</strong>
|
||||||
|
<span>{displayPath(file)}</span>
|
||||||
|
</button>
|
||||||
|
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
||||||
|
<div class="status-file-actions">
|
||||||
|
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><Undo2 size={14} aria-hidden="true" /></button>
|
||||||
|
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||||
|
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Cloud, GitBranch, Plus, Save, Trash2, X } from "@lucide/svelte";
|
||||||
|
import type { GitRemote, PullStrategy } from "../types";
|
||||||
|
|
||||||
|
export let remotes: GitRemote[] = [];
|
||||||
|
export let remoteBranches: string[] = [];
|
||||||
|
export let currentBranch = "";
|
||||||
|
export let currentUpstream = "";
|
||||||
|
export let strategy: PullStrategy = "merge";
|
||||||
|
export let selectedRemote = "";
|
||||||
|
export let isBusy = false;
|
||||||
|
export let language: "en" | "de" = "en";
|
||||||
|
export let onSaveSync: (strategy: PullStrategy, remote: string, upstream: string) => void | Promise<void> = () => {};
|
||||||
|
export let onAddRemote: (name: string, url: string) => void | Promise<void> = () => {};
|
||||||
|
export let onUpdateRemote: (name: string, url: string) => void | Promise<void> = () => {};
|
||||||
|
export let onRemoveRemote: (name: string) => void | Promise<void> = () => {};
|
||||||
|
export let onClose: () => void = () => {};
|
||||||
|
|
||||||
|
let draftStrategy = strategy;
|
||||||
|
let draftRemote = selectedRemote;
|
||||||
|
let draftUpstream = currentUpstream;
|
||||||
|
let newName = "origin";
|
||||||
|
let newUrl = "";
|
||||||
|
let editingName = "";
|
||||||
|
let editingUrl = "";
|
||||||
|
let actionError = "";
|
||||||
|
let actionStatus = "";
|
||||||
|
$: de = language === "de";
|
||||||
|
|
||||||
|
function beginEdit(remote: GitRemote) { actionError = ""; editingName = remote.name; editingUrl = remote.fetch_url; }
|
||||||
|
async function requestDelete(event: MouseEvent, name: string) {
|
||||||
|
console.log(name)
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
editingName = "";
|
||||||
|
actionError = "";
|
||||||
|
actionStatus = de ? `Remote „${name}“ wird entfernt …` : `Removing remote “${name}” …`;
|
||||||
|
console.info("[Gitty remote] remove button activated", { name });
|
||||||
|
await remove(name);
|
||||||
|
}
|
||||||
|
function cancelEdit() { editingName = ""; editingUrl = ""; }
|
||||||
|
async function add() { if (!newName.trim() || !newUrl.trim()) return; await onAddRemote(newName.trim(), newUrl.trim()); newName = "origin"; newUrl = ""; }
|
||||||
|
async function update() { if (!editingName || !editingUrl.trim()) return; await onUpdateRemote(editingName, editingUrl.trim()); cancelEdit(); }
|
||||||
|
async function remove(name: string) {
|
||||||
|
actionError = "";
|
||||||
|
console.log("remove")
|
||||||
|
try {
|
||||||
|
await onRemoveRemote(name);
|
||||||
|
if (draftRemote === name) draftRemote = "";
|
||||||
|
if (draftUpstream.startsWith(`${name}/`)) draftUpstream = "";
|
||||||
|
} catch (error) {
|
||||||
|
actionError = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
actionStatus = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="sync-settings-dialog" role="dialog" aria-modal="true" aria-labelledby="sync-settings-title">
|
||||||
|
<header class="sync-settings-head">
|
||||||
|
<div class="sync-settings-title">
|
||||||
|
<span class="sync-settings-icon"><Cloud size={18} aria-hidden="true" /></span>
|
||||||
|
<div><span class="eyebrow">Git sync</span><h2 id="sync-settings-title">{de ? "Synchronisierung & Remotes" : "Sync & remotes"}</h2></div>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-icon-button" type="button" onclick={onClose} disabled={isBusy} aria-label={de ? "Schließen" : "Close"}><X size={17} /></button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="sync-settings-body">
|
||||||
|
<section class="sync-settings-card">
|
||||||
|
<div class="sync-card-heading"><div><h3>{de ? "Pull-Verhalten" : "Pull behavior"}</h3><p>{de ? "Legt fest, wie entfernte Änderungen in deinen aktuellen Branch übernommen werden." : "Controls how remote changes are integrated into your current branch."}</p></div></div>
|
||||||
|
<div class="strategy-options">
|
||||||
|
<label class:active={draftStrategy === "merge"}><input type="radio" bind:group={draftStrategy} value="merge" /><span><strong>Merge</strong><small>{de ? "Erstellt bei getrennten Verläufen einen Merge-Commit. Sicher und leicht nachvollziehbar." : "Creates a merge commit for diverged history. Safe and easy to follow."}</small></span></label>
|
||||||
|
<label class:active={draftStrategy === "rebase"}><input type="radio" bind:group={draftStrategy} value="rebase" /><span><strong>Rebase</strong><small>{de ? "Setzt deine lokalen Commits auf die Remote-Änderungen. Ergibt eine lineare Historie." : "Replays your local commits on remote changes for a linear history."}</small></span></label>
|
||||||
|
<label class:active={draftStrategy === "ff-only"}><input type="radio" bind:group={draftStrategy} value="ff-only" /><span><strong>Fast-forward only</strong><small>{de ? "Pull stoppt, sobald ein Merge nötig wäre. Verändert die Historie nie automatisch." : "Stops when a merge would be required. Never combines diverged history automatically."}</small></span></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sync-fields">
|
||||||
|
<label><span>{de ? "Remote für Sync" : "Remote used for sync"}</span><select bind:value={draftRemote}><option value="">{de ? "Automatisch wählen" : "Choose automatically"}</option>{#each remotes as remote}<option value={remote.name}>{remote.name}</option>{/each}</select><small>{de ? "Ein Remote ist die gespeicherte Verbindung zu einem Server-Repository." : "A remote is a saved connection to a repository on a server."}</small></label>
|
||||||
|
<label><span>{de ? `Upstream für ${currentBranch || "aktuellen Branch"}` : `Upstream for ${currentBranch || "current branch"}`}</span><select bind:value={draftUpstream}><option value="">{de ? "Kein Upstream" : "No upstream"}</option>{#each remoteBranches as branch}<option value={branch}>{branch}</option>{/each}</select><small>{de ? "Der Upstream ist der Remote-Branch, mit dem Pull, Push und Ahead/Behind verglichen werden." : "The upstream is the remote branch used by Pull, Push, and Ahead/Behind."}</small></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="sync-settings-card">
|
||||||
|
<div class="sync-card-heading"><div><h3>Remotes</h3><p>{de ? "Server-Verbindungen dieses Repositorys verwalten." : "Manage this repository's server connections."}</p></div><span class="count-pill">{remotes.length}</span></div>
|
||||||
|
<div class="remote-list">
|
||||||
|
{#if actionError}<div class="sync-action-error" role="alert"><strong>{de ? "Remote konnte nicht entfernt werden" : "Remote could not be removed"}</strong><span>{actionError}</span></div>{/if}
|
||||||
|
{#if actionStatus}<div class="sync-action-status" role="status">{actionStatus}</div>{/if}
|
||||||
|
{#each remotes as remote (remote.name)}
|
||||||
|
<div class="remote-row">
|
||||||
|
<span class="remote-mark"><GitBranch size={15} /></span>
|
||||||
|
{#if editingName === remote.name}
|
||||||
|
<div class="remote-edit"><strong>{remote.name}</strong><input bind:value={editingUrl} aria-label={`URL for ${remote.name}`} /></div>
|
||||||
|
<button class="btn-sm" type="button" onclick={update} disabled={isBusy || !editingUrl.trim()}><Save size={13} /> {de ? "Speichern" : "Save"}</button>
|
||||||
|
<button class="btn-sm" type="button" onclick={cancelEdit} disabled={isBusy}>{de ? "Abbrechen" : "Cancel"}</button>
|
||||||
|
{:else}
|
||||||
|
<button class="remote-main" type="button" onclick={() => beginEdit(remote)} disabled={isBusy}><strong>{remote.name}</strong><span>{remote.fetch_url}</span></button>
|
||||||
|
<button class="remote-delete" type="button" onclick={(event) => requestDelete(event, remote.name)} data-remote-name={remote.name} title={de ? "Remote-Verbindung sofort entfernen; lokale Daten bleiben erhalten" : "Remove remote connection now; local data is kept"}><Trash2 size={13} /><span>{de ? "Entfernen" : "Remove"}</span></button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}<p class="remote-empty">{de ? "Noch kein Remote eingerichtet." : "No remote configured yet."}</p>{/each}
|
||||||
|
</div>
|
||||||
|
<div class="remote-add"><input bind:value={newName} placeholder={de ? "Name, z. B. origin" : "Name, e.g. origin"} aria-label="Remote name" /><input bind:value={newUrl} placeholder="https://… or git@…" aria-label="Remote URL" /><button class="btn-secondary" type="button" onclick={add} disabled={isBusy || !newName.trim() || !newUrl.trim()}><Plus size={14} /> {de ? "Hinzufügen" : "Add remote"}</button></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="sync-settings-footer"><p>{de ? "Fetch + Prune entfernt veraltete Remote-Verweise. Force with lease ist ein geschütztes Force-Push nach einem Rebase." : "Fetch + Prune removes stale remote references. Force with lease is a protected force-push after a rebase."}</p><div><button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{de ? "Abbrechen" : "Cancel"}</button><button class="btn-primary" type="button" onclick={() => onSaveSync(draftStrategy, draftRemote, draftUpstream)} disabled={isBusy}><Save size={14} /> {de ? "Einstellungen speichern" : "Save settings"}</button></div></footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
CircleDot,
|
||||||
|
ExternalLink,
|
||||||
|
FolderInput,
|
||||||
|
FolderOpen,
|
||||||
|
GitBranch,
|
||||||
|
HardDrive,
|
||||||
|
LoaderCircle,
|
||||||
|
Lock,
|
||||||
|
MapPin,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
Unlock,
|
||||||
|
Wrench,
|
||||||
|
X,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import type { GitBranch as GitBranchInfo, GitWorktree } from "../types";
|
||||||
|
|
||||||
|
type CreateMode = "existing" | "new" | "detached";
|
||||||
|
|
||||||
|
interface AddRequest {
|
||||||
|
worktreePath: string;
|
||||||
|
branch?: string;
|
||||||
|
newBranch?: string;
|
||||||
|
startPoint?: string;
|
||||||
|
detached?: boolean;
|
||||||
|
lock?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
worktrees: GitWorktree[];
|
||||||
|
branches: GitBranchInfo[];
|
||||||
|
initialBranch?: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
|
error?: string;
|
||||||
|
onRefresh: () => void | Promise<void>;
|
||||||
|
onOpen: (worktree: GitWorktree) => void | Promise<void>;
|
||||||
|
onAdd: (request: AddRequest) => boolean | Promise<boolean>;
|
||||||
|
onRemove: (worktree: GitWorktree, force: boolean) => boolean | Promise<boolean>;
|
||||||
|
onMove: (worktree: GitWorktree, destination: string) => void | Promise<void>;
|
||||||
|
onLock: (worktree: GitWorktree, reason: string) => boolean | Promise<boolean>;
|
||||||
|
onUnlock: (worktree: GitWorktree) => void | Promise<void>;
|
||||||
|
onPrune: () => void | Promise<void>;
|
||||||
|
onRepair: (worktree: GitWorktree, location: string) => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
worktrees = [],
|
||||||
|
branches = [],
|
||||||
|
initialBranch = "",
|
||||||
|
isLoading = false,
|
||||||
|
isBusy = false,
|
||||||
|
error = "",
|
||||||
|
onRefresh = () => {},
|
||||||
|
onOpen = () => {},
|
||||||
|
onAdd = () => false,
|
||||||
|
onRemove = () => false,
|
||||||
|
onMove = () => {},
|
||||||
|
onLock = () => false,
|
||||||
|
onUnlock = () => {},
|
||||||
|
onPrune = () => {},
|
||||||
|
onRepair = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let createOpen = $state(false);
|
||||||
|
let createMode = $state<CreateMode>("new");
|
||||||
|
let selectedBranch = $state("");
|
||||||
|
let newBranch = $state("");
|
||||||
|
let startPoint = $state("HEAD");
|
||||||
|
let destination = $state("");
|
||||||
|
let lockAfterCreate = $state(false);
|
||||||
|
let pendingRemoval = $state<GitWorktree | null>(null);
|
||||||
|
let forceRemoval = $state(false);
|
||||||
|
let pendingLock = $state<GitWorktree | null>(null);
|
||||||
|
let lockReason = $state("");
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (initialized) return;
|
||||||
|
createOpen = initialBranch.length > 0;
|
||||||
|
createMode = initialBranch ? "existing" : "new";
|
||||||
|
selectedBranch = initialBranch;
|
||||||
|
initialized = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
let localBranches = $derived(branches.filter((branch) => !branch.remote));
|
||||||
|
let prunableCount = $derived(worktrees.filter((worktree) => worktree.prunable).length);
|
||||||
|
let linkedCount = $derived(Math.max(0, worktrees.length - 1));
|
||||||
|
let checkedOutBranches = $derived(new Set(worktrees.map((worktree) => worktree.branch).filter((branch): branch is string => Boolean(branch))));
|
||||||
|
|
||||||
|
function displayName(worktree: GitWorktree): string {
|
||||||
|
return worktree.branch || (worktree.detached ? `Detached at ${worktree.short_head || "HEAD"}` : "Bare worktree");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathName(path: string): string {
|
||||||
|
return path.split(/[\\/]/).filter(Boolean).pop() || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchAvailable(branch: string): boolean {
|
||||||
|
return !checkedOutBranches.has(branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPath(parent: string, name: string): string {
|
||||||
|
const separator = parent.includes("\\") ? "\\" : "/";
|
||||||
|
return `${parent.replace(/[\\/]+$/, "")}${separator}${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseDestination(current = "") {
|
||||||
|
const selected = await openDialog({
|
||||||
|
title: current ? "Choose new worktree location" : "Choose worktree folder",
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
defaultPath: current || undefined,
|
||||||
|
});
|
||||||
|
if (typeof selected === "string") destination = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!destination.trim()) return;
|
||||||
|
const request: AddRequest = {
|
||||||
|
worktreePath: destination.trim(),
|
||||||
|
lock: lockAfterCreate,
|
||||||
|
};
|
||||||
|
if (createMode === "existing") request.branch = selectedBranch;
|
||||||
|
if (createMode === "new") {
|
||||||
|
request.newBranch = newBranch.trim();
|
||||||
|
request.startPoint = startPoint.trim() || "HEAD";
|
||||||
|
}
|
||||||
|
if (createMode === "detached") {
|
||||||
|
request.detached = true;
|
||||||
|
request.startPoint = startPoint.trim() || "HEAD";
|
||||||
|
}
|
||||||
|
if (await onAdd(request)) {
|
||||||
|
createOpen = false;
|
||||||
|
destination = "";
|
||||||
|
newBranch = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseMoveDestination(worktree: GitWorktree) {
|
||||||
|
const selected = await openDialog({
|
||||||
|
title: `Choose parent folder for ${displayName(worktree)}`,
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
defaultPath: worktree.path,
|
||||||
|
});
|
||||||
|
if (typeof selected === "string") {
|
||||||
|
const target = joinPath(selected, pathName(worktree.path));
|
||||||
|
if (target !== worktree.path) await onMove(worktree, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseRepairLocation(worktree: GitWorktree) {
|
||||||
|
const selected = await openDialog({
|
||||||
|
title: `Locate ${displayName(worktree)}`,
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
if (typeof selected === "string") await onRepair(worktree, selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRemoval(worktree: GitWorktree) {
|
||||||
|
pendingRemoval = worktree;
|
||||||
|
forceRemoval = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemoval() {
|
||||||
|
if (!pendingRemoval) return;
|
||||||
|
if (await onRemove(pendingRemoval, forceRemoval)) {
|
||||||
|
pendingRemoval = null;
|
||||||
|
forceRemoval = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestLock(worktree: GitWorktree) {
|
||||||
|
pendingLock = worktree;
|
||||||
|
lockReason = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmLock() {
|
||||||
|
if (!pendingLock) return;
|
||||||
|
if (await onLock(pendingLock, lockReason)) {
|
||||||
|
pendingLock = null;
|
||||||
|
lockReason = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog worktree-dialog" role="dialog" aria-modal="true" aria-labelledby="worktree-dialog-title">
|
||||||
|
<header class="dialog-header worktree-dialog-header">
|
||||||
|
<div class="worktree-dialog-heading">
|
||||||
|
<span class="worktree-dialog-mark" aria-hidden="true"><HardDrive size={18} /></span>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Parallel workspaces</span>
|
||||||
|
<p class="dialog-title" id="worktree-dialog-title">Worktrees</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-header-actions">
|
||||||
|
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading} title="Refresh worktrees">
|
||||||
|
<RefreshCw class={isLoading ? "spin" : undefined} size={15} aria-hidden="true" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="worktree-summary">
|
||||||
|
<div><strong>{linkedCount}</strong><span>linked worktrees</span></div>
|
||||||
|
<div><strong>{worktrees.filter((worktree) => !worktree.clean).length}</strong><span>with changes</span></div>
|
||||||
|
<div class:attention={prunableCount > 0}><strong>{prunableCount}</strong><span>stale entries</span></div>
|
||||||
|
<button class="btn-primary" type="button" onclick={() => { createOpen = !createOpen; }} disabled={isBusy}>
|
||||||
|
<Plus size={15} aria-hidden="true" />
|
||||||
|
New worktree
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="worktree-error" role="alert"><AlertTriangle size={15} aria-hidden="true" />{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="worktree-content">
|
||||||
|
{#if createOpen}
|
||||||
|
<form class="worktree-create-card" onsubmit={submitCreate}>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Create</span>
|
||||||
|
<h3>Choose what this workspace should track</h3>
|
||||||
|
</div>
|
||||||
|
<button class="btn-sm dialog-close" type="button" onclick={() => { createOpen = false; }} disabled={isBusy} aria-label="Close create form">
|
||||||
|
<X size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="worktree-mode-tabs" role="tablist" aria-label="Worktree type">
|
||||||
|
<button class:active={createMode === "existing"} type="button" role="tab" aria-selected={createMode === "existing"} onclick={() => { createMode = "existing"; }}>
|
||||||
|
<GitBranch size={14} aria-hidden="true" />Existing branch
|
||||||
|
</button>
|
||||||
|
<button class:active={createMode === "new"} type="button" role="tab" aria-selected={createMode === "new"} onclick={() => { createMode = "new"; }}>
|
||||||
|
<Plus size={14} aria-hidden="true" />New branch
|
||||||
|
</button>
|
||||||
|
<button class:active={createMode === "detached"} type="button" role="tab" aria-selected={createMode === "detached"} onclick={() => { createMode = "detached"; }}>
|
||||||
|
<CircleDot size={14} aria-hidden="true" />Detached
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="worktree-create-fields">
|
||||||
|
{#if createMode === "existing"}
|
||||||
|
<label>
|
||||||
|
<span>Branch</span>
|
||||||
|
<select bind:value={selectedBranch} disabled={isBusy}>
|
||||||
|
<option value="" disabled>Select a local branch</option>
|
||||||
|
{#each localBranches as branch (branch.name)}
|
||||||
|
<option value={branch.name} disabled={!branchAvailable(branch.name)}>{branch.name}{!branchAvailable(branch.name) ? " (already checked out)" : ""}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{:else if createMode === "new"}
|
||||||
|
<label>
|
||||||
|
<span>New branch name</span>
|
||||||
|
<input bind:value={newBranch} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="feature/my-change" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Start point</span>
|
||||||
|
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD, branch or commit" />
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<label>
|
||||||
|
<span>Commit or ref</span>
|
||||||
|
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD" />
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<label class="worktree-path-field">
|
||||||
|
<span>Folder</span>
|
||||||
|
<div>
|
||||||
|
<input bind:value={destination} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="Choose an empty folder" />
|
||||||
|
<button class="btn-secondary" type="button" onclick={() => chooseDestination()} disabled={isBusy}>
|
||||||
|
<FolderOpen size={15} aria-hidden="true" />Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<label class="worktree-check">
|
||||||
|
<input type="checkbox" bind:checked={lockAfterCreate} disabled={isBusy} />
|
||||||
|
<span><strong>Lock after creation</strong><small>Protects removable or temporary locations from pruning.</small></span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={isBusy || !destination.trim() || (createMode === "existing" && (!selectedBranch || !branchAvailable(selectedBranch))) || (createMode === "new" && !newBranch.trim())}
|
||||||
|
>
|
||||||
|
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Plus size={15} aria-hidden="true" />{/if}
|
||||||
|
Create worktree
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading && worktrees.length === 0}
|
||||||
|
<div class="worktree-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>Reading worktrees…</span></div>
|
||||||
|
{:else if worktrees.length === 0}
|
||||||
|
<div class="worktree-empty"><HardDrive size={24} aria-hidden="true" /><strong>No worktrees found</strong><span>Create one to work on another branch without switching this workspace.</span></div>
|
||||||
|
{:else}
|
||||||
|
<div class="worktree-list">
|
||||||
|
{#each worktrees as worktree (worktree.path)}
|
||||||
|
<article class:current={worktree.is_current} class:stale={worktree.prunable || worktree.missing} class="worktree-card">
|
||||||
|
<div class="worktree-rail" aria-hidden="true">
|
||||||
|
<span></span>
|
||||||
|
{#if !worktree.is_main}<i></i>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="worktree-card-main">
|
||||||
|
<header>
|
||||||
|
<div class="worktree-name">
|
||||||
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{displayName(worktree)}</strong>
|
||||||
|
<span>{pathName(worktree.path)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="worktree-badges">
|
||||||
|
{#if worktree.is_main}<span>Main</span>{/if}
|
||||||
|
{#if worktree.is_current}<span class="active">Open</span>{/if}
|
||||||
|
{#if worktree.detached}<span>Detached</span>{/if}
|
||||||
|
{#if worktree.locked}<span class="locked"><Lock size={10} aria-hidden="true" />Locked</span>{/if}
|
||||||
|
{#if worktree.prunable || worktree.missing}<span class="danger">Stale</span>{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="worktree-path" title={worktree.path}><MapPin size={13} aria-hidden="true" /><code>{worktree.path}</code></div>
|
||||||
|
|
||||||
|
<div class="worktree-meta">
|
||||||
|
<span class:dirty={!worktree.clean}>
|
||||||
|
{#if worktree.clean}<Check size={12} aria-hidden="true" />Clean{:else}<CircleDot size={12} aria-hidden="true" />{worktree.changed_files} changed{/if}
|
||||||
|
</span>
|
||||||
|
{#if worktree.short_head}<span><code>{worktree.short_head}</code></span>{/if}
|
||||||
|
{#if worktree.lock_reason}<span><Lock size={12} aria-hidden="true" />{worktree.lock_reason}</span>{/if}
|
||||||
|
{#if worktree.prune_reason}<span class="danger"><AlertTriangle size={12} aria-hidden="true" />{worktree.prune_reason}</span>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button class="btn-secondary" type="button" onclick={() => onOpen(worktree)} disabled={isBusy || worktree.missing || worktree.bare}>
|
||||||
|
<ExternalLink size={14} aria-hidden="true" />{worktree.is_current ? "Refresh tab" : "Open tab"}
|
||||||
|
</button>
|
||||||
|
<div class="worktree-card-actions">
|
||||||
|
{#if worktree.prunable}
|
||||||
|
<button class="btn-sm" type="button" onclick={() => chooseRepairLocation(worktree)} disabled={isBusy} title="Locate and repair worktree">
|
||||||
|
<Wrench size={14} aria-hidden="true" />Repair
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if !worktree.is_main && !worktree.missing}
|
||||||
|
<button class="btn-sm" type="button" onclick={() => chooseMoveDestination(worktree)} disabled={isBusy || worktree.locked || worktree.is_current} title="Move worktree">
|
||||||
|
<FolderInput size={14} aria-hidden="true" />Move
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if worktree.locked}
|
||||||
|
<button class="btn-sm" type="button" onclick={() => onUnlock(worktree)} disabled={isBusy} title="Unlock worktree">
|
||||||
|
<Unlock size={14} aria-hidden="true" />Unlock
|
||||||
|
</button>
|
||||||
|
{:else if !worktree.is_main}
|
||||||
|
<button class="btn-sm" type="button" onclick={() => requestLock(worktree)} disabled={isBusy} title="Lock worktree">
|
||||||
|
<Lock size={14} aria-hidden="true" />Lock
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if !worktree.is_main}
|
||||||
|
<button class="btn-sm danger" type="button" onclick={() => requestRemoval(worktree)} disabled={isBusy || worktree.is_current || worktree.locked || worktree.missing} title={worktree.missing ? "Use Prune to remove stale metadata" : "Remove worktree"}>
|
||||||
|
<Trash2 size={14} aria-hidden="true" />Remove
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="worktree-dialog-footer">
|
||||||
|
<div><ShieldCheck size={14} aria-hidden="true" /><span>Dirty, active and locked worktrees are protected.</span></div>
|
||||||
|
<button class="btn-secondary" type="button" onclick={onPrune} disabled={isBusy || prunableCount === 0}>
|
||||||
|
<Wrench size={14} aria-hidden="true" />Prune {prunableCount || ""}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pendingRemoval}
|
||||||
|
<div class="dialog-backdrop worktree-nested-backdrop" role="presentation">
|
||||||
|
<div class="dialog worktree-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="worktree-remove-title">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Remove worktree</span>
|
||||||
|
<p class="dialog-title" id="worktree-remove-title">Remove {displayName(pendingRemoval)}?</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="worktree-confirm-body">
|
||||||
|
<span class="discard-warning-icon" aria-hidden="true"><AlertTriangle size={21} /></span>
|
||||||
|
<div>
|
||||||
|
<p>This removes the worktree folder and its Git registration. The branch itself is kept.</p>
|
||||||
|
<code>{pendingRemoval.path}</code>
|
||||||
|
{#if !pendingRemoval.clean}
|
||||||
|
<label class="worktree-check danger">
|
||||||
|
<input type="checkbox" bind:checked={forceRemoval} disabled={isBusy} />
|
||||||
|
<span><strong>Remove despite local changes</strong><small>{pendingRemoval.changed_files} changed files may be permanently deleted.</small></span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="discard-confirm-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={() => { pendingRemoval = null; }} disabled={isBusy}>Cancel</button>
|
||||||
|
<button class="btn-danger" type="button" onclick={confirmRemoval} disabled={isBusy || (!pendingRemoval.clean && !forceRemoval)}>
|
||||||
|
<Trash2 size={15} aria-hidden="true" />Remove worktree
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pendingLock}
|
||||||
|
<div class="dialog-backdrop worktree-nested-backdrop" role="presentation">
|
||||||
|
<div class="dialog worktree-confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="worktree-lock-title">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Protect worktree</span>
|
||||||
|
<p class="dialog-title" id="worktree-lock-title">Lock {displayName(pendingLock)}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="worktree-lock-body">
|
||||||
|
<label>
|
||||||
|
<span>Reason <small>optional</small></span>
|
||||||
|
<input bind:value={lockReason} disabled={isBusy} autocomplete="off" placeholder="External drive, long-running work…" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<footer class="discard-confirm-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy}>Cancel</button>
|
||||||
|
<button class="btn-primary" type="button" onclick={confirmLock} disabled={isBusy}><Lock size={15} aria-hidden="true" />Lock</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -55,3 +55,17 @@ export function stripAuthPrefix(message: string): string {
|
|||||||
? message.slice(AUTH_PREFIX.length).trim()
|
? message.slice(AUTH_PREFIX.length).trim()
|
||||||
: message;
|
: message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condenses raw git stderr to the single most relevant line for the
|
||||||
|
* credential dialog. Prefers the server's own explanation ("remote: ...")
|
||||||
|
* over git's generic wrapper ("fatal: Authentication failed for ...").
|
||||||
|
*/
|
||||||
|
export function summarizeGitError(message: string): string {
|
||||||
|
const lines = message
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const remote = lines.find((line) => line.toLowerCase().startsWith("remote:"));
|
||||||
|
return remote ?? lines[0] ?? "";
|
||||||
|
}
|
||||||
|
|||||||
+113
-9
@@ -1,6 +1,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { tracedInvoke as invoke } from "./telemetry";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
AiReviewResult,
|
||||||
CommitAiLocalProfile,
|
CommitAiLocalProfile,
|
||||||
CommitAiProvider,
|
CommitAiProvider,
|
||||||
CommitAiStatus,
|
CommitAiStatus,
|
||||||
@@ -10,10 +11,17 @@ import type {
|
|||||||
GitCommit,
|
GitCommit,
|
||||||
GitCommitComparison,
|
GitCommitComparison,
|
||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
|
GitRemote,
|
||||||
|
MergeStrategy,
|
||||||
|
PullStrategy,
|
||||||
|
RebaseCommit,
|
||||||
|
RebasePlanItem,
|
||||||
|
ReflogEntry,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStash,
|
GitStash,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
GitTag,
|
GitTag,
|
||||||
|
GitWorktree,
|
||||||
LocalModelOption,
|
LocalModelOption,
|
||||||
PatchApplyAction,
|
PatchApplyAction,
|
||||||
RepositoryBundle,
|
RepositoryBundle,
|
||||||
@@ -24,6 +32,10 @@ export function openRepository(path: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("open_repository", { path });
|
return invoke<GitStatus>("open_repository", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function initRepository(path: string, initialBranch = "main"): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("init_repository", { path, initialBranch });
|
||||||
|
}
|
||||||
|
|
||||||
export function openRepoInExplorer(path: string): Promise<void> {
|
export function openRepoInExplorer(path: string): Promise<void> {
|
||||||
return invoke<void>("open_repo_in_explorer", { path });
|
return invoke<void>("open_repo_in_explorer", { path });
|
||||||
}
|
}
|
||||||
@@ -68,6 +80,15 @@ export function listBranches(path: string): Promise<GitBranch[]> {
|
|||||||
return invoke<GitBranch[]>("list_branches", { path });
|
return invoke<GitBranch[]>("list_branches", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listRemotes(path: string): Promise<GitRemote[]> { return invoke("list_remotes", { path }); }
|
||||||
|
export function addRemote(path: string, name: string, url: string): Promise<GitRemote[]> { return invoke("add_remote", { path, name, url }); }
|
||||||
|
export function updateRemote(path: string, name: string, url: string): Promise<GitRemote[]> { return invoke("update_remote", { path, name, url }); }
|
||||||
|
export function removeRemote(path: string, name: string): Promise<GitRemote[]> {
|
||||||
|
console.log("remove_remote")
|
||||||
|
return invoke("remove_remote", { path, name }); }
|
||||||
|
export function setBranchUpstream(path: string, branch: string, upstream?: string): Promise<GitStatus> { return invoke("set_branch_upstream", { path, branch, upstream: upstream || null }); }
|
||||||
|
export function deleteRemoteBranch(path: string, remote: string, branch: string): Promise<GitStatus> { return invoke("delete_remote_branch", { path, remote, branch }); }
|
||||||
|
|
||||||
export function listStashes(path: string): Promise<GitStash[]> {
|
export function listStashes(path: string): Promise<GitStash[]> {
|
||||||
return invoke<GitStash[]>("list_stashes", { path });
|
return invoke<GitStash[]>("list_stashes", { path });
|
||||||
}
|
}
|
||||||
@@ -96,6 +117,56 @@ export function deleteBranch(path: string, branch: string, force = false): Promi
|
|||||||
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listWorktrees(path: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("list_worktrees", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addWorktree(
|
||||||
|
path: string,
|
||||||
|
worktreePath: string,
|
||||||
|
options: {
|
||||||
|
branch?: string;
|
||||||
|
newBranch?: string;
|
||||||
|
startPoint?: string;
|
||||||
|
detached?: boolean;
|
||||||
|
lock?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("add_worktree", {
|
||||||
|
path,
|
||||||
|
worktreePath,
|
||||||
|
branch: options.branch ?? null,
|
||||||
|
newBranch: options.newBranch ?? null,
|
||||||
|
startPoint: options.startPoint ?? null,
|
||||||
|
detached: options.detached ?? false,
|
||||||
|
lock: options.lock ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeWorktree(path: string, worktreePath: string, force = false): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("remove_worktree", { path, worktreePath, force });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveWorktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("move_worktree", { path, worktreePath, destination });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockWorktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("lock_worktree", { path, worktreePath, reason: reason?.trim() || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlockWorktree(path: string, worktreePath: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("unlock_worktree", { path, worktreePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneWorktrees(path: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("prune_worktrees", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function repairWorktree(path: string, worktreePath: string): Promise<GitWorktree[]> {
|
||||||
|
return invoke<GitWorktree[]>("repair_worktree", { path, worktreePath });
|
||||||
|
}
|
||||||
|
|
||||||
export function listTags(path: string): Promise<GitTag[]> {
|
export function listTags(path: string): Promise<GitTag[]> {
|
||||||
return invoke<GitTag[]>("list_tags", { path });
|
return invoke<GitTag[]>("list_tags", { path });
|
||||||
}
|
}
|
||||||
@@ -241,16 +312,26 @@ export function commitAiGenerate(path: string, options: CommitAiGenerateOptions)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
|
export function commitAiReview(path: string, options: CommitAiGenerateOptions): Promise<AiReviewResult> {
|
||||||
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
|
return invoke<AiReviewResult>("commit_ai_review", {
|
||||||
|
path,
|
||||||
|
provider: options.provider,
|
||||||
|
model: options.model,
|
||||||
|
apiKey: options.apiKey,
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchRemote(path: string, username?: string, password?: string): Promise<GitStatus> {
|
export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null });
|
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function push(path: string, username?: string, password?: string): Promise<GitStatus> {
|
export function fetchRemote(path: string, username?: string, password?: string, prune = false, remote?: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null });
|
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null, prune, remote: remote || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function push(path: string, username?: string, password?: string, forceWithLease = false, remote?: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRemoteUrl(path: string): Promise<string | null> {
|
export function getRemoteUrl(path: string): Promise<string | null> {
|
||||||
@@ -290,9 +371,12 @@ export function restoreFileFromCommit(
|
|||||||
return invoke<GitStatus>("restore_file_from_commit", { path, commit, file });
|
return invoke<GitStatus>("restore_file_from_commit", { path, commit, file });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeBranch(path: string, branch: string): Promise<GitStatus> {
|
export function mergeBranch(path: string, branch: string, strategy: MergeStrategy = "default"): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("merge_branch", { path, branch });
|
return invoke<GitStatus>("merge_branch", { path, branch, strategy });
|
||||||
}
|
}
|
||||||
|
export function mergeContinue(path: string): Promise<GitStatus> { return invoke("merge_continue", { path }); }
|
||||||
|
export function mergeAbort(path: string): Promise<GitStatus> { return invoke("merge_abort", { path }); }
|
||||||
|
export function revertCommit(path: string, commit: string): Promise<GitStatus> { return invoke("revert_commit", { path, commit }); }
|
||||||
|
|
||||||
export function rebaseBranch(path: string, branch: string): Promise<GitStatus> {
|
export function rebaseBranch(path: string, branch: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("rebase_branch", { path, branch });
|
return invoke<GitStatus>("rebase_branch", { path, branch });
|
||||||
@@ -306,6 +390,26 @@ export function rebaseAbort(path: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("rebase_abort", { path });
|
return invoke<GitStatus>("rebase_abort", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listInteractiveRebaseCommits(path: string, base: string): Promise<RebaseCommit[]> {
|
||||||
|
return invoke<RebaseCommit[]>("list_interactive_rebase_commits", { path, base });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startInteractiveRebase(
|
||||||
|
path: string,
|
||||||
|
base: string,
|
||||||
|
plan: RebasePlanItem[],
|
||||||
|
): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("start_interactive_rebase", { path, base, plan });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listReflog(path: string, limit = 250): Promise<ReflogEntry[]> {
|
||||||
|
return invoke<ReflogEntry[]>("list_reflog", { path, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreReflogEntry(path: string, commit: string, branch: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("restore_reflog_entry", { path, commit, branch });
|
||||||
|
}
|
||||||
|
|
||||||
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
|
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
|
||||||
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
|
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
type TelemetryLevel = "info" | "warn" | "error";
|
||||||
|
|
||||||
|
const MAX_MESSAGE_LENGTH = 2_048;
|
||||||
|
let telemetryEnabled = false;
|
||||||
|
let configuration: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
// Telemetry must never contain repository contents or identifying local data.
|
||||||
|
function sanitize(message: string): string {
|
||||||
|
return message
|
||||||
|
.replace(/https?:\/\/\S+/gi, "[url]")
|
||||||
|
.replace(/(?:[A-Za-z]:\\|\/)(?:[^\s<>:"|?*]+[\\/])*[^\s<>:"|?*]*/g, "[path]")
|
||||||
|
.replace(/(token|password|secret|authorization|credential)\s*[:=]\s*\S+/gi, "$1=[redacted]")
|
||||||
|
.replace(/[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}/g, "[email]")
|
||||||
|
.slice(0, MAX_MESSAGE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTelemetryEnabled(enabled: boolean) {
|
||||||
|
telemetryEnabled = enabled;
|
||||||
|
configuration = invoke("set_telemetry_enabled", { enabled }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function telemetryLog(level: TelemetryLevel, message: string, eventName?: string) {
|
||||||
|
if (!telemetryEnabled) return;
|
||||||
|
void configuration.then(() => invoke("emit_frontend_log", {
|
||||||
|
log: {
|
||||||
|
level,
|
||||||
|
message: sanitize(message),
|
||||||
|
eventName,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.catch(() => {
|
||||||
|
// Observability must never interrupt the Git workflow.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomHex(bytes: number): string {
|
||||||
|
const values = new Uint8Array(bytes);
|
||||||
|
crypto.getRandomValues(values);
|
||||||
|
return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tracedInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
if (!telemetryEnabled) return invoke<T>(command, args);
|
||||||
|
const startedAtMs = Date.now();
|
||||||
|
const started = performance.now();
|
||||||
|
const traceId = randomHex(16);
|
||||||
|
const spanId = randomHex(8);
|
||||||
|
try {
|
||||||
|
const result = await invoke<T>(command, args);
|
||||||
|
void configuration.then(() => invoke("emit_frontend_span", {
|
||||||
|
span: { name: command, traceId, spanId, startedAtMs, durationMs: performance.now() - started, success: true },
|
||||||
|
})).catch(() => {});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
void configuration.then(() => invoke("emit_frontend_span", {
|
||||||
|
span: { name: command, traceId, spanId, startedAtMs, durationMs: performance.now() - started, success: false },
|
||||||
|
})).catch(() => {});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installGlobalErrorTelemetry() {
|
||||||
|
window.addEventListener("error", (event) => {
|
||||||
|
telemetryLog("error", event.message || "Unhandled frontend error", "frontend.unhandled_error");
|
||||||
|
});
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
const message = event.reason instanceof Error ? event.reason.message : String(event.reason ?? "Unhandled promise rejection");
|
||||||
|
telemetryLog("error", message, "frontend.unhandled_rejection");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
|||||||
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
||||||
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
||||||
export type AppTheme = "system" | "light" | "dark";
|
export type AppTheme = "system" | "light" | "dark";
|
||||||
|
export type AppLanguage = "en" | "de";
|
||||||
|
|
||||||
export interface CommitAiStatus {
|
export interface CommitAiStatus {
|
||||||
phase: CommitAiPhase;
|
phase: CommitAiPhase;
|
||||||
@@ -18,6 +19,24 @@ export interface CommitAiStatus {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AiReviewRisk = "low" | "medium" | "high";
|
||||||
|
export type AiReviewSeverity = "critical" | "warning" | "info";
|
||||||
|
|
||||||
|
export interface AiReviewFinding {
|
||||||
|
severity: AiReviewSeverity;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
file: string | null;
|
||||||
|
line: number | null;
|
||||||
|
suggestion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiReviewResult {
|
||||||
|
summary: string;
|
||||||
|
risk: AiReviewRisk;
|
||||||
|
findings: AiReviewFinding[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalModelOption {
|
export interface LocalModelOption {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -49,8 +68,13 @@ export interface GitStatus {
|
|||||||
clean: boolean;
|
clean: boolean;
|
||||||
rebase_in_progress: boolean;
|
rebase_in_progress: boolean;
|
||||||
cherry_pick_in_progress: boolean;
|
cherry_pick_in_progress: boolean;
|
||||||
|
merge_in_progress: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitRemote { name: string; fetch_url: string; push_url: string; }
|
||||||
|
export type PullStrategy = "merge" | "rebase" | "ff-only";
|
||||||
|
export type MergeStrategy = "default" | "squash" | "ff-only" | "no-ff";
|
||||||
|
|
||||||
export interface GitFileStatus {
|
export interface GitFileStatus {
|
||||||
path: string;
|
path: string;
|
||||||
old_path: string | null;
|
old_path: string | null;
|
||||||
@@ -66,6 +90,24 @@ export interface GitBranch {
|
|||||||
remote: boolean;
|
remote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitWorktree {
|
||||||
|
path: string;
|
||||||
|
head: string | null;
|
||||||
|
short_head: string | null;
|
||||||
|
branch: string | null;
|
||||||
|
bare: boolean;
|
||||||
|
detached: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
lock_reason: string | null;
|
||||||
|
prunable: boolean;
|
||||||
|
prune_reason: string | null;
|
||||||
|
missing: boolean;
|
||||||
|
is_main: boolean;
|
||||||
|
is_current: boolean;
|
||||||
|
clean: boolean;
|
||||||
|
changed_files: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitTag {
|
export interface GitTag {
|
||||||
name: string;
|
name: string;
|
||||||
hash: string;
|
hash: string;
|
||||||
@@ -198,6 +240,31 @@ export interface GitBlameResult {
|
|||||||
lines: GitBlameLine[];
|
lines: GitBlameLine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RebaseAction = "pick" | "reword" | "squash" | "fixup" | "drop";
|
||||||
|
|
||||||
|
export interface RebaseCommit {
|
||||||
|
hash: string;
|
||||||
|
short_hash: string;
|
||||||
|
summary: string;
|
||||||
|
author_name: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RebasePlanItem {
|
||||||
|
hash: string;
|
||||||
|
action: RebaseAction;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReflogEntry {
|
||||||
|
hash: string;
|
||||||
|
short_hash: string;
|
||||||
|
selector: string;
|
||||||
|
action: string;
|
||||||
|
author_name: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoredCredential {
|
export interface StoredCredential {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { mount } from "svelte";
|
|||||||
|
|
||||||
import App from "./App.svelte";
|
import App from "./App.svelte";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
import { installGlobalErrorTelemetry } from "./lib/telemetry";
|
||||||
|
|
||||||
const THEME_KEY = "gitlite.theme.v1";
|
const THEME_KEY = "gitlite.theme.v1";
|
||||||
const target = document.getElementById("app");
|
const target = document.getElementById("app");
|
||||||
|
|
||||||
|
installGlobalErrorTelemetry();
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw new Error("App target element was not found.");
|
throw new Error("App target element was not found.");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user