project_panel: Highlight containing folder which would be the target of the drop operation (#31976)

Part of https://github.com/zed-industries/zed/issues/14496

This PR adds highlighting on the containing folder which would be the
target of the drop operation. It only highlights those directories where
actual drop is possible, i.e. same directory where drag started is not
highlighted.

- [x] Tests


https://github.com/user-attachments/assets/46528467-e07a-4574-a8d5-beab25e70162

Release Notes:

- Improved project panel to show a highlight on the containing folder
which would be the target of the drop operation.
This commit is contained in:
Smit Barmase 2025-06-04 00:34:37 +05:30 committed by GitHub
parent 2645591cd5
commit d8195a8fd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 391 additions and 89 deletions

View file

@ -22,7 +22,7 @@ use gpui::{
Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior,
MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy,
Stateful, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions,
anchored, deferred, div, impl_actions, point, px, size, uniform_list,
anchored, deferred, div, impl_actions, point, px, size, transparent_white, uniform_list,
};
use indexmap::IndexMap;
use language::DiagnosticSeverity;
@ -85,8 +85,7 @@ pub struct ProjectPanel {
ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
last_worktree_root_id: Option<ProjectEntryId>,
last_selection_drag_over_entry: Option<ProjectEntryId>,
last_external_paths_drag_over_entry: Option<ProjectEntryId>,
drag_target_entry: Option<DragTargetEntry>,
expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
unfolded_dir_ids: HashSet<ProjectEntryId>,
// Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
@ -112,6 +111,13 @@ pub struct ProjectPanel {
hover_expand_task: Option<Task<()>>,
}
struct DragTargetEntry {
/// The entry currently under the mouse cursor during a drag operation
entry_id: ProjectEntryId,
/// Highlight this entry along with all of its children
highlight_entry_id: Option<ProjectEntryId>,
}
#[derive(Copy, Clone, Debug)]
struct FoldedDirectoryDragTarget {
entry_id: ProjectEntryId,
@ -472,9 +478,8 @@ impl ProjectPanel {
visible_entries: Default::default(),
ancestors: Default::default(),
folded_directory_drag_target: None,
drag_target_entry: None,
last_worktree_root_id: Default::default(),
last_external_paths_drag_over_entry: None,
last_selection_drag_over_entry: None,
expanded_dir_ids: Default::default(),
unfolded_dir_ids: Default::default(),
selection: None,
@ -3703,6 +3708,67 @@ impl ProjectPanel {
(depth, difference)
}
fn highlight_entry_for_external_drag(
&self,
target_entry: &Entry,
target_worktree: &Worktree,
) -> Option<ProjectEntryId> {
// Always highlight directory or parent directory if it's file
if target_entry.is_dir() {
Some(target_entry.id)
} else if let Some(parent_entry) = target_entry
.path
.parent()
.and_then(|parent_path| target_worktree.entry_for_path(parent_path))
{
Some(parent_entry.id)
} else {
None
}
}
fn highlight_entry_for_selection_drag(
&self,
target_entry: &Entry,
target_worktree: &Worktree,
dragged_selection: &DraggedSelection,
cx: &Context<Self>,
) -> Option<ProjectEntryId> {
let target_parent_path = target_entry.path.parent();
// In case of single item drag, we do not highlight existing
// directory which item belongs too
if dragged_selection.items().count() == 1 {
let active_entry_path = self
.project
.read(cx)
.path_for_entry(dragged_selection.active_selection.entry_id, cx)?;
if let Some(active_parent_path) = active_entry_path.path.parent() {
// Do not highlight active entry parent
if active_parent_path == target_entry.path.as_ref() {
return None;
}
// Do not highlight active entry sibling files
if Some(active_parent_path) == target_parent_path && target_entry.is_file() {
return None;
}
}
}
// Always highlight directory or parent directory if it's file
if target_entry.is_dir() {
Some(target_entry.id)
} else if let Some(parent_entry) =
target_parent_path.and_then(|parent_path| target_worktree.entry_for_path(parent_path))
{
Some(parent_entry.id)
} else {
None
}
}
fn render_entry(
&self,
entry_id: ProjectEntryId,
@ -3745,6 +3811,8 @@ impl ProjectPanel {
.as_ref()
.map(|f| f.to_string_lossy().to_string());
let path = details.path.clone();
let path_for_external_paths = path.clone();
let path_for_dragged_selection = path.clone();
let depth = details.depth;
let worktree_id = details.worktree_id;
@ -3802,6 +3870,27 @@ impl ProjectPanel {
};
let folded_directory_drag_target = self.folded_directory_drag_target;
let is_highlighted = {
if let Some(highlight_entry_id) = self
.drag_target_entry
.as_ref()
.and_then(|drag_target| drag_target.highlight_entry_id)
{
// Highlight if same entry or it's children
if entry_id == highlight_entry_id {
true
} else {
maybe!({
let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
let highlight_entry = worktree.read(cx).entry_for_id(highlight_entry_id)?;
Some(path.starts_with(&highlight_entry.path))
})
.unwrap_or(false)
}
} else {
false
}
};
div()
.id(entry_id.to_proto() as usize)
@ -3815,95 +3904,111 @@ impl ProjectPanel {
.hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
.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;
let is_current_target = this.drag_target_entry.as_ref()
.map(|entry| entry.entry_id) == Some(entry_id);
if !event.bounds.contains(&event.event.position) {
// Entry responsible for setting drag target is also responsible to
// clear it up after drag is out of bounds
if is_current_target {
this.drag_target_entry = None;
}
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 entry = worktree.entry_for_path(&path)?;
let path = if entry.is_dir() {
path.as_ref()
} else {
path.parent()?
};
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();
return;
}
if is_current_target {
return;
}
let Some((entry_id, highlight_entry_id)) = maybe!({
let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx);
let target_entry = target_worktree.entry_for_path(&path_for_external_paths)?;
let highlight_entry_id = this.highlight_entry_for_external_drag(target_entry, target_worktree);
Some((target_entry.id, highlight_entry_id))
}) else {
return;
};
this.drag_target_entry = Some(DragTargetEntry {
entry_id,
highlight_entry_id,
});
this.marked_entries.clear();
},
))
.on_drop(cx.listener(
move |this, external_paths: &ExternalPaths, window, cx| {
this.drag_target_entry = None;
this.hover_scroll_task.take();
this.last_external_paths_drag_over_entry = None;
this.marked_entries.clear();
this.drop_external_files(external_paths.paths(), entry_id, window, cx);
cx.stop_propagation();
},
))
.on_drag_move::<DraggedSelection>(cx.listener(
move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
if event.bounds.contains(&event.event.position) {
if this.last_selection_drag_over_entry == Some(entry_id) {
return;
}
this.last_selection_drag_over_entry = Some(entry_id);
this.hover_expand_task.take();
let is_current_target = this.drag_target_entry.as_ref()
.map(|entry| entry.entry_id) == Some(entry_id);
if !kind.is_dir()
|| this
.expanded_dir_ids
.get(&details.worktree_id)
.map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
{
return;
if !event.bounds.contains(&event.event.position) {
// Entry responsible for setting drag target is also responsible to
// clear it up after drag is out of bounds
if is_current_target {
this.drag_target_entry = None;
}
let bounds = event.bounds;
this.hover_expand_task =
Some(cx.spawn_in(window, async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(500))
.await;
this.update_in(cx, |this, window, cx| {
this.hover_expand_task.take();
if this.last_selection_drag_over_entry == Some(entry_id)
&& bounds.contains(&window.mouse_position())
{
this.expand_entry(worktree_id, entry_id, cx);
this.update_visible_entries(
Some((worktree_id, entry_id)),
cx,
);
cx.notify();
}
})
.ok();
}));
return;
}
if is_current_target {
return;
}
let Some((entry_id, highlight_entry_id)) = maybe!({
let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx);
let target_entry = target_worktree.entry_for_path(&path_for_dragged_selection)?;
let dragged_selection = event.drag(cx);
let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, dragged_selection, cx);
Some((target_entry.id, highlight_entry_id))
}) else {
return;
};
this.drag_target_entry = Some(DragTargetEntry {
entry_id,
highlight_entry_id,
});
this.marked_entries.clear();
this.hover_expand_task.take();
if !kind.is_dir()
|| this
.expanded_dir_ids
.get(&details.worktree_id)
.map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
{
return;
}
let bounds = event.bounds;
this.hover_expand_task =
Some(cx.spawn_in(window, async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(500))
.await;
this.update_in(cx, |this, window, cx| {
this.hover_expand_task.take();
if this.drag_target_entry.as_ref().map(|entry| entry.entry_id) == Some(entry_id)
&& bounds.contains(&window.mouse_position())
{
this.expand_entry(worktree_id, entry_id, cx);
this.update_visible_entries(
Some((worktree_id, entry_id)),
cx,
);
cx.notify();
}
})
.ok();
}));
},
))
.on_drag(
@ -3917,14 +4022,10 @@ impl ProjectPanel {
})
},
)
.drag_over::<DraggedSelection>(move |style, _, _, _| {
if folded_directory_drag_target.is_some() {
return style;
}
style.bg(item_colors.drag_over)
})
.when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over))
.on_drop(
cx.listener(move |this, selections: &DraggedSelection, window, cx| {
this.drag_target_entry = None;
this.hover_scroll_task.take();
this.hover_expand_task.take();
if folded_directory_drag_target.is_some() {
@ -4126,6 +4227,7 @@ impl ProjectPanel {
div()
.on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
this.hover_scroll_task.take();
this.drag_target_entry = None;
this.folded_directory_drag_target = None;
if let Some(target_entry_id) = target_entry_id {
this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
@ -4208,6 +4310,7 @@ impl ProjectPanel {
))
.on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| {
this.hover_scroll_task.take();
this.drag_target_entry = None;
this.folded_directory_drag_target = None;
if let Some(target_entry_id) = target_entry_id {
this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
@ -4573,13 +4676,14 @@ impl Render for ProjectPanel {
.map(|(_, worktree_entries, _)| worktree_entries.len())
.sum();
fn handle_drag_move_scroll<T: 'static>(
fn handle_drag_move<T: 'static>(
this: &mut ProjectPanel,
e: &DragMoveEvent<T>,
window: &mut Window,
cx: &mut Context<ProjectPanel>,
) {
if !e.bounds.contains(&e.event.position) {
this.drag_target_entry = None;
return;
}
this.hover_scroll_task.take();
@ -4633,8 +4737,8 @@ impl Render for ProjectPanel {
h_flex()
.id("project-panel")
.group("project-panel")
.on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
.on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
.on_drag_move(cx.listener(handle_drag_move::<ExternalPaths>))
.on_drag_move(cx.listener(handle_drag_move::<DraggedSelection>))
.size_full()
.relative()
.on_hover(cx.listener(|this, hovered, window, cx| {
@ -4890,8 +4994,7 @@ impl Render for ProjectPanel {
})
.on_drop(cx.listener(
move |this, external_paths: &ExternalPaths, window, cx| {
this.last_external_paths_drag_over_entry = None;
this.marked_entries.clear();
this.drag_target_entry = None;
this.hover_scroll_task.take();
if let Some(task) = this
.workspace