Compare commits

..
24 Commits
Author SHA1 Message Date
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
24 changed files with 1056 additions and 116 deletions
+2 -1
View File
@@ -78,7 +78,8 @@
"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)"
]
}
}
+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.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tauri-git-lite",
"version": "2026.7.10",
"name": "git-lite",
"version": "2026.7.13",
"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.13",
"private": true,
"type": "module",
"scripts": {
+264 -5
View File
@@ -250,6 +250,29 @@ pub struct RepositoryBundle {
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
@@ -2208,6 +2231,166 @@ 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 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,
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 +3415,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 +3896,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");
+11 -9
View File
@@ -6,15 +6,16 @@ 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,
checkout_branch, 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, 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,
};
fn main() {
@@ -25,6 +26,7 @@ fn main() {
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
open_repository,
clone_repository,
open_repo_in_explorer,
open_repository_file,
get_status,
+9 -4
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GitLite",
"version": "2026.7.10",
"version": "2026.7.13",
"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"]
}
}
+200 -5
View File
@@ -2,12 +2,13 @@
import { onDestroy, onMount, tick } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
import { AlertCircle, BookOpen, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
import TitleBar from "./lib/TitleBar.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte";
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareDialog from "./lib/components/CompareDialog.svelte";
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
@@ -28,6 +29,7 @@
import {
checkoutBranch,
cloneRepository,
commit,
commitAiGenerate,
commitAiLoad,
@@ -110,6 +112,7 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone";
type PendingDiscard =
| { kind: "file"; file: GitFileStatus; staged: boolean }
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
@@ -124,6 +127,12 @@
lastOpened: number;
}
interface CloneRequest {
remoteUrl: string;
parentPath: string;
directoryName: string;
}
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
@@ -135,6 +144,7 @@
const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
const HISTORY_ASIDE_MIN_WIDTH = 560;
const HISTORY_ASIDE_MAX_WIDTH = 920;
const ERROR_AUTO_HIDE_MS = 6000;
// ── State ──────────────────────────────────────────────────────────────────
@@ -144,6 +154,10 @@
let repoTabs: RepoTab[] = [];
let recentRepoPaths: string[] = [];
let repoSearch = "";
let cloneDialogOpen = false;
let cloneDialogError = "";
let cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined;
let pendingClone: CloneRequest | null = null;
let status: GitStatus | null = null;
let branches: GitBranchInfo[] = [];
let stashes: GitStash[] = [];
@@ -200,7 +214,7 @@
let autoRefreshEnabled = true;
let autoRefreshInFlight = false;
let credDialogOpen = false;
let credDialogAction: "push" | "pull" | "fetch" | null = null;
let credDialogAction: CredentialAction | null = null;
let credDialogError = "";
let credDialogKey: string | null = null;
let lastStatusFingerprint = "";
@@ -221,6 +235,7 @@
let updateCheckInFlight = false;
let updateDownloadTotal = 0;
let updateDownloadedBytes = 0;
let errorAutoHideTimers: Partial<Record<string, ReturnType<typeof setTimeout>>> = {};
let commitPanelHeight = loadCommitPanelHeight();
let resizingCommitPanel = false;
let resizeStartY = 0;
@@ -236,7 +251,9 @@
$: hasRepository = activeRepoPath.length > 0 && status !== null;
$: workspaceActive = activeView === "repository" && hasRepository;
$: openingRepo = operation === "Opening repository";
$: cloningRepo = operation === "Cloning repository";
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
$: cloneDisplayName = pendingClone?.directoryName || repoNameFromCloneUrl(pendingClone?.remoteUrl ?? "");
$: changedFiles = status?.files ?? [];
$: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0;
$: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0;
@@ -277,9 +294,37 @@
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
Object.values(errorAutoHideTimers).forEach((timer) => {
if (timer) clearTimeout(timer);
});
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
});
$: scheduleAutoHideError("errorMessage", errorMessage, (message) => {
if (errorMessage === message) errorMessage = "";
});
$: scheduleAutoHideError("linePatchError", linePatchError, (message) => {
if (linePatchError === message) linePatchError = "";
});
$: scheduleAutoHideError("globalSearchError", globalSearchError, (message) => {
if (globalSearchError === message) globalSearchError = "";
});
$: scheduleAutoHideError("credDialogError", credDialogError, (message) => {
if (credDialogError === message) credDialogError = "";
});
$: scheduleAutoHideError("updateError", updateError, (message) => {
if (updateError === message) updateError = "";
if (updateToastState === "error") updateToastOpen = false;
});
$: scheduleAutoHideError(
"updateErrorToast",
updateToastOpen && updateToastState === "error" ? (updateError || "Update failed") : "",
() => {
if (updateToastState === "error") updateToastOpen = false;
},
);
// ── Auto-refresh ───────────────────────────────────────────────────────────
function statusFingerprint(value: GitStatus): string {
@@ -517,6 +562,41 @@
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function repoNameFromCloneUrl(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 setCloneDialogError(message: string) {
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
cloneDialogError = message;
if (message) {
cloneDialogErrorTimer = setTimeout(() => {
if (cloneDialogError === message) cloneDialogError = "";
}, ERROR_AUTO_HIDE_MS);
}
}
function scheduleAutoHideError(
key: string,
message: string,
clearIfCurrent: (message: string) => void,
) {
const existing = errorAutoHideTimers[key];
if (existing) {
clearTimeout(existing);
delete errorAutoHideTimers[key];
}
if (!message) return;
errorAutoHideTimers[key] = setTimeout(() => {
clearIfCurrent(message);
delete errorAutoHideTimers[key];
}, ERROR_AUTO_HIDE_MS);
}
function repoKey(path: string): string {
return path.replace(/\\/g, "/").trim().toLowerCase();
}
@@ -983,6 +1063,91 @@
}
}
async function cloneRepo(
remoteUrl: string,
parentPath: string,
directoryName: string,
username?: string,
password?: string,
key?: string | null,
fromStore = false,
) {
if (isBusy) return;
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
if (!parentPath) { errorMessage = "Select a destination folder."; return; }
const request: CloneRequest = { remoteUrl, parentPath, directoryName };
pendingClone = request;
const credentialKey = key === undefined ? orgKeyFromUrl(remoteUrl) : key;
if (!username && !password) {
const stored = await loadStoredCredential(credentialKey);
if (stored && !isCredentialExpired(stored)) {
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true);
return;
}
if (stored && credentialKey) await credDelete(credentialKey).catch(() => {});
}
operation = "Cloning repository";
errorMessage = "";
setCloneDialogError("");
try {
const bundle = await cloneRepository(
remoteUrl,
parentPath,
directoryName || undefined,
username,
password,
100,
);
resetRepositoryState(false);
applyStatus(bundle.status);
if (globalSearchBusy) void cancelGlobalSearch();
activeView = "repository";
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
cloneDialogOpen = false;
pendingClone = null;
if (credDialogAction === "clone") {
credDialogOpen = false;
credDialogAction = null;
credDialogError = "";
credDialogKey = null;
}
lastRepoSwitchAt = Date.now();
} catch (error) {
const rawMessage = errorToMessage(error);
const message = stripAuthPrefix(rawMessage);
if (isAuthError(rawMessage)) {
errorMessage = "";
setCloneDialogError("");
if (fromStore) {
if (credentialKey) void credDelete(credentialKey).catch(() => {});
credDialogError = "Credentials were rejected or have expired. Please sign in again.";
} else {
credDialogError = message || "Sign-in is required to clone this repository.";
}
credDialogAction = "clone";
credDialogKey = credentialKey;
credDialogOpen = true;
} else {
setCloneDialogError(message);
errorMessage = "";
}
} finally {
operation = "";
}
}
function openCloneDialog() {
if (isBusy) return;
setCloneDialogError("");
cloneDialogOpen = true;
}
function openRepoManagement() {
if (isBusy) return;
activeView = "management";
@@ -1221,11 +1386,11 @@
}
}
async function openCredentialDialog(action: "push" | "pull" | "fetch", key?: string | null) {
if (!activeRepoPath) return;
async function openCredentialDialog(action: CredentialAction, key?: string | null) {
if (!activeRepoPath && action !== "clone") return;
credDialogError = "";
credDialogAction = action;
credDialogKey = key === undefined ? await currentCredKey() : key;
credDialogKey = key === undefined && action !== "clone" ? await currentCredKey() : (key ?? null);
credDialogOpen = true;
}
@@ -1358,6 +1523,17 @@
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
else if (credDialogAction === "push") await doActualPush(username, password, key, false);
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false);
else if (credDialogAction === "clone" && pendingClone) {
await cloneRepo(
pendingClone.remoteUrl,
pendingClone.parentPath,
pendingClone.directoryName,
username,
password,
key,
false,
);
}
// Only persist once the operation actually succeeded (dialog has closed).
if (!credDialogOpen && save && key) {
@@ -2060,6 +2236,10 @@
<h1>Repositories</h1>
</div>
<div class="repo-management-actions">
<button class="btn-primary" type="button" onclick={openCloneDialog} disabled={isBusy}>
<Download size={15} aria-hidden="true" />
Clone
</button>
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
<FolderOpen size={15} aria-hidden="true" />
Browse
@@ -2472,11 +2652,26 @@
/>
{/if}
<!-- Clone repository dialog -->
{#if cloneDialogOpen}
<CloneRepositoryDialog
isBusy={operation === "Cloning repository"}
error={cloneDialogError}
onClone={cloneRepo}
onClose={() => { if (!isBusy) cloneDialogOpen = false; }}
/>
{/if}
<!-- Full-screen overlay while a repository is being opened -->
{#if openingRepo}
<RepoLoadingOverlay repoName={repoDisplayName} />
{/if}
<!-- Full-screen overlay while a repository is being cloned -->
{#if cloningRepo}
<RepoLoadingOverlay label="Cloning repository" repoName={cloneDisplayName} />
{/if}
<!-- Conflict resolve dialog -->
{#if resolveDialogOpen}
<ResolveDialog
+80 -4
View File
@@ -1639,6 +1639,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 +1652,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 +1676,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 +1701,7 @@
}
.branch-context-menu button:disabled,
.history-context-menu button:disabled,
.explorer-context-menu button:disabled {
cursor: not-allowed;
opacity: 0.48;
@@ -1817,6 +1823,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 +2024,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 {
@@ -2200,8 +2223,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);
}
@@ -2363,6 +2385,54 @@
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;
@@ -3779,6 +3849,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; }
+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">
@@ -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>
@@ -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>
-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">
+12 -5
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;
@@ -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>
@@ -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">
+109 -17
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { ChevronDown, ChevronRight, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import { ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment {
@@ -60,6 +60,10 @@
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];
@@ -203,6 +207,24 @@
return labels.filter(branchIsVisible);
}
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
const directBranches = localBranchRefs(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 = localBranchRefs(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);
}
@@ -316,10 +338,52 @@
}
}
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);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
closeCommitContextMenu();
handleBranchDialogKeydown(event);
}
function localBranchRefs(commit: GitCommit): string[] {
@@ -399,9 +463,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,9 +501,14 @@
{#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}>
<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}
>
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
{#if row}
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
@@ -467,7 +536,7 @@
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}
title={commitHoverTitle(item, row)}
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
></span>
{#if hoverBranchRefs.length > 0}
@@ -551,13 +620,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 +639,29 @@
{/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>
</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">
-1
View File
@@ -254,7 +254,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
class="dialog"
+18
View File
@@ -34,6 +34,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 });
}