project_panel: Improve behavior for cut-pasting entries (#31931)

Finn Evers created

Previously, we would move entries each time they were pasted. Thus, if
you were to cut some files and pasted them in folder `a` and then `b`,
they would only occur in folder `b` and not in folder `a`. This is
unintuitive - e.g. the same does not apply to text and does not happen
in other editors.

This PR improves this behavior - after the first paste of a cut
clipboard, we change the clipboard to a copy clipboard, ensuring that
for all folloing pastes, the entries are not moved again. In the above
example, the files would then also be found in folder `a`. This is also
reflected in the added test.

Release Notes:

- Ensured that cut project panel entries are cut-pasted only on the
first use, and copy-pasted on all subsequent pastes.

Change summary

crates/project_panel/src/project_panel.rs       | 12 ++
crates/project_panel/src/project_panel_tests.rs | 85 +++++++++++++++++++
2 files changed, 97 insertions(+)

Detailed changes

crates/project_panel/src/project_panel.rs 🔗

@@ -2343,6 +2343,11 @@ impl ProjectPanel {
             })
             .detach_and_log_err(cx);
 
+            if clip_is_cut {
+                // Convert the clipboard cut entry to a copy entry after the first paste.
+                self.clipboard = self.clipboard.take().map(ClipboardEntry::to_copy_entry);
+            }
+
             self.expand_entry(worktree_id, entry.id, cx);
             Some(())
         });
@@ -5033,6 +5038,13 @@ impl ClipboardEntry {
             ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
         }
     }
+
+    fn to_copy_entry(self) -> Self {
+        match self {
+            ClipboardEntry::Copied(_) => self,
+            ClipboardEntry::Cut(entries) => ClipboardEntry::Copied(entries),
+        }
+    }
 }
 
 #[cfg(test)]

crates/project_panel/src/project_panel_tests.rs 🔗

@@ -1170,6 +1170,91 @@ async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_cut_paste(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor().clone());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "one.txt": "",
+            "two.txt": "",
+            "a": {},
+            "b": {}
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+    select_path_with_mark(&panel, "root/one.txt", cx);
+    select_path_with_mark(&panel, "root/two.txt", cx);
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..50, cx),
+        &[
+            "v root",
+            "    > a",
+            "    > b",
+            "      one.txt  <== marked",
+            "      two.txt  <== selected  <== marked",
+        ]
+    );
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.cut(&Default::default(), window, cx);
+    });
+
+    select_path(&panel, "root/a", cx);
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.paste(&Default::default(), window, cx);
+    });
+    cx.executor().run_until_parked();
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..50, cx),
+        &[
+            "v root",
+            "    v a",
+            "          one.txt  <== marked",
+            "          two.txt  <== selected  <== marked",
+            "    > b",
+        ],
+        "Cut entries should be moved on first paste."
+    );
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.cancel(&menu::Cancel {}, window, cx)
+    });
+    cx.executor().run_until_parked();
+
+    select_path(&panel, "root/b", cx);
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.paste(&Default::default(), window, cx);
+    });
+    cx.executor().run_until_parked();
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..50, cx),
+        &[
+            "v root",
+            "    v a",
+            "          one.txt",
+            "          two.txt",
+            "    v b",
+            "          one.txt",
+            "          two.txt  <== selected",
+        ],
+        "Cut entries should only be copied for the second paste!"
+    );
+}
+
 #[gpui::test]
 async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
     init_test(cx);