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:
+43
-8
@@ -255,6 +255,8 @@ 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 || {
|
||||
@@ -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<u32>,
|
||||
) -> Result<RepositoryBundle, String> {
|
||||
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<String, String> {
|
||||
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<bool, String> {
|
||||
@@ -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");
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis"],
|
||||
"icon": ["icons/icon.ico"],
|
||||
"icon": ["icons/icon.png", "icons/icon.ico"],
|
||||
"createUpdaterArtifacts": true,
|
||||
"windows": {
|
||||
"nsis": {
|
||||
|
||||
+153
-10
@@ -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
|
||||
|
||||
@@ -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, "") ?? "";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user