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