@@ -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::<DraggedTab>(|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<Pane>) -> 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::<DraggedTab>(|bar, _, _, cx| {
+ bar.bg(cx.theme().colors().drop_target_background)
+ })
+ .drag_over::<DraggedSelection>(|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<ContextMenu>) -> 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<Self>,
+ ) {
+ 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);