From 95f10fd1876a3555a37db54d35a2e0380aaceebf Mon Sep 17 00:00:00 2001 From: Carl Sverre <82591+carlsverre@users.noreply.github.com> Date: Mon, 23 Jun 2025 16:41:46 -0700 Subject: [PATCH] Add ability to clone item when using `workspace::MoveItemToPane` (#32895) This PR adds an optional `clone: bool` argument to `workspace::MoveItemToPane` and `workspace::MoveItemToPaneInDirection` which causes the item to be cloned into the destination pane rather than moved. It provides similar functionality to `workbench.action.splitEditorToRightGroup` in vscode. This PR supercedes #25030. Closes #24889 Release Notes: - Add optional `clone: bool` (default: `false`) to `workspace::MoveItemToPane` and `workspace::MoveItemToPaneInDirection` which causes the item to be cloned into the destination pane rather than moved. --- crates/workspace/src/workspace.rs | 150 ++++++++++++++++++++++++++---- 1 file changed, 132 insertions(+), 18 deletions(-) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 3b90968251..3fdfd0e2ac 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -222,6 +222,8 @@ pub struct MoveItemToPane { pub destination: usize, #[serde(default = "default_true")] pub focus: bool, + #[serde(default)] + pub clone: bool, } #[derive(Clone, Deserialize, PartialEq, JsonSchema)] @@ -230,6 +232,8 @@ pub struct MoveItemToPaneInDirection { pub direction: SplitDirection, #[serde(default = "default_true")] pub focus: bool, + #[serde(default)] + pub clone: bool, } #[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema)] @@ -3355,7 +3359,7 @@ impl Workspace { let destination = match panes.get(action.destination) { Some(&destination) => destination.clone(), None => { - if self.active_pane.read(cx).items_len() < 2 { + if !action.clone && self.active_pane.read(cx).items_len() < 2 { return; } let direction = SplitDirection::Right; @@ -3375,14 +3379,25 @@ impl Workspace { } }; - move_active_item( - &self.active_pane, - &destination, - action.focus, - true, - window, - cx, - ) + if action.clone { + clone_active_item( + self.database_id(), + &self.active_pane, + &destination, + action.focus, + window, + cx, + ) + } else { + move_active_item( + &self.active_pane, + &destination, + action.focus, + true, + window, + cx, + ) + } } pub fn activate_next_pane(&mut self, window: &mut Window, cx: &mut App) { @@ -3526,7 +3541,7 @@ impl Workspace { let destination = match self.find_pane_in_direction(action.direction, cx) { Some(destination) => destination, None => { - if self.active_pane.read(cx).items_len() < 2 { + if !action.clone && self.active_pane.read(cx).items_len() < 2 { return; } let new_pane = self.add_pane(window, cx); @@ -3542,14 +3557,25 @@ impl Workspace { } }; - move_active_item( - &self.active_pane, - &destination, - action.focus, - true, - window, - cx, - ); + if action.clone { + clone_active_item( + self.database_id(), + &self.active_pane, + &destination, + action.focus, + window, + cx, + ) + } else { + move_active_item( + &self.active_pane, + &destination, + action.focus, + true, + window, + cx, + ); + } } pub fn bounding_box_for_pane(&self, pane: &Entity) -> Option> { @@ -7631,6 +7657,35 @@ pub fn move_active_item( }); } +pub fn clone_active_item( + workspace_id: Option, + source: &Entity, + destination: &Entity, + focus_destination: bool, + window: &mut Window, + cx: &mut App, +) { + if source == destination { + return; + } + let Some(active_item) = source.read(cx).active_item() else { + return; + }; + destination.update(cx, |target_pane, cx| { + let Some(clone) = active_item.clone_on_split(workspace_id, window, cx) else { + return; + }; + target_pane.add_item( + clone, + focus_destination, + focus_destination, + Some(target_pane.items_len()), + window, + cx, + ); + }); +} + #[derive(Debug)] pub struct WorkspacePosition { pub window_bounds: Option, @@ -9814,6 +9869,7 @@ mod tests { &MoveItemToPaneInDirection { direction: SplitDirection::Right, focus: true, + clone: false, }, window, cx, @@ -9822,6 +9878,7 @@ mod tests { &MoveItemToPane { destination: 3, focus: true, + clone: false, }, window, cx, @@ -9848,6 +9905,7 @@ mod tests { &MoveItemToPaneInDirection { direction: SplitDirection::Right, focus: true, + clone: false, }, window, cx, @@ -9884,6 +9942,7 @@ mod tests { &MoveItemToPane { destination: 3, focus: true, + clone: false, }, window, cx, @@ -9907,6 +9966,61 @@ mod tests { }); } + #[gpui::test] + async fn test_moving_items_can_clone_panes(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let item_1 = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "first.txt", cx)]) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item_1), None, true, window, cx); + workspace.move_item_to_pane_in_direction( + &MoveItemToPaneInDirection { + direction: SplitDirection::Right, + focus: true, + clone: true, + }, + window, + cx, + ); + workspace.move_item_to_pane_at_index( + &MoveItemToPane { + destination: 3, + focus: true, + clone: true, + }, + window, + cx, + ); + + assert_eq!(workspace.panes.len(), 3, "Two new panes were created"); + for pane in workspace.panes() { + assert_eq!( + pane_items_paths(pane, cx), + vec!["first.txt".to_string()], + "Single item exists in all panes" + ); + } + }); + + // verify that the active pane has been updated after waiting for the + // pane focus event to fire and resolve + workspace.read_with(cx, |workspace, _app| { + assert_eq!( + workspace.active_pane(), + &workspace.panes[2], + "The third pane should be the active one: {:?}", + workspace.panes + ); + }) + } + mod register_project_item_tests { use super::*;