From 38d9ee3731ae627791d08a373e83334b98e9895c Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 13 Jun 2024 20:48:28 +0200 Subject: [PATCH] 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 --- crates/project_panel/src/project_panel.rs | 163 +++++++++++++++++++++- crates/worktree/src/worktree.rs | 98 +++++++++++++ 2 files changed, 256 insertions(+), 5 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 4749d45a2e..6b174cee31 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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)>, last_worktree_root_id: Option, + last_external_paths_drag_over_entry: Option, expanded_dir_ids: HashMap>, unfolded_dir_ids: HashSet, // 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, + ) { + let mut paths: Vec> = 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::(cx.listener( + move |this, event: &DragMoveEvent, 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::(|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(); + }), + ) } } } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index b020a5a876..f7fe575676 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -838,6 +838,23 @@ impl Worktree { } } + pub fn copy_external_entries( + &mut self, + target_directory: PathBuf, + paths: Vec>, + overwrite_existing_files: bool, + cx: &mut ModelContext, + ) -> Task>> { + 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>, + overwrite_existing_files: bool, + cx: &mut ModelContext, + ) -> Task>> { + 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::>(); + + let paths_to_refresh = paths + .iter() + .filter_map(|(_, target)| Some(target.strip_prefix(&worktree_path).ok()?.into())) + .collect::>(); + + 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::( + 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,