Compare commits

..
18 Commits
Author SHA1 Message Date
Christoph afa85b97aa chore(packaging): stabilize build process and resource usage
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 21m57s
publish / Build and publish Arch package (release) Successful in 49m42s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
Updates the PKGBUILD to improve stability and manage memory consumption during the compilation of large Rust dependencies. This involves adding specific environment variables within the build function to constrain compiler resources, alongside refining package options.

- Restrict parallel jobs and set explicit optimization levels for cargo builds
- Disable debug symbols in package options
2026-07-23 18:39:29 +02:00
Christoph 7869efeab5 ci(builder): pass build directory to makepkg command
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 28m52s
publish / Build and publish Arch package (release) Failing after 1h3m16s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
The CI workflow for building applications has been updated to correctly manage the build environment. Previously, makepkg was run without explicitly defining where its output should go, which could lead to unpredictable file placement or conflicts. This change ensures that a dedicated temporary directory is used as the build output location.

- Explicitly create and use a makepkg directory
- Pass BUILDDIR variable to makepkg command
2026-07-23 17:17:33 +02:00
Christoph 2b88e5a189 chore(ci): update build root ownership permissions
publish / Build and publish Arch package (release) Failing after 1m25s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Canceled after 7m5s
The workflow script responsible for application building is updated to adjust file ownership. Previously, ownership was restricted only to the source directory. By changing the scope to the entire build root, we ensure that all generated files and intermediate artifacts are correctly owned by the builder user before running makepkg.
2026-07-23 17:10:08 +02:00
Christoph f7793bc908 refactor(ci): Streamline package building workflow
publish / Build and publish Arch package (release) Failing after 1m23s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Canceled after 6m6s
The CI/CD pipeline has been significantly refactored to improve efficiency and reduce dependency on Docker containers during the build process. The core logic now uses temporary directories and direct execution commands, streamlining both the packaging of tarballs and the generation of the repository database. This change results in a faster and more robust build environment within the runner.

- Removed reliance on docker run for package building
- Simplified the mechanism for updating the pacman repository database
- Added necessary dependencies to base system installation steps
2026-07-23 17:03:57 +02:00
Christoph a1325d85e8 chore(ci): prepare arch container dependencies for building
publish / Build and publish Arch package (release) Failing after 2m17s
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 19m45s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
Added initial setup steps to the GitLite job within the continuous integration workflow. This ensures that the underlying Arch Linux environment is fully updated and contains all necessary system packages (like nodejs, git, and docker) before the main build process begins, improving overall build reliability.
2026-07-23 15:33:45 +02:00
Christoph e7e313b7d7 chore(config): Update git commands and CI runner platform
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 20m7s
publish / Build and publish Arch package (release) Failing after 3s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
Updated local development settings to include comprehensive Git status checks, improving developer workflow visibility. Additionally, the Arch package building job in the CI pipeline now targets archlinux runners for better environment parity during builds.

- Added multiple bash commands for detailed git logging and status retrieval
- Switched app builder workflow runner to archlinux
2026-07-23 13:57:28 +02:00
Christoph 482d7857b5 chore(ci): enhance build diagnostics and update runner platform
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 20m54s
publish / Build and publish Arch package (release) Failing after 1m25s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
This commit updates local configuration settings with additional diagnostic steps to improve visibility into the build environment during execution. Furthermore, the primary application builder workflow has been migrated from running on Arch Linux to Ubuntu 22.04. This change ensures broader compatibility for the CI/CD pipeline while maintaining core publishing functionality.

- Added extensive system and context checks in local settings
- Migrated app builder job runner platform to ubuntu-22.04
2026-07-23 13:22:12 +02:00
Christoph Brandau 8220e36629 feat(ci): automate arch package build and distribution
publish / Build and publish Arch package (release) Failing after 1s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Canceled after 1m59s
The continuous integration pipeline is significantly enhanced to fully automate the process of building, packaging, and publishing Gitty as an Arch Linux repository. A new dedicated workflow job handles version setting, dependency resolution, and package creation using makepkg within a Docker container environment. This ensures that every release is properly packaged and uploaded to the configured MinIO/S3 location for system-wide distribution.

- Added `publish-arch` job to CI pipeline
- Implemented repository management logic in arch_repo.py
- Updated PKGBUILD with necessary desktop dependencies
2026-07-23 00:58:32 +02:00
Christoph Brandau 27c8b40dc6 feat(ui): enhance branch delete confirmation flow
This update introduces a comprehensive and visually refined dialog component for confirming the deletion of branches. The logic now correctly distinguishes between deleting local and remote branches, providing tailored warnings and context to the user. Corresponding global styles were updated to implement the new layout and visual fidelity for this specific dialog type.

- Implements structured display for branch name and location
- Improves distinction between local and remote delete contexts
- Updates backdrop filter CSS properties for better compatibility
2026-07-23 00:33:17 +02:00
Christoph Brandau 6aed0b933e feat(ui): new background for commit cards 2026-07-22 00:06:43 +02:00
Christoph 94083a7106 Merge pull request 'Telemetry' (#24) from telemetry into main
Reviewed-on: #24
2026-07-21 21:51:09 +00:00
Christoph Brandau 647f2b2076 feat(ui): implement global error toast and state management
Introduces a persistent, styled error toast notification system for displaying critical application errors. This required refactoring the local error handling logic in App.svelte to manage complex state objects instead of simple timers, allowing for better control over visibility and interaction (e.g., pausing on hover).

- Adds global CSS styling and animations for the visible error toast component
- Updates error auto-hide mechanism to use structured state management
- Implements interactive features like pause/resume timing on mouse events
2026-07-21 23:45:05 +02:00
Christoph Brandau cae8390b54 feat(telemetry): Implement structured telemetry logging and reporting
Adds comprehensive client-side telemetry capabilities for usage, errors, and performance metrics. This includes integrating OpenTelemetry standards into both the frontend (Svelte) and backend (Tauri/Rust) layers to capture events, spans, and system resource utilization.

The implementation ensures that all collected data is privacy-filtered by design, explicitly excluding sensitive information like repository paths, credentials, source code, or email addresses from being logged.

- Updates README with detailed SigNoz telemetry guide
- Adds process metrics collection (CPU/Memory) in Rust backend
- Exposes `setTelemetryEnabled` state management to the frontend
2026-07-21 23:40:39 +02:00
Christoph Brandau 5c9f67700b Merge branch 'new_featrues' 2026-07-20 23:05:00 +02:00
Christoph 2bd60f0e5b feat(git): enhance error reporting and auth robustness
The commit enhances Git operation reliability by improving how authentication errors are detected and displayed to the user. It standardizes Git output language across different locales and introduces a mechanism to summarize complex raw Git stderr messages, ensuring users receive clear feedback when cloning or interacting with repositories that fail due to credentials.

- Standardize git command output using LC_ALL=C for consistent English messaging.
- Implement error summarization logic to extract the most relevant message from raw Git stderr.
- Update credential dialogs to display summarized and improved authentication failure details.
2026-07-19 16:41:21 +02:00
Christoph Brandau 95c8b01ed1 feat(remote): Enhance remote branch management and stability
Improved handling for deleting remote branches across the application, enhancing both user experience and backend reliability. This includes adding structured logging to all Git remote operations in Rust, refining UI components to handle remote-specific deletion flows, and providing clear status/error feedback in sync settings.

- Standardized styling for action toggles (Stash, Branch, Explorer) using consistent dimensions.
- Implemented detailed console logging for all Git remote operations on the backend.
- Refined dialogs and sync settings to provide explicit status and error messages during remote management.
2026-07-14 00:29:37 +02:00
Christoph Brandau 7800f0fb24 feat(git): expand core git repository management features
This update significantly expands the available Git functionality by adding robust support for initializing repositories, managing remotes, and improving complex workflow operations like merging and reverting commits. New API endpoints are exposed across the backend and frontend to handle remote setup, branch tracking, and conflict resolution workflows.

- Added full remote management capabilities (add, update, remove).
- Implemented advanced merge strategies and commit reversion logic.
- Introduced a dedicated UI component for synchronization settings.
2026-07-13 23:24:36 +02:00
Christoph 35310bec6d Update version to 2026.7.20 2026-07-13 00:27:15 +02:00
29 changed files with 2175 additions and 262 deletions
+18 -1
View File
@@ -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 *)"
] ]
} }
} }
+74
View File
@@ -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
+13 -3
View File
@@ -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
+32
View File
@@ -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.
+86
View File
@@ -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
+20 -3
View File
@@ -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 {
@@ -56,19 +59,33 @@ 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>`
- `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)
+2 -2
View File
@@ -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
View File
@@ -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": {
+253 -123
View File
@@ -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",
+3
View File
@@ -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 = [] }
+351 -15
View File
@@ -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)]
@@ -300,6 +308,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 +369,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 +495,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)?;
@@ -1405,18 +1564,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 +1629,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 +1676,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 +1754,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 +1843,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 +1921,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 +1933,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 +1977,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 +3429,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 +3441,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 +4804,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,9 +5949,16 @@ mod tests {
], ],
); );
let status = pull(repo.path.to_string_lossy().to_string(), None, None) let status = pull(
.await repo.path.to_string_lossy().to_string(),
.unwrap(); None,
None,
None,
None,
None,
)
.await
.unwrap();
assert!(status.clean, "{:?}", status.files); assert!(status.clean, "{:?}", status.files);
assert!(repo.path.join("remote.txt").exists()); assert!(repo.path.join("remote.txt").exists());
@@ -5699,9 +6029,15 @@ 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(
.await repo.path.to_string_lossy().to_string(),
.unwrap(); None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!( assert_eq!(
status.upstream.as_deref(), status.upstream.as_deref(),
+60 -11
View File
@@ -2,26 +2,60 @@
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, amend_commit, apply_file_patch, cancel_code_search,
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit, cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load, cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits, 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, merge_abort, merge_branch, merge_continue,
read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch, resolve_conflict, open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
resolve_conflict_side, restore_file_from_commit, restore_files, restore_reflog_entry, push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote,
restore_to_commit, run_sequence_editor_if_requested, search_code_introductions, stage_files, rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
restore_files, restore_reflog_entry, restore_to_commit, revert_commit,
run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream, stage_files,
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit,
unstage_files, 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 +79,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,11 +118,18 @@ 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,
@@ -124,6 +167,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 +196,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");
+363
View File
@@ -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 -1
View File
@@ -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",
+259 -31
View File
@@ -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,12 @@
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 { import {
amendCommit, amendCommit,
addRemote,
checkoutBranch, checkoutBranch,
cherryPickAbort, cherryPickAbort,
cherryPickCommit, cherryPickCommit,
@@ -59,6 +60,8 @@
createTag, createTag,
deleteBranch, deleteBranch,
deleteTag, deleteTag,
deleteRemoteBranch,
initRepository,
diffFileAgainstWorkingTree, diffFileAgainstWorkingTree,
compareFileToParent, compareFileToParent,
fetchRemote, fetchRemote,
@@ -66,6 +69,7 @@
getStatus, getStatus,
lastCommitMessage, lastCommitMessage,
listBranches, listBranches,
listRemotes,
listStashes, listStashes,
listTags, listTags,
listCommits, listCommits,
@@ -74,12 +78,18 @@
listReflog, listReflog,
listRepositoryFiles, listRepositoryFiles,
mergeBranch, mergeBranch,
mergeAbort,
mergeContinue,
openRepoInExplorer, openRepoInExplorer,
openRepositoryFile, openRepositoryFile,
openRepositoryBundle, openRepositoryBundle,
pull, pull,
push, push,
pushTag, pushTag,
removeRemote,
revertCommit,
setBranchUpstream,
updateRemote,
renameBranch, renameBranch,
rebaseAbort, rebaseAbort,
rebaseBranch, rebaseBranch,
@@ -126,6 +136,8 @@
GitDiffFile, GitDiffFile,
GitFileStatus, GitFileStatus,
GitRepositoryFile, GitRepositoryFile,
GitRemote,
PullStrategy,
GitSearchHit, GitSearchHit,
GitStash, GitStash,
GitStatus, GitStatus,
@@ -144,8 +156,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";
@@ -177,6 +191,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 +252,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;
@@ -344,7 +372,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 +420,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 +475,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 +511,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.
} }
@@ -794,6 +823,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 +840,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 +910,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 +1118,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 {
@@ -1725,9 +1778,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 +2052,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 +2314,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 = "";
@@ -2314,8 +2392,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 +2677,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 +2701,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 +2722,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 +2742,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 +2867,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;
@@ -3570,6 +3737,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 +3794,9 @@
onInteractiveRebase={openInteractiveRebase} onInteractiveRebase={openInteractiveRebase}
onReflog={openReflog} onReflog={openReflog}
onOpenInExplorer={openActiveRepoInExplorer} onOpenInExplorer={openActiveRepoInExplorer}
onFetchPrune={fetchPruneRepo}
onForcePush={forcePushRepo}
onSyncOptions={openSyncOptions}
/> />
{/if} {/if}
@@ -3656,14 +3827,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">
<span>{errorMessage}</span> <section
</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>
</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>
</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 +3867,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 +3933,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,6 +4111,7 @@
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}
@@ -4115,6 +4323,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);
@@ -4291,7 +4500,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 +4593,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
+327 -29
View File
@@ -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; }
@@ -2124,19 +2239,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 +3057,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 +3251,16 @@
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;
}
.ai-settings-dialog { .ai-settings-dialog {
display: block; display: block;
width: min(560px, calc(100vw - 32px)); width: min(560px, calc(100vw - 32px));
@@ -3633,7 +3765,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 +3829,61 @@
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; }
.discard-target-list { .discard-target-list {
display: grid; display: grid;
gap: 4px; gap: 4px;
@@ -5199,10 +5409,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 +5506,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 +6241,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; }
+18 -3
View File
@@ -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>
+3
View File
@@ -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
+1 -1
View File
@@ -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> <div class="branch-delete-heading">
<span class="eyebrow">{force ? "Force delete" : "Delete branch"}</span> <span class:force class="branch-delete-heading-icon" aria-hidden="true">
<p class="dialog-title">{title}</p> <Trash2 size={16} />
</span>
<div>
<span class="eyebrow">{branch.remote ? "Remote branch" : force ? "Force delete" : "Delete branch"}</span>
<p class="dialog-title" id="branch-delete-title">{title}</p>
</div>
</div> </div>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close"> <button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" /> <X size={16} aria-hidden="true" />
@@ -35,27 +43,35 @@
</header> </header>
<div class="branch-delete-body"> <div class="branch-delete-body">
<div class="discard-warning-icon" aria-hidden="true"> <div class:force class="discard-warning-icon branch-delete-warning-icon" aria-hidden="true">
<AlertTriangle size={22} /> <AlertTriangle size={22} />
</div> </div>
<div class="discard-confirm-copy"> <div class="discard-confirm-copy">
<p> <p class="branch-delete-lead">
{#if force} {#if branch.remote}
This branch is not fully merged. Force deleting removes the branch pointer even if some commits are only reachable from this branch. This branch will be removed from the shared remote repository.
{:else if force}
This branch is not fully merged. Some commits may only exist here.
{:else} {:else}
Delete this local branch from the repository? This branch will be removed from your local repository.
{/if} {/if}
</p> </p>
<code class="discard-target" title={branch.name}> <div class="branch-delete-target" title={branch.name}>
<GitBranch size={13} aria-hidden="true" /> <span class="branch-delete-target-icon" aria-hidden="true"><GitBranch size={16} /></span>
{branch.name} <span class="branch-delete-target-copy">
</code> <code>{branchName}</code>
<span>{branchLocation}</span>
</span>
<span class:remote={branch.remote} class="branch-delete-scope">{branch.remote ? "Remote" : "Local"}</span>
</div>
<p class="discard-warning-text"> <p class="discard-warning-text">
{#if force} {#if branch.remote}
Make sure you no longer need the unique commits on this branch. This affects everyone using <strong>{remoteParts[0] || "the remote"}</strong>. Your local branch is kept.
{:else if force}
Force deletion can make unmerged commits difficult to recover.
{:else} {:else}
Git will refuse if the branch is not fully merged. Git will stop the deletion if the branch contains unmerged commits.
{/if} {/if}
</p> </p>
</div> </div>
@@ -63,13 +79,13 @@
<footer class="discard-confirm-actions"> <footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button> <button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-danger" type="button" onclick={onConfirm} disabled={isBusy}> <button class="btn-danger branch-delete-confirm" type="button" onclick={onConfirm} disabled={isBusy}>
{#if isBusy} {#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" /> <LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else} {:else}
<Trash2 size={15} aria-hidden="true" /> <Trash2 size={15} aria-hidden="true" />
{/if} {/if}
{force ? "Force delete" : "Delete"} {branch.remote ? "Delete from remote" : force ? "Force delete" : "Delete"}
</button> </button>
</footer> </footer>
</div> </div>
+7 -5
View File
@@ -54,6 +54,7 @@
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>;
@@ -74,6 +75,7 @@
onCreateBranch = () => {}, onCreateBranch = () => {},
onRenameBranch = () => {}, onRenameBranch = () => {},
onDeleteBranch = () => {}, onDeleteBranch = () => {},
onDeleteRemoteBranch = () => {},
onCreateTag = () => {}, onCreateTag = () => {},
onDeleteTag = () => {}, onDeleteTag = () => {},
onPushTag = () => {}, onPushTag = () => {},
@@ -264,9 +266,9 @@
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 checkoutContextBranch() { async function checkoutContextBranch() {
@@ -669,11 +671,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}
+12
View File
@@ -42,6 +42,7 @@
onToggleCommitFiles: (hash: string) => void; onToggleCommitFiles: (hash: string) => void;
onCreateBranchFromCommit: (commit: GitCommit) => void; onCreateBranchFromCommit: (commit: GitCommit) => void;
onCherryPickCommit: (commit: GitCommit) => void; onCherryPickCommit: (commit: GitCommit) => void;
onRevertCommit: (commit: GitCommit) => void;
} }
let { let {
@@ -58,6 +59,7 @@
onToggleCommitFiles = () => {}, onToggleCommitFiles = () => {},
onCreateBranchFromCommit = () => {}, onCreateBranchFromCommit = () => {},
onCherryPickCommit = () => {}, onCherryPickCommit = () => {},
onRevertCommit = () => {},
}: Props = $props(); }: Props = $props();
let hiddenGraphBranches = $state<Set<string>>(new Set()); let hiddenGraphBranches = $state<Set<string>>(new Set());
@@ -406,6 +408,13 @@
await onCherryPickCommit(commit); await onCherryPickCommit(commit);
} }
async function revertContextCommit() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
await onRevertCommit(commit);
}
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
closeCommitContextMenu(); closeCommitContextMenu();
@@ -718,6 +727,9 @@
<Cherry size={14} aria-hidden="true" /> <Cherry size={14} aria-hidden="true" />
Cherry-pick Cherry-pick
</button> </button>
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title="Create a new commit that reverses this commit">
<RotateCcw size={14} aria-hidden="true" /> Revert
</button>
</div> </div>
{/if} {/if}
</section> </section>
@@ -0,0 +1,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>
+14
View File
@@ -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] ?? "";
}
+28 -9
View File
@@ -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,
@@ -28,6 +31,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 +79,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 });
} }
@@ -255,16 +271,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 +320,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 });
+72
View File
@@ -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");
});
}
+5
View File
@@ -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;
+3
View File
@@ -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.");
} }