Allow dragging and dropping files in the project panel (#3602)

Max Brunsfeld created

Also, fix a bug that prevented drag and drop from working in the collab
panel.

Change summary

crates/collab_ui2/src/collab_panel.rs         |   2 
crates/gpui2/src/elements/div.rs              |  47 ++-
crates/gpui2/src/window.rs                    |  21 +
crates/project_panel2/src/project_panel.rs    | 215 ++++++++++++--------
crates/ui2/src/components/disclosure.rs       |  10 
crates/ui2/src/components/list/list_header.rs |  10 
crates/ui2/src/components/list/list_item.rs   |  26 +-
7 files changed, 201 insertions(+), 130 deletions(-)

Detailed changes

crates/collab_ui2/src/collab_panel.rs 🔗

@@ -2557,7 +2557,7 @@ impl CollabPanel {
                 let channel = channel.clone();
                 move |cx| {
                     let channel = channel.clone();
-                    cx.build_view({ |cx| DraggedChannelView { channel, width } })
+                    cx.build_view(|cx| DraggedChannelView { channel, width })
                 }
             })
             .drag_over::<DraggedChannelView>(|style| {

crates/gpui2/src/elements/div.rs 🔗

@@ -763,6 +763,11 @@ impl InteractiveBounds {
     pub fn visibly_contains(&self, point: &Point<Pixels>, cx: &WindowContext) -> bool {
         self.bounds.contains(point) && cx.was_top_layer(&point, &self.stacking_order)
     }
+
+    pub fn drag_target_contains(&self, point: &Point<Pixels>, cx: &WindowContext) -> bool {
+        self.bounds.contains(point)
+            && cx.was_top_layer_under_active_drag(&point, &self.stacking_order)
+    }
 }
 
 impl Interactivity {
@@ -888,30 +893,32 @@ impl Interactivity {
         if cx.active_drag.is_some() {
             let drop_listeners = mem::take(&mut self.drop_listeners);
             let interactive_bounds = interactive_bounds.clone();
-            cx.on_mouse_event(move |event: &MouseUpEvent, phase, cx| {
-                if phase == DispatchPhase::Bubble
-                    && interactive_bounds.visibly_contains(&event.position, &cx)
-                {
-                    if let Some(drag_state_type) =
-                        cx.active_drag.as_ref().map(|drag| drag.view.entity_type())
+            if !drop_listeners.is_empty() {
+                cx.on_mouse_event(move |event: &MouseUpEvent, phase, cx| {
+                    if phase == DispatchPhase::Bubble
+                        && interactive_bounds.drag_target_contains(&event.position, cx)
                     {
-                        for (drop_state_type, listener) in &drop_listeners {
-                            if *drop_state_type == drag_state_type {
-                                let drag = cx
-                                    .active_drag
-                                    .take()
-                                    .expect("checked for type drag state type above");
-
-                                listener(drag.view.clone(), cx);
-                                cx.notify();
-                                cx.stop_propagation();
+                        if let Some(drag_state_type) =
+                            cx.active_drag.as_ref().map(|drag| drag.view.entity_type())
+                        {
+                            for (drop_state_type, listener) in &drop_listeners {
+                                if *drop_state_type == drag_state_type {
+                                    let drag = cx
+                                        .active_drag
+                                        .take()
+                                        .expect("checked for type drag state type above");
+
+                                    listener(drag.view.clone(), cx);
+                                    cx.notify();
+                                    cx.stop_propagation();
+                                }
                             }
+                        } else {
+                            cx.active_drag = None;
                         }
-                    } else {
-                        cx.active_drag = None;
                     }
-                }
-            });
+                });
+            }
         }
 
         let click_listeners = mem::take(&mut self.click_listeners);

crates/gpui2/src/window.rs 🔗

@@ -38,6 +38,8 @@ use std::{
 };
 use util::ResultExt;
 
+const ACTIVE_DRAG_Z_INDEX: u32 = 1;
+
 /// A global stacking order, which is created by stacking successive z-index values.
 /// Each z-index will always be interpreted in the context of its parent z-index.
 #[derive(Deref, DerefMut, Ord, PartialOrd, Eq, PartialEq, Clone, Default, Debug)]
@@ -907,6 +909,23 @@ impl<'a> WindowContext<'a> {
         false
     }
 
+    pub fn was_top_layer_under_active_drag(
+        &self,
+        point: &Point<Pixels>,
+        level: &StackingOrder,
+    ) -> bool {
+        for (stack, bounds) in self.window.rendered_frame.depth_map.iter() {
+            if stack.starts_with(&[ACTIVE_DRAG_Z_INDEX]) {
+                continue;
+            }
+            if bounds.contains(point) {
+                return level.starts_with(stack) || stack.starts_with(level);
+            }
+        }
+
+        false
+    }
+
     /// Called during painting to get the current stacking order.
     pub fn stacking_order(&self) -> &StackingOrder {
         &self.window.next_frame.z_index_stack
@@ -1238,7 +1257,7 @@ impl<'a> WindowContext<'a> {
         });
 
         if let Some(active_drag) = self.app.active_drag.take() {
-            self.with_z_index(1, |cx| {
+            self.with_z_index(ACTIVE_DRAG_Z_INDEX, |cx| {
                 let offset = cx.mouse_position() - active_drag.cursor_offset;
                 let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent);
                 active_drag.view.draw(offset, available_space, cx);

crates/project_panel2/src/project_panel.rs 🔗

@@ -29,6 +29,7 @@ use std::{
     path::Path,
     sync::Arc,
 };
+use theme::ThemeSettings;
 use ui::{prelude::*, v_stack, ContextMenu, IconElement, Label, ListItem};
 use unicase::UniCase;
 use util::{maybe, ResultExt, TryFutureExt};
@@ -55,7 +56,7 @@ pub struct ProjectPanel {
     clipboard_entry: Option<ClipboardEntry>,
     _dragged_entry_destination: Option<Arc<Path>>,
     _workspace: WeakView<Workspace>,
-    width: Option<f32>,
+    width: Option<Pixels>,
     pending_serialization: Task<Option<()>>,
 }
 
@@ -86,7 +87,7 @@ pub enum ClipboardEntry {
     },
 }
 
-#[derive(Debug, PartialEq, Eq)]
+#[derive(Debug, PartialEq, Eq, Clone)]
 pub struct EntryDetails {
     filename: String,
     icon: Option<Arc<str>>,
@@ -162,6 +163,12 @@ struct SerializedProjectPanel {
     width: Option<f32>,
 }
 
+struct DraggedProjectEntryView {
+    entry_id: ProjectEntryId,
+    details: EntryDetails,
+    width: Pixels,
+}
+
 impl ProjectPanel {
     fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
         let project = workspace.project().clone();
@@ -236,7 +243,6 @@ impl ProjectPanel {
                 context_menu: None,
                 filename_editor,
                 clipboard_entry: None,
-                // context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
                 _dragged_entry_destination: None,
                 _workspace: workspace.weak_handle(),
                 width: None,
@@ -331,7 +337,7 @@ impl ProjectPanel {
             let panel = ProjectPanel::new(workspace, cx);
             if let Some(serialized_panel) = serialized_panel {
                 panel.update(cx, |panel, cx| {
-                    panel.width = serialized_panel.width;
+                    panel.width = serialized_panel.width.map(px);
                     cx.notify();
                 });
             }
@@ -346,7 +352,9 @@ impl ProjectPanel {
                 KEY_VALUE_STORE
                     .write_kvp(
                         PROJECT_PANEL_KEY.into(),
-                        serde_json::to_string(&SerializedProjectPanel { width })?,
+                        serde_json::to_string(&SerializedProjectPanel {
+                            width: width.map(|p| p.0),
+                        })?,
                     )
                     .await?;
                 anyhow::Ok(())
@@ -1003,37 +1011,36 @@ impl ProjectPanel {
         }
     }
 
-    // todo!()
-    // fn move_entry(
-    //     &mut self,
-    //     entry_to_move: ProjectEntryId,
-    //     destination: ProjectEntryId,
-    //     destination_is_file: bool,
-    //     cx: &mut ViewContext<Self>,
-    // ) {
-    //     let destination_worktree = self.project.update(cx, |project, cx| {
-    //         let entry_path = project.path_for_entry(entry_to_move, cx)?;
-    //         let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
-
-    //         let mut destination_path = destination_entry_path.as_ref();
-    //         if destination_is_file {
-    //             destination_path = destination_path.parent()?;
-    //         }
-
-    //         let mut new_path = destination_path.to_path_buf();
-    //         new_path.push(entry_path.path.file_name()?);
-    //         if new_path != entry_path.path.as_ref() {
-    //             let task = project.rename_entry(entry_to_move, new_path, cx);
-    //             cx.foreground_executor().spawn(task).detach_and_log_err(cx);
-    //         }
-
-    //         Some(project.worktree_id_for_entry(destination, cx)?)
-    //     });
-
-    //     if let Some(destination_worktree) = destination_worktree {
-    //         self.expand_entry(destination_worktree, destination, cx);
-    //     }
-    // }
+    fn move_entry(
+        &mut self,
+        entry_to_move: ProjectEntryId,
+        destination: ProjectEntryId,
+        destination_is_file: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let destination_worktree = self.project.update(cx, |project, cx| {
+            let entry_path = project.path_for_entry(entry_to_move, cx)?;
+            let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
+
+            let mut destination_path = destination_entry_path.as_ref();
+            if destination_is_file {
+                destination_path = destination_path.parent()?;
+            }
+
+            let mut new_path = destination_path.to_path_buf();
+            new_path.push(entry_path.path.file_name()?);
+            if new_path != entry_path.path.as_ref() {
+                let task = project.rename_entry(entry_to_move, new_path, cx);
+                cx.foreground_executor().spawn(task).detach_and_log_err(cx);
+            }
+
+            Some(project.worktree_id_for_entry(destination, cx)?)
+        });
+
+        if let Some(destination_worktree) = destination_worktree {
+            self.expand_entry(destination_worktree, destination, cx);
+        }
+    }
 
     fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
         let mut entry_index = 0;
@@ -1349,15 +1356,15 @@ impl ProjectPanel {
         &self,
         entry_id: ProjectEntryId,
         details: EntryDetails,
-        // dragged_entry_destination: &mut Option<Arc<Path>>,
         cx: &mut ViewContext<Self>,
-    ) -> ListItem {
+    ) -> Stateful<Div> {
         let kind = details.kind;
         let settings = ProjectPanelSettings::get_global(cx);
         let show_editor = details.is_editing && !details.is_processing;
         let is_selected = self
             .selection
             .map_or(false, |selection| selection.entry_id == entry_id);
+        let width = self.width.unwrap_or(px(0.));
 
         let theme = cx.theme();
         let filename_text_color = details
@@ -1370,52 +1377,69 @@ impl ProjectPanel {
             })
             .unwrap_or(theme.status().info);
 
-        ListItem::new(entry_id.to_proto() as usize)
-            .indent_level(details.depth)
-            .indent_step_size(px(settings.indent_size))
-            .selected(is_selected)
-            .child(if let Some(icon) = &details.icon {
-                div().child(IconElement::from_path(icon.to_string()))
-            } else {
-                div()
+        div()
+            .id(entry_id.to_proto() as usize)
+            .on_drag({
+                let details = details.clone();
+                move |cx| {
+                    let details = details.clone();
+                    cx.build_view(|_| DraggedProjectEntryView {
+                        details,
+                        width,
+                        entry_id,
+                    })
+                }
+            })
+            .drag_over::<DraggedProjectEntryView>(|style| {
+                style.bg(cx.theme().colors().ghost_element_hover)
             })
+            .on_drop(cx.listener(
+                move |this, dragged_view: &View<DraggedProjectEntryView>, cx| {
+                    this.move_entry(dragged_view.read(cx).entry_id, entry_id, kind.is_file(), cx);
+                },
+            ))
             .child(
-                if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
-                    div().h_full().w_full().child(editor.clone())
-                } else {
-                    div()
-                        .text_color(filename_text_color)
-                        .child(Label::new(details.filename.clone()))
-                }
-                .ml_1(),
-            )
-            .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
-                if event.down.button == MouseButton::Right {
-                    return;
-                }
-                if !show_editor {
-                    if kind.is_dir() {
-                        this.toggle_expanded(entry_id, cx);
+                ListItem::new(entry_id.to_proto() as usize)
+                    .indent_level(details.depth)
+                    .indent_step_size(px(settings.indent_size))
+                    .selected(is_selected)
+                    .child(if let Some(icon) = &details.icon {
+                        div().child(IconElement::from_path(icon.to_string()))
                     } else {
-                        if event.down.modifiers.command {
-                            this.split_entry(entry_id, cx);
+                        div()
+                    })
+                    .child(
+                        if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
+                            div().h_full().w_full().child(editor.clone())
                         } else {
-                            this.open_entry(entry_id, event.up.click_count > 1, cx);
+                            div()
+                                .text_color(filename_text_color)
+                                .child(Label::new(details.filename.clone()))
                         }
-                    }
-                }
-            }))
-            .on_secondary_mouse_down(cx.listener(move |this, event: &MouseDownEvent, cx| {
-                this.deploy_context_menu(event.position, entry_id, cx);
-            }))
-        // .on_drop::<ProjectEntryId>(|this, event, cx| {
-        //     this.move_entry(
-        //         *dragged_entry,
-        //         entry_id,
-        //         matches!(details.kind, EntryKind::File(_)),
-        //         cx,
-        //     );
-        // })
+                        .ml_1(),
+                    )
+                    .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
+                        if event.down.button == MouseButton::Right {
+                            return;
+                        }
+                        if !show_editor {
+                            if kind.is_dir() {
+                                this.toggle_expanded(entry_id, cx);
+                            } else {
+                                if event.down.modifiers.command {
+                                    this.split_entry(entry_id, cx);
+                                } else {
+                                    this.open_entry(entry_id, event.up.click_count > 1, cx);
+                                }
+                            }
+                        }
+                    }))
+                    .on_secondary_mouse_down(cx.listener(
+                        move |this, event: &MouseDownEvent, cx| {
+                            this.deploy_context_menu(event.position, entry_id, cx);
+                        },
+                    )),
+            )
     }
 
     fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
@@ -1430,7 +1454,6 @@ impl ProjectPanel {
         };
 
         dispatch_context.add(identifier);
-
         dispatch_context
     }
 }
@@ -1503,6 +1526,30 @@ impl Render for ProjectPanel {
     }
 }
 
+impl Render for DraggedProjectEntryView {
+    type Element = Div;
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
+        let settings = ProjectPanelSettings::get_global(cx);
+        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
+        h_stack()
+            .font(ui_font)
+            .bg(cx.theme().colors().background)
+            .w(self.width)
+            .child(
+                ListItem::new(self.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(IconElement::from_path(icon.to_string()))
+                    } else {
+                        div()
+                    })
+                    .child(Label::new(self.details.filename.clone())),
+            )
+    }
+}
+
 impl EventEmitter<Event> for ProjectPanel {}
 
 impl EventEmitter<PanelEvent> for ProjectPanel {}
@@ -1534,12 +1581,14 @@ impl Panel for ProjectPanel {
     }
 
     fn size(&self, cx: &WindowContext) -> f32 {
-        self.width
-            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
+        self.width.map_or_else(
+            || ProjectPanelSettings::get_global(cx).default_width,
+            |width| width.0,
+        )
     }
 
     fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
-        self.width = size;
+        self.width = size.map(px);
         self.serialize(cx);
         cx.notify();
     }

crates/ui2/src/components/disclosure.rs 🔗

@@ -1,14 +1,10 @@
-use std::rc::Rc;
-
+use crate::{prelude::*, Color, Icon, IconButton, IconSize};
 use gpui::ClickEvent;
 
-use crate::prelude::*;
-use crate::{Color, Icon, IconButton, IconSize};
-
 #[derive(IntoElement)]
 pub struct Disclosure {
     is_open: bool,
-    on_toggle: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
+    on_toggle: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
 }
 
 impl Disclosure {
@@ -21,7 +17,7 @@ impl Disclosure {
 
     pub fn on_toggle(
         mut self,
-        handler: impl Into<Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>>,
+        handler: impl Into<Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>>,
     ) -> Self {
         self.on_toggle = handler.into();
         self

crates/ui2/src/components/list/list_header.rs 🔗

@@ -1,18 +1,14 @@
-use std::rc::Rc;
-
+use crate::{h_stack, prelude::*, Disclosure, Icon, IconElement, IconSize, Label};
 use gpui::{AnyElement, ClickEvent, Div};
 use smallvec::SmallVec;
 
-use crate::prelude::*;
-use crate::{h_stack, Disclosure, Icon, IconElement, IconSize, Label};
-
 #[derive(IntoElement)]
 pub struct ListHeader {
     label: SharedString,
     left_icon: Option<Icon>,
     meta: SmallVec<[AnyElement; 2]>,
     toggle: Option<bool>,
-    on_toggle: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
+    on_toggle: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
     inset: bool,
     selected: bool,
 }
@@ -39,7 +35,7 @@ impl ListHeader {
         mut self,
         on_toggle: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
     ) -> Self {
-        self.on_toggle = Some(Rc::new(on_toggle));
+        self.on_toggle = Some(Box::new(on_toggle));
         self
     }
 

crates/ui2/src/components/list/list_item.rs 🔗

@@ -1,14 +1,10 @@
-use std::rc::Rc;
-
+use crate::{prelude::*, Avatar, Disclosure, Icon, IconElement, IconSize};
 use gpui::{
     px, AnyElement, AnyView, ClickEvent, Div, ImageSource, MouseButton, MouseDownEvent, Pixels,
     Stateful,
 };
 use smallvec::SmallVec;
 
-use crate::prelude::*;
-use crate::{Avatar, Disclosure, Icon, IconElement, IconSize};
-
 #[derive(IntoElement)]
 pub struct ListItem {
     id: ElementId,
@@ -20,10 +16,10 @@ pub struct ListItem {
     left_slot: Option<AnyElement>,
     toggle: Option<bool>,
     inset: bool,
-    on_click: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
-    on_toggle: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
+    on_click: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
+    on_toggle: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
     tooltip: Option<Box<dyn Fn(&mut WindowContext) -> AnyView + 'static>>,
-    on_secondary_mouse_down: Option<Rc<dyn Fn(&MouseDownEvent, &mut WindowContext) + 'static>>,
+    on_secondary_mouse_down: Option<Box<dyn Fn(&MouseDownEvent, &mut WindowContext) + 'static>>,
     children: SmallVec<[AnyElement; 2]>,
 }
 
@@ -46,7 +42,7 @@ impl ListItem {
     }
 
     pub fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self {
-        self.on_click = Some(Rc::new(handler));
+        self.on_click = Some(Box::new(handler));
         self
     }
 
@@ -54,7 +50,15 @@ impl ListItem {
         mut self,
         handler: impl Fn(&MouseDownEvent, &mut WindowContext) + 'static,
     ) -> Self {
-        self.on_secondary_mouse_down = Some(Rc::new(handler));
+        self.on_secondary_mouse_down = Some(Box::new(handler));
+        self
+    }
+
+    pub fn on_drag(
+        mut self,
+        handler: impl Fn(&MouseDownEvent, &mut WindowContext) + 'static,
+    ) -> Self {
+        self.on_secondary_mouse_down = Some(Box::new(handler));
         self
     }
 
@@ -87,7 +91,7 @@ impl ListItem {
         mut self,
         on_toggle: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
     ) -> Self {
-        self.on_toggle = Some(Rc::new(on_toggle));
+        self.on_toggle = Some(Box::new(on_toggle));
         self
     }