Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
247c118bdf | ||
|
|
c59cb412dd | ||
|
|
1b83429c06 | ||
|
|
3e2885f64d | ||
|
|
eac0f059fa | ||
|
|
aaa4228b82 | ||
|
|
e5c26ea502 | ||
|
|
38b4f98233 | ||
|
|
d592894f28 | ||
|
|
9ee2f71dc3 | ||
|
|
283779610a | ||
|
|
d33f576212 | ||
|
|
a3fd8f1ce8 | ||
|
|
de06e7a15d | ||
|
|
0105e0d46e | ||
|
|
0732f9ca6d | ||
|
|
fcaaa770d6 | ||
|
|
379350b9ee | ||
|
|
5f7899bcee | ||
|
|
b646a2c647 | ||
|
|
32497d53df | ||
|
|
6365e164aa | ||
|
|
f6af156fbd | ||
|
|
33cef059e8 |
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+121
-28
@@ -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 = {
|
||||
"version": version,
|
||||
"pub_date": pub_date,
|
||||
"notes": os.environ.get("RELEASE_NOTES", "Automated release"),
|
||||
"platforms": {
|
||||
"windows-x86_64": {
|
||||
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")
|
||||
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__":
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "git-lite",
|
||||
"version": "2026.7.10",
|
||||
"version": "2026.7.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+262
-3
@@ -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);
|
||||
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(files.iter().map(OsString::from));
|
||||
run_git(repo, args)
|
||||
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
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"bundle": {
|
||||
"targets": ["appimage"]
|
||||
}
|
||||
}
|
||||
+200
-5
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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,12 +43,20 @@
|
||||
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."
|
||||
: 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) {
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -254,7 +254,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user