project_panel: Add precise drag-and-drop for files onto folded directories (#22983)

Closes #19192

1. Changed the drag overlay of entries for better visibility of where to
drop.
2. Folded directories (except for the last folded one) will be
highlighted as drop targets.
3. The delimiter between folded directories prevents the directory
highlight from losing focus and acts as part of the directory to avoid
flickering.

This works just like VS Code does.


[fold-drop.webm](https://github.com/user-attachments/assets/853f7c5e-3492-4f56-9736-6d0e3ef09325)

Release Notes:

- Added precise drag-and-drop for files onto folded directories in the
Project Panel.

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
This commit is contained in:
tims 2025-01-28 14:12:10 +05:30 committed by GitHub
parent 5c650cdcb2
commit f314662048
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -79,6 +79,7 @@ pub struct ProjectPanel {
/// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several /// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
/// project entries (and all non-leaf nodes are guaranteed to be directories). /// project entries (and all non-leaf nodes are guaranteed to be directories).
ancestors: HashMap<ProjectEntryId, FoldedAncestors>, ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
last_worktree_root_id: Option<ProjectEntryId>, last_worktree_root_id: Option<ProjectEntryId>,
last_selection_drag_over_entry: Option<ProjectEntryId>, last_selection_drag_over_entry: Option<ProjectEntryId>,
last_external_paths_drag_over_entry: Option<ProjectEntryId>, last_external_paths_drag_over_entry: Option<ProjectEntryId>,
@ -107,6 +108,14 @@ pub struct ProjectPanel {
hover_expand_task: Option<Task<()>>, hover_expand_task: Option<Task<()>>,
} }
#[derive(Copy, Clone, Debug)]
struct FoldedDirectoryDragTarget {
entry_id: ProjectEntryId,
index: usize,
/// Whether we are dragging over the delimiter rather than the component itself.
is_delimiter_target: bool,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct EditState { struct EditState {
worktree_id: WorktreeId, worktree_id: WorktreeId,
@ -249,7 +258,6 @@ struct SerializedProjectPanel {
struct DraggedProjectEntryView { struct DraggedProjectEntryView {
selection: SelectedEntry, selection: SelectedEntry,
details: EntryDetails, details: EntryDetails,
width: Pixels,
click_offset: Point<Pixels>, click_offset: Point<Pixels>,
selections: Arc<BTreeSet<SelectedEntry>>, selections: Arc<BTreeSet<SelectedEntry>>,
} }
@ -418,6 +426,7 @@ impl ProjectPanel {
focus_handle, focus_handle,
visible_entries: Default::default(), visible_entries: Default::default(),
ancestors: Default::default(), ancestors: Default::default(),
folded_directory_drag_target: None,
last_worktree_root_id: Default::default(), last_worktree_root_id: Default::default(),
last_external_paths_drag_over_entry: None, last_external_paths_drag_over_entry: None,
last_selection_drag_over_entry: None, last_selection_drag_over_entry: None,
@ -3464,7 +3473,6 @@ impl ProjectPanel {
.selection .selection
.map_or(false, |selection| selection.entry_id == entry_id); .map_or(false, |selection| selection.entry_id == entry_id);
let width = self.size(window, cx);
let file_name = details.filename.clone(); let file_name = details.filename.clone();
let mut icon = details.icon.clone(); let mut icon = details.icon.clone();
@ -3523,6 +3531,8 @@ impl ProjectPanel {
bg_hover_color bg_hover_color
}; };
let folded_directory_drag_target = self.folded_directory_drag_target;
div() div()
.id(entry_id.to_proto() as usize) .id(entry_id.to_proto() as usize)
.group(GROUP_NAME) .group(GROUP_NAME)
@ -3634,18 +3644,25 @@ impl ProjectPanel {
move |selection, click_offset, _window, cx| { move |selection, click_offset, _window, cx| {
cx.new(|_| DraggedProjectEntryView { cx.new(|_| DraggedProjectEntryView {
details: details.clone(), details: details.clone(),
width,
click_offset, click_offset,
selection: selection.active_selection, selection: selection.active_selection,
selections: selection.marked_selections.clone(), selections: selection.marked_selections.clone(),
}) })
}, },
) )
.drag_over::<DraggedSelection>(move |style, _, _, _| style.bg(item_colors.drag_over)) .drag_over::<DraggedSelection>(move |style, _, _, _| {
if folded_directory_drag_target.is_some() {
return style;
}
style.bg(item_colors.drag_over)
})
.on_drop( .on_drop(
cx.listener(move |this, selections: &DraggedSelection, window, cx| { cx.listener(move |this, selections: &DraggedSelection, window, cx| {
this.hover_scroll_task.take(); this.hover_scroll_task.take();
this.hover_expand_task.take(); this.hover_expand_task.take();
if folded_directory_drag_target.is_some() {
return;
}
this.drag_onto(selections, entry_id, kind.is_file(), window, cx); this.drag_onto(selections, entry_id, kind.is_file(), window, cx);
}), }),
) )
@ -3832,15 +3849,51 @@ impl ProjectPanel {
let active_index = components_len let active_index = components_len
- 1 - 1
- folded_ancestors.current_ancestor_depth; - folded_ancestors.current_ancestor_depth;
const DELIMITER: SharedString = const DELIMITER: SharedString =
SharedString::new_static(std::path::MAIN_SEPARATOR_STR); SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
for (index, component) in components.into_iter().enumerate() { for (index, component) in components.into_iter().enumerate() {
if index != 0 { if index != 0 {
this = this.child( let delimiter_target_index = index - 1;
Label::new(DELIMITER.clone()) let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned();
.single_line() this = this.child(
.color(filename_text_color), div()
); .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
this.hover_scroll_task.take();
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);
}
}))
.on_drag_move(cx.listener(
move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
if event.bounds.contains(&event.event.position) {
this.folded_directory_drag_target = Some(
FoldedDirectoryDragTarget {
entry_id,
index: delimiter_target_index,
is_delimiter_target: true,
}
);
} else {
let is_current_target = this.folded_directory_drag_target
.map_or(false, |target|
target.entry_id == entry_id &&
target.index == delimiter_target_index &&
target.is_delimiter_target
);
if is_current_target {
this.folded_directory_drag_target = None;
}
}
},
))
.child(
Label::new(DELIMITER.clone())
.single_line()
.color(filename_text_color)
)
);
} }
let id = SharedString::from(format!( let id = SharedString::from(format!(
"project_panel_path_component_{}_{index}", "project_panel_path_component_{}_{index}",
@ -3859,6 +3912,47 @@ impl ProjectPanel {
} }
} }
})) }))
.when(index != components_len - 1, |div|{
let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned();
div
.on_drag_move(cx.listener(
move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
if event.bounds.contains(&event.event.position) {
this.folded_directory_drag_target = Some(
FoldedDirectoryDragTarget {
entry_id,
index,
is_delimiter_target: false,
}
);
} else {
let is_current_target = this.folded_directory_drag_target
.as_ref()
.map_or(false, |target|
target.entry_id == entry_id &&
target.index == index &&
!target.is_delimiter_target
);
if is_current_target {
this.folded_directory_drag_target = None;
}
}
},
))
.on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| {
this.hover_scroll_task.take();
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);
}
}))
.when(folded_directory_drag_target.map_or(false, |target|
target.entry_id == entry_id &&
target.index == index
), |this| {
this.bg(item_colors.drag_over)
})
})
.child( .child(
Label::new(component) Label::new(component)
.single_line() .single_line()
@ -4547,35 +4641,33 @@ impl Render for ProjectPanel {
impl Render for DraggedProjectEntryView { impl Render for DraggedProjectEntryView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let settings = ProjectPanelSettings::get_global(cx);
let ui_font = ThemeSettings::get_global(cx).ui_font.clone(); let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
h_flex()
h_flex().font(ui_font).map(|this| { .font(ui_font)
if self.selections.len() > 1 && self.selections.contains(&self.selection) { .pl(self.click_offset.x + px(12.))
this.flex_none() .pt(self.click_offset.y + px(12.))
.w(self.width) .child(
.child(div().w(self.click_offset.x)) div()
.child( .flex()
div() .gap_1()
.p_1() .items_center()
.rounded_xl() .py_1()
.bg(cx.theme().colors().background) .px_2()
.child(Label::new(format!("{} entries", self.selections.len()))), .rounded_lg()
) .bg(cx.theme().colors().background)
} else { .map(|this| {
this.w(self.width).bg(cx.theme().colors().background).child( if self.selections.len() > 1 && self.selections.contains(&self.selection) {
ListItem::new(self.selection.entry_id.to_proto() as usize) this.child(Label::new(format!("{} entries", self.selections.len())))
.indent_level(self.details.depth)
.indent_step_size(px(settings.indent_size))
.child(if let Some(icon) = &self.details.icon {
div().child(Icon::from_path(icon.clone()))
} else { } else {
div() this.child(if let Some(icon) = &self.details.icon {
}) div().child(Icon::from_path(icon.clone()))
.child(Label::new(self.details.filename.clone())), } else {
) div()
} })
}) .child(Label::new(self.details.filename.clone()))
}
}),
)
} }
} }