feat: clone repository (#4)
Reviewed-on: https://git.reya.su/reya/signed/pulls/4
This commit was merged in pull request #4.
This commit is contained in:
@@ -57,24 +57,42 @@ impl GitCache {
|
|||||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut last_err: Option<anyhow::Error> = 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 {
|
/// Clone a repository into `path` from the first working URL in
|
||||||
match clone(url, &path) {
|
/// `clone_urls` (the announcement's `clone` tag), then fetch the
|
||||||
Ok(repo) => {
|
/// `refs/nostr/*` PR refs like the cache clone does. The destination must
|
||||||
// The initial clone uses the default refspecs; also
|
/// not exist yet; it is created by the clone. The first URL that works
|
||||||
// fetch the `refs/nostr/*` PR refs.
|
/// wins; when none do, the error of the last failing URL is returned.
|
||||||
fetch_all(&repo).ok();
|
///
|
||||||
return Ok(repo);
|
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache;
|
||||||
}
|
/// callers open it themselves if they need a [`gix::Repository`].
|
||||||
Err(e) => last_err = Some(e),
|
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;
|
||||||
|
|
||||||
|
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 {
|
match last_err {
|
||||||
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"),
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user