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(), } } }