From cda04a1930e2cd4e83267f27484a84c2d1c821d0 Mon Sep 17 00:00:00 2001 From: Konstantin Zadorozhny Date: Mon, 19 Oct 2026 20:48:11 -0700 Subject: [PATCH] feat(tabs): split the New Tab button into + and a menu chevron (#1199) * feat(tabs): add sidebar new tab options menu Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(tabs): split every New Tab button, not just the sidebar's The sidebar header was the only place the + opened a tab outright; the strip and the collapsed rail still dropped the menu on every press, so the same glyph did two different things depending on where the tabs sat and whether the rail was open. Route all three through new_tab_button as one split control, count its chevron in the title bar width budgets, render the trigger's own box so its interactivity is not dropped, and drop tests that only re-checked segment_corners and a bool setter. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- src/ui/i18n/en.rs | 1 + src/ui/i18n/ja.rs | 1 + src/ui/i18n/mod.rs | 1 + src/ui/i18n/ru.rs | 1 + src/ui/i18n/zh.rs | 1 + src/ui/tab_strip.rs | 192 +++++++++++++++++++++++++++++++++----------- 6 files changed, 151 insertions(+), 46 deletions(-) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 542abf69..a21ba7bb 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -2167,6 +2167,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppMenuWindow => "Window", L10nKey::AppMenuHelp => "Help", L10nKey::AppMenuNewTab => "New Tab", + L10nKey::NewTabMoreOptions => "More New Tab Options", L10nKey::AppMenuNewWorkspace => "New Workspace…", L10nKey::AppMenuNewWorktreeTab => "New Worktree Tab…", L10nKey::AppMenuSplitRight => "Split Right", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index c282f87f..cec38b8f 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -2234,6 +2234,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuWindow => "ウィンドウ", L10nKey::AppMenuHelp => "ヘルプ", L10nKey::AppMenuNewTab => "新規タブ", + L10nKey::NewTabMoreOptions => "その他の新規タブオプション", L10nKey::AppMenuNewWorkspace => "新規ワークスペース…", L10nKey::AppMenuNewWorktreeTab => "新規ワークツリータブ…", L10nKey::AppMenuSplitRight => "右に分割", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index e14dc8a9..aacf4ee2 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1264,6 +1264,7 @@ l10n_keys! { AppMenuWindow, AppMenuHelp, AppMenuNewTab, + NewTabMoreOptions, AppMenuNewWorkspace, AppMenuNewWorktreeTab, AppMenuSplitRight, diff --git a/src/ui/i18n/ru.rs b/src/ui/i18n/ru.rs index dad9171d..858c8af7 100644 --- a/src/ui/i18n/ru.rs +++ b/src/ui/i18n/ru.rs @@ -1714,6 +1714,7 @@ pub fn translate_ru(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuWindow => "Окно", L10nKey::AppMenuHelp => "Справка", L10nKey::AppMenuNewTab => "Новая вкладка", + L10nKey::NewTabMoreOptions => "Дополнительные параметры новой вкладки", L10nKey::AppMenuNewWorkspace => "Новая рабочая область…", L10nKey::AppMenuNewWorktreeTab => "Новая вкладка worktree…", L10nKey::AppMenuSplitRight => "Разделить вправо", diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 78051ec3..90788c6e 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -2016,6 +2016,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuWindow => "窗口", L10nKey::AppMenuHelp => "帮助", L10nKey::AppMenuNewTab => "新标签页", + L10nKey::NewTabMoreOptions => "更多新标签页选项", L10nKey::AppMenuNewWorkspace => "新工作区…", L10nKey::AppMenuNewWorktreeTab => "新 worktree 标签页…", L10nKey::AppMenuSplitRight => "向右分屏", diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 92e5c237..9434fd1d 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -1,16 +1,14 @@ use gpui::{ - Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, Context, Edges, FontWeight, - MouseButton, Pixels, SharedString, Window, canvas, deferred, div, ease_out_quint, - linear_color_stop, linear_gradient, prelude::*, px, + Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, Context, Div, Edges, FontWeight, + Interactivity, MouseButton, Pixels, SharedString, Stateful, StyleRefinement, Window, canvas, + deferred, div, ease_out_quint, linear_color_stop, linear_gradient, prelude::*, px, }; use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants as _}; use gpui_component::input::Input; use gpui_component::kbd::Kbd; -use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; +use gpui_component::menu::{ContextMenuExt as _, DropdownMenu, PopupMenu, PopupMenuItem}; use gpui_component::tooltip::Tooltip; -use gpui_component::{ - ActiveTheme as _, Icon, IconName, Selectable as _, Side, Sizable as _, h_flex, -}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Selectable, Side, Sizable as _, h_flex}; use unicode_segmentation::UnicodeSegmentation as _; use crate::core::actions::{ @@ -27,6 +25,7 @@ use crate::ui::app::{SpawnWhere, TILE_GLYPH, TILE_SIZE, Tab, Tty7App, tile_trail use crate::ui::hints::tab_badge_label; use crate::ui::i18n::{L10nKey, t, t_fmt}; use crate::ui::reorder::{self, Reorder, Surface}; +use crate::ui::rounding::{RoundedCorners as _, segment_corners}; use crate::ui::search::SearchTab; /// One duration and one curve for every transition the app runs, so a fade and @@ -864,7 +863,7 @@ fn collapsed_rail_group_w() -> f32 { true => 0., false => (CONTENT_INSET - tile_trailing_inset()) + WINDOW_MARK_SIZE + 4. + 2., }; - crate::ui::app::title_bar_hug_offset() + mark + TILE_SIZE + 2. + TILE_SIZE + crate::ui::app::title_bar_hug_offset() + mark + NEW_TAB_W + 2. + TILE_SIZE } /// How wide the New Tab menu is allowed to get. @@ -1874,19 +1873,22 @@ impl Tty7App { (!raw.trim().is_empty()).then_some(raw) } - /// The New Tab control: one `+` that drops the list of everything it could - /// open — the installed shells, and the saved SSH hosts. + /// The New Tab control, in two halves: `+` opens a tab with the default + /// shell outright, and the chevron beside it drops the list of everything + /// else it could open — the installed shells, the agents, the saved SSH + /// hosts. /// - /// It was briefly split in two, a `+` that opened a tab outright next to a - /// caret for the list, the way Windows Terminal and VS Code split theirs. - /// That buys back the click the menu costs on the most common action, and - /// it costs a second mark in a row of single ones. This row is four icons - /// wide and reads as four icons; a fifth that was half of the fourth had - /// to earn its place by looking like a pair, and a pair is exactly what a - /// chrome row of plain icons has no vocabulary for. + /// It used to be one `+` that only ever dropped the list, with ⌘T standing + /// in for the click that cost. But a plain tab is what the button is + /// pressed for most, and routing every press through a menu charged that + /// click to everyone who reaches for the pointer rather than the chord. So + /// the pair is drawn as one tile split down the middle — each half hovers + /// on its own, square where they meet, rounded only at the outer edges — + /// and still reads as one control in a row of single ones. /// - /// So: one tile, drawn and hovered like every other tile beside it, and - /// the click it costs is answered by ⌘T rather than by a second button. + /// Every New Tab in the chrome comes through here — the strip, the + /// collapsed rail, the sidebar header — so `+` does the same thing + /// wherever it is, and the rail keeps the same control when it opens. pub(crate) fn new_tab_button( &self, id: &'static str, @@ -1904,37 +1906,135 @@ impl Tty7App { cx: &Context, ) -> impl IntoElement + use<> { let app = cx.entity().downgrade(); - let glyph = match tile < crate::ui::app::TILE_SIZE { + NewTabSplit::new(id, tile, app.clone()) + // Built when the menu opens, not when the strip draws: this + // closure runs once per press, and again after each dismissal. + .dropdown_menu(move |menu, window, cx| { + let Some(this) = app.upgrade() else { + return menu; + }; + this.read(cx) + .new_tab_menu_rows(app.clone(), cx) + .build(menu, window) + }) + } +} + +/// How wide [`Tty7App::new_tab_button`] is: the `+` tile and the chevron. +/// Anything that budgets the title bar row around it counts it at this. +pub(crate) const NEW_TAB_W: f32 = crate::ui::app::TILE_SIZE + MIN_TARGET; + +/// The two halves of [`Tty7App::new_tab_button`], as one trigger for the menu. +/// +/// The menu wants a trigger it can mark selected while it is open, and that +/// has to be the pair rather than the chevron alone: anchored to the chevron +/// the list would open a tile's width to the right of the `+` it belongs to. +/// The popover toggles on a press anywhere in the pair, so the `+` stops its +/// own press from reaching it. +#[derive(IntoElement)] +struct NewTabSplit { + id: &'static str, + /// The pair's own box. The trigger traits hand out its interactivity, so + /// it is what renders — not a fresh `div` that would drop whatever was + /// hung on it. + base: Stateful
, + style: StyleRefinement, + selected: bool, + tile: f32, + app: gpui::WeakEntity, +} + +impl NewTabSplit { + fn new(id: &'static str, tile: f32, app: gpui::WeakEntity) -> Self { + Self { + id, + base: div().id(SharedString::from(format!("{id}-split"))), + style: StyleRefinement::default(), + selected: false, + tile, + app, + } + } +} + +impl Selectable for NewTabSplit { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +impl Styled for NewTabSplit { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl InteractiveElement for NewTabSplit { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl DropdownMenu for NewTabSplit {} + +impl RenderOnce for NewTabSplit { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let glyph = match self.tile < crate::ui::app::TILE_SIZE { true => RAIL_TILE_GLYPH, false => TILE_GLYPH, }; - chrome_tile_sized( - Button::new(id).icon(Icon::new(IconName::Plus)), - tile, - glyph, - false, - cx, - ) - .rounded(px(RAIL_TILE_RADIUS)) - // Every other tile in this row names itself on hover — Switch - // Workspace, More, Hide Sidebar. The three New Tab buttons that - // come through here were the ones left silent. The chord is worth - // more here than anywhere else in the row: it is the way back to - // opening a tab without reading a menu first. - .accessible_label(t(L10nKey::AppMenuNewTab)) - .tooltip_element(chord_tooltip(t(L10nKey::AppMenuNewTab), "NewTab", cx)) - // Built when the menu opens, not when the strip draws: this - // closure runs once per press, and again after each dismissal. - .dropdown_menu(move |menu, window, cx| { - let Some(this) = app.upgrade() else { - return menu; - }; - this.read(cx) - .new_tab_menu_rows(app.clone(), cx) - .build(menu, window) - }) + let app = self.app; + let half = |i| segment_corners(i, 2, px(RAIL_TILE_RADIUS), px(0.)); + self.base + .flex() + .flex_none() + .items_center() + .h(px(self.tile)) + .child( + chrome_tile_sized( + Button::new(self.id).icon(Icon::new(IconName::Plus)), + self.tile, + glyph, + self.selected, + cx, + ) + .rounded_corners(half(0)) + // Every other tile in this row names itself on hover. The + // chord is worth more here than anywhere else in the row: it + // is the way to a tab without reaching for the pointer at all. + .accessible_label(t(L10nKey::AppMenuNewTab)) + .when(!self.selected, |b| { + b.tooltip_element(chord_tooltip(t(L10nKey::AppMenuNewTab), "NewTab", cx)) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.new_tab(window, cx)); + }), + ) + .child( + chrome_tile_sized( + Button::new(SharedString::from(format!("{}-menu", self.id))) + .icon(Icon::new(IconName::ChevronDown)), + self.tile, + glyph, + self.selected, + cx, + ) + .w(px(MIN_TARGET)) + .rounded_corners(half(1)) + .accessible_label(t(L10nKey::NewTabMoreOptions)) + // Not while the list it names is already open under it. + .when(!self.selected, |b| b.tooltip(t(L10nKey::NewTabMoreOptions))), + ) } +} +impl Tty7App { /// What the menu offers, read off the app as the menu opens — the builder /// runs on the popup's own entity, so the rows carry a weak handle back. fn new_tab_menu_rows(&self, app: gpui::WeakEntity, cx: &App) -> NewTabMenu { @@ -2372,7 +2472,7 @@ impl Tty7App { } else { document_w }; - let fixed_w = 3. * CHIP_GAP + crate::ui::app::TILE_SIZE + corner_w; + let fixed_w = 3. * CHIP_GAP + NEW_TAB_W + corner_w; let chips_avail = (strip_w - px(fixed_w + GRAB_HANDLE_W)).max(px(80.)); let mut chips = h_flex() .items_center()