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
/// project entries (and all non-leaf nodes are guaranteed to be directories).
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>,
@ -107,6 +108,14 @@ pub struct ProjectPanel {
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)]
struct EditState {
worktree_id: WorktreeId,
@ -249,7 +258,6 @@ struct SerializedProjectPanel {
struct DraggedProjectEntryView {
selection: SelectedEntry,
details: EntryDetails,
width: Pixels,
click_offset: Point<Pixels>,
selections: Arc<BTreeSet<SelectedEntry>>,
}
@ -418,6 +426,7 @@ impl ProjectPanel {
focus_handle,
visible_entries: Default::default(),
ancestors: Default::default(),
folded_directory_drag_target: None,
last_worktree_root_id: Default::default(),
last_external_paths_drag_over_entry: None,
last_selection_drag_over_entry: None,
@ -3464,7 +3473,6 @@ impl ProjectPanel {
.selection
.map_or(false, |selection| selection.entry_id == entry_id);
let width = self.size(window, cx);
let file_name = details.filename.clone();
let mut icon = details.icon.clone();
@ -3523,6 +3531,8 @@ impl ProjectPanel {
bg_hover_color
};
let folded_directory_drag_target = self.folded_directory_drag_target;
div()
.id(entry_id.to_proto() as usize)
.group(GROUP_NAME)
@ -3634,18 +3644,25 @@ impl ProjectPanel {
move |selection, click_offset, _window, cx| {
cx.new(|_| DraggedProjectEntryView {
details: details.clone(),
width,
click_offset,
selection: selection.active_selection,
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(
cx.listener(move |this, selections: &DraggedSelection, window, cx| {
this.hover_scroll_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);
}),
)
@ -3832,15 +3849,51 @@ impl ProjectPanel {
let active_index = components_len
- 1
- folded_ancestors.current_ancestor_depth;
const DELIMITER: SharedString =
const DELIMITER: SharedString =
SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
for (index, component) in components.into_iter().enumerate() {
if index != 0 {
this = this.child(
Label::new(DELIMITER.clone())
.single_line()
.color(filename_text_color),
);
let delimiter_target_index = index - 1;
let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned();
this = this.child(
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!(
"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(
Label::new(component)
.single_line()
@ -4547,35 +4641,33 @@ impl Render for ProjectPanel {
impl Render for DraggedProjectEntryView {
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();
h_flex().font(ui_font).map(|this| {
if self.selections.len() > 1 && self.selections.contains(&self.selection) {
this.flex_none()
.w(self.width)
.child(div().w(self.click_offset.x))
.child(
div()
.p_1()
.rounded_xl()
.bg(cx.theme().colors().background)
.child(Label::new(format!("{} entries", self.selections.len()))),
)
} else {
this.w(self.width).bg(cx.theme().colors().background).child(
ListItem::new(self.selection.entry_id.to_proto() as usize)
.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()))
h_flex()
.font(ui_font)
.pl(self.click_offset.x + px(12.))
.pt(self.click_offset.y + px(12.))
.child(
div()
.flex()
.gap_1()
.items_center()
.py_1()
.px_2()
.rounded_lg()
.bg(cx.theme().colors().background)
.map(|this| {
if self.selections.len() > 1 && self.selections.contains(&self.selection) {
this.child(Label::new(format!("{} entries", self.selections.len())))
} else {
div()
})
.child(Label::new(self.details.filename.clone())),
)
}
})
this.child(if let Some(icon) = &self.details.icon {
div().child(Icon::from_path(icon.clone()))
} else {
div()
})
.child(Label::new(self.details.filename.clone()))
}
}),
)
}
}