Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

project panel: Support dropping files from finder #12880

Merged
merged 12 commits into from
Jun 13, 2024
163 changes: 158 additions & 5 deletions crates/project_panel/src/project_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1717,6 +1719,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,
Expand Down Expand Up @@ -1957,6 +2035,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;
Expand All @@ -1968,6 +2047,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(),
Expand Down Expand Up @@ -2265,6 +2395,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();
}),
)
}
}
}
Expand Down
98 changes: 98 additions & 0 deletions crates/worktree/src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading