From 584a8385a184b9640c9ce5d0cc7ff2a3c51796d8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 20 Aug 2026 15:04:36 +0700 Subject: [PATCH] update --- Cargo.lock | 1 + Cargo.toml | 1 + crates/dock/Cargo.toml | 1 + crates/dock/src/lib.rs | 3 +- crates/dock/src/tab_bar.rs | 469 ++++++++++++++++++ crates/dock/src/tab_panel.rs | 301 +---------- .../workspace/src/views/repo_detail/diff.rs | 8 +- crates/workspace/src/views/repo_detail/mod.rs | 24 +- crates/workspace/src/views/sidebar/mod.rs | 65 ++- desktop/src/main.rs | 7 +- 10 files changed, 556 insertions(+), 324 deletions(-) create mode 100644 crates/dock/src/tab_bar.rs diff --git a/Cargo.lock b/Cargo.lock index 54b2bef..2b0759e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1829,6 +1829,7 @@ version = "1.0.0" dependencies = [ "anyhow", "gpui", + "gpui-base", "gpui-component", "itertools 0.13.0", "serde", diff --git a/Cargo.toml b/Cargo.toml index 05373d3..5a5f7c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" } # `tree-sitter-languages` enables syntax highlighting for the TextView # code preview (fenced code blocks are highlighted with tree-sitter). gpui-component = { git = "https://github.com/longbridge/gpui-component", features = ["tree-sitter-languages"] } +gpui-base = { git = "https://github.com/longbridge/gpui-component" } dock = { path = "crates/dock" } diff --git a/crates/dock/Cargo.toml b/crates/dock/Cargo.toml index 423801a..555d6fb 100644 --- a/crates/dock/Cargo.toml +++ b/crates/dock/Cargo.toml @@ -8,6 +8,7 @@ publish.workspace = true [dependencies] gpui.workspace = true gpui-component.workspace = true +gpui-base.workspace = true anyhow.workspace = true serde.workspace = true diff --git a/crates/dock/src/lib.rs b/crates/dock/src/lib.rs index cf839ee..935fe36 100644 --- a/crates/dock/src/lib.rs +++ b/crates/dock/src/lib.rs @@ -4,6 +4,7 @@ mod panel; mod resize_handle; mod stack_panel; mod state; +mod tab_bar; mod tab_panel; mod window_controls; @@ -53,7 +54,7 @@ pub(crate) fn t(key: &'static str) -> &'static str { } /// The fixed height of the tab bar, which doubles as the window title bar. -pub(crate) const TAB_BAR_HEIGHT: Pixels = px(44.); +pub const TAB_BAR_HEIGHT: Pixels = px(44.); /// A host-owned drag item, dragged into the dock by the application. #[derive(Clone, Debug)] diff --git a/crates/dock/src/tab_bar.rs b/crates/dock/src/tab_bar.rs new file mode 100644 index 0000000..b404cfd --- /dev/null +++ b/crates/dock/src/tab_bar.rs @@ -0,0 +1,469 @@ +use std::sync::Arc; + +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, App, AppContext, ElementId, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, RenderOnce, ScrollHandle, StatefulInteractiveElement, Styled as _, Window, + div, px, +}; +use gpui_base::{InteractiveElementExt, Tab, Tabs}; +use gpui_component::{ActiveTheme, ElementExt, h_flex}; + +use super::{AnyDrag, DragPanel, PanelView, TabPanel}; +use crate::TAB_BAR_HEIGHT; + +/// The dock's custom tab bar, built on gpui-base's unstyled [`Tab`]/[`Tabs`]. +/// +/// The dock owns the pill presentation; the layout mirrors gpui-component's +/// `TabBar`: a prefix (dock toggle / tab navigation), a scrollable tab strip +/// with a trailing drop target, then a suffix (panel toolbar). +/// +/// Tabs are draggable to move panels between groups and double as drop +/// targets, so panel-level events are forwarded to the owning [`TabPanel`]. +#[derive(IntoElement)] +pub(crate) struct TabBar { + id: ElementId, + panels: Vec>, + active_panel: Option>, + collapsed: bool, + draggable: bool, + droppable: bool, + tab_panel: Entity, + scroll_handle: ScrollHandle, + prefix: Option, + suffix: Option, + empty_space: Option, +} + +impl TabBar { + pub(crate) fn new(id: impl Into, tab_panel: Entity) -> Self { + Self { + id: id.into(), + panels: Vec::new(), + active_panel: None, + collapsed: false, + draggable: false, + droppable: false, + tab_panel, + scroll_handle: ScrollHandle::new(), + prefix: None, + suffix: None, + empty_space: None, + } + } + + /// The panels to show as tabs, in strip order. + pub(crate) fn panels(mut self, panels: Vec>) -> Self { + self.panels = panels; + self + } + + /// The currently active panel; its tab is rendered as the filled pill. + pub(crate) fn active_panel(mut self, active_panel: Option>) -> Self { + self.active_panel = active_panel; + self + } + + /// Collapsed tab panels render no suffix or trailing drop target, and + /// their tabs lose the active style and all interactions. + pub(crate) fn collapsed(mut self, collapsed: bool) -> Self { + self.collapsed = collapsed; + self + } + + /// Whether the tabs can start a panel drag. + pub(crate) fn draggable(mut self, draggable: bool) -> Self { + self.draggable = draggable; + self + } + + /// Whether the tabs and trailing space accept drops. + pub(crate) fn droppable(mut self, droppable: bool) -> Self { + self.droppable = droppable; + self + } + + /// Track the strip's scroll state with the given handle, so callers can + /// scroll a tab into view with [`ScrollHandle::scroll_to_item`]. + pub(crate) fn scroll_handle(mut self, scroll_handle: &ScrollHandle) -> Self { + self.scroll_handle = scroll_handle.clone(); + self + } + + /// Element shown before the tab strip. + pub(crate) fn prefix(mut self, prefix: impl IntoElement) -> Self { + self.prefix = Some(prefix.into_any_element()); + self + } + + /// Element shown after the tab strip. + pub(crate) fn suffix(mut self, suffix: impl IntoElement) -> Self { + self.suffix = Some(suffix.into_any_element()); + self + } + + /// Replace the trailing empty space (the drop target after the last tab). + #[cfg(test)] + pub(crate) fn empty_space(mut self, empty_space: impl IntoElement) -> Self { + self.empty_space = Some(empty_space.into_any_element()); + self + } + + fn render_tab( + &self, + ix: usize, + panel: Arc, + active: bool, + window: &mut Window, + cx: &mut App, + ) -> Tab { + // While collapsed, tabs lose the active style and all interactions. + let droppable = self.collapsed; + let tab_panel = self.tab_panel.clone(); + + // The `ix` element id keeps each tab's identity stable across renders. + Tab::new(ix) + .h_6() + .px_3() + .text_sm() + .whitespace_nowrap() + .flex() + .items_center() + .justify_center() + .gap_1() + .flex_shrink_0() + .overflow_hidden() + .rounded(cx.theme().radius) + .text_color(cx.theme().foreground) + .map(|this| { + if let Some(tab_name) = panel.tab_name(cx) { + this.child(tab_name) + } else { + this.child(panel.title(window, cx)) + } + }) + // Pill presentation: the selected tab is the filled pill, the + // rest are transparent until hovered. + .styles(|styles| { + styles.selected(|style| { + style + .text_color(cx.theme().tab_active_foreground) + .bg(cx.theme().tab_active) + }) + }) + .hover(|this| { + if active { + this + } else { + this.text_color(cx.theme().secondary_foreground) + .bg(cx.theme().secondary_hover) + } + }) + .selected(active) + .on_click(move |_, window, cx| { + tab_panel.update(cx, |view, cx| view.set_active_ix(ix, window, cx)); + }) + .when(!droppable, |this| { + this.when(self.draggable, |this| { + this.on_drag( + DragPanel::new(panel.clone(), self.tab_panel.clone()), + |drag, offset, _, cx| { + cx.stop_propagation(); + drag.drag_offset.set(offset); + cx.new(|_| drag.clone()) + }, + ) + }) + .when(self.droppable, |this| { + this.drag_over::(|this, _, _, cx| { + this.rounded_l_none() + .border_l_2() + .border_r_0() + .border_color(cx.theme().drag_border) + }) + .on_drop({ + let tab_panel = self.tab_panel.clone(); + move |drag: &DragPanel, window, cx| { + tab_panel.update(cx, |view, cx| { + view.will_split_placement = None; + view.on_drop(drag, Some(ix), true, window, cx); + }); + } + }) + .drag_over::(|this, _, _, cx| { + this.rounded_l_none() + .border_l_2() + .border_r_0() + .border_color(cx.theme().drag_border) + }) + .on_drop({ + let tab_panel = self.tab_panel.clone(); + move |item: &AnyDrag, _, cx| { + tab_panel.update(cx, |view, cx| { + view.will_split_placement = None; + view.emit_drag_drop(item, None, cx); + }); + } + }) + }) + }) + } + + fn render_empty_space(&self) -> AnyElement { + let tabs_count = self.panels.len(); + + // The strip after the last tab is a drop target for panels and + // host-owned drag items. Its left edge (right after the last tab) + // marks the start of the title-bar drag overlay. + let mut empty = div() + .id("tab-bar-empty-space") + .h_full() + .flex_grow_1() + .min_w_16() + .on_prepaint({ + let view = self.tab_panel.clone(); + move |bounds, _, cx| { + view.update(cx, |this, cx| { + if this.title_bar_strip_bounds != Some(bounds) { + this.title_bar_strip_bounds = Some(bounds); + cx.notify(); + } + }); + } + }); + + if self.droppable { + empty = empty + .drag_over::(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target)) + .on_drop({ + let view = self.tab_panel.clone(); + move |drag: &DragPanel, window, cx| { + view.update(cx, |this, cx| { + this.will_split_placement = None; + + // Dropping a panel from this same tab group onto + // the strip moves it after the last tab. + let ix = if drag.tab_panel == cx.entity() { + Some(tabs_count - 1) + } else { + None + }; + + this.on_drop(drag, ix, false, window, cx); + }); + } + }) + .drag_over::(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target)) + .on_drop({ + let view = self.tab_panel.clone(); + move |item: &AnyDrag, _, cx| { + view.update(cx, |this, cx| { + this.will_split_placement = None; + this.emit_drag_drop(item, None, cx); + }); + } + }); + } + + empty.into_any_element() + } +} + +impl RenderOnce for TabBar { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let tabs: Vec<_> = self + .panels + .iter() + .enumerate() + .filter_map(|(ix, panel)| { + let mut active = self.active_panel.as_ref() == Some(panel); + if !panel.visible(cx) { + return None; + } + // Always not show active tab style, if the panel is collapsed + if self.collapsed { + active = false; + } + Some(self.render_tab(ix, panel.clone(), active, window, cx)) + }) + .collect(); + + let empty_space = match self.empty_space { + Some(empty_space) => empty_space, + None => self.render_empty_space(), + }; + + Tabs::new(self.id) + .px(px(-1.)) + .h(TAB_BAR_HEIGHT) + .flex() + .items_center() + .text_color(cx.theme().tab_foreground) + .when_some(self.prefix, |this, prefix| this.child(prefix)) + .child( + h_flex().id("tabs").flex_1().overflow_x_hidden().child( + h_flex() + .id("tabs-inner") + .relative() + .gap(px(4.)) + .overflow_x_scroll() + .lock_scroll_axis() + .track_scroll(&self.scroll_handle) + .children(tabs) + .when(!self.collapsed, |this| this.child(empty_space)), + ), + ) + .when_some(self.suffix, |this, suffix| { + this.when(!self.collapsed, |this| this.child(suffix)) + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + + use gpui::{ + Context, Entity, MouseButton, Render, TestAppContext, VisualTestContext, WindowOptions, + div, px, size, + }; + use gpui_component::{Root, Theme, v_flex}; + + use super::*; + use crate::DockArea; + use crate::tab_panel::title_bar_drag_handlers; + + #[derive(Default)] + struct ProbeFlags { + empty_down: AtomicBool, + empty_click: AtomicBool, + control_down: AtomicBool, + control_click: AtomicBool, + } + + struct ProbeView { + flags: Entity, + tab_panel: Entity, + } + + impl Render for ProbeView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let flags = self.flags.clone(); + + let empty = title_bar_drag_handlers( + div() + .id("empty-space") + .h(TAB_BAR_HEIGHT) + .flex_grow_1() + .min_w_16(), + window, + cx, + ) + .debug_selector(|| "empty-space".into()) + .on_mouse_down(MouseButton::Left, { + let flags = flags.clone(); + move |_, _, cx| flags.update(cx, |f, _| f.empty_down.store(true, Ordering::SeqCst)) + }) + .on_click({ + let flags = flags.clone(); + move |_, _, cx| flags.update(cx, |f, _| f.empty_click.store(true, Ordering::SeqCst)) + }); + + let control = + title_bar_drag_handlers(div().id("control-space").h_8().flex_grow_1(), window, cx) + .debug_selector(|| "control-space".into()) + .on_mouse_down(MouseButton::Left, { + let flags = flags.clone(); + move |_, _, cx| { + flags.update(cx, |f, _| f.control_down.store(true, Ordering::SeqCst)) + } + }) + .on_click({ + let flags = flags.clone(); + move |_, _, cx| { + flags.update(cx, |f, _| f.control_click.store(true, Ordering::SeqCst)) + } + }); + + v_flex() + .size_full() + .child(TabBar::new("probe-bar", self.tab_panel.clone()).empty_space(empty)) + .child(control) + } + } + + /// Diagnostic: verify that the tab bar's trailing empty space receives + /// mouse events when wrapped by `title_bar_drag_handlers`, i.e. the + /// strip's scroll containers do not swallow them. + #[gpui::test] + fn tab_bar_empty_space_receives_events(cx: &mut TestAppContext) { + let (flags, handle) = cx.update(|cx| { + cx.set_global(Theme::default()); + let flags = cx.new(|_| ProbeFlags::default()); + let handle = cx.open_window( + WindowOptions { + window_bounds: Some(gpui::WindowBounds::Windowed(gpui::Bounds { + origin: gpui::Point::default(), + size: size(px(800.), px(100.)), + })), + ..Default::default() + }, + |window, cx| { + let flags = flags.clone(); + let dock_area = cx.new(|cx| DockArea::new("probe-dock", None, window, cx)); + let tab_panel = + cx.new(|cx| TabPanel::new(None, dock_area.downgrade(), window, cx)); + let content = cx.new(|_| ProbeView { flags, tab_panel }); + cx.new(|cx| Root::new(content, window, cx)) + }, + ); + (flags, handle.unwrap()) + }); + let mut cx = VisualTestContext::from_window(handle.into(), cx); + + cx.run_until_parked(); + cx.update(|window, cx| { + _ = window.draw(cx); + }); + + let empty_bounds = cx + .debug_bounds("empty-space") + .expect("empty-space must be laid out"); + let control_bounds = cx + .debug_bounds("control-space") + .expect("control-space must be laid out"); + let empty_center = empty_bounds.center(); + let control_center = control_bounds.center(); + + // Control: a plain div with the same handlers, outside the tab bar. + cx.simulate_click(control_center, Default::default()); + // Target: the tab bar's empty-space strip. + cx.simulate_click(empty_center, Default::default()); + + let (empty_down, empty_click, control_down, control_click) = cx.read(|cx| { + let flags = flags.read(cx); + ( + flags.empty_down.load(Ordering::SeqCst), + flags.empty_click.load(Ordering::SeqCst), + flags.control_down.load(Ordering::SeqCst), + flags.control_click.load(Ordering::SeqCst), + ) + }); + assert!( + control_down, + "control mouse_down must fire at {control_center:?}" + ); + assert!( + control_click, + "control click must fire at {control_center:?}" + ); + assert!( + empty_down, + "empty-space mouse_down must fire at {empty_center:?}" + ); + assert!( + empty_click, + "empty-space click must fire at {empty_center:?}" + ); + } +} diff --git a/crates/dock/src/tab_panel.rs b/crates/dock/src/tab_panel.rs index f9da1f9..38b8d58 100644 --- a/crates/dock/src/tab_panel.rs +++ b/crates/dock/src/tab_panel.rs @@ -16,17 +16,17 @@ use gpui::{ use gpui_component::animation::{Lerp, ease_out_cubic}; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::menu::{DropdownMenu, PopupMenu}; -use gpui_component::tab::{Tab, TabBar}; use gpui_component::{ ActiveTheme, AxisExt, Disableable, ElementExt, IconName, Placement, Selectable, Sizable, h_flex, v_flex, }; +use super::tab_bar::TabBar; use super::{ AnyDrag, ClosePanel, DockArea, DockEvent, DockPlacement, DropTarget, Panel, PanelControl, PanelEvent, PanelState, PanelView, StackPanel, ToggleZoom, }; -use crate::{PanelInfo, TAB_BAR_HEIGHT, t, window_controls}; +use crate::{PanelInfo, t, window_controls}; #[derive(Clone)] struct TabState { @@ -41,7 +41,7 @@ struct TabState { pub(crate) struct DragPanel { pub(crate) panel: Arc, pub(crate) tab_panel: Entity, - drag_offset: Rc>>, + pub(crate) drag_offset: Rc>>, drag_session_id: u64, } @@ -150,7 +150,7 @@ pub struct TabPanel { zoomed: bool, collapsed: bool, /// When drag move, will get the placement of the panel to be split - will_split_placement: Option, + pub(crate) will_split_placement: Option, drop_placeholder_animation: Option, drop_placeholder_animation_name: SharedString, @@ -160,7 +160,7 @@ pub struct TabPanel { title_bar_bounds: Option>, /// Bounds of the tab bar's trailing empty space (right after the last /// tab), which marks where the draggable region starts. - title_bar_strip_bounds: Option>, + pub(crate) title_bar_strip_bounds: Option>, /// Bounds of the tab bar's suffix (toolbar) area, which marks where the /// draggable region ends. title_bar_suffix_bounds: Option>, @@ -342,7 +342,7 @@ impl TabPanel { self.active_ix } - fn set_active_ix(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { + pub(crate) fn set_active_ix(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { if ix == self.active_ix { return; } @@ -845,10 +845,8 @@ impl TabPanel { self.tab_bar_scroll_handle.scroll_to_item(visible_ix); } - let tabs_count = self.panels.len(); - - // TabBar lays out its scrollable content at content width, so the - // strip after the last tab only spans `min_w_16` — the rest of the + // The tab strip lays out its scrollable content at content width, so + // the area after the last tab only spans `min_w_16` — the rest of the // tab bar has no element at all. Cover that dead zone with a // measured overlay so the whole non-interactive area can drag the // window. Its span is [last tab's right edge, suffix's left edge]. @@ -898,11 +896,13 @@ impl TabPanel { } }) .child( - TabBar::new("tab-bar") - .h(TAB_BAR_HEIGHT) - .pill() - .small() - .track_scroll(&self.tab_bar_scroll_handle) + TabBar::new("tab-bar", cx.entity()) + .panels(self.panels.clone()) + .active_panel(state.active_panel.clone()) + .collapsed(self.collapsed) + .draggable(state.draggable) + .droppable(state.droppable) + .scroll_handle(&self.tab_bar_scroll_handle) .prefix( h_flex() .items_center() @@ -916,132 +916,6 @@ impl TabPanel { .children(left_dock_button) .child(self.render_prev_next_tab_buttons(cx)), ) - .children(self.panels.iter().enumerate().filter_map(|(ix, panel)| { - let mut active = state.active_panel.as_ref() == Some(panel); - let droppable = self.collapsed; - - if !panel.visible(cx) { - return None; - } - - // Always not show active tab style, if the panel is collapsed - if self.collapsed { - active = false; - } - - Some( - // Note: upstream also calls the crate-private `Tab::ix(ix)` and - // `Tab::tab_bar_prefix(...)` here. `TabBar` re-applies `ix` per - // child itself, and `tab_bar_prefix` only clears the first - // tab's left border when the bar has no prefix — both are - // inaccessible from outside gpui-component, so they are - // omitted. - Tab::new() - .map(|this| { - if let Some(tab_name) = panel.tab_name(cx) { - this.child(tab_name) - } else { - this.child(panel.title(window, cx)) - } - }) - .selected(active) - .on_click(cx.listener({ - move |view, _, window, cx| { - view.set_active_ix(ix, window, cx); - } - })) - .when(!droppable, |this| { - this.when(state.draggable, |this| { - this.on_drag( - DragPanel::new(panel.clone(), view.clone()), - |drag, offset, _, cx| { - cx.stop_propagation(); - drag.drag_offset.set(offset); - cx.new(|_| drag.clone()) - }, - ) - }) - .when( - state.droppable, - |this| { - this.drag_over::(|this, _, _, cx| { - this.rounded_l_none() - .border_l_2() - .border_r_0() - .border_color(cx.theme().drag_border) - }) - .on_drop(cx.listener( - move |this, drag: &DragPanel, window, cx| { - this.will_split_placement = None; - this.on_drop(drag, Some(ix), true, window, cx) - }, - )) - .drag_over::(|this, _, _, cx| { - this.rounded_l_none() - .border_l_2() - .border_r_0() - .border_color(cx.theme().drag_border) - }) - .on_drop(cx.listener( - |this, item: &AnyDrag, _, cx| { - this.will_split_placement = None; - this.emit_drag_drop(item, None, cx); - }, - )) - }, - ) - }), - ) - })) - .last_empty_space( - // Empty space to allow dropping at the last tab. Its left - // edge (right after the last tab) marks the start of the - // title-bar drag overlay. - div() - .id("tab-bar-empty-space") - .h_full() - .flex_grow_1() - .min_w_16() - .on_prepaint({ - let view = view.clone(); - move |bounds, _, cx| { - view.update(cx, |this, cx| { - if this.title_bar_strip_bounds != Some(bounds) { - this.title_bar_strip_bounds = Some(bounds); - cx.notify(); - } - }); - } - }) - .when(state.droppable, |this| { - let view = view.clone(); - this.drag_over::(|this, _, _, cx| { - this.bg(cx.theme().tokens.drop_target) - }) - .on_drop(cx.listener( - move |this, drag: &DragPanel, window, cx| { - this.will_split_placement = None; - - let ix = if drag.tab_panel == view { - Some(tabs_count - 1) - } else { - None - }; - - this.on_drop(drag, ix, false, window, cx) - }, - )) - .drag_over::(|this, _, _, cx| { - this.bg(cx.theme().tokens.drop_target) - }) - .on_drop( - cx.listener(|this, item: &AnyDrag, _, cx| { - this.will_split_placement = None; - this.emit_drag_drop(item, None, cx); - }), - ) - }), - ) .when(!self.collapsed, |this| { this.suffix( h_flex() @@ -1249,7 +1123,7 @@ impl TabPanel { /// Report a host-owned drag landing on this TabPanel. `placement` is `None` /// to merge into this tab group instead of splitting. - fn emit_drag_drop( + pub(crate) fn emit_drag_drop( &mut self, item: &AnyDrag, placement: Option, @@ -1269,7 +1143,7 @@ impl TabPanel { /// Handle the drop event when dragging a panel /// /// - `active` - When true, the panel will be active after the drop - fn on_drop( + pub(crate) fn on_drop( &mut self, drag: &DragPanel, ix: Option, @@ -1575,11 +1449,9 @@ impl Render for TabPanel { #[cfg(test)] mod tests { use std::sync::Mutex; - use std::sync::atomic::{AtomicBool, Ordering}; - use gpui::{Axis, TestAppContext, VisualTestContext, WindowHandle, WindowOptions, size}; - use gpui_component::tab::{Tab, TabBar}; - use gpui_component::{Root, Theme, v_flex}; + use gpui::{Axis, TestAppContext, VisualTestContext, WindowHandle}; + use gpui_component::{Root, Theme}; use super::*; use crate::DockItem; @@ -2291,140 +2163,9 @@ mod tests { }); } - #[derive(Default)] - struct ProbeFlags { - empty_down: AtomicBool, - empty_click: AtomicBool, - control_down: AtomicBool, - control_click: AtomicBool, - } - - struct ProbeView { - flags: Entity, - } - - impl Render for ProbeView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let flags = self.flags.clone(); - - let empty = title_bar_drag_handlers( - div().id("empty-space").h_full().flex_grow_1().min_w_16(), - window, - cx, - ) - .debug_selector(|| "empty-space".into()) - .on_mouse_down(MouseButton::Left, { - let flags = flags.clone(); - move |_, _, cx| flags.update(cx, |f, _| f.empty_down.store(true, Ordering::SeqCst)) - }) - .on_click({ - let flags = flags.clone(); - move |_, _, cx| flags.update(cx, |f, _| f.empty_click.store(true, Ordering::SeqCst)) - }); - - let control = - title_bar_drag_handlers(div().id("control-space").h_8().flex_grow_1(), window, cx) - .debug_selector(|| "control-space".into()) - .on_mouse_down(MouseButton::Left, { - let flags = flags.clone(); - move |_, _, cx| { - flags.update(cx, |f, _| f.control_down.store(true, Ordering::SeqCst)) - } - }) - .on_click({ - let flags = flags.clone(); - move |_, _, cx| { - flags.update(cx, |f, _| f.control_click.store(true, Ordering::SeqCst)) - } - }); - - v_flex() - .size_full() - .child( - TabBar::new("probe-bar") - .child(Tab::new().label("Alpha")) - .child(Tab::new().label("Beta")) - .suffix(div()) - .last_empty_space(empty), - ) - .child(control) - } - } - - /// Diagnostic: verify that the tab bar's `last_empty_space` element - /// receives mouse events when wrapped by `title_bar_drag_handlers`. - #[gpui::test] - fn tab_bar_empty_space_receives_events(cx: &mut TestAppContext) { - let (flags, handle) = cx.update(|cx| { - cx.set_global(Theme::default()); - let flags = cx.new(|_| ProbeFlags::default()); - let handle = cx.open_window( - WindowOptions { - window_bounds: Some(gpui::WindowBounds::Windowed(gpui::Bounds { - origin: gpui::Point::default(), - size: size(px(800.), px(100.)), - })), - ..Default::default() - }, - |window, cx| { - let flags = flags.clone(); - let content = cx.new(|_| ProbeView { flags }); - cx.new(|cx| Root::new(content, window, cx)) - }, - ); - (flags, handle.unwrap()) - }); - let mut cx = VisualTestContext::from_window(handle.into(), cx); - - cx.run_until_parked(); - cx.update(|window, cx| { - _ = window.draw(cx); - }); - - let empty_bounds = cx - .debug_bounds("empty-space") - .expect("empty-space must be laid out"); - let control_bounds = cx - .debug_bounds("control-space") - .expect("control-space must be laid out"); - let empty_center = empty_bounds.center(); - let control_center = control_bounds.center(); - - // Control: a plain div with the same handlers, outside the TabBar. - cx.simulate_click(control_center, Default::default()); - // Target: the TabBar's last_empty_space. - cx.simulate_click(empty_center, Default::default()); - - let (empty_down, empty_click, control_down, control_click) = cx.read(|cx| { - let flags = flags.read(cx); - ( - flags.empty_down.load(Ordering::SeqCst), - flags.empty_click.load(Ordering::SeqCst), - flags.control_down.load(Ordering::SeqCst), - flags.control_click.load(Ordering::SeqCst), - ) - }); - assert!( - control_down, - "control mouse_down must fire at {control_center:?}" - ); - assert!( - control_click, - "control click must fire at {control_center:?}" - ); - assert!( - empty_down, - "empty-space mouse_down must fire at {empty_center:?}" - ); - assert!( - empty_click, - "empty-space click must fire at {empty_center:?}" - ); - } - /// The title-bar drag overlay must cover the dead zone between the last - /// tab and the suffix (TabBar's scrollable content is content-width, so - /// that area has no element of its own). + /// tab and the suffix (the tab strip's scrollable content is + /// content-width, so that area has no element of its own). #[gpui::test] fn title_bar_drag_overlay_covers_the_dead_zone(cx: &mut TestAppContext) { let fixture = setup(cx); diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/repo_detail/diff.rs index 1a49da4..3679c12 100644 --- a/crates/workspace/src/views/repo_detail/diff.rs +++ b/crates/workspace/src/views/repo_detail/diff.rs @@ -6,13 +6,13 @@ use std::path::PathBuf; use std::rc::Rc; +use dock::{Panel, PanelEvent}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, ScrollStrategy, SharedString, Size, WeakEntity, Window, div, px, size, }; use gpui_component::clipboard::Clipboard; -use dock::{Panel, PanelEvent}; use gpui_component::list::ListItem; use gpui_component::resizable::{resizable_panel, v_resizable}; use gpui_component::scroll::{ScrollableElement, Scrollbar}; @@ -486,8 +486,6 @@ impl CommitDiffView { v_flex() .px_4() - .pt_2() - .pb_4() .w_full() .gap_4() .child( @@ -609,8 +607,8 @@ impl Render for CommitDiffView { v_resizable("commit-diff") .child( resizable_panel() - .size(px(170.)) - .size_range(px(100.)..px(400.)) + .size(px(180.)) + .size_range(px(120.)..px(420.)) .flex_none() .bg(cx.theme().background) .child(self.render_header(cx)), diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index b79c2d6..108f550 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -941,8 +941,7 @@ impl RepoDetailView { v_flex() .px_4() - .pt_2() - .pb_2() + .pb_4() .w_full() .gap_8() .border_b_1() @@ -1213,16 +1212,19 @@ impl Render for RepoDetailView { v_flex() .id("repo") .size_full() + .gap_4() .child(self.render_header(cx)) - .child(match self.active_tab { - 0 => h_flex() - .flex_1() - .w_full() - .overflow_hidden() - .child(Self::render_tree_column(tree_state, view, cx)) - .child(self.render_content_column(pane_title, cx)) - .into_any_element(), - _ => self.render_commits_tab(cx), + .map(|this| match self.active_tab { + 0 => this.child( + h_flex() + .flex_1() + .w_full() + .overflow_hidden() + .child(Self::render_tree_column(tree_state, view, cx)) + .child(self.render_content_column(pane_title, cx)) + .into_any_element(), + ), + _ => this.child(self.render_commits_tab(cx)), }) } } diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 43bf050..f45f46f 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use assets::CustomIconName; -use dock::{DockArea, DockPlacement, Panel, PanelEvent, title_bar_drag_handlers}; +use dock::{DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, title_bar_drag_handlers}; use gpui::prelude::*; use gpui::{ App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render, @@ -11,7 +11,7 @@ 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_state::{Backend, BackendEvent, ProfileStore}; +use signed_state::{Backend, BackendEvent, Profile, ProfileStore}; use super::RepoListView; @@ -100,6 +100,42 @@ impl SidebarPanel { fn open_import(&mut self, window: &mut Window, cx: &mut Context) { import_dialog::open(window, cx); } + + /// Render the user avatar and name in the sidebar, wrapped in the window titlebar drag area. + fn render_user( + &self, + profile: &Profile, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let name = profile.name(); + let picture = profile.picture(); + + title_bar_drag_handlers( + h_flex() + .id("user") + .h(TAB_BAR_HEIGHT) + .when(cfg!(target_os = "macos"), |this| this.pl(px(80.))) + .child( + div().child( + 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)) + .small() + .border_0(), + ) + .child(div().text_xs().font_semibold().child(name)), + ), + ), + ), + window, + cx, + ) + } } impl Panel for SidebarPanel { @@ -180,30 +216,7 @@ impl Render for SidebarPanel { div() .flex_1() .when_some(profile.as_ref(), |this, profile| { - let name = profile.name(); - let picture = profile.picture(); - - this.child(title_bar_drag_handlers( - h_flex() - .id("sidebar-profile") - .h_12() - .px_3() - .gap_2() - // The macOS traffic lights overlay the - // top-left corner of the window; keep the - // profile content clear of them. - .when(cfg!(target_os = "macos"), |this| this.pl(px(80.))) - .child( - Avatar::new() - .name(name.clone()) - .when_some(picture, |this, url| this.src(url)) - .small() - .border_0(), - ) - .child(div().text_sm().child(name)), - window, - cx, - )) + this.child(self.render_user(profile, window, cx)) }) .child( v_flex() diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 3aa8544..43a4c04 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use assets::Assets; +use dock::TAB_BAR_HEIGHT; use gpui::*; use gpui_component::theme; use gpui_platform::application; @@ -53,7 +54,11 @@ fn main() { app_id: Some("Signed".to_owned()), titlebar: Some(TitlebarOptions { title: Some(SharedString::new_static("Signed")), - traffic_light_position: Some(point(px(9.0), px(9.0))), + // AppKit's traffic-light buttons are 14 pt tall; offset them so their vertical center matches the tab bar. + traffic_light_position: Some(point( + px(9.0), + px(TAB_BAR_HEIGHT / px(2.) - 14. / 2.), + )), appears_transparent: true, }), app_owns_titlebar_drag: true,