From 72f6d39962a9919e991ec1ccc1248e2919c2cb63 Mon Sep 17 00:00:00 2001 From: Yaroslav Yenkala Date: Tue, 13 Jan 2026 17:30:27 +0200 Subject: [PATCH] Add option to show pinned tabs in a separate row (#46573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem: When working with many `pinned tabs`, they consume significant horizontal space in the tab bar, leaving less room for unpinned (active working) tabs, especially on small screens. This creates a poor user experience as users must constantly scroll to find their working tabs, or the tabs become too narrow to read file names. ![8_tabs](https://github.com/user-attachments/assets/087a0b4d-8f1c-42f0-851c-b774228f11dd) ## Solution: Added a new opt-in setting `tab_bar.show_pinned_tabs_in_separate_row` that displays pinned and unpinned tabs in two separate rows: - Top row: Pinned tabs with navigation buttons and tab bar controls - Bottom row: Unpinned (working) tabs with full horizontal space - when pinned and not pinned tabs we show 2 rows: 2_rows This is a well-established UX pattern used in many popular IDEs including JetBrains products (IntelliJ IDEA, WebStorm, PyCharm, RubyMine), Visual Studio, and Eclipse. The two-row layout only appears when both pinned AND unpinned tabs exist. If only one type is present, a single row is displayed: - when only not pinned tabs we show one row: when only not pinned - when only pinned tabs we show one row: when only pinned tabs **Important** - Default Behavior Preserved: - The setting defaults to false - existing users will see no change whatsoever - Original tab bar logic is preserved in a dedicated `render_single_row_tab_bar` method, while the new two-row layout lives in `render_two_row_tab_bar`. Both methods share common components (`configure_tab_bar_start`, `configure_tab_bar_end`, `render_unpinned_tabs_container`, `render_tab_bar_drop_target`) ensuring DRY code and consistent behavior. - Purely opt-in - users must explicitly enable this feature via Settings UI or settings.json - No impact on default UX - Zed's default tab bar appearance and behavior remains identical This implementation ensures zero risk to the existing user experience while providing an optional enhancement for users who prefer separated tab rows. Configuration Via Settings UI: - Open Settings (Cmd+,) → Editor section → "Pinned Tabs Layout" toggle ![settings_ui](https://github.com/user-attachments/assets/59b9bb94-1c10-4c32-b942-13aa01943526) Via settings.json: ```json { "tab_bar": { "show_pinned_tabs_in_separate_row": true } } ``` manual_settings ## Video of implemented feature: https://github.com/user-attachments/assets/b1e52074-a5a1-4c4e-ad59-e55e2213509d ## Tested: - Verify default behavior unchanged (single row with pinned + unpinned tabs inline) - Enable setting → verify two rows appear when both pinned and unpinned tabs exist - Enable setting → verify single row when only pinned tabs exist - Enable setting → verify single row when only unpinned tabs exist - Verify drag & drop works in both layouts - Verify navigation buttons and tab bar buttons render correctly - Verify setting toggle works in Settings UI - Verify setting works via settings.json Tested on: MacBook Pro M4, macOS 26.2 ## Release Notes: - Added `tab_bar.show_pinned_tabs_in_separate_row` setting to display pinned and unpinned tabs in separate rows, giving unpinned tabs full horizontal space --------- Co-authored-by: Matt Miller --- assets/settings/default.json | 4 + .../src/settings_content/workspace.rs | 5 + crates/settings/src/vscode_import.rs | 1 + crates/settings_ui/src/page_data.rs | 24 +- crates/workspace/src/pane.rs | 453 +++++++++++++++--- crates/workspace/src/workspace_settings.rs | 2 + 6 files changed, 425 insertions(+), 64 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 9e7737ff1c2471666fba9e59eef01f0f7427b021..923261166a672a07ec471adc0329079f30765571 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1179,6 +1179,10 @@ "show_nav_history_buttons": true, // Whether or not to show the tab bar buttons. "show_tab_bar_buttons": true, + // Whether or not to show pinned tabs in a separate row. + // When enabled, pinned tabs appear in a top row and unpinned tabs in a bottom row. + // When disabled, all tabs appear in a single row (default behavior). + "show_pinned_tabs_in_separate_row": false, }, // Settings related to the editor's tabs "tabs": { diff --git a/crates/settings/src/settings_content/workspace.rs b/crates/settings/src/settings_content/workspace.rs index 8f793c21d4bca14dd387a56ffdcc555bcdf117fb..152042d2119d59edbff305b6906f3fca5c78a367 100644 --- a/crates/settings/src/settings_content/workspace.rs +++ b/crates/settings/src/settings_content/workspace.rs @@ -413,6 +413,11 @@ pub struct TabBarSettingsContent { /// /// Default: true pub show_tab_bar_buttons: Option, + /// Whether or not to show pinned tabs in a separate row. + /// When enabled, pinned tabs appear in a top row and unpinned tabs in a bottom row. + /// + /// Default: false + pub show_pinned_tabs_in_separate_row: Option, } #[with_fallible_options] diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index d04f9cafbef53f55fd0b9741def72ef69c20cbf1..5e2719f7191cd21b767847ae1c00b49c44c459f8 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -646,6 +646,7 @@ impl VsCodeSettings { show_tab_bar_buttons: self .read_str("workbench.editor.editorActionsLocation") .and_then(|str| if str == "hidden" { Some(false) } else { None }), + show_pinned_tabs_in_separate_row: None, }) } diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 87ed27955dc7e4b672aee1ae2fad8d74b395b3ba..7a3e54d02eec09646a862ba9487f7c173a246384 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -3405,7 +3405,7 @@ fn window_and_layout_page() -> SettingsPage { ] } - fn tab_bar_section() -> [SettingsPageItem; 8] { + fn tab_bar_section() -> [SettingsPageItem; 9] { [ SettingsPageItem::SectionHeader("Tab Bar"), SettingsPageItem::SettingItem(SettingItem { @@ -3524,6 +3524,28 @@ fn window_and_layout_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Pinned Tabs Layout", + description: "Show pinned tabs in a separate row above unpinned tabs.", + field: Box::new(SettingField { + json_path: Some("tab_bar.show_pinned_tabs_in_separate_row"), + pick: |settings_content| { + settings_content + .tab_bar + .as_ref()? + .show_pinned_tabs_in_separate_row + .as_ref() + }, + write: |settings_content, value| { + settings_content + .tab_bar + .get_or_insert_default() + .show_pinned_tabs_in_separate_row = value; + }, + }), + metadata: None, + files: USER, + }), ] } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index ac2e31f37182126f356d9053c01be5a850e1d3e0..d02140b90450b1936c9e4d03956f0acb535e13bc 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2765,7 +2765,7 @@ impl Pane { .on_drop( cx.listener(move |this, dragged_tab: &DraggedTab, window, cx| { this.drag_split_direction = None; - this.handle_tab_drop(dragged_tab, ix, window, cx) + this.handle_tab_drop(dragged_tab, this.items.len(), window, cx) }), ) .on_drop( @@ -3276,6 +3276,7 @@ impl Pane { .zip(tab_details(&self.items, window, cx)) .map(|((ix, item), detail)| { self.render_tab(ix, &**item, detail, &focus_handle, window, cx) + .into_any_element() }) .collect::>(); let tab_count = tab_items.len(); @@ -3320,7 +3321,51 @@ impl Pane { .flatten() .unwrap_or(false); - TabBar::new("tab_bar") + let tab_bar_settings = TabBarSettings::get_global(cx); + let use_separate_rows = tab_bar_settings.show_pinned_tabs_in_separate_row; + + if use_separate_rows && !pinned_tabs.is_empty() && !unpinned_tabs.is_empty() { + self.render_two_row_tab_bar( + pinned_tabs, + unpinned_tabs, + tab_count, + navigate_backward, + navigate_forward, + open_aside_left, + open_aside_right, + render_aside_toggle_left, + render_aside_toggle_right, + window, + cx, + ) + } else { + self.render_single_row_tab_bar( + pinned_tabs, + unpinned_tabs, + tab_count, + navigate_backward, + navigate_forward, + open_aside_left, + open_aside_right, + render_aside_toggle_left, + render_aside_toggle_right, + window, + cx, + ) + } + } + + fn configure_tab_bar_start( + &mut self, + tab_bar: TabBar, + navigate_backward: IconButton, + navigate_forward: IconButton, + open_aside_left: Option, + render_aside_toggle_left: bool, + window: &mut Window, + cx: &mut Context, + ) -> TabBar { + tab_bar .map(|tab_bar| { if let Some(open_aside_left) = open_aside_left && render_aside_toggle_left @@ -3349,6 +3394,48 @@ impl Pane { tab_bar } }) + } + + fn configure_tab_bar_end( + tab_bar: TabBar, + open_aside_right: Option, + render_aside_toggle_right: bool, + ) -> TabBar { + tab_bar.map(|tab_bar| { + if let Some(open_aside_right) = open_aside_right + && render_aside_toggle_right + { + tab_bar.end_child(open_aside_right) + } else { + tab_bar + } + }) + } + + fn render_single_row_tab_bar( + &mut self, + pinned_tabs: Vec, + unpinned_tabs: Vec, + tab_count: usize, + navigate_backward: IconButton, + navigate_forward: IconButton, + open_aside_left: Option, + open_aside_right: Option, + render_aside_toggle_left: bool, + render_aside_toggle_right: bool, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let tab_bar = self + .configure_tab_bar_start( + TabBar::new("tab_bar"), + navigate_backward, + navigate_forward, + open_aside_left, + render_aside_toggle_left, + window, + cx, + ) .children(pinned_tabs.len().ne(&0).then(|| { let max_scroll = self.tab_bar_scroll_handle.max_offset().width; // We need to check both because offset returns delta values even when the scroll handle is not scrollable @@ -3365,75 +3452,129 @@ impl Pane { .border_color(cx.theme().colors().border) }) })) + .child(self.render_unpinned_tabs_container(unpinned_tabs, tab_count, cx)); + Self::configure_tab_bar_end(tab_bar, open_aside_right, render_aside_toggle_right) + .into_any_element() + } + + fn render_two_row_tab_bar( + &mut self, + pinned_tabs: Vec, + unpinned_tabs: Vec, + tab_count: usize, + navigate_backward: IconButton, + navigate_forward: IconButton, + open_aside_left: Option, + open_aside_right: Option, + render_aside_toggle_left: bool, + render_aside_toggle_right: bool, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let pinned_tab_bar = self + .configure_tab_bar_start( + TabBar::new("pinned_tab_bar"), + navigate_backward, + navigate_forward, + open_aside_left, + render_aside_toggle_left, + window, + cx, + ) .child( h_flex() - .id("unpinned tabs") + .id("pinned_tabs_row") + .debug_selector(|| "pinned_tabs_row".into()) .overflow_x_scroll() .w_full() - .track_scroll(&self.tab_bar_scroll_handle) - .on_scroll_wheel(cx.listener(|this, _, _, _| { - this.suppress_scroll = true; - })) - .children(unpinned_tabs) - .child( - div() - .id("tab_bar_drop_target") - .min_w_6() - // HACK: This empty child is currently necessary to force the drop target to appear - // despite us setting a min width above. - .child("") - // HACK: h_full doesn't occupy the complete height, using fixed height instead - .h(Tab::container_height(cx)) - .flex_grow() - .drag_over::(|bar, _, _, cx| { - bar.bg(cx.theme().colors().drop_target_background) - }) - .drag_over::(|bar, _, _, cx| { - bar.bg(cx.theme().colors().drop_target_background) - }) - .on_drop(cx.listener( - move |this, dragged_tab: &DraggedTab, window, cx| { - this.drag_split_direction = None; - this.handle_tab_drop(dragged_tab, this.items.len(), window, cx) - }, - )) - .on_drop(cx.listener( - move |this, selection: &DraggedSelection, window, cx| { - this.drag_split_direction = None; - this.handle_project_entry_drop( - &selection.active_selection.entry_id, - Some(tab_count), - window, - cx, - ) - }, - )) - .on_drop(cx.listener(move |this, paths, window, cx| { - this.drag_split_direction = None; - this.handle_external_paths_drop(paths, window, cx) - })) - .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| { - if event.click_count() == 2 { - window.dispatch_action( - this.double_click_dispatch_action.boxed_clone(), - cx, - ); - } - })), - ), + .children(pinned_tabs), + ); + let pinned_tab_bar = Self::configure_tab_bar_end( + pinned_tab_bar, + open_aside_right, + render_aside_toggle_right, + ); + + v_flex() + .w_full() + .flex_none() + .child(pinned_tab_bar) + .child( + TabBar::new("unpinned_tab_bar").child(self.render_unpinned_tabs_container( + unpinned_tabs, + tab_count, + cx, + )), ) - .map(|tab_bar| { - if let Some(open_aside_right) = open_aside_right - && render_aside_toggle_right - { - tab_bar.end_child(open_aside_right) - } else { - tab_bar - } - }) .into_any_element() } + fn render_unpinned_tabs_container( + &mut self, + unpinned_tabs: Vec, + tab_count: usize, + cx: &mut Context, + ) -> impl IntoElement { + h_flex() + .id("unpinned tabs") + .overflow_x_scroll() + .w_full() + .track_scroll(&self.tab_bar_scroll_handle) + .on_scroll_wheel(cx.listener(|this, _, _, _| { + this.suppress_scroll = true; + })) + .children(unpinned_tabs) + .child(self.render_tab_bar_drop_target(tab_count, cx)) + } + + fn render_tab_bar_drop_target( + &self, + tab_count: usize, + cx: &mut Context, + ) -> impl IntoElement { + div() + .id("tab_bar_drop_target") + .min_w_6() + // HACK: This empty child is currently necessary to force the drop target to appear + // despite us setting a min width above. + .child("") + // HACK: h_full doesn't occupy the complete height, using fixed height instead + .h(Tab::container_height(cx)) + .flex_grow() + .drag_over::(|bar, _, _, cx| { + bar.bg(cx.theme().colors().drop_target_background) + }) + .drag_over::(|bar, _, _, cx| { + bar.bg(cx.theme().colors().drop_target_background) + }) + .on_drop( + cx.listener(move |this, dragged_tab: &DraggedTab, window, cx| { + this.drag_split_direction = None; + this.handle_tab_drop(dragged_tab, this.items.len(), window, cx) + }), + ) + .on_drop( + cx.listener(move |this, selection: &DraggedSelection, window, cx| { + this.drag_split_direction = None; + this.handle_project_entry_drop( + &selection.active_selection.entry_id, + Some(tab_count), + window, + cx, + ) + }), + ) + .on_drop(cx.listener(move |this, paths, window, cx| { + this.drag_split_direction = None; + this.handle_external_paths_drop(paths, window, cx) + })) + .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| { + if event.click_count() == 2 { + window.dispatch_action(this.double_click_dispatch_action.boxed_clone(), cx); + } + })) + } + pub fn render_menu_overlay(menu: &Entity) -> Div { div().absolute().bottom_0().right_0().size_0().child( deferred(anchored().anchor(Corner::TopRight).child(menu.clone())).with_priority(1), @@ -5007,6 +5148,181 @@ mod tests { assert_item_labels(&pane, ["A", "B*", "C"], cx); } + #[gpui::test] + async fn test_separate_pinned_row_disabled_by_default(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + let item_a = add_labeled_item(&pane, "A", false, cx); + add_labeled_item(&pane, "B", false, cx); + add_labeled_item(&pane, "C", false, cx); + + // Pin one tab + pane.update_in(cx, |pane, window, cx| { + let ix = pane.index_for_item_id(item_a.item_id()).unwrap(); + pane.pin_tab_at(ix, window, cx); + }); + assert_item_labels(&pane, ["A!", "B", "C*"], cx); + + // Verify setting is disabled by default + let is_separate_row_enabled = pane.read_with(cx, |_, cx| { + TabBarSettings::get_global(cx).show_pinned_tabs_in_separate_row + }); + assert!( + !is_separate_row_enabled, + "Separate pinned row should be disabled by default" + ); + + // Verify pinned_tabs_row element does NOT exist (single row layout) + let pinned_row_bounds = cx.debug_bounds("pinned_tabs_row"); + assert!( + pinned_row_bounds.is_none(), + "pinned_tabs_row should not exist when setting is disabled" + ); + } + + #[gpui::test] + async fn test_separate_pinned_row_two_rows_when_both_tab_types_exist(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + // Enable separate row setting + set_pinned_tabs_separate_row(cx, true); + + let item_a = add_labeled_item(&pane, "A", false, cx); + add_labeled_item(&pane, "B", false, cx); + add_labeled_item(&pane, "C", false, cx); + + // Pin one tab - now we have both pinned and unpinned tabs + pane.update_in(cx, |pane, window, cx| { + let ix = pane.index_for_item_id(item_a.item_id()).unwrap(); + pane.pin_tab_at(ix, window, cx); + }); + assert_item_labels(&pane, ["A!", "B", "C*"], cx); + + // Verify pinned_tabs_row element exists (two row layout) + let pinned_row_bounds = cx.debug_bounds("pinned_tabs_row"); + assert!( + pinned_row_bounds.is_some(), + "pinned_tabs_row should exist when setting is enabled and both tab types exist" + ); + } + + #[gpui::test] + async fn test_separate_pinned_row_single_row_when_only_pinned_tabs(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + // Enable separate row setting + set_pinned_tabs_separate_row(cx, true); + + let item_a = add_labeled_item(&pane, "A", false, cx); + let item_b = add_labeled_item(&pane, "B", false, cx); + + // Pin all tabs - only pinned tabs exist + pane.update_in(cx, |pane, window, cx| { + let ix = pane.index_for_item_id(item_a.item_id()).unwrap(); + pane.pin_tab_at(ix, window, cx); + let ix = pane.index_for_item_id(item_b.item_id()).unwrap(); + pane.pin_tab_at(ix, window, cx); + }); + assert_item_labels(&pane, ["A!", "B*!"], cx); + + // Verify pinned_tabs_row does NOT exist (single row layout for pinned-only) + let pinned_row_bounds = cx.debug_bounds("pinned_tabs_row"); + assert!( + pinned_row_bounds.is_none(), + "pinned_tabs_row should not exist when only pinned tabs exist (uses single row)" + ); + } + + #[gpui::test] + async fn test_separate_pinned_row_single_row_when_only_unpinned_tabs(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + // Enable separate row setting + set_pinned_tabs_separate_row(cx, true); + + // Add only unpinned tabs + add_labeled_item(&pane, "A", false, cx); + add_labeled_item(&pane, "B", false, cx); + add_labeled_item(&pane, "C", false, cx); + assert_item_labels(&pane, ["A", "B", "C*"], cx); + + // Verify pinned_tabs_row does NOT exist (single row layout for unpinned-only) + let pinned_row_bounds = cx.debug_bounds("pinned_tabs_row"); + assert!( + pinned_row_bounds.is_none(), + "pinned_tabs_row should not exist when only unpinned tabs exist (uses single row)" + ); + } + + #[gpui::test] + async fn test_separate_pinned_row_toggles_between_layouts(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + let item_a = add_labeled_item(&pane, "A", false, cx); + add_labeled_item(&pane, "B", false, cx); + + // Pin one tab + pane.update_in(cx, |pane, window, cx| { + let ix = pane.index_for_item_id(item_a.item_id()).unwrap(); + pane.pin_tab_at(ix, window, cx); + }); + + // Initially disabled - single row + let pinned_row_bounds = cx.debug_bounds("pinned_tabs_row"); + assert!( + pinned_row_bounds.is_none(), + "Should be single row when disabled" + ); + + // Enable - two rows + set_pinned_tabs_separate_row(cx, true); + cx.run_until_parked(); + let pinned_row_bounds = cx.debug_bounds("pinned_tabs_row"); + assert!( + pinned_row_bounds.is_some(), + "Should be two rows when enabled" + ); + + // Disable again - back to single row + set_pinned_tabs_separate_row(cx, false); + cx.run_until_parked(); + let pinned_row_bounds = cx.debug_bounds("pinned_tabs_row"); + assert!( + pinned_row_bounds.is_none(), + "Should be single row when disabled again" + ); + } + #[gpui::test] async fn test_pinning_active_tab_without_position_change_maintains_focus( cx: &mut TestAppContext, @@ -7295,6 +7611,17 @@ mod tests { }); } + fn set_pinned_tabs_separate_row(cx: &mut TestAppContext, enabled: bool) { + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings + .tab_bar + .get_or_insert_default() + .show_pinned_tabs_in_separate_row = Some(enabled); + }); + }); + } + fn add_labeled_item( pane: &Entity, label: &str, diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 75969e4534961381b66fa75e4daf916a2b714ac2..1ef0cd29947782a3ddb68ce74ad8c0906c8adb1f 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -60,6 +60,7 @@ pub struct TabBarSettings { pub show: bool, pub show_nav_history_buttons: bool, pub show_tab_bar_buttons: bool, + pub show_pinned_tabs_in_separate_row: bool, } impl Settings for WorkspaceSettings { @@ -121,6 +122,7 @@ impl Settings for TabBarSettings { show: tab_bar.show.unwrap(), show_nav_history_buttons: tab_bar.show_nav_history_buttons.unwrap(), show_tab_bar_buttons: tab_bar.show_tab_bar_buttons.unwrap(), + show_pinned_tabs_in_separate_row: tab_bar.show_pinned_tabs_in_separate_row.unwrap(), } } }