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

Bennet Bo Fenner created

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

Change summary

crates/project_panel/src/project_panel.rs | 163 ++++++++++++++++++++++++
crates/worktree/src/worktree.rs           |  98 +++++++++++++++
2 files changed, 256 insertions(+), 5 deletions(-)

Detailed changes

crates/project_panel/src/project_panel.rs 🔗

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

crates/worktree/src/worktree.rs 🔗

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