Compare commits

..
48 Commits
Author SHA1 Message Date
Christoph 9d0b9de142 Merge pull request 'feat(analytics): add optional Aptabase event tracking UI' (#21) from features/aptabase into main
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 17m51s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 27m1s
Reviewed-on: #21
2026-07-07 15:37:02 +00:00
Christoph Brandau e503786211 feat(analytics): add optional Aptabase event tracking UI
Integrate Aptabase analytics via a Tauri plugin and add a privacy
notice flow plus a settings dialog to control whether anonymous
usage events are sent. The app now tracks key user actions while
ensuring analytics never blocks core Git operations.

- Add analytics tracking helper and Aptabase plugin wiring
- Persist analytics consent in localStorage and gate tracking
- Introduce notice and settings dialogs, plus new event calls
2026-07-07 17:35:20 +02:00
Christoph Brandau d4ac449074 feat(blame): add file blame dialog with git porcelain parsing
This change introduces a new backend command to fetch file blame using
git's line-porcelain output and returns structured per-line metadata.
The UI adds a BlameDialog that groups lines by commit, highlights
uncommitted changes, and styles the dialog to match the updated theme.

- Add get_file_blame command and parsing with uncommitted detection
- Wire BlameDialog into the explorer file node actions
- Add blame UI component and supporting types and styles
2026-07-07 15:09:25 +02:00
Christoph Brandau a67663f3c5 Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-07-07 10:30:35 +02:00
Christoph Brandau 9e48727fce chore(release): bump app version to 2026.7.14
Update the project and Tauri configuration version to 2026.7.14.
This keeps the package metadata and desktop app build in sync for
consistent release tracking.

- Bump version in package.json
- Bump version in tauri.conf.json
2026-07-07 10:28:18 +02:00
Christoph a09335b729 Update version to 2026.7.13 2026-07-07 10:27:33 +02:00
Christoph da05a8461f Update version to 2026.7.0 2026-07-07 10:27:33 +02:00
Christoph 21366cb26b Update version to 2026.7.14 2026-07-06 21:10:29 +02:00
Christoph a6312af6d7 Merge pull request 'Features/extend git features' (#20) from features/extend_git_features into main
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 16m33s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 28m3s
Reviewed-on: #20
2026-07-06 19:07:49 +00:00
Christoph Brandau 899f024a4f feat(ui): default credential dialog to save session
The credential dialog now starts with session saving enabled by
default, reducing extra user interaction for the common case. This
improves usability while keeping the option available to change.

- Set saveSession default to true in CredentialDialog
2026-07-06 21:06:58 +02:00
Christoph Brandau aa61199b43 feat(history): highlight ahead/behind vs active upstream
Repo status updates for the management view are now refreshed in the
background after switching, without blocking the initial open flow.
The history graph and hover labels now distinguish local vs upstream
branches, visually marking commits that are ahead or behind.

- Add background fetch helper and shared status update logic
- Pass active upstream into HistoryPanel and render sync styling
2026-07-06 20:58:01 +02:00
Christoph Brandau 98f3902839 style(ui): improve ahead/behind colors and amend toggle styling
Adjust the repo ahead/behind meta colors and add subtle background
highlights for better visual distinction. Also introduce dedicated
CSS for the commit amend toggle input, including checked, focus, and
disabled states to improve usability and accessibility.

- Refine ahead/behind meta color and background emphasis
- Add complete styling for commit amend toggle states
2026-07-06 20:24:07 +02:00
Christoph Brandau d1d3604a6d feat(repo-status): cache and background-refresh repo ahead/behind
Repo branch status is now cached locally and refreshed in the
background so management and tab lists show up-to-date data without
manual fetches. The background worker updates repos in a round-robin
batch to avoid running multiple git subprocesses at once, and the UI
renders ahead/behind/changed counts alongside branch names.

- Add localStorage-backed repo status cache for known repos
- Background polling updates open tabs and recent/known repos
- Show ahead/behind/changed metadata in repository management UI
2026-07-06 20:04:13 +02:00
Christoph Brandau ce0cbc0786 feat(commit): add amend and undo last commit support
This change introduces new Tauri commands to amend the last commit with
an optional message, fetch the last commit message, and undo the most
recent commit safely. The UI now offers an amend toggle and an undo
button only when the last commit hasn’t been pushed upstream, reducing
the risk of rewriting shared history.

- Add amend/undo/last-message git commands in Rust
- Wire new operations into the Svelte commit panel UI
- Add styling and state handling for amend mode
2026-07-06 17:34:05 +02:00
Christoph Brandau 0bad722c7a feat(git): add tag management and cherry-pick workflow
This update extends the Git backend and UI to support listing,
creating, deleting, and pushing tags. It also adds cherry-pick
commands with proper in-progress detection and conflict handling, and
prevents other operations while a cherry-pick is active.

- Add GitTag model, tag listing, and tag CRUD/push commands
- Implement cherry-pick start/continue/abort with status tracking
- Update UI to display tags and gate actions during cherry-pick
2026-07-06 15:05:12 +02:00
Christoph 247c118bdf Merge pull request 'fix(styles): show graph hover branches only on gutter hover' (#19) from opt/app_layout into main
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 16m35s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 26m22s
Reviewed-on: #19
2026-07-06 09:45:35 +00:00
Christoph c59cb412dd Update version to 2026.7.13 2026-07-06 11:39:13 +02:00
Christoph 1b83429c06 Merge pull request 'Opt/app layout' (#18) from opt/app_layout into main
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Has been cancelled
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Has been cancelled
Reviewed-on: #18
2026-07-06 09:36:07 +00:00
Christoph Brandau 3e2885f64d fix(styles): show graph hover branches only on gutter hover
Hover behavior for graph branch highlights was adjusted to limit the
opacity/transform changes to the gutter hover state. This prevents the
highlight from triggering when hovering over the graph rows, reducing
unintended visual effects.
2026-07-06 11:35:46 +02:00
Christoph Brandau eac0f059fa feat(history): improve commit hover branch labels and titles
The history panel now generates more informative hover text for commits.
It prefers direct local branch refs when available, otherwise it derives
a concise list from containing branch labels and summarizes overflow.

- Add helpers to compute hover branch labels and title text
- Update the graph row hover rendering to use the new helpers
2026-07-06 11:31:41 +02:00
Christoph Brandau aaa4228b82 feat(history): add commit action context menu in HistoryPanel
The commit history UI now provides a compact actions menu per commit,
with positioning constrained to the history panel. The menu supports
keyboard dismissal via Escape and closes on outside clicks to keep the
interaction predictable.

- Replace per-commit action buttons with an ellipsis menu
- Add context menu state and panel-relative positioning logic
- Style the new history context menu and menu button
2026-07-06 11:16:52 +02:00
Christoph e5c26ea502 Update version to 2026.7.0 2026-07-06 08:41:32 +02:00
Christoph Brandau 38b4f98233 feat(ci): improve Tauri build pipeline and artifact collection
The CI workflow now installs the required Rust toolchain and uv on
Ubuntu, and passes a no-strip flag into the Tauri build to control
binary stripping behavior per platform. The CI helper script was
updated to collect only expected bundle and signature files, reducing
noise and improving upload correctness. Versioning was aligned across
the Node package and Tauri config for the new release.

- Add platform-specific no_strip handling in the build workflow
- Install Rust toolchain/uv for Ubuntu builds
- Filter collected artifacts by expected extensions and signatures
2026-07-06 08:23:49 +02:00
Christoph Brandau d592894f28 fix(git): split long path args to avoid Windows CreateProcess limit
Git operations that stage or restore many files could exceed the Windows
command line length limit, causing os error 206 failures. This change
splits file paths into size-bounded chunks and concatenates the output
from multiple git invocations to keep commands within safe limits.

- Chunk file path arguments for Windows compatibility
- Add a local dev command to list pkg-config packages
2026-07-06 08:22:41 +02:00
Christoph 9ee2f71dc3 Update .gitea/workflows/app_builder.yaml
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 17m5s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 29m36s
2026-07-06 06:17:03 +00:00
Christoph 283779610a Merge pull request 'Build/arch image' (#17) from build/arch_image into main
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Failing after 16m49s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Has been cancelled
Reviewed-on: #17
2026-07-06 05:40:19 +00:00
Christoph Brandau d33f576212 ci(app): install Rust toolchain on Ubuntu 22.04 builds
The workflow now sets up a Rust toolchain only for the Ubuntu
22.04 runner. This ensures Rust-based components can compile
reliably without affecting other platforms.

- Add conditional Rust toolchain install for ubuntu-22.04
2026-07-06 07:33:16 +02:00
Christoph a3fd8f1ce8 Update version to 0.100.0
publish / publish-tauri (appimage, ubuntu-22.04, linux-x86_64) (release) Failing after 1m4s
publish / publish-tauri (nsis, windows-latest, windows-x86_64) (release) Failing after 13m46s
2026-07-06 07:15:04 +02:00
Christoph de06e7a15d feat(ci): control symbol stripping via NO_STRIP matrix var
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Failing after 1m7s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Has been cancelled
The workflow now passes a per-platform NO_STRIP setting into the
Tauri build environment. This allows builds to retain symbols when
needed, improving debugging and crash analysis without changing the
default behavior for other targets.

- Add matrix no_strip values for Windows and Ubuntu
- Export NO_STRIP into the build environment for Tauri
2026-07-05 23:28:23 +02:00
Christoph 0105e0d46e feat(cicd): collect only expected artifact bundle files
Artifact collection previously grabbed every file under each bundle
directory, which could include unrelated or intermediate outputs. This
updates the logic to only include files with the expected extensions
for each artifact type, improving accuracy and reducing noise.

- Use a per-bundle suffix map to filter collected artifacts
- Switch to non-recursive directory iteration for bundle roots
2026-07-05 23:20:28 +02:00
Christoph 0732f9ca6d Merge pull request 'feat(ci): build Linux AppImage and update updater metadata' (#16) from build/arch_image into main
publish / publish-tauri (nsis, windows-latest, windows-x86_64) (release) Has been cancelled
publish / publish-tauri (appimage, ubuntu-22.04, linux-x86_64) (release) Failing after 2m18s
Reviewed-on: #16
2026-07-05 21:12:29 +00:00
Christoph fcaaa770d6 feat(ci): build Linux AppImage and update updater metadata
The CI workflow now builds a single bundle per run, adds Linux
AppImage output, and updates the Tauri version using a Node script.
The publishing tool is extended to recognize Linux artifacts,
select a primary artifact based on the target platform, and merge
platform entries into an existing latest.json when present.

- Limit matrix parallelism and pass bundle targets to Tauri
- Update updater platform handling and latest.json merging
- Add Linux Tauri config for AppImage bundling
2026-07-05 23:10:52 +02:00
Christoph 379350b9ee Update version to 2026.7.12 2026-07-05 11:05:49 +02:00
Christoph 5f7899bcee Merge pull request 'Feature/clone repo' (#15) from feature/clone_repo into main
publish / publish-tauri (, windows-latest) (release) Successful in 26m10s
Reviewed-on: #15
2026-07-04 23:24:27 +00:00
Christoph b646a2c647 feat(clone): support credentialed git clone with askpass
Cloning now accepts optional username/password and passes them to the
Rust git layer via GIT_ASKPASS, avoiding interactive prompts. The UI
detects authentication failures, opens the credential dialog for clone,
and auto-hides error messages to keep the flow smooth.

- Add username/password support to clone_repository and git.ts
- Detect auth failures and route users to the clone credential dialog
- Improve clone UX with a dedicated loading overlay and timed errors
2026-07-05 01:18:26 +02:00
Christoph 32497d53df feat(clone): add repository cloning flow to UI and backend
This introduces a new Tauri command to clone a remote repository into a
validated destination folder, then return the full repository bundle for
immediate rendering. The Svelte app gains a clone dialog and a new action
in repository management, wiring the cloned bundle into existing refresh
helpers. Dialog backdrops were also adjusted to rely on explicit close
controls.

- Add clone_repository command and core cloning logic in Rust
- Create CloneRepositoryDialog and integrate it into App.svelte
- Improve clone-related styling and tighten dialog backdrop behavior
2026-07-05 01:05:23 +02:00
Christoph 6365e164aa update for linux 2026-07-04 18:14:25 +02:00
Christoph f6af156fbd install on linux 2026-07-04 17:49:08 +02:00
Christoph 33cef059e8 Update version to 2026.7.11 2026-07-04 13:56:50 +02:00
Christoph Brandau 9a4c6e5b9b feat(branches): add force delete flow for local branches
publish / publish-tauri (, windows-latest) (release) Successful in 22m57s
Branch deletion now supports an optional force mode in the Tauri
command, enabling deletion of branches that are not fully merged.
The UI replaces the simple confirm prompt with a dedicated dialog and
auto-escalates to force delete when Git rejects a normal delete.

- Add force flag to delete_branch command and tests
- Implement BranchDeleteConfirmDialog and wire it into App.svelte
- Update branch context menu actions and styling
2026-07-04 00:52:40 +02:00
Christoph c2d7fefb47 Merge pull request 'feat(git): add rebase workflow with UI state handling' (#14) from feature/rebase into master
Reviewed-on: #14
2026-07-03 22:36:42 +00:00
Christoph ede2e46d50 Merge pull request 'feat(git stash): add stash listing and push/apply/pop/drop UI' (#13) from feature/stash into master
Reviewed-on: #13
2026-07-03 22:36:24 +00:00
Christoph Brandau 3c4425a408 feat(git): add rebase workflow with UI state handling
This introduces Git rebase commands (start, continue, abort) and
exposes whether a rebase is currently in progress via the status
payload. The UI now blocks committing during an active rebase and
shows a dedicated rebase notice with continue/abort actions.

- Add tauri commands for rebase operations and status detection
- Update frontend to render rebase state and controls
- Adjust conflict/resolution messaging and related UI styling
2026-07-04 00:34:36 +02:00
Christoph Brandau 1d67312ee4 feat(git stash): add stash listing and push/apply/pop/drop UI
This change introduces Git stash support end-to-end, including a new
backend command to list stashes and operations to push, apply, pop, and
drop them. The frontend now fetches stashes as part of the repository
bundle and provides a dedicated panel to manage shelved changes.

- Add GitStash model and Tauri commands for stash operations
- Implement StashPanel component and wire it into the app
- Adjust sidebar layout and add stash-specific styling
2026-07-04 00:16:27 +02:00
Christoph 201bb90bf7 Merge pull request 'feat(history-ui): add resizable commit history aside' (#12) from feature/git_graph into master
Reviewed-on: #12
2026-07-03 21:39:58 +00:00
Christoph Brandau 9c371ec520 feat(history-ui): add resizable commit history aside
The commit history panel is now resizable with a draggable handle and
keyboard support. The chosen width is persisted in local storage so the
layout stays consistent across sessions. Git commit loading was also
adjusted to include all branch tips for a more complete graph.

- Add width persistence and pointer/keyboard resizing for history panel
- Update commit graph query to use topo-order across all refs
- Extend tests to ensure branch tips are included in commit results
2026-07-03 23:35:46 +02:00
Christoph Brandau 835bfae254 feat(history): enhance commit history UI with richer styling
The commit history panel now renders a more structured commit header,
including an avatar, kind badge, and clearer metadata layout. The git
graph visuals were also refined with smoother paths and improved hover
effects to make the history easier to scan.

- Add commit kind/root/merge badges and author initials
- Improve graph rendering with curved paths and hover emphasis
- Update CSS for commit cards, ref chips, and graph styling
2026-07-03 21:32:20 +02:00
Christoph 2bacd473fc Update version to 2026.7.10 2026-07-03 17:17:44 +02:00
39 changed files with 5283 additions and 262 deletions
+15 -1
View File
@@ -78,7 +78,21 @@
"Bash(rustfmt --edition 2024 --check src/badge.rs src/main.rs)",
"Bash(rustfmt --edition 2024 --check src/git.rs)",
"Bash(rustfmt --edition 2024 --check src/badge.rs)",
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)"
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)",
"Bash(pkg-config --list-all)",
"Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata src/git.rs)",
"Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata /mnt/d/Development/GitLite/src-tauri/src/git.rs)",
"Bash(rustc --edition 2021 --crate-type bin -o /dev/null --emit=metadata src/main.rs)",
"Bash(grep -B1 \"^error\\\\[E0432\\\\]\\\\|^error$\")",
"Bash(grep -v \"^--$\")",
"Bash(grep \"^error$\" -A2)",
"Bash(cargo run *)",
"Bash(node_modules/.bin/svelte-check --version)",
"Bash(rustfmt --check --edition 2021 src/git.rs src/main.rs)",
"Bash(rustfmt --check --edition 2021 src/git.rs)",
"Bash(awk *)",
"Bash(rustfmt --edition 2021 /tmp/blame_chunk.rs)",
"Read(//tmp/**)"
]
}
}
+25 -14
View File
@@ -32,16 +32,21 @@ jobs:
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
# - platform: 'macos-latest'
# args: '--target aarch64-apple-darwin'
# - platform: 'macos-latest'
# args: '--target x86_64-apple-darwin'
# - platform: 'ubuntu-22.04'
# args: ''
- platform: "windows-latest"
args: ""
bundles: "nsis"
updater_platform: "windows-x86_64"
no_strip: ""
- platform: "ubuntu-22.04"
bundles: "appimage"
updater_platform: "linux-x86_64"
no_strip: "1"
runs-on: ${{ matrix.platform }}
@@ -55,13 +60,21 @@ jobs:
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install uv
if: matrix.platform == 'ubuntu-22.04'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
- name: install rust toolchain
if: matrix.platform == 'ubuntu-22.04'
uses: dtolnay/rust-toolchain@stable
- name: install frontend dependencies
run: npm ci
@@ -69,18 +82,13 @@ jobs:
run: npm version ${{ github.ref_name }} --no-git-tag-version --allow-same-version
- name: Update tauri.conf.json Version
shell: powershell
env:
RELEASE_VERSION: ${{ github.ref_name }}
run: |
$path = ".\src-tauri\tauri.conf.json"
$version = '${{ github.ref_name }}'
$content = Get-Content -Raw -Encoding UTF8 $path
$regex = [regex]'"version"\s*:\s*"[^"]*"'
$replacement = '"version": "' + $version + '"'
$updated = $regex.Replace($content, $replacement, 1)
$encoding = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($path, $updated, $encoding)
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
- name: Commit and Push Changes
if: matrix.platform == 'windows-latest'
shell: powershell
run: |
git config user.name '${{ vars.USERNAME_GIT }}'
@@ -131,12 +139,15 @@ jobs:
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
run: npm run tauri build
NO_STRIP: ${{ matrix.no_strip }}
run: npm run tauri -- build --bundles ${{ matrix.bundles }}
# ----------------------
# Upload to MinIO (via mc) and create latest.json
# ----------------------
- name: Upload artifacts to MinIO with cicd_tool
working-directory: GitLite/cicd_tool
env:
PUBLISH_PLATFORM: ${{ matrix.updater_platform }}
run: |
uv sync
uv run main.py
+122 -29
View File
@@ -4,7 +4,7 @@ import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, List, Optional
from typing import Any, Iterable, List, Optional
from urllib.parse import urlparse
from minio import Minio
@@ -115,23 +115,57 @@ def _ensure_bucket(client: Minio, bucket_name: str) -> None:
def _collect_artifacts() -> List[Path]:
artifacts: List[Path] = []
for directory in (
ARTIFACT_ROOT / "msi",
ARTIFACT_ROOT / "nsis",
ARTIFACT_ROOT / "app",
):
bundle_dirs = {
ARTIFACT_ROOT / "msi": (".msi", ".sig"),
ARTIFACT_ROOT / "nsis": (".exe", ".sig"),
ARTIFACT_ROOT / "appimage": (".appimage", ".sig"),
ARTIFACT_ROOT / "deb": (".deb", ".sig"),
ARTIFACT_ROOT / "rpm": (".rpm", ".sig"),
ARTIFACT_ROOT / "app": (".dmg", ".zip", ".sig"),
}
for directory, suffixes in bundle_dirs.items():
if directory.exists():
artifacts.extend(p for p in directory.rglob("*") if p.is_file())
artifacts.extend(
p
for p in directory.iterdir()
if p.is_file() and p.suffix.lower() in suffixes
)
return artifacts
def _select_primary_artifact(artifacts: Iterable[Path]) -> Optional[Path]:
priority = (".exe", ".msi", ".zip", ".dmg")
def _select_primary_artifact(
artifacts: Iterable[Path], platform: Optional[str] = None
) -> Optional[Path]:
artifact_list = list(artifacts)
priorities = {
"windows-x86_64": (".exe", ".msi"),
"linux-x86_64": (".appimage", ".deb", ".rpm"),
"darwin-x86_64": (".dmg", ".zip"),
"darwin-aarch64": (".dmg", ".zip"),
}
priority = priorities.get(platform or "", ())
priority += (".exe", ".msi", ".appimage", ".deb", ".rpm", ".zip", ".dmg")
for ext in priority:
for artifact in artifacts:
for artifact in artifact_list:
if artifact.suffix.lower() == ext:
return artifact
return next(iter(artifacts), None)
return next(
(artifact for artifact in artifact_list if artifact.suffix != ".sig"), None
)
def _infer_platform(artifact: Path) -> str:
suffix = artifact.suffix.lower()
if suffix in {".exe", ".msi"}:
return "windows-x86_64"
if suffix in {".appimage", ".deb", ".rpm"}:
return "linux-x86_64"
if suffix in {".dmg", ".zip"}:
return "darwin-x86_64"
raise ConfigurationError(
f"Cannot infer updater platform from artifact: {artifact.name}. "
"Set PUBLISH_PLATFORM explicitly."
)
def _read_signature(artifact: Path) -> str:
@@ -145,16 +179,55 @@ def _read_signature(artifact: Path) -> str:
raise FileNotFoundError(f"Signature not found for {artifact.name}")
def _build_latest_json(
def _build_artifact_url(
version: str,
primary_artifact: Path,
signature: str,
artifact_base_url: str,
app_dir: str,
) -> str:
base_url = artifact_base_url.rstrip("/")
path_parts = [app_dir.strip("/"), version, primary_artifact.name]
artifact_url = "/".join([base_url, *filter(None, path_parts)])
return "/".join([base_url, *filter(None, path_parts)])
def _read_existing_latest_json(
client: Minio, bucket_name: str
) -> Optional[dict[str, Any]]:
try:
response = client.get_object(bucket_name, "latest.json")
except S3Error as exc:
if exc.code in {"NoSuchKey", "NoSuchObject"}:
return None
raise
try:
payload = json.loads(response.read().decode("utf-8"))
except json.JSONDecodeError as exc:
raise ConfigurationError("Existing latest.json is invalid JSON") from exc
finally:
response.close()
response.release_conn()
if not isinstance(payload, dict):
raise ConfigurationError("Existing latest.json must be a JSON object")
return payload
def _build_latest_payload(
version: str,
platform: str,
primary_artifact: Path,
signature: str,
artifact_base_url: str,
app_dir: str,
existing_payload: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
artifact_url = _build_artifact_url(
version=version,
primary_artifact=primary_artifact,
artifact_base_url=artifact_base_url,
app_dir=app_dir,
)
pub_date = (
datetime.now(timezone.utc)
@@ -163,18 +236,27 @@ def _build_latest_json(
.replace("+00:00", "Z")
)
latest_payload = {
platforms: dict[str, Any] = {}
existing_notes = None
if existing_payload and existing_payload.get("version") == version:
existing_platforms = existing_payload.get("platforms")
if isinstance(existing_platforms, dict):
platforms.update(existing_platforms)
existing_notes = existing_payload.get("notes")
platforms[platform] = {
"signature": signature,
"url": artifact_url,
}
return {
"version": version,
"pub_date": pub_date,
"notes": os.environ.get("RELEASE_NOTES", "Automated release"),
"platforms": {
"windows-x86_64": {
"signature": signature,
"url": artifact_url,
}
},
"notes": os.environ.get("RELEASE_NOTES")
or existing_notes
or "Automated release",
"platforms": platforms,
}
return json.dumps(latest_payload, separators=(",", ":"))
def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
@@ -194,28 +276,36 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
"No release artifacts found in src-tauri/target/release/bundle."
)
primary = _select_primary_artifact(artifacts)
publish_platform = os.environ.get("PUBLISH_PLATFORM") or os.environ.get(
"UPDATER_PLATFORM"
)
primary = _select_primary_artifact(artifacts, publish_platform)
if not primary:
raise FileNotFoundError("Unable to determine primary artifact to publish.")
platform = publish_platform or _infer_platform(primary)
signature = _read_signature(primary)
latest_json = _build_latest_json(
client = _create_minio_client()
_ensure_bucket(client, bucket_name)
existing_latest_json = _read_existing_latest_json(client, bucket_name)
latest_payload = _build_latest_payload(
version=resolved_version,
platform=platform,
primary_artifact=primary,
signature=signature,
artifact_base_url=artifact_base_url,
app_dir=bucket_prefix,
existing_payload=existing_latest_json,
)
latest_json = json.dumps(latest_payload, separators=(",", ":"))
local_dist_dir = PROJECT_DIR / "dist"
local_dist_dir.mkdir(parents=True, exist_ok=True)
latest_json_path = local_dist_dir / "latest.json"
latest_json_path.write_text(latest_json + "\n", encoding="utf-8")
client = _create_minio_client()
_ensure_bucket(client, bucket_name)
for artifact in artifacts:
object_segments = [part for part in (resolved_version, artifact.name) if part]
object_name = "/".join(object_segments)
@@ -231,7 +321,10 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
length=len(latest_bytes),
content_type="application/json",
)
print(f"Uploaded latest.json -> s3://{bucket_name}/{latest_object_name}")
print(
f"Uploaded latest.json for {platform} -> "
f"s3://{bucket_name}/{latest_object_name}"
)
if __name__ == "__main__":
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "tauri-git-lite",
"version": "2026.7.9",
"name": "git-lite",
"version": "2026.7.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tauri-git-lite",
"version": "2026.7.9",
"name": "git-lite",
"version": "2026.7.14",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "git-lite",
"version": "2026.7.9",
"version": "2026.7.14",
"private": true,
"type": "module",
"scripts": {
+166 -3
View File
@@ -1128,7 +1128,7 @@ dependencies = [
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -2027,6 +2027,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -2034,7 +2043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -2048,6 +2057,12 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -2526,8 +2541,10 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-aptabase",
"tauri-plugin-dialog",
"tauri-plugin-updater",
"tokio",
]
[[package]]
@@ -2895,6 +2912,22 @@ dependencies = [
"webpki-roots 1.0.8",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -4007,6 +4040,23 @@ dependencies = [
"typenum",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework 3.7.0",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -4059,6 +4109,18 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "no_std_io2"
version = "0.9.4"
@@ -4523,12 +4585,49 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -4554,6 +4653,22 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_info"
version = "3.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
dependencies = [
"android_system_properties",
"log",
"nix 0.31.3",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
"serde",
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
@@ -5342,11 +5457,13 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -5357,6 +5474,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -6618,6 +6736,15 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "sysctl"
version = "0.6.0"
@@ -6877,6 +7004,26 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-aptabase"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "075ebe083b5f311bdfbf436f8383d316dfb12267be5fd977b57d7701bdb99987"
dependencies = [
"futures",
"log",
"os_info",
"rand 0.9.4",
"reqwest 0.12.28",
"serde",
"serde_json",
"sys-locale",
"tauri",
"tauri-plugin",
"time",
"tokio",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.0"
@@ -7308,6 +7455,16 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rayon"
version = "2.1.0"
@@ -7942,6 +8099,12 @@ dependencies = [
"uuid 0.8.2",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -8952,7 +9115,7 @@ dependencies = [
"futures-sink",
"futures-util",
"hex",
"nix",
"nix 0.29.0",
"ordered-stream",
"rand 0.8.6",
"serde",
+2
View File
@@ -17,8 +17,10 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "=2.7.0"
tauri-plugin-aptabase = "1.0"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
commit_ai = { path = "crates/commit_ai" }
tokio = "1.52.3"
[build-dependencies]
tauri-build = { version = "2", features = [] }
+1
View File
@@ -1,3 +1,4 @@
fn main() {
println!("cargo:rerun-if-env-changed=APTABASE_KEY");
tauri_build::build();
}
+2 -1
View File
@@ -11,6 +11,7 @@
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"dialog:allow-open"
"dialog:allow-open",
"aptabase:allow-track-event"
]
}
+1044 -10
View File
File diff suppressed because it is too large Load Diff
+40 -9
View File
@@ -5,39 +5,66 @@ mod git;
use badge::set_sync_badge;
use git::{
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models,
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch,
cred_delete, cred_load, cred_save, delete_branch, diff_file_against_working_tree, fetch,
get_file_patch, get_remote_url, get_status, list_branches, list_commits, list_file_history,
list_repository_files, merge_branch, open_repo_in_explorer, open_repository,
open_repository_bundle, open_repository_file, pull, push, read_conflict, rename_branch,
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame,
get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits,
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
restore_to_commit, search_code_introductions, stage_files, unstage_files,
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
stash_push, undo_last_commit, unstage_files,
};
fn main() {
#[tokio::main]
async fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_aptabase::Builder::new("A-SH-1344793789").with_options(
tauri_plugin_aptabase::InitOptions {
host: Some("https://aptabase.cbsk-tech.de".to_string()),
flush_interval:None
}
).build())
.manage(SearchCancellationState::default())
.manage(commit_ai::CommitAiEngine::new())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
open_repository,
clone_repository,
open_repo_in_explorer,
open_repository_file,
get_status,
list_branches,
list_stashes,
checkout_branch,
create_branch,
rename_branch,
delete_branch,
list_tags,
create_tag,
delete_tag,
push_tag,
cherry_pick_commit,
cherry_pick_continue,
cherry_pick_abort,
stage_files,
unstage_files,
stash_push,
stash_apply,
stash_pop,
stash_drop,
restore_files,
get_file_patch,
apply_file_patch,
commit,
amend_commit,
undo_last_commit,
last_commit_message,
commit_ai_status,
commit_ai_load,
commit_ai_local_models,
@@ -49,10 +76,14 @@ fn main() {
restore_to_commit,
restore_file_from_commit,
merge_branch,
rebase_branch,
rebase_continue,
rebase_abort,
list_repository_files,
open_repository_bundle,
list_file_history,
cancel_file_history,
get_file_blame,
compare_commits,
compare_file_to_head,
compare_file_to_parent,
+9 -4
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GitLite",
"version": "2026.7.9",
"version": "2026.7.14",
"identifier": "com.git-lite",
"build": {
"beforeDevCommand": "npm run dev",
@@ -28,8 +28,13 @@
},
"bundle": {
"active": true,
"targets": ["nsis"],
"icon": ["icons/icon.ico"],
"targets": [
"nsis"
],
"icon": [
"icons/icon.png",
"icons/icon.ico"
],
"createUpdaterArtifacts": true,
"windows": {
"nsis": {
@@ -40,7 +45,7 @@
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdDNEJGRTZERTREOTc5QjkKUldTNWVkbmtiZjVMZk02YzdpenVLOVltREJpRkFrc2F4dUwzaTV5M3pacFRVR0tmd25xSGtjK1MK",
"endpoints" : [
"endpoints": [
"https://cdn.cbsk-tech.de/gitlite/latest.json"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"targets": ["appimage"]
}
}
+979 -35
View File
File diff suppressed because it is too large Load Diff
+1157 -48
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -2,7 +2,7 @@
import { onDestroy, onMount } from "svelte";
import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte";
export let branch: string = "";
export let ahead: number = 0;
@@ -21,6 +21,7 @@
export let onCompare: () => void = () => {};
export let onOpenInExplorer: () => void = () => {};
export let onToggleAutoRefresh: () => void = () => {};
export let onOpenSettings: () => void = () => {};
const win = getCurrentWindow();
let isMaximized = false;
@@ -198,6 +199,16 @@
/>
<span class="tb-action-label">Auto</span>
</button>
<button
class="tb-action"
onclick={onOpenSettings}
title="Settings"
aria-label="Settings"
>
<Settings size={14} aria-hidden="true" />
<span class="tb-action-label">Settings</span>
</button>
</div>
<div class="titlebar-divider" aria-hidden="true"></div>
+12
View File
@@ -0,0 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
export type AnalyticsEventProperties = Record<string, string | number>;
export function trackAnalyticsEvent(name: string, props?: AnalyticsEventProperties) {
void invoke("plugin:aptabase|track_event", {
name,
props: props && Object.keys(props).length > 0 ? props : undefined,
}).catch(() => {
// Analytics must never interrupt the Git workflow.
});
}
+16 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import { onDestroy, onMount } from "svelte";
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
@@ -36,6 +36,7 @@
let loadingKeys = $state(true);
let saving = $state(false);
let error = $state("");
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
provider = settings.provider;
@@ -47,6 +48,16 @@
customModel = settings.customModel;
});
$effect(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
const currentError = error;
if (currentError) {
errorHideTimer = setTimeout(() => {
if (error === currentError) error = "";
}, 6000);
}
});
onMount(() => {
(async () => {
try {
@@ -66,6 +77,10 @@
})();
});
onDestroy(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
});
async function persistKey(target: CloudProvider, value: string) {
const key = CRED_KEYS[target];
const trimmed = value.trim();
@@ -126,7 +141,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
<header class="dialog-header">
@@ -0,0 +1,57 @@
<script lang="ts">
import { BarChart3, Check, ShieldCheck } from "@lucide/svelte";
interface Props {
enabled: boolean;
onContinue: (enabled: boolean) => void;
}
let { enabled = true, onContinue = () => {} }: Props = $props();
let allowAnalytics = $state(true);
$effect(() => {
allowAnalytics = enabled;
});
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog analytics-notice-dialog" role="dialog" aria-modal="true" aria-label="Analytics notice" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Privacy</span>
<h2 class="dialog-title">Anonymous usage analytics</h2>
</div>
<ShieldCheck size={20} aria-hidden="true" />
</header>
<div class="analytics-notice-body">
<div class="analytics-notice-icon">
<BarChart3 size={24} aria-hidden="true" />
</div>
<div class="analytics-notice-copy">
<p>
GitLite can send anonymous usage events to Aptabase so crashes, rough edges, and commonly used workflows are easier to improve.
</p>
<p>
Events do not include repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, or source code.
</p>
</div>
<label class="settings-toggle-row analytics-notice-toggle">
<input type="checkbox" bind:checked={allowAnalytics} />
<span>
<strong>Allow anonymous analytics</strong>
<small>You can change this later in Settings.</small>
</span>
</label>
</div>
<footer class="dialog-footer analytics-notice-footer">
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage events.</span>
<button class="btn-primary" type="button" onclick={() => onContinue(allowAnalytics)}>
<Check size={16} aria-hidden="true" />
Continue
</button>
</footer>
</div>
</div>
@@ -0,0 +1,68 @@
<script lang="ts">
import { Check, Settings, X } from "@lucide/svelte";
import type { AnalyticsSettings } from "../types";
interface Props {
analytics: AnalyticsSettings;
onSave: (settings: AnalyticsSettings) => void;
onClose: () => void;
}
let { analytics, onSave = () => {}, onClose = () => {} }: Props = $props();
let analyticsEnabled = $state(true);
$effect(() => {
analyticsEnabled = analytics.enabled;
});
function save() {
onSave({
...analytics,
enabled: analyticsEnabled,
noticeSeen: true,
});
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">GitLite</span>
<h2 class="dialog-title">Settings</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}>
<section class="settings-section">
<header>
<Settings size={16} aria-hidden="true" />
<div>
<span class="eyebrow">Analytics</span>
<h3>Anonymous usage analytics</h3>
</div>
</header>
<label class="settings-toggle-row">
<input type="checkbox" bind:checked={analyticsEnabled} />
<span>
<strong>Allow anonymous Aptabase events</strong>
<small>No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent.</small>
</span>
</label>
</section>
<div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose}>Cancel</button>
<button class="btn-primary" type="submit">
<Check size={16} aria-hidden="true" />
Save
</button>
</div>
</form>
</div>
</div>
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
import { FileCode, LoaderCircle, X } from "@lucide/svelte";
import type { GitBlameLine } from "../types";
interface BlameGroup {
id: string;
hash: string;
shortHash: string;
authorName: string;
authorEmail: string;
summary: string;
authorTime: number;
isUncommitted: boolean;
lines: GitBlameLine[];
}
interface Props {
filePath: string;
lines: GitBlameLine[];
isBusy: boolean;
isLoading: boolean;
error: string;
onClose: () => void;
}
let {
filePath = "",
lines = [],
isBusy = false,
isLoading = false,
error = "",
onClose = () => {},
}: Props = $props();
function groupBlameLines(source: GitBlameLine[]): BlameGroup[] {
const groups: BlameGroup[] = [];
for (const line of source) {
const last = groups[groups.length - 1];
if (last && last.hash === line.commit_hash) {
last.lines.push(line);
continue;
}
groups.push({
id: `${line.commit_hash}-${line.line_number}`,
hash: line.commit_hash,
shortHash: line.short_hash,
authorName: line.author_name,
authorEmail: line.author_email,
summary: line.summary,
authorTime: line.author_time,
isUncommitted: line.is_uncommitted,
lines: [line],
});
}
return groups;
}
function formatBlameDate(seconds: number): string {
if (!seconds) return "";
const date = new Date(seconds * 1000);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function groupTooltip(group: BlameGroup): string {
if (group.isUncommitted) return "Not committed yet";
return `${group.authorName} <${group.authorEmail}>\n${group.summary}\n${group.hash}`;
}
let groups = $derived(groupBlameLines(lines));
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog blame-dialog" role="dialog" aria-modal="true" aria-label="File blame">
<header class="dialog-header">
<div>
<span class="eyebrow">Blame</span>
<p class="dialog-title" title={filePath}>{filePath}</p>
</div>
<div class="dialog-header-actions">
<span class="pill pill-count">{lines.length}</span>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</div>
</header>
<div class="dialog-diff blame-body">
{#if isLoading}
<div class="blank-state">
<LoaderCircle class="spin" size={18} aria-hidden="true" />
Loading blame...
</div>
{:else if error}
<div class="blank-state">{error}</div>
{:else if groups.length === 0}
<div class="blank-state">No blame information available for this file.</div>
{:else}
<div class="diff-header blame-code-header">
<FileCode size={13} aria-hidden="true" />
<span title={filePath}>{filePath}</span>
<strong>{lines.length} lines</strong>
</div>
<div class="split-col-headers blame-column-headers">
<div class="split-col-label blame-commit-col-label">Commit</div>
<div class="split-col-label blame-code-col-label">Code</div>
</div>
<div class="split-diff blame-diff" role="table" aria-label="File blame">
<div class="split-pane blame-scroll">
<div class="blame-code-table">
{#each groups as group (group.id)}
<div class="blame-group" class:uncommitted={group.isUncommitted} title={groupTooltip(group)}>
<div class="blame-meta">
<span class="blame-hash">{group.isUncommitted ? "Uncommitted" : group.shortHash}</span>
<span class="blame-author">{group.isUncommitted ? "Not committed yet" : group.authorName}</span>
<span class="blame-summary">{group.summary || "No commit message"}</span>
{#if !group.isUncommitted}
<span class="blame-date">{formatBlameDate(group.authorTime)}</span>
{/if}
</div>
<div class="split-pane-grid blame-lines">
{#each group.lines as line (line.line_number)}
<span class="split-num blame-line-number">{line.line_number}</span>
<code class="split-cell blame-line-code">{line.content}</code>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import { AlertTriangle, GitBranch, LoaderCircle, Trash2, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
interface Props {
branch: GitBranchInfo;
force: boolean;
isBusy: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}
let {
branch,
force = false,
isBusy = false,
onConfirm = () => {},
onClose = () => {},
}: Props = $props();
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header">
<div>
<span class="eyebrow">{force ? "Force delete" : "Delete branch"}</span>
<p class="dialog-title">{title}</p>
</div>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="branch-delete-body">
<div class="discard-warning-icon" aria-hidden="true">
<AlertTriangle size={22} />
</div>
<div class="discard-confirm-copy">
<p>
{#if force}
This branch is not fully merged. Force deleting removes the branch pointer even if some commits are only reachable from this branch.
{:else}
Delete this local branch from the repository?
{/if}
</p>
<code class="discard-target" title={branch.name}>
<GitBranch size={13} aria-hidden="true" />
{branch.name}
</code>
<p class="discard-warning-text">
{#if force}
Make sure you no longer need the unique commits on this branch.
{:else}
Git will refuse if the branch is not fully merged.
{/if}
</p>
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-danger" type="button" onclick={onConfirm} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<Trash2 size={15} aria-hidden="true" />
{/if}
{force ? "Force delete" : "Delete"}
</button>
</footer>
</div>
</div>
+226 -31
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Trash2, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -45,37 +45,55 @@
branches: GitBranchInfo[];
localBranches: GitBranchInfo[];
remoteBranches: GitBranchInfo[];
tags: GitTag[];
hasRepository: boolean;
isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void;
onMerge: (branch: GitBranchInfo) => void;
onRebase: (branch: GitBranchInfo) => void;
onCreateBranch: (branchName: string) => void | Promise<void>;
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>;
}
let {
branches = [],
localBranches = [],
remoteBranches = [],
tags = [],
hasRepository = false,
isBusy = false,
onCheckout = () => {},
onMerge = () => {},
onRebase = () => {},
onCreateBranch = () => {},
onRenameBranch = () => {},
onDeleteBranch = () => {},
onCreateTag = () => {},
onDeleteTag = () => {},
onPushTag = () => {},
}: Props = $props();
let localOpen = $state(true);
let remoteOpen = $state(false);
let tagsOpen = $state(false);
let createOpen = $state(false);
let newBranchName = $state("");
let createInput = $state<HTMLInputElement | null>(null);
let tagCreateOpen = $state(false);
let newTagName = $state("");
let newTagMessage = $state("");
let tagCreateInput = $state<HTMLInputElement | null>(null);
let panelElement = $state<HTMLElement | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextTag = $state<GitTag | null>(null);
let tagContextMenuX = $state(0);
let tagContextMenuY = $state(0);
let collapsedBranchFolders = $state<Set<string>>(new Set());
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
@@ -216,13 +234,13 @@
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault();
event.stopPropagation();
if (isBusy || branch.remote) return;
if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 92);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 190);
contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX));
@@ -242,17 +260,100 @@
async function deleteContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
if (!branch || branch.current || branch.remote || isBusy) return;
closeBranchContextMenu();
await onDeleteBranch(branch);
}
async function checkoutContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onCheckout(branch);
}
async function mergeContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onMerge(branch);
}
async function rebaseContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onRebase(branch);
}
function openTagCreateForm() {
if (!hasRepository || isBusy) return;
tagCreateOpen = true;
queueMicrotask(() => tagCreateInput?.focus());
}
function closeTagCreateForm() {
tagCreateOpen = false;
newTagName = "";
newTagMessage = "";
}
async function submitCreateTag(event: SubmitEvent) {
event.preventDefault();
const name = newTagName.trim();
if (!name || !hasRepository || isBusy) return;
await onCreateTag(name, newTagMessage.trim());
newTagName = "";
newTagMessage = "";
tagCreateOpen = false;
tagsOpen = true;
}
function openTagContextMenu(event: MouseEvent, tag: GitTag) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 130);
contextTag = tag;
tagContextMenuX = Math.max(8, Math.min(rawX, maxX));
tagContextMenuY = Math.max(8, Math.min(rawY, maxY));
}
function closeTagContextMenu() {
contextTag = null;
}
async function pushContextTag() {
const tag = contextTag;
if (!tag || isBusy) return;
closeTagContextMenu();
await onPushTag(tag);
}
async function deleteContextTag() {
const tag = contextTag;
if (!tag || isBusy) return;
closeTagContextMenu();
await onDeleteTag(tag);
}
function closeAllContextMenus() {
closeBranchContextMenu();
closeTagContextMenu();
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeBranchContextMenu();
if (event.key === "Escape") closeAllContextMenus();
}
</script>
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeBranchContextMenu} />
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
<div class="section-head">
@@ -277,7 +378,7 @@
{#if !hasRepository}
<p class="blank-state">Open a repository to list branches.</p>
{:else if branches.length === 0}
{:else if branches.length === 0 && tags.length === 0}
<p class="blank-state">No branches returned.</p>
{:else}
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
@@ -361,16 +462,6 @@
</div>
{#if row.branch.current}
<span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/if}
@@ -426,6 +517,7 @@
class:current={row.branch.current}
style={`--branch-indent: ${row.depth * 16}px;`}
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
title={row.branch.current ? "Current branch" : row.branch.name}
>
<div class="branch-info">
@@ -437,16 +529,6 @@
</div>
{#if row.branch.current}
<span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/if}
@@ -454,6 +536,86 @@
{/if}
{/if}
</div>
<div class="branch-group">
<div class="tag-group-head">
<button
class="branch-group-toggle"
type="button"
onclick={() => { tagsOpen = !tagsOpen; }}
aria-expanded={tagsOpen}
>
{#if tagsOpen}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Tags</span>
<span class="branch-group-count">{tags.length}</span>
</button>
<button
class="branch-create-toggle"
type="button"
onclick={openTagCreateForm}
disabled={!hasRepository || isBusy}
title="Create new tag"
aria-label="Create new tag"
>
<Plus size={13} aria-hidden="true" />
</button>
</div>
{#if tagCreateOpen}
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
<TagIcon size={15} aria-hidden="true" />
<input
bind:this={tagCreateInput}
bind:value={newTagName}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="v1.0.0"
aria-label="New tag name"
/>
<input
bind:value={newTagMessage}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="Message (optional)"
aria-label="Tag message"
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title="Create tag">
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title="Cancel">
<X size={14} aria-hidden="true" />
</button>
</form>
{/if}
{#if tagsOpen}
{#if tags.length === 0}
<div class="branch-empty">No tags.</div>
{:else}
{#each tags as tag (tag.name)}
<article
class="branch-row"
oncontextmenu={(event) => openTagContextMenu(event, tag)}
title={tag.message ?? tag.name}
>
<div class="branch-info">
<TagIcon size={16} aria-hidden="true" />
<div>
<strong>{tag.name}</strong>
<span>{tag.short_hash}</span>
</div>
</div>
</article>
{/each}
{/if}
{/if}
</div>
</div>
{/if}
@@ -465,7 +627,20 @@
tabindex="-1"
aria-label={`Actions for ${contextBranch.name}`}
>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
<button type="button" role="menuitem" onclick={checkoutContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Checkout
</button>
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
<GitMerge size={14} aria-hidden="true" />
Merge into current
</button>
<button type="button" role="menuitem" onclick={rebaseContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Rebase current onto this
</button>
<div class="menu-separator" role="separator"></div>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
<Pencil size={14} aria-hidden="true" />
Rename
</button>
@@ -474,12 +649,32 @@
type="button"
role="menuitem"
onclick={deleteContextBranch}
disabled={isBusy || contextBranch.current}
title={contextBranch.current ? "Current branch cannot be deleted" : "Delete local branch"}
disabled={isBusy || contextBranch.current || contextBranch.remote}
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Remote branch cannot be deleted here" : "Delete local branch"}
>
<Trash2 size={14} aria-hidden="true" />
Delete
</button>
</div>
{/if}
{#if contextTag}
<div
class="branch-context-menu"
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextTag.name}`}
>
<button type="button" role="menuitem" onclick={pushContextTag} disabled={isBusy}>
<Upload size={14} aria-hidden="true" />
Push to remote
</button>
<div class="menu-separator" role="separator"></div>
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title="Delete local tag">
<Trash2 size={14} aria-hidden="true" />
Delete
</button>
</div>
{/if}
</section>
@@ -0,0 +1,175 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { Download, FolderOpen, LoaderCircle, X } from "@lucide/svelte";
interface Props {
isBusy: boolean;
error: string;
onClone: (remoteUrl: string, parentPath: string, directoryName: string) => void;
onClose: () => void;
}
let {
isBusy = false,
error = "",
onClone = () => {},
onClose = () => {},
}: Props = $props();
let remoteUrl = $state("");
let parentPath = $state("");
let directoryName = $state("");
let directoryNameEdited = $state(false);
let directoryAutoName = $state("");
let browseError = $state("");
let visibleError = $state("");
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
let directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
let canSubmit = $derived(
!isBusy &&
remoteUrl.trim().length > 0 &&
parentPath.trim().length > 0,
);
$effect(() => {
const nextError = error || browseError;
if (errorHideTimer) clearTimeout(errorHideTimer);
visibleError = nextError;
if (nextError) {
errorHideTimer = setTimeout(() => {
visibleError = "";
}, 6000);
}
});
onDestroy(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
});
function directoryNameFromRemoteUrl(url: string): string {
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
return lastSegment.replace(/\.git$/i, "").trim();
}
function errorToMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
try { return JSON.stringify(error) ?? "Unknown error"; } catch { return "Unknown error"; }
}
async function chooseParentFolder() {
if (isBusy) return;
browseError = "";
try {
const selected = await openDialog({
title: "Select clone destination",
directory: true,
multiple: false,
defaultPath: parentPath.trim() || undefined,
});
if (typeof selected !== "string") return;
parentPath = selected;
} catch (error) {
browseError = errorToMessage(error);
}
}
function handleRemoteInput(event: Event) {
const nextRemoteUrl = (event.currentTarget as HTMLInputElement).value;
if (directoryNameEdited) return;
directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl);
directoryName = directoryAutoName;
}
function handleDirectoryInput(event: Event) {
const nextDirectoryName = (event.currentTarget as HTMLInputElement).value;
directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName;
}
function submit(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim());
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label="Clone repository" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Repository Management</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Clone repository</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="clone-dialog-form" onsubmit={submit}>
<label class="clone-dialog-field">
<span>Remote URL</span>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={remoteUrl}
oninput={handleRemoteInput}
autocomplete="off"
spellcheck="false"
placeholder="https://github.com/org/project.git"
disabled={isBusy}
autofocus
/>
</label>
<label class="clone-dialog-field">
<span>Destination</span>
<div class="clone-dialog-path-field">
<input
bind:value={parentPath}
autocomplete="off"
spellcheck="false"
placeholder="Choose parent folder"
disabled={isBusy}
/>
<button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}>
<FolderOpen size={14} aria-hidden="true" />
Browse
</button>
</div>
</label>
<label class="clone-dialog-field">
<span>Folder name</span>
<input
bind:value={directoryName}
oninput={handleDirectoryInput}
autocomplete="off"
spellcheck="false"
placeholder={directorySuggestion || "Optional"}
disabled={isBusy}
/>
</label>
{#if visibleError}
<div class="clone-dialog-error" role="alert">{visibleError}</div>
{/if}
<div class="clone-dialog-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
Cancel
</button>
<button class="btn-primary" type="submit" disabled={!canSubmit}>
{#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Download size={16} aria-hidden="true" />
{/if}
Clone
</button>
</div>
</form>
</div>
</div>
+35 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, LoaderCircle, Settings, Sparkles } from "@lucide/svelte";
import { Check, LoaderCircle, RotateCcw, Settings, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props {
@@ -13,10 +13,14 @@
commitAiProvider: CommitAiProvider;
commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean;
canAmend: boolean;
amendMode: boolean;
onCommit: () => void;
onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void;
onOpenAiSettings: () => void;
onToggleAmend: (checked: boolean) => void;
onUndoLastCommit: () => void;
}
let {
@@ -30,10 +34,14 @@
commitAiProvider = "local",
commitAiPhase = "idle",
commitAiGenerating = false,
canAmend = false,
amendMode = false,
onCommit = () => {},
onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {},
onOpenAiSettings = () => {},
onToggleAmend = () => {},
onUndoLastCommit = () => {},
}: Props = $props();
function handleSubmit(event: SubmitEvent) {
@@ -77,14 +85,37 @@
{#if commitBlockReason}
<p class="commit-block-reason">{commitBlockReason}</p>
{/if}
{#if canAmend}
<div class="commit-amend-row">
<label class="commit-amend-toggle">
<input
type="checkbox"
checked={amendMode}
disabled={isBusy}
onchange={(e) => onToggleAmend((e.target as HTMLInputElement).checked)}
/>
Amend last commit
</label>
<button
class="btn-secondary commit-undo-button"
type="button"
onclick={onUndoLastCommit}
disabled={isBusy}
title="Undo the last commit — its changes come back as uncommitted changes (not pushed yet, so safe)"
>
<RotateCcw size={13} aria-hidden="true" />
Undo last commit
</button>
</div>
{/if}
<div class="commit-actions-row flex-shrink-0">
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
{#if operation === "Committing"}
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || (amendMode ? "Amend the last commit" : "Commit staged changes")}>
{#if operation === "Committing" || operation === "Amending"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Commit
{amendMode ? "Amend" : "Commit"}
</button>
<button
class="btn-secondary commit-ai-button"
-1
View File
@@ -182,7 +182,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
@@ -41,7 +41,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
<header class="dialog-header">
+13 -6
View File
@@ -14,7 +14,7 @@
} from "@lucide/svelte";
interface Props {
action: "push" | "pull" | "fetch";
action: "push" | "pull" | "fetch" | "clone";
error: string;
isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
@@ -35,7 +35,7 @@
let username = $state("");
let password = $state("");
let showPassword = $state(false);
let saveSession = $state(false);
let saveSession = $state(true);
let expiresAt = $state("");
let canSubmit = $derived(
@@ -43,13 +43,21 @@
password.trim().length > 0 &&
(mode === "token" || username.trim().length > 0),
);
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : "Pull");
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
let actionTitle = $derived(
action === "push" ? "Authenticate push" : action === "fetch" ? "Authenticate fetch" : "Authenticate pull",
action === "push"
? "Authenticate push"
: action === "fetch"
? "Authenticate fetch"
: action === "clone"
? "Authenticate clone"
: "Authenticate pull",
);
let actionHint = $derived(action === "push"
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: "The remote needs access to the repository. Use your Git credentials or a personal access token.");
: action === "clone"
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
: "The remote needs access to the repository. Use your Git credentials or a personal access token.");
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
@@ -66,7 +74,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git credentials" tabindex="-1">
<div class="cred-hero">
@@ -25,13 +25,9 @@
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
function closeFromBackdrop(event: MouseEvent) {
if (isBusy || event.target !== event.currentTarget) return;
onClose();
}
</script>
<div class="dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
<div class="dialog-backdrop" role="presentation">
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header">
<div>
+20
View File
@@ -18,6 +18,7 @@
Folder,
FolderOpen,
ExternalLink,
History,
Terminal,
} from "@lucide/svelte";
import { languageIconForPath } from "../languageIcons";
@@ -36,6 +37,7 @@
onCollapseAllFolders: () => void;
onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void;
}
let {
@@ -50,6 +52,7 @@
onCollapseAllFolders = () => {},
onSelectNode = () => {},
onOpenFile = () => {},
onBlame = () => {},
}: Props = $props();
let contextNode = $state<ExplorerNode | null>(null);
@@ -182,6 +185,13 @@
onOpenFile(node);
}
function openContextBlame() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onBlame(node);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeFileContextMenu();
}
@@ -335,5 +345,15 @@
<ExternalLink size={14} aria-hidden="true" />
Open in Explorer
</button>
<button
type="button"
role="menuitem"
onclick={openContextBlame}
disabled={!contextNode.tracked || contextNode.status === "deleted"}
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
>
<History size={14} aria-hidden="true" />
Blame
</button>
</div>
{/if}
@@ -129,7 +129,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1">
<header class="dialog-header">
+556 -40
View File
@@ -1,28 +1,39 @@
<script lang="ts">
import { ChevronDown, ChevronRight, GitBranch, RotateCcw } from "@lucide/svelte";
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment {
fromCol: number;
toCol: number;
color: string;
branches: string[];
}
interface GraphRow {
dotCol: number;
dotColor: string;
branchLabels: string[];
top: GraphSegment[];
bottom: GraphSegment[];
}
interface VisibleCommitEntry {
commit: GitCommit;
graphCommit: GitCommit;
}
const GRAPH_COLORS = [
"#2f6fb0", "#4aa777", "#c9851f", "#a05bd0",
"#cc4b6e", "#1f9ab0", "#7a8a1f", "#b0631f",
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
];
const GRAPH_LANE = 16;
const GRAPH_LANE = 18;
interface Props {
commits: GitCommit[];
localBranchNames: string[];
activeBranch: string;
activeUpstream: string;
repositoryKey: string;
hasRepository: boolean;
isBusy: boolean;
expandedCommitHashes: Set<string>;
@@ -30,10 +41,15 @@
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
onToggleCommitFiles: (hash: string) => void;
onCreateBranchFromCommit: (commit: GitCommit) => void;
onCherryPickCommit: (commit: GitCommit) => void;
}
let {
commits = [],
localBranchNames = [],
activeBranch = "",
activeUpstream = "",
repositoryKey = "",
hasRepository = false,
isBusy = false,
expandedCommitHashes = new Set(),
@@ -41,8 +57,18 @@
onPreviewCommitFile = () => {},
onToggleCommitFiles = () => {},
onCreateBranchFromCommit = () => {},
onCherryPickCommit = () => {},
}: Props = $props();
let hiddenGraphBranches = $state<Set<string>>(new Set());
let branchDialogOpen = $state(false);
let userAdjustedBranchFilter = $state(false);
let lastDefaultFilterKey = $state("");
let panelElement = $state<HTMLElement | null>(null);
let contextCommit = $state<GitCommit | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
function laneColor(col: number): string {
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
}
@@ -51,13 +77,23 @@
return col * GRAPH_LANE + GRAPH_LANE / 2;
}
function computeGraph(items: GitCommit[]): { rows: GraphRow[]; columns: number } {
function graphPath(seg: GraphSegment, fromY: number, toY: number): string {
const x1 = graphColX(seg.fromCol);
const x2 = graphColX(seg.toCol);
if (x1 === x2) return `M ${x1} ${fromY} L ${x2} ${toY}`;
const midY = (fromY + toY) / 2;
return `M ${x1} ${fromY} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${toY}`;
}
function computeGraph(items: GitCommit[], branchMembership = new Map<string, string[]>()): { rows: GraphRow[]; columns: number } {
const rows: GraphRow[] = [];
let lanes: (string | null)[] = [];
let laneBranches: string[][] = [];
let maxColumns = 1;
for (const commit of items) {
const before = lanes.slice();
const beforeBranches = laneBranches.map((branches) => branches.slice());
let col = before.indexOf(commit.hash);
if (col === -1) {
@@ -67,18 +103,27 @@
const after = before.slice();
while (after.length <= col) after.push(null);
const afterBranches = beforeBranches.map((branches) => branches.slice());
while (afterBranches.length <= col) afterBranches.push([]);
for (let k = 0; k < after.length; k++) {
if (after[k] === commit.hash) after[k] = null;
if (after[k] === commit.hash) {
after[k] = null;
afterBranches[k] = [];
}
}
const currentBranches = branchMembership.get(commit.hash) ?? localBranchRefs(commit);
const commitBranches = uniqueStrings([...(beforeBranches[col] ?? []), ...currentBranches]);
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
afterBranches[col] = after[col] ? commitBranches.slice() : [];
const fromCommit = new Set<number>([col]);
for (let p = 1; p < commit.parents.length; p++) {
let slot = after.indexOf(null);
if (slot === -1) { slot = after.length; after.push(null); }
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
after[slot] = commit.parents[p];
afterBranches[slot] = [];
fromCommit.add(slot);
}
@@ -86,19 +131,28 @@
for (let k = 0; k < before.length; k++) {
const target = before[k];
if (target == null) continue;
top.push({ fromCol: k, toCol: target === commit.hash ? col : k, color: laneColor(k) });
top.push({ fromCol: k, toCol: target === commit.hash ? col : k, color: laneColor(k), branches: beforeBranches[k] ?? [] });
}
const bottom: GraphSegment[] = [];
for (let k = 0; k < after.length; k++) {
if (after[k] == null) continue;
bottom.push({ fromCol: fromCommit.has(k) ? col : k, toCol: k, color: laneColor(k) });
bottom.push({
fromCol: fromCommit.has(k) ? col : k,
toCol: k,
color: laneColor(k),
branches: fromCommit.has(k) ? commitBranches : afterBranches[k] ?? [],
});
}
rows.push({ dotCol: col, dotColor: laneColor(col), top, bottom });
rows.push({ dotCol: col, dotColor: laneColor(col), branchLabels: currentBranches, top, bottom });
lanes = after.slice();
while (lanes.length > 0 && lanes[lanes.length - 1] == null) lanes.pop();
laneBranches = afterBranches.map((branches) => branches.slice());
while (lanes.length > 0 && lanes[lanes.length - 1] == null) {
lanes.pop();
laneBranches.pop();
}
maxColumns = Math.max(maxColumns, before.length, after.length, col + 1);
}
@@ -123,73 +177,459 @@
: baseName(file.path);
}
function authorInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
function commitKind(commit: GitCommit): "merge" | "root" | "commit" {
if (commit.parents.length > 1) return "merge";
if (commit.parents.length === 0) return "root";
return "commit";
}
function commitKindLabel(commit: GitCommit): string {
const kind = commitKind(commit);
if (kind === "merge") return "merge";
if (kind === "root") return "root";
return "commit";
}
let localBranchNameSet = $derived(new Set(localBranchNames));
function uniqueStrings(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, activeUpstream].filter(Boolean)));
let graphBranchNameSet = $derived(new Set(graphBranchNames));
function branchIsVisible(branch: string): boolean {
if (branch === activeUpstream && activeUpstream) {
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
}
return !hiddenGraphBranches.has(branch);
}
function visibleBranchLabels(labels: string[]): string[] {
return labels.filter(branchIsVisible);
}
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
const directBranches = graphBranchRefs(commit);
if (directBranches.length > 0) return directBranches;
const containingBranches = row?.branchLabels ?? [];
if (containingBranches.length <= 3) return containingBranches;
return [...containingBranches.slice(0, 3), `+${containingBranches.length - 3} more`];
}
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
const directBranches = graphBranchRefs(commit);
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
const containingBranches = row?.branchLabels ?? [];
if (containingBranches.length === 0) return commit.short_hash;
return `Branches containing this commit: ${containingBranches.join(", ")}`;
}
function segmentIsVisible(segment: GraphSegment): boolean {
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
}
function syncClassForBranches(labels: string[]): "" | "ahead" | "behind" {
if (!activeBranch || !activeUpstream) return "";
const hasLocal = labels.includes(activeBranch);
const hasRemote = labels.includes(activeUpstream);
if (hasLocal && !hasRemote) return "ahead";
if (hasRemote && !hasLocal) return "behind";
return "";
}
function branchesAreVisible(branches: string[]): boolean {
if (graphBranchNames.length === 0) return true;
return branches.some(branchIsVisible);
}
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
return branchesAreVisible(row?.branchLabels ?? []);
}
function nearestVisibleGraphParents(
hash: string,
visibleHashes: Set<string>,
commitByHash: Map<string, GitCommit>,
seen: Set<string>,
): string[] {
if (visibleHashes.has(hash)) return [hash];
if (seen.has(hash)) return [];
seen.add(hash);
const commit = commitByHash.get(hash);
if (!commit) return [];
return uniqueStrings(
commit.parents.flatMap((parentHash) => (
nearestVisibleGraphParents(parentHash, visibleHashes, commitByHash, new Set(seen))
)),
);
}
function branchMembershipByHash(items: GitCommit[]): Map<string, string[]> {
const commitByHash = new Map(items.map((commit) => [commit.hash, commit]));
const membership = new Map<string, Set<string>>();
for (const commit of items) {
for (const branch of graphBranchRefs(commit)) {
const stack = [commit.hash];
const seen = new Set<string>();
while (stack.length > 0) {
const hash = stack.pop();
if (!hash || seen.has(hash)) continue;
seen.add(hash);
let branches = membership.get(hash);
if (!branches) {
branches = new Set<string>();
membership.set(hash, branches);
}
branches.add(branch);
const parentCommit = commitByHash.get(hash);
if (parentCommit) stack.push(...parentCommit.parents);
}
}
}
return new Map(
items.map((commit) => [
commit.hash,
graphBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
]),
);
}
function visibleCommitEntriesForGraph(items: GitCommit[], branchMembership: Map<string, string[]>): VisibleCommitEntry[] {
const visibleItems = items.filter((commit) => branchesAreVisible(branchMembership.get(commit.hash) ?? []));
const visibleHashes = new Set(visibleItems.map((commit) => commit.hash));
const commitByHash = new Map(items.map((commit) => [commit.hash, commit]));
return visibleItems.map((commit) => {
const parents = uniqueStrings(
commit.parents.flatMap((parentHash) => (
nearestVisibleGraphParents(parentHash, visibleHashes, commitByHash, new Set())
)),
);
return { commit, graphCommit: { ...commit, parents } };
});
}
function toggleGraphBranch(branch: string) {
const next = new Set(hiddenGraphBranches);
if (next.has(branch)) next.delete(branch); else next.add(branch);
hiddenGraphBranches = next;
userAdjustedBranchFilter = true;
}
function showAllGraphBranches() {
hiddenGraphBranches = new Set();
userAdjustedBranchFilter = true;
}
function hideAllGraphBranches() {
hiddenGraphBranches = new Set(localBranchNames);
userAdjustedBranchFilter = true;
}
function openBranchDialog() {
branchDialogOpen = true;
}
function closeBranchDialog() {
branchDialogOpen = false;
}
function handleBranchDialogKeydown(event: KeyboardEvent) {
if (branchDialogOpen && event.key === "Escape") {
closeBranchDialog();
}
}
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
if (contextCommit?.hash === commit.hash) {
closeCommitContextMenu();
return;
}
const panelRect = panelElement?.getBoundingClientRect();
const buttonRect = event.currentTarget instanceof HTMLElement
? event.currentTarget.getBoundingClientRect()
: null;
const rawX = panelRect && buttonRect ? buttonRect.right - panelRect.left - 184 : event.offsetX;
const rawY = panelRect && buttonRect ? buttonRect.bottom - panelRect.top + 4 : event.offsetY;
const maxX = Math.max(8, (panelRect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (panelRect?.height ?? window.innerHeight) - 96);
contextCommit = commit;
contextMenuX = Math.max(8, Math.min(rawX, maxX));
contextMenuY = Math.max(8, Math.min(rawY, maxY));
}
function closeCommitContextMenu() {
contextCommit = null;
}
async function createBranchFromContextCommit() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
await onCreateBranchFromCommit(commit);
}
async function restoreContextCommit() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
await onRestoreCommit(commit);
}
async function cherryPickContextCommit() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
await onCherryPickCommit(commit);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
closeCommitContextMenu();
handleBranchDialogKeydown(event);
}
function localBranchRefs(commit: GitCommit): string[] {
const seen = new Set<string>();
const labels: string[] = [];
for (const ref of commit.refs) {
const label = refLabel(ref);
if (!localBranchNameSet.has(label) || seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels;
}
function graphBranchRefs(commit: GitCommit): string[] {
const seen = new Set<string>();
const labels: string[] = [];
for (const ref of commit.refs) {
const label = refLabel(ref);
if (!graphBranchNameSet.has(label) || seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels;
}
function visibleRefs(commit: GitCommit): string[] {
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
}
function refClass(ref: string): string {
if (ref.startsWith("HEAD")) return "head";
if (ref.startsWith("tag:")) return "tag";
if (localBranchNameSet.has(refLabel(ref))) return "branch";
if (ref.includes("/")) return "remote";
return "branch";
}
function refLabel(ref: string): string {
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
}
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
}
let graph = $derived(computeGraph(commits));
$effect(() => {
const available = new Set(localBranchNames);
const nextHidden = new Set([...hiddenGraphBranches].filter((branch) => available.has(branch)));
if (nextHidden.size !== hiddenGraphBranches.size) {
hiddenGraphBranches = nextHidden;
}
});
$effect(() => {
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
? activeBranch
: (graphBranchNames[0] ?? "");
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
if (!defaultBranch) {
if (lastDefaultFilterKey !== defaultFilterKey) {
hiddenGraphBranches = new Set();
userAdjustedBranchFilter = false;
lastDefaultFilterKey = defaultFilterKey;
}
return;
}
if (lastDefaultFilterKey !== defaultFilterKey) {
userAdjustedBranchFilter = false;
lastDefaultFilterKey = defaultFilterKey;
}
if (!userAdjustedBranchFilter) {
hiddenGraphBranches = new Set(localBranchNames.filter((branch) => branch !== defaultBranch));
}
});
let branchMembership = $derived(branchMembershipByHash(commits));
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
let visibleBranchCount = $derived(localBranchNames.filter(branchIsVisible).length);
let graph = $derived(computeGraph(graphCommits, branchMembership));
let graphRows = $derived(graph.rows);
let graphWidth = $derived(Math.max(graph.columns, 1) * GRAPH_LANE);
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
</script>
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
<svelte:window onclick={closeCommitContextMenu} onkeydown={handleWindowKeydown} on:contextmenu|capture={closeCommitContextMenu} />
<section bind:this={panelElement} class="panel history-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
<div class="section-head">
<div>
<span class="eyebrow">History</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
</div>
<span class="pill pill-count">{commits.length}</span>
<div class="section-head-actions">
{#if localBranchNames.length > 0}
<button
class="graph-branch-dialog-button"
type="button"
onclick={openBranchDialog}
title="Select branches shown in the graph"
>
<GitBranch size={13} aria-hidden="true" />
Branches
<span>{visibleBranchCount}/{localBranchNames.length}</span>
</button>
{/if}
<span class="pill pill-count" title={visibleCommits.length === commits.length ? "Commits" : `${visibleCommits.length} of ${commits.length} commits shown`}>
{visibleCommits.length}
</span>
</div>
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else if commits.length === 0}
<div class="blank-state">No commits returned.</div>
{:else if visibleCommits.length === 0}
<div class="blank-state">No commits match the selected branches.</div>
{:else}
<div class="history-list graph-list overflow-auto">
{#each commits as item, rowIndex (item.hash)}
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
{@const item = entry.commit}
{@const row = graphRows[rowIndex]}
<article class="commit-row graph-row">
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
{@const otherRefs = visibleRefs(item)}
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
<article
class="commit-row graph-row"
class:graph-ahead-row={rowSyncClass === "ahead"}
class:graph-behind-row={rowSyncClass === "behind"}
class:merge-row={item.parents.length > 1}
class:root-row={item.parents.length === 0}
class:tip-row={item.refs.length > 0}
>
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
{#if row}
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
{#each row.top as seg}
<line
x1={graphColX(seg.fromCol)} y1="0"
x2={graphColX(seg.toCol)} y2="50"
stroke={seg.color} stroke-width="2" vector-effect="non-scaling-stroke"
<path
class:hidden-branch={!segmentIsVisible(seg)}
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
d={graphPath(seg, 0, 50)}
stroke={seg.color}
stroke-width="2.2"
vector-effect="non-scaling-stroke"
/>
{/each}
{#each row.bottom as seg}
<line
x1={graphColX(seg.fromCol)} y1="50"
x2={graphColX(seg.toCol)} y2="100"
stroke={seg.color} stroke-width="2" vector-effect="non-scaling-stroke"
<path
class:hidden-branch={!segmentIsVisible(seg)}
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
d={graphPath(seg, 50, 100)}
stroke={seg.color}
stroke-width="2.2"
vector-effect="non-scaling-stroke"
/>
{/each}
</svg>
<span
class="graph-dot"
class:merge={item.parents.length > 1}
class:tip={item.refs.length > 0}
class:hidden-branch={!rowGraphIsVisible(row)}
class:ahead={rowSyncClass === "ahead"}
class:behind={rowSyncClass === "behind"}
title={commitHoverTitle(item, row)}
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
></span>
{#if hoverBranchRefs.length > 0}
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
{#each hoverBranchRefs as branch}
<span
class:remote={branch === activeUpstream}
class:ahead={activeBranch === branch && rowSyncClass === "ahead"}
class:behind={activeUpstream === branch && rowSyncClass === "behind"}
title={branch}
>
<GitBranch size={10} aria-hidden="true" />
{branch}
</span>
{/each}
</div>
{/if}
{/if}
</div>
<div class="commit-body">
<div class="commit-line">
<div>
<strong title={item.summary}>{item.summary}</strong>
<span>{item.short_hash} - {item.author_name}</span>
<div class="commit-card-head">
<span class="commit-avatar">
{authorInitials(item.author_name)}
</span>
<div class="commit-card-main">
<div class="commit-title-row">
<strong class="commit-summary" title={item.summary}>{item.summary}</strong>
<span class={`commit-kind ${commitKind(item)}`}>
{#if item.parents.length > 1}
<GitMerge size={12} aria-hidden="true" />
{/if}
{commitKindLabel(item)}
</span>
</div>
<div class="commit-meta-line">
<span class="commit-hash">{item.short_hash}</span>
<span class="commit-author" title={item.author_email}>{item.author_name}</span>
</div>
</div>
</div>
{#if item.refs.length > 0}
{#if otherRefs.length > 0}
<div class="ref-list" aria-label="Commit refs">
{#each item.refs as ref}
<span>{ref}</span>
{#each otherRefs as ref}
<span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
{/each}
</div>
{/if}
@@ -230,15 +670,19 @@
{/if}
<div class="commit-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons">
<button class="btn-sm" type="button" onclick={() => onCreateBranchFromCommit(item)} disabled={isBusy} title="Create a new branch from this commit">
<GitBranch size={15} aria-hidden="true" />
Branch
</button>
<button class="btn-sm" type="button" onclick={() => onRestoreCommit(item)} disabled={isBusy} title="Bring this commit's files into your working tree as unstaged changes (no history is changed)">
<RotateCcw size={15} aria-hidden="true" />
Restore
<button
class="commit-menu-button"
type="button"
onclick={(event) => openCommitActionMenu(event, item)}
disabled={isBusy}
title="Commit actions"
aria-label={`Actions for ${item.short_hash}`}
aria-haspopup="menu"
aria-expanded={contextCommit?.hash === item.hash}
>
<EllipsisVertical size={15} aria-hidden="true" />
</button>
</div>
</div>
@@ -247,4 +691,76 @@
{/each}
</div>
{/if}
{#if contextCommit}
<div
class="history-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextCommit.short_hash}`}
>
<button type="button" role="menuitem" onclick={createBranchFromContextCommit} disabled={isBusy}>
<GitBranch size={14} aria-hidden="true" />
Branch
</button>
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
<RotateCcw size={14} aria-hidden="true" />
Restore
</button>
<button
type="button"
role="menuitem"
onclick={cherryPickContextCommit}
disabled={isBusy}
title="Apply this commit's changes on top of the current branch"
>
<Cherry size={14} aria-hidden="true" />
Cherry-pick
</button>
</div>
{/if}
</section>
{#if branchDialogOpen}
<div class="branch-filter-backdrop" role="presentation">
<div
class="branch-filter-dialog"
role="dialog"
aria-modal="true"
aria-label="Select visible branches"
>
<header class="branch-filter-dialog-head">
<div>
<span class="eyebrow">Git graph</span>
<h3>Visible branches</h3>
</div>
<button class="dialog-icon-button" type="button" onclick={closeBranchDialog} aria-label="Close branch selection">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="branch-filter-summary">
<span>{visibleBranchCount} of {localBranchNames.length} branches selected</span>
<div class="branch-filter-actions">
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === localBranchNames.length}>Show all</button>
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
</div>
</div>
<div class="branch-filter-dialog-list">
{#each localBranchNames as branch}
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
<input
type="checkbox"
checked={branchIsVisible(branch)}
onchange={() => toggleGraphBranch(branch)}
/>
<GitBranch size={14} aria-hidden="true" />
<span>{branch}</span>
</label>
{/each}
</div>
</div>
</div>
{/if}
@@ -29,7 +29,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
<header class="dialog-header">
@@ -33,7 +33,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
<header class="dialog-header">
+5 -3
View File
@@ -43,12 +43,14 @@
<style>
.repo-loading {
position: fixed;
inset: 0;
top: var(--app-titlebar-height, 42px);
right: 0;
bottom: 0;
left: 0;
z-index: 400;
display: grid;
place-items: center;
background: rgba(6, 6, 14, 0.72);
backdrop-filter: blur(8px);
background: #050712;
animation: overlay-in 180ms ease;
}
+3 -4
View File
@@ -254,20 +254,19 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
class="dialog"
style="grid-template-rows: auto minmax(0,1fr) auto;"
role="dialog"
aria-modal="true"
aria-label="Resolve merge conflicts"
aria-label="Resolve conflicts"
tabindex="-1"
>
<header class="dialog-header">
<div>
<span class="eyebrow">Resolve</span>
<h2 class="dialog-title">Merge conflicts</h2>
<h2 class="dialog-title">Conflicts</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
@@ -275,7 +274,7 @@
</header>
{#if conflictedFiles.length === 0}
<div class="blank-state">All conflicts resolved. You can commit the merge now.</div>
<div class="blank-state">All conflicts resolved. Continue the current operation when ready.</div>
{:else}
<div class="dialog-body">
<aside class="dialog-files" aria-label="Conflicted files">
+134
View File
@@ -0,0 +1,134 @@
<script lang="ts">
import { Archive, ChevronDown, ChevronRight, Download, Trash2, Upload } from "@lucide/svelte";
import type { GitStash } from "../types";
interface Props {
stashes: GitStash[];
changedCount: number;
hasRepository: boolean;
isBusy: boolean;
onPush: (message: string, includeUntracked: boolean) => void;
onApply: (stash: GitStash) => void;
onPop: (stash: GitStash) => void;
onDrop: (stash: GitStash) => void;
}
let {
stashes = [],
changedCount = 0,
hasRepository = false,
isBusy = false,
onPush = () => {},
onApply = () => {},
onPop = () => {},
onDrop = () => {},
}: Props = $props();
let message = $state("");
let includeUntracked = $state(true);
let open = $state(false);
function submitPush() {
onPush(message, includeUntracked);
message = "";
}
function stashTitle(stash: GitStash): string {
return stash.message || stash.selector;
}
</script>
<section class="panel stash-panel overflow-hidden" class:collapsed={!open} aria-label="Git stash">
<div class="section-head">
<div>
<span class="eyebrow">Stash</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Shelved changes</h2>
</div>
<div class="stash-head-actions">
<button
class="stash-toggle"
type="button"
onclick={() => { open = !open; }}
aria-expanded={open}
title={open ? "Collapse stash panel" : "Expand stash panel"}
>
{#if open}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
</button>
<span class="pill pill-count">{stashes.length}</span>
</div>
</div>
{#if !open}
<!-- collapsed -->
{:else if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else}
<div class="stash-create">
<input
class="stash-input"
type="text"
bind:value={message}
placeholder="Optional message"
disabled={isBusy || changedCount === 0}
onkeydown={(event) => {
if (event.key === "Enter" && changedCount > 0 && !isBusy) submitPush();
}}
/>
<label class="stash-check">
<input type="checkbox" bind:checked={includeUntracked} disabled={isBusy || changedCount === 0} />
Untracked
</label>
<button
class="btn-sm stash-save-button"
type="button"
onclick={submitPush}
disabled={isBusy || changedCount === 0}
title="Save current working tree changes to a stash"
>
<Archive size={14} aria-hidden="true" />
Stash
</button>
</div>
{#if stashes.length === 0}
<div class="blank-state stash-empty">No stashes saved.</div>
{:else}
<div class="stash-list">
{#each stashes as stash (stash.selector)}
<article class="stash-row">
<div class="stash-row-main">
<strong title={stashTitle(stash)}>{stashTitle(stash)}</strong>
<span>
{stash.selector}
{#if stash.branch}
on {stash.branch}
{/if}
{#if stash.date}
- {stash.date}
{/if}
</span>
</div>
<div class="stash-actions">
<button class="btn-sm" type="button" onclick={() => onApply(stash)} disabled={isBusy} title="Apply stash and keep it">
<Download size={13} aria-hidden="true" />
Apply
</button>
<button class="btn-sm" type="button" onclick={() => onPop(stash)} disabled={isBusy} title="Apply stash and remove it if successful">
<Upload size={13} aria-hidden="true" />
Pop
</button>
<button class="btn-sm danger" type="button" onclick={() => onDrop(stash)} disabled={isBusy} title="Delete stash">
<Trash2 size={13} aria-hidden="true" />
Drop
</button>
</div>
</article>
{/each}
</div>
{/if}
{/if}
</section>
+122 -2
View File
@@ -5,12 +5,15 @@ import type {
CommitAiProvider,
CommitAiStatus,
ConflictFile,
GitBlameResult,
GitBranch,
GitCommit,
GitCommitComparison,
GitRepositoryFile,
GitSearchHit,
GitStash,
GitStatus,
GitTag,
LocalModelOption,
PatchApplyAction,
RepositoryBundle,
@@ -33,6 +36,24 @@ export function openRepositoryBundle(path: string, commitLimit = 100): Promise<R
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
}
export function cloneRepository(
remoteUrl: string,
parentPath: string,
directoryName?: string,
username?: string,
password?: string,
commitLimit = 100,
): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("clone_repository", {
remoteUrl,
parentPath,
directoryName: directoryName?.trim() ? directoryName.trim() : null,
username: username ?? null,
password: password ?? null,
commitLimit,
});
}
export function getStatus(path: string): Promise<GitStatus> {
return invoke<GitStatus>("get_status", { path });
}
@@ -47,6 +68,10 @@ export function listBranches(path: string): Promise<GitBranch[]> {
return invoke<GitBranch[]>("list_branches", { path });
}
export function listStashes(path: string): Promise<GitStash[]> {
return invoke<GitStash[]>("list_stashes", { path });
}
export function checkoutBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("checkout_branch", { path, branch });
}
@@ -67,8 +92,51 @@ export function renameBranch(
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
}
export function deleteBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch });
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch, force });
}
export function listTags(path: string): Promise<GitTag[]> {
return invoke<GitTag[]>("list_tags", { path });
}
export function createTag(
path: string,
name: string,
target?: string,
message?: string,
): Promise<GitTag[]> {
return invoke<GitTag[]>("create_tag", {
path,
name,
target: target?.trim() ? target.trim() : null,
message: message?.trim() ? message.trim() : null,
});
}
export function deleteTag(path: string, name: string): Promise<GitTag[]> {
return invoke<GitTag[]>("delete_tag", { path, name });
}
export function pushTag(
path: string,
name: string,
username?: string,
password?: string,
): Promise<void> {
return invoke<void>("push_tag", { path, name, username: username ?? null, password: password ?? null });
}
export function cherryPickCommit(path: string, commit: string): Promise<GitStatus> {
return invoke<GitStatus>("cherry_pick_commit", { path, commit });
}
export function cherryPickContinue(path: string): Promise<GitStatus> {
return invoke<GitStatus>("cherry_pick_continue", { path });
}
export function cherryPickAbort(path: string): Promise<GitStatus> {
return invoke<GitStatus>("cherry_pick_abort", { path });
}
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
@@ -104,6 +172,42 @@ export function commit(path: string, message: string): Promise<GitStatus> {
return invoke<GitStatus>("commit", { path, message });
}
export function amendCommit(path: string, message?: string): Promise<GitStatus> {
return invoke<GitStatus>("amend_commit", { path, message: message?.trim() ? message.trim() : null });
}
export function lastCommitMessage(path: string): Promise<string | null> {
return invoke<string | null>("last_commit_message", { path });
}
export function undoLastCommit(path: string): Promise<GitStatus> {
return invoke<GitStatus>("undo_last_commit", { path });
}
export function stashPush(
path: string,
message?: string,
includeUntracked = true,
): Promise<GitStatus> {
return invoke<GitStatus>("stash_push", {
path,
message: message?.trim() ? message.trim() : null,
includeUntracked,
});
}
export function stashApply(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_apply", { path, selector });
}
export function stashPop(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_pop", { path, selector });
}
export function stashDrop(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_drop", { path, selector });
}
export function commitAiStatus(): Promise<CommitAiStatus> {
return invoke<CommitAiStatus>("commit_ai_status");
}
@@ -190,6 +294,18 @@ export function mergeBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("merge_branch", { path, branch });
}
export function rebaseBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_branch", { path, branch });
}
export function rebaseContinue(path: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_continue", { path });
}
export function rebaseAbort(path: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_abort", { path });
}
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
}
@@ -207,6 +323,10 @@ export function cancelFileHistory(requestId: string): Promise<void> {
return invoke<void>("cancel_file_history", { requestId });
}
export function getFileBlame(path: string, file: string): Promise<GitBlameResult> {
return invoke<GitBlameResult>("get_file_blame", { path, file });
}
export function compareCommits(
path: string,
from: string,
+44
View File
@@ -33,6 +33,11 @@ export interface AiSettings {
customModel: string;
}
export interface AnalyticsSettings {
enabled: boolean;
noticeSeen: boolean;
}
export interface GitStatus {
repo_path: string;
current_branch: string | null;
@@ -41,6 +46,8 @@ export interface GitStatus {
behind: number;
files: GitFileStatus[];
clean: boolean;
rebase_in_progress: boolean;
cherry_pick_in_progress: boolean;
}
export interface GitFileStatus {
@@ -58,6 +65,24 @@ export interface GitBranch {
remote: boolean;
}
export interface GitTag {
name: string;
hash: string;
short_hash: string;
message: string | null;
date: string;
annotated: boolean;
}
export interface GitStash {
selector: string;
index: number;
hash: string;
branch: string | null;
message: string;
date: string;
}
export interface GitCommit {
hash: string;
short_hash: string;
@@ -85,6 +110,8 @@ export interface GitRepositoryFile {
export interface RepositoryBundle {
status: GitStatus;
branches: GitBranch[];
tags: GitTag[];
stashes: GitStash[];
commits: GitCommit[];
files: GitRepositoryFile[];
}
@@ -153,6 +180,23 @@ export interface ConflictFile {
theirs_size: number | null;
}
export interface GitBlameLine {
line_number: number;
content: string;
commit_hash: string;
short_hash: string;
author_name: string;
author_email: string;
author_time: number;
summary: string;
is_uncommitted: boolean;
}
export interface GitBlameResult {
path: string;
lines: GitBlameLine[];
}
export interface StoredCredential {
username: string;
password: string;