From 5c6ab8871075b1e81a601539547c0a87e24f523b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 1 Sep 2026 04:18:23 +0000 Subject: [PATCH] chore: move some components to custom ui crate (#10) Reviewed-on: https://git.reya.su/reya/signed/pulls/10 --- Cargo.lock | 15 + crates/dock/Cargo.toml | 1 + crates/dock/src/lib.rs | 56 +-- crates/dock/src/tab_panel.rs | 4 +- crates/signed_ui/Cargo.toml | 17 + crates/signed_ui/src/copy_row.rs | 67 ++++ crates/signed_ui/src/dropdown_button.rs | 185 +++++++++ .../src/image_cache.rs | 0 crates/signed_ui/src/lib.rs | 52 +++ crates/signed_ui/src/nav_item.rs | 78 ++++ .../src/pixel_avatar.rs | 4 +- crates/signed_ui/src/placeholder.rs | 19 + crates/signed_ui/src/segment_button.rs | 179 +++++++++ crates/signed_ui/src/status_badge.rs | 53 +++ crates/signed_ui/src/title_bar.rs | 55 +++ crates/signed_ui/src/tree_row.rs | 43 +++ crates/signed_ui/src/user_avatar.rs | 48 +++ crates/signed_ui/src/util.rs | 38 ++ crates/workspace/Cargo.toml | 1 + crates/workspace/src/lib.rs | 7 +- .../workspace/src/views/repo_detail/about.rs | 14 +- .../src/views/repo_detail/browser.rs | 8 +- .../src/views/repo_detail/commits.rs | 6 +- .../workspace/src/views/repo_detail/diff.rs | 4 +- .../src/views/repo_detail/helpers.rs | 363 +----------------- .../src/views/repo_detail/issue_detail.rs | 31 +- .../workspace/src/views/repo_detail/issues.rs | 128 +----- crates/workspace/src/views/repo_detail/mod.rs | 65 +--- .../views/repo_detail/pull_request_detail.rs | 32 +- .../src/views/repo_detail/pull_requests.rs | 194 ++-------- crates/workspace/src/views/repo_list.rs | 28 +- crates/workspace/src/views/sidebar/mod.rs | 95 +---- crates/workspace/src/workspace.rs | 2 +- 33 files changed, 954 insertions(+), 938 deletions(-) create mode 100644 crates/signed_ui/Cargo.toml create mode 100644 crates/signed_ui/src/copy_row.rs create mode 100644 crates/signed_ui/src/dropdown_button.rs rename crates/{workspace => signed_ui}/src/image_cache.rs (100%) create mode 100644 crates/signed_ui/src/lib.rs create mode 100644 crates/signed_ui/src/nav_item.rs rename crates/{workspace => signed_ui}/src/pixel_avatar.rs (98%) create mode 100644 crates/signed_ui/src/placeholder.rs create mode 100644 crates/signed_ui/src/segment_button.rs create mode 100644 crates/signed_ui/src/status_badge.rs create mode 100644 crates/signed_ui/src/title_bar.rs create mode 100644 crates/signed_ui/src/tree_row.rs create mode 100644 crates/signed_ui/src/user_avatar.rs create mode 100644 crates/signed_ui/src/util.rs diff --git a/Cargo.lock b/Cargo.lock index 825ceea..928662a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1800,6 +1800,7 @@ dependencies = [ "gpui", "gpui-base", "gpui-component", + "signed_ui", ] [[package]] @@ -7934,6 +7935,19 @@ dependencies = [ "utils", ] +[[package]] +name = "signed_ui" +version = "1.0.0" +dependencies = [ + "assets", + "futures", + "gpui", + "gpui-base", + "gpui-component", + "log", + "signed_core", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -10763,6 +10777,7 @@ dependencies = [ "signed_core", "signed_git", "signed_state", + "signed_ui", "utils", ] diff --git a/crates/dock/Cargo.toml b/crates/dock/Cargo.toml index 6009a56..b6c512e 100644 --- a/crates/dock/Cargo.toml +++ b/crates/dock/Cargo.toml @@ -9,6 +9,7 @@ publish.workspace = true gpui.workspace = true gpui-component.workspace = true gpui-base.workspace = true +signed_ui = { path = "../signed_ui" } [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/dock/src/lib.rs b/crates/dock/src/lib.rs index b919f15..2a566b2 100644 --- a/crates/dock/src/lib.rs +++ b/crates/dock/src/lib.rs @@ -9,10 +9,7 @@ //! Everything `gpui_component::dock` exports is re-exported here, so the app //! keeps importing the dock from a single place. -use gpui::{ - App, Div, InteractiveElement as _, MouseButton, Pixels, Stateful, - StatefulInteractiveElement as _, Window, WindowControlArea, px, -}; +use gpui::{Pixels, px}; mod dock_area; mod invalid_panel; @@ -44,54 +41,3 @@ pub(crate) fn t(key: &'static str) -> &'static str { _ => key, } } - -/// State used to move the window when the title bar area is dragged. -struct WindowDragState { - should_move: bool, -} - -/// Make an element behave like a window title bar: dragging it moves the -/// window, and double-clicking zooms the window (or performs the platform's -/// default title-bar double-click action on macOS). -/// -/// Only the bar's non-interactive areas should get this — tabs are draggable -/// (to reorder panels) and must not move the window. -pub fn title_bar_drag_handlers( - this: Stateful
, - window: &mut Window, - cx: &mut App, -) -> Stateful
{ - let state = window.use_state(cx, |_, _| WindowDragState { should_move: false }); - - this.window_control_area(WindowControlArea::Drag) - .on_mouse_down_out(window.listener_for(&state, |state, _, _, _| { - state.should_move = false; - })) - .on_mouse_down( - MouseButton::Left, - window.listener_for(&state, |state, _, _, _| { - state.should_move = true; - }), - ) - .on_mouse_up( - MouseButton::Left, - window.listener_for(&state, |state, _, _, _| { - state.should_move = false; - }), - ) - .on_mouse_move(window.listener_for(&state, |state, _, window, _| { - if state.should_move { - state.should_move = false; - window.start_window_move(); - } - })) - .on_click(|event, window, _| { - if event.click_count() == 2 { - if cfg!(target_os = "macos") { - window.titlebar_double_click(); - } else { - window.zoom_window(); - } - } - }) -} diff --git a/crates/dock/src/tab_panel.rs b/crates/dock/src/tab_panel.rs index bcf135a..c6ed188 100644 --- a/crates/dock/src/tab_panel.rs +++ b/crates/dock/src/tab_panel.rs @@ -31,11 +31,11 @@ use gpui_component::menu::DropdownMenu as _; use gpui_component::{ ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex, }; +use signed_ui::title_bar_drag_handlers; use crate::dock_area::SkinShared; use crate::{ - ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, title_bar_drag_handlers, - window_controls, + ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, window_controls, }; /// The size the styled drag preview occupies, reported to base so a drop diff --git a/crates/signed_ui/Cargo.toml b/crates/signed_ui/Cargo.toml new file mode 100644 index 0000000..d7a13a6 --- /dev/null +++ b/crates/signed_ui/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "signed_ui" +description = "Reusable UI components and elements for Signed, built on gpui-base and gpui-component." +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +assets = { path = "../assets" } +signed_core = { path = "../signed_core" } + +gpui.workspace = true +gpui-base.workspace = true +gpui-component.workspace = true + +futures.workspace = true +log.workspace = true diff --git a/crates/signed_ui/src/copy_row.rs b/crates/signed_ui/src/copy_row.rs new file mode 100644 index 0000000..8610c16 --- /dev/null +++ b/crates/signed_ui/src/copy_row.rs @@ -0,0 +1,67 @@ +use gpui::prelude::*; +use gpui::{App, ClipboardItem, Div, ElementId, SharedString, div}; +use gpui_component::clipboard::Clipboard; +use gpui_component::menu::PopupMenuItem; +use gpui_component::{ActiveTheme, StyledExt, h_flex}; + +/// A muted command row with a copy button: the value in a mono-friendly, +/// truncated line, with a [`Clipboard`] button copying the full value. +pub fn copy_row(copy_id: E, command: &SharedString, cx: &App) -> Div +where + E: Into, +{ + h_flex() + .h_8() + .w_full() + .px_2() + .gap_2() + .items_center() + .bg(cx.theme().muted) + .rounded(cx.theme().radius) + .child( + h_flex() + .flex_1() + .min_w_0() + .truncate() + .text_ellipsis() + .text_xs() + .child(command.clone()), + ) + .child( + Clipboard::new(copy_id) + .tooltip("Copy") + .value(command.clone()), + ) +} + +/// One row of a copy menu: a small title above the compact label, with +/// a copy button that flips to a check while the value is on the clipboard. +/// Clicking the row copies and dismisses the menu; the copy button stops +/// propagation so the menu stays open. Both copy `copy`, never the label. +pub fn menu_copy_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())); + }) +} diff --git a/crates/signed_ui/src/dropdown_button.rs b/crates/signed_ui/src/dropdown_button.rs new file mode 100644 index 0000000..f83aa4d --- /dev/null +++ b/crates/signed_ui/src/dropdown_button.rs @@ -0,0 +1,185 @@ +use gpui::prelude::*; +use gpui::{ + Anchor, AnyElement, App, DismissEvent, ElementId, Entity, Focusable, SharedString, + StyleRefinement, Window, px, +}; +use gpui_base::{Button as BaseButton, Popover, Selectable, StyledExt}; +use gpui_component::menu::PopupMenu; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex}; + +/// 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 caller-supplied elements, so the look stays +/// in the application; this component only owns the popover wiring. +#[derive(IntoElement)] +pub struct DropdownButton { + id: ElementId, + style: StyleRefinement, + anchor: Anchor, + action: Option, + caret: Option, + menu: Option, +} + +type MenuBuilder = + Box) -> PopupMenu + 'static>; +type CaretBuilder = Box AnyElement>; + +impl DropdownButton { + pub 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 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 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 fn anchor(mut self, anchor: impl Into) -> Self { + self.anchor = anchor.into(); + self + } +} + +impl Styled for DropdownButton { + 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 DropdownButton { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + debug_assert!( + self.menu.is_some(), + "a DropdownButton 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()) +} + +#[cfg(test)] +mod tests { + use gpui::div; + + use super::*; + + #[test] + fn dropdown_button_builder_state() { + let button = DropdownButton::new("issues") + .action(div()) + .anchor(Anchor::BottomLeft) + .dropdown_menu(|menu, _, _| menu); + + assert!(button.action.is_some()); + // The caret is `None` until render, which falls back to the default. + assert!(button.caret.is_none()); + assert!(button.menu.is_some()); + assert_eq!(button.anchor, Anchor::BottomLeft); + } +} diff --git a/crates/workspace/src/image_cache.rs b/crates/signed_ui/src/image_cache.rs similarity index 100% rename from crates/workspace/src/image_cache.rs rename to crates/signed_ui/src/image_cache.rs diff --git a/crates/signed_ui/src/lib.rs b/crates/signed_ui/src/lib.rs new file mode 100644 index 0000000..d574ad8 --- /dev/null +++ b/crates/signed_ui/src/lib.rs @@ -0,0 +1,52 @@ +//! Reusable UI components and elements for Signed. +//! +//! Everything here is presentation-only: the components read theme tokens +//! through [`gpui_component::ActiveTheme`] and render with plain GPUI +//! elements, so any view in the app can use them without depending on app +//! state. They are built on the unstyled `gpui-base` primitives and the +//! styled `gpui-component` library. +//! +//! The crate is organized by component: +//! +//! - [`PixelAvatar`] — deterministic, offline "pixel art" avatar +//! - [`NavItem`] — sidebar navigation row (leading element + label + suffix) +//! - [`DropdownButton`] — split button: an action plus a caret that opens a +//! [`PopupMenu`], wired through `gpui_base::Popover` +//! - [`SegmentButton`] / [`CountBadge`] — segmented filter/tab button with an +//! optional count badge +//! - [`UserAvatar`] — user picture avatar with a name-initials fallback +//! - [`status_badge`] — NIP-34 issue/PR status badge +//! - [`placeholder`] — centered muted placeholder message +//! - [`copy_row`] / [`menu_copy_row`] — rows with a copy-to-clipboard button +//! - [`tree_row`] — one row of a file tree +//! - [`title_bar_drag_handlers`] — make an element behave like a window +//! title bar (drag moves the window, double-click zooms) +//! - [`image_cache`] — per-view LRU image cache provider +//! - [`middle_truncate`] — `[head]...[tail]` string truncation + +mod dropdown_button; +mod nav_item; +mod pixel_avatar; +mod placeholder; +mod segment_button; +mod status_badge; +mod title_bar; +mod tree_row; +mod user_avatar; + +pub mod copy_row; +pub mod image_cache; +pub mod util; + +pub use copy_row::{copy_row, menu_copy_row}; +pub use dropdown_button::DropdownButton; +pub use image_cache::{MAX_IMAGES, image_cache}; +pub use nav_item::NavItem; +pub use pixel_avatar::PixelAvatar; +pub use placeholder::placeholder; +pub use segment_button::{CountBadge, SegmentButton}; +pub use status_badge::status_badge; +pub use title_bar::title_bar_drag_handlers; +pub use tree_row::tree_row; +pub use user_avatar::UserAvatar; +pub use util::middle_truncate; diff --git a/crates/signed_ui/src/nav_item.rs b/crates/signed_ui/src/nav_item.rs new file mode 100644 index 0000000..69195e6 --- /dev/null +++ b/crates/signed_ui/src/nav_item.rs @@ -0,0 +1,78 @@ +use gpui::prelude::*; +use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div}; +use gpui_component::{ActiveTheme, StyledExt, h_flex}; + +/// A single navigation entry in a sidebar: an arbitrary leading element +/// (an icon, avatar, ...) and a text label with a hover highlight, +/// an optional trailing suffix (e.g. a status icon) and an optional click handler. +#[allow(clippy::type_complexity)] +#[derive(IntoElement)] +pub struct NavItem { + id: ElementId, + style: StyleRefinement, + icon: gpui::AnyElement, + label: SharedString, + /// Trailing element rendered at the right edge of the row, after the (ellipsized) label. + suffix: Option, + on_click: Option>, +} + +impl NavItem { + pub fn new(id: I, label: L, icon: N) -> Self + where + I: Into, + L: Into, + N: IntoElement, + { + Self { + id: id.into(), + icon: icon.into_any_element(), + label: label.into(), + style: StyleRefinement::default(), + suffix: None, + on_click: None, + } + } + + /// A trailing element rendered at the right edge of the row + pub fn suffix(mut self, suffix: impl IntoElement) -> Self { + self.suffix = Some(suffix.into_any_element()); + self + } + + pub fn on_click( + mut self, + listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Box::new(listener)); + self + } +} + +impl RenderOnce for NavItem { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .id(self.id) + .refine_style(&self.style) + .px_2() + .py_1() + .w_full() + .gap_2() + .rounded(cx.theme().radius) + .child(self.icon) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .whitespace_nowrap() + .text_ellipsis() + .child(self.label), + ) + .when_some(self.suffix, |this, suffix| { + this.child(div().flex_shrink_0().child(suffix)) + }) + .hover(|this| this.bg(cx.theme().list_hover)) + .when_some(self.on_click, |this, listener| this.on_click(listener)) + } +} diff --git a/crates/workspace/src/pixel_avatar.rs b/crates/signed_ui/src/pixel_avatar.rs similarity index 98% rename from crates/workspace/src/pixel_avatar.rs rename to crates/signed_ui/src/pixel_avatar.rs index e60870d..601ba35 100644 --- a/crates/workspace/src/pixel_avatar.rs +++ b/crates/signed_ui/src/pixel_avatar.rs @@ -17,7 +17,7 @@ const MIN_FILLED: usize = 5; /// mirror symmetry, seeded from a stable string such as the repository id and /// owner public key. The same seed always renders the same avatar. #[derive(IntoElement)] -pub(crate) struct PixelAvatar { +pub struct PixelAvatar { seed: u64, size: Pixels, style: StyleRefinement, @@ -26,7 +26,7 @@ pub(crate) struct PixelAvatar { impl PixelAvatar { /// Create an avatar seeded from `seed`. The seed should be a stable string /// unique to the entity the avatar represents. - pub(crate) fn new(seed: impl AsRef) -> Self { + pub fn new(seed: impl AsRef) -> Self { Self { seed: fnv1a(seed.as_ref().as_bytes()), size: px(16.), diff --git a/crates/signed_ui/src/placeholder.rs b/crates/signed_ui/src/placeholder.rs new file mode 100644 index 0000000..08fbfbd --- /dev/null +++ b/crates/signed_ui/src/placeholder.rs @@ -0,0 +1,19 @@ +use gpui::prelude::*; +use gpui::{AnyElement, App, div}; +use gpui_component::{ActiveTheme, v_flex}; + +/// A centered muted placeholder message, filling its parent. +pub fn placeholder(message: &str, cx: &App) -> AnyElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .p_4() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(message.to_string()), + ) + .into_any_element() +} diff --git a/crates/signed_ui/src/segment_button.rs b/crates/signed_ui/src/segment_button.rs new file mode 100644 index 0000000..e13aa84 --- /dev/null +++ b/crates/signed_ui/src/segment_button.rs @@ -0,0 +1,179 @@ +use gpui::prelude::*; +use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div, px, relative}; +use gpui_base::{Button as BaseButton, StyledExt}; +use gpui_component::ActiveTheme; + +/// A small count badge shown after a label, e.g. on a segmented filter +/// button ("All 12") or a tab. Rendered from theme tokens; sized for the +/// compact header buttons it lives on. +#[derive(IntoElement)] +pub struct CountBadge { + count: usize, + style: StyleRefinement, +} + +impl CountBadge { + pub fn new(count: usize) -> Self { + Self { + count, + style: StyleRefinement::default(), + } + } +} + +impl Styled for CountBadge { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for CountBadge { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + div() + .refine_style(&self.style) + .h_flex() + .justify_center() + .ml_2() + .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(self.count.to_string())) + } +} + +/// A segmented filter/tab button: an icon, a label, an optional [`CountBadge`] +/// and a selected (pressed) state, styled from the theme's button tokens. +/// +/// Built on the unstyled `gpui_base::Button`, like the app's other custom +/// controls; the `primary` variant uses the primary button tokens for +/// call-to-action buttons ("New issue", "New PR"). +#[allow(clippy::type_complexity)] +#[derive(IntoElement)] +pub struct SegmentButton { + id: ElementId, + style: StyleRefinement, + icon: Option, + label: SharedString, + count: Option, + selected: bool, + primary: bool, + on_click: Option>, +} + +impl SegmentButton { + pub fn new(id: I, label: L) -> Self + where + I: Into, + L: Into, + { + Self { + id: id.into(), + label: label.into(), + style: StyleRefinement::default(), + icon: None, + count: None, + selected: false, + primary: false, + on_click: None, + } + } + + /// The leading icon, e.g. `Icon::new(CustomIconName::GitIssueDone)`. + pub fn icon(mut self, icon: impl IntoElement) -> Self { + self.icon = Some(icon.into_any_element()); + self + } + + /// A count shown in a badge after the label. + pub fn count(mut self, count: usize) -> Self { + self.count = Some(count); + self + } + + /// Whether the button reflects an active filter/tab. + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + /// Use the primary button tokens (for call-to-action buttons). + pub fn primary(mut self) -> Self { + self.primary = true; + self + } + + pub fn on_click( + mut self, + listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Box::new(listener)); + self + } +} + +impl Styled for SegmentButton { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for SegmentButton { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let Self { + id, + style, + icon, + label, + count, + selected, + primary, + on_click, + } = self; + + let theme = cx.theme(); + let fg = if primary { + theme.button_primary_foreground + } else { + theme.button_foreground + }; + let base = if primary { + theme.button_primary + } else { + theme.button_active + }; + let hover = if primary { + theme.button_primary_hover + } else { + theme.button_hover + }; + let active = if primary { + theme.button_primary_active + } else { + theme.button_active + }; + + BaseButton::new(id) + .refine_style(&style) + .flex() + .items_center() + .h_7() + .px_2() + .gap_1() + .when_some(icon, |this, icon| this.child(icon)) + .child(div().text_sm().child(label)) + .when_some(count, |this, count| this.child(CountBadge::new(count))) + .text_color(fg) + .rounded(theme.radius) + .hover(move |this| this.bg(hover)) + .active(move |this| this.bg(active)) + .selected(selected) + .when(primary, |this| this.bg(base)) + .when(selected, |this| this.bg(active)) + .when_some(on_click, |this, listener| this.on_click(listener)) + } +} diff --git a/crates/signed_ui/src/status_badge.rs b/crates/signed_ui/src/status_badge.rs new file mode 100644 index 0000000..5b58537 --- /dev/null +++ b/crates/signed_ui/src/status_badge.rs @@ -0,0 +1,53 @@ +use assets::CustomIconName; +use gpui::prelude::*; +use gpui::{AnyElement, App}; +use gpui_component::tooltip::Tooltip; +use gpui_component::{ActiveTheme, Icon, Sizable, v_flex}; +use signed_core::RepoStatus; + +/// The status badge shown next to an issue or pull request: icon + colored square, +/// with a tooltip describing the status. +pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement { + let (icon, label, tooltip, bg, fg) = match status { + RepoStatus::Open => ( + CustomIconName::GitIssueDone, + "open", + "Issue is open", + cx.theme().primary, + cx.theme().primary_foreground, + ), + RepoStatus::Closed => ( + CustomIconName::GitIssueClosed, + "closed", + "Issue is closed", + cx.theme().danger, + cx.theme().danger_foreground, + ), + RepoStatus::Draft => ( + CustomIconName::GitIssueOngoing, + "draft", + "Issue is draft", + cx.theme().accent, + cx.theme().accent_foreground, + ), + RepoStatus::Applied => ( + CustomIconName::GitIssueOpen, + "applied", + "Issue is completed", + cx.theme().secondary, + cx.theme().secondary_foreground, + ), + }; + + v_flex() + .id(label) + .flex_shrink_0() + .size_7() + .items_center() + .justify_center() + .rounded(cx.theme().radius) + .bg(bg) + .child(Icon::new(icon).small().text_color(fg)) + .tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx)) + .into_any_element() +} diff --git a/crates/signed_ui/src/title_bar.rs b/crates/signed_ui/src/title_bar.rs new file mode 100644 index 0000000..b194596 --- /dev/null +++ b/crates/signed_ui/src/title_bar.rs @@ -0,0 +1,55 @@ +use gpui::{ + App, Div, InteractiveElement as _, MouseButton, Stateful, StatefulInteractiveElement as _, + Window, WindowControlArea, +}; + +/// State used to move the window when the title bar area is dragged. +struct WindowDragState { + should_move: bool, +} + +/// Make an element behave like a window title bar: dragging it moves the +/// window, and double-clicking zooms the window (or performs the platform's +/// default title-bar double-click action on macOS). +/// +/// Only the bar's non-interactive areas should get this — tabs are draggable +/// (to reorder panels) and must not move the window. +pub fn title_bar_drag_handlers( + this: Stateful
, + window: &mut Window, + cx: &mut App, +) -> Stateful
{ + let state = window.use_state(cx, |_, _| WindowDragState { should_move: false }); + + this.window_control_area(WindowControlArea::Drag) + .on_mouse_down_out(window.listener_for(&state, |state, _, _, _| { + state.should_move = false; + })) + .on_mouse_down( + MouseButton::Left, + window.listener_for(&state, |state, _, _, _| { + state.should_move = true; + }), + ) + .on_mouse_up( + MouseButton::Left, + window.listener_for(&state, |state, _, _, _| { + state.should_move = false; + }), + ) + .on_mouse_move(window.listener_for(&state, |state, _, window, _| { + if state.should_move { + state.should_move = false; + window.start_window_move(); + } + })) + .on_click(|event, window, _| { + if event.click_count() == 2 { + if cfg!(target_os = "macos") { + window.titlebar_double_click(); + } else { + window.zoom_window(); + } + } + }) +} diff --git a/crates/signed_ui/src/tree_row.rs b/crates/signed_ui/src/tree_row.rs new file mode 100644 index 0000000..d2598d8 --- /dev/null +++ b/crates/signed_ui/src/tree_row.rs @@ -0,0 +1,43 @@ +use gpui::prelude::*; +use gpui::{App, Window, div, px}; +use gpui_component::list::ListItem; +use gpui_component::tree::TreeEntry; +use gpui_component::{Icon, IconName, Sizable, h_flex}; + +/// 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 fn tree_row(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem +where + F: Fn(&mut Window, &mut App) + 'static, +{ + let item = entry.item(); + let is_folder = entry.is_folder(); + + let icon = if is_folder { + if entry.is_expanded() { + IconName::FolderOpen + } else { + IconName::FolderClosed + } + } else { + IconName::File + }; + + ListItem::new(ix) + .pl(px(8.) + px(14.) * entry.depth() as f32) + .selected(selected) + .child( + h_flex() + .gap_2() + .overflow_hidden() + .child(Icon::new(icon).small()) + .child(div().text_sm().text_ellipsis().child(item.label.clone())), + ) + .on_click(move |_event, window, cx| { + // Folders expand/collapse via the tree itself. + if is_folder { + return; + } + on_click(window, cx); + }) +} diff --git a/crates/signed_ui/src/user_avatar.rs b/crates/signed_ui/src/user_avatar.rs new file mode 100644 index 0000000..ec51c24 --- /dev/null +++ b/crates/signed_ui/src/user_avatar.rs @@ -0,0 +1,48 @@ +use gpui::prelude::*; +use gpui::{App, SharedString, StyleRefinement, Window}; +use gpui_component::avatar::Avatar; +use gpui_component::{ActiveTheme, Sizable, StyledExt}; + +/// A user avatar: the gpui-component [`Avatar`] sized small and rounded with +/// the theme radius, showing the user's picture or a name-initials fallback. +#[derive(IntoElement)] +pub struct UserAvatar { + name: SharedString, + picture: Option, + style: StyleRefinement, +} + +impl UserAvatar { + /// Create an avatar for `name`; the name seeds the initials fallback + /// shown when no picture is set. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + picture: None, + style: StyleRefinement::default(), + } + } + + /// The user's picture URL, if known. + pub fn picture(mut self, picture: Option>) -> Self { + self.picture = picture.map(Into::into); + self + } +} + +impl Styled for UserAvatar { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for UserAvatar { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + Avatar::new() + .name(self.name) + .when_some(self.picture, |this, url| this.src(url)) + .rounded(cx.theme().radius) + .refine_style(&self.style) + .small() + } +} diff --git a/crates/signed_ui/src/util.rs b/crates/signed_ui/src/util.rs new file mode 100644 index 0000000..e5d33ef --- /dev/null +++ b/crates/signed_ui/src/util.rs @@ -0,0 +1,38 @@ +/// `[head chars]...[tail chars]` middle truncation; the value is left alone +/// when it is too short for the ellipsis to save space. +pub 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}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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"); + } +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 4168acc..2406672 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -11,6 +11,7 @@ paths = { path = "../paths" } signed_core = { path = "../signed_core" } signed_git = { path = "../signed_git" } signed_state = { path = "../signed_state" } +signed_ui = { path = "../signed_ui" } utils = { path = "../utils" } gpui.workspace = true diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index aca2e9f..59ed328 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -1,16 +1,13 @@ -mod pixel_avatar; mod views; mod workspace; -pub mod image_cache; - use gpui::{App, AppContext, Entity, Window}; use gpui_component::Root; +pub use signed_ui::image_cache; pub use views::{RepoListView, SidebarPanel}; pub use workspace::Workspace; -/// Build the root view tree. Requires `signed_state::init` and -/// `gpui_component::init` to have been called first. +/// Build the root view tree. pub fn root(window: &mut Window, cx: &mut App) -> Entity { let view = cx.new(|cx| Workspace::new(window, cx)); cx.new(|cx| Root::new(view, window, cx)) diff --git a/crates/workspace/src/views/repo_detail/about.rs b/crates/workspace/src/views/repo_detail/about.rs index 36c2091..290954e 100644 --- a/crates/workspace/src/views/repo_detail/about.rs +++ b/crates/workspace/src/views/repo_detail/about.rs @@ -1,13 +1,11 @@ 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 gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex}; use nostr::prelude::PublicKey; use signed_core::Announcement; use signed_state::ProfileStore; - -use super::helpers::middle_truncate; +use signed_ui::{UserAvatar, middle_truncate}; /// Open the "About" dialog: every field of the repository's announcement /// event (NIP-34, kind 30617), as parsed into [`Announcement`]. @@ -175,13 +173,7 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement { .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(UserAvatar::new(name.clone()).picture(picture)) .child( div() .flex_1() diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index fc839f8..eeda566 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -1,8 +1,3 @@ -//! File explorer of the repository detail view: the file tree column and the -//! content column (README / file preview), backed by persistent -//! [`TextViewState`]s for markdown documents and persistent [`InputState`]s -//! for code files. - use gpui::prelude::*; use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; @@ -12,9 +7,10 @@ use gpui_component::spinner::Spinner; use gpui_component::text::{TextView, TextViewState}; use gpui_component::tree::{TreeEntry, TreeState, tree}; use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex}; +use signed_ui::{placeholder, tree_row}; use super::RepoDetailView; -use super::helpers::{code_language, is_markdown_path, placeholder, tree_row}; +use super::helpers::{code_language, is_markdown_path}; /// Width of the file explorer column. const TREE_WIDTH: f32 = 240.; diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs index a20e8b2..3c757b6 100644 --- a/crates/workspace/src/views/repo_detail/commits.rs +++ b/crates/workspace/src/views/repo_detail/commits.rs @@ -1,17 +1,13 @@ -//! Commits tab of the repository detail view: a virtual list of all -//! commits reachable from HEAD, newest first, with the total count shown -//! as a badge on the tab. - use gpui::prelude::*; use gpui::{AnyElement, App, Context, WeakEntity, div, px}; use gpui_component::scroll::Scrollbar; use gpui_component::spinner::Spinner; use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list}; use signed_git::FileCommit; +use signed_ui::placeholder; use utils::relative_time_secs; use super::RepoDetailView; -use super::helpers::placeholder; /// Height of one commit row in the virtual list. pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.; diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/repo_detail/diff.rs index 67ac752..8d0cacc 100644 --- a/crates/workspace/src/views/repo_detail/diff.rs +++ b/crates/workspace/src/views/repo_detail/diff.rs @@ -18,11 +18,11 @@ use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff}; +use signed_ui::{placeholder, tree_row}; use utils::relative_time_secs; use super::helpers::{ - DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, placeholder, render_diff_row, - tree_items, tree_row, + DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items, }; /// Width of the changed-files column. diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index f69167f..7c51eb3 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -1,22 +1,15 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use assets::CustomIconName; use gpui::prelude::*; -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 gpui::{AnyElement, App, SharedString, div, px}; +use gpui_component::menu::PopupMenu; +use gpui_component::tree::TreeItem; +use gpui_component::{ActiveTheme, h_flex}; use nostr::nips::nip19::{Nip19Coordinate, ToBech32}; -use signed_core::{Announcement, RepoStatus}; +use signed_core::Announcement; use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff}; +use signed_ui::{menu_copy_row, middle_truncate}; /// A `Send` file-tree node: the tree is built on a background thread and /// converted into [`TreeItem`]s (which hold `Rc` state, @@ -55,44 +48,6 @@ 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. -pub(super) fn tree_row(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem -where - F: Fn(&mut Window, &mut App) + 'static, -{ - let item = entry.item(); - let is_folder = entry.is_folder(); - - let icon = if is_folder { - if entry.is_expanded() { - IconName::FolderOpen - } else { - IconName::FolderClosed - } - } else { - IconName::File - }; - - ListItem::new(ix) - .pl(px(8.) + px(14.) * entry.depth() as f32) - .selected(selected) - .child( - h_flex() - .gap_2() - .overflow_hidden() - .child(Icon::new(icon).small()) - .child(div().text_sm().text_ellipsis().child(item.label.clone())), - ) - .on_click(move |_event, window, cx| { - // Folders expand/collapse via the tree itself. - if is_folder { - return; - } - on_click(window, cx); - }) -} - /// Build nested tree items from a flat, sorted (dirs-first) entry list. /// /// Returns [`TreeItemSeed`]s so the build can run off the main thread; a @@ -210,225 +165,6 @@ pub(super) fn is_markdown_path(path: &str) -> bool { }) } -/// A centered muted placeholder message. -pub(super) fn placeholder(message: &str, cx: &App) -> AnyElement { - v_flex() - .size_full() - .items_center() - .justify_center() - .p_4() - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(message.to_string()), - ) - .into_any_element() -} - -/// 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 => ( - CustomIconName::GitIssueDone, - "open", - "Issue is open", - cx.theme().primary, - cx.theme().primary_foreground, - ), - RepoStatus::Closed => ( - CustomIconName::GitIssueClosed, - "closed", - "Issue is closed", - cx.theme().danger, - cx.theme().danger_foreground, - ), - RepoStatus::Draft => ( - CustomIconName::GitIssueOngoing, - "draft", - "Issue is draft", - cx.theme().accent, - cx.theme().accent_foreground, - ), - RepoStatus::Applied => ( - CustomIconName::GitIssueOpen, - "applied", - "Issue is completed", - cx.theme().secondary, - cx.theme().secondary_foreground, - ), - }; - - v_flex() - .id(label) - .flex_shrink_0() - .size_7() - .items_center() - .justify_center() - .rounded(cx.theme().radius) - .bg(bg) - .child(Icon::new(icon).small().text_color(fg)) - .tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx)) - .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 caller-supplied elements, so the look stays -/// in the application; this component only owns the popover wiring. -#[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, @@ -463,25 +199,25 @@ impl ShareTargets { /// 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( + .item(menu_copy_row( "copy-gitworkshop", "GitWorkshop", truncate_naddr_link(&self.gitworkshop, 4), self.gitworkshop.clone(), )) - .item(share_menu_row( + .item(menu_copy_row( "copy-ditto", "Ditto", truncate_naddr_link(&self.ditto, 4), self.ditto.clone(), )) - .item(share_menu_row( + .item(menu_copy_row( "copy-event-id", "Event ID", middle_truncate(&self.event_id, 10, 10), self.event_id.clone(), )) - .item(share_menu_row( + .item(menu_copy_row( "copy-coordinate", "Coordinate", middle_truncate(&self.coordinate, 10, 10), @@ -490,50 +226,6 @@ impl ShareTargets { } } -/// One row of the share menu: a small title above the compact label, with -/// a copy button that flips to a check while the value is on the clipboard. -/// Clicking the row copies and dismisses the menu; the copy button stops -/// propagation so the menu stays open. Both copy `copy`, never the 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. @@ -763,28 +455,6 @@ mod tests { 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!( @@ -797,17 +467,4 @@ mod tests { "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/issue_detail.rs b/crates/workspace/src/views/repo_detail/issue_detail.rs index d0853a9..43ba3fb 100644 --- a/crates/workspace/src/views/repo_detail/issue_detail.rs +++ b/crates/workspace/src/views/repo_detail/issue_detail.rs @@ -7,7 +7,6 @@ use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Window, div, px, relative, }; -use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::{Textarea, TextareaState}; use gpui_component::scroll::ScrollableElement; @@ -16,11 +15,10 @@ use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex}; use nostr::prelude::{Event, EventId, PublicKey}; use signed_core::activity_subject; use signed_state::{ProfileStore, RepoStore}; +use signed_ui::image_cache::{MAX_IMAGES, image_cache}; +use signed_ui::{UserAvatar, placeholder, status_badge}; use utils::relative_time; -use super::helpers::{placeholder, status_badge}; -use crate::image_cache::{MAX_IMAGES, image_cache}; - /// Detail panel of a single issue. pub struct IssueDetailView { focus_handle: FocusHandle, @@ -92,13 +90,7 @@ impl IssueDetailView { h_flex() .gap_1() .items_center() - .child( - Avatar::new() - .name(name.clone()) - .when_some(picture, |this, url| this.src(url)) - .rounded(cx.theme().radius) - .small(), - ) + .child(UserAvatar::new(name.clone()).picture(picture)) .child(div().text_sm().truncate().text_ellipsis().child(name)) .into_any_element() })), @@ -170,13 +162,7 @@ impl IssueDetailView { .child( h_flex() .gap_1() - .child( - Avatar::new() - .name(author.clone()) - .when_some(picture, |this, url| this.src(url)) - .rounded(cx.theme().radius) - .small(), - ) + .child(UserAvatar::new(author.clone()).picture(picture)) .child(author), ) .child( @@ -352,13 +338,8 @@ impl Render for IssueDetailView { h_flex() .gap_1() .child( - Avatar::new() - .when_some(picture, |this, url| { - this.src(url) - }) - .name(author.clone()) - .rounded(cx.theme().radius) - .small(), + UserAvatar::new(author.clone()) + .picture(picture), ) .child(author), ) diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/repo_detail/issues.rs index 44e8a61..403f402 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/repo_detail/issues.rs @@ -5,26 +5,24 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, WeakEntity, Window, div, px, relative, size, + SharedString, Size, WeakEntity, Window, div, px, size, }; -use gpui_base::Button as BaseButton; -use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; use gpui_component::form::{field, v_form}; use gpui_component::input::{Input, InputState, Textarea, TextareaState}; use gpui_component::scroll::Scrollbar; use gpui_component::{ - ActiveTheme, Icon, Sizable, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, + ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, }; use nostr::prelude::EventId; use signed_core::{RepoStatus, activity_subject}; use signed_state::{ProfileStore, RepoStore}; +use signed_ui::image_cache::{MAX_IMAGES, image_cache}; +use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge}; use utils::relative_time; -use super::helpers::{placeholder, status_badge}; use super::issue_detail::IssueDetailView; -use crate::image_cache::{MAX_IMAGES, image_cache}; /// Height of one issue row in the virtual list: `py_2` padding, a 32px /// title line (`h_8`), a 24px meta line (`h_6`) and the 1px bottom border. @@ -168,13 +166,7 @@ impl IssuesView { .child( h_flex() .gap_1() - .child( - Avatar::new() - .name(author.clone()) - .when_some(picture, |this, url| this.src(url)) - .rounded(cx.theme().radius) - .small(), - ) + .child(UserAvatar::new(author.clone()).picture(picture)) .child(div().child(author)), ) .child(SharedString::from("opened")) @@ -207,106 +199,30 @@ impl IssuesView { .h_12() .gap_2() .child( - BaseButton::new("all") - .flex() - .items_center() - .h_7() - .px_2() - .gap_1() - .child(Icon::new(CustomIconName::GitIssueDone)) - .child(div().text_sm().child("All")) - .child( - h_flex() - .justify_center() - .ml_2() - .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(total.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)) + SegmentButton::new("all", "All") + .icon(Icon::new(CustomIconName::GitIssueDone)) + .count(total) .selected(self.filter == IssueFilter::All) - .when(self.filter == IssueFilter::All, |this| { - this.bg(cx.theme().button_active) - }) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = IssueFilter::All; cx.notify(); })), ) .child( - BaseButton::new("open") - .flex() - .items_center() - .h_7() - .px_2() - .gap_1() - .child(Icon::new(CustomIconName::GitIssueOpen)) - .child(div().text_sm().child("Open")) - .child( - h_flex() - .justify_center() - .ml_2() - .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(open.to_string())), - ) - .text_color(cx.theme().button_foreground) - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().button_hover)) + SegmentButton::new("open", "Open") + .icon(Icon::new(CustomIconName::GitIssueOpen)) + .count(open) .selected(self.filter == IssueFilter::Open) - .when(self.filter == IssueFilter::Open, |this| { - this.bg(cx.theme().button_active) - }) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = IssueFilter::Open; cx.notify(); })), ) .child( - BaseButton::new("closed") - .flex() - .items_center() - .h_7() - .px_2() - .gap_1() - .child(Icon::new(CustomIconName::GitIssueClosed)) - .child(div().text_sm().child("Closed")) - .child( - h_flex() - .justify_center() - .ml_2() - .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(closed.to_string())), - ) - .text_color(cx.theme().button_foreground) - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().button_hover)) + SegmentButton::new("closed", "Closed") + .icon(Icon::new(CustomIconName::GitIssueClosed)) + .count(closed) .selected(self.filter == IssueFilter::Closed) - .when(self.filter == IssueFilter::Closed, |this| { - this.bg(cx.theme().button_active) - }) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = IssueFilter::Closed; cx.notify(); @@ -315,19 +231,9 @@ impl IssuesView { ) .child(div().flex_1()) .child( - BaseButton::new("new") - .flex() - .items_center() - .h_7() - .px_2() - .gap_1() - .child(Icon::new(CustomIconName::CirclePlus)) - .child(div().text_sm().child("New issue")) - .text_color(cx.theme().button_primary_foreground) - .rounded(cx.theme().radius) - .bg(cx.theme().button_primary) - .hover(|this| this.bg(cx.theme().button_primary_hover)) - .active(|this| this.bg(cx.theme().button_primary_active)) + SegmentButton::new("new", "New issue") + .icon(Icon::new(CustomIconName::CirclePlus)) + .primary() .on_click(cx.listener(|this, _event, window, cx| { open_new_issue_dialog(this.store.clone(), window, cx); })), diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index b9400f6..16038c2 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -8,15 +8,13 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gix::Repository; use gpui::prelude::*; use gpui::{ - Action, Anchor, AnyElement, App, ClipboardItem, Context, Div, ElementId, Entity, EventEmitter, - FocusHandle, Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, - Task, WeakEntity, Window, div, px, relative, size, + Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, + Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, Task, + WeakEntity, Window, div, px, relative, size, }; use gpui_base::{Button as BaseButton, Disableable, Popover}; use gpui_component::alert::Alert; -use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::clipboard::Clipboard; use gpui_component::combobox::{ Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, }; @@ -31,9 +29,8 @@ use nostr::prelude::{EventId, RelayUrl, ToBech32}; use signed_core::Announcement; use signed_git::{CommitList, FileCommit}; use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoStore}; - -use crate::image_cache::{MAX_IMAGES, image_cache}; -use crate::pixel_avatar::PixelAvatar; +use signed_ui::image_cache::{MAX_IMAGES, image_cache}; +use signed_ui::{DropdownButton, PixelAvatar, UserAvatar, copy_row}; mod about; mod browser; @@ -53,9 +50,7 @@ use browser::{ }; use commits::COMMIT_ROW_HEIGHT; use diff::CommitDiffView; -use helpers::{ - BaseDropdownButton, ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, tree_items, -}; +use helpers::{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}; @@ -1358,7 +1353,7 @@ impl RepoDetailView { .gap_2() .justify_end() .child( - BaseDropdownButton::new("issues") + DropdownButton::new("issues") .action( BaseButton::new("issues-open") .child( @@ -1399,7 +1394,7 @@ impl RepoDetailView { }), ) .child( - BaseDropdownButton::new("prs") + DropdownButton::new("prs") .action( BaseButton::new("prs-open") .child( @@ -1442,7 +1437,7 @@ impl RepoDetailView { }), ) .child( - BaseDropdownButton::new("share") + DropdownButton::new("share") .action( Button::new("link") .icon(IconName::Copy) @@ -1523,8 +1518,8 @@ impl RepoDetailView { ) .content(move |_, _window, cx| { let state = cx.entity(); - let ngit_row = command_row("copy-ngit", &ngit_command, cx); - let nak_row = command_row("copy-nak", &nak_command, cx); + let ngit_row = copy_row("copy-ngit", &ngit_command, cx); + let nak_row = copy_row("copy-nak", &nak_command, cx); v_flex() .w(px(440.)) @@ -1570,7 +1565,7 @@ impl RepoDetailView { this.children( git_commands.iter().enumerate().map( |(ix, cmd)| { - command_row( + copy_row( format!("copy-git-{ix}"), cmd, cx, @@ -1887,13 +1882,7 @@ impl RepoDetailView { .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(UserAvatar::new(owner_name.clone()).picture(owner_picture)) .child(div().text_xs().whitespace_nowrap().child(owner_name)), ) .when(!rest.is_empty(), |this| { @@ -2026,31 +2015,3 @@ fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedSt SharedString::from(url) } - -fn command_row(copy_id: E, command: &SharedString, cx: &mut App) -> Div -where - E: Into, -{ - h_flex() - .h_8() - .w_full() - .px_2() - .gap_2() - .items_center() - .bg(cx.theme().muted) - .rounded(cx.theme().radius) - .child( - h_flex() - .flex_1() - .min_w_0() - .truncate() - .text_ellipsis() - .text_xs() - .child(command.clone()), - ) - .child( - Clipboard::new(copy_id) - .tooltip("Copy") - .value(command.clone()), - ) -} diff --git a/crates/workspace/src/views/repo_detail/pull_request_detail.rs b/crates/workspace/src/views/repo_detail/pull_request_detail.rs index 2024678..ef52da5 100644 --- a/crates/workspace/src/views/repo_detail/pull_request_detail.rs +++ b/crates/workspace/src/views/repo_detail/pull_request_detail.rs @@ -10,7 +10,6 @@ use gpui::{ ScrollStrategy, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, relative, size, }; -use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::clipboard::Clipboard; use gpui_component::input::{Textarea, TextareaState}; @@ -27,14 +26,14 @@ use nostr::prelude::{Event, EventId, Kind, Nip34Tag, PublicKey}; use signed_core::{activity_subject, pull_request_patch}; use signed_git::{CommitDiff, FileCommit, FileDiff, patch_commits, patch_diffs}; use signed_state::{GitStore, ProfileStore, RepoStore}; +use signed_ui::image_cache::{MAX_IMAGES, image_cache}; +use signed_ui::{UserAvatar, placeholder, status_badge, tree_row}; use utils::{relative_time, relative_time_secs}; use super::diff::CommitDiffView; use super::helpers::{ - DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, placeholder, render_diff_row, - status_badge, tree_items, tree_row, + DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items, }; -use crate::image_cache::{MAX_IMAGES, image_cache}; /// Width of the changed-files column. const TREE_WIDTH: f32 = 260.; @@ -668,13 +667,8 @@ impl PullRequestDetailView { h_flex() .gap_1() .child( - Avatar::new() - .name(author.clone()) - .when_some(picture, |this, url| { - this.src(url) - }) - .rounded(cx.theme().radius) - .small(), + UserAvatar::new(author.clone()) + .picture(picture), ) .child(author), ) @@ -741,13 +735,7 @@ impl PullRequestDetailView { h_flex() .gap_1() .items_center() - .child( - Avatar::new() - .name(name.clone()) - .when_some(picture, |this, url| this.src(url)) - .rounded(cx.theme().radius) - .small(), - ) + .child(UserAvatar::new(name.clone()).picture(picture)) .child(div().text_sm().truncate().text_ellipsis().child(name)) .into_any_element() })), @@ -937,13 +925,7 @@ impl PullRequestDetailView { .child( h_flex() .gap_1() - .child( - Avatar::new() - .name(author.clone()) - .when_some(picture, |this, url| this.src(url)) - .rounded(cx.theme().radius) - .small(), - ) + .child(UserAvatar::new(author.clone()).picture(picture)) .child(author), ) .child( diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/repo_detail/pull_requests.rs index c51ca64..7084d11 100644 --- a/crates/workspace/src/views/repo_detail/pull_requests.rs +++ b/crates/workspace/src/views/repo_detail/pull_requests.rs @@ -5,26 +5,24 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, WeakEntity, Window, div, px, relative, size, + SharedString, Size, WeakEntity, Window, div, px, size, }; -use gpui_base::Button as BaseButton; -use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; use gpui_component::form::{field, v_form}; use gpui_component::input::{Input, InputState, Textarea, TextareaState}; use gpui_component::scroll::Scrollbar; use gpui_component::{ - ActiveTheme, Icon, Sizable, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, + ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, }; use nostr::prelude::{EventId, Kind}; use signed_core::{RepoStatus, activity_subject}; use signed_state::{ProfileStore, RepoStore}; +use signed_ui::image_cache::{MAX_IMAGES, image_cache}; +use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge}; use utils::relative_time; -use super::helpers::{placeholder, status_badge}; use super::pull_request_detail::PullRequestDetailView; -use crate::image_cache::{MAX_IMAGES, image_cache}; /// Height of one pull request row in the virtual list; same layout as an /// issue row. @@ -188,13 +186,7 @@ impl PullRequestsView { .child( h_flex() .gap_1() - .child( - Avatar::new() - .name(author.clone()) - .when_some(picture, |this, url| this.src(url)) - .rounded(cx.theme().radius) - .small(), - ) + .child(UserAvatar::new(author.clone()).picture(picture)) .child(div().child(author)), ) .child(SharedString::from("opened")) @@ -227,180 +219,50 @@ impl PullRequestsView { .h_12() .gap_2() .child( - BaseButton::new("all") - .flex() - .items_center() - .h_7() - .px_2() - .gap_1() - .child(Icon::new(CustomIconName::GitPullRequest)) - .child(div().text_sm().child("All")) - .child( - h_flex() - .justify_center() - .ml_2() - .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(total.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)) + SegmentButton::new("all", "All") + .icon(Icon::new(CustomIconName::GitPullRequest)) + .count(total) .selected(self.filter == PullRequestFilter::All) - .when(self.filter == PullRequestFilter::All, |this| { - this.bg(cx.theme().button_active) - }) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::All; cx.notify(); })), ) .child( - BaseButton::new("open") - .flex() - .items_center() - .h_7() - .px_2() - .gap_1() - .child(Icon::new(CustomIconName::GitPullRequest)) - .child(div().text_sm().child("Open")) - .child( - h_flex() - .justify_center() - .ml_2() - .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(open.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)) + SegmentButton::new("open", "Open") + .icon(Icon::new(CustomIconName::GitPullRequest)) + .count(open) .selected(self.filter == PullRequestFilter::Open) - .when(self.filter == PullRequestFilter::Open, |this| { - this.bg(cx.theme().button_active) - }) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::Open; cx.notify(); })), ) .child( - BaseButton::new("closed") - .flex() - .items_center() - .h_7() - .px_2() - .gap_1() - .child(Icon::new(CustomIconName::GitPullRequestClosed)) - .child(div().text_sm().child("Closed")) - .child( - h_flex() - .justify_center() - .ml_2() - .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(closed.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)) + SegmentButton::new("closed", "Closed") + .icon(Icon::new(CustomIconName::GitPullRequestClosed)) + .count(closed) .selected(self.filter == PullRequestFilter::Closed) - .when(self.filter == PullRequestFilter::Closed, |this| { - this.bg(cx.theme().button_active) - }) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::Closed; cx.notify(); })), ) .child( - BaseButton::new("draft") - .flex() - .items_center() - .h_7() - .px_2() - .gap_1() - .child(Icon::new(CustomIconName::GitPullRequestDraft)) - .child(div().text_sm().child("Draft")) - .child( - h_flex() - .justify_center() - .ml_2() - .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(draft.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)) + SegmentButton::new("draft", "Draft") + .icon(Icon::new(CustomIconName::GitPullRequestDraft)) + .count(draft) .selected(self.filter == PullRequestFilter::Draft) - .when(self.filter == PullRequestFilter::Draft, |this| { - this.bg(cx.theme().button_active) - }) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::Draft; cx.notify(); })), ) .child( - BaseButton::new("merged") - .flex() - .items_center() - .h_7() - .px_2() - .gap_1() - .child(Icon::new(CustomIconName::GitPullRequestMerged)) - .child(div().text_sm().child("Merged")) - .child( - h_flex() - .justify_center() - .ml_2() - .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(merged.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)) + SegmentButton::new("merged", "Merged") + .icon(Icon::new(CustomIconName::GitPullRequestMerged)) + .count(merged) .selected(self.filter == PullRequestFilter::Merged) - .when(self.filter == PullRequestFilter::Merged, |this| { - this.bg(cx.theme().button_active) - }) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::Merged; cx.notify(); @@ -409,19 +271,9 @@ impl PullRequestsView { ) .child(div().flex_1()) .child( - BaseButton::new("new-pr") - .flex() - .items_center() - .h_7() - .px_2() - .gap_1() - .child(Icon::new(CustomIconName::CirclePlus)) - .child(div().text_sm().child("New pull request")) - .text_color(cx.theme().button_primary_foreground) - .rounded(cx.theme().radius) - .bg(cx.theme().button_primary) - .hover(|this| this.bg(cx.theme().button_primary_hover)) - .active(|this| this.bg(cx.theme().button_primary_active)) + SegmentButton::new("new-pr", "New pull request") + .icon(Icon::new(CustomIconName::CirclePlus)) + .primary() .on_click(cx.listener(|this, _event, window, cx| { open_new_pull_request_dialog(this.store.clone(), window, cx); })), diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 3d2beff..0582f59 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -7,8 +7,6 @@ use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size, }; -use gpui_base::Button as BaseButton; -use gpui_component::avatar::Avatar; use gpui_component::input::{Input, InputEvent, InputState}; use gpui_component::scroll::Scrollbar; use gpui_component::{ @@ -17,10 +15,11 @@ use gpui_component::{ }; use signed_core::Announcement; use signed_state::{ProfileStore, RepoListStore, Timestamp}; +use signed_ui::image_cache::{MAX_IMAGES, image_cache}; +use signed_ui::{SegmentButton, UserAvatar}; use utils::relative_time; use super::RepoDetailView; -use crate::image_cache::{MAX_IMAGES, image_cache}; const COLUMNS: usize = 2; const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.; @@ -255,13 +254,7 @@ impl RepoListView { h_flex() .gap_2() .items_center() - .child( - Avatar::new() - .name(owner.name()) - .when_some(owner.picture(), |this, url| this.src(url)) - .rounded(cx.theme().radius) - .small(), - ) + .child(UserAvatar::new(owner.name()).picture(owner.picture())) .child( div() .text_xs() @@ -340,20 +333,9 @@ impl RepoListView { ) -> AnyElement { let active = self.filter == filter; - BaseButton::new(label) - .flex() - .items_center() - .h_7() - .px_2() - .gap_1() - .child(Icon::new(filter.icon_name())) - .child(div().text_sm().child(label)) - .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)) + SegmentButton::new(label, label) + .icon(Icon::new(filter.icon_name())) .selected(active) - .when(active, |this| this.bg(cx.theme().button_active)) .on_click(cx.listener(move |this, _event, _window, cx| { this.filter = filter; this.rebuild_rows(cx); diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 5e5929d..5c962c6 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -4,27 +4,22 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use assets::CustomIconName; -use dock::{ - BasePanel, DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, panel_handle, - title_bar_drag_handlers, -}; +use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, panel_handle}; use gpui::prelude::*; use gpui::{ - AnyElement, App, ClickEvent, Context, Div, ElementId, Entity, EventEmitter, FocusHandle, - Focusable, ObjectFit, Render, SharedString, StyleRefinement, Subscription, WeakEntity, Window, - div, img, px, uniform_list, + AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render, + SharedString, Subscription, WeakEntity, Window, div, img, px, uniform_list, }; use gpui_base::Button as BaseButton; -use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::InputState; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use signed_core::{Announcement, identifier_from_name}; use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore}; +use signed_ui::image_cache::{MAX_IMAGES, image_cache}; +use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; use super::{RepoDetailView, RepoListView}; -use crate::image_cache::{MAX_IMAGES, image_cache}; -use crate::pixel_avatar::PixelAvatar; mod create_repo_dialog; pub(crate) mod grasp_servers; @@ -401,13 +396,7 @@ impl SidebarPanel { Button::new("user").text().dropdown_caret(true).child( h_flex() .gap_1() - .child( - Avatar::new() - .name(name.clone()) - .when_some(picture, |this, url| this.src(url)) - .rounded(cx.theme().radius) - .small(), - ) + .child(UserAvatar::new(name.clone()).picture(picture)) .child(div().text_xs().font_semibold().child(name)), ), ), @@ -609,75 +598,3 @@ impl Render for SidebarPanel { ) } } - -/// A single navigation entry in the sidebar: an arbitrary leading element -/// (an icon, avatar, ...) and a text label with a hover highlight, -/// an optional trailing suffix (e.g. a status icon) and an optional click handler. -#[allow(clippy::type_complexity)] -#[derive(IntoElement)] -struct NavItem { - id: ElementId, - style: StyleRefinement, - icon: AnyElement, - label: SharedString, - /// Trailing element rendered at the right edge of the row, after the (ellipsized) label. - suffix: Option, - on_click: Option>, -} - -impl NavItem { - fn new(id: I, label: L, icon: N) -> Self - where - I: Into, - L: Into, - N: IntoElement, - { - Self { - id: id.into(), - icon: icon.into_any_element(), - label: label.into(), - style: StyleRefinement::default(), - suffix: None, - on_click: None, - } - } - - /// A trailing element rendered at the right edge of the row - fn suffix(mut self, suffix: impl IntoElement) -> Self { - self.suffix = Some(suffix.into_any_element()); - self - } - - fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self { - self.on_click = Some(Box::new(listener)); - self - } -} - -impl RenderOnce for NavItem { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - h_flex() - .id(self.id) - .refine_style(&self.style) - .px_2() - .py_1() - .w_full() - .gap_2() - .rounded(cx.theme().radius) - .child(self.icon) - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .whitespace_nowrap() - .text_ellipsis() - .child(self.label), - ) - .when_some(self.suffix, |this, suffix| { - this.child(div().flex_shrink_0().child(suffix)) - }) - .hover(|this| this.bg(cx.theme().list_hover)) - .when_some(self.on_click, |this, listener| this.on_click(listener)) - } -} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 4c97a3b..fb7e872 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3,8 +3,8 @@ use gpui::prelude::*; use gpui::{Context, Entity, Render, Subscription, Window, div, px}; use gpui_component::{Root, StyledExt, Theme}; use signed_state::{Backend, BackendEvent}; +use signed_ui::image_cache::{MAX_IMAGES, image_cache}; -use crate::image_cache::{MAX_IMAGES, image_cache}; use crate::views::SidebarPanel; use crate::views::sidebar::passphrase_dialog;