feat: add repo about dialog (#5)

Reviewed-on: https://git.reya.su/reya/signed/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
2026-08-28 03:26:22 +00:00
parent 932679311c
commit 7e421a0f3e
19 changed files with 863 additions and 696 deletions
+11 -1
View File
@@ -1,5 +1,6 @@
use gpui::prelude::*;
use gpui::{App, Pixels, Window, div, px};
use gpui::{App, Pixels, StyleRefinement, Window, div, px};
use gpui_base::StyledExt;
use gpui_component::{ActiveTheme, Colorize};
/// Number of rows and columns in the pixel grid.
@@ -19,6 +20,7 @@ const MIN_FILLED: usize = 5;
pub(crate) struct PixelAvatar {
seed: u64,
size: Pixels,
style: StyleRefinement,
}
impl PixelAvatar {
@@ -28,10 +30,17 @@ impl PixelAvatar {
Self {
seed: fnv1a(seed.as_ref().as_bytes()),
size: px(16.),
style: StyleRefinement::default(),
}
}
}
impl Styled for PixelAvatar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for PixelAvatar {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
@@ -64,6 +73,7 @@ impl RenderOnce for PixelAvatar {
}
div()
.refine_style(&self.style)
.grid()
.grid_cols(GRID_SIZE as u16)
.grid_rows(GRID_SIZE as u16)
@@ -0,0 +1,232 @@
use gpui::prelude::*;
use gpui::{AnyElement, App, SharedString, Window, div, px};
use gpui_component::avatar::Avatar;
use gpui_component::clipboard::Clipboard;
use gpui_component::{ActiveTheme, Sizable, StyledExt, WindowExt, h_flex, v_flex};
use nostr::prelude::PublicKey;
use signed_core::Announcement;
use signed_state::ProfileStore;
use super::helpers::middle_truncate;
/// Open the "About" dialog: every field of the repository's announcement
/// event (NIP-34, kind 30617), as parsed into [`Announcement`].
pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |dialog, _window, cx| {
let announcement = announcement.clone();
dialog
.w(px(500.))
.h(px(600.))
.keyboard(true)
.close_button(true)
.title("About")
.child(announcement_rows(&announcement, cx))
});
}
/// The announcement's fields as labeled rows; hex identifiers carry a copy
/// button, multi-value tags one line per value.
fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
let mut rows: Vec<AnyElement> = Vec::new();
rows.push(row(
"Name",
text(
announcement
.name
.clone()
.unwrap_or_else(|| SharedString::from("")),
),
cx,
));
rows.push(row(
"Description",
text(
announcement
.description
.clone()
.unwrap_or_else(|| SharedString::from("")),
),
cx,
));
if !announcement.web.is_empty() {
rows.push(row(
"Web",
list(
"about-web",
announcement.web.iter().map(|url| url.to_string()),
cx,
),
cx,
));
}
if let Some(euc) = &announcement.euc {
rows.push(row(
"Earliest Commit",
copy_value("about-euc", euc.clone(), cx),
cx,
));
}
if let Some(upstream) = &announcement.upstream {
rows.push(row(
"Upstream",
text(SharedString::from(upstream.clone())),
cx,
));
}
if !announcement.hashtags.is_empty() {
rows.push(row(
"Hashtags",
text(SharedString::from(announcement.hashtags.join(", "))),
cx,
));
}
if !announcement.clone.is_empty() {
rows.push(row(
"Clone URLs",
list(
"about-clone",
announcement.clone.iter().map(|url| url.to_string()),
cx,
),
cx,
));
}
if !announcement.relays.is_empty() {
rows.push(row(
"Grasp Relays",
list(
"about-relays",
announcement.relays.iter().map(|url| url.to_string()),
cx,
),
cx,
));
}
if !announcement.maintainers.is_empty() {
rows.push(row(
"Maintainers",
maintainers(&announcement.maintainers, cx),
cx,
));
}
v_flex().gap_3().w_full().children(rows).into_any_element()
}
/// One info row: a small muted label above the value.
fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
v_flex()
.gap_1()
.min_w_0()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child(label),
)
.child(value)
.into_any_element()
}
/// Plain text value, wrapping within the dialog.
fn text(value: SharedString) -> AnyElement {
div()
.text_sm()
.w_full()
.min_w_0()
.child(value)
.into_any_element()
}
/// A mono-spaced value with a copy button, for hex identifiers.
fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
h_flex()
.gap_2()
.items_center()
.min_w_0()
.child(
div()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.min_w_0()
.child(SharedString::from(value.clone())),
)
.child(Clipboard::new(id).tooltip("Copy").value(value))
.into_any_element()
}
/// One row per maintainer: avatar and display name (falling back to a
/// shortened npub), with a copy button for the full pubkey.
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement {
let profile_store = ProfileStore::global(cx);
v_flex()
.gap_2()
.min_w_0()
.children(maintainers.iter().map(|pubkey| {
let profile = profile_store.read(cx).get(pubkey);
let name = profile.name();
let picture = profile.picture();
h_flex()
.gap_2()
.items_center()
.min_w_0()
.child(
Avatar::new()
.name(name.clone())
.when_some(picture, |this, url| this.src(url))
.rounded(cx.theme().radius)
.small(),
)
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.text_sm()
.child(name),
)
}))
.into_any_element()
}
/// One row per item of a multi-value tag: the value is truncated to a single
/// line, with a copy button that copies the full value.
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
v_flex()
.gap_2()
.min_w_0()
.children(items.into_iter().enumerate().map(|(ix, item)| {
h_flex()
.gap_2()
.items_center()
.min_w_0()
.child(
h_flex()
.h_5()
.px_1()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.text_sm()
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.child(SharedString::from(middle_truncate(&item, 28, 16))),
)
.child(
Clipboard::new(format!("{id}-{ix}"))
.tooltip("Copy")
.value(item),
)
.into_any_element()
}))
.into_any_element()
}
+347 -15
View File
@@ -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<TreeItemSeed>,
}
/// Convert tree seeds into [`TreeItem`]s, expanding every folder when
/// `expand_folders` is set.
/// Convert tree seeds into [`TreeItem`]s, expanding every folder
/// when `expand_folders` is set.
///
/// The commit diff explorer shows only changed files, which is typically a
/// handful of paths, so its folders start expanded; the worktree explorer
/// starts collapsed instead.
/// The commit diff explorer shows only changed files,
/// which is typically a handful of paths, so its folders start expanded;
/// the worktree explorer starts collapsed instead.
pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
let mut item = TreeItem::new(seed.id, seed.label);
@@ -48,8 +55,8 @@ pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<
.collect()
}
/// One row of a file tree: icon + name, indented by depth. Clicking a file
/// runs `on_click`; folders expand/collapse via the tree itself.
/// One row of a file tree: icon + name, indented by depth.
/// Clicking a file runs `on_click`; folders expand/collapse via the tree itself.
pub(super) fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
where
F: Fn(&mut Window, &mut App) + 'static,
@@ -139,8 +146,7 @@ pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
/// The markdown fence language for a file path, or `None` for plain text.
///
/// Names are chosen so `gpui_component`'s highlighter can resolve them
/// (`highlighter::Language::from_name` accepts short aliases such as `rs`
/// and `js`).
/// (`highlighter::Language::from_name` accepts short aliases such as `rs` and `js`).
pub(super) fn code_language(path: &str) -> Option<&'static str> {
let name = Path::new(path)
.file_name()
@@ -220,8 +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<AnyElement>,
caret: Option<CaretBuilder>,
menu: Option<MenuBuilder>,
}
type MenuBuilder =
Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>;
type CaretBuilder = Box<dyn FnOnce(bool, &Window, &App) -> AnyElement>;
impl BaseDropdownButton {
pub(super) fn new(id: impl Into<ElementId>) -> 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>) -> 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<Anchor>) -> 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<Entity<PopupMenu>>,
}
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<ElementId>, 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:<pubkey>:<repo-id>`.
pub(super) coordinate: String,
/// `https://gitworkshop.dev/<naddr>`
pub(super) gitworkshop: String,
/// `https://ditto.pub/<naddr>`
pub(super) ditto: String,
}
impl ShareTargets {
pub(super) fn from_announcement(announcement: &Announcement) -> Self {
let addr = announcement.addr();
let coordinate = addr.to_string();
let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned())
.to_bech32()
.expect("a complete coordinate always encodes to naddr");
Self {
naddr: naddr.clone(),
event_id: announcement.event_id.to_bech32().unwrap(),
coordinate,
gitworkshop: format!("https://gitworkshop.dev/{naddr}"),
ditto: format!("https://ditto.pub/{naddr}"),
}
}
/// The share dropdown menu: one row per target, each showing a compact
/// label while the copy button (and row click) copy the full value.
pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu {
menu.min_w(px(340.))
.item(share_menu_row(
"copy-gitworkshop",
"GitWorkshop",
truncate_naddr_link(&self.gitworkshop, 4),
self.gitworkshop.clone(),
))
.item(share_menu_row(
"copy-ditto",
"Ditto",
truncate_naddr_link(&self.ditto, 4),
self.ditto.clone(),
))
.item(share_menu_row(
"copy-event-id",
"Event ID",
middle_truncate(&self.event_id, 10, 10),
self.event_id.clone(),
))
.item(share_menu_row(
"copy-coordinate",
"Coordinate",
middle_truncate(&self.coordinate, 10, 10),
self.coordinate.clone(),
))
}
}
/// One row of the share menu: a small title on top of the compact label,
/// with a copy button that flips to a check while the value is on the
/// clipboard. Clicking the row text copies and dismisses the menu; the copy
/// button stops propagation, so the menu stays open for further copies.
/// Both copy `copy`, never the truncated label.
pub(super) fn share_menu_row(
id: &'static str,
title: &'static str,
label: String,
copy: String,
) -> PopupMenuItem {
let row_copy = copy.clone();
PopupMenuItem::element(move |_window, _cx| {
let button_copy = copy.clone();
h_flex()
.flex_1()
.gap_2()
.items_end()
.child(
h_flex()
.flex_1()
.gap_1()
.text_xs()
.child(div().flex_shrink_0().w_20().font_semibold().child(title))
.child(div().flex_1().text_ellipsis().child(label.clone())),
)
.child(Clipboard::new(id).tooltip("Copy").value(button_copy))
})
.on_click(move |_, _, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(row_copy.clone()));
})
}
/// `[head chars]...[tail chars]` middle truncation; the value is left alone
/// when it is too short for the ellipsis to save space.
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 `<url>/naddr1...[last tail chars]`, e.g.
/// `https://gitworkshop.dev/naddr1...abcd`. Only the label is shortened;
/// the value to be copied stays the full URL.
fn truncate_naddr_link(url: &str, tail: usize) -> String {
let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else {
return url.to_string();
};
if url.len() - end <= tail + 3 {
return url.to_string();
}
format!("{}...{}", &url[..end], &url[url.len() - tail..])
}
/// Width of one line-number gutter in a diff row.
pub(super) const GUTTER_WIDTH: f32 = 44.;
/// Height of one row in a virtual diff list.
@@ -482,4 +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);
}
}
@@ -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<Vec<Size<Pixels>>>,
/// 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<RepoStore>, window: &mut Window, cx: &mut App) {
pub(super) fn open_new_issue_dialog(store: Entity<RepoStore>, 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…"));
+229 -79
View File
@@ -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<Self>) -> 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<Self>) -> 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()
}
}
@@ -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<RepoStore>, window: &mut Window, cx: &mut App) {
pub(super) fn open_new_pull_request_dialog(
store: Entity<RepoStore>,
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…"));
+1 -5
View File
@@ -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(