diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index be9cc59..995cfb9 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -3,8 +3,11 @@ use std::path::{Path, PathBuf}; use assets::CustomIconName; use gpui::prelude::*; -use gpui::{AnyElement, App, ClipboardItem, SharedString, Window, div, px}; -use gpui_base::StyledExt; +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}; @@ -270,6 +273,165 @@ pub(super) fn status_badge(status: RepoStatus, cx: &App) -> AnyElement { .into_any_element() } +/// A split dropdown button built on `gpui_base::Popover`: an action element +/// with a separate caret trigger that opens a [`PopupMenu`]. +/// +/// The action and the caret are ordinary elements supplied by the caller, so +/// the look — icons, borders, hover states, sizes — stays fully in the +/// application. The component only owns the popover wiring: opening on caret +/// click, Escape/outside dismissal, focus movement into the menu, and the +/// menu entity's lifecycle. +#[derive(IntoElement)] +pub(super) struct BaseDropdownButton { + id: ElementId, + style: StyleRefinement, + anchor: Anchor, + action: Option, + caret: Option, + menu: Option, +} + +type MenuBuilder = + Box) -> PopupMenu + 'static>; +type CaretBuilder = Box AnyElement>; + +impl BaseDropdownButton { + pub(super) fn new(id: impl Into) -> Self { + Self { + id: id.into(), + style: StyleRefinement::default(), + anchor: Anchor::TopRight, + action: None, + caret: None, + menu: None, + } + } + + /// The action half of the button. It keeps its own icon, label, tooltip + /// and click handler. + pub(super) fn action(mut self, action: impl IntoElement + 'static) -> Self { + self.action = Some(action.into_any_element()); + self + } + + /// The menu built by `builder` — the same signature as gpui-component's + /// `DropdownButton::dropdown_menu`, so existing menu code keeps working. + pub(super) fn dropdown_menu( + mut self, + builder: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> Self { + self.menu = Some(Box::new(builder)); + self + } + + /// Which corner of the caret the menu anchors to. Defaults to + /// [`Anchor::TopRight`], so the menu's right edge lines up with the + /// caret's. + #[allow(dead_code)] // API knob; current call sites use the default anchor. + pub(super) fn anchor(mut self, anchor: impl Into) -> Self { + self.anchor = anchor.into(); + self + } +} + +impl Styled for BaseDropdownButton { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +/// Holds the [`PopupMenu`] entity of one popover between renders. Dismissal +/// drops it, so the menu is rebuilt with fresh items on the next open. +#[derive(Default)] +struct DropdownMenuState { + menu: Option>, +} + +impl RenderOnce for BaseDropdownButton { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + debug_assert!( + self.menu.is_some(), + "a BaseDropdownButton needs a `dropdown_menu`" + ); + + // The popover needs its own id: both the container and the popover register keyed state on this window. + let popover_id = SharedString::from(format!("{}-popover", self.id)); + let anchor = self.anchor; + let menu_state = + window.use_keyed_state(popover_id.clone(), cx, |_, _| DropdownMenuState::default()); + + let caret = self.caret.unwrap_or_else(|| { + let id = popover_id.clone(); + Box::new(move |is_open, _, cx| { + let caret = default_caret(id.clone(), cx); + let selected = caret.is_selected(); + caret.selected(selected || is_open).into_any_element() + }) + }); + + h_flex() + .id(self.id) + .refine_style(&self.style) + .gap_0p5() + .when_some(self.action, |this, action| this.child(action)) + .when_some(self.menu, |this, builder| { + this.child( + Popover::new(popover_id) + .anchor(anchor) + // The menu dismisses itself on outside click or Escape; + // the subscription below closes the popover along with it. + .overlay_closable(false) + .trigger_with(caret) + .content( + move |_, window, cx| match menu_state.read(cx).menu.clone() { + Some(menu) => menu, + None => { + let menu = PopupMenu::build(window, cx, |menu, window, cx| { + builder(menu, window, cx) + }); + menu_state + .update(cx, |state, _| state.menu = Some(menu.clone())); + menu.focus_handle(cx).focus(window, cx); + + let popover_state = cx.entity(); + window + .subscribe(&menu, cx, { + let menu_state = menu_state.clone(); + move |_, _: &DismissEvent, window, cx| { + popover_state.update(cx, |state, cx| { + state.dismiss(window, cx); + }); + menu_state.update(cx, |state, _| { + state.menu = None; + }); + } + }) + .detach(); + + menu.clone() + } + }, + ), + ) + }) + } +} + +/// The default caret: a chevron button the height of a medium button, tinted +/// by the theme, with hover and menu-open states. +fn default_caret(id: impl Into, cx: &App) -> BaseButton { + BaseButton::new(id) + .h(px(32.)) + .px_1p5() + .text_color(cx.theme().muted_foreground) + .hover(|style| style.bg(cx.theme().secondary_hover)) + .styles(|this| { + this.selected(|style| style.bg(cx.theme().secondary_active)) + .disabled(|style| style.opacity(0.5)) + }) + .child(Icon::new(IconName::ChevronDown).xsmall()) +} + pub(super) struct ShareTargets { /// NIP-19 `naddr1...` of the announcement (with its announced relays). pub(super) naddr: String, @@ -639,4 +801,17 @@ 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/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 3365f45..60792a3 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -14,15 +14,15 @@ use gpui::{ }; use gpui_base::{Button as BaseButton, Disableable}; use gpui_component::avatar::Avatar; -use gpui_component::button::{Button, ButtonVariants, DropdownButton}; +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, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, + ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, + v_flex, }; use signed_core::Announcement; use signed_git::{CommitList, FileCommit}; @@ -45,7 +45,9 @@ use browser::{ }; use commits::COMMIT_ROW_HEIGHT; use diff::CommitDiffView; -use helpers::{ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, tree_items}; +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}; @@ -1109,20 +1111,32 @@ impl RepoDetailView { .gap_2() .justify_end() .child( - DropdownButton::new("issues") - .outline() - .button( - Button::new("issues-open") - .icon(CustomIconName::GitIssueDone) + BaseDropdownButton::new("issues") + .action( + BaseButton::new("issues-open") .child( - h_flex().gap_2().text_sm().child("Issues").child( - Tag::secondary() - .xsmall() - .border_0() - .child(issue_count), - ), + 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), ) - .secondary() .on_click(cx.listener(|this, _event, window, cx| { this.open_issue_detail(window, cx); })), @@ -1136,22 +1150,33 @@ impl RepoDetailView { }), ) .child( - DropdownButton::new("prs") - .outline() - .button( - Button::new("prs-open") - .icon(CustomIconName::GitPullRequest) + BaseDropdownButton::new("prs") + .action( + BaseButton::new("prs-open") .child( h_flex() - .gap_2() + .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( - Tag::secondary() - .xsmall() - .border_0() - .child(pr_count), - ), + 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); @@ -1166,9 +1191,8 @@ impl RepoDetailView { }), ) .child( - DropdownButton::new("share") - .secondary() - .button( + BaseDropdownButton::new("share") + .action( Button::new("link") .icon(IconName::Copy) .tooltip("Copy ID")