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 |
@@ -116,7 +116,24 @@
|
|||||||
"Bash(cp /mnt/data/Development/GitLite/PKGBUILD .)",
|
"Bash(cp /mnt/data/Development/GitLite/PKGBUILD .)",
|
||||||
"Bash(sed -i 's/^pkgrel=1/pkgrel=3/' 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(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(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 *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,15 +5,15 @@
|
|||||||
# binary + desktop entry. Run `makepkg -si` from the repo root.
|
# binary + desktop entry. Run `makepkg -si` from the repo root.
|
||||||
|
|
||||||
pkgname=gitty
|
pkgname=gitty
|
||||||
pkgver=2026.7.19
|
pkgver=2026.7.20
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="A lightweight, modern Git client built with Tauri"
|
pkgdesc="A lightweight, modern Git client built with Tauri"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://git.cbsk-tech.de/Christoph/GitLite"
|
url="https://git.cbsk-tech.de/Christoph/GitLite"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'hicolor-icon-theme')
|
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
||||||
makedepends=('rust' 'nodejs' 'npm')
|
makedepends=('rust' 'nodejs' 'npm')
|
||||||
options=('!lto')
|
options=('!lto' '!debug')
|
||||||
|
|
||||||
source=()
|
source=()
|
||||||
sha256sums=()
|
sha256sums=()
|
||||||
@@ -26,6 +26,16 @@ pkgver() {
|
|||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "$startdir"
|
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
|
npm ci
|
||||||
# Build through the Tauri CLI (not a raw `cargo build --release`): it enables
|
# 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
|
# the `custom-protocol` cargo feature that makes the binary load the embedded
|
||||||
|
|||||||
@@ -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.19",
|
"version": "2026.7.20",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.7.19",
|
"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.19",
|
"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,6 +21,9 @@ 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 = [] }
|
||||||
|
|||||||
+751
-11
@@ -51,6 +51,14 @@ pub struct GitStatus {
|
|||||||
pub clean: bool,
|
pub clean: bool,
|
||||||
pub rebase_in_progress: bool,
|
pub rebase_in_progress: bool,
|
||||||
pub cherry_pick_in_progress: bool,
|
pub cherry_pick_in_progress: bool,
|
||||||
|
pub merge_in_progress: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct GitRemote {
|
||||||
|
pub name: String,
|
||||||
|
pub fetch_url: String,
|
||||||
|
pub push_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
@@ -60,6 +68,25 @@ pub struct GitBranch {
|
|||||||
pub remote: bool,
|
pub remote: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct GitWorktree {
|
||||||
|
pub path: String,
|
||||||
|
pub head: Option<String>,
|
||||||
|
pub short_head: Option<String>,
|
||||||
|
pub branch: Option<String>,
|
||||||
|
pub bare: bool,
|
||||||
|
pub detached: bool,
|
||||||
|
pub locked: bool,
|
||||||
|
pub lock_reason: Option<String>,
|
||||||
|
pub prunable: bool,
|
||||||
|
pub prune_reason: Option<String>,
|
||||||
|
pub missing: bool,
|
||||||
|
pub is_main: bool,
|
||||||
|
pub is_current: bool,
|
||||||
|
pub clean: bool,
|
||||||
|
pub changed_files: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub struct GitTag {
|
pub struct GitTag {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -300,6 +327,10 @@ static CANCELLABLE_GIT_OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|||||||
|
|
||||||
fn git_command() -> Command {
|
fn git_command() -> Command {
|
||||||
let mut command = Command::new("git");
|
let mut command = Command::new("git");
|
||||||
|
// Force English output regardless of the system locale, so is_auth_error()
|
||||||
|
// and other message heuristics keep working (e.g. German git prints
|
||||||
|
// "Authentifizierung fehlgeschlagen" instead of "Authentication failed").
|
||||||
|
command.env("LC_ALL", "C");
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
command.creation_flags(CREATE_NO_WINDOW);
|
command.creation_flags(CREATE_NO_WINDOW);
|
||||||
command
|
command
|
||||||
@@ -357,6 +388,34 @@ pub fn open_repository(path: String) -> Result<GitStatus, String> {
|
|||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn init_repository(path: String, initial_branch: Option<String>) -> Result<GitStatus, String> {
|
||||||
|
let path = PathBuf::from(path.trim());
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
return Err("Repository path must not be empty.".to_string());
|
||||||
|
}
|
||||||
|
fs::create_dir_all(&path)
|
||||||
|
.map_err(|err| format!("Could not create repository folder: {err}"))?;
|
||||||
|
let branch = initial_branch.unwrap_or_else(|| "main".to_string());
|
||||||
|
let branch = branch.trim();
|
||||||
|
if branch.is_empty() {
|
||||||
|
return Err("Initial branch must not be empty.".to_string());
|
||||||
|
}
|
||||||
|
let output = git_command()
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&path)
|
||||||
|
.args(["init", "-b", branch])
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"Could not initialize repository: {}",
|
||||||
|
command_output_details(&output)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
status_for_repo(&path)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
|
pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
@@ -455,6 +514,125 @@ pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
|
|||||||
branches_for_repo(&repo)
|
branches_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_remotes(path: String) -> Result<Vec<GitRemote>, String> {
|
||||||
|
log::info!(target: "gitty::remote", "list_remotes invoked: path={path:?}");
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let names = run_git(&repo, ["remote"])?;
|
||||||
|
Ok(String::from_utf8_lossy(&names)
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let name = line.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(GitRemote {
|
||||||
|
name: name.to_string(),
|
||||||
|
fetch_url: remote_url_for(&repo, name).unwrap_or_default(),
|
||||||
|
push_url: remote_push_url_for(&repo, name).unwrap_or_default(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn add_remote(path: String, name: String, url: String) -> Result<Vec<GitRemote>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let name = validate_remote_name(&repo, &name, false)?;
|
||||||
|
let url = validate_remote_url(&url)?;
|
||||||
|
run_git(&repo, ["remote", "add", name.as_str(), url.as_str()])?;
|
||||||
|
list_remotes(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_remote(path: String, name: String, url: String) -> Result<Vec<GitRemote>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let name = validate_remote_name(&repo, &name, true)?;
|
||||||
|
let url = validate_remote_url(&url)?;
|
||||||
|
run_git(&repo, ["remote", "set-url", name.as_str(), url.as_str()])?;
|
||||||
|
list_remotes(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_remote(path: String, name: String) -> Result<Vec<GitRemote>, String> {
|
||||||
|
log::info!(target: "gitty::remote", "remove_remote invoked: path={path:?}, name={name:?}");
|
||||||
|
let result = (|| {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
log::info!(target: "gitty::remote", "remove_remote resolved repository: {}", repo.display());
|
||||||
|
let name = validate_remote_name(&repo, &name, true)?;
|
||||||
|
log::info!(target: "gitty::remote", "remove_remote validated remote: {name}");
|
||||||
|
run_git(&repo, ["remote", "remove", name.as_str()])?;
|
||||||
|
log::info!(target: "gitty::remote", "remove_remote git command succeeded: {name}");
|
||||||
|
list_remotes(path)
|
||||||
|
})();
|
||||||
|
match &result {
|
||||||
|
Ok(remotes) => {
|
||||||
|
log::info!(target: "gitty::remote", "remove_remote completed; remaining={:?}", remotes.iter().map(|remote| remote.name.as_str()).collect::<Vec<_>>())
|
||||||
|
}
|
||||||
|
Err(error) => log::error!(target: "gitty::remote", "remove_remote failed: {error}"),
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_branch_upstream(
|
||||||
|
path: String,
|
||||||
|
branch: String,
|
||||||
|
upstream: Option<String>,
|
||||||
|
) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let branch = validate_existing_local_branch_name(&repo, &branch)?;
|
||||||
|
match upstream
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
{
|
||||||
|
Some(upstream) => {
|
||||||
|
if !ref_exists(&repo, &format!("refs/remotes/{upstream}"))? {
|
||||||
|
return Err(format!("Remote branch '{upstream}' was not found."));
|
||||||
|
}
|
||||||
|
run_git(
|
||||||
|
&repo,
|
||||||
|
[
|
||||||
|
"branch",
|
||||||
|
"--set-upstream-to",
|
||||||
|
upstream.as_str(),
|
||||||
|
branch.as_str(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
run_git(&repo, ["branch", "--unset-upstream", branch.as_str()])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_remote_branch(
|
||||||
|
path: String,
|
||||||
|
remote: String,
|
||||||
|
branch: String,
|
||||||
|
) -> Result<GitStatus, String> {
|
||||||
|
log::info!(target: "gitty::remote", "delete_remote_branch invoked: path={path:?}, remote={remote:?}, branch={branch:?}");
|
||||||
|
let result = (|| {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let remote = validate_remote_name(&repo, &remote, true)?;
|
||||||
|
let branch = branch.trim();
|
||||||
|
if branch.is_empty() || branch.starts_with('-') {
|
||||||
|
return Err("Invalid remote branch name.".to_string());
|
||||||
|
}
|
||||||
|
run_git(&repo, ["check-ref-format", "--branch", branch])?;
|
||||||
|
log::info!(target: "gitty::remote", "deleting remote branch with git push: {remote}/{branch}");
|
||||||
|
run_git(&repo, ["push", remote.as_str(), "--delete", branch])?;
|
||||||
|
log::info!(target: "gitty::remote", "remote branch deleted successfully: {remote}/{branch}");
|
||||||
|
status_for_repo(&repo)
|
||||||
|
})();
|
||||||
|
if let Err(error) = &result {
|
||||||
|
log::error!(target: "gitty::remote", "delete_remote_branch failed: {error}");
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
|
pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
@@ -768,6 +946,291 @@ pub fn delete_branch(
|
|||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn worktree_path_matches(left: &Path, right: &Path) -> bool {
|
||||||
|
match (fs::canonicalize(left), fs::canonicalize(right)) {
|
||||||
|
(Ok(left), Ok(right)) => left == right,
|
||||||
|
_ => left == right,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_worktree_porcelain(output: &[u8], current_repo: &Path) -> Vec<GitWorktree> {
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Record {
|
||||||
|
path: Option<String>,
|
||||||
|
head: Option<String>,
|
||||||
|
branch: Option<String>,
|
||||||
|
bare: bool,
|
||||||
|
detached: bool,
|
||||||
|
locked: bool,
|
||||||
|
lock_reason: Option<String>,
|
||||||
|
prunable: bool,
|
||||||
|
prune_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_record(rows: &mut Vec<GitWorktree>, record: &mut Record, current_repo: &Path) {
|
||||||
|
let Some(path) = record.path.take() else {
|
||||||
|
*record = Record::default();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let path_buf = PathBuf::from(&path);
|
||||||
|
let missing = !path_buf.exists();
|
||||||
|
let is_current = worktree_path_matches(&path_buf, current_repo);
|
||||||
|
let head = record.head.take();
|
||||||
|
let short_head = head
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.chars().take(8).collect::<String>());
|
||||||
|
rows.push(GitWorktree {
|
||||||
|
path,
|
||||||
|
head,
|
||||||
|
short_head,
|
||||||
|
branch: record.branch.take(),
|
||||||
|
bare: record.bare,
|
||||||
|
detached: record.detached,
|
||||||
|
locked: record.locked,
|
||||||
|
lock_reason: record.lock_reason.take(),
|
||||||
|
prunable: record.prunable,
|
||||||
|
prune_reason: record.prune_reason.take(),
|
||||||
|
missing,
|
||||||
|
is_main: rows.is_empty(),
|
||||||
|
is_current,
|
||||||
|
clean: true,
|
||||||
|
changed_files: 0,
|
||||||
|
});
|
||||||
|
*record = Record::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut record = Record::default();
|
||||||
|
for field in output.split(|byte| *byte == 0) {
|
||||||
|
if field.is_empty() {
|
||||||
|
finish_record(&mut rows, &mut record, current_repo);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let value = String::from_utf8_lossy(field);
|
||||||
|
let (key, detail) = value
|
||||||
|
.split_once(' ')
|
||||||
|
.map_or((value.as_ref(), None), |(key, detail)| (key, Some(detail)));
|
||||||
|
match key {
|
||||||
|
"worktree" => record.path = detail.map(str::to_string),
|
||||||
|
"HEAD" => record.head = detail.map(str::to_string),
|
||||||
|
"branch" => {
|
||||||
|
record.branch = detail.map(|branch| {
|
||||||
|
branch
|
||||||
|
.strip_prefix("refs/heads/")
|
||||||
|
.unwrap_or(branch)
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"bare" => record.bare = true,
|
||||||
|
"detached" => record.detached = true,
|
||||||
|
"locked" => {
|
||||||
|
record.locked = true;
|
||||||
|
record.lock_reason = detail.map(str::to_string).filter(|value| !value.is_empty());
|
||||||
|
}
|
||||||
|
"prunable" => {
|
||||||
|
record.prunable = true;
|
||||||
|
record.prune_reason = detail.map(str::to_string).filter(|value| !value.is_empty());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finish_record(&mut rows, &mut record, current_repo);
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worktrees_for_repo(repo: &Path) -> Result<Vec<GitWorktree>, String> {
|
||||||
|
let output = run_git(repo, ["worktree", "list", "--porcelain", "-z"])?;
|
||||||
|
let mut worktrees = parse_worktree_porcelain(&output, repo);
|
||||||
|
for worktree in &mut worktrees {
|
||||||
|
if worktree.missing {
|
||||||
|
worktree.clean = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if worktree.bare {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match run_git_at(
|
||||||
|
Path::new(&worktree.path),
|
||||||
|
["status", "--porcelain=v1", "-z", "--untracked-files=normal"],
|
||||||
|
"Could not inspect worktree status",
|
||||||
|
) {
|
||||||
|
Ok(status) => {
|
||||||
|
worktree.changed_files = status
|
||||||
|
.split(|byte| *byte == 0)
|
||||||
|
.filter(|entry| entry.len() >= 3 && entry[2] == b' ')
|
||||||
|
.count() as u32;
|
||||||
|
worktree.clean = worktree.changed_files == 0;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
worktree.clean = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(worktrees)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
worktrees_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn add_worktree(
|
||||||
|
path: String,
|
||||||
|
worktree_path: String,
|
||||||
|
branch: Option<String>,
|
||||||
|
new_branch: Option<String>,
|
||||||
|
start_point: Option<String>,
|
||||||
|
detached: Option<bool>,
|
||||||
|
lock: Option<bool>,
|
||||||
|
) -> Result<Vec<GitWorktree>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let destination = worktree_path.trim();
|
||||||
|
if destination.is_empty() {
|
||||||
|
return Err("Choose a folder for the new worktree.".to_string());
|
||||||
|
}
|
||||||
|
let branch = branch
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let new_branch = new_branch
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
if branch.is_some() && new_branch.is_some() {
|
||||||
|
return Err("Choose either an existing branch or a new branch.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut args = vec![OsString::from("worktree"), OsString::from("add")];
|
||||||
|
if lock.unwrap_or(false) {
|
||||||
|
args.push(OsString::from("--lock"));
|
||||||
|
}
|
||||||
|
if detached.unwrap_or(false) {
|
||||||
|
args.push(OsString::from("--detach"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = if let Some(new_branch) = new_branch {
|
||||||
|
let new_branch = validate_new_branch_name(&repo, &new_branch)?;
|
||||||
|
args.push(OsString::from("-b"));
|
||||||
|
args.push(OsString::from(new_branch));
|
||||||
|
start_point
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(|value| verify_commit(&repo, value))
|
||||||
|
.transpose()?
|
||||||
|
} else if let Some(branch) = branch {
|
||||||
|
Some(validate_existing_local_branch_name(&repo, &branch)?)
|
||||||
|
} else {
|
||||||
|
start_point
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(|value| verify_commit(&repo, value))
|
||||||
|
.transpose()?
|
||||||
|
};
|
||||||
|
|
||||||
|
args.push(OsString::from(destination));
|
||||||
|
if let Some(target) = target {
|
||||||
|
args.push(OsString::from(target));
|
||||||
|
}
|
||||||
|
run_git(&repo, args)?;
|
||||||
|
worktrees_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_worktree(
|
||||||
|
path: String,
|
||||||
|
worktree_path: String,
|
||||||
|
force: Option<bool>,
|
||||||
|
) -> Result<Vec<GitWorktree>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let worktrees = worktrees_for_repo(&repo)?;
|
||||||
|
let target = worktrees
|
||||||
|
.iter()
|
||||||
|
.find(|worktree| {
|
||||||
|
worktree_path_matches(Path::new(&worktree.path), Path::new(&worktree_path))
|
||||||
|
})
|
||||||
|
.ok_or_else(|| "The selected worktree is no longer registered.".to_string())?;
|
||||||
|
if target.is_main {
|
||||||
|
return Err("The main worktree cannot be removed.".to_string());
|
||||||
|
}
|
||||||
|
if target.is_current {
|
||||||
|
return Err("The currently open worktree cannot be removed.".to_string());
|
||||||
|
}
|
||||||
|
if target.locked {
|
||||||
|
return Err("Unlock this worktree before removing it.".to_string());
|
||||||
|
}
|
||||||
|
let mut args = vec![OsString::from("worktree"), OsString::from("remove")];
|
||||||
|
if force.unwrap_or(false) {
|
||||||
|
args.push(OsString::from("--force"));
|
||||||
|
}
|
||||||
|
args.push(OsString::from(worktree_path));
|
||||||
|
run_git(&repo, args)?;
|
||||||
|
worktrees_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn move_worktree(
|
||||||
|
path: String,
|
||||||
|
worktree_path: String,
|
||||||
|
destination: String,
|
||||||
|
) -> Result<Vec<GitWorktree>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let destination = destination.trim();
|
||||||
|
if destination.is_empty() {
|
||||||
|
return Err("Choose a new location for the worktree.".to_string());
|
||||||
|
}
|
||||||
|
run_git(
|
||||||
|
&repo,
|
||||||
|
[
|
||||||
|
OsString::from("worktree"),
|
||||||
|
OsString::from("move"),
|
||||||
|
OsString::from(worktree_path),
|
||||||
|
OsString::from(destination),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
worktrees_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn lock_worktree(
|
||||||
|
path: String,
|
||||||
|
worktree_path: String,
|
||||||
|
reason: Option<String>,
|
||||||
|
) -> Result<Vec<GitWorktree>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let mut args = vec![OsString::from("worktree"), OsString::from("lock")];
|
||||||
|
if let Some(reason) = reason
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
args.push(OsString::from("--reason"));
|
||||||
|
args.push(OsString::from(reason));
|
||||||
|
}
|
||||||
|
args.push(OsString::from(worktree_path));
|
||||||
|
run_git(&repo, args)?;
|
||||||
|
worktrees_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn unlock_worktree(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
run_git(&repo, ["worktree", "unlock", "--", worktree_path.as_str()])?;
|
||||||
|
worktrees_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn prune_worktrees(path: String) -> Result<Vec<GitWorktree>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
run_git(&repo, ["worktree", "prune"])?;
|
||||||
|
worktrees_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn repair_worktree(path: String, worktree_path: String) -> Result<Vec<GitWorktree>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?;
|
||||||
|
worktrees_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn create_tag(
|
pub fn create_tag(
|
||||||
path: String,
|
path: String,
|
||||||
@@ -1405,18 +1868,43 @@ pub async fn pull(
|
|||||||
path: String,
|
path: String,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
|
strategy: Option<String>,
|
||||||
|
remote: Option<String>,
|
||||||
|
branch: Option<String>,
|
||||||
) -> Result<GitStatus, String> {
|
) -> Result<GitStatus, String> {
|
||||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"];
|
let strategy = strategy.as_deref().unwrap_or("merge");
|
||||||
|
let mut pull_args = vec![OsString::from("pull")];
|
||||||
|
match strategy {
|
||||||
|
"merge" => {
|
||||||
|
pull_args.extend([OsString::from("--no-rebase"), OsString::from("--no-edit")])
|
||||||
|
}
|
||||||
|
"rebase" => pull_args.push(OsString::from("--rebase")),
|
||||||
|
"ff-only" => pull_args.push(OsString::from("--ff-only")),
|
||||||
|
_ => return Err("Unknown pull strategy.".to_string()),
|
||||||
|
}
|
||||||
|
if let Some(remote) = remote
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
{
|
||||||
|
validate_remote_name(&repo, &remote, true)?;
|
||||||
|
pull_args.push(remote.into());
|
||||||
|
if let Some(branch) = branch
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
{
|
||||||
|
pull_args.push(branch.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
let output = match (username.as_deref(), password.as_deref()) {
|
let output = match (username.as_deref(), password.as_deref()) {
|
||||||
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||||
run_git_authenticated_output(&repo, pull_args, u, p)?
|
run_git_authenticated_output(&repo, pull_args.clone(), u, p)?
|
||||||
}
|
}
|
||||||
_ => git_command()
|
_ => git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(&repo)
|
.arg(&repo)
|
||||||
.args(pull_args)
|
.args(&pull_args)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
|
||||||
};
|
};
|
||||||
@@ -1445,18 +1933,30 @@ pub async fn fetch(
|
|||||||
path: String,
|
path: String,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
|
prune: Option<bool>,
|
||||||
|
remote: Option<String>,
|
||||||
) -> Result<GitStatus, String> {
|
) -> Result<GitStatus, String> {
|
||||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
let fetch_args = ["fetch"];
|
let mut fetch_args = vec![OsString::from("fetch")];
|
||||||
|
if prune.unwrap_or(false) {
|
||||||
|
fetch_args.push(OsString::from("--prune"));
|
||||||
|
}
|
||||||
|
if let Some(remote) = remote
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
{
|
||||||
|
validate_remote_name(&repo, &remote, true)?;
|
||||||
|
fetch_args.push(remote.into());
|
||||||
|
}
|
||||||
let output = match (username.as_deref(), password.as_deref()) {
|
let output = match (username.as_deref(), password.as_deref()) {
|
||||||
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||||
run_git_authenticated_output(&repo, fetch_args, u, p)?
|
run_git_authenticated_output(&repo, fetch_args.clone(), u, p)?
|
||||||
}
|
}
|
||||||
_ => git_command()
|
_ => git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(&repo)
|
.arg(&repo)
|
||||||
.args(fetch_args)
|
.args(&fetch_args)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
|
||||||
};
|
};
|
||||||
@@ -1480,10 +1980,15 @@ pub async fn push(
|
|||||||
path: String,
|
path: String,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
|
force_with_lease: Option<bool>,
|
||||||
|
remote: Option<String>,
|
||||||
) -> Result<GitStatus, String> {
|
) -> Result<GitStatus, String> {
|
||||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
let push_args = push_args_for_repo(&repo)?;
|
let mut push_args = push_args_for_repo_to(&repo, remote.as_deref())?;
|
||||||
|
if force_with_lease.unwrap_or(false) {
|
||||||
|
push_args.insert(1, OsString::from("--force-with-lease"));
|
||||||
|
}
|
||||||
match (username.as_deref(), password.as_deref()) {
|
match (username.as_deref(), password.as_deref()) {
|
||||||
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||||
run_git_authenticated(&repo, push_args, u, p)?;
|
run_git_authenticated(&repo, push_args, u, p)?;
|
||||||
@@ -1553,6 +2058,43 @@ fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
|
|||||||
if url.is_empty() { None } else { Some(url) }
|
if url.is_empty() { None } else { Some(url) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remote_push_url_for(repo: &Path, remote: &str) -> Option<String> {
|
||||||
|
let out = git_command()
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo)
|
||||||
|
.args(["remote", "get-url", "--push", remote])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
if url.is_empty() { None } else { Some(url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_remote_url(url: &str) -> Result<String, String> {
|
||||||
|
let url = url.trim();
|
||||||
|
if url.is_empty() || url.starts_with('-') {
|
||||||
|
return Err("Remote URL must not be empty.".to_string());
|
||||||
|
}
|
||||||
|
Ok(url.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_remote_name(repo: &Path, name: &str, must_exist: bool) -> Result<String, String> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() || name.starts_with('-') || name.chars().any(char::is_whitespace) {
|
||||||
|
return Err("Invalid remote name.".to_string());
|
||||||
|
}
|
||||||
|
let exists = remote_url_for(repo, name).is_some();
|
||||||
|
if must_exist && !exists {
|
||||||
|
return Err(format!("Remote '{name}' was not found."));
|
||||||
|
}
|
||||||
|
if !must_exist && exists {
|
||||||
|
return Err(format!("Remote '{name}' already exists."));
|
||||||
|
}
|
||||||
|
Ok(name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn upstream_remote_name(repo: &Path) -> Option<String> {
|
fn upstream_remote_name(repo: &Path) -> Option<String> {
|
||||||
let branch = current_branch_name(repo).ok()?;
|
let branch = current_branch_name(repo).ok()?;
|
||||||
let out = git_command()
|
let out = git_command()
|
||||||
@@ -1605,7 +2147,25 @@ fn initial_push_remote_name(repo: &Path) -> Result<String, String> {
|
|||||||
.ok_or_else(|| "This branch has no upstream and no remote is configured.".to_string())
|
.ok_or_else(|| "This branch has no upstream and no remote is configured.".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn push_args_for_repo(repo: &Path) -> Result<Vec<OsString>, String> {
|
fn push_args_for_repo(repo: &Path) -> Result<Vec<OsString>, String> {
|
||||||
|
push_args_for_repo_to(repo, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_args_for_repo_to(
|
||||||
|
repo: &Path,
|
||||||
|
requested_remote: Option<&str>,
|
||||||
|
) -> Result<Vec<OsString>, String> {
|
||||||
|
if let Some(remote) = requested_remote.map(str::trim).filter(|v| !v.is_empty()) {
|
||||||
|
let remote = validate_remote_name(repo, remote, true)?;
|
||||||
|
let branch = current_branch_name(repo)?;
|
||||||
|
return Ok(vec![
|
||||||
|
OsString::from("push"),
|
||||||
|
OsString::from("--set-upstream"),
|
||||||
|
remote.into(),
|
||||||
|
branch.into(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
if branch_has_upstream(repo) {
|
if branch_has_upstream(repo) {
|
||||||
return Ok(vec![OsString::from("push")]);
|
return Ok(vec![OsString::from("push")]);
|
||||||
}
|
}
|
||||||
@@ -1665,7 +2225,11 @@ pub fn cred_delete(key: String) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
pub async fn merge_branch(
|
||||||
|
path: String,
|
||||||
|
branch: String,
|
||||||
|
strategy: Option<String>,
|
||||||
|
) -> Result<GitStatus, String> {
|
||||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
let branch = branch.trim();
|
let branch = branch.trim();
|
||||||
@@ -1673,10 +2237,19 @@ pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, Str
|
|||||||
return Err("Branch name must not be empty.".to_string());
|
return Err("Branch name must not be empty.".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut args = vec!["merge", "--no-edit"];
|
||||||
|
match strategy.as_deref().unwrap_or("default") {
|
||||||
|
"default" => {}
|
||||||
|
"squash" => args.push("--squash"),
|
||||||
|
"ff-only" => args.push("--ff-only"),
|
||||||
|
"no-ff" => args.push("--no-ff"),
|
||||||
|
_ => return Err("Unknown merge strategy.".to_string()),
|
||||||
|
}
|
||||||
|
args.push(branch);
|
||||||
let output = git_command()
|
let output = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(&repo)
|
.arg(&repo)
|
||||||
.args(["merge", "--no-edit", branch])
|
.args(args)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||||
|
|
||||||
@@ -1708,6 +2281,52 @@ pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, Str
|
|||||||
.map_err(|err| format!("Could not merge: {err}"))?
|
.map_err(|err| format!("Could not merge: {err}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn merge_continue(path: String) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
if !merge_in_progress(&repo) {
|
||||||
|
return Err("No merge is in progress.".to_string());
|
||||||
|
}
|
||||||
|
if has_unresolved_conflicts(&status_for_repo(&repo)?) {
|
||||||
|
return Err("Resolve all conflicts before continuing the merge.".to_string());
|
||||||
|
}
|
||||||
|
run_git(&repo, ["commit", "--no-edit"])?;
|
||||||
|
status_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn merge_abort(path: String) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
if !merge_in_progress(&repo) {
|
||||||
|
return Err("No merge is in progress.".to_string());
|
||||||
|
}
|
||||||
|
run_git(&repo, ["merge", "--abort"])?;
|
||||||
|
status_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn revert_commit(path: String, commit: String) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let commit = verify_commit(&repo, &commit)?;
|
||||||
|
let output = git_command()
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo)
|
||||||
|
.args(["revert", "--no-edit", commit.as_str()])
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||||
|
if output.status.success() {
|
||||||
|
return status_for_repo(&repo);
|
||||||
|
}
|
||||||
|
let status = status_for_repo(&repo)?;
|
||||||
|
if has_unresolved_conflicts(&status) {
|
||||||
|
return Ok(status);
|
||||||
|
}
|
||||||
|
Err(format!(
|
||||||
|
"Revert failed: {}",
|
||||||
|
command_output_details(&output)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||||
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
@@ -3114,6 +3733,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
|||||||
files,
|
files,
|
||||||
rebase_in_progress: rebase_in_progress(repo),
|
rebase_in_progress: rebase_in_progress(repo),
|
||||||
cherry_pick_in_progress: cherry_pick_in_progress(repo),
|
cherry_pick_in_progress: cherry_pick_in_progress(repo),
|
||||||
|
merge_in_progress: merge_in_progress(repo),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3125,6 +3745,10 @@ fn cherry_pick_in_progress(repo: &Path) -> bool {
|
|||||||
git_path_exists(repo, "CHERRY_PICK_HEAD")
|
git_path_exists(repo, "CHERRY_PICK_HEAD")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_in_progress(repo: &Path) -> bool {
|
||||||
|
git_path_exists(repo, "MERGE_HEAD")
|
||||||
|
}
|
||||||
|
|
||||||
fn git_path_exists(repo: &Path, name: &str) -> bool {
|
fn git_path_exists(repo: &Path, name: &str) -> bool {
|
||||||
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
|
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
|
||||||
return false;
|
return false;
|
||||||
@@ -4484,6 +5108,9 @@ fn is_auth_error(details: &str) -> bool {
|
|||||||
|| d.contains(" 401")
|
|| d.contains(" 401")
|
||||||
|| d.contains("authorization failed")
|
|| d.contains("authorization failed")
|
||||||
|| d.contains("authentication required")
|
|| d.contains("authentication required")
|
||||||
|
// Server-side message (e.g. Gitea/Forgejo: "remote: Failed to
|
||||||
|
// authenticate user"), independent of the local git locale.
|
||||||
|
|| d.contains("failed to authenticate")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Windows' CreateProcess rejects command lines longer than ~32K chars with
|
// Windows' CreateProcess rejects command lines longer than ~32K chars with
|
||||||
@@ -5626,7 +6253,14 @@ mod tests {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
let status = pull(repo.path.to_string_lossy().to_string(), None, None)
|
let status = pull(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -5699,7 +6333,13 @@ mod tests {
|
|||||||
["remote", "add", "origin", remote.path.to_str().unwrap()],
|
["remote", "add", "origin", remote.path.to_str().unwrap()],
|
||||||
);
|
);
|
||||||
|
|
||||||
let status = push(repo.path.to_string_lossy().to_string(), None, None)
|
let status = push(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -6073,6 +6713,36 @@ mod tests {
|
|||||||
"one\nTWO\nthree\nfour\n"
|
"one\nTWO\nthree\nfour\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let staged_patch = get_file_patch(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
"old.txt".to_string(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("staged patch should load");
|
||||||
|
let status = apply_file_patch(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
"old.txt".to_string(),
|
||||||
|
staged_patch,
|
||||||
|
"unstage".to_string(),
|
||||||
|
)
|
||||||
|
.expect("selected staged lines should unstage");
|
||||||
|
|
||||||
|
assert_eq!(status.files[0].staged, None);
|
||||||
|
assert_eq!(status.files[0].unstaged, Some(FileStatusKind::Modified));
|
||||||
|
assert_eq!(
|
||||||
|
git_output_test(&repo.path, ["show", ":old.txt"]),
|
||||||
|
"one\ntwo\nthree"
|
||||||
|
);
|
||||||
|
|
||||||
|
let selected_patch = "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n";
|
||||||
|
apply_file_patch(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
"old.txt".to_string(),
|
||||||
|
selected_patch.to_string(),
|
||||||
|
"stage".to_string(),
|
||||||
|
)
|
||||||
|
.expect("selected line should stage again");
|
||||||
|
|
||||||
let unstaged_patch = get_file_patch(
|
let unstaged_patch = get_file_patch(
|
||||||
repo.path.to_string_lossy().to_string(),
|
repo.path.to_string_lossy().to_string(),
|
||||||
"old.txt".to_string(),
|
"old.txt".to_string(),
|
||||||
@@ -6351,6 +7021,76 @@ mod tests {
|
|||||||
assert!(result.lines[0].is_uncommitted);
|
assert!(result.lines[0].is_uncommitted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_worktree_porcelain_preserves_flags_and_reasons() {
|
||||||
|
let current = Path::new("/repos/main");
|
||||||
|
let output = b"worktree /repos/main\0HEAD 1234567890abcdef\0branch refs/heads/main\0\0worktree /repos/feature\0HEAD abcdef1234567890\0detached\0locked external drive\0prunable gitdir file points to non-existent location\0\0";
|
||||||
|
|
||||||
|
let rows = parse_worktree_porcelain(output, current);
|
||||||
|
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert!(rows[0].is_main);
|
||||||
|
assert_eq!(rows[0].branch.as_deref(), Some("main"));
|
||||||
|
assert_eq!(rows[0].short_head.as_deref(), Some("12345678"));
|
||||||
|
assert!(rows[1].detached);
|
||||||
|
assert!(rows[1].locked);
|
||||||
|
assert_eq!(rows[1].lock_reason.as_deref(), Some("external drive"));
|
||||||
|
assert!(rows[1].prunable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worktree_add_list_and_remove_round_trip() {
|
||||||
|
let repo = init_temp_repo("worktree_round_trip");
|
||||||
|
let destination = temp_dir("worktree_round_trip_destination");
|
||||||
|
commit_initial_file(&repo.path);
|
||||||
|
run_git_test(&repo.path, ["branch", "feature"]);
|
||||||
|
|
||||||
|
let rows = add_worktree(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
destination.path.to_string_lossy().to_string(),
|
||||||
|
Some("feature".to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(false),
|
||||||
|
Some(false),
|
||||||
|
)
|
||||||
|
.expect("worktree should be created");
|
||||||
|
|
||||||
|
let linked = rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.branch.as_deref() == Some("feature"))
|
||||||
|
.expect("linked worktree should be listed");
|
||||||
|
assert!(!linked.is_main);
|
||||||
|
assert!(linked.clean);
|
||||||
|
|
||||||
|
let rows = lock_worktree(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
destination.path.to_string_lossy().to_string(),
|
||||||
|
Some("test lock".to_string()),
|
||||||
|
)
|
||||||
|
.expect("worktree should lock");
|
||||||
|
let linked = rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.branch.as_deref() == Some("feature"))
|
||||||
|
.expect("linked worktree should remain listed");
|
||||||
|
assert!(linked.locked);
|
||||||
|
assert_eq!(linked.lock_reason.as_deref(), Some("test lock"));
|
||||||
|
|
||||||
|
unlock_worktree(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
destination.path.to_string_lossy().to_string(),
|
||||||
|
)
|
||||||
|
.expect("worktree should unlock");
|
||||||
|
|
||||||
|
let rows = remove_worktree(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
destination.path.to_string_lossy().to_string(),
|
||||||
|
Some(false),
|
||||||
|
)
|
||||||
|
.expect("worktree should be removed");
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_ai_review_accepts_fenced_json_and_normalizes_findings() {
|
fn parse_ai_review_accepts_fenced_json_and_normalizes_findings() {
|
||||||
let raw = r#"```json
|
let raw = r#"```json
|
||||||
|
|||||||
+73
-15
@@ -2,26 +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_review, commit_ai_status, compare_commits,
|
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits,
|
||||||
compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete,
|
compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete,
|
||||||
cred_load, cred_save, delete_branch, delete_tag, diff_file_against_working_tree, fetch,
|
cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
|
||||||
get_file_blame, get_file_patch, get_remote_url, get_status, last_commit_message, list_branches,
|
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
|
||||||
list_commits, list_file_history, list_interactive_rebase_commits, list_reflog,
|
get_status, init_repository, last_commit_message, list_branches, list_commits,
|
||||||
list_repository_files, list_stashes, list_tags, merge_branch, open_repo_in_explorer,
|
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
|
||||||
open_repository, open_repository_bundle, open_repository_file, pull, push, push_tag,
|
list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort,
|
||||||
read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch, resolve_conflict,
|
merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository,
|
||||||
resolve_conflict_side, restore_file_from_commit, restore_files, restore_reflog_entry,
|
open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag,
|
||||||
restore_to_commit, run_sequence_editor_if_requested, search_code_introductions, stage_files,
|
read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree,
|
||||||
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit,
|
rename_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
|
||||||
unstage_files,
|
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
|
||||||
|
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
||||||
|
set_branch_upstream, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop,
|
||||||
|
stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote,
|
||||||
};
|
};
|
||||||
use tauri::Manager;
|
use 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> {
|
||||||
@@ -45,6 +80,8 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
init_console_logging();
|
||||||
|
telemetry::init();
|
||||||
if let Some(result) = run_sequence_editor_if_requested() {
|
if let Some(result) = run_sequence_editor_if_requested() {
|
||||||
if let Err(error) = result {
|
if let Err(error) = result {
|
||||||
eprintln!("{error}");
|
eprintln!("{error}");
|
||||||
@@ -82,16 +119,31 @@ async fn main() {
|
|||||||
builder
|
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,
|
||||||
@@ -124,6 +176,9 @@ 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,
|
||||||
@@ -150,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.19",
|
"version": "2026.7.20",
|
||||||
"identifier": "com.gitty",
|
"identifier": "com.gitty",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+456
-43
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount, tick } from "svelte";
|
import { onDestroy, onMount, tick } from "svelte";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||||
import { AlertCircle, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
|
import { AlertCircle, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
|
||||||
@@ -36,10 +35,14 @@
|
|||||||
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
|
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
|
||||||
import StashPanel from "./lib/components/StashPanel.svelte";
|
import StashPanel from "./lib/components/StashPanel.svelte";
|
||||||
import StatusPanel from "./lib/components/StatusPanel.svelte";
|
import StatusPanel from "./lib/components/StatusPanel.svelte";
|
||||||
|
import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte";
|
||||||
import UpdateToast from "./lib/components/UpdateToast.svelte";
|
import UpdateToast from "./lib/components/UpdateToast.svelte";
|
||||||
|
import WorktreeDialog from "./lib/components/WorktreeDialog.svelte";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
amendCommit,
|
amendCommit,
|
||||||
|
addRemote,
|
||||||
|
addWorktree,
|
||||||
checkoutBranch,
|
checkoutBranch,
|
||||||
cherryPickAbort,
|
cherryPickAbort,
|
||||||
cherryPickCommit,
|
cherryPickCommit,
|
||||||
@@ -59,6 +62,8 @@
|
|||||||
createTag,
|
createTag,
|
||||||
deleteBranch,
|
deleteBranch,
|
||||||
deleteTag,
|
deleteTag,
|
||||||
|
deleteRemoteBranch,
|
||||||
|
initRepository,
|
||||||
diffFileAgainstWorkingTree,
|
diffFileAgainstWorkingTree,
|
||||||
compareFileToParent,
|
compareFileToParent,
|
||||||
fetchRemote,
|
fetchRemote,
|
||||||
@@ -66,20 +71,33 @@
|
|||||||
getStatus,
|
getStatus,
|
||||||
lastCommitMessage,
|
lastCommitMessage,
|
||||||
listBranches,
|
listBranches,
|
||||||
|
listRemotes,
|
||||||
listStashes,
|
listStashes,
|
||||||
listTags,
|
listTags,
|
||||||
|
listWorktrees,
|
||||||
listCommits,
|
listCommits,
|
||||||
listFileHistory,
|
listFileHistory,
|
||||||
listInteractiveRebaseCommits,
|
listInteractiveRebaseCommits,
|
||||||
listReflog,
|
listReflog,
|
||||||
listRepositoryFiles,
|
listRepositoryFiles,
|
||||||
mergeBranch,
|
mergeBranch,
|
||||||
|
mergeAbort,
|
||||||
|
mergeContinue,
|
||||||
openRepoInExplorer,
|
openRepoInExplorer,
|
||||||
openRepositoryFile,
|
openRepositoryFile,
|
||||||
openRepositoryBundle,
|
openRepositoryBundle,
|
||||||
|
lockWorktree,
|
||||||
|
moveWorktree,
|
||||||
|
pruneWorktrees,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
pushTag,
|
pushTag,
|
||||||
|
removeRemote,
|
||||||
|
removeWorktree,
|
||||||
|
repairWorktree,
|
||||||
|
revertCommit,
|
||||||
|
setBranchUpstream,
|
||||||
|
updateRemote,
|
||||||
renameBranch,
|
renameBranch,
|
||||||
rebaseAbort,
|
rebaseAbort,
|
||||||
rebaseBranch,
|
rebaseBranch,
|
||||||
@@ -105,6 +123,7 @@
|
|||||||
stashPop,
|
stashPop,
|
||||||
stashPush,
|
stashPush,
|
||||||
undoLastCommit,
|
undoLastCommit,
|
||||||
|
unlockWorktree,
|
||||||
unstageFiles,
|
unstageFiles,
|
||||||
} from "./lib/git";
|
} from "./lib/git";
|
||||||
|
|
||||||
@@ -126,10 +145,13 @@
|
|||||||
GitDiffFile,
|
GitDiffFile,
|
||||||
GitFileStatus,
|
GitFileStatus,
|
||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
|
GitRemote,
|
||||||
|
PullStrategy,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStash,
|
GitStash,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
GitTag,
|
GitTag,
|
||||||
|
GitWorktree,
|
||||||
LocalModelOption,
|
LocalModelOption,
|
||||||
PatchApplyAction,
|
PatchApplyAction,
|
||||||
PreparedResolution,
|
PreparedResolution,
|
||||||
@@ -144,8 +166,10 @@
|
|||||||
isCredentialExpired,
|
isCredentialExpired,
|
||||||
isAuthError,
|
isAuthError,
|
||||||
stripAuthPrefix,
|
stripAuthPrefix,
|
||||||
|
summarizeGitError,
|
||||||
} from "./lib/credentials";
|
} from "./lib/credentials";
|
||||||
import { trackAnalyticsEvent, type AnalyticsEventProperties } from "./lib/analytics";
|
import { trackAnalyticsEvent, type AnalyticsEventProperties } from "./lib/analytics";
|
||||||
|
import { setTelemetryEnabled, tracedInvoke } from "./lib/telemetry";
|
||||||
|
|
||||||
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
||||||
type AppView = "management" | "repository";
|
type AppView = "management" | "repository";
|
||||||
@@ -153,7 +177,7 @@
|
|||||||
type PendingDiscard =
|
type PendingDiscard =
|
||||||
| { kind: "file"; files: GitFileStatus[]; staged: boolean }
|
| { kind: "file"; files: GitFileStatus[]; staged: boolean }
|
||||||
| { kind: "all-changes"; files: GitFileStatus[] }
|
| { kind: "all-changes"; files: GitFileStatus[] }
|
||||||
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
|
| { kind: "patch"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string; scope: "hunk" | "lines" };
|
||||||
|
|
||||||
interface RepoTab {
|
interface RepoTab {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -177,6 +201,14 @@
|
|||||||
directoryName: string;
|
directoryName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ErrorAutoHideState {
|
||||||
|
timer?: ReturnType<typeof setTimeout>;
|
||||||
|
message: string;
|
||||||
|
remaining: number;
|
||||||
|
startedAt: number;
|
||||||
|
clearIfCurrent: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
|
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
|
||||||
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
|
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
|
||||||
const FAVORITE_REPOS_KEY = "gitlite.favoriteRepos.v1";
|
const FAVORITE_REPOS_KEY = "gitlite.favoriteRepos.v1";
|
||||||
@@ -230,6 +262,12 @@
|
|||||||
let repoStatusCache: Record<string, RepoTab> = {};
|
let repoStatusCache: Record<string, RepoTab> = {};
|
||||||
let repoSearch = "";
|
let repoSearch = "";
|
||||||
let cloneDialogOpen = false;
|
let cloneDialogOpen = false;
|
||||||
|
let pullStrategy: PullStrategy = (localStorage.getItem("gitlite.pullStrategy") as PullStrategy) || "merge";
|
||||||
|
let selectedRemote = localStorage.getItem("gitlite.selectedRemote") || "";
|
||||||
|
let remoteActionForceWithLease = false;
|
||||||
|
let remoteActionPrune = false;
|
||||||
|
let syncSettingsOpen = false;
|
||||||
|
let syncSettingsRemotes: GitRemote[] = [];
|
||||||
let cloneDialogError = "";
|
let cloneDialogError = "";
|
||||||
let cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined;
|
let cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let pendingClone: CloneRequest | null = null;
|
let pendingClone: CloneRequest | null = null;
|
||||||
@@ -276,6 +314,11 @@
|
|||||||
let renameBranchTarget: GitBranchInfo | null = null;
|
let renameBranchTarget: GitBranchInfo | null = null;
|
||||||
let deleteBranchTarget: GitBranchInfo | null = null;
|
let deleteBranchTarget: GitBranchInfo | null = null;
|
||||||
let deleteBranchForce = false;
|
let deleteBranchForce = false;
|
||||||
|
let worktreeDialogOpen = false;
|
||||||
|
let worktreeInitialBranch = "";
|
||||||
|
let worktrees: GitWorktree[] = [];
|
||||||
|
let worktreesLoading = false;
|
||||||
|
let worktreeError = "";
|
||||||
let compareSelectOpen = false;
|
let compareSelectOpen = false;
|
||||||
let compareDialogOpen = false;
|
let compareDialogOpen = false;
|
||||||
let interactiveRebaseOpen = false;
|
let interactiveRebaseOpen = false;
|
||||||
@@ -344,7 +387,7 @@
|
|||||||
let updateCheckInFlight = false;
|
let updateCheckInFlight = false;
|
||||||
let updateDownloadTotal = 0;
|
let updateDownloadTotal = 0;
|
||||||
let updateDownloadedBytes = 0;
|
let updateDownloadedBytes = 0;
|
||||||
let errorAutoHideTimers: Partial<Record<string, ReturnType<typeof setTimeout>>> = {};
|
let errorAutoHideStates: Partial<Record<string, ErrorAutoHideState>> = {};
|
||||||
let commitPanelHeight = loadCommitPanelHeight();
|
let commitPanelHeight = loadCommitPanelHeight();
|
||||||
let resizingCommitPanel = false;
|
let resizingCommitPanel = false;
|
||||||
let resizeStartY = 0;
|
let resizeStartY = 0;
|
||||||
@@ -392,6 +435,7 @@
|
|||||||
$: hasConflicts = conflictedFiles.length > 0;
|
$: hasConflicts = conflictedFiles.length > 0;
|
||||||
$: rebaseInProgress = status?.rebase_in_progress ?? false;
|
$: rebaseInProgress = status?.rebase_in_progress ?? false;
|
||||||
$: cherryPickInProgress = status?.cherry_pick_in_progress ?? false;
|
$: cherryPickInProgress = status?.cherry_pick_in_progress ?? false;
|
||||||
|
$: mergeInProgress = status?.merge_in_progress ?? false;
|
||||||
// Amending/undoing is only offered while the last commit hasn't reached a
|
// Amending/undoing is only offered while the last commit hasn't reached a
|
||||||
// remote yet: no upstream at all, or the branch is still ahead of it.
|
// remote yet: no upstream at all, or the branch is still ahead of it.
|
||||||
$: canAmend = hasRepository && commits.length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress
|
$: canAmend = hasRepository && commits.length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress
|
||||||
@@ -446,8 +490,8 @@
|
|||||||
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
|
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
|
||||||
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
|
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
|
||||||
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
|
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
|
||||||
Object.values(errorAutoHideTimers).forEach((timer) => {
|
Object.values(errorAutoHideStates).forEach((state) => {
|
||||||
if (timer) clearTimeout(timer);
|
if (state?.timer) clearTimeout(state.timer);
|
||||||
});
|
});
|
||||||
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
||||||
});
|
});
|
||||||
@@ -482,7 +526,7 @@
|
|||||||
|
|
||||||
async function closeStartupSplashscreen() {
|
async function closeStartupSplashscreen() {
|
||||||
try {
|
try {
|
||||||
await invoke("close_splashscreen");
|
await tracedInvoke("close_splashscreen");
|
||||||
} catch {
|
} catch {
|
||||||
// Browser preview and failed startup paths should keep working without Tauri.
|
// Browser preview and failed startup paths should keep working without Tauri.
|
||||||
}
|
}
|
||||||
@@ -690,7 +734,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function autoRefreshTick() {
|
async function autoRefreshTick() {
|
||||||
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || newBranchCommit || globalSearchOpen || helpOpen) return;
|
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || globalSearchOpen || helpOpen) return;
|
||||||
const path = activeRepoPath;
|
const path = activeRepoPath;
|
||||||
autoRefreshInFlight = true;
|
autoRefreshInFlight = true;
|
||||||
try {
|
try {
|
||||||
@@ -794,6 +838,7 @@
|
|||||||
|
|
||||||
function initAnalytics() {
|
function initAnalytics() {
|
||||||
analyticsSettings = loadAnalyticsSettings();
|
analyticsSettings = loadAnalyticsSettings();
|
||||||
|
setTelemetryEnabled(analyticsSettings.noticeSeen && analyticsSettings.enabled);
|
||||||
if (!analyticsSettings.noticeSeen) {
|
if (!analyticsSettings.noticeSeen) {
|
||||||
analyticsNoticeOpen = true;
|
analyticsNoticeOpen = true;
|
||||||
return;
|
return;
|
||||||
@@ -810,6 +855,7 @@
|
|||||||
analyticsSettings = { enabled, noticeSeen: true };
|
analyticsSettings = { enabled, noticeSeen: true };
|
||||||
persistAnalyticsSettings(analyticsSettings);
|
persistAnalyticsSettings(analyticsSettings);
|
||||||
analyticsNoticeOpen = false;
|
analyticsNoticeOpen = false;
|
||||||
|
setTelemetryEnabled(enabled);
|
||||||
trackEvent("app_started", { first_run: 1 });
|
trackEvent("app_started", { first_run: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,6 +925,7 @@
|
|||||||
persistThemePreference(nextTheme);
|
persistThemePreference(nextTheme);
|
||||||
persistLanguagePreference(nextLanguage);
|
persistLanguagePreference(nextLanguage);
|
||||||
persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh);
|
persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh);
|
||||||
|
setTelemetryEnabled(next.enabled);
|
||||||
appSettingsOpen = false;
|
appSettingsOpen = false;
|
||||||
if (nextAutoRefresh && !autoRefreshWasEnabled) void autoRefreshTick();
|
if (nextAutoRefresh && !autoRefreshWasEnabled) void autoRefreshTick();
|
||||||
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme, language: nextLanguage, auto_refresh: nextAutoRefresh ? 1 : 0 });
|
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme, language: nextLanguage, auto_refresh: nextAutoRefresh ? 1 : 0 });
|
||||||
@@ -1086,18 +1133,39 @@
|
|||||||
message: string,
|
message: string,
|
||||||
clearIfCurrent: (message: string) => void,
|
clearIfCurrent: (message: string) => void,
|
||||||
) {
|
) {
|
||||||
const existing = errorAutoHideTimers[key];
|
const existing = errorAutoHideStates[key];
|
||||||
if (existing) {
|
if (existing?.timer) {
|
||||||
clearTimeout(existing);
|
clearTimeout(existing.timer);
|
||||||
delete errorAutoHideTimers[key];
|
|
||||||
}
|
}
|
||||||
|
delete errorAutoHideStates[key];
|
||||||
|
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
|
|
||||||
errorAutoHideTimers[key] = setTimeout(() => {
|
errorAutoHideStates[key] = {
|
||||||
clearIfCurrent(message);
|
message,
|
||||||
delete errorAutoHideTimers[key];
|
remaining: ERROR_AUTO_HIDE_MS,
|
||||||
}, ERROR_AUTO_HIDE_MS);
|
startedAt: Date.now(),
|
||||||
|
clearIfCurrent,
|
||||||
|
};
|
||||||
|
resumeAutoHideError(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseAutoHideError(key: string) {
|
||||||
|
const state = errorAutoHideStates[key];
|
||||||
|
if (!state?.timer) return;
|
||||||
|
clearTimeout(state.timer);
|
||||||
|
state.timer = undefined;
|
||||||
|
state.remaining = Math.max(0, state.remaining - (Date.now() - state.startedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumeAutoHideError(key: string) {
|
||||||
|
const state = errorAutoHideStates[key];
|
||||||
|
if (!state || state.timer) return;
|
||||||
|
state.startedAt = Date.now();
|
||||||
|
state.timer = setTimeout(() => {
|
||||||
|
state.clearIfCurrent(state.message);
|
||||||
|
delete errorAutoHideStates[key];
|
||||||
|
}, state.remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
function repoKey(path: string): string {
|
function repoKey(path: string): string {
|
||||||
@@ -1707,6 +1775,10 @@
|
|||||||
globalSearchResults = [];
|
globalSearchResults = [];
|
||||||
deleteBranchTarget = null;
|
deleteBranchTarget = null;
|
||||||
deleteBranchForce = false;
|
deleteBranchForce = false;
|
||||||
|
worktreeDialogOpen = false;
|
||||||
|
worktreeInitialBranch = "";
|
||||||
|
worktrees = [];
|
||||||
|
worktreeError = "";
|
||||||
globalSearchOpen = false;
|
globalSearchOpen = false;
|
||||||
globalSearchError = "";
|
globalSearchError = "";
|
||||||
resolveDialogOpen = false;
|
resolveDialogOpen = false;
|
||||||
@@ -1725,9 +1797,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function errorToMessage(error: unknown): string {
|
function errorToMessage(error: unknown): string {
|
||||||
if (error instanceof Error) return error.message;
|
let message: string;
|
||||||
if (typeof error === "string") return error;
|
if (error instanceof Error) message = error.message;
|
||||||
try { return JSON.stringify(error) ?? "Unknown error"; } catch { return "Unknown error"; }
|
else if (typeof error === "string") message = error;
|
||||||
|
else try { message = JSON.stringify(error) ?? "Unknown error"; } catch { message = "Unknown error"; }
|
||||||
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNonFastForwardPushError(message: string): boolean {
|
function isNonFastForwardPushError(message: string): boolean {
|
||||||
@@ -1997,7 +2071,10 @@
|
|||||||
setCloneDialogError("");
|
setCloneDialogError("");
|
||||||
if (fromStore) {
|
if (fromStore) {
|
||||||
if (credentialKey) void credDelete(credentialKey).catch(() => {});
|
if (credentialKey) void credDelete(credentialKey).catch(() => {});
|
||||||
credDialogError = "Credentials were rejected or have expired. Please sign in again.";
|
const detail = summarizeGitError(message);
|
||||||
|
credDialogError = detail
|
||||||
|
? `${detail} — please sign in again.`
|
||||||
|
: "Credentials were rejected or have expired. Please sign in again.";
|
||||||
} else {
|
} else {
|
||||||
credDialogError = message || "Sign-in is required to clone this repository.";
|
credDialogError = message || "Sign-in is required to clone this repository.";
|
||||||
}
|
}
|
||||||
@@ -2256,7 +2333,27 @@
|
|||||||
|
|
||||||
async function confirmDeleteBranch() {
|
async function confirmDeleteBranch() {
|
||||||
const branch = deleteBranchTarget;
|
const branch = deleteBranchTarget;
|
||||||
if (!activeRepoPath || !branch || branch.remote || branch.current || isBusy) return;
|
if (!activeRepoPath || !branch || branch.current || isBusy) return;
|
||||||
|
|
||||||
|
if (branch.remote) {
|
||||||
|
const slash = branch.name.indexOf("/");
|
||||||
|
if (slash < 1) { errorMessage = "Could not determine remote name."; return; }
|
||||||
|
const remote = branch.name.slice(0, slash);
|
||||||
|
const remoteBranch = branch.name.slice(slash + 1);
|
||||||
|
operation = `Deleting ${branch.name} from remote`;
|
||||||
|
errorMessage = "";
|
||||||
|
try {
|
||||||
|
applyStatus(await deleteRemoteBranch(activeRepoPath, remote, remoteBranch));
|
||||||
|
deleteBranchTarget = null;
|
||||||
|
await refreshRefsAndCommitGraph(activeRepoPath);
|
||||||
|
trackEvent("remote_branch_deleted");
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = errorToMessage(error);
|
||||||
|
} finally {
|
||||||
|
operation = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
operation = `${deleteBranchForce ? "Force deleting" : "Deleting"} ${branch.name}`;
|
operation = `${deleteBranchForce ? "Force deleting" : "Deleting"} ${branch.name}`;
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
@@ -2291,6 +2388,146 @@
|
|||||||
deleteBranchForce = false;
|
deleteBranchForce = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openWorktreeDialog(branch = "") {
|
||||||
|
if (!activeRepoPath || isBusy) return;
|
||||||
|
worktreeInitialBranch = branch;
|
||||||
|
worktreeDialogOpen = true;
|
||||||
|
worktreeError = "";
|
||||||
|
worktreesLoading = true;
|
||||||
|
try {
|
||||||
|
worktrees = await listWorktrees(activeRepoPath);
|
||||||
|
trackEvent("worktree_dialog_opened", { linked_worktrees: Math.max(0, worktrees.length - 1) });
|
||||||
|
} catch (error) {
|
||||||
|
worktreeError = errorToMessage(error);
|
||||||
|
} finally {
|
||||||
|
worktreesLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBranchInWorktree(branch: GitBranchInfo) {
|
||||||
|
if (branch.remote) return;
|
||||||
|
void openWorktreeDialog(branch.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshWorktrees() {
|
||||||
|
if (!activeRepoPath || worktreesLoading) return;
|
||||||
|
worktreesLoading = true;
|
||||||
|
worktreeError = "";
|
||||||
|
try {
|
||||||
|
worktrees = await listWorktrees(activeRepoPath);
|
||||||
|
} catch (error) {
|
||||||
|
worktreeError = errorToMessage(error);
|
||||||
|
} finally {
|
||||||
|
worktreesLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWorktreeOperation(
|
||||||
|
label: string,
|
||||||
|
task: () => Promise<GitWorktree[]>,
|
||||||
|
eventName: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!activeRepoPath || isBusy) return false;
|
||||||
|
operation = label;
|
||||||
|
worktreeError = "";
|
||||||
|
try {
|
||||||
|
worktrees = await task();
|
||||||
|
await refreshBranchList(activeRepoPath);
|
||||||
|
trackEvent(eventName, { linked_worktrees: Math.max(0, worktrees.length - 1) });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
worktreeError = errorToMessage(error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
operation = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorktree(request: {
|
||||||
|
worktreePath: string;
|
||||||
|
branch?: string;
|
||||||
|
newBranch?: string;
|
||||||
|
startPoint?: string;
|
||||||
|
detached?: boolean;
|
||||||
|
lock?: boolean;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
return runWorktreeOperation(
|
||||||
|
"Creating worktree",
|
||||||
|
() => addWorktree(activeRepoPath, request.worktreePath, request),
|
||||||
|
"worktree_created",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorktreeTab(worktree: GitWorktree) {
|
||||||
|
if (worktree.missing || worktree.bare || isBusy) return;
|
||||||
|
worktreeDialogOpen = false;
|
||||||
|
worktreeInitialBranch = "";
|
||||||
|
await openRepo(worktree.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSelectedWorktree(worktree: GitWorktree, force: boolean): Promise<boolean> {
|
||||||
|
if (repoTabs.some((tab) => sameRepoPath(tab.path, worktree.path))) {
|
||||||
|
worktreeError = "Close this worktree's repository tab before removing it.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return runWorktreeOperation(
|
||||||
|
`Removing ${worktree.branch || "worktree"}`,
|
||||||
|
() => removeWorktree(activeRepoPath, worktree.path, force),
|
||||||
|
"worktree_removed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveSelectedWorktree(worktree: GitWorktree, destination: string) {
|
||||||
|
if (repoTabs.some((tab) => sameRepoPath(tab.path, worktree.path))) {
|
||||||
|
worktreeError = "Close this worktree's repository tab before moving it.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runWorktreeOperation(
|
||||||
|
`Moving ${worktree.branch || "worktree"}`,
|
||||||
|
() => moveWorktree(activeRepoPath, worktree.path, destination),
|
||||||
|
"worktree_moved",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockSelectedWorktree(worktree: GitWorktree, reason: string): Promise<boolean> {
|
||||||
|
return runWorktreeOperation(
|
||||||
|
`Locking ${worktree.branch || "worktree"}`,
|
||||||
|
() => lockWorktree(activeRepoPath, worktree.path, reason),
|
||||||
|
"worktree_locked",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlockSelectedWorktree(worktree: GitWorktree) {
|
||||||
|
await runWorktreeOperation(
|
||||||
|
`Unlocking ${worktree.branch || "worktree"}`,
|
||||||
|
() => unlockWorktree(activeRepoPath, worktree.path),
|
||||||
|
"worktree_unlocked",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneStaleWorktrees() {
|
||||||
|
await runWorktreeOperation(
|
||||||
|
"Pruning stale worktrees",
|
||||||
|
() => pruneWorktrees(activeRepoPath),
|
||||||
|
"worktrees_pruned",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function repairSelectedWorktree(worktree: GitWorktree, location: string) {
|
||||||
|
await runWorktreeOperation(
|
||||||
|
`Repairing ${worktree.branch || "worktree"}`,
|
||||||
|
() => repairWorktree(activeRepoPath, location),
|
||||||
|
"worktree_repaired",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWorktreeDialog() {
|
||||||
|
if (isBusy) return;
|
||||||
|
worktreeDialogOpen = false;
|
||||||
|
worktreeInitialBranch = "";
|
||||||
|
worktreeError = "";
|
||||||
|
}
|
||||||
|
|
||||||
function openNewBranchDialog(commit: GitCommit) {
|
function openNewBranchDialog(commit: GitCommit) {
|
||||||
if (!activeRepoPath || isBusy) return;
|
if (!activeRepoPath || isBusy) return;
|
||||||
newBranchCommit = commit;
|
newBranchCommit = commit;
|
||||||
@@ -2314,8 +2551,11 @@
|
|||||||
|
|
||||||
async function merge(branch: GitBranchInfo) {
|
async function merge(branch: GitBranchInfo) {
|
||||||
if (!activeRepoPath || branch.current) return;
|
if (!activeRepoPath || branch.current) return;
|
||||||
|
const strategy = (window.prompt("Merge strategy: default, squash, ff-only, or no-ff", "default") ?? "").trim();
|
||||||
|
if (!strategy) return;
|
||||||
|
if (!["default", "squash", "ff-only", "no-ff"].includes(strategy)) { errorMessage = "Unknown merge strategy."; return; }
|
||||||
await runOperation(`Merging ${branch.name}`, async () => {
|
await runOperation(`Merging ${branch.name}`, async () => {
|
||||||
applyStatus(await mergeBranch(activeRepoPath, branch.name));
|
applyStatus(await mergeBranch(activeRepoPath, branch.name, strategy as import("./lib/types").MergeStrategy));
|
||||||
await refreshBranchList(activeRepoPath);
|
await refreshBranchList(activeRepoPath);
|
||||||
await refreshCommitHistory(activeRepoPath);
|
await refreshCommitHistory(activeRepoPath);
|
||||||
await refreshExplorerFiles(activeRepoPath);
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
@@ -2596,8 +2836,10 @@
|
|||||||
if (fromStore) {
|
if (fromStore) {
|
||||||
if (auth) {
|
if (auth) {
|
||||||
if (key) void credDelete(key).catch(() => {});
|
if (key) void credDelete(key).catch(() => {});
|
||||||
credDialogError =
|
const detail = summarizeGitError(message);
|
||||||
"Credentials were rejected or have expired. Please sign in again.";
|
credDialogError = detail
|
||||||
|
? `${detail} — please sign in again.`
|
||||||
|
: "Credentials were rejected or have expired. Please sign in again.";
|
||||||
credDialogAction = action;
|
credDialogAction = action;
|
||||||
credDialogKey = key;
|
credDialogKey = key;
|
||||||
credDialogOpen = true;
|
credDialogOpen = true;
|
||||||
@@ -2618,7 +2860,7 @@
|
|||||||
) {
|
) {
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
await runOperation("Pulling", async () => {
|
await runOperation("Pulling", async () => {
|
||||||
applyStatus(await pull(activeRepoPath, username, password));
|
applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
|
||||||
await refreshBranchList(activeRepoPath);
|
await refreshBranchList(activeRepoPath);
|
||||||
await refreshCommitHistory(activeRepoPath);
|
await refreshCommitHistory(activeRepoPath);
|
||||||
await refreshExplorerFiles(activeRepoPath);
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
@@ -2639,7 +2881,8 @@
|
|||||||
) {
|
) {
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
await runOperation("Fetching", async () => {
|
await runOperation("Fetching", async () => {
|
||||||
applyStatus(await fetchRemote(activeRepoPath, username, password));
|
applyStatus(await fetchRemote(activeRepoPath, username, password, remoteActionPrune, selectedRemote || undefined));
|
||||||
|
remoteActionPrune = false;
|
||||||
await refreshRefsAndCommitGraph(activeRepoPath);
|
await refreshRefsAndCommitGraph(activeRepoPath);
|
||||||
trackEvent("repository_fetched", {
|
trackEvent("repository_fetched", {
|
||||||
from_stored_credential: fromStore ? 1 : 0,
|
from_stored_credential: fromStore ? 1 : 0,
|
||||||
@@ -2658,7 +2901,8 @@
|
|||||||
) {
|
) {
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
await runOperation("Pushing", async () => {
|
await runOperation("Pushing", async () => {
|
||||||
applyStatus(await push(activeRepoPath, username, password));
|
applyStatus(await push(activeRepoPath, username, password, remoteActionForceWithLease, selectedRemote || undefined));
|
||||||
|
remoteActionForceWithLease = false;
|
||||||
await refreshBranchList(activeRepoPath);
|
await refreshBranchList(activeRepoPath);
|
||||||
await refreshCommitHistory(activeRepoPath);
|
await refreshCommitHistory(activeRepoPath);
|
||||||
await refreshFileHistory(activeRepoPath);
|
await refreshFileHistory(activeRepoPath);
|
||||||
@@ -2782,6 +3026,88 @@
|
|||||||
await startRemoteAction("push");
|
await startRemoteAction("push");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteTrackedRemoteBranch(branch: GitBranchInfo) {
|
||||||
|
if (!activeRepoPath || !branch.remote) return;
|
||||||
|
if (import.meta.env.DEV) console.info("[Gitty remote] remote branch delete requested", branch);
|
||||||
|
deleteBranchTarget = branch;
|
||||||
|
deleteBranchForce = false;
|
||||||
|
trackEvent("remote_branch_delete_dialog_opened");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeRepository() {
|
||||||
|
const selected = await openDialog({ directory: true, multiple: false, title: "Choose an empty or existing folder" });
|
||||||
|
if (typeof selected !== "string") return;
|
||||||
|
const branch = window.prompt("Initial branch name", "main")?.trim(); if (!branch) return;
|
||||||
|
await runOperation("Initializing repository", async () => { await initRepository(selected, branch); await openRepo(selected); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revertHistoryCommit(commit: GitCommit) {
|
||||||
|
if (!activeRepoPath || !window.confirm(`Revert commit ${commit.short_hash} (${commit.summary}) with a new commit?`)) return;
|
||||||
|
await runOperation(`Reverting ${commit.short_hash}`, async () => {
|
||||||
|
applyStatus(await revertCommit(activeRepoPath, commit.hash));
|
||||||
|
await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); await refreshFileHistory(activeRepoPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function continueMerge() {
|
||||||
|
if (!activeRepoPath) return;
|
||||||
|
await runOperation("Continuing merge", async () => { applyStatus(await mergeContinue(activeRepoPath)); await refreshCommitHistory(activeRepoPath); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abortMerge() {
|
||||||
|
if (!activeRepoPath || !window.confirm("Abort the current merge and restore the pre-merge state?")) return;
|
||||||
|
await runOperation("Aborting merge", async () => { applyStatus(await mergeAbort(activeRepoPath)); await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPruneRepo() {
|
||||||
|
remoteActionPrune = true;
|
||||||
|
await startRemoteAction("fetch");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forcePushRepo() {
|
||||||
|
if (!window.confirm("Push the current branch with --force-with-lease? This is intended for a branch whose history you rebased.")) return;
|
||||||
|
remoteActionForceWithLease = true;
|
||||||
|
await startRemoteAction("push");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSyncOptions() {
|
||||||
|
if (!activeRepoPath) return;
|
||||||
|
try {
|
||||||
|
syncSettingsRemotes = await listRemotes(activeRepoPath);
|
||||||
|
syncSettingsOpen = true;
|
||||||
|
} catch (error) { errorMessage = errorToMessage(error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSyncSettings(strategy: PullStrategy, remote: string, upstream: string) {
|
||||||
|
if (!activeRepoPath || !status?.current_branch) return;
|
||||||
|
await runOperation("Saving sync settings", async () => {
|
||||||
|
pullStrategy = strategy; selectedRemote = remote;
|
||||||
|
localStorage.setItem("gitlite.pullStrategy", strategy); localStorage.setItem("gitlite.selectedRemote", remote);
|
||||||
|
applyStatus(await setBranchUpstream(activeRepoPath, status!.current_branch!, upstream || undefined));
|
||||||
|
await refreshBranchList(activeRepoPath); syncSettingsOpen = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await addRemote(activeRepoPath, name, url); await refreshBranchList(activeRepoPath); }
|
||||||
|
async function updateSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await updateRemote(activeRepoPath, name, url); }
|
||||||
|
async function removeSyncRemote(name: string) {
|
||||||
|
if (!activeRepoPath) return;
|
||||||
|
operation = `Removing remote ${name}`;
|
||||||
|
try {
|
||||||
|
syncSettingsRemotes = await removeRemote(activeRepoPath, name);
|
||||||
|
if (syncSettingsRemotes.some((remote) => remote.name === name)) throw new Error(`Remote '${name}' still exists after removal.`);
|
||||||
|
if (selectedRemote === name) { selectedRemote = ""; localStorage.setItem("gitlite.selectedRemote", ""); }
|
||||||
|
applyStatus(await getStatus(activeRepoPath));
|
||||||
|
await refreshBranchList(activeRepoPath);
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorToMessage(error);
|
||||||
|
if (import.meta.env.DEV) console.error("[Gitty remote] remove_remote failed", { name, path: activeRepoPath, error });
|
||||||
|
throw new Error(message);
|
||||||
|
} finally {
|
||||||
|
operation = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveStash(message: string, includeUntracked: boolean) {
|
async function saveStash(message: string, includeUntracked: boolean) {
|
||||||
if (!activeRepoPath || changedFiles.length === 0) return;
|
if (!activeRepoPath || changedFiles.length === 0) return;
|
||||||
const stashedFiles = changedFiles.length;
|
const stashedFiles = changedFiles.length;
|
||||||
@@ -2977,14 +3303,15 @@
|
|||||||
blameError = "";
|
blameError = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchOperationLabel(action: PatchApplyAction, file: GitFileStatus): string {
|
function patchOperationLabel(action: PatchApplyAction, file: GitFileStatus, scope: "hunk" | "lines"): string {
|
||||||
|
const target = scope === "lines" ? "selected lines" : "hunk";
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "stage":
|
case "stage":
|
||||||
return `Staging hunk in ${file.path}`;
|
return `Staging ${target} in ${file.path}`;
|
||||||
case "unstage":
|
case "unstage":
|
||||||
return `Unstaging hunk in ${file.path}`;
|
return `Unstaging ${target} in ${file.path}`;
|
||||||
default:
|
default:
|
||||||
return `Discarding hunk in ${file.path}`;
|
return `Discarding ${target} in ${file.path}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2997,9 +3324,10 @@
|
|||||||
patch: string,
|
patch: string,
|
||||||
file: GitFileStatus,
|
file: GitFileStatus,
|
||||||
staged: boolean,
|
staged: boolean,
|
||||||
|
scope: "hunk" | "lines",
|
||||||
) {
|
) {
|
||||||
if (!activeRepoPath || isBusy) return;
|
if (!activeRepoPath || isBusy) return;
|
||||||
operation = patchOperationLabel(action, file);
|
operation = patchOperationLabel(action, file, scope);
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
linePatchError = "";
|
linePatchError = "";
|
||||||
|
|
||||||
@@ -3027,21 +3355,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyLinePatch(action: PatchApplyAction, patch: string) {
|
async function applyLinePatch(action: PatchApplyAction, patch: string, scope: "hunk" | "lines") {
|
||||||
if (!activeRepoPath || !linePatchFile || isBusy) return;
|
if (!activeRepoPath || !linePatchFile || isBusy) return;
|
||||||
const file = linePatchFile;
|
const file = linePatchFile;
|
||||||
const staged = linePatchStaged;
|
const staged = linePatchStaged;
|
||||||
|
|
||||||
if (isDiscardPatchAction(action)) {
|
if (isDiscardPatchAction(action)) {
|
||||||
pendingDiscard = { kind: "hunk", file, staged, action, patch };
|
pendingDiscard = { kind: "patch", file, staged, action, patch, scope };
|
||||||
trackEvent("discard_confirm_opened", {
|
trackEvent("discard_confirm_opened", {
|
||||||
kind: "hunk",
|
kind: scope,
|
||||||
staged: staged ? 1 : 0,
|
staged: staged ? 1 : 0,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await runLinePatchAction(action, patch, file, staged);
|
await runLinePatchAction(action, patch, file, staged, scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDiscard() {
|
async function confirmDiscard() {
|
||||||
@@ -3053,7 +3381,7 @@
|
|||||||
} else if (discard.kind === "all-changes") {
|
} else if (discard.kind === "all-changes") {
|
||||||
await runDiscardAllChanges(discard.files);
|
await runDiscardAllChanges(discard.files);
|
||||||
} else {
|
} else {
|
||||||
await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged);
|
await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged, discard.scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingDiscard = null;
|
pendingDiscard = null;
|
||||||
@@ -3563,6 +3891,7 @@
|
|||||||
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
|
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
|
||||||
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
|
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
|
||||||
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
|
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
|
||||||
|
else if (event.key === "Escape" && worktreeDialogOpen) closeWorktreeDialog();
|
||||||
else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false;
|
else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false;
|
||||||
else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false;
|
else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false;
|
||||||
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
|
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
|
||||||
@@ -3570,6 +3899,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleWindowContextMenu(event: MouseEvent) {
|
function handleWindowContextMenu(event: MouseEvent) {
|
||||||
|
if (import.meta.env.DEV) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (repoTabContextMenu) closeRepoTabContextMenu();
|
if (repoTabContextMenu) closeRepoTabContextMenu();
|
||||||
}
|
}
|
||||||
@@ -3626,6 +3956,9 @@
|
|||||||
onInteractiveRebase={openInteractiveRebase}
|
onInteractiveRebase={openInteractiveRebase}
|
||||||
onReflog={openReflog}
|
onReflog={openReflog}
|
||||||
onOpenInExplorer={openActiveRepoInExplorer}
|
onOpenInExplorer={openActiveRepoInExplorer}
|
||||||
|
onFetchPrune={fetchPruneRepo}
|
||||||
|
onForcePush={forcePushRepo}
|
||||||
|
onSyncOptions={openSyncOptions}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -3656,14 +3989,39 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Status notices -->
|
<!-- Global error toast -->
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
<section class="notice error" role="alert">
|
{#key errorMessage}
|
||||||
<AlertCircle size={17} aria-hidden="true" />
|
<div class="error-toast-region" aria-live="assertive" aria-atomic="true">
|
||||||
|
<section
|
||||||
|
class="error-toast"
|
||||||
|
role="alert"
|
||||||
|
onmouseenter={() => pauseAutoHideError("errorMessage")}
|
||||||
|
onmouseleave={() => resumeAutoHideError("errorMessage")}
|
||||||
|
>
|
||||||
|
<div class="error-toast-icon">
|
||||||
|
<AlertCircle size={20} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div class="error-toast-content">
|
||||||
|
<strong>{appLanguage === "de" ? "Etwas ist schiefgelaufen" : "Something went wrong"}</strong>
|
||||||
<span>{errorMessage}</span>
|
<span>{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="error-toast-close"
|
||||||
|
type="button"
|
||||||
|
onclick={() => { errorMessage = ""; }}
|
||||||
|
title={appLanguage === "de" ? "Schließen" : "Close"}
|
||||||
|
aria-label={appLanguage === "de" ? "Fehlermeldung schließen" : "Dismiss error"}
|
||||||
|
>
|
||||||
|
<X size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<div class="error-toast-timeout" aria-hidden="true"></div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Status notices -->
|
||||||
{#if operation && operation !== "Opening repository" && !hasRepository}
|
{#if operation && operation !== "Opening repository" && !hasRepository}
|
||||||
<section class="notice busy" aria-live="polite">
|
<section class="notice busy" aria-live="polite">
|
||||||
<LoaderCircle class="spin" size={17} aria-hidden="true" />
|
<LoaderCircle class="spin" size={17} aria-hidden="true" />
|
||||||
@@ -3671,7 +4029,15 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
|
{#if workspaceActive && mergeInProgress}
|
||||||
|
<section class="notice conflict" role="status">
|
||||||
|
<GitMerge size={17} aria-hidden="true" />
|
||||||
|
<span>{hasConflicts ? "Merge in progress. Resolve all conflicts, then continue." : "Merge is ready to be completed."}</span>
|
||||||
|
{#if hasConflicts}<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>{/if}
|
||||||
|
<button type="button" onclick={continueMerge} disabled={isBusy || hasConflicts}>Continue</button>
|
||||||
|
<button type="button" onclick={abortMerge} disabled={isBusy}>Abort</button>
|
||||||
|
</section>
|
||||||
|
{:else if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
|
||||||
<section class="notice conflict" role="alert">
|
<section class="notice conflict" role="alert">
|
||||||
<GitMerge size={17} aria-hidden="true" />
|
<GitMerge size={17} aria-hidden="true" />
|
||||||
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
|
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
|
||||||
@@ -3729,6 +4095,9 @@
|
|||||||
<Download size={15} aria-hidden="true" />
|
<Download size={15} aria-hidden="true" />
|
||||||
Clone
|
Clone
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-secondary" type="button" onclick={initializeRepository} disabled={isBusy}>
|
||||||
|
<Plus size={15} aria-hidden="true" /> Init
|
||||||
|
</button>
|
||||||
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
|
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
|
||||||
<FolderOpen size={15} aria-hidden="true" />
|
<FolderOpen size={15} aria-hidden="true" />
|
||||||
Browse
|
Browse
|
||||||
@@ -3904,9 +4273,12 @@
|
|||||||
onCreateBranch={createNewBranch}
|
onCreateBranch={createNewBranch}
|
||||||
onRenameBranch={renameLocalBranch}
|
onRenameBranch={renameLocalBranch}
|
||||||
onDeleteBranch={deleteLocalBranch}
|
onDeleteBranch={deleteLocalBranch}
|
||||||
|
onDeleteRemoteBranch={deleteTrackedRemoteBranch}
|
||||||
onCreateTag={createNewTag}
|
onCreateTag={createNewTag}
|
||||||
onDeleteTag={deleteLocalTag}
|
onDeleteTag={deleteLocalTag}
|
||||||
onPushTag={pushLocalTag}
|
onPushTag={pushLocalTag}
|
||||||
|
onManageWorktrees={() => { void openWorktreeDialog(); }}
|
||||||
|
onCreateWorktree={openBranchInWorktree}
|
||||||
collapsed={branchPanelCollapsed}
|
collapsed={branchPanelCollapsed}
|
||||||
onToggleCollapsed={toggleBranchPanelCollapsed}
|
onToggleCollapsed={toggleBranchPanelCollapsed}
|
||||||
/>
|
/>
|
||||||
@@ -4115,6 +4487,7 @@
|
|||||||
onPreviewCommitFile={previewCommitFileFromHistory}
|
onPreviewCommitFile={previewCommitFileFromHistory}
|
||||||
onCreateBranchFromCommit={openNewBranchDialog}
|
onCreateBranchFromCommit={openNewBranchDialog}
|
||||||
onCherryPickCommit={cherryPickFromCommit}
|
onCherryPickCommit={cherryPickFromCommit}
|
||||||
|
onRevertCommit={revertHistoryCommit}
|
||||||
onToggleCommitFiles={(hash) => {
|
onToggleCommitFiles={(hash) => {
|
||||||
const next = new Set(expandedCommitHashes);
|
const next = new Set(expandedCommitHashes);
|
||||||
if (next.has(hash)) next.delete(hash); else next.add(hash);
|
if (next.has(hash)) next.delete(hash); else next.add(hash);
|
||||||
@@ -4242,9 +4615,9 @@
|
|||||||
|
|
||||||
{#if pendingDiscard}
|
{#if pendingDiscard}
|
||||||
<DiscardConfirmDialog
|
<DiscardConfirmDialog
|
||||||
files={pendingDiscard.kind === "hunk" ? [pendingDiscard.file] : pendingDiscard.files}
|
files={pendingDiscard.kind === "patch" ? [pendingDiscard.file] : pendingDiscard.files}
|
||||||
staged={pendingDiscard.kind === "all-changes" ? null : pendingDiscard.staged}
|
staged={pendingDiscard.kind === "all-changes" ? null : pendingDiscard.staged}
|
||||||
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
|
scope={pendingDiscard.kind === "patch" ? pendingDiscard.scope : "file"}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
onConfirm={confirmDiscard}
|
onConfirm={confirmDiscard}
|
||||||
onClose={closeDiscardConfirm}
|
onClose={closeDiscardConfirm}
|
||||||
@@ -4271,6 +4644,27 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if worktreeDialogOpen}
|
||||||
|
<WorktreeDialog
|
||||||
|
{worktrees}
|
||||||
|
{branches}
|
||||||
|
initialBranch={worktreeInitialBranch}
|
||||||
|
isLoading={worktreesLoading}
|
||||||
|
{isBusy}
|
||||||
|
error={worktreeError}
|
||||||
|
onRefresh={refreshWorktrees}
|
||||||
|
onOpen={openWorktreeTab}
|
||||||
|
onAdd={createWorktree}
|
||||||
|
onRemove={removeSelectedWorktree}
|
||||||
|
onMove={moveSelectedWorktree}
|
||||||
|
onLock={lockSelectedWorktree}
|
||||||
|
onUnlock={unlockSelectedWorktree}
|
||||||
|
onPrune={pruneStaleWorktrees}
|
||||||
|
onRepair={repairSelectedWorktree}
|
||||||
|
onClose={closeWorktreeDialog}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Create a branch from a specific commit in the history -->
|
<!-- Create a branch from a specific commit in the history -->
|
||||||
{#if newBranchCommit}
|
{#if newBranchCommit}
|
||||||
<NewBranchDialog
|
<NewBranchDialog
|
||||||
@@ -4291,7 +4685,7 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Delete a local branch from the branch context menu -->
|
<!-- Confirm deletion of a local or remote branch from the shared branch context menu -->
|
||||||
{#if deleteBranchTarget}
|
{#if deleteBranchTarget}
|
||||||
<BranchDeleteConfirmDialog
|
<BranchDeleteConfirmDialog
|
||||||
branch={deleteBranchTarget}
|
branch={deleteBranchTarget}
|
||||||
@@ -4384,6 +4778,25 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Clone repository dialog -->
|
||||||
|
{#if syncSettingsOpen}
|
||||||
|
<SyncSettingsDialog
|
||||||
|
remotes={syncSettingsRemotes}
|
||||||
|
remoteBranches={remoteBranches.map((branch) => branch.name)}
|
||||||
|
currentBranch={status?.current_branch ?? ""}
|
||||||
|
currentUpstream={status?.upstream ?? ""}
|
||||||
|
strategy={pullStrategy}
|
||||||
|
{selectedRemote}
|
||||||
|
{isBusy}
|
||||||
|
language={appLanguage}
|
||||||
|
onSaveSync={saveSyncSettings}
|
||||||
|
onAddRemote={addSyncRemote}
|
||||||
|
onUpdateRemote={updateSyncRemote}
|
||||||
|
onRemoveRemote={removeSyncRemote}
|
||||||
|
onClose={() => { if (!isBusy) syncSettingsOpen = false; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Clone repository dialog -->
|
<!-- Clone repository dialog -->
|
||||||
{#if cloneDialogOpen}
|
{#if cloneDialogOpen}
|
||||||
<CloneRepositoryDialog
|
<CloneRepositoryDialog
|
||||||
|
|||||||
+667
-32
@@ -37,7 +37,7 @@
|
|||||||
--app-select-option-bg: #171d2b;
|
--app-select-option-bg: #171d2b;
|
||||||
--app-panel-highlight: linear-gradient(180deg, rgba(255,255,255,0.038), transparent 70%);
|
--app-panel-highlight: linear-gradient(180deg, rgba(255,255,255,0.038), transparent 70%);
|
||||||
--app-panel-shadow: 0 18px 48px rgba(0,0,0,0.28), inset 0 1px 0 rgba(255,255,255,0.045);
|
--app-panel-shadow: 0 18px 48px rgba(0,0,0,0.28), inset 0 1px 0 rgba(255,255,255,0.045);
|
||||||
--app-dialog-backdrop: #070a10;
|
--app-dialog-backdrop: rgba(4, 7, 12, 0.58);
|
||||||
--app-dialog-bg: #101622;
|
--app-dialog-bg: #101622;
|
||||||
--app-dialog-chrome: #141b29;
|
--app-dialog-chrome: #141b29;
|
||||||
--app-dialog-shadow: 0 24px 68px rgba(0, 0, 0, 0.54), 0 2px 12px rgba(0,0,0,0.32);
|
--app-dialog-shadow: 0 24px 68px rgba(0, 0, 0, 0.54), 0 2px 12px rgba(0,0,0,0.32);
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
--app-select-option-bg: #ffffff;
|
--app-select-option-bg: #ffffff;
|
||||||
--app-panel-highlight: linear-gradient(180deg, rgba(255,255,255,0.72), transparent 70%);
|
--app-panel-highlight: linear-gradient(180deg, rgba(255,255,255,0.72), transparent 70%);
|
||||||
--app-panel-shadow: 0 18px 48px rgba(28, 44, 74, 0.12), inset 0 1px 0 rgba(255,255,255,0.86);
|
--app-panel-shadow: 0 18px 48px rgba(28, 44, 74, 0.12), inset 0 1px 0 rgba(255,255,255,0.86);
|
||||||
--app-dialog-backdrop: rgba(232, 238, 248, 0.96);
|
--app-dialog-backdrop: rgba(222, 229, 241, 0.56);
|
||||||
--app-dialog-bg: #ffffff;
|
--app-dialog-bg: #ffffff;
|
||||||
--app-dialog-chrome: #f4f7fc;
|
--app-dialog-chrome: #f4f7fc;
|
||||||
--app-dialog-shadow: 0 24px 72px rgba(28, 44, 74, 0.2), 0 2px 12px rgba(28,44,74,0.1);
|
--app-dialog-shadow: 0 24px 72px rgba(28, 44, 74, 0.2), 0 2px 12px rgba(28,44,74,0.1);
|
||||||
@@ -1222,6 +1222,113 @@
|
|||||||
color: #eadfff;
|
color: #eadfff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Global error toast --- */
|
||||||
|
|
||||||
|
.error-toast-region {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--app-titlebar-height, 40px) + 14px);
|
||||||
|
left: 50%;
|
||||||
|
z-index: 90;
|
||||||
|
width: min(560px, calc(100vw - 32px));
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 11px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px 13px 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(232,96,96,0.46);
|
||||||
|
border-radius: 13px;
|
||||||
|
color: var(--color-ink);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(232,96,96,0.18), rgba(100,108,255,0.08)),
|
||||||
|
rgba(12,13,24,0.96);
|
||||||
|
box-shadow: 0 18px 54px rgba(0,0,0,0.46), 0 0 0 1px rgba(255,255,255,0.035) inset;
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
pointer-events: auto;
|
||||||
|
animation: error-toast-in 180ms cubic-bezier(.2,.8,.2,1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid rgba(232,96,96,0.28);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #ff9b9b;
|
||||||
|
background: rgba(232,96,96,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast-content strong {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast-content span {
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast-close {
|
||||||
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border-color: rgba(255,255,255,0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast-close:hover:not(:disabled) {
|
||||||
|
border-color: rgba(255,255,255,0.2);
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast-timeout {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, #ff7878, #ef9a9a);
|
||||||
|
transform-origin: left;
|
||||||
|
animation: error-toast-timeout 6s linear both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast:hover .error-toast-timeout {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes error-toast-in {
|
||||||
|
from { opacity: 0; transform: translateY(-10px) scale(.985); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes error-toast-timeout {
|
||||||
|
from { transform: scaleX(1); }
|
||||||
|
to { transform: scaleX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Update toast --- */
|
/* --- Update toast --- */
|
||||||
|
|
||||||
.update-toast {
|
.update-toast {
|
||||||
@@ -1703,20 +1810,23 @@
|
|||||||
.stash-toggle {
|
.stash-toggle {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 26px;
|
width: 28px;
|
||||||
min-width: 26px;
|
min-width: 28px;
|
||||||
min-height: 26px;
|
height: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
flex: 0 0 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-color: rgba(94,110,156,0.18);
|
border-color: rgba(94,110,156,0.18);
|
||||||
border-radius: 7px;
|
border-radius: 6px;
|
||||||
color: var(--color-ink-dim);
|
color: var(--color-ink-dim);
|
||||||
background: rgba(255,255,255,0.035);
|
background: rgba(255,255,255,0.018);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stash-toggle:hover:not(:disabled) {
|
.stash-toggle:hover:not(:disabled) {
|
||||||
border-color: rgba(65,209,255,0.28);
|
border-color: rgba(65,209,255,0.28);
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
background: rgba(65,209,255,0.08);
|
background: rgba(65,209,255,0.055);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stash-create {
|
.stash-create {
|
||||||
@@ -1847,19 +1957,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.branch-create-toggle {
|
.branch-create-toggle {
|
||||||
width: 26px;
|
display: inline-grid;
|
||||||
min-width: 26px;
|
place-items: center;
|
||||||
min-height: 26px;
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
flex: 0 0 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-color: rgba(65,209,255,0.2);
|
border-color: rgba(94,110,156,0.18);
|
||||||
border-radius: 7px;
|
border-radius: 6px;
|
||||||
color: var(--color-ink-dim);
|
color: var(--color-ink-dim);
|
||||||
background: rgba(65,209,255,0.06);
|
background: rgba(255,255,255,0.018);
|
||||||
}
|
}
|
||||||
.branch-create-toggle:hover:not(:disabled) {
|
.branch-create-toggle:hover:not(:disabled) {
|
||||||
border-color: rgba(65,209,255,0.45);
|
border-color: rgba(65,209,255,0.3);
|
||||||
color: #ffffff;
|
color: var(--color-ink);
|
||||||
background: rgba(65,209,255,0.13);
|
background: rgba(65,209,255,0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-list { gap: 8px; }
|
.branch-list { gap: 8px; }
|
||||||
@@ -1949,6 +2064,20 @@
|
|||||||
.tag-group-head { display: flex; align-items: center; gap: 6px; }
|
.tag-group-head { display: flex; align-items: center; gap: 6px; }
|
||||||
.tag-group-head .branch-group-toggle { flex: 1; min-width: 0; }
|
.tag-group-head .branch-group-toggle { flex: 1; min-width: 0; }
|
||||||
.tag-group-head .branch-create-toggle { flex-shrink: 0; }
|
.tag-group-head .branch-create-toggle { flex-shrink: 0; }
|
||||||
|
.worktree-group-toggle {
|
||||||
|
border-color: rgba(77, 182, 214, 0.12);
|
||||||
|
background: linear-gradient(90deg, rgba(77, 182, 214, 0.055), transparent 72%);
|
||||||
|
}
|
||||||
|
.worktree-group-toggle:hover:not(:disabled) {
|
||||||
|
border-color: rgba(77, 182, 214, 0.25);
|
||||||
|
background: linear-gradient(90deg, rgba(77, 182, 214, 0.1), rgba(77, 182, 214, 0.025));
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
.worktree-group-toggle svg { color: #76cde7; }
|
||||||
|
.worktree-group-toggle .branch-group-count {
|
||||||
|
color: #9edced;
|
||||||
|
background: rgba(77, 182, 214, 0.11);
|
||||||
|
}
|
||||||
|
|
||||||
.tag-create-form { grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto auto; }
|
.tag-create-form { grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto auto; }
|
||||||
|
|
||||||
@@ -2124,19 +2253,24 @@
|
|||||||
|
|
||||||
.explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
|
.explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
|
||||||
.explorer-bulk-button {
|
.explorer-bulk-button {
|
||||||
width: 26px;
|
display: inline-grid;
|
||||||
min-width: 26px;
|
place-items: center;
|
||||||
min-height: 26px;
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
flex: 0 0 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-color: rgba(65,209,255,0.2);
|
border-color: rgba(94,110,156,0.18);
|
||||||
border-radius: 7px;
|
border-radius: 6px;
|
||||||
color: var(--color-ink-dim);
|
color: var(--color-ink-dim);
|
||||||
background: rgba(65,209,255,0.06);
|
background: rgba(255,255,255,0.018);
|
||||||
}
|
}
|
||||||
.explorer-bulk-button:hover:not(:disabled) {
|
.explorer-bulk-button:hover:not(:disabled) {
|
||||||
border-color: rgba(65,209,255,0.45);
|
border-color: rgba(65,209,255,0.3);
|
||||||
color: #ffffff;
|
color: var(--color-ink);
|
||||||
background: rgba(65,209,255,0.13);
|
background: rgba(65,209,255,0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.explorer-row {
|
.explorer-row {
|
||||||
@@ -2937,6 +3071,8 @@
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
background-color: var(--app-dialog-backdrop);
|
background-color: var(--app-dialog-backdrop);
|
||||||
background-image: none;
|
background-image: none;
|
||||||
|
-webkit-backdrop-filter: blur(10px) saturate(0.78);
|
||||||
|
backdrop-filter: blur(10px) saturate(0.78);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-chrome-backdrop {
|
.app-chrome-backdrop {
|
||||||
@@ -3129,6 +3265,29 @@
|
|||||||
max-height: calc(100vh - 32px);
|
max-height: calc(100vh - 32px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.branch-delete-dialog {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
width: min(500px, calc(100vw - 32px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
border-color: rgba(255, 90, 103, 0.22);
|
||||||
|
box-shadow: var(--app-dialog-shadow), 0 0 0 1px rgba(255, 90, 103, 0.04);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.worktree-dialog {
|
||||||
|
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||||
|
width: min(920px, calc(100vw - 32px));
|
||||||
|
height: min(820px, calc(100vh - 32px));
|
||||||
|
border-color: rgba(77, 182, 214, 0.24);
|
||||||
|
}
|
||||||
|
.worktree-confirm-dialog {
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
width: min(520px, calc(100vw - 32px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
}
|
||||||
|
.worktree-nested-backdrop { z-index: 70; }
|
||||||
.ai-settings-dialog {
|
.ai-settings-dialog {
|
||||||
display: block;
|
display: block;
|
||||||
width: min(560px, calc(100vw - 32px));
|
width: min(560px, calc(100vw - 32px));
|
||||||
@@ -3633,7 +3792,30 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 18px 16px 16px;
|
padding: 20px 18px 18px;
|
||||||
|
}
|
||||||
|
.branch-delete-header {
|
||||||
|
padding: 15px 16px;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(255, 90, 103, 0.08), transparent 42%),
|
||||||
|
var(--app-dialog-chrome);
|
||||||
|
}
|
||||||
|
.branch-delete-heading { display: flex; align-items: center; gap: 11px; }
|
||||||
|
.branch-delete-heading-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid rgba(255, 90, 103, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ff9aa4;
|
||||||
|
background: rgba(255, 90, 103, 0.09);
|
||||||
|
}
|
||||||
|
.branch-delete-heading-icon.force {
|
||||||
|
border-color: rgba(255, 174, 92, 0.34);
|
||||||
|
color: #ffc07a;
|
||||||
|
background: rgba(255, 151, 61, 0.1);
|
||||||
}
|
}
|
||||||
.discard-warning-icon {
|
.discard-warning-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -3674,6 +3856,299 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
.branch-delete-lead { color: var(--color-ink); font-weight: 600; }
|
||||||
|
.branch-delete-target {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 11px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.16);
|
||||||
|
box-shadow: inset 3px 0 0 rgba(255, 90, 103, 0.48);
|
||||||
|
}
|
||||||
|
.branch-delete-target-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: rgba(77, 182, 214, 0.1);
|
||||||
|
}
|
||||||
|
.branch-delete-target-copy { display: grid; gap: 2px; min-width: 0; flex: 1; }
|
||||||
|
.branch-delete-target-copy code {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.branch-delete-target-copy > span { color: var(--color-ink-faint); font-size: 10.5px; }
|
||||||
|
.branch-delete-scope {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid rgba(77, 182, 214, 0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #8ed8ee;
|
||||||
|
background: rgba(77, 182, 214, 0.08);
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.branch-delete-scope.remote { border-color: rgba(167, 126, 255, 0.25); color: #c2a9ff; background: rgba(132, 88, 232, 0.1); }
|
||||||
|
.branch-delete-body .discard-warning-text {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-left: 2px solid rgba(255, 90, 103, 0.55);
|
||||||
|
color: #f2aeb5;
|
||||||
|
background: rgba(255, 90, 103, 0.055);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.branch-delete-confirm { min-width: 116px; }
|
||||||
|
.worktree-dialog-header {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(77, 182, 214, 0.09), transparent 40%),
|
||||||
|
var(--app-dialog-chrome);
|
||||||
|
}
|
||||||
|
.worktree-dialog-heading { display: flex; align-items: center; gap: 11px; }
|
||||||
|
.worktree-dialog-mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid rgba(77, 182, 214, 0.3);
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #8ed8ee;
|
||||||
|
background: rgba(77, 182, 214, 0.1);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
.worktree-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 1px 0;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
background: var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
.worktree-summary > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 11px 14px;
|
||||||
|
background: var(--app-dialog-bg);
|
||||||
|
}
|
||||||
|
.worktree-summary > div strong { color: var(--color-ink); font-family: var(--font-mono); font-size: 14px; }
|
||||||
|
.worktree-summary > div span { overflow: hidden; color: var(--color-ink-faint); font-size: 10.5px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.worktree-summary > div.attention strong { color: #ffc07a; }
|
||||||
|
.worktree-summary > .btn-primary { align-self: center; margin: 0 14px; white-space: nowrap; }
|
||||||
|
.worktree-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255, 90, 103, 0.2);
|
||||||
|
color: #ffb8bf;
|
||||||
|
background: rgba(255, 90, 103, 0.08);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.worktree-content {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 92% 0%, rgba(77, 182, 214, 0.055), transparent 28%),
|
||||||
|
var(--app-dialog-bg);
|
||||||
|
}
|
||||||
|
.worktree-create-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(77, 182, 214, 0.26);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.035);
|
||||||
|
}
|
||||||
|
.worktree-create-card > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.worktree-create-card h3 { margin: 3px 0 0; color: var(--color-ink); font-size: 13px; font-weight: 700; }
|
||||||
|
.worktree-mode-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 3px;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.14);
|
||||||
|
}
|
||||||
|
.worktree-mode-tabs button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 32px;
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
.worktree-mode-tabs button.active {
|
||||||
|
border-color: rgba(77, 182, 214, 0.27);
|
||||||
|
color: #b8e7f6;
|
||||||
|
background: rgba(77, 182, 214, 0.11);
|
||||||
|
box-shadow: 0 3px 12px rgba(0,0,0,0.14);
|
||||||
|
}
|
||||||
|
.worktree-create-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||||
|
.worktree-create-fields label,
|
||||||
|
.worktree-lock-body label { display: grid; gap: 5px; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 750; }
|
||||||
|
.worktree-create-fields input,
|
||||||
|
.worktree-create-fields select,
|
||||||
|
.worktree-lock-body input { width: 100%; min-width: 0; }
|
||||||
|
.worktree-create-fields .worktree-path-field { grid-column: 1 / -1; }
|
||||||
|
.worktree-path-field > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }
|
||||||
|
.worktree-create-card > footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
|
||||||
|
.worktree-check { display: flex; align-items: flex-start; gap: 8px; color: var(--color-ink-muted); font-size: 11px; cursor: pointer; }
|
||||||
|
.worktree-check input { width: auto; margin-top: 2px; }
|
||||||
|
.worktree-check span { display: grid; gap: 1px; }
|
||||||
|
.worktree-check strong { color: var(--color-ink); font-size: 11px; }
|
||||||
|
.worktree-check small { color: var(--color-ink-faint); font-size: 10px; font-weight: 500; }
|
||||||
|
.worktree-check.danger strong,
|
||||||
|
.worktree-check.danger small { color: #ffb8bf; }
|
||||||
|
.worktree-list { display: grid; gap: 8px; }
|
||||||
|
.worktree-card {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.worktree-card.current { border-color: rgba(77, 182, 214, 0.34); box-shadow: inset 0 0 0 1px rgba(77, 182, 214, 0.06); }
|
||||||
|
.worktree-card.stale { border-color: rgba(255, 151, 61, 0.28); }
|
||||||
|
.worktree-rail {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: start center;
|
||||||
|
padding-top: 18px;
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.worktree-rail::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
background: rgba(77, 182, 214, 0.22);
|
||||||
|
}
|
||||||
|
.worktree-rail span {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border: 2px solid #76cde7;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-surface-solid);
|
||||||
|
box-shadow: 0 0 0 3px rgba(77, 182, 214, 0.08);
|
||||||
|
}
|
||||||
|
.worktree-rail i {
|
||||||
|
position: absolute;
|
||||||
|
top: 31px;
|
||||||
|
left: 50%;
|
||||||
|
width: 7px;
|
||||||
|
height: 14px;
|
||||||
|
border-bottom: 1px solid rgba(77, 182, 214, 0.26);
|
||||||
|
border-left: 1px solid rgba(77, 182, 214, 0.26);
|
||||||
|
}
|
||||||
|
.worktree-card-main { display: grid; gap: 9px; min-width: 0; padding: 12px; }
|
||||||
|
.worktree-card-main > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||||
|
.worktree-name { display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--color-accent); }
|
||||||
|
.worktree-name > div { display: grid; gap: 1px; min-width: 0; }
|
||||||
|
.worktree-name strong { overflow: hidden; color: var(--color-ink); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.worktree-name span { color: var(--color-ink-faint); font-size: 10px; }
|
||||||
|
.worktree-badges { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.worktree-badges span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
background: rgba(255,255,255,0.025);
|
||||||
|
font-size: 8.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.worktree-badges span.active { border-color: rgba(78, 202, 118, 0.25); color: #77d99a; background: rgba(78, 202, 118, 0.08); }
|
||||||
|
.worktree-badges span.locked { border-color: rgba(111, 140, 255, 0.26); color: #aebcff; background: rgba(111, 140, 255, 0.08); }
|
||||||
|
.worktree-badges span.danger { border-color: rgba(255, 151, 61, 0.3); color: #ffc07a; background: rgba(255, 151, 61, 0.08); }
|
||||||
|
.worktree-path { display: flex; align-items: center; gap: 5px; min-width: 0; color: var(--color-ink-faint); }
|
||||||
|
.worktree-path code { overflow: hidden; font-family: var(--font-mono); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.worktree-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; color: var(--color-ink-faint); font-size: 9.5px; }
|
||||||
|
.worktree-meta span { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.worktree-meta span.dirty,
|
||||||
|
.worktree-meta span.danger { color: #ffc07a; }
|
||||||
|
.worktree-card-main > footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 2px; }
|
||||||
|
.worktree-card-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 5px; }
|
||||||
|
.worktree-card-actions .danger { border-color: rgba(255, 90, 103, 0.2); color: #ff9aa4; background: rgba(255, 90, 103, 0.06); }
|
||||||
|
.worktree-dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid var(--color-border-subtle);
|
||||||
|
background: var(--app-dialog-chrome);
|
||||||
|
}
|
||||||
|
.worktree-dialog-footer > div { display: flex; align-items: center; gap: 6px; color: var(--color-ink-faint); font-size: 10px; }
|
||||||
|
.worktree-loading,
|
||||||
|
.worktree-empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 240px;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.worktree-empty strong { color: var(--color-ink); font-size: 13px; }
|
||||||
|
.worktree-confirm-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 13px;
|
||||||
|
padding: 17px 16px;
|
||||||
|
}
|
||||||
|
.worktree-confirm-body > div { display: grid; gap: 10px; min-width: 0; }
|
||||||
|
.worktree-confirm-body p { margin: 0; color: var(--color-ink-muted); font-size: 12px; line-height: 1.45; }
|
||||||
|
.worktree-confirm-body code { overflow: auto; padding: 8px; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-ink); background: rgba(0,0,0,0.16); font-size: 10.5px; }
|
||||||
|
.worktree-lock-body { padding: 18px 16px; }
|
||||||
|
.worktree-lock-body label > span { display: flex; align-items: baseline; justify-content: space-between; }
|
||||||
|
.worktree-lock-body small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.worktree-summary { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.worktree-summary > div { display: grid; gap: 2px; }
|
||||||
|
.worktree-summary > .btn-primary { grid-column: 1 / -1; margin: 9px 14px; }
|
||||||
|
.worktree-create-fields { grid-template-columns: 1fr; }
|
||||||
|
.worktree-create-fields .worktree-path-field { grid-column: auto; }
|
||||||
|
.worktree-create-card > footer,
|
||||||
|
.worktree-card-main > footer { align-items: stretch; flex-direction: column; }
|
||||||
|
.worktree-card-actions { justify-content: flex-start; }
|
||||||
|
.worktree-dialog-footer > div { display: none; }
|
||||||
|
}
|
||||||
.discard-target-list {
|
.discard-target-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -3706,11 +4181,37 @@
|
|||||||
|
|
||||||
.line-patch-body {
|
.line-patch-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.line-patch-selection-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
background: var(--app-dialog-chrome);
|
||||||
|
}
|
||||||
|
.line-patch-selection-bar.active {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(77, 182, 214, 0.1), transparent 44%),
|
||||||
|
var(--app-dialog-chrome);
|
||||||
|
}
|
||||||
|
.line-patch-selection-bar > div:first-child {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.line-patch-selection-bar strong { color: var(--color-ink); font-size: 11.5px; }
|
||||||
|
.line-patch-selection-bar span { color: var(--color-ink-faint); font-size: 10px; }
|
||||||
|
.line-patch-selected-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
||||||
|
|
||||||
.line-patch-scroll {
|
.line-patch-scroll {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -3736,6 +4237,33 @@
|
|||||||
border-bottom: 1px solid var(--color-border-subtle);
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
background: color-mix(in srgb, var(--code-surface-raised) 96%, transparent);
|
background: color-mix(in srgb, var(--code-surface-raised) 96%, transparent);
|
||||||
}
|
}
|
||||||
|
.line-patch-select-hunk,
|
||||||
|
.line-patch-line-select {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 16px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
min-height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--code-surface);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.line-patch-select-hunk:hover:not(:disabled),
|
||||||
|
.line-patch-line-select:hover:not(:disabled) {
|
||||||
|
border-color: rgba(77, 182, 214, 0.65);
|
||||||
|
background: rgba(77, 182, 214, 0.1);
|
||||||
|
}
|
||||||
|
.line-patch-select-hunk.all,
|
||||||
|
.line-patch-select-hunk.some,
|
||||||
|
.line-patch-line-select[aria-pressed="true"] {
|
||||||
|
border-color: rgba(77, 182, 214, 0.78);
|
||||||
|
background: #238eb4;
|
||||||
|
}
|
||||||
|
.line-patch-select-hunk.some { background: rgba(35, 142, 180, 0.55); }
|
||||||
.line-patch-hunk-head code {
|
.line-patch-hunk-head code {
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -3781,12 +4309,17 @@
|
|||||||
|
|
||||||
.line-patch-row {
|
.line-patch-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 22px minmax(max-content, 1fr);
|
grid-template-columns: 18px 38px 38px 22px minmax(max-content, 1fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
min-height: 22px;
|
min-height: 22px;
|
||||||
padding: 1px 10px 1px 28px;
|
padding: 1px 10px 1px 8px;
|
||||||
color: var(--color-ink-muted);
|
color: var(--color-ink-muted);
|
||||||
}
|
}
|
||||||
|
.line-patch-row.selectable { cursor: default; }
|
||||||
|
.line-patch-row.selected {
|
||||||
|
box-shadow: inset 3px 0 0 rgba(77, 182, 214, 0.86);
|
||||||
|
filter: saturate(1.12) brightness(1.06);
|
||||||
|
}
|
||||||
.line-patch-row.add {
|
.line-patch-row.add {
|
||||||
background: var(--code-add-bg);
|
background: var(--code-add-bg);
|
||||||
color: var(--code-add-text);
|
color: var(--code-add-text);
|
||||||
@@ -3803,12 +4336,26 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
.line-patch-line-select { align-self: center; }
|
||||||
|
.line-patch-line-select-placeholder { width: 16px; }
|
||||||
|
.line-patch-line-number {
|
||||||
|
padding-right: 7px;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: right;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
.line-patch-row.add .line-patch-prefix { color: var(--code-add-strong); }
|
.line-patch-row.add .line-patch-prefix { color: var(--code-add-strong); }
|
||||||
.line-patch-row.delete .line-patch-prefix { color: var(--code-delete-strong); }
|
.line-patch-row.delete .line-patch-prefix { color: var(--code-delete-strong); }
|
||||||
.line-patch-row code {
|
.line-patch-row code {
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.line-patch-selection-bar { align-items: stretch; flex-direction: column; }
|
||||||
|
.line-patch-selected-actions { justify-content: flex-start; flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
|
||||||
.blame-body {
|
.blame-body {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -5199,10 +5746,13 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
.history-panel, .file-history-panel { min-height: 0; }
|
.history-panel, .file-history-panel { min-height: 0; }
|
||||||
.graph-list { background: var(--color-surface-solid); }
|
.graph-list { background: var(--color-surface-solid); }
|
||||||
.graph-gutter { background: var(--color-surface-dim); }
|
.graph-gutter { background: var(--color-surface-dim); }
|
||||||
.commit-body { border-radius: var(--ui-radius-sm); background: transparent; box-shadow: none; }
|
.commit-body {
|
||||||
|
border-radius: var(--ui-radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 7%, var(--color-surface-solid));
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
.graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); }
|
.graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); }
|
||||||
.graph-row:hover .commit-body { background: var(--color-surface-hover); }
|
.graph-row:hover .commit-body { background: var(--color-surface-hover); }
|
||||||
.graph-row.tip-row .commit-body { background: color-mix(in srgb, var(--color-primary) 7%, var(--color-surface-solid)); }
|
|
||||||
.commit-avatar { border-radius: 50%; }
|
.commit-avatar { border-radius: 50%; }
|
||||||
.commit-kind, .commit-branch-chip, .ref-chip { border-radius: 4px !important; }
|
.commit-kind, .commit-branch-chip, .ref-chip { border-radius: 4px !important; }
|
||||||
.file-history-row { margin: 0; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; background: transparent; }
|
.file-history-row { margin: 0; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; background: transparent; }
|
||||||
@@ -5293,6 +5843,23 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
background: rgba(210, 55, 75, 0.08);
|
background: rgba(210, 55, 75, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .error-toast {
|
||||||
|
border-color: rgba(190,48,66,0.28);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(210,55,75,0.11), rgba(49,95,214,0.05)),
|
||||||
|
rgba(255,255,255,0.96);
|
||||||
|
box-shadow: 0 18px 48px rgba(28,44,74,0.18), 0 0 0 1px rgba(255,255,255,0.8) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .error-toast-icon {
|
||||||
|
color: #b33445;
|
||||||
|
background: rgba(210,55,75,0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .error-toast-content strong {
|
||||||
|
color: #24314a;
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .notice.busy {
|
:root[data-theme="light"] .notice.busy {
|
||||||
color: #315fd6;
|
color: #315fd6;
|
||||||
}
|
}
|
||||||
@@ -6011,6 +6578,74 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
.ai-review-finding-body > p { margin: 6px 0 8px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
|
.ai-review-finding-body > p { margin: 6px 0 8px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
|
||||||
.ai-review-location { display: flex; align-items: center; gap: 5px; color: var(--color-primary); }
|
.ai-review-location { display: flex; align-items: center; gap: 5px; color: var(--color-primary); }
|
||||||
.ai-review-location code { overflow-wrap: anywhere; font: 10.5px/1.4 var(--font-mono); }
|
.ai-review-location code { overflow-wrap: anywhere; font: 10.5px/1.4 var(--font-mono); }
|
||||||
|
|
||||||
|
/* Sync settings: one place for pull behavior, upstream and remote connections. */
|
||||||
|
.sync-settings-dialog { width: min(820px, calc(100vw - 32px)); max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--color-border); border-radius: 14px; background: var(--app-dialog-bg); box-shadow: 0 28px 80px rgba(0,0,0,.48); }
|
||||||
|
.sync-settings-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||||
|
.sync-settings-title { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.sync-settings-title h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 19px; }
|
||||||
|
.sync-settings-icon { display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid rgba(90,140,248,.28); border-radius: 10px; color: var(--color-accent); background: rgba(90,140,248,.1); }
|
||||||
|
.sync-settings-body { display: grid; gap: 12px; min-height: 0; padding: 14px; overflow: auto; }
|
||||||
|
.sync-settings-card { padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 11px; background: var(--color-surface-raised); }
|
||||||
|
.sync-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 13px; }
|
||||||
|
.sync-card-heading h3 { margin: 0; color: var(--color-ink); font-size: 13px; }
|
||||||
|
.sync-card-heading p { margin: 4px 0 0; color: var(--color-ink-faint); font-size: 11px; }
|
||||||
|
.count-pill { min-width: 24px; padding: 3px 7px; border-radius: 999px; color: var(--color-ink-muted); background: var(--color-surface-hover); font: 700 10px var(--font-mono); text-align: center; }
|
||||||
|
.strategy-options { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; }
|
||||||
|
.strategy-options label { position: relative; display: grid; grid-template-columns: 18px 1fr; gap: 8px; min-height: 94px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 9px; color: var(--color-ink-muted); background: var(--app-input-bg); cursor: pointer; }
|
||||||
|
.strategy-options label.active { border-color: rgba(90,140,248,.65); box-shadow: inset 0 0 0 1px rgba(90,140,248,.18); background: rgba(90,140,248,.08); }
|
||||||
|
.strategy-options input { margin-top: 2px; accent-color: var(--color-accent); }
|
||||||
|
.strategy-options span { display: grid; align-content: start; gap: 5px; }
|
||||||
|
.strategy-options strong { color: var(--color-ink); font-size: 12px; }
|
||||||
|
.strategy-options small, .sync-fields small { color: var(--color-ink-faint); font-size: 10px; line-height: 1.45; }
|
||||||
|
.sync-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-border-subtle); }
|
||||||
|
.sync-fields label { display: grid; gap: 6px; color: var(--color-ink-muted); font-size: 11px; font-weight: 700; }
|
||||||
|
.sync-fields select, .remote-add input, .remote-edit input { width: 100%; height: 35px; border: 1px solid var(--color-border-input); border-radius: 7px; color: var(--color-ink); background: var(--app-input-bg); font-size: 11px; }
|
||||||
|
.sync-fields select { padding: 0 9px; }
|
||||||
|
.remote-list { display: grid; gap: 5px; }
|
||||||
|
.sync-action-error { display: grid; gap: 4px; margin-bottom: 5px; padding: 10px 11px; border: 1px solid rgba(232,96,96,.28); border-radius: 8px; color: #ef8888; background: rgba(232,96,96,.08); }
|
||||||
|
.sync-action-error strong { font-size: 11px; }
|
||||||
|
.sync-action-error span { font: 9.5px/1.45 var(--font-mono); overflow-wrap: anywhere; }
|
||||||
|
.sync-action-status { margin-bottom: 5px; padding: 9px 11px; border: 1px solid rgba(90,140,248,.28); border-radius: 8px; color: var(--color-accent); background: rgba(90,140,248,.08); font-size: 10.5px; font-weight: 700; }
|
||||||
|
.remote-row { display: grid; grid-template-columns: 28px minmax(0,1fr) auto auto; align-items: center; gap: 7px; min-height: 48px; padding: 6px 7px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--app-input-bg); }
|
||||||
|
.remote-mark { display: grid; place-items: center; color: var(--color-accent); }
|
||||||
|
.remote-main { display: grid; gap: 3px; min-width: 0; padding: 0; border: 0; color: var(--color-ink); background: transparent; text-align: left; }
|
||||||
|
.remote-main strong, .remote-edit strong { font-size: 11.5px; }
|
||||||
|
.remote-main span { overflow: hidden; color: var(--color-ink-faint); font: 10px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.remote-edit { display: grid; grid-template-columns: 80px minmax(0,1fr); align-items: center; gap: 8px; }
|
||||||
|
.remote-confirm { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.remote-confirm strong { color: var(--color-ink); font-size: 11px; }
|
||||||
|
.remote-confirm span { color: var(--color-ink-faint); font-size: 9.5px; }
|
||||||
|
.remote-edit input, .remote-add input { padding: 0 9px; }
|
||||||
|
.remote-delete { display: inline-flex; align-items: center; gap: 5px; height: 30px; padding: 0 8px; border: 0; border-radius: 6px; color: #e86060; background: transparent; font-size: 10px; font-weight: 750; }
|
||||||
|
.remote-delete:hover { background: rgba(235,87,87,.1); }
|
||||||
|
.btn-danger { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 34px; padding: 0 11px; border: 1px solid rgba(232,96,96,.38); border-radius: 7px; color: #fff; background: #c84f4f; font-size: 10.5px; font-weight: 750; }
|
||||||
|
.btn-danger:hover:not(:disabled) { background: #dd5b5b; }
|
||||||
|
.remote-empty { margin: 4px 0 10px; color: var(--color-ink-faint); font-size: 11px; }
|
||||||
|
.remote-add { display: grid; grid-template-columns: 120px minmax(180px,1fr) auto; gap: 7px; margin-top: 9px; padding-top: 10px; border-top: 1px solid var(--color-border-subtle); }
|
||||||
|
.sync-settings-footer { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 13px 20px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||||
|
.sync-settings-footer p { max-width: 440px; margin: 0; color: var(--color-ink-faint); font-size: 9.5px; line-height: 1.4; }
|
||||||
|
.sync-settings-footer > div { display: flex; gap: 8px; }
|
||||||
|
@media (max-width: 720px) { .strategy-options, .sync-fields { grid-template-columns: 1fr; } .remote-add { grid-template-columns: 1fr; } .sync-settings-footer { align-items: stretch; flex-direction: column; } .sync-settings-footer > div { justify-content: flex-end; } }
|
||||||
|
|
||||||
|
/* Compact square controls shared by the Branches, Stash and Explorer headers. */
|
||||||
|
.branch-head-actions .branch-create-toggle,
|
||||||
|
.stash-head-actions .stash-toggle,
|
||||||
|
.explorer-head-actions .explorer-bulk-button {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
inline-size: 24px;
|
||||||
|
min-inline-size: 24px;
|
||||||
|
max-inline-size: 24px;
|
||||||
|
block-size: 24px;
|
||||||
|
min-block-size: 24px;
|
||||||
|
max-block-size: 24px;
|
||||||
|
flex: 0 0 24px;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
.ai-review-suggestion { display: grid; gap: 3px; margin-top: 9px; padding: 8px 9px; border-radius: 5px; background: var(--color-surface-dim); }
|
.ai-review-suggestion { display: grid; gap: 3px; margin-top: 9px; padding: 8px 9px; border-radius: 5px; background: var(--color-surface-dim); }
|
||||||
.ai-review-suggestion strong { color: var(--color-ink-dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; }
|
.ai-review-suggestion strong { color: var(--color-ink-dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
.ai-review-suggestion span { color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.45; }
|
.ai-review-suggestion span { color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.45; }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Upload,
|
Upload,
|
||||||
|
Settings2,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
export let hasRepository: boolean = false;
|
export let hasRepository: boolean = false;
|
||||||
@@ -28,8 +29,12 @@
|
|||||||
export let onInteractiveRebase: () => void = () => {};
|
export let onInteractiveRebase: () => void = () => {};
|
||||||
export let onReflog: () => void = () => {};
|
export let onReflog: () => void = () => {};
|
||||||
export let onOpenInExplorer: () => void = () => {};
|
export let onOpenInExplorer: () => void = () => {};
|
||||||
|
export let onFetchPrune: () => void = () => {};
|
||||||
|
export let onForcePush: () => void = () => {};
|
||||||
|
export let onSyncOptions: () => void = () => {};
|
||||||
|
|
||||||
let historyOpen = false;
|
let historyOpen = false;
|
||||||
|
let syncOpen = false;
|
||||||
let toolbarElement: HTMLDivElement;
|
let toolbarElement: HTMLDivElement;
|
||||||
|
|
||||||
$: isGerman = language === "de";
|
$: isGerman = language === "de";
|
||||||
@@ -40,9 +45,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleWindowClick(event: MouseEvent) {
|
function handleWindowClick(event: MouseEvent) {
|
||||||
if (historyOpen && toolbarElement && !toolbarElement.contains(event.target as Node)) {
|
if (toolbarElement && !toolbarElement.contains(event.target as Node)) { historyOpen = false; syncOpen = false; }
|
||||||
historyOpen = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWindowKeydown(event: KeyboardEvent) {
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
@@ -108,6 +111,18 @@
|
|||||||
<span class="repo-action-label">Push</span>
|
<span class="repo-action-label">Push</span>
|
||||||
{#if ahead > 0}<span class="repo-action-count ahead">↑{ahead}</span>{/if}
|
{#if ahead > 0}<span class="repo-action-count ahead">↑{ahead}</span>{/if}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
<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>{isGerman ? "Anonyme Aptabase-Ereignisse erlauben" : "Allow anonymous Aptabase events"}</strong>
|
<strong>{isGerman ? "Anonyme Analytics und Fehlerberichte erlauben" : "Allow anonymous analytics and error reports"}</strong>
|
||||||
<small>{isGerman ? "Es werden keine Repository-Pfade, Remotes, Branches, Commit-Nachrichten, Dateinamen, Diffs, Zugangsdaten oder Code übertragen." : "No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent."}</small>
|
<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>
|
||||||
|
|||||||
@@ -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 class="branch-delete-heading">
|
||||||
|
<span class:force class="branch-delete-heading-icon" aria-hidden="true">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">{force ? "Force delete" : "Delete branch"}</span>
|
<span class="eyebrow">{branch.remote ? "Remote branch" : force ? "Force delete" : "Delete branch"}</span>
|
||||||
<p class="dialog-title">{title}</p>
|
<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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
Lightbulb,
|
Lightbulb,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Search,
|
Search,
|
||||||
|
Sparkles,
|
||||||
Wrench,
|
Wrench,
|
||||||
X,
|
X,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
@@ -617,6 +618,87 @@
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
deCategories.find((category) => category.id === "app")?.sections.push(
|
||||||
|
{
|
||||||
|
id: "app-line-staging",
|
||||||
|
title: "Änderungen zeilenweise stagen",
|
||||||
|
summary: "Im Patch-Dialog kannst du nicht nur vollständige Dateien oder Hunks, sondern einzelne hinzugefügte und entfernte Zeilen für den nächsten Commit auswählen.",
|
||||||
|
steps: [
|
||||||
|
"Öffne im Bereich Unstaged den Patch einer geänderten Textdatei.",
|
||||||
|
"Klicke auf die Auswahlfläche vor einer grünen oder roten Zeile. Mit Shift-Klick markierst du einen zusammenhängenden Bereich.",
|
||||||
|
"Die Auswahlleiste zeigt die Anzahl der gewählten Zeilen. Stage selected übernimmt nur diese Auswahl in den Staging-Bereich.",
|
||||||
|
"Öffne denselben Dialog unter Staged und nutze Unstage selected, wenn einzelne Zeilen wieder zurück ins Arbeitsverzeichnis sollen.",
|
||||||
|
"Prüfe danach den Staged-Diff. Er ist die verbindliche Vorschau des nächsten Commits.",
|
||||||
|
],
|
||||||
|
note: "Bei einer geänderten Zeile stellt Git intern meist eine entfernte und eine hinzugefügte Zeile dar. Wähle beide Seiten, wenn die vollständige Ersetzung in denselben Commit gehört.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "app-line-discard",
|
||||||
|
title: "Hunks und einzelne Zeilen verwerfen",
|
||||||
|
summary: "Discard selected verwirft nur die markierten Zeilen; Discard Hunk verwirft den vollständigen Abschnitt. Gitty zeigt vor dem Löschen eine Sicherheitsabfrage.",
|
||||||
|
steps: [
|
||||||
|
"Kontrolliere, ob der Dialog Staged changes oder Unstaged changes anzeigt.",
|
||||||
|
"Markiere exakt die Zeilen, die nicht behalten werden sollen.",
|
||||||
|
"Lies in der Sicherheitsabfrage Pfad und Umfang und bestätige erst danach.",
|
||||||
|
"Aktualisiere den Patch, falls Dateien während des geöffneten Dialogs extern verändert wurden.",
|
||||||
|
],
|
||||||
|
note: "Vorsicht: Verwerfen kann nicht zuverlässig rückgängig gemacht werden. Erstelle bei wichtigen Zwischenständen zuerst einen Commit oder Stash.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "app-package-updates",
|
||||||
|
title: "Gitty unter Arch Linux installieren und aktualisieren",
|
||||||
|
summary: "Ein Pacman-Repository auf der CDN-Seite ermöglicht signierte, versionierte Updates. Alternativ lässt sich eine heruntergeladene .pkg.tar.zst-Datei direkt installieren.",
|
||||||
|
commands: [
|
||||||
|
{ command: "sudo pacman --config /pfad/gitty-pacman.conf -Syu gitty", description: "Repository-Konfiguration für genau diesen Aufruf verwenden und Gitty aktualisieren" },
|
||||||
|
{ command: "sudo pacman -U /pfad/gitty-<version>-x86_64.pkg.tar.zst", description: "Ein einzelnes lokales oder heruntergeladenes Paket installieren" },
|
||||||
|
{ command: "pacman -Qi gitty", description: "Installierte Version und Paketinformationen anzeigen" },
|
||||||
|
{ command: "pacman -Si gitty", description: "Im konfigurierten Repository verfügbare Version anzeigen" },
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
"Für normale Systemupdates trägst du das Gitty-Repository einmal dauerhaft in /etc/pacman.conf ein.",
|
||||||
|
"Wenn du die Systemdatei nicht ändern möchtest, gib --config bei jedem Pacman-Aufruf erneut an oder verwende ein eigenes Wrapper-Skript.",
|
||||||
|
"Die Build-Pipeline erzeugt das Paket aus dem PKGBUILD, aktualisiert die Repository-Datenbank und veröffentlicht beides auf dem CDN.",
|
||||||
|
"Pacman installiert nur dann eine neue Version, wenn Paketversion und Repository-Datenbank entsprechend aktualisiert wurden.",
|
||||||
|
],
|
||||||
|
note: "--config gilt nur für den aktuellen Pacman-Prozess. Ein späteres sudo pacman -Syu erinnert sich nicht automatisch an den zuvor angegebenen Pfad.",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
deCategories.find((category) => category.id === "branches")?.sections.push(
|
||||||
|
{
|
||||||
|
id: "branches-worktrees-concept",
|
||||||
|
title: "Was ein Worktree ist",
|
||||||
|
summary: "Ein Repository besitzt eine gemeinsame Git-Datenbank, kann aber mehrere Arbeitsordner haben. Jeder Worktree hat eigene ausgecheckte Dateien und normalerweise einen eigenen Branch; Commits und Referenzen sind sofort in allen Worktrees sichtbar.",
|
||||||
|
steps: [
|
||||||
|
"Das Haupt-Worktree ist der Ordner, den du ursprünglich als Repository geöffnet hast.",
|
||||||
|
"Ein zusätzlicher Worktree ist kein vollständiger Clone: Git-Objekte und Historie werden gemeinsam genutzt, nur Arbeitsordner und Index sind getrennt.",
|
||||||
|
"Änderungen in Dateien bleiben im jeweiligen Worktree. Ein dort erstellter Commit ist dagegen sofort Teil desselben Repositorys.",
|
||||||
|
"Worktrees eignen sich besonders für parallele Features, dringende Hotfixes, Reviews oder längere Builds auf einem zweiten Branch.",
|
||||||
|
"Für vollständig unabhängige Remotes, Konfigurationen oder Experimente ist ein eigener Clone weiterhin die passendere Wahl.",
|
||||||
|
],
|
||||||
|
note: "Ein Branch kann normalerweise nicht gleichzeitig in zwei Worktrees ausgecheckt werden. Das verhindert, dass zwei Ordner denselben Branch widersprüchlich verändern.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "branches-worktrees",
|
||||||
|
title: "Mehrere Branches mit Worktrees parallel öffnen",
|
||||||
|
summary: "Ein Worktree checkt einen weiteren Branch in einen eigenen Ordner aus. So kannst du an mehreren Aufgaben arbeiten, ohne Branches im Hauptordner umzuschalten oder lokale Änderungen zu stashen.",
|
||||||
|
steps: [
|
||||||
|
"Öffne im Branch-Panel unter Tags den Reiter Worktrees.",
|
||||||
|
"Wähle Existing branch für einen vorhandenen lokalen Branch, New branch für einen neuen Branch oder Detached für einen festen Commit ohne Branch.",
|
||||||
|
"Lege einen leeren Zielordner fest und erstelle den Worktree. Open tab öffnet ihn anschließend als eigenen Repository-Tab.",
|
||||||
|
"Move verschiebt einen nicht aktiven Worktree. Lock schützt ihn vor versehentlichem Prune oder Entfernen, etwa auf einem externen Laufwerk.",
|
||||||
|
"Remove löscht den Worktree-Ordner und seine Registrierung, behält aber den Branch. Prune entfernt nur veraltete Registrierungen nicht mehr vorhandener Ordner.",
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{ command: "git worktree list", description: "Alle registrierten Worktrees anzeigen" },
|
||||||
|
{ command: "git worktree add ../projekt-fix fix/login", description: "Vorhandenen Branch in neuem Ordner auschecken" },
|
||||||
|
{ command: "git worktree add -b feature/name ../projekt-feature main", description: "Neuen Branch und Worktree von main erstellen" },
|
||||||
|
{ command: "git worktree prune --dry-run", description: "Veraltete Registrierungen vor dem Aufräumen prüfen" },
|
||||||
|
],
|
||||||
|
note: "Ein Branch kann normalerweise nur in einem Worktree gleichzeitig ausgecheckt sein. Ein Worktree mit lokalen Änderungen sollte vor Remove committed, gestasht oder bewusst erzwungen entfernt werden.",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
deCategories.find((category) => category.id === "branches")?.sections.push(
|
deCategories.find((category) => category.id === "branches")?.sections.push(
|
||||||
{
|
{
|
||||||
id: "branches-strategy",
|
id: "branches-strategy",
|
||||||
@@ -979,6 +1061,87 @@
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
enCategories.find((category) => category.id === "app")?.sections.push(
|
||||||
|
{
|
||||||
|
id: "app-line-staging",
|
||||||
|
title: "Stage changes line by line",
|
||||||
|
summary: "The patch dialog lets you prepare individual added and deleted lines instead of staging an entire file or hunk.",
|
||||||
|
steps: [
|
||||||
|
"Open the patch of a changed text file in Unstaged.",
|
||||||
|
"Click the selector in front of a green or red line. Shift-click selects a continuous range.",
|
||||||
|
"The selection bar shows the number of selected lines. Stage selected moves only that selection into Staged.",
|
||||||
|
"Open the same dialog under Staged and use Unstage selected to return individual lines to the working tree.",
|
||||||
|
"Review the Staged diff afterward. It is the authoritative preview of the next commit.",
|
||||||
|
],
|
||||||
|
note: "Git usually represents one edited line as one deletion plus one addition. Select both sides when the complete replacement belongs in the same commit.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "app-line-discard",
|
||||||
|
title: "Discard hunks or individual lines",
|
||||||
|
summary: "Discard selected removes only selected lines; Discard Hunk removes the complete section. Gitty shows a confirmation before deleting changes.",
|
||||||
|
steps: [
|
||||||
|
"Check whether the dialog currently shows Staged changes or Unstaged changes.",
|
||||||
|
"Select exactly the lines that should not be kept.",
|
||||||
|
"Review the file path and scope in the confirmation, then confirm.",
|
||||||
|
"Refresh the patch if another application changed the file while the dialog was open.",
|
||||||
|
],
|
||||||
|
note: "Caution: discarded work cannot always be recovered. Commit or stash important intermediate work first.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "app-package-updates",
|
||||||
|
title: "Install and update Gitty on Arch Linux",
|
||||||
|
summary: "A Pacman repository hosted on the CDN provides versioned updates. Alternatively, install a downloaded .pkg.tar.zst package directly.",
|
||||||
|
commands: [
|
||||||
|
{ command: "sudo pacman --config /path/gitty-pacman.conf -Syu gitty", description: "Use the custom repository configuration for this invocation and update Gitty" },
|
||||||
|
{ command: "sudo pacman -U /path/gitty-<version>-x86_64.pkg.tar.zst", description: "Install one local or downloaded package file" },
|
||||||
|
{ command: "pacman -Qi gitty", description: "Show the installed version and package information" },
|
||||||
|
{ command: "pacman -Si gitty", description: "Show the version available from configured repositories" },
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
"For regular system upgrades, add the Gitty repository to /etc/pacman.conf once.",
|
||||||
|
"If you do not want to change the system file, pass --config on every Pacman invocation or create a dedicated wrapper script.",
|
||||||
|
"The build pipeline creates the package from PKGBUILD, updates the repository database, and publishes both to the CDN.",
|
||||||
|
"Pacman offers an update only after both the package version and repository database have been updated.",
|
||||||
|
],
|
||||||
|
note: "--config applies only to the current Pacman process. A later sudo pacman -Syu does not remember a previously supplied configuration path.",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
enCategories.find((category) => category.id === "branches")?.sections.push(
|
||||||
|
{
|
||||||
|
id: "branches-worktrees-concept",
|
||||||
|
title: "What a worktree is",
|
||||||
|
summary: "One repository has a shared Git database but can have multiple working folders. Each worktree has its own checked-out files and usually its own branch; commits and references are immediately visible from every worktree.",
|
||||||
|
steps: [
|
||||||
|
"The main worktree is the folder you originally opened as the repository.",
|
||||||
|
"An additional worktree is not a full clone: Git objects and history are shared while the working folder and index remain separate.",
|
||||||
|
"File changes stay inside their worktree. A commit created there immediately becomes part of the shared repository.",
|
||||||
|
"Worktrees are useful for parallel features, urgent hotfixes, reviews, or long-running builds on another branch.",
|
||||||
|
"Use a separate clone when you need independent remotes, configuration, or isolated experiments.",
|
||||||
|
],
|
||||||
|
note: "A branch normally cannot be checked out in two worktrees at once. This prevents two folders from changing the same branch inconsistently.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "branches-worktrees",
|
||||||
|
title: "Open multiple branches in parallel with worktrees",
|
||||||
|
summary: "A worktree checks out another branch into a separate folder. This lets you work on multiple tasks without switching the main folder or stashing local changes.",
|
||||||
|
steps: [
|
||||||
|
"Open Worktrees below Tags in the Branch panel.",
|
||||||
|
"Choose Existing branch for a local branch, New branch to create one, or Detached for a fixed commit without a branch.",
|
||||||
|
"Choose an empty destination folder and create the worktree. Open tab then opens it as a separate repository tab.",
|
||||||
|
"Move relocates an inactive worktree. Lock protects it from accidental pruning or removal, for example while stored on an external drive.",
|
||||||
|
"Remove deletes the worktree folder and registration but keeps its branch. Prune removes only stale registrations for missing folders.",
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{ command: "git worktree list", description: "Show all registered worktrees" },
|
||||||
|
{ command: "git worktree add ../project-fix fix/login", description: "Check out an existing branch in a new folder" },
|
||||||
|
{ command: "git worktree add -b feature/name ../project-feature main", description: "Create a branch and worktree from main" },
|
||||||
|
{ command: "git worktree prune --dry-run", description: "Preview stale registrations before pruning" },
|
||||||
|
],
|
||||||
|
note: "A branch can normally be checked out in only one worktree at a time. Commit, stash, or deliberately force removal of local changes before removing a worktree.",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
enCategories.find((category) => category.id === "branches")?.sections.push(
|
enCategories.find((category) => category.id === "branches")?.sections.push(
|
||||||
{
|
{
|
||||||
id: "branches-strategy",
|
id: "branches-strategy",
|
||||||
@@ -1193,6 +1356,76 @@
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
deCategories.splice(1, 0, {
|
||||||
|
id: "changelog",
|
||||||
|
label: "Neu in Gitty",
|
||||||
|
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "changelog-2026-07-21",
|
||||||
|
title: "Version 2026.07.21",
|
||||||
|
summary: "Dieses Release bündelt paralleles Arbeiten mit Worktrees, präzisere Commits und die neue Arch-Linux-Verteilung.",
|
||||||
|
steps: [
|
||||||
|
"Worktree-Verwaltung: zusätzliche Arbeitsordner erstellen, öffnen, verschieben, sperren, entsperren, reparieren, entfernen und veraltete Registrierungen aufräumen.",
|
||||||
|
"Worktrees sind direkt über den neuen Reiter unter Tags erreichbar; Branches lassen sich außerdem aus ihrem Kontextmenü in einem Worktree öffnen.",
|
||||||
|
"Zeilenweises Staging: einzelne Ergänzungen und Löschungen auswählen, per Shift-Klick Bereiche markieren sowie ausgewählte Zeilen stagen, unstagen oder verwerfen.",
|
||||||
|
"Sicherere Dialoge: verständlichere Branch-Löschabfrage und weichgezeichneter Hintergrund bei geöffneten Dialogen.",
|
||||||
|
"Arch-Linux-Pakete: automatisierter Build aus dem PKGBUILD, Veröffentlichung von .pkg.tar.zst und Repository-Datenbank für Pacman auf dem CDN.",
|
||||||
|
"Robustere Remote-Aktionen, zentrale Fehlermeldungen und strukturierte, datensparsame Telemetrie.",
|
||||||
|
"Erweiterte zweisprachige Hilfe mit Worktree-, Pacman- und Line-Staging-Anleitungen.",
|
||||||
|
],
|
||||||
|
note: "In Paketdateien kann dieselbe Version als 2026.7.21 erscheinen, weil Paketmanager numerische Versionssegmente ohne führende Null verwenden.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-7-20",
|
||||||
|
title: "Version 2026.7.20",
|
||||||
|
summary: "Der letzte veröffentlichte Stand konzentrierte sich auf produktiveres Arbeiten, bessere Orientierung und einen stabileren Paket-Build.",
|
||||||
|
steps: [
|
||||||
|
"Vor dem Commit kann eine AI-gestützte Codeprüfung den Staged-Diff analysieren.",
|
||||||
|
"Automatische Aktualisierung hält Repository-Status und Arbeitsbereich auf Wunsch aktuell.",
|
||||||
|
"Repository-Aktionsleiste, Statusdarstellung, Tabs und Diff-Ansicht wurden übersichtlicher gestaltet.",
|
||||||
|
"Die integrierte Hilfe wurde um ausführliche deutsche Git-Dokumentation ergänzt.",
|
||||||
|
"PKGBUILD und Build-Skripte wurden für die Arch-Linux-Verteilung vorbereitet.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
enCategories.splice(1, 0, {
|
||||||
|
id: "changelog",
|
||||||
|
label: "What's new",
|
||||||
|
description: "Changes since the latest published version and notable additions from earlier releases.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "changelog-2026-07-21",
|
||||||
|
title: "Version 2026.07.21",
|
||||||
|
summary: "This release combines parallel worktree workflows, more precise commits, and the new Arch Linux distribution.",
|
||||||
|
steps: [
|
||||||
|
"Worktree management: create, open, move, lock, unlock, repair, remove, and prune additional working folders.",
|
||||||
|
"Worktrees are available from the new entry below Tags; branches can also be opened in a worktree from their context menu.",
|
||||||
|
"Line-level staging: select additions and deletions, Shift-click ranges, and stage, unstage, or discard selected lines.",
|
||||||
|
"Safer dialogs: a clearer branch deletion confirmation and a blurred background while dialogs are open.",
|
||||||
|
"Arch Linux packages: automated PKGBUILD builds plus publication of .pkg.tar.zst and the Pacman repository database to the CDN.",
|
||||||
|
"More robust remote operations, centralized error messages, and structured privacy-conscious telemetry.",
|
||||||
|
"Expanded bilingual help for worktrees, Pacman installation, and line-level staging.",
|
||||||
|
],
|
||||||
|
note: "Package metadata may represent the same release as 2026.7.21 because package managers use numeric version segments without leading zeroes.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "changelog-2026-7-20",
|
||||||
|
title: "Version 2026.7.20",
|
||||||
|
summary: "The latest published version focused on productivity, clearer navigation, and a more stable package build.",
|
||||||
|
steps: [
|
||||||
|
"AI-assisted pre-commit review can analyze the staged diff before committing.",
|
||||||
|
"Optional automatic refresh keeps repository status and the workspace current.",
|
||||||
|
"The repository action bar, status presentation, tabs, and diff view became easier to scan.",
|
||||||
|
"The built-in help gained comprehensive German Git documentation.",
|
||||||
|
"PKGBUILD and build scripts prepared the Arch Linux distribution workflow.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
let { language = "en", onClose = () => {} }: Props = $props();
|
let { language = "en", onClose = () => {} }: Props = $props();
|
||||||
const isGerman = $derived(language === "de");
|
const isGerman = $derived(language === "de");
|
||||||
const categories = $derived(isGerman ? deCategories : enCategories);
|
const categories = $derived(isGerman ? deCategories : enCategories);
|
||||||
@@ -1297,6 +1530,7 @@
|
|||||||
>
|
>
|
||||||
<span class="help-nav-icon">
|
<span class="help-nav-icon">
|
||||||
{#if category.id === "start"}<Home size={17} aria-hidden="true" />
|
{#if category.id === "start"}<Home size={17} aria-hidden="true" />
|
||||||
|
{:else if category.id === "changelog"}<Sparkles size={17} aria-hidden="true" />
|
||||||
{:else if category.id === "app"}<BookOpen size={17} aria-hidden="true" />
|
{:else if category.id === "app"}<BookOpen size={17} aria-hidden="true" />
|
||||||
{:else if category.id === "basics"}<GitCommitHorizontal size={17} aria-hidden="true" />
|
{:else if category.id === "basics"}<GitCommitHorizontal size={17} aria-hidden="true" />
|
||||||
{:else if category.id === "branches"}<GitBranch size={17} aria-hidden="true" />
|
{:else if category.id === "branches"}<GitBranch size={17} aria-hidden="true" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -48,12 +53,20 @@
|
|||||||
|
|
||||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||||
let patchScroll = $state<HTMLDivElement | null>(null);
|
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 {
|
||||||
@@ -64,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;
|
||||||
}
|
}
|
||||||
@@ -78,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 {
|
||||||
@@ -92,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";
|
||||||
@@ -117,7 +157,91 @@
|
|||||||
|
|
||||||
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 {
|
function hunkPosition(index: number): number {
|
||||||
@@ -166,11 +290,47 @@
|
|||||||
{: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:active={selectedCount > 0} class="line-patch-selection-bar">
|
||||||
|
<div>
|
||||||
|
<MousePointer2 size={14} aria-hidden="true" />
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<strong>{selectedCount} {selectedCount === 1 ? "line" : "lines"} selected</strong>
|
||||||
|
<span>Shift-click to select a range.</span>
|
||||||
|
{:else}
|
||||||
|
<strong>Select changed lines</strong>
|
||||||
|
<span>Choose individual additions or deletions below.</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<div class="line-patch-selected-actions">
|
||||||
|
<button class="line-patch-hunk-button discard" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy}>
|
||||||
|
Discard selected
|
||||||
|
</button>
|
||||||
|
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy}>
|
||||||
|
{staged ? "Unstage selected" : "Stage selected"}
|
||||||
|
</button>
|
||||||
|
<button class="btn-sm" type="button" onclick={() => { selectedLineIds = new Set(); }} disabled={isBusy}>Clear</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="line-patch-workspace">
|
<div class="line-patch-workspace">
|
||||||
<div class="line-patch-scroll" bind:this={patchScroll}>
|
<div class="line-patch-scroll" bind:this={patchScroll}>
|
||||||
{#each parsed.hunks as hunk (hunk.id)}
|
{#each parsed.hunks as hunk (hunk.id)}
|
||||||
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
|
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
|
||||||
<div class="line-patch-hunk-head">
|
<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>
|
<code>{hunk.header}</code>
|
||||||
<div class="line-patch-hunk-actions">
|
<div class="line-patch-hunk-actions">
|
||||||
{#if staged}
|
{#if staged}
|
||||||
@@ -193,7 +353,23 @@
|
|||||||
|
|
||||||
<div class="line-patch-lines">
|
<div class="line-patch-lines">
|
||||||
{#each hunk.lines as line (line.id)}
|
{#each hunk.lines as line (line.id)}
|
||||||
<div class={`line-patch-row ${line.kind}`}>
|
<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>
|
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
||||||
<code>{lineBody(line)}</code>
|
<code>{lineBody(line)}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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] ?? "";
|
||||||
|
}
|
||||||
|
|||||||
+79
-9
@@ -1,4 +1,4 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { tracedInvoke as invoke } from "./telemetry";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AiReviewResult,
|
AiReviewResult,
|
||||||
@@ -11,6 +11,9 @@ import type {
|
|||||||
GitCommit,
|
GitCommit,
|
||||||
GitCommitComparison,
|
GitCommitComparison,
|
||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
|
GitRemote,
|
||||||
|
MergeStrategy,
|
||||||
|
PullStrategy,
|
||||||
RebaseCommit,
|
RebaseCommit,
|
||||||
RebasePlanItem,
|
RebasePlanItem,
|
||||||
ReflogEntry,
|
ReflogEntry,
|
||||||
@@ -18,6 +21,7 @@ import type {
|
|||||||
GitStash,
|
GitStash,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
GitTag,
|
GitTag,
|
||||||
|
GitWorktree,
|
||||||
LocalModelOption,
|
LocalModelOption,
|
||||||
PatchApplyAction,
|
PatchApplyAction,
|
||||||
RepositoryBundle,
|
RepositoryBundle,
|
||||||
@@ -28,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 });
|
||||||
}
|
}
|
||||||
@@ -72,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 });
|
||||||
}
|
}
|
||||||
@@ -100,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 });
|
||||||
}
|
}
|
||||||
@@ -255,16 +322,16 @@ export function commitAiReview(path: string, options: CommitAiGenerateOptions):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pull(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>("pull", { 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 fetchRemote(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>("fetch", { 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): Promise<GitStatus> {
|
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 });
|
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> {
|
||||||
@@ -304,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 });
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -68,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;
|
||||||
@@ -85,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;
|
||||||
|
|||||||
@@ -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