update copy button

This commit is contained in:
2026-08-27 20:12:09 +07:00
parent 1a8f96b3ba
commit b73dccd0d3
5 changed files with 204 additions and 25 deletions
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M9.00003 10.4286L14 7.57141M9.00003 13.5714L14 16.4286M20.25 6C20.25 7.79493 18.7949 9.25 17 9.25C15.2051 9.25 13.75 7.79493 13.75 6C13.75 4.20507 15.2051 2.75 17 2.75C18.7949 2.75 20.25 4.20507 20.25 6ZM20.25 18C20.25 19.7949 18.7949 21.25 17 21.25C15.2051 21.25 13.75 19.7949 13.75 18C13.75 16.2051 15.2051 14.75 17 14.75C18.7949 14.75 20.25 16.2051 20.25 18ZM9.25 12C9.25 13.7949 7.79493 15.25 6 15.25C4.20507 15.25 2.75 13.7949 2.75 12C2.75 10.2051 4.20507 8.75 6 8.75C7.79493 8.75 9.25 10.2051 9.25 12Z" stroke="currentColor" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 641 B

+2
View File
@@ -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()
}
+3
View File
@@ -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,
+172 -15
View File
@@ -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<TreeItemSeed>,
}
/// 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<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
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<TreeItemSeed>, 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<F>(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<TreeItemSeed> {
/// 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:<pubkey>:<repo-id>`.
pub(super) coordinate: String,
/// `https://gitworkshop.dev/<naddr>`
pub(super) gitworkshop: String,
/// `https://ditto.pub/<naddr>`
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 `<url>/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"
);
}
}
+24 -10
View File
@@ -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<Self>) -> 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(