Add ability to clone item when using `workspace::MoveItemToPane` (#32895)

Carl Sverre created

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.

Change summary

crates/workspace/src/workspace.rs | 150 +++++++++++++++++++++++++++++---
1 file changed, 132 insertions(+), 18 deletions(-)

Detailed changes

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<Pane>) -> Option<Bounds<Pixels>> {
@@ -7631,6 +7657,35 @@ pub fn move_active_item(
     });
 }
 
+pub fn clone_active_item(
+    workspace_id: Option<WorkspaceId>,
+    source: &Entity<Pane>,
+    destination: &Entity<Pane>,
+    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<WindowBounds>,
@@ -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::*;