diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 7b3278a..7d25b81 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -250,6 +250,29 @@ pub struct RepositoryBundle { pub files: Vec, } +#[tauri::command] +pub async fn clone_repository( + remote_url: String, + parent_path: String, + directory_name: Option, + username: Option, + password: Option, + commit_limit: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + clone_repository_core( + &remote_url, + &parent_path, + directory_name.as_deref(), + username.as_deref(), + password.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 +2231,166 @@ fn repository_files_with_status( Ok(files.into_values().collect()) } +fn clone_repository_core( + remote_url: &str, + parent_path: &str, + directory_name: Option<&str>, + username: Option<&str>, + password: Option<&str>, + commit_limit: Option, +) -> Result { + let target = clone_target_path(remote_url, parent_path, directory_name)?; + run_git_clone(remote_url.trim(), &target, username, password)?; + + 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, + username: Option<&str>, + password: Option<&str>, +) -> Result<(), String> { + let mut command = git_command(); + command + .arg("clone") + .arg("--") + .arg(remote_url) + .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() + .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() { + return Ok(()); + } + + let details = command_output_details(&output); + if is_auth_error(&details) { + return Err(format!("AUTH_FAILED:{details}")); + } + + Err(format!("Git clone failed: {}", details)) +} + fn is_repository_folder_path(repo: &Path, path: &str) -> Result { let normalized = normalize_git_path(path); if repo.join(path).is_dir() { @@ -3682,6 +3865,51 @@ 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"), + None, + None, + 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-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c779711..8a01a16 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -29,7 +29,7 @@ "bundle": { "active": true, "targets": ["nsis"], - "icon": ["icons/icon.ico"], + "icon": ["icons/icon.png", "icons/icon.ico"], "createUpdaterArtifacts": true, "windows": { "nsis": { diff --git a/src/App.svelte b/src/App.svelte index cb6fa6a..05a696a 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, @@ -110,6 +112,7 @@ type UpdateToastState = "available" | "downloading" | "installed" | "error"; type AppView = "management" | "repository"; + type CredentialAction = "push" | "pull" | "fetch" | "clone"; type PendingDiscard = | { kind: "file"; file: GitFileStatus; staged: boolean } | { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string }; @@ -124,6 +127,12 @@ lastOpened: number; } + interface CloneRequest { + remoteUrl: string; + parentPath: string; + directoryName: string; + } + const OPEN_REPOS_KEY = "gitlite.openRepos.v1"; const RECENT_REPOS_KEY = "gitlite.recentRepos.v1"; const AI_SETTINGS_KEY = "gitlite.aiSettings.v1"; @@ -135,6 +144,7 @@ const HISTORY_ASIDE_DEFAULT_WIDTH = 620; const HISTORY_ASIDE_MIN_WIDTH = 560; const HISTORY_ASIDE_MAX_WIDTH = 920; + const ERROR_AUTO_HIDE_MS = 6000; // ── State ────────────────────────────────────────────────────────────────── @@ -144,6 +154,10 @@ let repoTabs: RepoTab[] = []; let recentRepoPaths: string[] = []; let repoSearch = ""; + let cloneDialogOpen = false; + let cloneDialogError = ""; + let cloneDialogErrorTimer: ReturnType | undefined; + let pendingClone: CloneRequest | null = null; let status: GitStatus | null = null; let branches: GitBranchInfo[] = []; let stashes: GitStash[] = []; @@ -200,7 +214,7 @@ let autoRefreshEnabled = true; let autoRefreshInFlight = false; let credDialogOpen = false; - let credDialogAction: "push" | "pull" | "fetch" | null = null; + let credDialogAction: CredentialAction | null = null; let credDialogError = ""; let credDialogKey: string | null = null; let lastStatusFingerprint = ""; @@ -221,6 +235,7 @@ let updateCheckInFlight = false; let updateDownloadTotal = 0; let updateDownloadedBytes = 0; + let errorAutoHideTimers: Partial>> = {}; let commitPanelHeight = loadCommitPanelHeight(); let resizingCommitPanel = false; let resizeStartY = 0; @@ -236,7 +251,9 @@ $: hasRepository = activeRepoPath.length > 0 && status !== null; $: workspaceActive = activeView === "repository" && hasRepository; $: openingRepo = operation === "Opening repository"; + $: cloningRepo = operation === "Cloning repository"; $: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? ""; + $: cloneDisplayName = pendingClone?.directoryName || repoNameFromCloneUrl(pendingClone?.remoteUrl ?? ""); $: changedFiles = status?.files ?? []; $: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0; $: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0; @@ -277,9 +294,37 @@ if (autoRefreshTimer) clearInterval(autoRefreshTimer); if (backgroundFetchTimer) clearInterval(backgroundFetchTimer); if (commitAiPollTimer) clearInterval(commitAiPollTimer); + if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer); + Object.values(errorAutoHideTimers).forEach((timer) => { + if (timer) clearTimeout(timer); + }); 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 ─────────────────────────────────────────────────────────── function statusFingerprint(value: GitStatus): string { @@ -517,6 +562,41 @@ 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 { return path.replace(/\\/g, "/").trim().toLowerCase(); } @@ -983,6 +1063,91 @@ } } + async function cloneRepo( + remoteUrl: string, + parentPath: string, + directoryName: string, + username?: string, + password?: string, + key?: string | null, + fromStore = false, + ) { + if (isBusy) return; + if (!remoteUrl) { errorMessage = "Enter a remote URL."; 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"; + errorMessage = ""; + setCloneDialogError(""); + try { + const bundle = await cloneRepository( + remoteUrl, + parentPath, + directoryName || undefined, + username, + password, + 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; + pendingClone = null; + if (credDialogAction === "clone") { + credDialogOpen = false; + credDialogAction = null; + credDialogError = ""; + credDialogKey = null; + } + lastRepoSwitchAt = Date.now(); + } catch (error) { + const rawMessage = errorToMessage(error); + 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 { + operation = ""; + } + } + + function openCloneDialog() { + if (isBusy) return; + setCloneDialogError(""); + cloneDialogOpen = true; + } + function openRepoManagement() { if (isBusy) return; activeView = "management"; @@ -1221,11 +1386,11 @@ } } - async function openCredentialDialog(action: "push" | "pull" | "fetch", key?: string | null) { - if (!activeRepoPath) return; + async function openCredentialDialog(action: CredentialAction, key?: string | null) { + if (!activeRepoPath && action !== "clone") return; credDialogError = ""; credDialogAction = action; - credDialogKey = key === undefined ? await currentCredKey() : key; + credDialogKey = key === undefined && action !== "clone" ? await currentCredKey() : (key ?? null); credDialogOpen = true; } @@ -1358,6 +1523,17 @@ if (credDialogAction === "pull") await doActualPull(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 === "clone" && pendingClone) { + await cloneRepo( + pendingClone.remoteUrl, + pendingClone.parentPath, + pendingClone.directoryName, + username, + password, + key, + false, + ); + } // Only persist once the operation actually succeeded (dialog has closed). if (!credDialogOpen && save && key) { @@ -2060,6 +2236,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 @@