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,
|
remote_url: String,
|
||||||
parent_path: String,
|
parent_path: String,
|
||||||
directory_name: Option<String>,
|
directory_name: Option<String>,
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
commit_limit: Option<u32>,
|
commit_limit: Option<u32>,
|
||||||
) -> Result<RepositoryBundle, String> {
|
) -> Result<RepositoryBundle, String> {
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
@@ -262,6 +264,8 @@ pub async fn clone_repository(
|
|||||||
&remote_url,
|
&remote_url,
|
||||||
&parent_path,
|
&parent_path,
|
||||||
directory_name.as_deref(),
|
directory_name.as_deref(),
|
||||||
|
username.as_deref(),
|
||||||
|
password.as_deref(),
|
||||||
commit_limit,
|
commit_limit,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -2231,10 +2235,12 @@ fn clone_repository_core(
|
|||||||
remote_url: &str,
|
remote_url: &str,
|
||||||
parent_path: &str,
|
parent_path: &str,
|
||||||
directory_name: Option<&str>,
|
directory_name: Option<&str>,
|
||||||
|
username: Option<&str>,
|
||||||
|
password: Option<&str>,
|
||||||
commit_limit: Option<u32>,
|
commit_limit: Option<u32>,
|
||||||
) -> Result<RepositoryBundle, String> {
|
) -> Result<RepositoryBundle, String> {
|
||||||
let target = clone_target_path(remote_url, parent_path, directory_name)?;
|
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 repo = resolve_repo(&target.to_string_lossy())?;
|
||||||
let status = status_for_repo(&repo)?;
|
let status = status_for_repo(&repo)?;
|
||||||
@@ -2339,23 +2345,50 @@ fn validate_clone_directory_name(name: &str) -> Result<String, String> {
|
|||||||
Ok(trimmed.to_string())
|
Ok(trimmed.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_git_clone(remote_url: &str, target: &Path) -> Result<(), String> {
|
fn run_git_clone(
|
||||||
let output = git_command()
|
remote_url: &str,
|
||||||
|
target: &Path,
|
||||||
|
username: Option<&str>,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut command = git_command();
|
||||||
|
command
|
||||||
.arg("clone")
|
.arg("clone")
|
||||||
.arg("--")
|
.arg("--")
|
||||||
.arg(remote_url)
|
.arg(remote_url)
|
||||||
.arg(target)
|
.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()
|
.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() {
|
if output.status.success() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(format!(
|
let details = command_output_details(&output);
|
||||||
"Git clone failed: {}",
|
if is_auth_error(&details) {
|
||||||
command_output_details(&output)
|
return Err(format!("AUTH_FAILED:{details}"));
|
||||||
))
|
}
|
||||||
|
|
||||||
|
Err(format!("Git clone failed: {}", details))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_repository_folder_path(repo: &Path, path: &str) -> Result<bool, String> {
|
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"),
|
source.path.to_str().expect("source path should be UTF-8"),
|
||||||
parent.path.to_str().expect("parent path should be UTF-8"),
|
parent.path.to_str().expect("parent path should be UTF-8"),
|
||||||
Some("local-copy"),
|
Some("local-copy"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
Some(100),
|
Some(100),
|
||||||
)
|
)
|
||||||
.expect("repository should clone");
|
.expect("repository should clone");
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["nsis"],
|
"targets": ["nsis"],
|
||||||
"icon": ["icons/icon.ico"],
|
"icon": ["icons/icon.png", "icons/icon.ico"],
|
||||||
"createUpdaterArtifacts": true,
|
"createUpdaterArtifacts": true,
|
||||||
"windows": {
|
"windows": {
|
||||||
"nsis": {
|
"nsis": {
|
||||||
|
|||||||
+153
-10
@@ -112,6 +112,7 @@
|
|||||||
|
|
||||||
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
||||||
type AppView = "management" | "repository";
|
type AppView = "management" | "repository";
|
||||||
|
type CredentialAction = "push" | "pull" | "fetch" | "clone";
|
||||||
type PendingDiscard =
|
type PendingDiscard =
|
||||||
| { kind: "file"; file: GitFileStatus; staged: boolean }
|
| { kind: "file"; file: GitFileStatus; staged: boolean }
|
||||||
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
|
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
|
||||||
@@ -126,6 +127,12 @@
|
|||||||
lastOpened: number;
|
lastOpened: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CloneRequest {
|
||||||
|
remoteUrl: string;
|
||||||
|
parentPath: string;
|
||||||
|
directoryName: string;
|
||||||
|
}
|
||||||
|
|
||||||
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
|
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
|
||||||
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
|
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
|
||||||
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
|
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
|
||||||
@@ -137,6 +144,7 @@
|
|||||||
const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
|
const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
|
||||||
const HISTORY_ASIDE_MIN_WIDTH = 560;
|
const HISTORY_ASIDE_MIN_WIDTH = 560;
|
||||||
const HISTORY_ASIDE_MAX_WIDTH = 920;
|
const HISTORY_ASIDE_MAX_WIDTH = 920;
|
||||||
|
const ERROR_AUTO_HIDE_MS = 6000;
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -148,6 +156,8 @@
|
|||||||
let repoSearch = "";
|
let repoSearch = "";
|
||||||
let cloneDialogOpen = false;
|
let cloneDialogOpen = false;
|
||||||
let cloneDialogError = "";
|
let cloneDialogError = "";
|
||||||
|
let cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let pendingClone: CloneRequest | null = null;
|
||||||
let status: GitStatus | null = null;
|
let status: GitStatus | null = null;
|
||||||
let branches: GitBranchInfo[] = [];
|
let branches: GitBranchInfo[] = [];
|
||||||
let stashes: GitStash[] = [];
|
let stashes: GitStash[] = [];
|
||||||
@@ -204,7 +214,7 @@
|
|||||||
let autoRefreshEnabled = true;
|
let autoRefreshEnabled = true;
|
||||||
let autoRefreshInFlight = false;
|
let autoRefreshInFlight = false;
|
||||||
let credDialogOpen = false;
|
let credDialogOpen = false;
|
||||||
let credDialogAction: "push" | "pull" | "fetch" | null = null;
|
let credDialogAction: CredentialAction | null = null;
|
||||||
let credDialogError = "";
|
let credDialogError = "";
|
||||||
let credDialogKey: string | null = null;
|
let credDialogKey: string | null = null;
|
||||||
let lastStatusFingerprint = "";
|
let lastStatusFingerprint = "";
|
||||||
@@ -225,6 +235,7 @@
|
|||||||
let updateCheckInFlight = false;
|
let updateCheckInFlight = false;
|
||||||
let updateDownloadTotal = 0;
|
let updateDownloadTotal = 0;
|
||||||
let updateDownloadedBytes = 0;
|
let updateDownloadedBytes = 0;
|
||||||
|
let errorAutoHideTimers: Partial<Record<string, ReturnType<typeof setTimeout>>> = {};
|
||||||
let commitPanelHeight = loadCommitPanelHeight();
|
let commitPanelHeight = loadCommitPanelHeight();
|
||||||
let resizingCommitPanel = false;
|
let resizingCommitPanel = false;
|
||||||
let resizeStartY = 0;
|
let resizeStartY = 0;
|
||||||
@@ -240,7 +251,9 @@
|
|||||||
$: hasRepository = activeRepoPath.length > 0 && status !== null;
|
$: hasRepository = activeRepoPath.length > 0 && status !== null;
|
||||||
$: workspaceActive = activeView === "repository" && hasRepository;
|
$: workspaceActive = activeView === "repository" && hasRepository;
|
||||||
$: openingRepo = operation === "Opening repository";
|
$: openingRepo = operation === "Opening repository";
|
||||||
|
$: cloningRepo = operation === "Cloning repository";
|
||||||
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
|
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
|
||||||
|
$: cloneDisplayName = pendingClone?.directoryName || repoNameFromCloneUrl(pendingClone?.remoteUrl ?? "");
|
||||||
$: changedFiles = status?.files ?? [];
|
$: changedFiles = status?.files ?? [];
|
||||||
$: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0;
|
$: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0;
|
||||||
$: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0;
|
$: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0;
|
||||||
@@ -281,9 +294,37 @@
|
|||||||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||||||
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
|
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
|
||||||
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
|
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
|
||||||
|
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
|
||||||
|
Object.values(errorAutoHideTimers).forEach((timer) => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
});
|
||||||
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
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 ───────────────────────────────────────────────────────────
|
// ── Auto-refresh ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function statusFingerprint(value: GitStatus): string {
|
function statusFingerprint(value: GitStatus): string {
|
||||||
@@ -521,6 +562,41 @@
|
|||||||
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
|
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 {
|
function repoKey(path: string): string {
|
||||||
return path.replace(/\\/g, "/").trim().toLowerCase();
|
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 (isBusy) return;
|
||||||
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
|
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
|
||||||
if (!parentPath) { errorMessage = "Select a destination folder."; 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";
|
operation = "Cloning repository";
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
cloneDialogError = "";
|
setCloneDialogError("");
|
||||||
try {
|
try {
|
||||||
const bundle = await cloneRepository(remoteUrl, parentPath, directoryName || undefined, 100);
|
const bundle = await cloneRepository(
|
||||||
|
remoteUrl,
|
||||||
|
parentPath,
|
||||||
|
directoryName || undefined,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
100,
|
||||||
|
);
|
||||||
resetRepositoryState(false);
|
resetRepositoryState(false);
|
||||||
applyStatus(bundle.status);
|
applyStatus(bundle.status);
|
||||||
if (globalSearchBusy) void cancelGlobalSearch();
|
if (globalSearchBusy) void cancelGlobalSearch();
|
||||||
@@ -1006,10 +1110,33 @@
|
|||||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||||
cloneDialogOpen = false;
|
cloneDialogOpen = false;
|
||||||
|
pendingClone = null;
|
||||||
|
if (credDialogAction === "clone") {
|
||||||
|
credDialogOpen = false;
|
||||||
|
credDialogAction = null;
|
||||||
|
credDialogError = "";
|
||||||
|
credDialogKey = null;
|
||||||
|
}
|
||||||
lastRepoSwitchAt = Date.now();
|
lastRepoSwitchAt = Date.now();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
cloneDialogError = errorToMessage(error);
|
const rawMessage = errorToMessage(error);
|
||||||
errorMessage = cloneDialogError;
|
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 {
|
} finally {
|
||||||
operation = "";
|
operation = "";
|
||||||
}
|
}
|
||||||
@@ -1017,7 +1144,7 @@
|
|||||||
|
|
||||||
function openCloneDialog() {
|
function openCloneDialog() {
|
||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
cloneDialogError = "";
|
setCloneDialogError("");
|
||||||
cloneDialogOpen = true;
|
cloneDialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1259,11 +1386,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openCredentialDialog(action: "push" | "pull" | "fetch", key?: string | null) {
|
async function openCredentialDialog(action: CredentialAction, key?: string | null) {
|
||||||
if (!activeRepoPath) return;
|
if (!activeRepoPath && action !== "clone") return;
|
||||||
credDialogError = "";
|
credDialogError = "";
|
||||||
credDialogAction = action;
|
credDialogAction = action;
|
||||||
credDialogKey = key === undefined ? await currentCredKey() : key;
|
credDialogKey = key === undefined && action !== "clone" ? await currentCredKey() : (key ?? null);
|
||||||
credDialogOpen = true;
|
credDialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1396,6 +1523,17 @@
|
|||||||
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
|
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
|
||||||
else if (credDialogAction === "push") await doActualPush(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 === "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).
|
// Only persist once the operation actually succeeded (dialog has closed).
|
||||||
if (!credDialogOpen && save && key) {
|
if (!credDialogOpen && save && key) {
|
||||||
@@ -2529,6 +2667,11 @@
|
|||||||
<RepoLoadingOverlay repoName={repoDisplayName} />
|
<RepoLoadingOverlay repoName={repoDisplayName} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Full-screen overlay while a repository is being cloned -->
|
||||||
|
{#if cloningRepo}
|
||||||
|
<RepoLoadingOverlay label="Cloning repository" repoName={cloneDisplayName} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Conflict resolve dialog -->
|
<!-- Conflict resolve dialog -->
|
||||||
{#if resolveDialogOpen}
|
{#if resolveDialogOpen}
|
||||||
<ResolveDialog
|
<ResolveDialog
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
|
||||||
import { credDelete, credLoad, credSave } from "../git";
|
import { credDelete, credLoad, credSave } from "../git";
|
||||||
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
|
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
let loadingKeys = $state(true);
|
let loadingKeys = $state(true);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
|
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
provider = settings.provider;
|
provider = settings.provider;
|
||||||
@@ -47,6 +48,16 @@
|
|||||||
customModel = settings.customModel;
|
customModel = settings.customModel;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||||
|
const currentError = error;
|
||||||
|
if (currentError) {
|
||||||
|
errorHideTimer = setTimeout(() => {
|
||||||
|
if (error === currentError) error = "";
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -66,6 +77,10 @@
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (errorHideTimer) clearTimeout(errorHideTimer);
|
||||||
|
});
|
||||||
|
|
||||||
async function persistKey(target: CloudProvider, value: string) {
|
async function persistKey(target: CloudProvider, value: string) {
|
||||||
const key = CRED_KEYS[target];
|
const key = CRED_KEYS[target];
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
import { Download, FolderOpen, LoaderCircle, X } from "@lucide/svelte";
|
import { Download, FolderOpen, LoaderCircle, X } from "@lucide/svelte";
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@
|
|||||||
let directoryNameEdited = $state(false);
|
let directoryNameEdited = $state(false);
|
||||||
let directoryAutoName = $state("");
|
let directoryAutoName = $state("");
|
||||||
let browseError = $state("");
|
let browseError = $state("");
|
||||||
|
let visibleError = $state("");
|
||||||
|
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
let directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
|
let directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
|
||||||
let canSubmit = $derived(
|
let canSubmit = $derived(
|
||||||
@@ -29,7 +32,21 @@
|
|||||||
remoteUrl.trim().length > 0 &&
|
remoteUrl.trim().length > 0 &&
|
||||||
parentPath.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 {
|
function directoryNameFromRemoteUrl(url: string): string {
|
||||||
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
|
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: "push" | "pull" | "fetch";
|
action: "push" | "pull" | "fetch" | "clone";
|
||||||
error: string;
|
error: string;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
|
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
|
||||||
@@ -43,12 +43,20 @@
|
|||||||
password.trim().length > 0 &&
|
password.trim().length > 0 &&
|
||||||
(mode === "token" || username.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(
|
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"
|
let actionHint = $derived(action === "push"
|
||||||
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
|
? "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.");
|
: "The remote needs access to the repository. Use your Git credentials or a personal access token.");
|
||||||
|
|
||||||
function handleSubmit(e: SubmitEvent) {
|
function handleSubmit(e: SubmitEvent) {
|
||||||
|
|||||||
@@ -38,12 +38,16 @@ export function cloneRepository(
|
|||||||
remoteUrl: string,
|
remoteUrl: string,
|
||||||
parentPath: string,
|
parentPath: string,
|
||||||
directoryName?: string,
|
directoryName?: string,
|
||||||
|
username?: string,
|
||||||
|
password?: string,
|
||||||
commitLimit = 100,
|
commitLimit = 100,
|
||||||
): Promise<RepositoryBundle> {
|
): Promise<RepositoryBundle> {
|
||||||
return invoke<RepositoryBundle>("clone_repository", {
|
return invoke<RepositoryBundle>("clone_repository", {
|
||||||
remoteUrl,
|
remoteUrl,
|
||||||
parentPath,
|
parentPath,
|
||||||
directoryName: directoryName?.trim() ? directoryName.trim() : null,
|
directoryName: directoryName?.trim() ? directoryName.trim() : null,
|
||||||
|
username: username ?? null,
|
||||||
|
password: password ?? null,
|
||||||
commitLimit,
|
commitLimit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user