From 56561ce07684ebd2508ab2dea26f6076732dbd92 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 19 Aug 2026 16:32:57 +0700 Subject: [PATCH] update --- crates/dock/src/tab_panel.rs | 182 +++++++++++++++++++++++++++++++---- 1 file changed, 163 insertions(+), 19 deletions(-) diff --git a/crates/dock/src/tab_panel.rs b/crates/dock/src/tab_panel.rs index cb13466..766c7c6 100644 --- a/crates/dock/src/tab_panel.rs +++ b/crates/dock/src/tab_panel.rs @@ -18,7 +18,8 @@ use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::menu::{DropdownMenu, PopupMenu}; use gpui_component::tab::{Tab, TabBar}; use gpui_component::{ - ActiveTheme, AxisExt, ElementExt, IconName, Placement, Selectable, Sizable, h_flex, v_flex, + ActiveTheme, AxisExt, Disableable, ElementExt, IconName, Placement, Selectable, Sizable, + h_flex, v_flex, }; use super::{ @@ -355,6 +356,22 @@ impl TabPanel { cx.notify(); } + /// Switch the active tab to the previous or next tab in the strip. + /// + /// Same as clicking that tab; the tab order is unchanged. + fn activate_relative_tab(&mut self, delta: isize, window: &mut Window, cx: &mut Context) { + if self.collapsed || self.panels.len() < 2 { + return; + } + + let target = self.active_ix as isize + delta; + if !(0..self.panels.len() as isize).contains(&target) { + return; + } + + self.set_active_ix(target as usize, window, cx); + } + /// Queue one reconcile task per frame that notifies panels of their /// frame-end net active state. Using a spawned task (not `defer`) is what /// guarantees the task runs after every same-frame mutation, including @@ -702,6 +719,42 @@ impl TabPanel { ) } + /// The previous/next tab buttons shown in the tab bar's leading prefix. + /// + /// Unlike the dock toggle button they always render, but are disabled at + /// the ends of the tab strip (or while the panel is collapsed). + fn render_prev_next_tab_buttons(&self, cx: &mut Context) -> impl IntoElement { + let prev_enabled = !self.collapsed && self.active_ix > 0; + let next_enabled = !self.collapsed && self.active_ix + 1 < self.panels.len(); + + h_flex() + .gap_1() + .child( + Button::new("tab:prev") + .icon(IconName::ChevronLeft) + .small() + .ghost() + .tab_stop(false) + .tooltip("Previous tab") + .disabled(!prev_enabled) + .on_click(cx.listener(|view, _, window, cx| { + view.activate_relative_tab(-1, window, cx); + })), + ) + .child( + Button::new("tab:next") + .icon(IconName::ChevronRight) + .small() + .ghost() + .tab_stop(false) + .tooltip("Next tab") + .disabled(!next_enabled) + .on_click(cx.listener(|view, _, window, cx| { + view.activate_relative_tab(1, window, cx); + })), + ) + } + fn render_dock_toggle_button( &self, placement: DockPlacement, @@ -770,7 +823,6 @@ impl TabPanel { let view = cx.entity().clone(); let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, window, cx); - let has_extend_dock_button = left_dock_button.is_some(); // macOS: the traffic lights overlay the window's top-left corner. The // left dock (sidebar) normally clears them; when it is closed or @@ -851,23 +903,17 @@ impl TabPanel { .pill() .small() .track_scroll(&self.tab_bar_scroll_handle) - .when( - has_extend_dock_button || needs_traffic_light_padding, - |this| { - this.prefix( - h_flex() - .items_center() - .top_0() - // Right -1 for avoid border overlap with the first tab - .right(-px(1.)) - .h_full() - .px_2() - .when(needs_traffic_light_padding, |this| { - this.pl(px(80.)) - }) - .children(left_dock_button), - ) - }, + .prefix( + h_flex() + .items_center() + .top_0() + // Right -1 for avoid border overlap with the first tab + .right(-px(1.)) + .h_full() + .px_2() + .when(needs_traffic_light_padding, |this| this.pl(px(80.))) + .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); @@ -1956,6 +2002,104 @@ mod tests { assert_eq!(drain(&fixture.log), [("A", false), ("C", true)]); } + #[gpui::test] + fn prev_next_buttons_switch_the_active_tab(cx: &mut TestAppContext) { + let fixture = setup(cx); + let mut cx = VisualTestContext::from_window(fixture.window.into(), cx); + + let (_keep, tab_panel, panels) = build_tabs(&fixture, &["A", "B", "C"], Some(0), &mut cx); + cx.run_until_parked(); + drain(&fixture.log); + + let order = |cx: &App| { + tab_panel + .read(cx) + .panels + .iter() + .map(|p| p.view().entity_id()) + .collect::>() + }; + + // Next: activate B (index 1). + cx.update(|window, cx| { + tab_panel.update(cx, |tab_panel, cx| { + tab_panel.activate_relative_tab(1, window, cx) + }) + }); + cx.run_until_parked(); + assert_eq!(cx.read(|cx| tab_panel.read(cx).active_ix), 1); + assert_eq!( + cx.read(|cx| order(cx)), + vec![ + panels[0].entity_id(), + panels[1].entity_id(), + panels[2].entity_id() + ], + "switching must not reorder the tabs" + ); + assert_eq!(drain(&fixture.log), [("A", false), ("B", true)]); + + // Next: activate C (index 2). + cx.update(|window, cx| { + tab_panel.update(cx, |tab_panel, cx| { + tab_panel.activate_relative_tab(1, window, cx) + }) + }); + cx.run_until_parked(); + assert_eq!(cx.read(|cx| tab_panel.read(cx).active_ix), 2); + assert_eq!(drain(&fixture.log), [("B", false), ("C", true)]); + + // Past the end is a no-op. + cx.update(|window, cx| { + tab_panel.update(cx, |tab_panel, cx| { + tab_panel.activate_relative_tab(1, window, cx) + }) + }); + cx.run_until_parked(); + assert_eq!(cx.read(|cx| tab_panel.read(cx).active_ix), 2); + assert_eq!(drain(&fixture.log), []); + + // Prev: back to B, then to A. + cx.update(|window, cx| { + tab_panel.update(cx, |tab_panel, cx| { + tab_panel.activate_relative_tab(-1, window, cx) + }) + }); + cx.run_until_parked(); + assert_eq!(cx.read(|cx| tab_panel.read(cx).active_ix), 1); + assert_eq!(drain(&fixture.log), [("C", false), ("B", true)]); + + cx.update(|window, cx| { + tab_panel.update(cx, |tab_panel, cx| { + tab_panel.activate_relative_tab(-1, window, cx) + }) + }); + cx.run_until_parked(); + assert_eq!(cx.read(|cx| tab_panel.read(cx).active_ix), 0); + assert_eq!(drain(&fixture.log), [("B", false), ("A", true)]); + } + + #[gpui::test] + fn prev_next_buttons_are_a_noop_for_a_single_tab(cx: &mut TestAppContext) { + let fixture = setup(cx); + let mut cx = VisualTestContext::from_window(fixture.window.into(), cx); + + let (_keep, tab_panel, _) = build_tabs(&fixture, &["A"], None, &mut cx); + cx.run_until_parked(); + drain(&fixture.log); + + cx.update(|window, cx| { + tab_panel.update(cx, |tab_panel, cx| { + tab_panel.activate_relative_tab(1, window, cx); + tab_panel.activate_relative_tab(-1, window, cx); + }) + }); + cx.run_until_parked(); + + assert_eq!(cx.read(|cx| tab_panel.read(cx).active_ix), 0); + assert_eq!(drain(&fixture.log), []); + } + #[gpui::test] fn removing_before_active_keeps_displayed_panel(cx: &mut TestAppContext) { let fixture = setup(cx);