mirror of
https://github.com/zed-industries/zed.git
synced 2024-11-08 07:35:01 +03:00
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:
parent
81eb594037
commit
a5b82b2bf3
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -8311,9 +8311,9 @@ dependencies = [
|
||||
"db",
|
||||
"editor",
|
||||
"file_icons",
|
||||
"futures 0.3.30",
|
||||
"git",
|
||||
"gpui",
|
||||
"indexmap 1.9.3",
|
||||
"language",
|
||||
"menu",
|
||||
"pretty_assertions",
|
||||
|
@ -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()
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user