From 32497d53df1cc4cc39a19fac252d52d0208407ae Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Sun, 5 Jul 2026 01:05:23 +0200 Subject: [PATCH] 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 --- src-tauri/src/git.rs | 193 ++++++++++++++++++ src-tauri/src/main.rs | 20 +- src/App.svelte | 54 ++++- src/app.css | 54 +++++ src/lib/components/AiSettingsDialog.svelte | 1 - .../BranchDeleteConfirmDialog.svelte | 6 +- .../components/CloneRepositoryDialog.svelte | 158 ++++++++++++++ src/lib/components/CompareDialog.svelte | 1 - src/lib/components/CompareSelectDialog.svelte | 1 - src/lib/components/CredentialDialog.svelte | 1 - .../components/DiscardConfirmDialog.svelte | 6 +- src/lib/components/GlobalSearchDialog.svelte | 1 - src/lib/components/HistoryPanel.svelte | 8 +- src/lib/components/NewBranchDialog.svelte | 1 - src/lib/components/RenameBranchDialog.svelte | 1 - src/lib/components/ResolveDialog.svelte | 1 - src/lib/git.ts | 14 ++ 17 files changed, 486 insertions(+), 35 deletions(-) create mode 100644 src/lib/components/CloneRepositoryDialog.svelte diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 7b3278a..fdae018 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -250,6 +250,25 @@ pub struct RepositoryBundle { pub files: Vec, } +#[tauri::command] +pub async fn clone_repository( + remote_url: String, + parent_path: String, + directory_name: Option, + commit_limit: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + clone_repository_core( + &remote_url, + &parent_path, + directory_name.as_deref(), + commit_limit, + ) + }) + .await + .map_err(|err| format!("Could not clone repository: {err}"))? +} + /// Opens a repository and gathers everything the UI needs in a single call. /// /// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves @@ -2208,6 +2227,137 @@ fn repository_files_with_status( Ok(files.into_values().collect()) } +fn clone_repository_core( + remote_url: &str, + parent_path: &str, + directory_name: Option<&str>, + commit_limit: Option, +) -> Result { + let target = clone_target_path(remote_url, parent_path, directory_name)?; + run_git_clone(remote_url.trim(), &target)?; + + let repo = resolve_repo(&target.to_string_lossy())?; + let status = status_for_repo(&repo)?; + let branches = branches_for_repo(&repo)?; + let stashes = stashes_for_repo(&repo)?; + let commits = commits_for_repo(&repo, commit_limit)?; + let files = repository_files_with_status(&repo, &status)?; + + Ok(RepositoryBundle { + status, + branches, + stashes, + commits, + files, + }) +} + +fn clone_target_path( + remote_url: &str, + parent_path: &str, + directory_name: Option<&str>, +) -> Result { + let remote = remote_url.trim(); + if remote.is_empty() { + return Err("Remote URL must not be empty.".to_string()); + } + if remote.starts_with('-') || remote.chars().any(|c| c.is_control()) { + return Err("Remote URL contains invalid characters.".to_string()); + } + + let parent = PathBuf::from(parent_path.trim()); + if parent_path.trim().is_empty() { + return Err("Destination folder must not be empty.".to_string()); + } + if !parent.exists() { + return Err("Destination folder does not exist.".to_string()); + } + if !parent.is_dir() { + return Err("Destination path must be a folder.".to_string()); + } + + let raw_name = directory_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| infer_clone_directory_name(remote)); + let name = validate_clone_directory_name(&raw_name)?; + let target = parent.join(name); + + if target.exists() { + if !target.is_dir() { + return Err("Clone destination already exists and is not a folder.".to_string()); + } + let mut entries = target + .read_dir() + .map_err(|err| format!("Could not inspect clone destination: {err}"))?; + if entries.next().is_some() { + return Err("Clone destination already exists and is not empty.".to_string()); + } + } + + Ok(target) +} + +fn infer_clone_directory_name(remote_url: &str) -> String { + let trimmed = remote_url + .trim() + .split(['?', '#']) + .next() + .unwrap_or(remote_url) + .trim_end_matches(['/', '\\']); + let last_segment = trimmed + .rsplit(['/', '\\', ':']) + .find(|part| !part.trim().is_empty()) + .unwrap_or("") + .trim(); + + last_segment + .strip_suffix(".git") + .unwrap_or(last_segment) + .trim() + .to_string() +} + +fn validate_clone_directory_name(name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Folder name could not be inferred. Enter a folder name.".to_string()); + } + if trimmed == "." || trimmed == ".." { + return Err("Folder name is not valid.".to_string()); + } + if trimmed.chars().any(|c| { + c.is_control() || matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*') + }) { + return Err("Folder name contains invalid characters.".to_string()); + } + if Path::new(trimmed).is_absolute() { + return Err("Folder name must be relative.".to_string()); + } + + Ok(trimmed.to_string()) +} + +fn run_git_clone(remote_url: &str, target: &Path) -> Result<(), String> { + let output = git_command() + .arg("clone") + .arg("--") + .arg(remote_url) + .arg(target) + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; + + if output.status.success() { + return Ok(()); + } + + Err(format!( + "Git clone failed: {}", + command_output_details(&output) + )) +} + fn is_repository_folder_path(repo: &Path, path: &str) -> Result { let normalized = normalize_git_path(path); if repo.join(path).is_dir() { @@ -3682,6 +3832,49 @@ mod tests { run_git_test(repo, ["commit", "-q", "-m", "init"]); } + #[test] + fn clone_directory_name_is_inferred_from_common_remote_urls() { + assert_eq!( + infer_clone_directory_name("https://github.com/example/project.git"), + "project" + ); + assert_eq!( + infer_clone_directory_name("git@github.com:example/project.git"), + "project" + ); + assert_eq!( + infer_clone_directory_name("ssh://git@example.com/example/project.git/"), + "project" + ); + } + + #[test] + fn clone_repository_core_clones_and_returns_repository_bundle() { + let source = init_temp_repo("clone_source"); + commit_initial_file(&source.path); + let parent = temp_dir("clone_parent"); + + let bundle = clone_repository_core( + 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"), + Some(100), + ) + .expect("repository should clone"); + + let cloned_repo = parent.path.join("local-copy"); + assert_eq!( + PathBuf::from(bundle.status.repo_path), + cloned_repo + .canonicalize() + .expect("clone path should resolve") + ); + assert!(cloned_repo.join("old.txt").exists()); + assert!(bundle.status.clean); + assert_eq!(bundle.commits.len(), 1); + assert!(bundle.files.iter().any(|file| file.path == "old.txt")); + } + #[test] fn search_code_introductions_finds_added_string() { let repo = init_temp_repo("search_added_string"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 930b6e7..f3f059c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,15 +6,16 @@ mod git; use badge::set_sync_badge; use git::{ SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history, - checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models, - commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, - cred_delete, cred_load, cred_save, delete_branch, diff_file_against_working_tree, fetch, - get_file_patch, get_remote_url, get_status, list_branches, list_commits, list_file_history, - list_repository_files, list_stashes, merge_branch, open_repo_in_explorer, open_repository, - open_repository_bundle, open_repository_file, pull, push, read_conflict, rebase_abort, - rebase_branch, rebase_continue, rename_branch, resolve_conflict, resolve_conflict_side, - restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions, - stage_files, stash_apply, stash_drop, stash_pop, stash_push, unstage_files, + checkout_branch, clone_repository, commit, commit_ai_generate, commit_ai_load, + commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head, + compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch, + diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status, + list_branches, list_commits, list_file_history, list_repository_files, list_stashes, + merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle, + open_repository_file, pull, push, read_conflict, rebase_abort, rebase_branch, rebase_continue, + rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit, + restore_files, restore_to_commit, search_code_introductions, stage_files, stash_apply, + stash_drop, stash_pop, stash_push, unstage_files, }; fn main() { @@ -25,6 +26,7 @@ fn main() { .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ open_repository, + clone_repository, open_repo_in_explorer, open_repository_file, get_status, diff --git a/src/App.svelte b/src/App.svelte index cb6fa6a..dafc48f 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2,12 +2,13 @@ import { onDestroy, onMount, tick } from "svelte"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; - import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte"; + import { AlertCircle, BookOpen, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte"; import TitleBar from "./lib/TitleBar.svelte"; import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte"; import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte"; + import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte"; import CompareDialog from "./lib/components/CompareDialog.svelte"; import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte"; @@ -28,6 +29,7 @@ import { checkoutBranch, + cloneRepository, commit, commitAiGenerate, commitAiLoad, @@ -144,6 +146,8 @@ let repoTabs: RepoTab[] = []; let recentRepoPaths: string[] = []; let repoSearch = ""; + let cloneDialogOpen = false; + let cloneDialogError = ""; let status: GitStatus | null = null; let branches: GitBranchInfo[] = []; let stashes: GitStash[] = []; @@ -983,6 +987,40 @@ } } + async function cloneRepo(remoteUrl: string, parentPath: string, directoryName: string) { + if (isBusy) return; + if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; } + if (!parentPath) { errorMessage = "Select a destination folder."; return; } + + operation = "Cloning repository"; + errorMessage = ""; + cloneDialogError = ""; + try { + const bundle = await cloneRepository(remoteUrl, parentPath, directoryName || undefined, 100); + resetRepositoryState(false); + applyStatus(bundle.status); + if (globalSearchBusy) void cancelGlobalSearch(); + activeView = "repository"; + await refreshBranchList(activeRepoPath, bundle.branches); + await refreshStashes(activeRepoPath, bundle.stashes); + await refreshCommitHistory(activeRepoPath, bundle.commits); + await refreshExplorerFiles(activeRepoPath, bundle.files); + cloneDialogOpen = false; + lastRepoSwitchAt = Date.now(); + } catch (error) { + cloneDialogError = errorToMessage(error); + errorMessage = cloneDialogError; + } finally { + operation = ""; + } + } + + function openCloneDialog() { + if (isBusy) return; + cloneDialogError = ""; + cloneDialogOpen = true; + } + function openRepoManagement() { if (isBusy) return; activeView = "management"; @@ -2060,6 +2098,10 @@

Repositories

+ + + +
+ + + + + + + {#if visibleError} + + {/if} + +
+ + +
+
+
+ diff --git a/src/lib/components/CompareDialog.svelte b/src/lib/components/CompareDialog.svelte index 7639eab..35a62c9 100644 --- a/src/lib/components/CompareDialog.svelte +++ b/src/lib/components/CompareDialog.svelte @@ -182,7 +182,6 @@