feat: add repo about dialog #5
Generated
+1
@@ -10869,6 +10869,7 @@ version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assets",
|
||||
"chrono",
|
||||
"dock",
|
||||
"futures",
|
||||
"gix",
|
||||
|
||||
@@ -20,5 +20,6 @@ gix.workspace = true
|
||||
nostr.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<AnyElement> = 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<Item = String>, 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()
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user