feat: clone repository #4

Merged
reya merged 2 commits from feat/clone into master 2026-08-26 21:05:26 +00:00
2 changed files with 130 additions and 23 deletions
+26 -7
View File
@@ -57,15 +57,34 @@ impl GitCache {
.with_context(|| format!("failed to create {}", parent.display()))?; .with_context(|| format!("failed to create {}", parent.display()))?;
} }
clone_repo(clone_urls, &path)?;
self.open(addr)?
.ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened"))
}
}
/// 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<anyhow::Error> = None; let mut last_err: Option<anyhow::Error> = None;
for url in clone_urls { for url in clone_urls {
match clone(url, &path) { match clone(url, path) {
Ok(repo) => { Ok(repo) => {
// The initial clone uses the default refspecs; also // The initial clone uses the default refspecs; also
// fetch the `refs/nostr/*` PR refs. // fetch the `refs/nostr/*` PR refs.
fetch_all(&repo).ok(); fetch_all(&repo).ok();
return Ok(repo); return Ok(());
} }
Err(e) => last_err = Some(e), Err(e) => last_err = Some(e),
} }
@@ -75,7 +94,6 @@ impl GitCache {
Some(e) => Err(e).context("failed to clone from any mirror"), Some(e) => Err(e).context("failed to clone from any mirror"),
None => bail!("no clone URLs provided"), None => bail!("no clone URLs provided"),
} }
}
} }
/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` /// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*`
@@ -246,12 +264,13 @@ fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) 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 /// Replaces everything outside `[A-Za-z0-9._-]` with `_`, and rejects the
/// special components `.` and `..` so the id can't escape the cache root /// special components `.` and `..` so the id can't escape a directory it is
/// when joined onto the owner directory. /// joined onto.
fn sanitize_path_component(id: &str) -> String { pub fn sanitize_path_component(id: &str) -> String {
let sanitized: String = id let sanitized: String = id
.chars() .chars()
.map(|c| { .map(|c| {
+93 -5
View File
@@ -8,9 +8,10 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use gix::Repository; use gix::Repository;
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{ use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, PathPromptOptions,
SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size, Pixels, Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size,
}; };
use gpui_base::Disableable;
use gpui_component::avatar::{Avatar, AvatarGroup}; use gpui_component::avatar::{Avatar, AvatarGroup};
use gpui_component::button::{Button, ButtonVariants}; use gpui_component::button::{Button, ButtonVariants};
use gpui_component::combobox::{ use gpui_component::combobox::{
@@ -121,6 +122,8 @@ pub struct RepoDetailView {
item_sizes: Rc<Vec<Size<Pixels>>>, item_sizes: Rc<Vec<Size<Pixels>>>,
/// A clone/fetch is in flight. /// A clone/fetch is in flight.
loading: bool, loading: bool,
/// The header clone button is cloning into a user-chosen folder.
cloning: bool,
error: Option<SharedString>, error: Option<SharedString>,
/// Commit HEAD currently points to, shown in the header button. /// Commit HEAD currently points to, shown in the header button.
head_commit: Option<FileCommit>, head_commit: Option<FileCommit>,
@@ -221,6 +224,7 @@ impl RepoDetailView {
scroll_handle: VirtualListScrollHandle::new(), scroll_handle: VirtualListScrollHandle::new(),
item_sizes: Rc::new(Vec::new()), item_sizes: Rc::new(Vec::new()),
loading: true, loading: true,
cloning: false,
error: None, error: None,
head_commit: None, head_commit: None,
branch_select, 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<Self>) {
if self.cloning {
return;
}
let (clone_urls, name) = {
let announcement = self.announcement(cx);
let addr = announcement.addr();
let clone_urls: Vec<String> =
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). /// Preview the file at `path` (relative to the worktree root).
fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) { fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
self.selected_file = Some(path.into()); self.selected_file = Some(path.into());
@@ -985,6 +1068,7 @@ impl RepoDetailView {
.child( .child(
h_flex() h_flex()
.mt_2() .mt_2()
.w_full()
.gap_2() .gap_2()
.child( .child(
div() div()
@@ -1042,8 +1126,13 @@ impl RepoDetailView {
.child( .child(
Button::new("clone") Button::new("clone")
.icon(CustomIconName::GitClone) .icon(CustomIconName::GitClone)
.tooltip("Clone") .tooltip("Clone to folder...")
.primary(), .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() .w_full()
.gap_3() .gap_3()
.items_center() .items_center()
.flex_wrap()
.child( .child(
h_flex() h_flex()
.gap_1() .gap_1()