From 52f160619338bdd02c631268830722326a8e35b8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 29 Aug 2026 08:39:04 +0700 Subject: [PATCH 1/2] update clone button --- crates/signed_core/src/model.rs | 13 ++ crates/workspace/src/views/repo_detail/mod.rs | 170 ++++++++++++++++-- 2 files changed, 166 insertions(+), 17 deletions(-) diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 98685a7..2dd190c 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use gpui::SharedString; use nostr::prelude::*; @@ -252,6 +254,17 @@ impl Announcement { } maintainers } + + /// The `git clone` URLs for this repository, deduplicated while + /// preserving the announced order (deterministic across calls). + pub fn clone_urls(&self) -> Vec { + 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)] diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index be0836a..b2ffae7 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -8,22 +8,24 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gix::Repository; use gpui::prelude::*; use gpui::{ - Action, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, - PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, Task, WeakEntity, Window, - div, px, relative, size, + Action, Anchor, AnyElement, App, ClipboardItem, Context, Div, ElementId, Entity, EventEmitter, + FocusHandle, Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, + 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::button::{Button, ButtonVariants}; +use gpui_component::clipboard::Clipboard; use gpui_component::combobox::{ Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, }; use gpui_component::searchable_list::SearchableVec; use gpui_component::tree::TreeState; use gpui_component::{ - ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, - v_flex, + ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, + VirtualListScrollHandle, h_flex, v_flex, }; +use nostr::prelude::{RelayUrl, ToBech32}; use signed_core::Announcement; use signed_git::{CommitList, FileCommit}; use signed_state::{GitStore, ProfileStore, RepoStore}; @@ -1055,6 +1057,9 @@ impl RepoDetailView { let commits_count = self.all_commits.as_ref().map(|list| list.total); let worktree_empty = self.switching_ref || self.worktree.is_none(); + let ngit_command = ngit_clone_command(announcement, cx); + let git_commands = announcement.clone_urls(); + v_flex() .on_action( cx.listener(|this, action: &RepoAction, window, cx| match action { @@ -1232,17 +1237,94 @@ impl RepoDetailView { ); })), ) - .child( - Button::new("clone") - .icon(CustomIconName::GitClone) - .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); - })), - ), + .child({ + let view = cx.entity(); + let ngit_command = ngit_command.clone(); + let git_commands = git_commands.clone(); + + Popover::new("clone") + .anchor(Anchor::TopRight) + .trigger( + Button::new("clone") + .icon(CustomIconName::GitClone) + .tooltip("Clone") + .loading(self.cloning) + .disabled(self.cloning) + .primary(), + ) + .content(move |_, _window, cx| { + let state = cx.entity(); + let ngit_row = command_row("copy-ngit", &ngit_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("Git"), + ) + .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() + .small() + .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( @@ -1530,3 +1612,57 @@ fn load_repo_data(repo: &Repository) -> Result { head_commit, }) } + +/// The `ngit clone nostr://...` command for 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 ngit_clone_command(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(format!("ngit clone {url}")) +} + +fn command_row(copy_id: E, command: &SharedString, cx: &mut App) -> Div +where + E: Into, +{ + 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()), + ) +} -- 2.54.0 From 05480184260e274a14232d256640c95255175ab7 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 29 Aug 2026 10:44:51 +0700 Subject: [PATCH 2/2] add nak --- .../src/views/repo_detail/commits.rs | 2 -- crates/workspace/src/views/repo_detail/mod.rs | 31 ++++++++++++++----- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs index 6900101..a20e8b2 100644 --- a/crates/workspace/src/views/repo_detail/commits.rs +++ b/crates/workspace/src/views/repo_detail/commits.rs @@ -25,8 +25,6 @@ fn commit_row( cx: &App, ) -> AnyElement { 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(); h_flex() diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index b2ffae7..9a26175 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -1057,7 +1057,9 @@ impl RepoDetailView { let commits_count = self.all_commits.as_ref().map(|list| list.total); let worktree_empty = self.switching_ref || self.worktree.is_none(); - let ngit_command = ngit_clone_command(announcement, cx); + 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() @@ -1240,6 +1242,7 @@ impl RepoDetailView { .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") @@ -1255,6 +1258,7 @@ impl RepoDetailView { .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.)) @@ -1282,7 +1286,19 @@ impl RepoDetailView { .text_xs() .font_semibold() .text_color(cx.theme().muted_foreground) - .child("Git"), + .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( @@ -1312,7 +1328,6 @@ impl RepoDetailView { .icon(CustomIconName::GitClone) .label("Download") .primary() - .small() .on_click(move |_event, window, cx| { state.update(cx, |state, cx| { state.dismiss(window, cx); @@ -1613,10 +1628,10 @@ fn load_repo_data(repo: &Repository) -> Result { }) } -/// The `ngit clone nostr://...` command for 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 ngit_clone_command(announcement: &Announcement, cx: &App) -> SharedString { +/// 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) @@ -1636,7 +1651,7 @@ fn ngit_clone_command(announcement: &Announcement, cx: &App) -> SharedString { url.push('/'); url.push_str(&announcement.id); - SharedString::from(format!("ngit clone {url}")) + SharedString::from(url) } fn command_row(copy_id: E, command: &SharedString, cx: &mut App) -> Div -- 2.54.0