From bfcf9281f04a066ef2ddbf6bc88c21bc8d1329d8 Mon Sep 17 00:00:00 2001 From: Verun Tests Date: Thu, 25 Jun 2026 11:15:30 +0000 Subject: [PATCH] fix(clone): create missing destination folder on clone instead of erroring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clone_repo now create_dir_all's the destination parent when it's absent, and clone_github_repo_and_add tilde-expands the typed path first so `~/Desktop/...` resolves to an absolute path before cloning. The destination autocomplete drops its inline "Create " row and the "folder doesn't exist" warning (new allowCreate prop on PathAutocomplete, defaults to true so BtsBuilder keeps inline create) — the path is created under the hood when you click Clone. --- CHANGELOG.md | 1 + src-tauri/src/github.rs | 20 +++++++++++++++++++- src-tauri/src/ipc.rs | 13 +++++++++++-- src/components/CloneRepoDialog.tsx | 1 + src/components/PathAutocomplete.test.tsx | 10 ++++++++++ src/components/PathAutocomplete.tsx | 2 ++ 6 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c28e816..58724659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Clone GitHub repo dialog: a destination folder that doesn't exist yet is now created (recursively) when you clone, instead of failing with "Destination parent does not exist". The typed path is also tilde-expanded (e.g. `~/Desktop/SoftwareSavants/code`) before cloning — `clone_github_repo_and_add` runs `expand_tilde` and `clone_repo` `create_dir_all`s the parent, matching `list_subdirs`/`create_subdir`. The destination autocomplete no longer shows the inline `Create ""` row or its "folder doesn't exist" warning — the path is just created under the hood on Clone (new `allowCreate` prop on `PathAutocomplete`, defaults to true so the BtsBuilder dialog keeps its inline create) - Clone GitHub repo dialog: row clicks now select a repo instead of cloning immediately and stamp the repo URL into the search input; the local filter normalizes URL queries to the `owner/repo` slug so the list stays narrowed after selection. An explicit Clone button at the bottom-right stays disabled until both a repo (or URL/slug query) and a destination folder are set. Destination folder moved below the result list. While cloning, the picker stays visible (controls disabled) and a compact `max-h-[10rem]` terminal-style preview box appears above the footer streaming git's `--progress` stderr (newest line at the top, capped at 200 lines) via a new `clone-progress` Tauri event from `clone_repo_with_progress` in `github.rs`; the Clone button reads "Cloning..." while git is running - Fix `Clone failed: git clone failed: … the repository exists.` for users without SSH set up. GitHub clones now go over HTTPS (`https://github.com//.git`) so `gh auth login`'s credential helper supplies the OAuth token instead of relying on user SSH keys. The unused `resolve_repo_clone_urls` + its `gh repo view` roundtrip are gone. A failed clone now also `fs::remove_dir_all`s the partial worktree git left behind, so retrying doesn't immediately hit "Destination already exists" - Clone GitHub repo dialog no longer scrolls vertically when the destination folder's autocomplete dropdown opens. Overriding the base `Dialog`'s `overflow-y-auto` with `!overflow-visible` lets the absolute-positioned `PathAutocomplete` list pop out below the dialog instead of clipping (which was the trigger that forced the dialog into a scroll container). `PathAutocomplete`'s dropdown also tightened from `max-h-56` to `max-h-40` so it stays compact when there are many sibling folders diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs index a0efb1be..eaf878f2 100644 --- a/src-tauri/src/github.rs +++ b/src-tauri/src/github.rs @@ -332,7 +332,8 @@ pub fn clone_repo(remote_url: &str, parent_dir: &str, dir_name: &str) -> Result< let parent = Path::new(parent_dir); if !parent.is_dir() { - return Err(format!("Destination parent does not exist: {parent_dir}")); + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create destination folder {parent_dir}: {e}"))?; } let target = parent.join(dir_name); if target.exists() { @@ -1248,6 +1249,23 @@ mod tests { ); } + #[test] + fn clone_creates_missing_destination_parent() { + let base = tempfile::tempdir().expect("tempdir"); + let parent = base.path().join("does/not/exist/yet"); + assert!(!parent.exists(), "precondition: parent must be missing"); + let result = clone_repo( + "https://invalid.example.invalid/no/such/repo.git", + parent.to_str().unwrap(), + "verun-clone-mkparent-target", + ); + assert!( + parent.is_dir(), + "missing destination parent {parent:?} should have been created", + ); + assert!(result.is_err()); + } + #[test] fn parse_auth_status_offline_dns_failure() { // `gh auth status` when DNS resolution fails. We must distinguish diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 7425cd99..deac2411 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -176,6 +176,9 @@ pub async fn clone_github_repo_and_add( if parent_dir.trim().is_empty() { return Err("Choose a destination folder before cloning.".to_string()); } + let parent_dir = crate::bts_scaffold::expand_tilde(parent_dir.trim())? + .to_string_lossy() + .into_owned(); let (final_url, dir_name) = if let Some(nwo) = name_with_owner.as_ref() { let nwo = nwo.trim(); if nwo.is_empty() || !nwo.contains('/') { @@ -1634,7 +1637,8 @@ pub async fn git_commit_amend( .ok_or_else(|| format!("Task {task_id} not found"))?; let hash = flatten_join( - tokio::task::spawn_blocking(move || git_ops::commit_amend(&t.worktree_path, &message)).await, + tokio::task::spawn_blocking(move || git_ops::commit_amend(&t.worktree_path, &message)) + .await, )?; emit_git_local_changed(&app, &task_id); Ok(hash) @@ -1689,7 +1693,12 @@ pub async fn get_staged_diff( flatten_join( tokio::task::spawn_blocking(move || { - git_ops::get_staged_diff(&t.worktree_path, &file_path, context_lines, ignore_whitespace) + git_ops::get_staged_diff( + &t.worktree_path, + &file_path, + context_lines, + ignore_whitespace, + ) }) .await, ) diff --git a/src/components/CloneRepoDialog.tsx b/src/components/CloneRepoDialog.tsx index 882b218e..05091dac 100644 --- a/src/components/CloneRepoDialog.tsx +++ b/src/components/CloneRepoDialog.tsx @@ -781,6 +781,7 @@ export const CloneRepoDialog: Component = (props) => { value={parentDir()} onChange={setParentDir} placeholder="~" + allowCreate={false} /> diff --git a/src/components/PathAutocomplete.test.tsx b/src/components/PathAutocomplete.test.tsx index 7d2afe01..0c4d5fec 100644 --- a/src/components/PathAutocomplete.test.tsx +++ b/src/components/PathAutocomplete.test.tsx @@ -111,6 +111,16 @@ describe('PathAutocomplete', () => { expect(await findByText(/Create.*myproj/)).toBeTruthy() }) + it('does not show the Create row when allowCreate is false', async () => { + listSubdirsMock.mockResolvedValue(['Desktop', 'Documents']) + const { findByText, queryByText, getByRole } = render(() => ( + {}} allowCreate={false} /> + )) + fireEvent.focus(getByRole('textbox')) + await findByText('Desktop') + expect(queryByText(/Create.*Des/)).toBeNull() + }) + it('does not show Create row when prefix exactly matches existing dir', async () => { listSubdirsMock.mockResolvedValue(['Desktop']) const { findByText, queryByText, getByRole } = render(() => ( diff --git a/src/components/PathAutocomplete.tsx b/src/components/PathAutocomplete.tsx index fda7b152..2310a329 100644 --- a/src/components/PathAutocomplete.tsx +++ b/src/components/PathAutocomplete.tsx @@ -8,6 +8,7 @@ interface Props { onChange: (next: string) => void placeholder?: string autoFocus?: boolean + allowCreate?: boolean } function splitPath(raw: string): { parent: string; prefix: string } { @@ -72,6 +73,7 @@ export const PathAutocomplete: Component = (props) => { }) const createName = createMemo(() => { + if (props.allowCreate === false) return null const { prefix } = splitPath(props.value) if (!prefix || prefix === '.' || prefix === '..' || !VALID_NAME.test(prefix)) return null const exact = entries().some((n) => n.toLowerCase() === prefix.toLowerCase())