From a5b82b2bf3b54ec210bb293cf541eb4c6164824b Mon Sep 17 00:00:00 2001 From: CharlesChen0823 Date: Wed, 28 Aug 2024 16:35:18 +0800 Subject: [PATCH] project_panel: Add support for copy/paste between different worktrees (#15396) 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)) --- 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(-) diff --git a/Cargo.lock b/Cargo.lock index b1eea5f6f8..b0b943dfde 100644 --- a/Cargo.lock +++ b/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", diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index cdd06f2078..f5abc1ad79 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/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() diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index f676f72843..7b2eab5c47 100644 --- a/crates/project/src/project.rs +++ b/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, new_path: impl Into>, cx: &mut ModelContext, ) -> Task>> { @@ -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 = Vec::new(); diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index 5e11b60477..11c7364e58 100644 --- a/crates/project_panel/Cargo.toml +++ b/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 diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index f1f9034a10..d9ec5ee652 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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>), + Copy(Task>>), + } + 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.copy_entry(clipboard_entry.entry_id, new_path, cx) + project.rename_entry(clip_entry_id, new_path, cx) }); - tasks.push(task); - } + 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(entry_id, relative_worktree_source_path, new_path, cx) + }); + 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); diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 4cdc33cddc..b702a67be8 100644 --- a/crates/proto/proto/zed.proto +++ b/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 { diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index f304a29325..372166e9ce 100644 --- a/crates/worktree/src/worktree.rs +++ b/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, new_path: impl Into>, cx: &mut ModelContext, ) -> Task>> { 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 { 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, new_path: impl Into>, cx: &mut ModelContext, ) -> Task>> { @@ -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 {