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);