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))
This commit is contained in:
CharlesChen0823 2024-08-28 16:35:18 +08:00 committed by GitHub
parent 81eb594037
commit a5b82b2bf3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 348 additions and 50 deletions

2
Cargo.lock generated
View File

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

View File

@ -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()

View File

@ -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();

View File

@ -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

View File

@ -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.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);

View File

@ -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 {

View File

@ -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 {