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/assets/assets/icons/chevron-down.svg b/crates/assets/assets/icons/chevron-down.svg index 7a0a041..36be93e 100644 --- a/crates/assets/assets/icons/chevron-down.svg +++ b/crates/assets/assets/icons/chevron-down.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/crates/assets/assets/icons/git-commit.svg b/crates/assets/assets/icons/git-commit.svg new file mode 100644 index 0000000..931e995 --- /dev/null +++ b/crates/assets/assets/icons/git-commit.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/git-file.svg b/crates/assets/assets/icons/git-file.svg new file mode 100644 index 0000000..c98599d --- /dev/null +++ b/crates/assets/assets/icons/git-file.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/plus.svg b/crates/assets/assets/icons/plus.svg index 7e2e5b8..c4f4bd7 100644 --- a/crates/assets/assets/icons/plus.svg +++ b/crates/assets/assets/icons/plus.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + 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/assets/themes/.keep b/crates/assets/assets/themes/.keep new file mode 100644 index 0000000..e69de29 diff --git a/crates/assets/assets/themes/signed.json b/crates/assets/assets/themes/signed.json deleted file mode 100644 index 977c566..0000000 --- a/crates/assets/assets/themes/signed.json +++ /dev/null @@ -1,576 +0,0 @@ -{ - "$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json", - "name": "Signed", - "author": "Signed", - "url": "https://git.reya.su/reya/signed", - "themes": [ - { - "name": "Signed Light", - "mode": "light", - "colors": { - "accent.background": "#F4F4F5", - "accent.foreground": "#18181B", - "accordion.background": "#FFFFFF", - "background": "#FFFFFF", - "border": "#E4E4E7", - "button.background": "#FFFFFF", - "button.active.background": "#E4E4E7", - "button.foreground": "#18181B", - "button.hover.background": "#F4F4F5", - "button.danger.background": "#DC2626", - "button.danger.active.background": "#B91C1C", - "button.danger.foreground": "#FFFFFF", - "button.danger.hover.background": "#EF4444", - "button.info.background": "#0284C7", - "button.info.active.background": "#0369A1", - "button.info.foreground": "#FFFFFF", - "button.info.hover.background": "#0EA5E9", - "button.primary.background": "#C6FF4D", - "button.primary.active.background": "#65A30D", - "button.primary.foreground": "#0B0F0C", - "button.primary.hover.background": "#B2EC3C", - "button.secondary.background": "#F4F4F5", - "button.secondary.active.background": "#D4D4D8", - "button.secondary.foreground": "#18181B", - "button.secondary.hover.background": "#E4E4E7", - "button.success.background": "#2FBF71", - "button.success.active.background": "#1F9A58", - "button.success.foreground": "#0B0F0C", - "button.success.hover.background": "#2AB568", - "button.warning.background": "#D97706", - "button.warning.active.background": "#B45309", - "button.warning.foreground": "#FFFFFF", - "button.warning.hover.background": "#F59E0B", - "group_box.background": "#F4F4F5", - "group_box.foreground": "#18181B", - "group_box.title.foreground": "#18181B", - "caret": "#18181B", - "chart.1": "#65A30D", - "chart.2": "#2FBF71", - "chart.3": "#3F6212", - "chart.4": "#7A8B5F", - "chart.5": "#A1A1AA", - "chart_bullish": "#2FBF71", - "chart_bearish": "#DC2626", - "danger.background": "#DC2626", - "danger.active.background": "#B91C1C", - "danger.foreground": "#FFFFFF", - "danger.hover.background": "#EF4444", - "description_list.label.background": "#F4F4F5", - "description_list.label.foreground": "#71717A", - "drag.border": "#65A30D", - "drop_target.background": "#65A30D33", - "foreground": "#18181B", - "info.background": "#0284C7", - "info.active.background": "#0369A1", - "info.foreground": "#FFFFFF", - "info.hover.background": "#0EA5E9", - "input.border": "#D4D4D8", - "link": "#65A30D", - "link.active": "#3F6212", - "link.hover": "#4A7A0B", - "list.background": "#FFFFFF", - "list.active.background": "#C6FF4D40", - "list.active.border": "#65A30D80", - "list.even.background": "#FAFAFA", - "list.head.background": "#FAFAFA", - "list.hover.background": "#F4F4F5", - "muted.background": "#F4F4F5", - "muted.foreground": "#71717A", - "popover.background": "#FFFFFF", - "popover.foreground": "#18181B", - "primary.background": "#C6FF4D", - "primary.active.background": "#65A30D", - "primary.foreground": "#0B0F0C", - "primary.hover.background": "#B2EC3C", - "progress.bar.background": "#65A30D", - "ring": "#65A30D", - "scrollbar.background": "#FFFFFF00", - "scrollbar.thumb.background": "#A1A1AA99", - "scrollbar.thumb.hover.background": "#A1A1AA", - "secondary.background": "#F4F4F5", - "secondary.active.background": "#D4D4D8", - "secondary.foreground": "#18181B", - "secondary.hover.background": "#E4E4E7", - "selection.background": "#C6FF4D59", - "sidebar.background": "#FAFAFA", - "sidebar.accent.background": "#C6FF4D40", - "sidebar.accent.foreground": "#65A30D", - "sidebar.border": "#E4E4E7", - "sidebar.foreground": "#18181B", - "sidebar.primary.background": "#F4F4F5", - "sidebar.primary.foreground": "#18181B", - "skeleton.background": "#E4E4E7", - "slider.background": "#E4E4E7", - "slider.thumb.background": "#65A30D", - "success.background": "#2FBF71", - "success.active.background": "#1F9A58", - "success.foreground": "#0B0F0C", - "success.hover.background": "#2AB568", - "switch.background": "#D4D4D8", - "switch.thumb.background": "#FFFFFF", - "tab.background": "#F4F4F5", - "tab.active.background": "#EBFFC1", - "tab.active.foreground": "#3F6212", - "tab.foreground": "#71717A", - "tab_bar.background": "#F4F4F5", - "tab_bar.segmented.background": "#E4E4E7", - "table.background": "#FFFFFF", - "table.active.background": "#C6FF4D40", - "table.active.border": "#65A30D80", - "table.even.background": "#FAFAFA", - "table.head.background": "#FAFAFA", - "table.head.foreground": "#71717A", - "table.foot.background": "#FAFAFA", - "table.foot.foreground": "#71717A", - "table.hover.background": "#F4F4F5", - "table.row.border": "#E4E4E7", - "title_bar.background": "#FAFAFA", - "title_bar.border": "#E4E4E7", - "status_bar.background": "#FAFAFA", - "status_bar.border": "#E4E4E7", - "tiles.background": "#FFFFFF", - "warning.background": "#D97706", - "warning.active.background": "#B45309", - "warning.foreground": "#FFFFFF", - "warning.hover.background": "#F59E0B", - "overlay": "#0000004D", - "window.border": "#E4E4E7", - "base.red": "#DC2626", - "base.green": "#16A34A", - "base.yellow": "#CA8A04", - "base.blue": "#2563EB", - "base.magenta": "#9333EA", - "base.cyan": "#0891B2" - }, - "highlight": { - "editor.background": "#FFFFFF", - "editor.foreground": "#383A42", - "editor.active_line.background": "#F2F3F4", - "editor.line_number": "#A0A1A7", - "editor.active_line_number": "#383A42", - "editor.invisible": "#A0A1A766", - "conflict": "#DC2626", - "created": "#16A34A", - "deleted": "#DC2626", - "error": "#DC2626", - "error.background": "#FEF2F2", - "error.border": "#F87171", - "hidden": "#A0A1A7", - "hint": "#A626A4", - "hint.background": "#FAF5FF", - "hint.border": "#C4B5FD", - "ignored": "#A0A1A7", - "info": "#4078F2", - "info.background": "#EFF6FF", - "info.border": "#93C5FD", - "modified": "#C18401", - "modified.background": "#FFFBEB", - "predictive": "#A0A1A7", - "renamed": "#A626A4", - "success": "#16A34A", - "success.background": "#F0FDF4", - "unreachable": "#A0A1A7", - "warning": "#C18401", - "warning.background": "#FFFBEB", - "warning.border": "#FCD34D", - "syntax": { - "attribute": { - "color": "#986801" - }, - "boolean": { - "color": "#986801" - }, - "comment": { - "color": "#A0A1A7", - "font_style": "italic" - }, - "comment.doc": { - "color": "#A0A1A7", - "font_style": "italic" - }, - "constant": { - "color": "#986801" - }, - "constructor": { - "color": "#4078F2" - }, - "embedded": { - "color": "#C18401" - }, - "function": { - "color": "#4078F2" - }, - "keyword": { - "color": "#A626A4" - }, - "label": { - "color": "#4078F2" - }, - "link_text": { - "color": "#4078F2", - "font_style": "underline" - }, - "link_uri": { - "color": "#4078F2", - "font_style": "italic" - }, - "number": { - "color": "#986801" - }, - "operator": { - "color": "#383A42" - }, - "preproc": { - "color": "#C18401" - }, - "property": { - "color": "#E45649" - }, - "punctuation": { - "color": "#383A42" - }, - "punctuation.bracket": { - "color": "#383A42" - }, - "punctuation.delimiter": { - "color": "#383A42" - }, - "punctuation.list_marker": { - "color": "#986801" - }, - "punctuation.special": { - "color": "#A626A4" - }, - "string": { - "color": "#50A14F" - }, - "string.escape": { - "color": "#A626A4" - }, - "string.regex": { - "color": "#50A14F" - }, - "string.special": { - "color": "#50A14F" - }, - "string.special.symbol": { - "color": "#986801" - }, - "tag": { - "color": "#E45649" - }, - "tag.doctype": { - "color": "#A0A1A7" - }, - "text.code.span": { - "color": "#383A42" - }, - "text.literal": { - "color": "#50A14F" - }, - "title": { - "color": "#E45649", - "font_weight": 700 - }, - "type": { - "color": "#0184BC" - }, - "variable": { - "color": "#383A42" - }, - "variable.special": { - "color": "#E45649" - }, - "variant": { - "color": "#0184BC" - } - } - } - }, - { - "name": "Signed Dark", - "mode": "dark", - "colors": { - "accent.background": "#18181B", - "accent.foreground": "#FAFAFA", - "accordion.background": "#0A0A0A", - "background": "#0A0A0A", - "border": "#27272A", - "button.background": "#18181B", - "button.active.background": "#3F3F46", - "button.foreground": "#FAFAFA", - "button.hover.background": "#27272A", - "button.danger.background": "#EF4444", - "button.danger.active.background": "#DC2626", - "button.danger.foreground": "#FFFFFF", - "button.danger.hover.background": "#F87171", - "button.info.background": "#0EA5E9", - "button.info.active.background": "#0284C7", - "button.info.foreground": "#FFFFFF", - "button.info.hover.background": "#38BDF8", - "button.primary.background": "#C6FF4D", - "button.primary.active.background": "#65A30D", - "button.primary.foreground": "#0B0F0C", - "button.primary.hover.background": "#B2EC3C", - "button.secondary.background": "#18181B", - "button.secondary.active.background": "#3F3F46", - "button.secondary.foreground": "#FAFAFA", - "button.secondary.hover.background": "#27272A", - "button.success.background": "#2FBF71", - "button.success.active.background": "#24A35D", - "button.success.foreground": "#0B0F0C", - "button.success.hover.background": "#2AB568", - "button.warning.background": "#F59E0B", - "button.warning.active.background": "#D97706", - "button.warning.foreground": "#18181B", - "button.warning.hover.background": "#FBBF24", - "group_box.background": "#18181B", - "group_box.foreground": "#FAFAFA", - "group_box.title.foreground": "#FAFAFA", - "caret": "#FAFAFA", - "chart.1": "#C6FF4D", - "chart.2": "#A6E22E", - "chart.3": "#2FBF71", - "chart.4": "#7A8B5F", - "chart.5": "#A1A1AA", - "chart_bullish": "#2FBF71", - "chart_bearish": "#EF4444", - "danger.background": "#EF4444", - "danger.active.background": "#DC2626", - "danger.foreground": "#FFFFFF", - "danger.hover.background": "#F87171", - "description_list.label.background": "#18181B", - "description_list.label.foreground": "#A1A1AA", - "drag.border": "#C6FF4D", - "drop_target.background": "#C6FF4D33", - "foreground": "#FAFAFA", - "info.background": "#0EA5E9", - "info.active.background": "#0284C7", - "info.foreground": "#FFFFFF", - "info.hover.background": "#38BDF8", - "input.border": "#3F3F46", - "link": "#C6FF4D", - "link.active": "#A6E22E", - "link.hover": "#B2EC3C", - "list.background": "#0A0A0A", - "list.active.background": "#C6FF4D40", - "list.active.border": "#C6FF4D80", - "list.even.background": "#18181B", - "list.head.background": "#18181B", - "list.hover.background": "#27272A", - "muted.background": "#18181B", - "muted.foreground": "#A1A1AA", - "popover.background": "#18181B", - "popover.foreground": "#FAFAFA", - "primary.background": "#C6FF4D", - "primary.active.background": "#65A30D", - "primary.foreground": "#0B0F0C", - "primary.hover.background": "#B2EC3C", - "progress.bar.background": "#C6FF4D", - "ring": "#C6FF4D", - "scrollbar.background": "#0A0A0A00", - "scrollbar.thumb.background": "#3F3F46", - "scrollbar.thumb.hover.background": "#52525B", - "secondary.background": "#18181B", - "secondary.active.background": "#3F3F46", - "secondary.foreground": "#FAFAFA", - "secondary.hover.background": "#27272A", - "selection.background": "#C6FF4D59", - "sidebar.background": "#0A0A0A", - "sidebar.accent.background": "#C6FF4D40", - "sidebar.accent.foreground": "#C6FF4D", - "sidebar.border": "#27272A", - "sidebar.foreground": "#FAFAFA", - "sidebar.primary.background": "#18181B", - "sidebar.primary.foreground": "#FAFAFA", - "skeleton.background": "#27272A", - "slider.background": "#27272A", - "slider.thumb.background": "#C6FF4D", - "success.background": "#2FBF71", - "success.active.background": "#24A35D", - "success.foreground": "#0B0F0C", - "success.hover.background": "#2AB568", - "switch.background": "#3F3F46", - "switch.thumb.background": "#FAFAFA", - "tab.background": "#18181B", - "tab.active.background": "#19200A", - "tab.active.foreground": "#C6FF4D", - "tab.foreground": "#A1A1AA", - "tab_bar.background": "#18181B", - "tab_bar.segmented.background": "#27272A", - "table.background": "#0A0A0A", - "table.active.background": "#C6FF4D40", - "table.active.border": "#C6FF4D80", - "table.even.background": "#18181B", - "table.head.background": "#18181B", - "table.head.foreground": "#A1A1AA", - "table.foot.background": "#18181B", - "table.foot.foreground": "#A1A1AA", - "table.hover.background": "#27272A", - "table.row.border": "#27272A", - "title_bar.background": "#18181B", - "title_bar.border": "#27272A", - "status_bar.background": "#18181B", - "status_bar.border": "#27272A", - "tiles.background": "#0A0A0A", - "warning.background": "#F59E0B", - "warning.active.background": "#D97706", - "warning.foreground": "#18181B", - "warning.hover.background": "#FBBF24", - "overlay": "#00000080", - "window.border": "#27272A", - "base.red": "#EF4444", - "base.green": "#22C55E", - "base.yellow": "#EAB308", - "base.blue": "#3B82F6", - "base.magenta": "#A855F7", - "base.cyan": "#06B6D4" - }, - "highlight": { - "editor.background": "#0A0A0A", - "editor.foreground": "#ABB2BF", - "editor.active_line.background": "#18181B", - "editor.line_number": "#71717A", - "editor.active_line_number": "#FAFAFA", - "editor.invisible": "#71717A66", - "conflict": "#E06C75", - "created": "#98C379", - "deleted": "#E06C75", - "error": "#E06C75", - "error.background": "#3A1D1D", - "error.border": "#E06C75", - "hidden": "#5C6370", - "hint": "#C678DD", - "hint.background": "#2E2740", - "hint.border": "#C678DD", - "ignored": "#5C6370", - "info": "#61AFEF", - "info.background": "#1D2E3A", - "info.border": "#61AFEF", - "modified": "#E5C07B", - "modified.background": "#3A320E", - "predictive": "#5C6370", - "renamed": "#C678DD", - "success": "#98C379", - "success.background": "#1D3A2E", - "unreachable": "#5C6370", - "warning": "#E5C07B", - "warning.background": "#3A320E", - "warning.border": "#E5C07B", - "syntax": { - "attribute": { - "color": "#D19A66" - }, - "boolean": { - "color": "#D19A66" - }, - "comment": { - "color": "#5C6370", - "font_style": "italic" - }, - "comment.doc": { - "color": "#5C6370", - "font_style": "italic" - }, - "constant": { - "color": "#D19A66" - }, - "constructor": { - "color": "#E5C07B" - }, - "embedded": { - "color": "#98C379" - }, - "function": { - "color": "#61AFEF" - }, - "keyword": { - "color": "#C678DD" - }, - "label": { - "color": "#61AFEF" - }, - "link_text": { - "color": "#61AFEF", - "font_style": "underline" - }, - "link_uri": { - "color": "#61AFEF", - "font_style": "italic" - }, - "number": { - "color": "#D19A66" - }, - "operator": { - "color": "#56B6C2" - }, - "preproc": { - "color": "#E5C07B" - }, - "property": { - "color": "#E06C75" - }, - "punctuation": { - "color": "#ABB2BF" - }, - "punctuation.bracket": { - "color": "#ABB2BF" - }, - "punctuation.delimiter": { - "color": "#ABB2BF" - }, - "punctuation.list_marker": { - "color": "#98C379" - }, - "punctuation.special": { - "color": "#ABB2BF" - }, - "string": { - "color": "#98C379" - }, - "string.escape": { - "color": "#56B6C2" - }, - "string.regex": { - "color": "#E06C75" - }, - "string.special": { - "color": "#98C379" - }, - "string.special.symbol": { - "color": "#D19A66" - }, - "tag": { - "color": "#E06C75" - }, - "tag.doctype": { - "color": "#5C6370" - }, - "text.code.span": { - "color": "#ABB2BF" - }, - "text.literal": { - "color": "#98C379" - }, - "title": { - "color": "#61AFEF", - "font_weight": 700 - }, - "type": { - "color": "#E5C07B" - }, - "variable": { - "color": "#E06C75" - }, - "variable.special": { - "color": "#D19A66" - }, - "variant": { - "color": "#E5C07B" - } - } - } - } - ] -} diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 3a16192..e40c0ca 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -76,6 +76,8 @@ pub enum CustomIconName { Filter, GlobalOn, GlobalOff, + GitFile, + GitCommit, GitIssueDone, GitIssueOpen, GitIssueClosed, @@ -88,6 +90,7 @@ pub enum CustomIconName { GitBranch, Tag, Markdown, + Share, } impl IconNamed for CustomIconName { @@ -98,6 +101,8 @@ impl IconNamed for CustomIconName { CustomIconName::Filter => "icons/filter.svg", CustomIconName::GlobalOn => "icons/global-on.svg", CustomIconName::GlobalOff => "icons/global-off.svg", + CustomIconName::GitCommit => "icons/git-commit.svg", + CustomIconName::GitFile => "icons/git-file.svg", CustomIconName::GitIssueDone => "icons/git-issue-done.svg", CustomIconName::GitIssueOpen => "icons/git-issue-open.svg", CustomIconName::GitIssueClosed => "icons/git-issue-close.svg", @@ -110,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/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 408982d..43b4430 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -3,17 +3,24 @@ use std::path::{Path, PathBuf}; use assets::CustomIconName; use gpui::prelude::*; -use gpui::{AnyElement, App, SharedString, Window, div, px}; +use gpui::{ + Anchor, AnyElement, App, ClipboardItem, DismissEvent, ElementId, Entity, Focusable, + SharedString, StyleRefinement, Window, div, px, +}; +use gpui_base::{Button as BaseButton, Popover, Selectable, 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 +29,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 +55,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 +146,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 +226,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 +273,284 @@ pub(super) fn status_badge(status: RepoStatus, cx: &App) -> AnyElement { .into_any_element() } +/// A split dropdown button built on `gpui_base::Popover`: an action element +/// with a separate caret trigger that opens a [`PopupMenu`]. +/// +/// The action and the caret are ordinary elements supplied by the caller, so +/// the look — icons, borders, hover states, sizes — stays fully in the +/// application. The component only owns the popover wiring: opening on caret +/// click, Escape/outside dismissal, focus movement into the menu, and the +/// menu entity's lifecycle. +#[derive(IntoElement)] +pub(super) struct BaseDropdownButton { + id: ElementId, + style: StyleRefinement, + anchor: Anchor, + action: Option, + caret: Option, + menu: Option, +} + +type MenuBuilder = + Box) -> PopupMenu + 'static>; +type CaretBuilder = Box AnyElement>; + +impl BaseDropdownButton { + pub(super) fn new(id: impl Into) -> Self { + Self { + id: id.into(), + style: StyleRefinement::default(), + anchor: Anchor::TopRight, + action: None, + caret: None, + menu: None, + } + } + + /// The action half of the button. It keeps its own icon, label, tooltip + /// and click handler. + pub(super) fn action(mut self, action: impl IntoElement + 'static) -> Self { + self.action = Some(action.into_any_element()); + self + } + + /// The menu built by `builder` — the same signature as gpui-component's + /// `DropdownButton::dropdown_menu`, so existing menu code keeps working. + pub(super) fn dropdown_menu( + mut self, + builder: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> Self { + self.menu = Some(Box::new(builder)); + self + } + + /// Which corner of the caret the menu anchors to. Defaults to + /// [`Anchor::TopRight`], so the menu's right edge lines up with the + /// caret's. + #[allow(dead_code)] // API knob; current call sites use the default anchor. + pub(super) fn anchor(mut self, anchor: impl Into) -> Self { + self.anchor = anchor.into(); + self + } +} + +impl Styled for BaseDropdownButton { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +/// Holds the [`PopupMenu`] entity of one popover between renders. Dismissal +/// drops it, so the menu is rebuilt with fresh items on the next open. +#[derive(Default)] +struct DropdownMenuState { + menu: Option>, +} + +impl RenderOnce for BaseDropdownButton { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + debug_assert!( + self.menu.is_some(), + "a BaseDropdownButton needs a `dropdown_menu`" + ); + + // The popover needs its own id: both the container and the popover register keyed state on this window. + let popover_id = SharedString::from(format!("{}-popover", self.id)); + let anchor = self.anchor; + let menu_state = + window.use_keyed_state(popover_id.clone(), cx, |_, _| DropdownMenuState::default()); + + let caret = self.caret.unwrap_or_else(|| { + let id = popover_id.clone(); + Box::new(move |is_open, _, cx| { + let caret = default_caret(id.clone(), cx); + let selected = caret.is_selected(); + caret.selected(selected || is_open).into_any_element() + }) + }); + + h_flex() + .id(self.id) + .refine_style(&self.style) + .gap_0p5() + .when_some(self.action, |this, action| this.child(action)) + .when_some(self.menu, |this, builder| { + this.child( + Popover::new(popover_id) + .anchor(anchor) + // The menu dismisses itself on outside click or Escape; + // the subscription below closes the popover along with it. + .overlay_closable(false) + .trigger_with(caret) + .content( + move |_, window, cx| match menu_state.read(cx).menu.clone() { + Some(menu) => menu, + None => { + let menu = PopupMenu::build(window, cx, |menu, window, cx| { + builder(menu, window, cx) + }); + menu_state + .update(cx, |state, _| state.menu = Some(menu.clone())); + menu.focus_handle(cx).focus(window, cx); + + let popover_state = cx.entity(); + window + .subscribe(&menu, cx, { + let menu_state = menu_state.clone(); + move |_, _: &DismissEvent, window, cx| { + popover_state.update(cx, |state, cx| { + state.dismiss(window, cx); + }); + menu_state.update(cx, |state, _| { + state.menu = None; + }); + } + }) + .detach(); + + menu.clone() + } + }, + ), + ) + }) + } +} + +/// The default caret: a chevron button the height of a medium button, tinted +/// by the theme, with hover and menu-open states. +fn default_caret(id: impl Into, cx: &App) -> BaseButton { + BaseButton::new(id) + .h(px(32.)) + .px_1p5() + .text_color(cx.theme().muted_foreground) + .hover(|style| style.bg(cx.theme().secondary_hover)) + .styles(|this| { + this.selected(|style| style.bg(cx.theme().secondary_active)) + .disabled(|style| style.opacity(0.5)) + }) + .child(Icon::new(IconName::ChevronDown).xsmall()) +} + +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. +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(); + } + 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 +766,52 @@ 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" + ); + } + + #[test] + fn base_dropdown_button_builder_state() { + let button = BaseDropdownButton::new("issues") + .action(div()) + .anchor(Anchor::BottomLeft) + .dropdown_menu(|menu, _, _| menu); + + assert!(button.action.is_some()); + assert!(button.caret.is_some()); + assert!(button.menu.is_some()); + assert_eq!(button.anchor, Anchor::BottomLeft); + } } diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/repo_detail/issues.rs index 7a4e9a1..fda3ec6 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/repo_detail/issues.rs @@ -1,7 +1,3 @@ -//! Issues panel: a bottom panel listing every issue of the repository with -//! its title, event id, author, age and status, filterable by status via -//! the header's All/Open/Closed filter. - use std::rc::Rc; use assets::CustomIconName; @@ -43,8 +39,8 @@ enum IssueFilter { All, /// Issues whose resolved status is [`RepoStatus::Open`]. Open, - /// Issues whose resolved status is [`RepoStatus::Closed`] or - /// [`RepoStatus::Applied`] (both are "done" states). + /// Issues whose resolved status is + /// [`RepoStatus::Closed`] or [`RepoStatus::Applied`] (both are "done" states). Closed, } @@ -74,8 +70,7 @@ pub struct IssuesView { filter: IssueFilter, /// Per-row heights of the virtual list. item_sizes: Rc>>, - /// Number of rows [`Self::item_sizes`] was built for (the filtered - /// issue count); rebuilt on change. + /// Number of rows [`Self::item_sizes`] was built for (the filtered issue count). issue_len: usize, /// Indices into the store's `issues` matching [`Self::filter`], rebuilt /// every render; the virtual list renders this slice. @@ -350,7 +345,7 @@ impl IssuesView { /// Open the "new issue" dialog: a title and a content input that submit /// through [`RepoStore::open_issue`] when confirmed. -fn open_new_issue_dialog(store: Entity, window: &mut Window, cx: &mut App) { +pub(super) fn open_new_issue_dialog(store: Entity, window: &mut Window, cx: &mut App) { let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title")); let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue…")); diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index dbad492..be0836a 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -8,20 +8,20 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gix::Repository; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, PathPromptOptions, - Pixels, Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size, + Action, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, + PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, Task, WeakEntity, Window, + div, px, relative, size, }; -use gpui_base::Disableable; -use gpui_component::avatar::{Avatar, AvatarGroup}; +use gpui_base::{Button as BaseButton, Disableable}; +use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::combobox::{ Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, }; use gpui_component::searchable_list::SearchableVec; -use gpui_component::tag::Tag; use gpui_component::tree::TreeState; use gpui_component::{ - ActiveTheme, Icon, IconName, Selectable, Sizable, StyledExt, VirtualListScrollHandle, h_flex, + ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, }; use signed_core::Announcement; @@ -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,15 +41,18 @@ 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, }; use commits::COMMIT_ROW_HEIGHT; use diff::CommitDiffView; -use helpers::{TreeItemSeed, build_tree_items, is_markdown_path, tree_items}; -use issues::IssuesView; -use pull_requests::PullRequestsView; +use helpers::{ + BaseDropdownButton, 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}; /// What kind of ref the header selectors switch to. #[derive(Clone, Copy, PartialEq, Eq)] @@ -58,6 +63,16 @@ enum RefKind { Tag, } +/// Header actions dispatched by the dropdown menus of the header buttons. +#[derive(Clone, Action, PartialEq, Eq)] +#[action(namespace = repo_detail, no_json)] +enum RepoAction { + /// Open the "new issue" dialog. + NewIssue, + /// Open the "new pull request" dialog. + NewPR, +} + /// Everything loaded from the local clone for the explorer: the tree seeds, /// README, refs and HEAD commit. Computed on a background thread (see /// [`load_repo_data`]) and applied on the main thread. @@ -1029,16 +1044,28 @@ impl RepoDetailView { fn render_header(&self, cx: &mut Context) -> AnyElement { let store = self.store.read(cx); let announcement = store.announcement.as_ref().unwrap_or(&self.initial); - let issue_count = store.issue_count(); - let pull_request_count = store.pull_request_count(); + let issue_count = SharedString::from(store.issue_count().to_string()); + let pr_count = SharedString::from(store.pull_request_count().to_string()); 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); let worktree_empty = self.switching_ref || self.worktree.is_none(); v_flex() + .on_action( + cx.listener(|this, action: &RepoAction, window, cx| match action { + RepoAction::NewIssue => { + open_new_issue_dialog(this.store.clone(), window, cx); + } + RepoAction::NewPR => { + open_new_pull_request_dialog(this.store.clone(), window, cx); + } + }), + ) .px_4() .pb_4() .w_full() @@ -1048,20 +1075,29 @@ impl RepoDetailView { .child( h_flex() .w_full() - .gap_2() + .gap_4() .items_start() .justify_between() .child( v_flex() .flex_1() .min_w_0() - .child(div().font_semibold().child(name)) + .gap_1() + .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.25)) .text_ellipsis() .child(description), ) @@ -1069,7 +1105,7 @@ impl RepoDetailView { h_flex() .mt_2() .w_full() - .gap_2() + .gap_0p5() .child( div() .text_xs() @@ -1086,42 +1122,115 @@ impl RepoDetailView { .gap_2() .justify_end() .child( - Button::new("issues") - .child( - h_flex() - .gap_2() - .text_sm() - .child(SharedString::from("Issues")) - .child(Tag::new().xsmall().child(SharedString::from( - issue_count.to_string(), - ))), + BaseDropdownButton::new("issues") + .action( + BaseButton::new("issues-open") + .child( + h_flex() + .h_8() + .px_2() + .gap_1() + .rounded(cx.theme().radius) + .bg(cx.theme().secondary) + .hover(|this| { + this.bg(cx.theme().secondary_hover) + }) + .text_sm() + .text_color(cx.theme().secondary_foreground) + .child(Icon::new(CustomIconName::GitIssueDone)) + .child("Issues") + .child( + div() + .mx_1() + .h_5() + .w_px() + .bg(cx.theme().border.darken(0.1)), + ) + .child(issue_count), + ) + .on_click(cx.listener(|this, _event, window, cx| { + this.open_issue_detail(window, cx); + })), ) - .outline() - .on_click(cx.listener(|this, _event, window, cx| { - this.open_issue_detail(window, cx); - })), + .dropdown_menu(|menu, _, _| { + menu.menu_element_with_icon( + IconName::Plus, + Box::new(RepoAction::NewIssue), + |_, _| div().text_xs().child("New issue"), + ) + }), ) .child( - Button::new("prs") - .child( - h_flex() - .gap_2() - .text_sm() - .child(SharedString::from("Pull Requests")) - .child(Tag::new().xsmall().child(SharedString::from( - pull_request_count.to_string(), - ))), + BaseDropdownButton::new("prs") + .action( + BaseButton::new("prs-open") + .child( + h_flex() + .h_8() + .px_2() + .gap_1() + .rounded(cx.theme().radius) + .bg(cx.theme().secondary) + .hover(|this| { + this.bg(cx.theme().secondary_hover) + }) + .text_sm() + .text_color(cx.theme().secondary_foreground) + .child(Icon::new( + CustomIconName::GitPullRequest, + )) + .child("Pull Requests") + .child( + div() + .mx_1() + .h_5() + .w_px() + .bg(cx.theme().border.darken(0.1)), + ) + .child(pr_count), + ) + .on_click(cx.listener(|this, _event, window, cx| { + this.open_pull_request_detail(window, cx); + })), ) - .outline() - .on_click(cx.listener(|this, _event, window, cx| { - this.open_pull_request_detail(window, cx); - })), + .dropdown_menu(|menu, _, _| { + menu.menu_element_with_icon( + IconName::Plus, + Box::new(RepoAction::NewPR), + |_, _| div().text_xs().child("New PR"), + ) + }), ) .child( - Button::new("link") - .icon(IconName::ExternalLink) - .tooltip("Open in gitworkshop.dev") - .secondary(), + BaseDropdownButton::new("share") + .action( + 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("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") @@ -1141,27 +1250,69 @@ impl RepoDetailView { .items_center() .gap_2() .child( - Button::new("files-tab") - .label("Files") + BaseButton::new("files-tab") + .flex() + .items_center() + .h_8() + .px_2() + .gap_2() + .child( + h_flex() + .gap_1() + .text_sm() + .child(Icon::new(CustomIconName::GitFile).small()) + .child("Files"), + ) + .text_color(cx.theme().button_foreground) + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().button_hover)) + .active(|this| this.bg(cx.theme().button_active)) .selected(self.active_tab == 0) - .toggled(self.active_tab == 0) + .when(self.active_tab == 0, |this| { + this.bg(cx.theme().button_active) + }) .on_click(cx.listener(|this, _event, _window, cx| { this.active_tab = 0; cx.notify(); })), ) .child( - Button::new("commits-tab") - .label("Commits") - .selected(self.active_tab == 1) - .toggled(self.active_tab == 1) + BaseButton::new("commits-tab") + .flex() + .items_center() + .h_8() + .px_2() + .gap_2() + .child( + h_flex() + .gap_1() + .text_sm() + .child(Icon::new(CustomIconName::GitCommit).small()) + .child("Commits"), + ) .when_some(commits_count, |this, count| { this.child( - Tag::secondary() - .xsmall() + h_flex() + .justify_center() + .px_1() + .py_0p5() + .min_w_4() + .text_size(px(8.)) + .bg(cx.theme().muted) + .text_color(cx.theme().muted_foreground) + .rounded(cx.theme().radius) + .line_height(relative(1.)) .child(SharedString::from(count.to_string())), ) }) + .text_color(cx.theme().button_foreground) + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().button_hover)) + .active(|this| this.bg(cx.theme().button_active)) + .selected(self.active_tab == 1) + .when(self.active_tab == 1, |this| { + this.bg(cx.theme().button_active) + }) .on_click(cx.listener(|this, _event, _window, cx| { this.active_tab = 1; cx.notify(); @@ -1243,8 +1394,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); @@ -1254,7 +1403,7 @@ impl RepoDetailView { .maintainers .iter() .copied() - .filter(|key| *key != announcement.owner && seen.insert(*key)) + .filter(|key| key != &announcement.owner && seen.insert(*key)) .collect(); let owner = profile_store.read(cx).get(&announcement.owner); @@ -1264,31 +1413,32 @@ impl RepoDetailView { h_flex() .w_full() .gap_3() - .items_center() .child( - h_flex() - .gap_1() - .items_center() - .child( - Avatar::new() - .name(owner_name.clone()) - .when_some(owner_picture, |this, url| this.src(url)) - .rounded(cx.theme().radius) - .small(), - ) - .child(div().text_xs().whitespace_nowrap().child(owner_name)), + Button::new("maintainers").compact().ghost().child( + h_flex() + .gap_2() + .child( + h_flex() + .gap_1() + .child( + Avatar::new() + .name(owner_name.clone()) + .when_some(owner_picture, |this, url| this.src(url)) + .rounded(cx.theme().radius) + .small(), + ) + .child(div().text_xs().whitespace_nowrap().child(owner_name)), + ) + .when(!rest.is_empty(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(format!("+{}", rest.len()))), + ) + }), + ), ) - .when(!rest.is_empty(), |this| { - this.child(AvatarGroup::new().small().limit(5).ellipsis().children( - rest.into_iter().map(|key| { - let profile = profile_store.read(cx).get(&key); - Avatar::new() - .name(profile.name()) - .when_some(profile.picture(), |this, url| this.src(url)) - .rounded(cx.theme().radius) - }), - )) - }) .into_any_element() } } diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/repo_detail/pull_requests.rs index fc72b91..02c29d4 100644 --- a/crates/workspace/src/views/repo_detail/pull_requests.rs +++ b/crates/workspace/src/views/repo_detail/pull_requests.rs @@ -435,7 +435,11 @@ impl PullRequestsView { /// Open the "new pull request" dialog: a title, an optional description and /// a patch input that submit through [`RepoStore::open_pull_request`] when /// confirmed. -fn open_new_pull_request_dialog(store: Entity, window: &mut Window, cx: &mut App) { +pub(super) fn open_new_pull_request_dialog( + store: Entity, + window: &mut Window, + cx: &mut App, +) { let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title")); let description = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change…")); 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( diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 4ef5bf3..137fb1e 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -32,21 +32,21 @@ fn main() { } } - let light_theme = registry.themes().get("Signed Light").cloned(); - let dark_theme = registry.themes().get("Signed Dark").cloned(); + //let light_theme = registry.themes().get("Signed Light").cloned(); + //let dark_theme = registry.themes().get("Signed Dark").cloned(); let theme = Theme::global_mut(cx); theme.radius = px(2.); theme.radius_lg = px(6.); theme.focus_ring = false; theme.shadow = false; - if let Some(light) = light_theme { - theme.light_theme = light; - } + //if let Some(light) = light_theme { + // theme.light_theme = light; + //} - if let Some(dark) = dark_theme { - theme.dark_theme = dark; - } + //if let Some(dark) = dark_theme { + // theme.dark_theme = dark; + //} // Sync the theme with the system appearance Theme::sync_system_appearance(None, cx);