This commit is contained in:
2026-08-19 16:32:57 +07:00
parent cc4fcd315c
commit 56561ce076
+163 -19
View File
@@ -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<Self>) {
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<Self>) -> 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::<Vec<_>>()
};
// 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);