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
This commit is contained in:
2026-07-05 01:18:26 +02:00
parent 32497d53df
commit b646a2c647
7 changed files with 247 additions and 25 deletions
+153 -10
View File
@@ -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<typeof setTimeout> | 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<Record<string, ReturnType<typeof setTimeout>>> = {};
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 @@
<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
+16 -1
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();
@@ -1,4 +1,5 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { Download, FolderOpen, LoaderCircle, X } from "@lucide/svelte";
@@ -22,6 +23,8 @@
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(
@@ -29,7 +32,21 @@
remoteUrl.trim().length > 0 &&
parentPath.trim().length > 0,
);
let visibleError = $derived(error || browseError);
$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, "") ?? "";
+12 -4
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();
+4
View File
@@ -38,12 +38,16 @@ 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,
});
}