Compare commits

..
39 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
38 changed files with 3297 additions and 186 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.10",
"name": "git-lite",
"version": "2026.7.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tauri-git-lite",
"version": "2026.7.10",
"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.10",
"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"
]
}
+738 -5
View File
@@ -48,6 +48,7 @@ pub struct GitStatus {
pub files: Vec<GitFileStatus>,
pub clean: bool,
pub rebase_in_progress: bool,
pub cherry_pick_in_progress: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -57,6 +58,16 @@ pub struct GitBranch {
pub remote: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitTag {
pub name: String,
pub hash: String,
pub short_hash: String,
pub message: Option<String>,
pub date: String,
pub annotated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitStash {
pub selector: String,
@@ -106,6 +117,25 @@ pub struct GitCommitComparison {
pub patch: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitBlameLine {
pub line_number: u32,
pub content: String,
pub commit_hash: String,
pub short_hash: String,
pub author_name: String,
pub author_email: String,
pub author_time: i64,
pub summary: String,
pub is_uncommitted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitBlameResult {
pub path: String,
pub lines: Vec<GitBlameLine>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ConflictFile {
pub path: String,
@@ -245,11 +275,35 @@ pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
pub struct RepositoryBundle {
pub status: GitStatus,
pub branches: Vec<GitBranch>,
pub tags: Vec<GitTag>,
pub stashes: Vec<GitStash>,
pub commits: Vec<GitCommit>,
pub files: Vec<GitRepositoryFile>,
}
#[tauri::command]
pub async fn clone_repository(
remote_url: String,
parent_path: String,
directory_name: Option<String>,
username: Option<String>,
password: Option<String>,
commit_limit: Option<u32>,
) -> Result<RepositoryBundle, String> {
tauri::async_runtime::spawn_blocking(move || {
clone_repository_core(
&remote_url,
&parent_path,
directory_name.as_deref(),
username.as_deref(),
password.as_deref(),
commit_limit,
)
})
.await
.map_err(|err| format!("Could not clone repository: {err}"))?
}
/// Opens a repository and gathers everything the UI needs in a single call.
///
/// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves
@@ -264,12 +318,14 @@ pub async fn open_repository_bundle(
let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?;
let branches = branches_for_repo(&repo)?;
let tags = tags_for_repo(&repo)?;
let stashes = stashes_for_repo(&repo)?;
let commits = commits_for_repo(&repo, commit_limit)?;
let files = repository_files_with_status(&repo, &status)?;
Ok(RepositoryBundle {
status,
branches,
tags,
stashes,
commits,
files,
@@ -297,6 +353,63 @@ pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
stashes_for_repo(&repo)
}
#[tauri::command]
pub fn list_tags(path: String) -> Result<Vec<GitTag>, String> {
let repo = resolve_repo(&path)?;
tags_for_repo(&repo)
}
fn tags_for_repo(repo: &Path) -> Result<Vec<GitTag>, String> {
let output = run_git(
repo,
[
"for-each-ref",
"--sort=-creatordate",
"refs/tags",
"--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(*objectname)%00%(*objectname:short)%00%(contents:subject)%00%(creatordate:iso-strict)",
],
)?;
let text = String::from_utf8_lossy(&output);
let mut tags = Vec::new();
for line in text.lines() {
let mut parts = line.splitn(7, '\0');
let name = parts.next().unwrap_or_default().trim();
let object_hash = parts.next().unwrap_or_default().trim();
let object_short = parts.next().unwrap_or_default().trim();
let deref_hash = parts.next().unwrap_or_default().trim();
let deref_short = parts.next().unwrap_or_default().trim();
let subject = parts.next().unwrap_or_default().trim();
let date = parts.next().unwrap_or_default().trim();
if name.is_empty() {
continue;
}
// Annotated tags are their own object with a `taggerdate`/subject and
// dereference (`*...`) to the commit they point at; lightweight tags
// point straight at the commit, so the dereferenced fields are empty.
let annotated = !deref_hash.is_empty();
let hash = if annotated { deref_hash } else { object_hash };
let short_hash = if annotated { deref_short } else { object_short };
tags.push(GitTag {
name: name.to_string(),
hash: hash.to_string(),
short_hash: short_hash.to_string(),
message: if annotated && !subject.is_empty() {
Some(subject.to_string())
} else {
None
},
date: date.to_string(),
annotated,
});
}
Ok(tags)
}
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
let output = run_git(
repo,
@@ -547,6 +660,123 @@ pub fn delete_branch(
status_for_repo(&repo)
}
#[tauri::command]
pub fn create_tag(
path: String,
name: String,
target: Option<String>,
message: Option<String>,
) -> Result<Vec<GitTag>, String> {
let repo = resolve_repo(&path)?;
let name = validate_new_tag_name(&repo, &name)?;
let target = match target {
Some(target) if !target.trim().is_empty() => verify_commit(&repo, &target)?,
_ => verify_commit(&repo, "HEAD")?,
};
let message = message
.map(|message| message.trim().to_string())
.filter(|message| !message.is_empty());
match message {
Some(message) => {
run_git(
&repo,
["tag", "-a", name.as_str(), "-m", message.as_str(), target.as_str()],
)?;
}
None => {
run_git(&repo, ["tag", name.as_str(), target.as_str()])?;
}
}
tags_for_repo(&repo)
}
#[tauri::command]
pub fn delete_tag(path: String, name: String) -> Result<Vec<GitTag>, String> {
let repo = resolve_repo(&path)?;
let name = validate_existing_tag_name(&repo, &name)?;
run_git(&repo, ["tag", "-d", name.as_str()])?;
tags_for_repo(&repo)
}
#[tauri::command]
pub fn push_tag(
path: String,
name: String,
username: Option<String>,
password: Option<String>,
) -> Result<(), String> {
let repo = resolve_repo(&path)?;
let name = validate_existing_tag_name(&repo, &name)?;
let remote = initial_push_remote_name(&repo)?;
let tag_ref = format!("refs/tags/{name}");
let push_args = ["push", remote.as_str(), tag_ref.as_str()];
match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated(&repo, push_args, u, p)?;
}
_ => {
run_git(&repo, push_args)?;
}
}
Ok(())
}
fn validate_new_tag_name(repo: &Path, name: &str) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("Tag name must not be empty.".to_string());
}
let normalized = validate_tag_ref_name(name)?;
if ref_exists(repo, &format!("refs/tags/{normalized}"))? {
return Err(format!("Tag '{normalized}' already exists."));
}
Ok(normalized)
}
fn validate_existing_tag_name(repo: &Path, name: &str) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("Tag name must not be empty.".to_string());
}
let normalized = validate_tag_ref_name(name)?;
if !ref_exists(repo, &format!("refs/tags/{normalized}"))? {
return Err(format!("Tag '{normalized}' was not found."));
}
Ok(normalized)
}
fn validate_tag_ref_name(name: &str) -> Result<String, String> {
let output = git_command()
.args(["check-ref-format", "--allow-onelevel", &format!("refs/tags/{name}")])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if !output.status.success() {
let details = command_output_details(&output);
return Err(format!("Invalid tag name: {details}"));
}
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
let normalized = normalized
.strip_prefix("refs/tags/")
.map(str::to_string)
.unwrap_or(normalized);
Ok(if normalized.is_empty() {
name.to_string()
} else {
normalized
})
}
#[tauri::command]
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
@@ -829,6 +1059,63 @@ pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
status_for_repo(&repo)
}
#[tauri::command]
pub fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if verify_commit(&repo, "HEAD").is_err() {
return Err("There is no commit to amend.".to_string());
}
let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) {
return Err("Merge conflicts must be resolved before you can commit.".to_string());
}
let message = message
.map(|message| message.trim().to_string())
.filter(|message| !message.is_empty());
match message {
Some(message) => {
run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?;
}
None => {
run_git(&repo, ["commit", "--amend", "--no-edit"])?;
}
}
status_for_repo(&repo)
}
#[tauri::command]
pub fn last_commit_message(path: String) -> Result<Option<String>, String> {
let repo = resolve_repo(&path)?;
if verify_commit(&repo, "HEAD").is_err() {
return Ok(None);
}
let output = run_git(&repo, ["log", "-1", "--format=%B", "HEAD"])?;
let message = String::from_utf8_lossy(&output).trim_end().to_string();
Ok(if message.is_empty() { None } else { Some(message) })
}
#[tauri::command]
pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if verify_commit(&repo, "HEAD").is_err() {
return Err("There is no commit to undo.".to_string());
}
if verify_commit(&repo, "HEAD~1").is_err() {
return Err("This is the first commit; there is nothing to undo to.".to_string());
}
// Mixed reset: moves HEAD back one commit and unstages the difference, but
// leaves the working tree files untouched, so the undone commit's changes
// reappear as ordinary uncommitted changes instead of being discarded.
run_git(&repo, ["reset", "HEAD~1"])?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn pull(
path: String,
@@ -1191,6 +1478,68 @@ fn rebase_status_or_error(
Err(format!("{context}: {}", command_output_details(&output)))
}
#[tauri::command]
pub fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let commit_hash = verify_commit(&repo, &commit)?;
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["cherry-pick", commit_hash.as_str()])
.env("GIT_EDITOR", "true")
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
cherry_pick_status_or_error(&repo, output, "Cherry-pick failed")
}
#[tauri::command]
pub fn cherry_pick_continue(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !cherry_pick_in_progress(&repo) {
return Err("No cherry-pick is currently in progress.".to_string());
}
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["cherry-pick", "--continue"])
.env("GIT_EDITOR", "true")
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
cherry_pick_status_or_error(&repo, output, "Cherry-pick continue failed")
}
#[tauri::command]
pub fn cherry_pick_abort(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !cherry_pick_in_progress(&repo) {
return Err("No cherry-pick is currently in progress.".to_string());
}
run_git(&repo, ["cherry-pick", "--abort"])?;
status_for_repo(&repo)
}
fn cherry_pick_status_or_error(
repo: &Path,
output: Output,
context: &str,
) -> Result<GitStatus, String> {
if output.status.success() {
return status_for_repo(repo);
}
let status = status_for_repo(repo)?;
if has_unresolved_conflicts(&status) || status.cherry_pick_in_progress {
return Ok(status);
}
Err(format!("{context}: {}", command_output_details(&output)))
}
#[tauri::command]
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
let repo = resolve_repo(&path)?;
@@ -1277,6 +1626,107 @@ pub fn cancel_file_history(
state.cancel(request_id)
}
const UNCOMMITTED_BLAME_HASH: &str = "0000000000000000000000000000000000000000";
#[tauri::command]
pub fn get_file_blame(path: String, file: String) -> Result<GitBlameResult, String> {
let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?;
if verify_commit(&repo, "HEAD").is_err() {
return Err("Repository has no commits yet.".to_string());
}
let args = vec![
OsString::from("blame"),
OsString::from("--line-porcelain"),
OsString::from("--"),
OsString::from(file.clone()),
];
let output =
run_git(&repo, args).map_err(|err| format!("Could not load blame for '{file}': {err}"))?;
Ok(GitBlameResult {
path: file,
lines: parse_blame_porcelain(&output),
})
}
fn parse_blame_porcelain(output: &[u8]) -> Vec<GitBlameLine> {
#[derive(Default, Clone)]
struct BlameMeta {
author_name: String,
author_email: String,
author_time: i64,
summary: String,
}
let text = String::from_utf8_lossy(output);
let mut lines_out: Vec<GitBlameLine> = Vec::new();
let mut commit_meta: BTreeMap<String, BlameMeta> = BTreeMap::new();
let mut current_hash = String::new();
let mut current_final_line: u32 = 0;
for line in text.split('\n') {
if let Some(content) = line.strip_prefix('\t') {
let meta = commit_meta.get(&current_hash).cloned().unwrap_or_default();
lines_out.push(GitBlameLine {
line_number: current_final_line,
content: content.to_string(),
commit_hash: current_hash.clone(),
short_hash: short_hash(&current_hash),
author_name: meta.author_name,
author_email: meta.author_email,
author_time: meta.author_time,
summary: meta.summary,
is_uncommitted: current_hash == UNCOMMITTED_BLAME_HASH,
});
continue;
}
let mut parts = line.splitn(2, ' ');
let head = parts.next().unwrap_or("");
let tail = parts.next().unwrap_or("");
if head.len() == 40 && head.bytes().all(|b| b.is_ascii_hexdigit()) {
if let Some(final_line) = tail.split_whitespace().nth(1) {
current_final_line = final_line.parse().unwrap_or(current_final_line);
}
current_hash = head.to_string();
commit_meta.entry(current_hash.clone()).or_default();
continue;
}
match head {
"author" => {
commit_meta
.entry(current_hash.clone())
.or_default()
.author_name = tail.to_string()
}
"author-mail" => {
let email = tail.trim_matches(|c| c == '<' || c == '>').to_string();
commit_meta
.entry(current_hash.clone())
.or_default()
.author_email = email;
}
"author-time" => {
commit_meta
.entry(current_hash.clone())
.or_default()
.author_time = tail.parse().unwrap_or(0);
}
"summary" => {
commit_meta.entry(current_hash.clone()).or_default().summary = tail.to_string()
}
_ => {}
}
}
lines_out
}
fn list_file_history_core(
repo: &Path,
file: String,
@@ -2026,6 +2476,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
clean: files.is_empty(),
files,
rebase_in_progress: rebase_in_progress(repo),
cherry_pick_in_progress: cherry_pick_in_progress(repo),
})
}
@@ -2033,6 +2484,10 @@ fn rebase_in_progress(repo: &Path) -> bool {
git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply")
}
fn cherry_pick_in_progress(repo: &Path) -> bool {
git_path_exists(repo, "CHERRY_PICK_HEAD")
}
fn git_path_exists(repo: &Path, name: &str) -> bool {
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
return false;
@@ -2208,6 +2663,168 @@ fn repository_files_with_status(
Ok(files.into_values().collect())
}
fn clone_repository_core(
remote_url: &str,
parent_path: &str,
directory_name: Option<&str>,
username: Option<&str>,
password: Option<&str>,
commit_limit: Option<u32>,
) -> Result<RepositoryBundle, String> {
let target = clone_target_path(remote_url, parent_path, directory_name)?;
run_git_clone(remote_url.trim(), &target, username, password)?;
let repo = resolve_repo(&target.to_string_lossy())?;
let status = status_for_repo(&repo)?;
let branches = branches_for_repo(&repo)?;
let tags = tags_for_repo(&repo)?;
let stashes = stashes_for_repo(&repo)?;
let commits = commits_for_repo(&repo, commit_limit)?;
let files = repository_files_with_status(&repo, &status)?;
Ok(RepositoryBundle {
status,
branches,
tags,
stashes,
commits,
files,
})
}
fn clone_target_path(
remote_url: &str,
parent_path: &str,
directory_name: Option<&str>,
) -> Result<PathBuf, String> {
let remote = remote_url.trim();
if remote.is_empty() {
return Err("Remote URL must not be empty.".to_string());
}
if remote.starts_with('-') || remote.chars().any(|c| c.is_control()) {
return Err("Remote URL contains invalid characters.".to_string());
}
let parent = PathBuf::from(parent_path.trim());
if parent_path.trim().is_empty() {
return Err("Destination folder must not be empty.".to_string());
}
if !parent.exists() {
return Err("Destination folder does not exist.".to_string());
}
if !parent.is_dir() {
return Err("Destination path must be a folder.".to_string());
}
let raw_name = directory_name
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| infer_clone_directory_name(remote));
let name = validate_clone_directory_name(&raw_name)?;
let target = parent.join(name);
if target.exists() {
if !target.is_dir() {
return Err("Clone destination already exists and is not a folder.".to_string());
}
let mut entries = target
.read_dir()
.map_err(|err| format!("Could not inspect clone destination: {err}"))?;
if entries.next().is_some() {
return Err("Clone destination already exists and is not empty.".to_string());
}
}
Ok(target)
}
fn infer_clone_directory_name(remote_url: &str) -> String {
let trimmed = remote_url
.trim()
.split(['?', '#'])
.next()
.unwrap_or(remote_url)
.trim_end_matches(['/', '\\']);
let last_segment = trimmed
.rsplit(['/', '\\', ':'])
.find(|part| !part.trim().is_empty())
.unwrap_or("")
.trim();
last_segment
.strip_suffix(".git")
.unwrap_or(last_segment)
.trim()
.to_string()
}
fn validate_clone_directory_name(name: &str) -> Result<String, String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("Folder name could not be inferred. Enter a folder name.".to_string());
}
if trimmed == "." || trimmed == ".." {
return Err("Folder name is not valid.".to_string());
}
if trimmed.chars().any(|c| {
c.is_control() || matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*')
}) {
return Err("Folder name contains invalid characters.".to_string());
}
if Path::new(trimmed).is_absolute() {
return Err("Folder name must be relative.".to_string());
}
Ok(trimmed.to_string())
}
fn run_git_clone(
remote_url: &str,
target: &Path,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
let mut command = git_command();
command
.arg("clone")
.arg("--")
.arg(remote_url)
.arg(target)
.env("GIT_TERMINAL_PROMPT", "0");
let askpass = match (username, password) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
let askpass = write_askpass_script()?;
command
.env("GIT_ASKPASS", &askpass)
.env("GIT_CRED_USER", u)
.env("GIT_CRED_PASS", p);
Some(askpass)
}
_ => None,
};
let output = command
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"));
if let Some(path) = askpass {
let _ = std::fs::remove_file(path);
}
let output = output?;
if output.status.success() {
return Ok(());
}
let details = command_output_details(&output);
if is_auth_error(&details) {
return Err(format!("AUTH_FAILED:{details}"));
}
Err(format!("Git clone failed: {}", details))
}
fn is_repository_folder_path(repo: &Path, path: &str) -> Result<bool, String> {
let normalized = normalize_git_path(path);
if repo.join(path).is_dir() {
@@ -3232,16 +3849,47 @@ fn is_auth_error(details: &str) -> bool {
|| d.contains("authentication required")
}
// Windows' CreateProcess rejects command lines longer than ~32K chars with
// "os error 206" (filename or extension too long). Staging/restoring a large
// number of files can easily exceed that, so split the paths across multiple
// invocations and concatenate their output.
const MAX_PATH_ARGS_CHARS: usize = 8_000;
fn run_git_with_paths(
repo: &Path,
base_args: &[&str],
files: &[String],
) -> Result<Vec<u8>, String> {
let mut args = Vec::with_capacity(base_args.len() + files.len() + 1);
args.extend(base_args.iter().map(OsString::from));
args.push(OsString::from("--"));
args.extend(files.iter().map(OsString::from));
run_git(repo, args)
if files.is_empty() {
let args: Vec<OsString> = base_args.iter().map(OsString::from).collect();
return run_git(repo, args);
}
let mut combined = Vec::new();
let mut start = 0;
while start < files.len() {
let mut end = start;
let mut chunk_chars = 0usize;
while end < files.len() {
let len = files[end].len() + 1;
if end > start && chunk_chars + len > MAX_PATH_ARGS_CHARS {
break;
}
chunk_chars += len;
end += 1;
}
let chunk = &files[start..end];
let mut args = Vec::with_capacity(base_args.len() + chunk.len() + 1);
args.extend(base_args.iter().map(OsString::from));
args.push(OsString::from("--"));
args.extend(chunk.iter().map(OsString::from));
combined.extend(run_git(repo, args)?);
start = end;
}
Ok(combined)
}
fn run_git_with_paths_cancellable(
@@ -3682,6 +4330,51 @@ mod tests {
run_git_test(repo, ["commit", "-q", "-m", "init"]);
}
#[test]
fn clone_directory_name_is_inferred_from_common_remote_urls() {
assert_eq!(
infer_clone_directory_name("https://github.com/example/project.git"),
"project"
);
assert_eq!(
infer_clone_directory_name("git@github.com:example/project.git"),
"project"
);
assert_eq!(
infer_clone_directory_name("ssh://git@example.com/example/project.git/"),
"project"
);
}
#[test]
fn clone_repository_core_clones_and_returns_repository_bundle() {
let source = init_temp_repo("clone_source");
commit_initial_file(&source.path);
let parent = temp_dir("clone_parent");
let bundle = clone_repository_core(
source.path.to_str().expect("source path should be UTF-8"),
parent.path.to_str().expect("parent path should be UTF-8"),
Some("local-copy"),
None,
None,
Some(100),
)
.expect("repository should clone");
let cloned_repo = parent.path.join("local-copy");
assert_eq!(
PathBuf::from(bundle.status.repo_path),
cloned_repo
.canonicalize()
.expect("clone path should resolve")
);
assert!(cloned_repo.join("old.txt").exists());
assert!(bundle.status.clean);
assert_eq!(bundle.commits.len(), 1);
assert!(bundle.files.iter().any(|file| file.path == "old.txt"));
}
#[test]
fn search_code_introductions_finds_added_string() {
let repo = init_temp_repo("search_added_string");
@@ -4846,4 +5539,44 @@ mod tests {
assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE);
}
#[test]
fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() {
let repo = init_temp_repo("file_blame");
commit_initial_file(&repo.path);
fs::write(repo.path.join("old.txt"), "original\ntwo\n").expect("tracked file should change");
run_git_test(&repo.path, ["add", "old.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "add second line"]);
let result = get_file_blame(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
)
.unwrap();
assert_eq!(result.path, "old.txt");
assert_eq!(result.lines.len(), 2);
assert_eq!(result.lines[0].content, "original");
assert_eq!(result.lines[0].summary, "init");
assert_eq!(result.lines[1].content, "two");
assert_eq!(result.lines[1].summary, "add second line");
assert!(!result.lines[1].author_name.is_empty());
assert!(!result.lines[1].is_uncommitted);
}
#[test]
fn get_file_blame_marks_uncommitted_working_tree_changes() {
let repo = init_temp_repo("file_blame_uncommitted");
commit_initial_file(&repo.path);
fs::write(repo.path.join("old.txt"), "changed\n").expect("tracked file should change");
let result = get_file_blame(
repo.path.to_string_lossy().to_string(),
"old.txt".to_string(),
)
.unwrap();
assert_eq!(result.lines.len(), 1);
assert!(result.lines[0].is_uncommitted);
}
}
+33 -11
View File
@@ -5,26 +5,37 @@ 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, list_stashes, merge_branch, open_repo_in_explorer, open_repository,
open_repository_bundle, open_repository_file, pull, push, read_conflict, rebase_abort,
rebase_branch, rebase_continue, rename_branch, resolve_conflict, resolve_conflict_side,
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions,
stage_files, stash_apply, stash_drop, stash_pop, stash_push, unstage_files,
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, 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,
@@ -34,6 +45,13 @@ fn main() {
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,
@@ -44,6 +62,9 @@ fn main() {
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,
@@ -62,6 +83,7 @@ fn main() {
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.10",
"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"]
}
}
+706 -26
View File
File diff suppressed because it is too large Load Diff
+446 -28
View File
@@ -234,8 +234,7 @@
display: grid;
place-items: center;
padding: 24px;
background: rgba(4,8,18,0.58);
backdrop-filter: blur(8px);
background: #050712;
}
.branch-filter-dialog {
@@ -247,8 +246,8 @@
border: 1px solid rgba(94,110,156,0.24);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(255,255,255,0.045), transparent 70%),
var(--color-surface);
linear-gradient(180deg, #20253f 0%, #151827 72%),
#151827;
box-shadow: 0 24px 80px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.05);
}
@@ -440,7 +439,13 @@
/* --- App shell --- */
.shell { display: flex; flex-direction: column; height: 100%; background: transparent; }
.shell {
--app-titlebar-height: 42px;
display: flex;
flex-direction: column;
height: 100%;
background: transparent;
}
.shell-body {
display: flex;
@@ -454,10 +459,12 @@
/* --- Titlebar --- */
.titlebar {
position: relative;
z-index: 500;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
height: 42px;
height: var(--app-titlebar-height);
background:
linear-gradient(90deg, rgba(100,108,255,0.16), rgba(65,209,255,0.08), rgba(255,211,67,0.035)),
var(--color-bar);
@@ -833,8 +840,8 @@
font-style: normal;
font-weight: 800;
}
.repo-row-meta .ahead { color: #4eca76; }
.repo-row-meta .behind { color: #e0a040; }
.repo-row-meta .ahead { color: #e0a040; background: rgba(224,160,64,0.13); }
.repo-row-meta .behind { color: #7aacff; background: rgba(122,172,255,0.13); }
.repo-row-icon {
min-height: 36px;
min-width: 36px;
@@ -1550,6 +1557,12 @@
font-size: 10px;
}
.tag-group-head { display: flex; align-items: center; gap: 6px; }
.tag-group-head .branch-group-toggle { flex: 1; min-width: 0; }
.tag-group-head .branch-create-toggle { flex-shrink: 0; }
.tag-create-form { grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto auto; }
.branch-empty {
padding: 8px 10px;
color: var(--color-ink-faint);
@@ -1639,6 +1652,7 @@
.branch-actions .btn-sm { min-height: 24px; padding: 0 6px; font-size: 11px; }
.branch-context-menu,
.history-context-menu,
.explorer-context-menu {
z-index: 120;
display: grid;
@@ -1651,10 +1665,12 @@
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
}
.branch-context-menu { position: absolute; }
.branch-context-menu,
.history-context-menu { position: absolute; }
.explorer-context-menu { position: fixed; }
.branch-context-menu button,
.history-context-menu button,
.explorer-context-menu button {
display: flex;
align-items: center;
@@ -1673,13 +1689,15 @@
}
.branch-context-menu button:hover:not(:disabled),
.history-context-menu button:hover:not(:disabled),
.explorer-context-menu button:hover:not(:disabled) {
border-color: var(--color-border-subtle);
background: rgba(255,255,255,0.06);
color: var(--color-ink);
}
.branch-context-menu .menu-separator {
.branch-context-menu .menu-separator,
.history-context-menu .menu-separator {
height: 1px;
margin: 4px 3px;
background: var(--color-border-subtle);
@@ -1696,6 +1714,7 @@
}
.branch-context-menu button:disabled,
.history-context-menu button:disabled,
.explorer-context-menu button:disabled {
cursor: not-allowed;
opacity: 0.48;
@@ -1795,6 +1814,56 @@
resize: none;
overflow: auto;
}
.commit-amend-row {
display: flex;
align-items: center;
justify-content: space-between;
flex: 0 0 auto;
gap: 8px;
min-width: 0;
}
.commit-amend-toggle {
display: flex;
align-items: center;
gap: 6px;
color: var(--color-ink-dim);
font-size: 12px;
white-space: nowrap;
}
.commit-amend-toggle input {
appearance: none;
flex: 0 0 auto;
width: 14px;
height: 14px;
min-width: 14px;
min-height: 14px;
padding: 0;
border: 1px solid rgba(94,110,156,0.42);
border-radius: 4px;
background: rgba(13,14,26,0.78);
box-shadow: none;
cursor: pointer;
}
.commit-amend-toggle input:checked {
border-color: rgba(65,209,255,0.62);
background:
linear-gradient(135deg, rgba(65,209,255,0.92), rgba(100,108,255,0.92));
box-shadow: inset 0 0 0 2px rgba(13,14,26,0.82);
}
.commit-amend-toggle input:focus {
box-shadow: 0 0 0 2px rgba(65,209,255,0.2);
}
.commit-amend-toggle input:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.commit-undo-button {
flex: 0 0 auto;
min-height: 26px;
padding: 0 8px;
font-size: 11px;
}
.commit-actions-row {
display: flex;
flex: 0 0 auto;
@@ -1817,6 +1886,7 @@
/* --- Commit history --- */
.history-panel { position: relative; }
.history-list { min-width: 0; padding: 6px; overflow: auto; }
.commit-row { display: grid; min-width: 0; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); transition: border-color 120ms; }
@@ -2017,6 +2087,22 @@
}
.commit-action-buttons { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; }
.commit-action-buttons button { flex: 0 0 auto; white-space: nowrap; }
.commit-menu-button {
width: 28px;
min-width: 28px;
min-height: 26px;
padding: 0;
border-color: rgba(94,110,156,0.16);
border-radius: 7px;
background: rgba(255,255,255,0.035);
color: var(--color-ink-dim);
}
.commit-menu-button:hover:not(:disabled),
.commit-menu-button[aria-expanded="true"] {
border-color: rgba(65,209,255,0.28);
background: rgba(65,209,255,0.08);
color: var(--color-ink);
}
.file-history-head { align-items: flex-start; }
.file-history-heading { min-width: 0; flex: 1 1 auto; overflow: hidden; }
.section-head .file-history-name {
@@ -2162,6 +2248,15 @@
.graph-svg path.hidden-branch {
opacity: 0.08;
}
.graph-svg path.graph-segment-ahead {
stroke: #e0a040;
filter: drop-shadow(0 0 3px rgba(224,160,64,0.24));
}
.graph-svg path.graph-segment-behind {
stroke: #7aacff;
stroke-dasharray: 4 4;
filter: drop-shadow(0 0 3px rgba(122,172,255,0.24));
}
.graph-dot {
position: absolute;
z-index: 2;
@@ -2179,6 +2274,14 @@
opacity: 0.16;
box-shadow: none;
}
.graph-dot.ahead {
background: #e0a040;
box-shadow: 0 0 0 1px rgba(224,160,64,0.42), 0 0 10px rgba(224,160,64,0.16);
}
.graph-dot.behind {
background: #7aacff;
box-shadow: 0 0 0 1px rgba(122,172,255,0.46), 0 0 10px rgba(122,172,255,0.18);
}
.graph-dot.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); }
.graph-dot.merge {
width: 15px;
@@ -2200,8 +2303,7 @@
transform: translateY(-50%) translateX(-4px);
transition: opacity 120ms ease, transform 120ms ease;
}
.graph-gutter:hover .graph-hover-branches,
.graph-row:hover .graph-hover-branches {
.graph-gutter:hover .graph-hover-branches {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
@@ -2225,6 +2327,22 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.graph-hover-branches span.remote,
.branch-filter-option.remote {
border-color: rgba(122,172,255,0.32);
color: #bcd2ff;
background: rgba(31,43,72,0.88);
}
.graph-hover-branches span.ahead {
border-color: rgba(224,160,64,0.42);
color: #ffd99a;
background: rgba(58,42,20,0.94);
}
.graph-hover-branches span.behind {
border-color: rgba(122,172,255,0.46);
color: #c7dbff;
background: rgba(25,39,70,0.94);
}
.graph-hover-branches svg {
flex: 0 0 auto;
color: #76d995;
@@ -2238,6 +2356,12 @@
transition: background 120ms ease;
}
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(94,110,156,0.1); }
.graph-row.graph-ahead-row .commit-body {
box-shadow: inset 3px 0 0 rgba(224,160,64,0.72);
}
.graph-row.graph-behind-row .commit-body {
box-shadow: inset 3px 0 0 rgba(122,172,255,0.72);
}
.graph-row:hover .commit-body { background: rgba(37,40,62,0.54); }
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; }
.graph-row:hover .graph-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; }
@@ -2299,8 +2423,12 @@
display: grid;
place-items: center;
padding: 24px;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(2px);
background-color: #050712;
background-image: none;
}
.app-chrome-backdrop {
top: var(--app-titlebar-height, 42px);
}
.dialog {
@@ -2310,7 +2438,8 @@
height: min(760px, 100%);
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-surface);
background-color: #111321;
background-image: none;
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.6), 0 2px 12px rgba(0,0,0,0.4);
overflow: hidden;
}
@@ -2327,6 +2456,11 @@
width: min(1320px, calc(100vw - 32px));
height: min(860px, calc(100vh - 32px));
}
.blame-dialog {
grid-template-rows: auto minmax(0, 1fr);
width: min(1320px, calc(100vw - 32px));
height: min(860px, calc(100vh - 32px));
}
.compare-select-dialog {
display: block;
width: min(720px, calc(100vw - 32px));
@@ -2363,12 +2497,163 @@
max-height: calc(100vh - 32px);
overflow: auto;
}
.app-settings-dialog,
.analytics-notice-dialog {
display: block;
width: min(620px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.clone-repository-dialog {
display: block;
width: min(620px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.clone-dialog-form {
display: grid;
gap: 14px;
padding: 16px;
}
.clone-dialog-field {
display: grid;
gap: 5px;
min-width: 0;
}
.clone-dialog-field > span {
overflow: hidden;
color: var(--color-ink-faint);
font-size: 10.5px;
font-weight: 800;
text-transform: uppercase;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.04em;
}
.clone-dialog-path-field {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
min-width: 0;
}
.clone-dialog-error {
padding: 9px 10px;
border: 1px solid rgba(232,96,90,0.3);
border-radius: 7px;
color: #f09090;
background: rgba(232,96,90,0.08);
font-size: 12.5px;
line-height: 1.45;
}
.clone-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 2px;
}
.ai-settings-form {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
}
.app-settings-form {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.settings-section {
display: grid;
gap: 12px;
min-width: 0;
}
.settings-section > header {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
padding-bottom: 10px;
border-bottom: 1px solid var(--color-border-subtle);
}
.settings-section > header svg {
color: var(--color-accent);
flex: 0 0 auto;
}
.settings-section h3 {
margin: 1px 0 0;
color: var(--color-ink);
font-size: 15px;
font-weight: 750;
}
.settings-toggle-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 10px;
min-width: 0;
padding: 12px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: #151827;
color: var(--color-ink-muted);
cursor: pointer;
}
.settings-toggle-row input {
width: 16px;
height: 16px;
margin-top: 2px;
accent-color: var(--color-accent);
}
.settings-toggle-row span {
display: grid;
gap: 4px;
min-width: 0;
}
.settings-toggle-row strong {
color: var(--color-ink);
font-size: 13px;
}
.settings-toggle-row small {
color: var(--color-ink-dim);
font-size: 12px;
line-height: 1.45;
}
.analytics-notice-body {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 14px;
padding: 18px 16px 16px;
}
.analytics-notice-icon {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border: 1px solid rgba(65,209,255,0.28);
border-radius: 10px;
color: var(--color-accent);
background: rgba(65,209,255,0.08);
}
.analytics-notice-copy {
display: grid;
gap: 9px;
min-width: 0;
color: var(--color-ink-muted);
font-size: 13px;
line-height: 1.5;
}
.analytics-notice-copy p {
margin: 0;
}
.analytics-notice-toggle {
grid-column: 1 / -1;
}
.analytics-notice-footer {
align-items: center;
}
.ai-provider-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2478,7 +2763,7 @@
gap: 8px;
}
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: #171a2b; }
.dialog-header > div:first-child { min-width: 0; }
.dialog-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex: 0 0 auto; min-width: 0; }
.compare-restore { max-width: 170px; min-width: 0; }
@@ -2492,7 +2777,7 @@
.dialog-body { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 0; }
.compare-dialog .dialog-body { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); }
.dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
.dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: #171a2b; }
.dialog-file-row {
display: grid;
@@ -2522,7 +2807,7 @@
gap: 8px;
padding: 7px 12px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface-dim);
background: #171a2b;
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-ink-muted);
@@ -2544,7 +2829,7 @@
font-size: 12px;
line-height: 1.5;
tab-size: 2;
background: var(--color-surface-raised);
background: #111321;
}
.split-pane {
@@ -2568,7 +2853,7 @@
white-space: pre-wrap;
word-break: break-all;
}
.split-span.split-meta { color: var(--color-ink-faint); background: rgba(0,0,0,0.18); font-size: 11px; }
.split-span.split-meta { color: var(--color-ink-faint); background: #0c0e18; font-size: 11px; }
.split-span.split-hunk { color: #7aacff; background: rgba(122,172,255,0.08); padding: 3px 10px; }
.split-num {
@@ -2578,11 +2863,11 @@
font-size: 11px;
user-select: none;
border-right: 1px solid var(--color-border-subtle);
background: rgba(0,0,0,0.14);
background: #0d101a;
}
.split-num.del { background: rgba(232,96,96,0.12); color: rgba(232,96,96,0.6); border-right-color: rgba(232,96,96,0.2); }
.split-num.add { background: rgba(78,202,118,0.1); color: rgba(78,202,118,0.6); border-right-color: rgba(78,202,118,0.2); }
.split-num.empty { background: rgba(0,0,0,0.08); }
.split-num.empty { background: #10131e; }
.split-cell {
padding: 0 8px;
@@ -2593,7 +2878,7 @@
}
.split-cell.del { background: rgba(232,96,96,0.1); color: #ef8080; }
.split-cell.add { background: rgba(78,202,118,0.09); color: #5dd88a; }
.split-cell.empty { background: rgba(0,0,0,0.06); }
.split-cell.empty { background: #10131e; }
/* Search-hit highlight: amber, distinct from add (green) / del (red).
Higher specificity so it overrides the add/del backgrounds on a matched line. */
@@ -2624,7 +2909,7 @@
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-ink-faint);
background: rgba(0,0,0,0.1);
background: #0d101a;
}
.split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); }
.split-col-hash {
@@ -2640,7 +2925,7 @@
letter-spacing: 0;
}
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-border-subtle); background: #171a2b; }
.dialog-footer-info { color: var(--color-ink-dim); font-size: 13px; font-weight: 700; }
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; }
@@ -2815,6 +3100,134 @@
font-family: var(--font-mono);
}
.blame-body {
min-height: 0;
overflow: hidden;
background: #111321;
}
.blame-code-header strong {
flex: 0 0 auto;
margin-left: auto;
color: var(--color-ink-faint);
font-size: 11px;
font-weight: 800;
}
.blame-column-headers {
grid-template-columns: minmax(300px, 340px) minmax(0, 1fr);
}
.blame-commit-col-label {
padding-left: 12px;
}
.blame-code-col-label {
padding-left: calc(3.2rem + 10px);
}
.blame-commit-col-label + .blame-code-col-label {
border-left: 1px solid var(--color-border-subtle);
}
.blame-diff {
grid-template-columns: minmax(0, 1fr);
}
.blame-scroll {
min-height: 0;
overflow: auto;
}
.blame-code-table {
width: max-content;
min-width: 100%;
}
.blame-group {
display: grid;
grid-template-columns: minmax(300px, 340px) minmax(max-content, 1fr);
align-items: stretch;
min-width: max-content;
border-bottom: 1px solid var(--color-border-subtle);
}
.blame-group.uncommitted {
background: #171725;
}
.blame-meta {
position: sticky;
left: 0;
z-index: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
padding: 8px 12px;
border-right: 1px solid var(--color-border-subtle);
background: #0d101a;
box-shadow: 8px 0 18px rgba(0, 0, 0, 0.18);
}
.blame-hash {
align-self: flex-start;
overflow: hidden;
max-width: 100%;
padding: 2px 7px;
border: 1px solid rgba(90,140,248,0.2);
border-radius: 5px;
background: #141b2d;
color: var(--color-accent);
font-size: 10.5px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.blame-author {
overflow: hidden;
color: var(--color-ink);
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.blame-summary {
overflow: hidden;
color: #aeb6d8;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.blame-date {
color: var(--color-ink-faint);
font-size: 10.5px;
}
.blame-group:hover .blame-meta {
background: #111728;
}
.blame-group.uncommitted .blame-hash,
.blame-group.uncommitted .blame-author {
color: #e8b45a;
}
.blame-group.uncommitted .blame-hash {
border-color: rgba(232, 180, 90, 0.26);
background: #271f14;
}
.blame-lines {
min-width: max-content;
grid-template-columns: 4rem minmax(max-content, 1fr);
}
.blame-line-number {
min-height: 20px;
padding-right: 8px;
}
.blame-line-code {
min-height: 20px;
}
.blame-group:hover .blame-line-number {
background: #111728;
}
.blame-group:hover .blame-line-code {
background: #151a2b;
}
.global-search-body {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
@@ -3205,11 +3618,10 @@
border: 1px solid rgba(100, 108, 255, 0.36);
border-radius: 14px;
background:
linear-gradient(180deg, rgba(255,255,255,0.06), transparent 42%),
rgba(15, 16, 28, 0.96);
linear-gradient(180deg, #222743 0%, #111321 42%),
#111321;
box-shadow: 0 32px 90px rgba(0, 0, 0, 0.68), 0 0 0 1px rgba(255,255,255,0.04) inset;
overflow: hidden;
backdrop-filter: blur(18px);
}
.cred-hero {
@@ -3779,6 +4191,12 @@
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
.repo-tab.management { min-width: 0; }
.repo-tabs-scroll { grid-column: 1 / -1; order: 2; border-top: 1px solid var(--color-border-subtle); }
.repo-management-head,
.repo-management-tools { align-items: stretch; flex-direction: column; }
.repo-management-actions { justify-content: flex-start; }
.clone-dialog-path-field { grid-template-columns: minmax(0, 1fr); }
.clone-dialog-actions { flex-direction: column-reverse; }
.clone-dialog-actions button { width: 100%; }
.repo-row-main { grid-template-columns: minmax(0, 1fr); gap: 2px; align-content: center; padding-left: 12px; }
.repo-row { min-height: 58px; }
.repo-row-icon { min-height: 58px; }
+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>
@@ -20,13 +20,9 @@
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
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 branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header">
<div>
+183 -5
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,6 +45,7 @@
branches: GitBranchInfo[];
localBranches: GitBranchInfo[];
remoteBranches: GitBranchInfo[];
tags: GitTag[];
hasRepository: boolean;
isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void;
@@ -53,12 +54,16 @@
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 = () => {},
@@ -67,17 +72,28 @@
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"));
@@ -270,12 +286,74 @@
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">
@@ -300,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">
@@ -458,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}
@@ -499,4 +657,24 @@
</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">
+176 -22
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { ChevronDown, ChevronRight, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment {
@@ -32,6 +32,7 @@
commits: GitCommit[];
localBranchNames: string[];
activeBranch: string;
activeUpstream: string;
repositoryKey: string;
hasRepository: boolean;
isBusy: boolean;
@@ -40,12 +41,14 @@
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,
@@ -54,12 +57,17 @@
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];
@@ -195,7 +203,13 @@
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);
}
@@ -203,12 +217,39 @@
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 (localBranchNames.length === 0) return true;
if (graphBranchNames.length === 0) return true;
return branches.some(branchIsVisible);
}
@@ -240,7 +281,7 @@
const membership = new Map<string, Set<string>>();
for (const commit of items) {
for (const branch of localBranchRefs(commit)) {
for (const branch of graphBranchRefs(commit)) {
const stack = [commit.hash];
const seen = new Set<string>();
@@ -265,7 +306,7 @@
return new Map(
items.map((commit) => [
commit.hash,
localBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
graphBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
]),
);
}
@@ -316,10 +357,59 @@
}
}
function handleBranchDialogBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
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[] {
@@ -334,6 +424,18 @@
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)));
}
@@ -367,7 +469,7 @@
$effect(() => {
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
? activeBranch
: (localBranchNames[0] ?? "");
: (graphBranchNames[0] ?? "");
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
if (!defaultBranch) {
@@ -399,9 +501,9 @@
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
</script>
<svelte:window onkeydown={handleBranchDialogKeydown} />
<svelte:window onclick={closeCommitContextMenu} onkeydown={handleWindowKeydown} on:contextmenu|capture={closeCommitContextMenu} />
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
<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>
@@ -437,15 +539,25 @@
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
{@const item = entry.commit}
{@const row = graphRows[rowIndex]}
{@const hoverBranchRefs = visibleBranchLabels(row?.branchLabels ?? [])}
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
{@const otherRefs = visibleRefs(item)}
<article class="commit-row graph-row" class:merge-row={item.parents.length > 1} class:root-row={item.parents.length === 0} class:tip-row={item.refs.length > 0}>
{@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}
<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"
@@ -455,6 +567,8 @@
{#each row.bottom as seg}
<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"
@@ -467,13 +581,20 @@
class:merge={item.parents.length > 1}
class:tip={item.refs.length > 0}
class:hidden-branch={!rowGraphIsVisible(row)}
title={hoverBranchRefs.length > 0 ? `Contained in: ${hoverBranchRefs.join(", ")}` : item.short_hash}
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 title={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>
@@ -551,13 +672,17 @@
<div class="commit-actions">
<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>
@@ -566,10 +691,39 @@
{/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" onclick={handleBranchDialogBackdropClick}>
<div class="branch-filter-backdrop" role="presentation">
<div
class="branch-filter-dialog"
role="dialog"
@@ -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;
}
-1
View File
@@ -254,7 +254,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
class="dialog"
+79
View File
@@ -5,6 +5,7 @@ import type {
CommitAiProvider,
CommitAiStatus,
ConflictFile,
GitBlameResult,
GitBranch,
GitCommit,
GitCommitComparison,
@@ -12,6 +13,7 @@ import type {
GitSearchHit,
GitStash,
GitStatus,
GitTag,
LocalModelOption,
PatchApplyAction,
RepositoryBundle,
@@ -34,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 });
}
@@ -76,6 +96,49 @@ export function deleteBranch(path: string, branch: string, force = false): Promi
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> {
return invoke<GitStatus>("stage_files", { path, files });
}
@@ -109,6 +172,18 @@ 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,
@@ -248,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,
+33
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;
@@ -42,6 +47,7 @@ export interface GitStatus {
files: GitFileStatus[];
clean: boolean;
rebase_in_progress: boolean;
cherry_pick_in_progress: boolean;
}
export interface GitFileStatus {
@@ -59,6 +65,15 @@ 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;
@@ -95,6 +110,7 @@ export interface GitRepositoryFile {
export interface RepositoryBundle {
status: GitStatus;
branches: GitBranch[];
tags: GitTag[];
stashes: GitStash[];
commits: GitCommit[];
files: GitRepositoryFile[];
@@ -164,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;