From b646a2c647a9c37c6caec59f26b1a10a96b1a486 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Sun, 5 Jul 2026 01:18:26 +0200 Subject: [PATCH] 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 --- src-tauri/src/git.rs | 51 +++++- src-tauri/tauri.conf.json | 2 +- src/App.svelte | 163 ++++++++++++++++-- src/lib/components/AiSettingsDialog.svelte | 17 +- .../components/CloneRepositoryDialog.svelte | 19 +- src/lib/components/CredentialDialog.svelte | 16 +- src/lib/git.ts | 4 + 7 files changed, 247 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index fdae018..7d25b81 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -255,6 +255,8 @@ pub async fn clone_repository( remote_url: String, parent_path: String, directory_name: Option, + username: Option, + password: Option, commit_limit: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { @@ -262,6 +264,8 @@ pub async fn clone_repository( &remote_url, &parent_path, directory_name.as_deref(), + username.as_deref(), + password.as_deref(), commit_limit, ) }) @@ -2231,10 +2235,12 @@ fn clone_repository_core( remote_url: &str, parent_path: &str, directory_name: Option<&str>, + username: Option<&str>, + password: Option<&str>, commit_limit: Option, ) -> Result { let target = clone_target_path(remote_url, parent_path, directory_name)?; - run_git_clone(remote_url.trim(), &target)?; + run_git_clone(remote_url.trim(), &target, username, password)?; let repo = resolve_repo(&target.to_string_lossy())?; let status = status_for_repo(&repo)?; @@ -2339,23 +2345,50 @@ fn validate_clone_directory_name(name: &str) -> Result { Ok(trimmed.to_string()) } -fn run_git_clone(remote_url: &str, target: &Path) -> Result<(), String> { - let output = git_command() +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}"))?; + .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(()); } - Err(format!( - "Git clone failed: {}", - command_output_details(&output) - )) + 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 { @@ -3858,6 +3891,8 @@ mod tests { 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"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c779711..8a01a16 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -29,7 +29,7 @@ "bundle": { "active": true, "targets": ["nsis"], - "icon": ["icons/icon.ico"], + "icon": ["icons/icon.png", "icons/icon.ico"], "createUpdaterArtifacts": true, "windows": { "nsis": { diff --git a/src/App.svelte b/src/App.svelte index dafc48f..05a696a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -112,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 }; @@ -126,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"; @@ -137,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 ────────────────────────────────────────────────────────────────── @@ -148,6 +156,8 @@ let repoSearch = ""; let cloneDialogOpen = false; let cloneDialogError = ""; + let cloneDialogErrorTimer: ReturnType | undefined; + let pendingClone: CloneRequest | null = null; let status: GitStatus | null = null; let branches: GitBranchInfo[] = []; let stashes: GitStash[] = []; @@ -204,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 = ""; @@ -225,6 +235,7 @@ let updateCheckInFlight = false; let updateDownloadTotal = 0; let updateDownloadedBytes = 0; + let errorAutoHideTimers: Partial>> = {}; let commitPanelHeight = loadCommitPanelHeight(); let resizingCommitPanel = false; let resizeStartY = 0; @@ -240,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; @@ -281,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 { @@ -521,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(); } @@ -987,16 +1063,44 @@ } } - async function cloneRepo(remoteUrl: string, parentPath: string, directoryName: string) { + 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 = ""; - cloneDialogError = ""; + setCloneDialogError(""); try { - const bundle = await cloneRepository(remoteUrl, parentPath, directoryName || undefined, 100); + const bundle = await cloneRepository( + remoteUrl, + parentPath, + directoryName || undefined, + username, + password, + 100, + ); resetRepositoryState(false); applyStatus(bundle.status); if (globalSearchBusy) void cancelGlobalSearch(); @@ -1006,10 +1110,33 @@ 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) { - cloneDialogError = errorToMessage(error); - errorMessage = cloneDialogError; + 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 = ""; } @@ -1017,7 +1144,7 @@ function openCloneDialog() { if (isBusy) return; - cloneDialogError = ""; + setCloneDialogError(""); cloneDialogOpen = true; } @@ -1259,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; } @@ -1396,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) { @@ -2529,6 +2667,11 @@ {/if} + +{#if cloningRepo} + +{/if} + {#if resolveDialogOpen} - 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 | 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(); diff --git a/src/lib/components/CloneRepositoryDialog.svelte b/src/lib/components/CloneRepositoryDialog.svelte index e6b2404..da4aeea 100644 --- a/src/lib/components/CloneRepositoryDialog.svelte +++ b/src/lib/components/CloneRepositoryDialog.svelte @@ -1,4 +1,5 @@