feat: more options to clone (#6)

Reviewed-on: https://git.reya.su/reya/signed/pulls/6
This commit was merged in pull request #6.
This commit is contained in:
2026-08-29 03:49:16 +00:00
parent fc796582a8
commit c76ebfb8f6
3 changed files with 181 additions and 19 deletions
+13
View File
@@ -1,3 +1,5 @@
use std::collections::HashSet;
use gpui::SharedString; use gpui::SharedString;
use nostr::prelude::*; use nostr::prelude::*;
@@ -252,6 +254,17 @@ impl Announcement {
} }
maintainers maintainers
} }
/// The `git clone` URLs for this repository, deduplicated while
/// preserving the announced order (deterministic across calls).
pub fn clone_urls(&self) -> Vec<SharedString> {
let mut seen = HashSet::new();
self.clone
.iter()
.map(|url| SharedString::from(format!("git clone {url}")))
.filter(|command| seen.insert(command.clone()))
.collect()
}
} }
#[cfg(test)] #[cfg(test)]
@@ -25,8 +25,6 @@ fn commit_row(
cx: &App, cx: &App,
) -> AnyElement { ) -> AnyElement {
let view = view.clone(); let view = view.clone();
// Only the id is needed by the click handler: the diff panel fetches
// the full commit itself.
let id = commit.id.clone(); let id = commit.id.clone();
h_flex() h_flex()
+163 -12
View File
@@ -8,22 +8,24 @@ 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::{
Action, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Action, Anchor, AnyElement, App, ClipboardItem, Context, Div, ElementId, Entity, EventEmitter,
PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, Task, WeakEntity, Window, FocusHandle, Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription,
div, px, relative, size, Task, WeakEntity, Window, div, px, relative, size,
}; };
use gpui_base::{Button as BaseButton, Disableable}; use gpui_base::{Button as BaseButton, Disableable, Popover};
use gpui_component::avatar::Avatar; use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants}; use gpui_component::button::{Button, ButtonVariants};
use gpui_component::clipboard::Clipboard;
use gpui_component::combobox::{ use gpui_component::combobox::{
Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext,
}; };
use gpui_component::searchable_list::SearchableVec; use gpui_component::searchable_list::SearchableVec;
use gpui_component::tree::TreeState; use gpui_component::tree::TreeState;
use gpui_component::{ use gpui_component::{
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled,
v_flex, VirtualListScrollHandle, h_flex, v_flex,
}; };
use nostr::prelude::{RelayUrl, ToBech32};
use signed_core::Announcement; use signed_core::Announcement;
use signed_git::{CommitList, FileCommit}; use signed_git::{CommitList, FileCommit};
use signed_state::{GitStore, ProfileStore, RepoStore}; use signed_state::{GitStore, ProfileStore, RepoStore};
@@ -1055,6 +1057,11 @@ impl RepoDetailView {
let commits_count = self.all_commits.as_ref().map(|list| list.total); let commits_count = self.all_commits.as_ref().map(|list| list.total);
let worktree_empty = self.switching_ref || self.worktree.is_none(); let worktree_empty = self.switching_ref || self.worktree.is_none();
let nostr_url = nostr_clone_url(announcement, cx);
let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
let git_commands = announcement.clone_urls();
v_flex() v_flex()
.on_action( .on_action(
cx.listener(|this, action: &RepoAction, window, cx| match action { cx.listener(|this, action: &RepoAction, window, cx| match action {
@@ -1232,17 +1239,107 @@ impl RepoDetailView {
); );
})), })),
) )
.child( .child({
let view = cx.entity();
let ngit_command = ngit_command.clone();
let nak_command = nak_command.clone();
let git_commands = git_commands.clone();
Popover::new("clone")
.anchor(Anchor::TopRight)
.trigger(
Button::new("clone") Button::new("clone")
.icon(CustomIconName::GitClone) .icon(CustomIconName::GitClone)
.tooltip("Clone to folder...") .tooltip("Clone")
.loading(self.cloning) .loading(self.cloning)
.disabled(self.cloning) .disabled(self.cloning)
.primary() .primary(),
.on_click(cx.listener(|this, _event, window, cx| { )
this.clone_to_folder(window, cx); .content(move |_, _window, cx| {
})), let state = cx.entity();
let ngit_row = command_row("copy-ngit", &ngit_command, cx);
let nak_row = command_row("copy-nak", &nak_command, cx);
v_flex()
.w(px(440.))
.mt_1()
.p_3()
.gap_4()
.popover_style(cx)
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Clone with ngit"),
)
.child(ngit_row),
)
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Clone with nak"),
)
.child(nak_row),
)
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Grasp Servers"),
)
.when(!git_commands.is_empty(), |this| {
this.children(
git_commands.iter().enumerate().map(
|(ix, cmd)| {
command_row(
format!("copy-git-{ix}"),
cmd,
cx,
)
},
), ),
)
})
.when(git_commands.is_empty(), |this| {
this.child(
div()
.text_xs()
.child("No git clone urls."),
)
}),
)
.child(div().h_px().w_full().bg(cx.theme().border))
.child(
h_flex().gap_1().justify_end().child(
Button::new("download")
.icon(CustomIconName::GitClone)
.label("Download")
.primary()
.on_click(move |_event, window, cx| {
state.update(cx, |state, cx| {
state.dismiss(window, cx);
});
view.update(cx, |this, cx| {
this.clone_to_folder(window, cx);
});
}),
),
)
})
}),
), ),
) )
.child( .child(
@@ -1530,3 +1627,57 @@ fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
head_commit, head_commit,
}) })
} }
/// The `nostr://...` clone URL of an announcement (NIP-34): the owner as a
/// NIP-05 identifier when known (npub otherwise), the first announced relay
/// as a hint, and the repository identifier.
fn nostr_clone_url(announcement: &Announcement, cx: &App) -> SharedString {
let owner = announcement.owner;
let user = ProfileStore::global(cx)
.read(cx)
.get(&owner)
.metadata()
.nip05
.as_deref()
.filter(|nip05| !nip05.trim().is_empty())
.map(str::to_owned)
.unwrap_or_else(|| owner.to_bech32().unwrap_or_else(|_| owner.to_hex()));
let mut url = format!("nostr://{user}");
if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) {
url.push('/');
url.push_str(hint);
}
url.push('/');
url.push_str(&announcement.id);
SharedString::from(url)
}
fn command_row<E>(copy_id: E, command: &SharedString, cx: &mut App) -> Div
where
E: Into<ElementId>,
{
h_flex()
.h_8()
.w_full()
.px_2()
.gap_2()
.items_center()
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.child(
h_flex()
.flex_1()
.min_w_0()
.truncate()
.text_ellipsis()
.text_xs()
.child(command.clone()),
)
.child(
Clipboard::new(copy_id)
.tooltip("Copy")
.value(command.clone()),
)
}