Start dragging project panel entries

Julia and Kay Simmons created

Co-Authored-By: Kay Simmons <kay@zed.dev>

Change summary

Cargo.lock                                |   1 
crates/drag_and_drop/src/drag_and_drop.rs |  23 ++
crates/project_panel/Cargo.toml           |   1 
crates/project_panel/src/project_panel.rs | 168 +++++++++++++++---------
crates/theme/src/theme.rs                 |   1 
styles/src/styleTree/projectPanel.ts      |  17 ++
styles/src/styleTree/tabBar.ts            |   2 
7 files changed, 142 insertions(+), 71 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4265,6 +4265,7 @@ name = "project_panel"
 version = "0.1.0"
 dependencies = [
  "context_menu",
+ "drag_and_drop",
  "editor",
  "futures 0.3.24",
  "gpui",

crates/drag_and_drop/src/drag_and_drop.rs 🔗

@@ -3,7 +3,7 @@ use std::{any::Any, rc::Rc};
 use collections::HashSet;
 use gpui::{
     elements::{MouseEventHandler, Overlay},
-    geometry::vector::Vector2F,
+    geometry::{rect::RectF, vector::Vector2F},
     scene::MouseDrag,
     CursorStyle, Element, ElementBox, EventContext, MouseButton, MutableAppContext, RenderContext,
     View, WeakViewHandle,
@@ -13,6 +13,7 @@ struct State<V: View> {
     window_id: usize,
     position: Vector2F,
     region_offset: Vector2F,
+    region: RectF,
     payload: Rc<dyn Any + 'static>,
     render: Rc<dyn Fn(Rc<dyn Any>, &mut RenderContext<V>) -> ElementBox>,
 }
@@ -23,6 +24,7 @@ impl<V: View> Clone for State<V> {
             window_id: self.window_id.clone(),
             position: self.position.clone(),
             region_offset: self.region_offset.clone(),
+            region: self.region.clone(),
             payload: self.payload.clone(),
             render: self.render.clone(),
         }
@@ -77,15 +79,20 @@ impl<V: View> DragAndDrop<V> {
     ) {
         let window_id = cx.window_id();
         cx.update_global::<Self, _, _>(|this, cx| {
-            let region_offset = if let Some(previous_state) = this.currently_dragged.as_ref() {
-                previous_state.region_offset
-            } else {
-                event.region.origin() - event.prev_mouse_position
-            };
+            let (region_offset, region) =
+                if let Some(previous_state) = this.currently_dragged.as_ref() {
+                    (previous_state.region_offset, previous_state.region)
+                } else {
+                    (
+                        event.region.origin() - event.prev_mouse_position,
+                        event.region,
+                    )
+                };
 
             this.currently_dragged = Some(State {
                 window_id,
                 region_offset,
+                region,
                 position: event.position,
                 payload,
                 render: Rc::new(move |payload, cx| {
@@ -105,6 +112,7 @@ impl<V: View> DragAndDrop<V> {
                  window_id,
                  region_offset,
                  position,
+                 region,
                  payload,
                  render,
              }| {
@@ -134,6 +142,9 @@ impl<V: View> DragAndDrop<V> {
                         })
                         // Don't block hover events or invalidations
                         .with_hoverable(false)
+                        .constrained()
+                        .with_width(region.width())
+                        .with_height(region.height())
                         .boxed(),
                     )
                     .with_anchor_position(position)

crates/project_panel/Cargo.toml 🔗

@@ -9,6 +9,7 @@ doctest = false
 
 [dependencies]
 context_menu = { path = "../context_menu" }
+drag_and_drop = { path = "../drag_and_drop" }
 editor = { path = "../editor" }
 gpui = { path = "../gpui" }
 menu = { path = "../menu" }

crates/project_panel/src/project_panel.rs 🔗

@@ -1,12 +1,13 @@
 use context_menu::{ContextMenu, ContextMenuItem};
+use drag_and_drop::Draggable;
 use editor::{Cancel, Editor};
 use futures::stream::StreamExt;
 use gpui::{
     actions,
     anyhow::{anyhow, Result},
     elements::{
-        AnchorCorner, ChildView, ConstrainedBox, Empty, Flex, Label, MouseEventHandler,
-        ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
+        AnchorCorner, ChildView, ConstrainedBox, ContainerStyle, Empty, Flex, Label,
+        MouseEventHandler, ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
     },
     geometry::vector::Vector2F,
     impl_internal_actions, keymap,
@@ -25,6 +26,7 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
+use theme::ProjectPanelEntry;
 use unicase::UniCase;
 use workspace::Workspace;
 
@@ -70,9 +72,10 @@ pub enum ClipboardEntry {
     },
 }
 
-#[derive(Debug, PartialEq, Eq)]
+#[derive(Debug, PartialEq, Eq, Clone)]
 struct EntryDetails {
     filename: String,
+    path: Arc<Path>,
     depth: usize,
     kind: EntryKind,
     is_ignored: bool,
@@ -220,6 +223,7 @@ impl ProjectPanel {
             this.update_visible_entries(None, cx);
             this
         });
+
         cx.subscribe(&project_panel, {
             let project_panel = project_panel.downgrade();
             move |workspace, _, event, cx| match event {
@@ -950,14 +954,15 @@ impl ProjectPanel {
             let end_ix = range.end.min(ix + visible_worktree_entries.len());
             if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
                 let snapshot = worktree.read(cx).snapshot();
+                let root_name = OsStr::new(snapshot.root_name());
                 let expanded_entry_ids = self
                     .expanded_dir_ids
                     .get(&snapshot.id())
                     .map(Vec::as_slice)
                     .unwrap_or(&[]);
-                let root_name = OsStr::new(snapshot.root_name());
-                for entry in &visible_worktree_entries[range.start.saturating_sub(ix)..end_ix - ix]
-                {
+
+                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
+                for entry in &visible_worktree_entries[entry_range] {
                     let mut details = EntryDetails {
                         filename: entry
                             .path
@@ -965,6 +970,7 @@ impl ProjectPanel {
                             .unwrap_or(root_name)
                             .to_string_lossy()
                             .to_string(),
+                        path: entry.path.clone(),
                         depth: entry.path.components().count(),
                         kind: entry.kind,
                         is_ignored: entry.is_ignored,
@@ -978,12 +984,14 @@ impl ProjectPanel {
                             .clipboard_entry
                             .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
                     };
+
                     if let Some(edit_state) = &self.edit_state {
                         let is_edited_entry = if edit_state.is_new_entry {
                             entry.id == NEW_ENTRY_ID
                         } else {
                             entry.id == edit_state.entry_id
                         };
+
                         if is_edited_entry {
                             if let Some(processing_filename) = &edit_state.processing_filename {
                                 details.is_processing = true;
@@ -1005,6 +1013,63 @@ impl ProjectPanel {
         }
     }
 
+    fn render_entry_visual_element<V: View>(
+        details: EntryDetails,
+        editor: &ViewHandle<Editor>,
+        padding: f32,
+        row_container_style: ContainerStyle,
+        style: &ProjectPanelEntry,
+        cx: &mut RenderContext<V>,
+    ) -> ElementBox {
+        let kind = details.kind;
+        let show_editor = details.is_editing && !details.is_processing;
+
+        Flex::row()
+            .with_child(
+                ConstrainedBox::new(if kind == EntryKind::Dir {
+                    if details.is_expanded {
+                        Svg::new("icons/chevron_down_8.svg")
+                            .with_color(style.icon_color)
+                            .boxed()
+                    } else {
+                        Svg::new("icons/chevron_right_8.svg")
+                            .with_color(style.icon_color)
+                            .boxed()
+                    }
+                } else {
+                    Empty::new().boxed()
+                })
+                .with_max_width(style.icon_size)
+                .with_max_height(style.icon_size)
+                .aligned()
+                .constrained()
+                .with_width(style.icon_size)
+                .boxed(),
+            )
+            .with_child(if show_editor {
+                ChildView::new(editor.clone(), cx)
+                    .contained()
+                    .with_margin_left(style.icon_spacing)
+                    .aligned()
+                    .left()
+                    .flex(1.0, true)
+                    .boxed()
+            } else {
+                Label::new(details.filename.clone(), style.text.clone())
+                    .contained()
+                    .with_margin_left(style.icon_spacing)
+                    .aligned()
+                    .left()
+                    .boxed()
+            })
+            .constrained()
+            .with_height(style.height)
+            .contained()
+            .with_style(row_container_style)
+            .with_padding_left(padding)
+            .boxed()
+    }
+
     fn render_entry(
         entry_id: ProjectEntryId,
         details: EntryDetails,
@@ -1013,69 +1078,34 @@ impl ProjectPanel {
         cx: &mut RenderContext<Self>,
     ) -> ElementBox {
         let kind = details.kind;
-        let show_editor = details.is_editing && !details.is_processing;
-        MouseEventHandler::<Self>::new(entry_id.to_usize(), cx, |state, cx| {
-            let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
+        let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
 
-            let entry_style = if details.is_cut {
-                &theme.cut_entry
-            } else if details.is_ignored {
-                &theme.ignored_entry
-            } else {
-                &theme.entry
-            };
+        let entry_style = if details.is_cut {
+            &theme.cut_entry
+        } else if details.is_ignored {
+            &theme.ignored_entry
+        } else {
+            &theme.entry
+        };
 
-            let style = entry_style.style_for(state, details.is_selected).clone();
+        let show_editor = details.is_editing && !details.is_processing;
 
+        MouseEventHandler::<Self>::new(entry_id.to_usize(), cx, |state, cx| {
+            let style = entry_style.style_for(state, details.is_selected).clone();
             let row_container_style = if show_editor {
                 theme.filename_editor.container
             } else {
                 style.container
             };
-            Flex::row()
-                .with_child(
-                    ConstrainedBox::new(if kind == EntryKind::Dir {
-                        if details.is_expanded {
-                            Svg::new("icons/chevron_down_8.svg")
-                                .with_color(style.icon_color)
-                                .boxed()
-                        } else {
-                            Svg::new("icons/chevron_right_8.svg")
-                                .with_color(style.icon_color)
-                                .boxed()
-                        }
-                    } else {
-                        Empty::new().boxed()
-                    })
-                    .with_max_width(style.icon_size)
-                    .with_max_height(style.icon_size)
-                    .aligned()
-                    .constrained()
-                    .with_width(style.icon_size)
-                    .boxed(),
-                )
-                .with_child(if show_editor {
-                    ChildView::new(editor.clone(), cx)
-                        .contained()
-                        .with_margin_left(theme.entry.default.icon_spacing)
-                        .aligned()
-                        .left()
-                        .flex(1.0, true)
-                        .boxed()
-                } else {
-                    Label::new(details.filename, style.text.clone())
-                        .contained()
-                        .with_margin_left(style.icon_spacing)
-                        .aligned()
-                        .left()
-                        .boxed()
-                })
-                .constrained()
-                .with_height(theme.entry.default.height)
-                .contained()
-                .with_style(row_container_style)
-                .with_padding_left(padding)
-                .boxed()
+
+            Self::render_entry_visual_element(
+                details.clone(),
+                editor,
+                padding,
+                row_container_style,
+                &style,
+                cx,
+            )
         })
         .on_click(MouseButton::Left, move |e, cx| {
             if kind == EntryKind::Dir {
@@ -1093,6 +1123,22 @@ impl ProjectPanel {
                 position: e.position,
             })
         })
+        .as_draggable(details.clone(), {
+            let editor = editor.clone();
+            let row_container_style = theme.dragged_entry.container;
+
+            move |payload, cx: &mut RenderContext<Workspace>| {
+                let theme = cx.global::<Settings>().theme.clone();
+                Self::render_entry_visual_element(
+                    payload.clone(),
+                    &editor,
+                    padding,
+                    row_container_style,
+                    &theme.project_panel.dragged_entry,
+                    cx,
+                )
+            }
+        })
         .with_cursor_style(CursorStyle::PointingHand)
         .boxed()
     }

crates/theme/src/theme.rs 🔗

@@ -326,6 +326,7 @@ pub struct ProjectPanel {
     #[serde(flatten)]
     pub container: ContainerStyle,
     pub entry: Interactive<ProjectPanelEntry>,
+    pub dragged_entry: ProjectPanelEntry,
     pub ignored_entry: Interactive<ProjectPanelEntry>,
     pub cut_entry: Interactive<ProjectPanelEntry>,
     pub filename_editor: FieldEditor,

styles/src/styleTree/projectPanel.ts 🔗

@@ -1,14 +1,19 @@
 import { ColorScheme } from "../themes/common/colorScheme";
-import { background, foreground, text } from "./components";
+import { withOpacity } from "../utils/color";
+import { background, border, foreground, text } from "./components";
 
 export default function projectPanel(colorScheme: ColorScheme) {
   let layer = colorScheme.middle;
-
-  let entry = {
+  
+  let baseEntry = {
     height: 24,
     iconColor: foreground(layer, "variant"),
     iconSize: 8,
     iconSpacing: 8,
+  }
+
+  let entry = {
+    ...baseEntry,
     text: text(layer, "mono", "variant", { size: "sm" }),
     hover: {
       background: background(layer, "variant", "hovered"),
@@ -28,6 +33,12 @@ export default function projectPanel(colorScheme: ColorScheme) {
     padding: { left: 12, right: 12, top: 6, bottom: 6 },
     indentWidth: 8,
     entry,
+    draggedEntry: {
+      ...baseEntry,
+      text: text(layer, "mono", "on", { size: "sm" }),
+      background: withOpacity(background(layer, "on"), 0.9),
+      border: border(layer),
+    },
     ignoredEntry: {
       ...entry,
       text: text(layer, "mono", "disabled"),

styles/src/styleTree/tabBar.ts 🔗

@@ -67,7 +67,7 @@ export default function tabBar(colorScheme: ColorScheme) {
 
   const draggedTab = {
     ...activePaneActiveTab,
-    background: withOpacity(tab.background, 0.95),
+    background: withOpacity(tab.background, 0.9),
     border: undefined as any,
     shadow: colorScheme.popoverShadow,
   };