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(