From d0c0c33abfc00552e78e5f6b1657852becb30534 Mon Sep 17 00:00:00 2001 From: Yaroslav Yenkala Date: Mon, 16 Feb 2026 16:11:54 +0200 Subject: [PATCH] Fix missing right border on pinned tabs in two-row layout (#46952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/zed-industries/zed/issues/46926 ## Description: - Fixes the missing right border on pinned tabs when `show_pinned_tabs_in_separate_row` is enabled. The two-row tab bar layout was missing the border element that visually separates the pinned tabs row. This border was present in the single-row layout but was not added when implementing the two-row layout. ## Steps to reproduce 1. Enable `"tab_bar": { "show_pinned_tabs_in_separate_row": true }` in settings 2. Open multiple files 3. Pin at least one tab (right-click → Pin Tab) 4. Notice pinned tabs are missing right-hand side border **Before (bug):** image **After (fixed):** image ## Test plan - [x] Follow reproduction steps above and verify the border now appears - [x] Verify border appears immediately (not just after drag-and-drop) - [x] Verify single-row layout still works correctly when setting is disabled - [x] Added automated test `test_separate_pinned_row_has_right_border` ### Additional fix: Drag-and-drop to pinned tabs bar During implementation, discovered that we couldn't drag tabs to the end of the pinned tabs bar. This PR also adds: - A drop target at the end of the pinned tabs row - Proper pinning behavior when dropping unpinned tabs to the pinned area - Keeps the dragged tab active after drop Release Notes: - Fixed the missing right border on pinned tabs when `show_pinned_tabs_in_separate_row` is enabled. - Fixed drop target at the end of the pinned tabs row - Fixed pinning behavior when dropping unpinned tabs to the pinned area - Fixed case when the dragged tab was not active after drop (when enable `"tab_bar": { "show_pinned_tabs_in_separate_row": true }` in settings) --------- Co-authored-by: Joseph T. Lyons Co-authored-by: Danilo Leal --- crates/workspace/src/pane.rs | 343 ++++++++++++++++++++++++++++++++++- 1 file changed, 339 insertions(+), 4 deletions(-) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 06b05e2a11d5c34d7a71babfadbf2282ff3b6afa..7c59329f428f73285a218b3c59e215bfa80564fb 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -3483,7 +3483,8 @@ impl Pane { .debug_selector(|| "pinned_tabs_row".into()) .overflow_x_scroll() .w_full() - .children(pinned_tabs), + .children(pinned_tabs) + .child(self.render_pinned_tab_bar_drop_target(cx)), ); v_flex() .w_full() @@ -3525,12 +3526,11 @@ impl Pane { div() .id("tab_bar_drop_target") .min_w_6() + .h(Tab::container_height(cx)) + .flex_grow() // 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) }) @@ -3565,6 +3565,47 @@ impl Pane { })) } + fn render_pinned_tab_bar_drop_target(&self, cx: &mut Context) -> impl IntoElement { + div() + .id("pinned_tabs_border") + .debug_selector(|| "pinned_tabs_border".into()) + .min_w_6() + .h(Tab::container_height(cx)) + .flex_grow() + .border_l_1() + .border_color(cx.theme().colors().border) + // HACK: This empty child is currently necessary to force the drop target to appear + // despite us setting a min width above. + .child("") + .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_pinned_tab_bar_drop(dragged_tab, 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(this.pinned_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) + })) + } + 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), @@ -3731,6 +3772,60 @@ impl Pane { .log_err(); } + fn handle_pinned_tab_bar_drop( + &mut self, + dragged_tab: &DraggedTab, + window: &mut Window, + cx: &mut Context, + ) { + let item_id = dragged_tab.item.item_id(); + let pinned_count = self.pinned_tab_count; + + self.handle_tab_drop(dragged_tab, pinned_count, window, cx); + + let to_pane = cx.entity(); + + self.workspace + .update(cx, |_, cx| { + cx.defer_in(window, move |_, _, cx| { + to_pane.update(cx, |this, cx| { + if let Some(actual_ix) = this.index_for_item_id(item_id) { + // If the tab ended up at or after pinned_tab_count, it's not pinned + // so we pin it now + if actual_ix >= this.pinned_tab_count { + let was_active = this.active_item_index == actual_ix; + let destination_ix = this.pinned_tab_count; + + // Move item to pinned area if needed + if actual_ix != destination_ix { + let item = this.items.remove(actual_ix); + this.items.insert(destination_ix, item); + + // Update active_item_index to follow the moved item + if was_active { + this.active_item_index = destination_ix; + } else if this.active_item_index > actual_ix + && this.active_item_index <= destination_ix + { + // Item moved left past the active item + this.active_item_index -= 1; + } else if this.active_item_index >= destination_ix + && this.active_item_index < actual_ix + { + // Item moved right past the active item + this.active_item_index += 1; + } + } + this.pinned_tab_count += 1; + cx.notify(); + } + } + }); + }); + }) + .log_err(); + } + fn handle_dragged_selection_drop( &mut self, dragged_selection: &DraggedSelection, @@ -5374,6 +5469,46 @@ mod tests { ); } + #[gpui::test] + async fn test_separate_pinned_row_has_right_border(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 (two-row layout) + 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); + cx.run_until_parked(); + + // Verify two-row layout is active + let pinned_row_bounds = cx.debug_bounds("pinned_tabs_row"); + assert!( + pinned_row_bounds.is_some(), + "Two-row layout should be active when both pinned and unpinned tabs exist" + ); + + // Verify pinned_tabs_border element exists (the right border after pinned tabs) + let border_bounds = cx.debug_bounds("pinned_tabs_border"); + assert!( + border_bounds.is_some(), + "pinned_tabs_border should exist in two-row layout to show right border" + ); + } + #[gpui::test] async fn test_pinning_active_tab_without_position_change_maintains_focus( cx: &mut TestAppContext, @@ -6326,6 +6461,206 @@ mod tests { assert_item_labels(&pane, ["B", "C", "A*", "D"], cx); } + #[gpui::test] + async fn test_drag_pinned_tab_when_show_pinned_tabs_in_separate_row_enabled( + cx: &mut TestAppContext, + ) { + use gpui::{Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent}; + + init_test(cx); + set_pinned_tabs_separate_row(cx, true); + 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); + let item_b = add_labeled_item(&pane, "B", false, cx); + let item_c = add_labeled_item(&pane, "C", false, cx); + let item_d = add_labeled_item(&pane, "D", false, cx); + + pane.update_in(cx, |pane, window, cx| { + pane.pin_tab_at( + pane.index_for_item_id(item_a.item_id()).unwrap(), + window, + cx, + ); + pane.pin_tab_at( + pane.index_for_item_id(item_b.item_id()).unwrap(), + window, + cx, + ); + pane.pin_tab_at( + pane.index_for_item_id(item_c.item_id()).unwrap(), + window, + cx, + ); + pane.pin_tab_at( + pane.index_for_item_id(item_d.item_id()).unwrap(), + window, + cx, + ); + }); + assert_item_labels(&pane, ["A!", "B!", "C!", "D*!"], cx); + cx.run_until_parked(); + + let tab_a_bounds = cx + .debug_bounds("TAB-0") + .expect("Tab A (index 0) should have debug bounds"); + let tab_c_bounds = cx + .debug_bounds("TAB-2") + .expect("Tab C (index 2) should have debug bounds"); + + cx.simulate_event(MouseDownEvent { + position: tab_a_bounds.center(), + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + cx.run_until_parked(); + cx.simulate_event(MouseMoveEvent { + position: tab_c_bounds.center(), + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + cx.run_until_parked(); + cx.simulate_event(MouseUpEvent { + position: tab_c_bounds.center(), + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 1, + }); + cx.run_until_parked(); + + assert_item_labels(&pane, ["B!", "C!", "A*!", "D!"], cx); + } + + #[gpui::test] + async fn test_drag_unpinned_tab_when_show_pinned_tabs_in_separate_row_enabled( + cx: &mut TestAppContext, + ) { + use gpui::{Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent}; + + init_test(cx); + set_pinned_tabs_separate_row(cx, true); + 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()); + + add_labeled_item(&pane, "A", false, cx); + add_labeled_item(&pane, "B", false, cx); + add_labeled_item(&pane, "C", false, cx); + add_labeled_item(&pane, "D", false, cx); + assert_item_labels(&pane, ["A", "B", "C", "D*"], cx); + cx.run_until_parked(); + + let tab_a_bounds = cx + .debug_bounds("TAB-0") + .expect("Tab A (index 0) should have debug bounds"); + let tab_c_bounds = cx + .debug_bounds("TAB-2") + .expect("Tab C (index 2) should have debug bounds"); + + cx.simulate_event(MouseDownEvent { + position: tab_a_bounds.center(), + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + cx.run_until_parked(); + cx.simulate_event(MouseMoveEvent { + position: tab_c_bounds.center(), + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + cx.run_until_parked(); + cx.simulate_event(MouseUpEvent { + position: tab_c_bounds.center(), + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 1, + }); + cx.run_until_parked(); + + assert_item_labels(&pane, ["B", "C", "A*", "D"], cx); + } + + #[gpui::test] + async fn test_drag_mixed_tabs_when_show_pinned_tabs_in_separate_row_enabled( + cx: &mut TestAppContext, + ) { + use gpui::{Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent}; + + init_test(cx); + set_pinned_tabs_separate_row(cx, true); + 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); + let item_b = add_labeled_item(&pane, "B", false, cx); + add_labeled_item(&pane, "C", false, cx); + add_labeled_item(&pane, "D", false, cx); + add_labeled_item(&pane, "E", false, cx); + add_labeled_item(&pane, "F", false, cx); + + pane.update_in(cx, |pane, window, cx| { + pane.pin_tab_at( + pane.index_for_item_id(item_a.item_id()).unwrap(), + window, + cx, + ); + pane.pin_tab_at( + pane.index_for_item_id(item_b.item_id()).unwrap(), + window, + cx, + ); + }); + assert_item_labels(&pane, ["A!", "B!", "C", "D", "E", "F*"], cx); + cx.run_until_parked(); + + let tab_c_bounds = cx + .debug_bounds("TAB-2") + .expect("Tab C (index 2) should have debug bounds"); + let tab_e_bounds = cx + .debug_bounds("TAB-4") + .expect("Tab E (index 4) should have debug bounds"); + + cx.simulate_event(MouseDownEvent { + position: tab_c_bounds.center(), + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + cx.run_until_parked(); + cx.simulate_event(MouseMoveEvent { + position: tab_e_bounds.center(), + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + cx.run_until_parked(); + cx.simulate_event(MouseUpEvent { + position: tab_e_bounds.center(), + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 1, + }); + cx.run_until_parked(); + + assert_item_labels(&pane, ["A!", "B!", "D", "E", "C*", "F"], cx); + } + #[gpui::test] async fn test_add_item_with_new_item(cx: &mut TestAppContext) { init_test(cx);