feat(clone): add repository cloning flow to UI and backend
This introduces a new Tauri command to clone a remote repository into a validated destination folder, then return the full repository bundle for immediate rendering. The Svelte app gains a clone dialog and a new action in repository management, wiring the cloned bundle into existing refresh helpers. Dialog backdrops were also adjusted to rely on explicit close controls. - Add clone_repository command and core cloning logic in Rust - Create CloneRepositoryDialog and integrate it into App.svelte - Improve clone-related styling and tighten dialog backdrop behavior
This commit is contained in:
@@ -126,7 +126,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
|
||||
@@ -20,13 +20,9 @@
|
||||
|
||||
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
|
||||
|
||||
function closeFromBackdrop(event: MouseEvent) {
|
||||
if (isBusy || event.target !== event.currentTarget) return;
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
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 directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
|
||||
let canSubmit = $derived(
|
||||
!isBusy &&
|
||||
remoteUrl.trim().length > 0 &&
|
||||
parentPath.trim().length > 0,
|
||||
);
|
||||
let visibleError = $derived(error || browseError);
|
||||
|
||||
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>
|
||||
@@ -182,7 +182,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
||||
>
|
||||
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git credentials" tabindex="-1">
|
||||
<div class="cred-hero">
|
||||
|
||||
@@ -25,13 +25,9 @@
|
||||
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
||||
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
||||
|
||||
function closeFromBackdrop(event: MouseEvent) {
|
||||
if (isBusy || event.target !== event.currentTarget) return;
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
|
||||
@@ -129,7 +129,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
|
||||
@@ -316,12 +316,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleBranchDialogBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeBranchDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function localBranchRefs(commit: GitCommit): string[] {
|
||||
const seen = new Set<string>();
|
||||
const labels: string[] = [];
|
||||
@@ -569,7 +563,7 @@
|
||||
</section>
|
||||
|
||||
{#if branchDialogOpen}
|
||||
<div class="branch-filter-backdrop" role="presentation" onclick={handleBranchDialogBackdropClick}>
|
||||
<div class="branch-filter-backdrop" role="presentation">
|
||||
<div
|
||||
class="branch-filter-dialog"
|
||||
role="dialog"
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
|
||||
@@ -254,7 +254,6 @@
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
|
||||
Reference in New Issue
Block a user