project_panel: Add support for copy/paste between different worktrees (#15396)

CharlesChen0823 created

Closes https://github.com/zed-industries/zed/issues/5362

Release Notes:

- Added a way to copy/cut-paste between different worktrees ([#5362](https://github.com/zed-industries/zed/issues/5362))

Change summary

Cargo.lock                                   |   2 
crates/collab/src/tests/integration_tests.rs |   2 
crates/project/src/project.rs                |   5 
crates/project_panel/Cargo.toml              |   2 
crates/project_panel/src/project_panel.rs    | 358 +++++++++++++++++++--
crates/proto/proto/zed.proto                 |   1 
crates/worktree/src/worktree.rs              |  28 +
7 files changed, 348 insertions(+), 50 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -8311,9 +8311,9 @@ dependencies = [
  "db",
  "editor",
  "file_icons",
- "futures 0.3.30",
  "git",
  "gpui",
+ "indexmap 1.9.3",
  "language",
  "menu",
  "pretty_assertions",

crates/collab/src/tests/integration_tests.rs 🔗

@@ -3178,7 +3178,7 @@ async fn test_fs_operations(
 
     project_b
         .update(cx_b, |project, cx| {
-            project.copy_entry(entry.id, Path::new("f.txt"), cx)
+            project.copy_entry(entry.id, None, Path::new("f.txt"), cx)
         })
         .await
         .unwrap()

crates/project/src/project.rs 🔗

@@ -1607,6 +1607,7 @@ impl Project {
     pub fn copy_entry(
         &mut self,
         entry_id: ProjectEntryId,
+        relative_worktree_source_path: Option<PathBuf>,
         new_path: impl Into<Arc<Path>>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<Option<Entry>>> {
@@ -1614,7 +1615,7 @@ impl Project {
             return Task::ready(Ok(None));
         };
         worktree.update(cx, |worktree, cx| {
-            worktree.copy_entry(entry_id, new_path, cx)
+            worktree.copy_entry(entry_id, relative_worktree_source_path, new_path, cx)
         })
     }
 
@@ -10986,7 +10987,7 @@ fn serialize_symbol(symbol: &Symbol) -> proto::Symbol {
     }
 }
 
-fn relativize_path(base: &Path, path: &Path) -> PathBuf {
+pub fn relativize_path(base: &Path, path: &Path) -> PathBuf {
     let mut path_components = path.components();
     let mut base_components = base.components();
     let mut components: Vec<Component> = Vec::new();

crates/project_panel/Cargo.toml 🔗

@@ -18,7 +18,7 @@ collections.workspace = true
 db.workspace = true
 editor.workspace = true
 file_icons.workspace = true
-futures.workspace = true
+indexmap.workspace = true
 git.workspace = true
 gpui.workspace = true
 menu.workspace = true

crates/project_panel/src/project_panel.rs 🔗

@@ -23,8 +23,12 @@ use gpui::{
     PromptLevel, Render, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View,
     ViewContext, VisualContext as _, WeakView, WindowContext,
 };
+use indexmap::IndexMap;
 use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
-use project::{Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
+use project::{
+    relativize_path, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree,
+    WorktreeId,
+};
 use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings, ShowScrollbar};
 use serde::{Deserialize, Serialize};
 use std::{
@@ -495,23 +499,8 @@ impl ProjectPanel {
                             .action("Copy", Box::new(Copy))
                             .action("Duplicate", Box::new(Duplicate))
                             // TODO: Paste should always be visible, cbut disabled when clipboard is empty
-                            .when_some(self.clipboard.as_ref(), |menu, entry| {
-                                let entries_for_worktree_id = (SelectedEntry {
-                                    worktree_id,
-                                    entry_id: ProjectEntryId::MIN,
-                                })
-                                    ..(SelectedEntry {
-                                        worktree_id,
-                                        entry_id: ProjectEntryId::MAX,
-                                    });
-                                menu.when(
-                                    entry
-                                        .items()
-                                        .range(entries_for_worktree_id)
-                                        .next()
-                                        .is_some(),
-                                    |menu| menu.action("Paste", Box::new(Paste)),
-                                )
+                            .when(self.clipboard.as_ref().is_some(), |menu| {
+                                menu.action("Paste", Box::new(Paste))
                             })
                             .separator()
                             .action("Copy Path", Box::new(CopyPath))
@@ -1304,46 +1293,99 @@ impl ProjectPanel {
                 .as_ref()
                 .filter(|clipboard| !clipboard.items().is_empty())?;
 
-            let mut tasks = Vec::new();
-
+            enum PasteTask {
+                Rename(Task<Result<CreatedEntry>>),
+                Copy(Task<Result<Option<Entry>>>),
+            }
+            let mut paste_entry_tasks: IndexMap<(ProjectEntryId, bool), PasteTask> =
+                IndexMap::default();
+            let clip_is_cut = clipboard_entries.is_cut();
             for clipboard_entry in clipboard_entries.items() {
-                if clipboard_entry.worktree_id != worktree_id {
-                    return None;
-                }
                 let new_path =
                     self.create_paste_path(clipboard_entry, self.selected_entry_handle(cx)?, cx)?;
-                if clipboard_entries.is_cut() {
-                    self.project
-                        .update(cx, |project, cx| {
-                            project.rename_entry(clipboard_entry.entry_id, new_path, cx)
-                        })
-                        .detach_and_log_err(cx);
+                let clip_entry_id = clipboard_entry.entry_id;
+                let is_same_worktree = clipboard_entry.worktree_id == worktree_id;
+                let relative_worktree_source_path = if !is_same_worktree {
+                    let target_base_path = worktree.read(cx).abs_path();
+                    let clipboard_project_path =
+                        self.project.read(cx).path_for_entry(clip_entry_id, cx)?;
+                    let clipboard_abs_path = self
+                        .project
+                        .read(cx)
+                        .absolute_path(&clipboard_project_path, cx)?;
+                    Some(relativize_path(
+                        &target_base_path,
+                        clipboard_abs_path.as_path(),
+                    ))
+                } else {
+                    None
+                };
+                let task = if clip_is_cut && is_same_worktree {
+                    let task = self.project.update(cx, |project, cx| {
+                        project.rename_entry(clip_entry_id, new_path, cx)
+                    });
+                    PasteTask::Rename(task)
                 } else {
+                    let entry_id = if is_same_worktree {
+                        clip_entry_id
+                    } else {
+                        entry.id
+                    };
                     let task = self.project.update(cx, |project, cx| {
-                        project.copy_entry(clipboard_entry.entry_id, new_path, cx)
+                        project.copy_entry(entry_id, relative_worktree_source_path, new_path, cx)
                     });
-                    tasks.push(task);
-                }
+                    PasteTask::Copy(task)
+                };
+                let needs_delete = !is_same_worktree && clip_is_cut;
+                paste_entry_tasks.insert((clip_entry_id, needs_delete), task);
             }
 
             cx.spawn(|project_panel, mut cx| async move {
-                let entry_ids = futures::future::join_all(tasks).await;
-                if let Some(Some(entry)) = entry_ids
-                    .into_iter()
-                    .rev()
-                    .find_map(|entry_id| entry_id.ok())
-                {
+                let mut last_succeed = None;
+                let mut need_delete_ids = Vec::new();
+                for ((entry_id, need_delete), task) in paste_entry_tasks.into_iter() {
+                    match task {
+                        PasteTask::Rename(task) => {
+                            if let Some(CreatedEntry::Included(entry)) = task.await.log_err() {
+                                last_succeed = Some(entry.id);
+                            }
+                        }
+                        PasteTask::Copy(task) => {
+                            if let Some(Some(entry)) = task.await.log_err() {
+                                last_succeed = Some(entry.id);
+                                if need_delete {
+                                    need_delete_ids.push(entry_id);
+                                }
+                            }
+                        }
+                    }
+                }
+                // update selection
+                if let Some(entry_id) = last_succeed {
                     project_panel
                         .update(&mut cx, |project_panel, _cx| {
                             project_panel.selection = Some(SelectedEntry {
                                 worktree_id,
-                                entry_id: entry.id,
+                                entry_id,
                             });
                         })
                         .ok();
                 }
+                // remove entry for cut in difference worktree
+                for entry_id in need_delete_ids {
+                    project_panel
+                        .update(&mut cx, |project_panel, cx| {
+                            project_panel
+                                .project
+                                .update(cx, |project, cx| project.delete_entry(entry_id, true, cx))
+                                .ok_or_else(|| anyhow!("no such entry"))
+                        })??
+                        .await?;
+                }
+
+                anyhow::Ok(())
             })
-            .detach();
+            .detach_and_log_err(cx);
 
             self.expand_entry(worktree_id, entry.id, cx);
             Some(())
@@ -1842,7 +1884,7 @@ impl ProjectPanel {
                     )?;
                     self.project
                         .update(cx, |project, cx| {
-                            project.copy_entry(selection.entry_id, new_path, cx)
+                            project.copy_entry(selection.entry_id, None, new_path, cx)
                         })
                         .detach_and_log_err(cx)
                 }
@@ -3675,6 +3717,236 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor().clone());
+        fs.insert_tree(
+            "/root1",
+            json!({
+                "one.txt": "",
+                "two.txt": "",
+                "three.txt": "",
+                "a": {
+                    "0": { "q": "", "r": "", "s": "" },
+                    "1": { "t": "", "u": "" },
+                    "2": { "v": "", "w": "", "x": "", "y": "" },
+                },
+            }),
+        )
+        .await;
+
+        fs.insert_tree(
+            "/root2",
+            json!({
+                "one.txt": "",
+                "two.txt": "",
+                "four.txt": "",
+                "b": {
+                    "3": { "Q": "" },
+                    "4": { "R": "", "S": "", "T": "", "U": "" },
+                },
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
+        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+        let panel = workspace
+            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
+            .unwrap();
+
+        select_path(&panel, "root1/three.txt", cx);
+        panel.update(cx, |panel, cx| {
+            panel.cut(&Default::default(), cx);
+        });
+
+        select_path(&panel, "root2/one.txt", cx);
+        panel.update(cx, |panel, cx| {
+            panel.select_next(&Default::default(), cx);
+            panel.paste(&Default::default(), cx);
+        });
+        cx.executor().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..50, cx),
+            &[
+                //
+                "v root1",
+                "    > a",
+                "      one.txt",
+                "      two.txt",
+                "v root2",
+                "    > b",
+                "      four.txt",
+                "      one.txt",
+                "      three.txt  <== selected",
+                "      two.txt",
+            ]
+        );
+
+        select_path(&panel, "root1/a", cx);
+        panel.update(cx, |panel, cx| {
+            panel.cut(&Default::default(), cx);
+        });
+        select_path(&panel, "root2/two.txt", cx);
+        panel.update(cx, |panel, cx| {
+            panel.select_next(&Default::default(), cx);
+            panel.paste(&Default::default(), cx);
+        });
+
+        cx.executor().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..50, cx),
+            &[
+                //
+                "v root1",
+                "      one.txt",
+                "      two.txt",
+                "v root2",
+                "    > a  <== selected",
+                "    > b",
+                "      four.txt",
+                "      one.txt",
+                "      three.txt",
+                "      two.txt",
+            ]
+        );
+    }
+
+    #[gpui::test]
+    async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor().clone());
+        fs.insert_tree(
+            "/root1",
+            json!({
+                "one.txt": "",
+                "two.txt": "",
+                "three.txt": "",
+                "a": {
+                    "0": { "q": "", "r": "", "s": "" },
+                    "1": { "t": "", "u": "" },
+                    "2": { "v": "", "w": "", "x": "", "y": "" },
+                },
+            }),
+        )
+        .await;
+
+        fs.insert_tree(
+            "/root2",
+            json!({
+                "one.txt": "",
+                "two.txt": "",
+                "four.txt": "",
+                "b": {
+                    "3": { "Q": "" },
+                    "4": { "R": "", "S": "", "T": "", "U": "" },
+                },
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
+        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+        let panel = workspace
+            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
+            .unwrap();
+
+        select_path(&panel, "root1/three.txt", cx);
+        panel.update(cx, |panel, cx| {
+            panel.copy(&Default::default(), cx);
+        });
+
+        select_path(&panel, "root2/one.txt", cx);
+        panel.update(cx, |panel, cx| {
+            panel.select_next(&Default::default(), cx);
+            panel.paste(&Default::default(), cx);
+        });
+        cx.executor().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..50, cx),
+            &[
+                //
+                "v root1",
+                "    > a",
+                "      one.txt",
+                "      three.txt",
+                "      two.txt",
+                "v root2",
+                "    > b",
+                "      four.txt",
+                "      one.txt",
+                "      three.txt  <== selected",
+                "      two.txt",
+            ]
+        );
+
+        select_path(&panel, "root1/three.txt", cx);
+        panel.update(cx, |panel, cx| {
+            panel.copy(&Default::default(), cx);
+        });
+        select_path(&panel, "root2/two.txt", cx);
+        panel.update(cx, |panel, cx| {
+            panel.select_next(&Default::default(), cx);
+            panel.paste(&Default::default(), cx);
+        });
+
+        cx.executor().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..50, cx),
+            &[
+                //
+                "v root1",
+                "    > a",
+                "      one.txt",
+                "      three.txt",
+                "      two.txt",
+                "v root2",
+                "    > b",
+                "      four.txt",
+                "      one.txt",
+                "      three copy.txt  <== selected",
+                "      three.txt",
+                "      two.txt",
+            ]
+        );
+
+        select_path(&panel, "root1/a", cx);
+        panel.update(cx, |panel, cx| {
+            panel.copy(&Default::default(), cx);
+        });
+        select_path(&panel, "root2/two.txt", cx);
+        panel.update(cx, |panel, cx| {
+            panel.select_next(&Default::default(), cx);
+            panel.paste(&Default::default(), cx);
+        });
+
+        cx.executor().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..50, cx),
+            &[
+                //
+                "v root1",
+                "    > a",
+                "      one.txt",
+                "      three.txt",
+                "      two.txt",
+                "v root2",
+                "    > a  <== selected",
+                "    > b",
+                "      four.txt",
+                "      one.txt",
+                "      three copy.txt",
+                "      three.txt",
+                "      two.txt",
+            ]
+        );
+    }
+
     #[gpui::test]
     async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
         init_test(cx);
@@ -4360,9 +4632,9 @@ mod tests {
             &[
                 "v project_root",
                 "    v dir_1",
-                "        v nested_dir  <== selected",
+                "        v nested_dir",
                 "              file_1.py  <== marked",
-                "              file_a.py  <== marked",
+                "              file_a.py  <== selected  <== marked",
             ]
         );
         cx.simulate_modifiers_change(modifiers_with_shift);

crates/proto/proto/zed.proto 🔗

@@ -654,6 +654,7 @@ message CopyProjectEntry {
     uint64 project_id = 1;
     uint64 entry_id = 2;
     string new_path = 3;
+    optional string relative_worktree_source_path = 4;
 }
 
 message DeleteProjectEntry {

crates/worktree/src/worktree.rs 🔗

@@ -786,16 +786,26 @@ impl Worktree {
     pub fn copy_entry(
         &mut self,
         entry_id: ProjectEntryId,
+        relative_worktree_source_path: Option<PathBuf>,
         new_path: impl Into<Arc<Path>>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<Option<Entry>>> {
         let new_path = new_path.into();
         match self {
-            Worktree::Local(this) => this.copy_entry(entry_id, new_path, cx),
+            Worktree::Local(this) => {
+                this.copy_entry(entry_id, relative_worktree_source_path, new_path, cx)
+            }
             Worktree::Remote(this) => {
+                let relative_worktree_source_path =
+                    if let Some(relative_worktree_source_path) = relative_worktree_source_path {
+                        Some(relative_worktree_source_path.to_string_lossy().into())
+                    } else {
+                        None
+                    };
                 let response = this.client.request(proto::CopyProjectEntry {
                     project_id: this.project_id,
                     entry_id: entry_id.to_proto(),
+                    relative_worktree_source_path,
                     new_path: new_path.to_string_lossy().into(),
                 });
                 cx.spawn(move |this, mut cx| async move {
@@ -948,10 +958,18 @@ impl Worktree {
         mut cx: AsyncAppContext,
     ) -> Result<proto::ProjectEntryResponse> {
         let (scan_id, task) = this.update(&mut cx, |this, cx| {
+            let relative_worktree_source_path = if let Some(relative_worktree_source_path) =
+                request.relative_worktree_source_path
+            {
+                Some(PathBuf::from(relative_worktree_source_path))
+            } else {
+                None
+            };
             (
                 this.scan_id(),
                 this.copy_entry(
                     ProjectEntryId::from_proto(request.entry_id),
+                    relative_worktree_source_path,
                     PathBuf::from(request.new_path),
                     cx,
                 ),
@@ -1529,6 +1547,7 @@ impl LocalWorktree {
     fn copy_entry(
         &self,
         entry_id: ProjectEntryId,
+        relative_worktree_source_path: Option<PathBuf>,
         new_path: impl Into<Arc<Path>>,
         cx: &mut ModelContext<Worktree>,
     ) -> Task<Result<Option<Entry>>> {
@@ -1537,7 +1556,12 @@ impl LocalWorktree {
             None => return Task::ready(Ok(None)),
         };
         let new_path = new_path.into();
-        let abs_old_path = self.absolutize(&old_path);
+        let abs_old_path =
+            if let Some(relative_worktree_source_path) = relative_worktree_source_path {
+                Ok(self.abs_path().join(relative_worktree_source_path))
+            } else {
+                self.absolutize(&old_path)
+            };
         let abs_new_path = self.absolutize(&new_path);
         let fs = self.fs.clone();
         let copy = cx.background_executor().spawn(async move {