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
176 lines
5.4 KiB
Svelte
176 lines
5.4 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy } from "svelte";
|
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
|
import { Download, FolderOpen, LoaderCircle, X } from "@lucide/svelte";
|
|
|
|
interface Props {
|
|
isBusy: boolean;
|
|
error: string;
|
|
onClone: (remoteUrl: string, parentPath: string, directoryName: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
let {
|
|
isBusy = false,
|
|
error = "",
|
|
onClone = () => {},
|
|
onClose = () => {},
|
|
}: Props = $props();
|
|
|
|
let remoteUrl = $state("");
|
|
let parentPath = $state("");
|
|
let directoryName = $state("");
|
|
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(
|
|
!isBusy &&
|
|
remoteUrl.trim().length > 0 &&
|
|
parentPath.trim().length > 0,
|
|
);
|
|
|
|
$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, "") ?? "";
|
|
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
|
|
return lastSegment.replace(/\.git$/i, "").trim();
|
|
}
|
|
|
|
function errorToMessage(error: unknown): string {
|
|
if (error instanceof Error) return error.message;
|
|
if (typeof error === "string") return error;
|
|
try { return JSON.stringify(error) ?? "Unknown error"; } catch { return "Unknown error"; }
|
|
}
|
|
|
|
async function chooseParentFolder() {
|
|
if (isBusy) return;
|
|
browseError = "";
|
|
try {
|
|
const selected = await openDialog({
|
|
title: "Select clone destination",
|
|
directory: true,
|
|
multiple: false,
|
|
defaultPath: parentPath.trim() || undefined,
|
|
});
|
|
if (typeof selected !== "string") return;
|
|
parentPath = selected;
|
|
} catch (error) {
|
|
browseError = errorToMessage(error);
|
|
}
|
|
}
|
|
|
|
function handleRemoteInput(event: Event) {
|
|
const nextRemoteUrl = (event.currentTarget as HTMLInputElement).value;
|
|
if (directoryNameEdited) return;
|
|
directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl);
|
|
directoryName = directoryAutoName;
|
|
}
|
|
|
|
function handleDirectoryInput(event: Event) {
|
|
const nextDirectoryName = (event.currentTarget as HTMLInputElement).value;
|
|
directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName;
|
|
}
|
|
|
|
function submit(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
if (!canSubmit) return;
|
|
onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim());
|
|
}
|
|
|
|
</script>
|
|
|
|
<div class="dialog-backdrop" role="presentation">
|
|
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label="Clone repository" tabindex="-1">
|
|
<header class="dialog-header">
|
|
<div>
|
|
<span class="eyebrow">Repository Management</span>
|
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Clone repository</h2>
|
|
</div>
|
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close">
|
|
<X size={18} aria-hidden="true" />
|
|
</button>
|
|
</header>
|
|
|
|
<form class="clone-dialog-form" onsubmit={submit}>
|
|
<label class="clone-dialog-field">
|
|
<span>Remote URL</span>
|
|
<!-- svelte-ignore a11y_autofocus -->
|
|
<input
|
|
bind:value={remoteUrl}
|
|
oninput={handleRemoteInput}
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
placeholder="https://github.com/org/project.git"
|
|
disabled={isBusy}
|
|
autofocus
|
|
/>
|
|
</label>
|
|
|
|
<label class="clone-dialog-field">
|
|
<span>Destination</span>
|
|
<div class="clone-dialog-path-field">
|
|
<input
|
|
bind:value={parentPath}
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
placeholder="Choose parent folder"
|
|
disabled={isBusy}
|
|
/>
|
|
<button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}>
|
|
<FolderOpen size={14} aria-hidden="true" />
|
|
Browse
|
|
</button>
|
|
</div>
|
|
</label>
|
|
|
|
<label class="clone-dialog-field">
|
|
<span>Folder name</span>
|
|
<input
|
|
bind:value={directoryName}
|
|
oninput={handleDirectoryInput}
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
placeholder={directorySuggestion || "Optional"}
|
|
disabled={isBusy}
|
|
/>
|
|
</label>
|
|
|
|
{#if visibleError}
|
|
<div class="clone-dialog-error" role="alert">{visibleError}</div>
|
|
{/if}
|
|
|
|
<div class="clone-dialog-actions">
|
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
|
Cancel
|
|
</button>
|
|
<button class="btn-primary" type="submit" disabled={!canSubmit}>
|
|
{#if isBusy}
|
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
|
{:else}
|
|
<Download size={16} aria-hidden="true" />
|
|
{/if}
|
|
Clone
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|