From ae7be10dd31805693f2a8c0ac9ae4188fbec4774 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 28 Aug 2026 10:25:09 +0700 Subject: [PATCH] add repo info dialog --- Cargo.lock | 1 + crates/workspace/Cargo.toml | 1 + crates/workspace/src/pixel_avatar.rs | 12 +- .../workspace/src/views/repo_detail/about.rs | 232 ++++++++++++++++++ .../src/views/repo_detail/helpers.rs | 2 +- crates/workspace/src/views/repo_detail/mod.rs | 28 ++- crates/workspace/src/views/sidebar/mod.rs | 6 +- 7 files changed, 273 insertions(+), 9 deletions(-) create mode 100644 crates/workspace/src/views/repo_detail/about.rs diff --git a/Cargo.lock b/Cargo.lock index 7b0016b..13d5499 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10869,6 +10869,7 @@ version = "1.0.0" dependencies = [ "anyhow", "assets", + "chrono", "dock", "futures", "gix", diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 4bef62f..4168acc 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -20,5 +20,6 @@ gix.workspace = true nostr.workspace = true anyhow.workspace = true +chrono.workspace = true futures.workspace = true log.workspace = true diff --git a/crates/workspace/src/pixel_avatar.rs b/crates/workspace/src/pixel_avatar.rs index a996c16..e60870d 100644 --- a/crates/workspace/src/pixel_avatar.rs +++ b/crates/workspace/src/pixel_avatar.rs @@ -1,5 +1,6 @@ use gpui::prelude::*; -use gpui::{App, Pixels, Window, div, px}; +use gpui::{App, Pixels, StyleRefinement, Window, div, px}; +use gpui_base::StyledExt; use gpui_component::{ActiveTheme, Colorize}; /// Number of rows and columns in the pixel grid. @@ -19,6 +20,7 @@ const MIN_FILLED: usize = 5; pub(crate) struct PixelAvatar { seed: u64, size: Pixels, + style: StyleRefinement, } impl PixelAvatar { @@ -28,10 +30,17 @@ impl PixelAvatar { Self { seed: fnv1a(seed.as_ref().as_bytes()), size: px(16.), + style: StyleRefinement::default(), } } } +impl Styled for PixelAvatar { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + impl RenderOnce for PixelAvatar { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); @@ -64,6 +73,7 @@ impl RenderOnce for PixelAvatar { } div() + .refine_style(&self.style) .grid() .grid_cols(GRID_SIZE as u16) .grid_rows(GRID_SIZE as u16) diff --git a/crates/workspace/src/views/repo_detail/about.rs b/crates/workspace/src/views/repo_detail/about.rs new file mode 100644 index 0000000..1bad904 --- /dev/null +++ b/crates/workspace/src/views/repo_detail/about.rs @@ -0,0 +1,232 @@ +use gpui::prelude::*; +use gpui::{AnyElement, App, SharedString, Window, div, px}; +use gpui_component::avatar::Avatar; +use gpui_component::clipboard::Clipboard; +use gpui_component::{ActiveTheme, Sizable, StyledExt, WindowExt, h_flex, v_flex}; +use nostr::prelude::PublicKey; +use signed_core::Announcement; +use signed_state::ProfileStore; + +use super::helpers::middle_truncate; + +/// Open the "About" dialog: every field of the repository's announcement +/// event (NIP-34, kind 30617), as parsed into [`Announcement`]. +pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) { + window.open_dialog(cx, move |dialog, _window, cx| { + let announcement = announcement.clone(); + dialog + .w(px(500.)) + .h(px(600.)) + .keyboard(true) + .close_button(true) + .title("About") + .child(announcement_rows(&announcement, cx)) + }); +} + +/// The announcement's fields as labeled rows; hex identifiers carry a copy +/// button, multi-value tags one line per value. +fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement { + let mut rows: Vec = Vec::new(); + + rows.push(row( + "Name", + text( + announcement + .name + .clone() + .unwrap_or_else(|| SharedString::from("—")), + ), + cx, + )); + + rows.push(row( + "Description", + text( + announcement + .description + .clone() + .unwrap_or_else(|| SharedString::from("—")), + ), + cx, + )); + + if !announcement.web.is_empty() { + rows.push(row( + "Web", + list( + "about-web", + announcement.web.iter().map(|url| url.to_string()), + cx, + ), + cx, + )); + } + if let Some(euc) = &announcement.euc { + rows.push(row( + "Earliest Commit", + copy_value("about-euc", euc.clone(), cx), + cx, + )); + } + if let Some(upstream) = &announcement.upstream { + rows.push(row( + "Upstream", + text(SharedString::from(upstream.clone())), + cx, + )); + } + if !announcement.hashtags.is_empty() { + rows.push(row( + "Hashtags", + text(SharedString::from(announcement.hashtags.join(", "))), + cx, + )); + } + if !announcement.clone.is_empty() { + rows.push(row( + "Clone URLs", + list( + "about-clone", + announcement.clone.iter().map(|url| url.to_string()), + cx, + ), + cx, + )); + } + if !announcement.relays.is_empty() { + rows.push(row( + "Grasp Relays", + list( + "about-relays", + announcement.relays.iter().map(|url| url.to_string()), + cx, + ), + cx, + )); + } + if !announcement.maintainers.is_empty() { + rows.push(row( + "Maintainers", + maintainers(&announcement.maintainers, cx), + cx, + )); + } + + v_flex().gap_3().w_full().children(rows).into_any_element() +} + +/// One info row: a small muted label above the value. +fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement { + v_flex() + .gap_1() + .min_w_0() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child(label), + ) + .child(value) + .into_any_element() +} + +/// Plain text value, wrapping within the dialog. +fn text(value: SharedString) -> AnyElement { + div() + .text_sm() + .w_full() + .min_w_0() + .child(value) + .into_any_element() +} + +/// A mono-spaced value with a copy button, for hex identifiers. +fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement { + h_flex() + .gap_2() + .items_center() + .min_w_0() + .child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .min_w_0() + .child(SharedString::from(value.clone())), + ) + .child(Clipboard::new(id).tooltip("Copy").value(value)) + .into_any_element() +} + +/// One row per maintainer: avatar and display name (falling back to a +/// shortened npub), with a copy button for the full pubkey. +fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement { + let profile_store = ProfileStore::global(cx); + v_flex() + .gap_2() + .min_w_0() + .children(maintainers.iter().map(|pubkey| { + let profile = profile_store.read(cx).get(pubkey); + let name = profile.name(); + let picture = profile.picture(); + + h_flex() + .gap_2() + .items_center() + .min_w_0() + .child( + Avatar::new() + .name(name.clone()) + .when_some(picture, |this, url| this.src(url)) + .rounded(cx.theme().radius) + .small(), + ) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_sm() + .child(name), + ) + })) + .into_any_element() +} + +/// One row per item of a multi-value tag: the value is truncated to a single +/// line, with a copy button that copies the full value. +fn list(id: &'static str, items: impl IntoIterator, cx: &App) -> AnyElement { + v_flex() + .gap_2() + .min_w_0() + .children(items.into_iter().enumerate().map(|(ix, item)| { + h_flex() + .gap_2() + .items_center() + .min_w_0() + .child( + h_flex() + .h_5() + .px_1() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_sm() + .bg(cx.theme().muted) + .rounded(cx.theme().radius) + .child(SharedString::from(middle_truncate(&item, 28, 16))), + ) + .child( + Clipboard::new(format!("{id}-{ix}")) + .tooltip("Copy") + .value(item), + ) + .into_any_element() + })) + .into_any_element() +} diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index 995cfb9..43b4430 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -528,7 +528,7 @@ pub(super) fn share_menu_row( /// `[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 { +pub(super) fn middle_truncate(value: &str, head: usize, tail: usize) -> String { let len = value.chars().count(); if len <= head + tail + 3 { return value.to_string(); diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 60792a3..be0836a 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -29,7 +29,9 @@ use signed_git::{CommitList, FileCommit}; use signed_state::{GitStore, ProfileStore, RepoStore}; use crate::image_cache::{MAX_IMAGES, image_cache}; +use crate::pixel_avatar::PixelAvatar; +mod about; mod browser; mod commits; mod diff; @@ -39,6 +41,7 @@ mod issues; mod pull_request_detail; mod pull_requests; +use about::open_about_dialog; use browser::{ CodeView, FileContent, MAX_PREVIEW_BYTES, MAX_PREVIEW_CACHE_BYTES, MAX_PREVIEWED_FILES, MarkdownView, @@ -1046,6 +1049,7 @@ impl RepoDetailView { let name = self.display_name(cx); let description = announcement.description(); + let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); let share = ShareTargets::from_announcement(announcement); let commits_count = self.all_commits.as_ref().map(|list| list.total); @@ -1079,14 +1083,21 @@ impl RepoDetailView { .flex_1() .min_w_0() .gap_1() - .child(div().font_semibold().child(name)) + .child( + h_flex() + .gap_2() + .min_h_8() + .font_semibold() + .child(avatar.size_6()) + .child(name), + ) .child( div() .min_w_0() .text_sm() .text_color(cx.theme().muted_foreground) .line_clamp(2) - .line_height(relative(1.2)) + .line_height(relative(1.25)) .text_ellipsis() .child(description), ) @@ -1208,6 +1219,19 @@ impl RepoDetailView { ) .dropdown_menu(move |menu, _, _| share.menu(menu)), ) + .child( + Button::new("info") + .icon(IconName::Info) + .tooltip("About") + .secondary() + .on_click(cx.listener(|this, _event, window, cx| { + open_about_dialog( + this.announcement(cx).clone(), + window, + cx, + ); + })), + ) .child( Button::new("clone") .icon(CustomIconName::GitClone) diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index bc0d96a..9b7d01a 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -239,11 +239,7 @@ impl SidebarPanel { .name .clone() .unwrap_or_else(|| SharedString::from(announcement.id.clone())); - let avatar = PixelAvatar::new(format!( - "{}:{}", - announcement.owner.to_hex(), - announcement.id - )); + let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); let announcement = announcement.clone(); NavItem::new(format!("my-repo:{}", announcement.id), name, avatar).on_click(