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