project_panel: Add directory auto-expand after 500ms hover during dragging (#23080)

tims created

This PR resolves one part of issue #14496 

In project panel, when dragging, if you hover over a directory for
~500ms, it now auto-expands so you can drag and drop into nested
directories.

Task cleanup is handled in these cases:  
- Dragged onto a different entry.  
- Dragged anywhere else, and the 500ms timer runs out (for example, out
of the project panel).
- Dropped onto any entry.  

I don’t see any edge cases where task isn’t cleaned up after 500ms.


https://github.com/user-attachments/assets/19da0da1-f9e2-42df-8ee4-fab6dc9a185a

Release Notes:

- Added auto-expand for directories on hover for a while during
dragging.

Change summary

crates/project_panel/src/project_panel.rs | 43 +++++++++++++++++++++++++
1 file changed, 43 insertions(+)

Detailed changes

crates/project_panel/src/project_panel.rs 🔗

@@ -81,6 +81,7 @@ pub struct ProjectPanel {
     /// project entries (and all non-leaf nodes are guaranteed to be directories).
     ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
     last_worktree_root_id: Option<ProjectEntryId>,
+    last_selection_drag_over_entry: Option<ProjectEntryId>,
     last_external_paths_drag_over_entry: Option<ProjectEntryId>,
     expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
     unfolded_dir_ids: HashSet<ProjectEntryId>,
@@ -104,6 +105,7 @@ pub struct ProjectPanel {
     // We keep track of the mouse down state on entries so we don't flash the UI
     // in case a user clicks to open a file.
     mouse_down: bool,
+    hover_expand_task: Option<Task<()>>,
 }
 
 #[derive(Clone, Debug)]
@@ -380,6 +382,7 @@ impl ProjectPanel {
                 ancestors: Default::default(),
                 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,
@@ -402,6 +405,7 @@ impl ProjectPanel {
                 diagnostics: Default::default(),
                 scroll_handle,
                 mouse_down: false,
+                hover_expand_task: None,
             };
             this.update_visible_entries(None, cx);
 
@@ -3356,6 +3360,44 @@ impl ProjectPanel {
                     },
                 ))
             })
+            .on_drag_move::<DraggedSelection>(cx.listener(
+                move |this, event: &DragMoveEvent<DraggedSelection>, 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();
+
+                        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(|this, mut cx| async move {
+                            cx.background_executor()
+                                .timer(Duration::from_millis(500))
+                                .await;
+                            this.update(&mut cx, |this, cx| {
+                                this.hover_expand_task.take();
+                                if this.last_selection_drag_over_entry == Some(entry_id)
+                                    && bounds.contains(&cx.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(dragged_selection, move |selection, click_offset, cx| {
                 cx.new_view(|_| DraggedProjectEntryView {
                     details: details.clone(),
@@ -3368,6 +3410,7 @@ impl ProjectPanel {
             .drag_over::<DraggedSelection>(move |style, _, _| style.bg(item_colors.drag_over))
             .on_drop(cx.listener(move |this, selections: &DraggedSelection, cx| {
                 this.hover_scroll_task.take();
+                this.hover_expand_task.take();
                 this.drag_onto(selections, entry_id, kind.is_file(), cx);
             }))
             .on_mouse_down(