project panel: Support dropping files from finder (#12880)

Partially addresses #7386 



https://github.com/zed-industries/zed/assets/53836821/fc2e9864-40a8-4ada-ac95-a76a31c44437



Release Notes:

- Added support for dropping files from the finder onto the project
panel
This commit is contained in:
Bennet Bo Fenner 2024-06-13 20:48:28 +02:00 committed by GitHub
parent 95c69d0696
commit 38d9ee3731
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 256 additions and 5 deletions

View File

@ -11,11 +11,11 @@ use collections::{hash_map, BTreeSet, HashMap};
use git::repository::GitFileStatus;
use gpui::{
actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, EventEmitter,
FocusHandle, FocusableView, InteractiveElement, KeyContext, ListSizingBehavior, Model,
MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful,
Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _,
WeakView, WindowContext,
AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, DragMoveEvent,
EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement, KeyContext,
ListSizingBehavior, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
PromptLevel, Render, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View,
ViewContext, VisualContext as _, WeakView, WindowContext,
};
use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
use project::{Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
@ -50,6 +50,7 @@ pub struct ProjectPanel {
focus_handle: FocusHandle,
visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
last_worktree_root_id: Option<ProjectEntryId>,
last_external_paths_drag_over_entry: Option<ProjectEntryId>,
expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
unfolded_dir_ids: HashSet<ProjectEntryId>,
// Currently selected entry in a file tree
@ -260,6 +261,7 @@ impl ProjectPanel {
focus_handle,
visible_entries: Default::default(),
last_worktree_root_id: Default::default(),
last_external_paths_drag_over_entry: None,
expanded_dir_ids: Default::default(),
unfolded_dir_ids: Default::default(),
selection: None,
@ -1718,6 +1720,82 @@ impl ProjectPanel {
});
}
fn drop_external_files(
&mut self,
paths: &[PathBuf],
entry_id: ProjectEntryId,
cx: &mut ViewContext<Self>,
) {
let mut paths: Vec<Arc<Path>> = paths
.into_iter()
.map(|path| Arc::from(path.clone()))
.collect();
let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
let Some((target_directory, worktree)) = maybe!({
let worktree = self.project.read(cx).worktree_for_entry(entry_id, cx)?;
let entry = worktree.read(cx).entry_for_id(entry_id)?;
let path = worktree.read(cx).absolutize(&entry.path).ok()?;
let target_directory = if path.is_dir() {
path
} else {
path.parent()?.to_path_buf()
};
Some((target_directory, worktree))
}) else {
return;
};
let mut paths_to_replace = Vec::new();
for path in &paths {
if let Some(name) = path.file_name() {
let mut target_path = target_directory.clone();
target_path.push(name);
if target_path.exists() {
paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
}
}
}
cx.spawn(|this, mut cx| {
async move {
for (filename, original_path) in &paths_to_replace {
let answer = cx
.prompt(
PromptLevel::Info,
format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
None,
&["Replace", "Cancel"],
)
.await?;
if answer == 1 {
if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
paths.remove(item_idx);
}
}
}
if paths.is_empty() {
return Ok(());
}
let task = worktree.update(&mut cx, |worktree, cx| {
worktree.copy_external_entries(target_directory, paths, true, cx)
})?;
let opened_entries = task.await?;
this.update(&mut cx, |this, cx| {
if open_file_after_drop && !opened_entries.is_empty() {
this.open_entry(opened_entries[0], true, true, false, cx);
}
})
}
.log_err()
})
.detach();
}
fn drag_onto(
&mut self,
selections: &DraggedSelection,
@ -1949,6 +2027,7 @@ impl ProjectPanel {
.canonical_path
.as_ref()
.map(|f| f.to_string_lossy().to_string());
let path = details.path.clone();
let depth = details.depth;
let worktree_id = details.worktree_id;
@ -1960,6 +2039,57 @@ impl ProjectPanel {
};
div()
.id(entry_id.to_proto() as usize)
.on_drag_move::<ExternalPaths>(cx.listener(
move |this, event: &DragMoveEvent<ExternalPaths>, cx| {
if event.bounds.contains(&event.event.position) {
if this.last_external_paths_drag_over_entry == Some(entry_id) {
return;
}
this.last_external_paths_drag_over_entry = Some(entry_id);
this.marked_entries.clear();
let Some((worktree, path, entry)) = maybe!({
let worktree = this
.project
.read(cx)
.worktree_for_id(selection.worktree_id, cx)?;
let worktree = worktree.read(cx);
let abs_path = worktree.absolutize(&path).log_err()?;
let path = if abs_path.is_dir() {
path.as_ref()
} else {
path.parent()?
};
let entry = worktree.entry_for_path(path)?;
Some((worktree, path, entry))
}) else {
return;
};
this.marked_entries.insert(SelectedEntry {
entry_id: entry.id,
worktree_id: worktree.id(),
});
for entry in worktree.child_entries(path) {
this.marked_entries.insert(SelectedEntry {
entry_id: entry.id,
worktree_id: worktree.id(),
});
}
cx.notify();
}
},
))
.on_drop(
cx.listener(move |this, external_paths: &ExternalPaths, cx| {
this.last_external_paths_drag_over_entry = None;
this.marked_entries.clear();
this.drop_external_files(external_paths.paths(), entry_id, cx);
cx.stop_propagation();
}),
)
.on_drag(dragged_selection, move |selection, cx| {
cx.new_view(|_| DraggedProjectEntryView {
details: details.clone(),
@ -2257,6 +2387,29 @@ impl Render for ProjectPanel {
.log_err();
})),
)
.drag_over::<ExternalPaths>(|style, _, cx| {
style.bg(cx.theme().colors().drop_target_background)
})
.on_drop(
cx.listener(move |this, external_paths: &ExternalPaths, cx| {
this.last_external_paths_drag_over_entry = None;
this.marked_entries.clear();
if let Some(task) = this
.workspace
.update(cx, |workspace, cx| {
workspace.open_workspace_for_paths(
true,
external_paths.paths().to_owned(),
cx,
)
})
.log_err()
{
task.detach_and_log_err(cx);
}
cx.stop_propagation();
}),
)
}
}
}

View File

@ -838,6 +838,23 @@ impl Worktree {
}
}
pub fn copy_external_entries(
&mut self,
target_directory: PathBuf,
paths: Vec<Arc<Path>>,
overwrite_existing_files: bool,
cx: &mut ModelContext<Worktree>,
) -> Task<Result<Vec<ProjectEntryId>>> {
match self {
Worktree::Local(this) => {
this.copy_external_entries(target_directory, paths, overwrite_existing_files, cx)
}
_ => Task::ready(Err(anyhow!(
"Copying external entries is not supported for remote worktrees"
))),
}
}
pub fn expand_entry(
&mut self,
entry_id: ProjectEntryId,
@ -1579,6 +1596,87 @@ impl LocalWorktree {
})
}
pub fn copy_external_entries(
&mut self,
target_directory: PathBuf,
paths: Vec<Arc<Path>>,
overwrite_existing_files: bool,
cx: &mut ModelContext<Worktree>,
) -> Task<Result<Vec<ProjectEntryId>>> {
let worktree_path = self.abs_path().clone();
let fs = self.fs.clone();
let paths = paths
.into_iter()
.filter_map(|source| {
let file_name = source.file_name()?;
let mut target = target_directory.clone();
target.push(file_name);
// Do not allow copying the same file to itself.
if source.as_ref() != target.as_path() {
Some((source, target))
} else {
None
}
})
.collect::<Vec<_>>();
let paths_to_refresh = paths
.iter()
.filter_map(|(_, target)| Some(target.strip_prefix(&worktree_path).ok()?.into()))
.collect::<Vec<_>>();
cx.spawn(|this, cx| async move {
cx.background_executor()
.spawn(async move {
for (source, target) in paths {
copy_recursive(
fs.as_ref(),
&source,
&target,
fs::CopyOptions {
overwrite: overwrite_existing_files,
..Default::default()
},
)
.await
.with_context(|| {
anyhow!("Failed to copy file from {source:?} to {target:?}")
})?;
}
Ok::<(), anyhow::Error>(())
})
.await
.log_err();
let mut refresh = cx.read_model(
&this.upgrade().with_context(|| "Dropped worktree")?,
|this, _| {
Ok::<postage::barrier::Receiver, anyhow::Error>(
this.as_local()
.with_context(|| "Worktree is not local")?
.refresh_entries_for_paths(paths_to_refresh.clone()),
)
},
)??;
cx.background_executor()
.spawn(async move {
refresh.next().await;
Ok::<(), anyhow::Error>(())
})
.await
.log_err();
let this = this.upgrade().with_context(|| "Dropped worktree")?;
cx.read_model(&this, |this, _| {
paths_to_refresh
.iter()
.filter_map(|path| Some(this.entry_for_path(path)?.id))
.collect()
})
})
}
fn expand_entry(
&mut self,
entry_id: ProjectEntryId,