diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index c08dd2c..886eb95 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -57,24 +57,42 @@ impl GitCache { .with_context(|| format!("failed to create {}", parent.display()))?; } - let mut last_err: Option = None; + clone_repo(clone_urls, &path)?; + self.open(addr)? + .ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened")) + } +} - for url in clone_urls { - match clone(url, &path) { - Ok(repo) => { - // The initial clone uses the default refspecs; also - // fetch the `refs/nostr/*` PR refs. - fetch_all(&repo).ok(); - return Ok(repo); - } - Err(e) => last_err = Some(e), +/// Clone a repository into `path` from the first working URL in +/// `clone_urls` (the announcement's `clone` tag), then fetch the +/// `refs/nostr/*` PR refs like the cache clone does. The destination must +/// not exist yet; it is created by the clone. The first URL that works +/// wins; when none do, the error of the last failing URL is returned. +/// +/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache; +/// callers open it themselves if they need a [`gix::Repository`]. +pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> { + if path.exists() { + bail!("destination {} already exists", path.display()); + } + + let mut last_err: Option = None; + + for url in clone_urls { + match clone(url, path) { + Ok(repo) => { + // The initial clone uses the default refspecs; also + // fetch the `refs/nostr/*` PR refs. + fetch_all(&repo).ok(); + return Ok(()); } + Err(e) => last_err = Some(e), } + } - match last_err { - Some(e) => Err(e).context("failed to clone from any mirror"), - None => bail!("no clone URLs provided"), - } + match last_err { + Some(e) => Err(e).context("failed to clone from any mirror"), + None => bail!("no clone URLs provided"), } } @@ -246,12 +264,13 @@ fn git_in(dir: &Path, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) } -/// Map an untrusted repository id to a safe single path component. +/// Map an untrusted repository id (or display name) to a safe single path +/// component. /// /// Replaces everything outside `[A-Za-z0-9._-]` with `_`, and rejects the -/// special components `.` and `..` so the id can't escape the cache root -/// when joined onto the owner directory. -fn sanitize_path_component(id: &str) -> String { +/// special components `.` and `..` so the id can't escape a directory it is +/// joined onto. +pub fn sanitize_path_component(id: &str) -> String { let sanitized: String = id .chars() .map(|c| { diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 4c6d3c3..dbad492 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -8,9 +8,10 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gix::Repository; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size, + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, PathPromptOptions, + Pixels, Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size, }; +use gpui_base::Disableable; use gpui_component::avatar::{Avatar, AvatarGroup}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::combobox::{ @@ -121,6 +122,8 @@ pub struct RepoDetailView { item_sizes: Rc>>, /// A clone/fetch is in flight. loading: bool, + /// The header clone button is cloning into a user-chosen folder. + cloning: bool, error: Option, /// Commit HEAD currently points to, shown in the header button. head_commit: Option, @@ -221,6 +224,7 @@ impl RepoDetailView { scroll_handle: VirtualListScrollHandle::new(), item_sizes: Rc::new(Vec::new()), loading: true, + cloning: false, error: None, head_commit: None, branch_select, @@ -403,6 +407,85 @@ impl RepoDetailView { } } + /// Clone the repository into a folder chosen by the user (outside the + /// cache), then open the new clone in the system file manager. Like + /// ngit's clone, this resolves the announcement's `clone` URLs and + /// clones from the first working git server. + fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { + if self.cloning { + return; + } + + let (clone_urls, name) = { + let announcement = self.announcement(cx); + let addr = announcement.addr(); + let clone_urls: Vec = + announcement.clone.iter().map(ToString::to_string).collect(); + // Directory name: the display name, falling back to the repo id; + // both sanitized to a safe single path component. + let name = announcement + .name + .as_ref() + .map(|name| name.to_string()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| addr.identifier.clone()); + let name = signed_git::sanitize_path_component(&name); + let name = if name.is_empty() { + "repository".to_owned() + } else { + name + }; + (clone_urls, name) + }; + + self.cloning = true; + cx.notify(); + + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Clone".into()), + }); + + let task = cx.spawn_in(window, async move |this, cx| { + // `Ok(Ok(Some(paths)))` means the user picked a folder; a + // cancel (or a picker failure) resolves to anything else. + let picked = match prompt.await { + Ok(Ok(Some(mut paths))) => paths.pop(), + _ => None, + }; + let Some(folder) = picked else { + this.update_in(cx, |this, _window, cx| { + this.cloning = false; + cx.notify(); + })?; + return Ok(()); + }; + + let destination = folder.join(&name); + let destination_for_open = destination.clone(); + let result = cx + .background_spawn(async move { signed_git::clone_repo(&clone_urls, &destination) }) + .await; + + this.update_in(cx, |this, _window, cx| { + this.cloning = false; + match result { + Ok(_) => cx.open_with_system(&destination_for_open), + Err(error) => { + this.error = Some(format!("Failed to clone: {error}").into()); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + /// Preview the file at `path` (relative to the worktree root). fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context) { self.selected_file = Some(path.into()); @@ -985,6 +1068,7 @@ impl RepoDetailView { .child( h_flex() .mt_2() + .w_full() .gap_2() .child( div() @@ -1042,8 +1126,13 @@ impl RepoDetailView { .child( Button::new("clone") .icon(CustomIconName::GitClone) - .tooltip("Clone") - .primary(), + .tooltip("Clone to folder...") + .loading(self.cloning) + .disabled(self.cloning) + .primary() + .on_click(cx.listener(|this, _event, window, cx| { + this.clone_to_folder(window, cx); + })), ), ), ) @@ -1176,7 +1265,6 @@ impl RepoDetailView { .w_full() .gap_3() .items_center() - .flex_wrap() .child( h_flex() .gap_1()