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:
2026-07-05 01:05:23 +02:00
parent 6365e164aa
commit 32497d53df
17 changed files with 486 additions and 35 deletions
@@ -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>
-1
View File
@@ -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">
+1 -7
View File
@@ -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">
-1
View File
@@ -254,7 +254,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
class="dialog"