diff --git a/crates/assets/assets/icons/share.svg b/crates/assets/assets/icons/share.svg new file mode 100644 index 0000000..c91f073 --- /dev/null +++ b/crates/assets/assets/icons/share.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 658af48..e40c0ca 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -90,6 +90,7 @@ pub enum CustomIconName { GitBranch, Tag, Markdown, + Share, } impl IconNamed for CustomIconName { @@ -114,6 +115,7 @@ impl IconNamed for CustomIconName { CustomIconName::GitBranch => "icons/git-branch.svg", CustomIconName::Tag => "icons/tag.svg", CustomIconName::Markdown => "icons/markdown.svg", + CustomIconName::Share => "icons/share.svg", } .into() } diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index a4a9f83..98685a7 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -4,6 +4,8 @@ use nostr::prelude::*; /// Parsed NIP-34 repository announcement (plain data, ready for the UI). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Announcement { + /// ID of the announcement event itself. + pub event_id: EventId, /// Repository ID (`d` tag). pub id: String, /// Author of the announcement event. @@ -211,6 +213,7 @@ impl Announcement { } Some(Self { + event_id: event.id, owner: event.pubkey, created_at: event.created_at, id, diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index 408982d..be9cc59 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -3,17 +3,21 @@ use std::path::{Path, PathBuf}; use assets::CustomIconName; use gpui::prelude::*; -use gpui::{AnyElement, App, SharedString, Window, div, px}; +use gpui::{AnyElement, App, ClipboardItem, SharedString, Window, div, px}; +use gpui_base::StyledExt; +use gpui_component::clipboard::Clipboard; use gpui_component::list::ListItem; +use gpui_component::menu::{PopupMenu, PopupMenuItem}; use gpui_component::tooltip::Tooltip; use gpui_component::tree::{TreeEntry, TreeItem}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex}; -use signed_core::RepoStatus; +use nostr::nips::nip19::{Nip19Coordinate, ToBech32}; +use signed_core::{Announcement, RepoStatus}; use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff}; /// A `Send` file-tree node: the tree is built on a background thread and -/// converted into [`TreeItem`]s (which hold `Rc` state, so they cannot -/// cross threads) on the main thread. +/// converted into [`TreeItem`]s (which hold `Rc` state, +/// so they cannot cross threads) on the main thread. pub(super) struct TreeItemSeed { /// Path of the node, relative to the worktree root. id: String, @@ -22,12 +26,12 @@ pub(super) struct TreeItemSeed { children: Vec, } -/// Convert tree seeds into [`TreeItem`]s, expanding every folder when -/// `expand_folders` is set. +/// Convert tree seeds into [`TreeItem`]s, expanding every folder +/// when `expand_folders` is set. /// -/// The commit diff explorer shows only changed files, which is typically a -/// handful of paths, so its folders start expanded; the worktree explorer -/// starts collapsed instead. +/// The commit diff explorer shows only changed files, +/// which is typically a handful of paths, so its folders start expanded; +/// the worktree explorer starts collapsed instead. pub(super) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec { fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem { let mut item = TreeItem::new(seed.id, seed.label); @@ -48,8 +52,8 @@ pub(super) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec< .collect() } -/// One row of a file tree: icon + name, indented by depth. Clicking a file -/// runs `on_click`; folders expand/collapse via the tree itself. +/// One row of a file tree: icon + name, indented by depth. +/// Clicking a file runs `on_click`; folders expand/collapse via the tree itself. pub(super) fn tree_row(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem where F: Fn(&mut Window, &mut App) + 'static, @@ -139,8 +143,7 @@ pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec { /// The markdown fence language for a file path, or `None` for plain text. /// /// Names are chosen so `gpui_component`'s highlighter can resolve them -/// (`highlighter::Language::from_name` accepts short aliases such as `rs` -/// and `js`). +/// (`highlighter::Language::from_name` accepts short aliases such as `rs` and `js`). pub(super) fn code_language(path: &str) -> Option<&'static str> { let name = Path::new(path) .file_name() @@ -220,8 +223,8 @@ pub(super) fn placeholder(message: &str, cx: &App) -> AnyElement { .into_any_element() } -/// The status badge shown next to an issue or pull request: icon + colored -/// square, with a tooltip describing the status. +/// The status badge shown next to an issue or pull request: icon + colored square, +/// with a tooltip describing the status. pub(super) fn status_badge(status: RepoStatus, cx: &App) -> AnyElement { let (icon, label, tooltip, bg, fg) = match status { RepoStatus::Open => ( @@ -267,6 +270,125 @@ pub(super) fn status_badge(status: RepoStatus, cx: &App) -> AnyElement { .into_any_element() } +pub(super) struct ShareTargets { + /// NIP-19 `naddr1...` of the announcement (with its announced relays). + pub(super) naddr: String, + /// Hex ID of the announcement event itself. + pub(super) event_id: String, + /// NIP-34 coordinate `30617::`. + pub(super) coordinate: String, + /// `https://gitworkshop.dev/` + pub(super) gitworkshop: String, + /// `https://ditto.pub/` + pub(super) ditto: String, +} + +impl ShareTargets { + pub(super) fn from_announcement(announcement: &Announcement) -> Self { + let addr = announcement.addr(); + let coordinate = addr.to_string(); + let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) + .to_bech32() + .expect("a complete coordinate always encodes to naddr"); + + Self { + naddr: naddr.clone(), + event_id: announcement.event_id.to_bech32().unwrap(), + coordinate, + gitworkshop: format!("https://gitworkshop.dev/{naddr}"), + ditto: format!("https://ditto.pub/{naddr}"), + } + } + + /// The share dropdown menu: one row per target, each showing a compact + /// label while the copy button (and row click) copy the full value. + pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu { + menu.min_w(px(340.)) + .item(share_menu_row( + "copy-gitworkshop", + "GitWorkshop", + truncate_naddr_link(&self.gitworkshop, 4), + self.gitworkshop.clone(), + )) + .item(share_menu_row( + "copy-ditto", + "Ditto", + truncate_naddr_link(&self.ditto, 4), + self.ditto.clone(), + )) + .item(share_menu_row( + "copy-event-id", + "Event ID", + middle_truncate(&self.event_id, 10, 10), + self.event_id.clone(), + )) + .item(share_menu_row( + "copy-coordinate", + "Coordinate", + middle_truncate(&self.coordinate, 10, 10), + self.coordinate.clone(), + )) + } +} + +/// One row of the share menu: a small title on top of the compact label, +/// with a copy button that flips to a check while the value is on the +/// clipboard. Clicking the row text copies and dismisses the menu; the copy +/// button stops propagation, so the menu stays open for further copies. +/// Both copy `copy`, never the truncated label. +pub(super) fn share_menu_row( + id: &'static str, + title: &'static str, + label: String, + copy: String, +) -> PopupMenuItem { + let row_copy = copy.clone(); + PopupMenuItem::element(move |_window, _cx| { + let button_copy = copy.clone(); + h_flex() + .flex_1() + .gap_2() + .items_end() + .child( + h_flex() + .flex_1() + .gap_1() + .text_xs() + .child(div().flex_shrink_0().w_20().font_semibold().child(title)) + .child(div().flex_1().text_ellipsis().child(label.clone())), + ) + .child(Clipboard::new(id).tooltip("Copy").value(button_copy)) + }) + .on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(row_copy.clone())); + }) +} + +/// `[head chars]...[tail chars]` middle truncation; the value is left alone +/// when it is too short for the ellipsis to save space. +fn middle_truncate(value: &str, head: usize, tail: usize) -> String { + let len = value.chars().count(); + if len <= head + tail + 3 { + return value.to_string(); + } + let head: String = value.chars().take(head).collect(); + let tail: String = value.chars().skip(len - tail).collect(); + format!("{head}...{tail}") +} + +/// Shorten an naddr link to `/naddr1...[last tail chars]`, e.g. +/// `https://gitworkshop.dev/naddr1...abcd`. Only the label is shortened; +/// the value to be copied stays the full URL. +fn truncate_naddr_link(url: &str, tail: usize) -> String { + let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { + return url.to_string(); + }; + if url.len() - end <= tail + 3 { + return url.to_string(); + } + format!("{}...{}", &url[..end], &url[url.len() - tail..]) +} + /// Width of one line-number gutter in a diff row. pub(super) const GUTTER_WIDTH: f32 = 44.; /// Height of one row in a virtual diff list. @@ -482,4 +604,39 @@ mod tests { assert_eq!(code_language("LICENSE"), None); assert_eq!(code_language("README.md"), None); } + + #[test] + fn middle_truncates_long_values_only() { + assert_eq!( + middle_truncate( + "a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d", + 10, + 10, + ), + "a008def157...ad57a3564d" + ); + assert_eq!( + middle_truncate( + "30617:a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d:ngit", + 10, + 10 + ), + "30617:a008...3564d:ngit" + ); + // Too short to save space with the ellipsis: left alone. + assert_eq!(middle_truncate("short", 10, 10), "short"); + } + + #[test] + fn naddr_link_keeps_url_and_tail() { + assert_eq!( + truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4), + "https://gitworkshop.dev/naddr1...1234" + ); + // No naddr1 prefix: unchanged. + assert_eq!( + truncate_naddr_link("https://example.com/x", 4), + "https://example.com/x" + ); + } } diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index f9ff6cf..3365f45 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -8,7 +8,7 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gix::Repository; use gpui::prelude::*; use gpui::{ - Action, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, + Action, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, relative, size, }; @@ -45,7 +45,7 @@ use browser::{ }; use commits::COMMIT_ROW_HEIGHT; use diff::CommitDiffView; -use helpers::{TreeItemSeed, build_tree_items, is_markdown_path, tree_items}; +use helpers::{ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, tree_items}; use issues::{IssuesView, open_new_issue_dialog}; use pull_requests::{PullRequestsView, open_new_pull_request_dialog}; @@ -1044,6 +1044,7 @@ impl RepoDetailView { let name = self.display_name(cx); let description = announcement.description(); + let share = ShareTargets::from_announcement(announcement); let commits_count = self.all_commits.as_ref().map(|list| list.total); let worktree_empty = self.switching_ref || self.worktree.is_none(); @@ -1091,7 +1092,7 @@ impl RepoDetailView { h_flex() .mt_2() .w_full() - .gap_2() + .gap_0p5() .child( div() .text_xs() @@ -1112,6 +1113,7 @@ impl RepoDetailView { .outline() .button( Button::new("issues-open") + .icon(CustomIconName::GitIssueDone) .child( h_flex().gap_2().text_sm().child("Issues").child( Tag::secondary() @@ -1138,6 +1140,7 @@ impl RepoDetailView { .outline() .button( Button::new("prs-open") + .icon(CustomIconName::GitPullRequest) .child( h_flex() .gap_2() @@ -1163,10 +1166,23 @@ impl RepoDetailView { }), ) .child( - Button::new("link") - .icon(IconName::ExternalLink) - .tooltip("Open in gitworkshop.dev") - .secondary(), + DropdownButton::new("share") + .secondary() + .button( + Button::new("link") + .icon(IconName::Copy) + .tooltip("Copy ID") + .secondary() + .on_click({ + let naddr = share.naddr.clone(); + move |_, _, cx| { + cx.write_to_clipboard( + ClipboardItem::new_string(naddr.clone()), + ); + } + }), + ) + .dropdown_menu(move |menu, _, _| share.menu(menu)), ) .child( Button::new("clone") @@ -1330,8 +1346,6 @@ impl RepoDetailView { .into_any_element() } - /// Horizontal list of everyone who maintains the repository: the owner - /// shown in full, and any additional maintainers as a compact overlapping avatar group. fn render_maintainers(&self, cx: &mut Context) -> AnyElement { let announcement = self.announcement(cx); let profile_store = ProfileStore::global(cx); @@ -1352,7 +1366,7 @@ impl RepoDetailView { .w_full() .gap_3() .child( - Button::new("maintainers").ghost().child( + Button::new("maintainers").compact().ghost().child( h_flex() .gap_2() .child(