From 116c6549f67335bf7e9469fd22ad2899f07cd91f Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 11 Sep 2025 10:55:10 -0700 Subject: [PATCH] project_panel: Make rest of the project panel drag and drop target (#38008) Closes #25854 You can now drag-and-drop on the remaining space in the project panel to drop entries/external paths in the last worktree. https://github.com/user-attachments/assets/a7e14518-6065-4b0f-ba2c-823c70f154f4 Release Notes: - Added support for drag-and-drop files and external paths into the empty space of the project panel, placing them in the last folder you have added to the project. --- crates/project_panel/src/project_panel.rs | 559 ++++++++++++------ .../project_panel/src/project_panel_tests.rs | 235 ++++++++ 2 files changed, 610 insertions(+), 184 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 06f3fe15b7bffe73fae8e89f439af92ae1897662..f82901595b82a07684185e161e6198b63c4e46ca 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -95,7 +95,7 @@ pub struct ProjectPanel { ancestors: HashMap, folded_directory_drag_target: Option, last_worktree_root_id: Option, - drag_target_entry: Option, + drag_target_entry: Option, expanded_dir_ids: HashMap>, unfolded_dir_ids: HashSet, // Currently selected leaf entry (see auto-folding for a definition of that) in a file tree @@ -125,11 +125,16 @@ pub struct ProjectPanel { last_reported_update: Instant, } -struct DragTargetEntry { - /// The entry currently under the mouse cursor during a drag operation - entry_id: ProjectEntryId, - /// Highlight this entry along with all of its children - highlight_entry_id: Option, +enum DragTarget { + /// Dragging on an entry + Entry { + /// The entry currently under the mouse cursor during a drag operation + entry_id: ProjectEntryId, + /// Highlight this entry along with all of its children + highlight_entry_id: ProjectEntryId, + }, + /// Dragging on background + Background, } #[derive(Copy, Clone, Debug)] @@ -3966,7 +3971,9 @@ impl ProjectPanel { // In case of single item drag, we do not highlight existing // directory which item belongs too - if drag_state.items().count() == 1 { + if drag_state.items().count() == 1 + && drag_state.active_selection.worktree_id == target_worktree.id() + { let active_entry_path = self .project .read(cx) @@ -3995,6 +4002,44 @@ impl ProjectPanel { } } + fn should_highlight_background_for_selection_drag( + &self, + drag_state: &DraggedSelection, + last_root_id: ProjectEntryId, + cx: &App, + ) -> bool { + // Always highlight for multiple entries + if drag_state.items().count() > 1 { + return true; + } + + // Since root will always have empty relative path + if let Some(entry_path) = self + .project + .read(cx) + .path_for_entry(drag_state.active_selection.entry_id, cx) + { + if let Some(parent_path) = entry_path.path.parent() { + if !parent_path.as_os_str().is_empty() { + return true; + } + } + } + + // If parent is empty, check if different worktree + if let Some(last_root_worktree_id) = self + .project + .read(cx) + .worktree_id_for_entry(last_root_id, cx) + { + if drag_state.active_selection.worktree_id != last_root_worktree_id { + return true; + } + } + + false + } + fn render_entry( &self, entry_id: ProjectEntryId, @@ -4097,10 +4142,15 @@ impl ProjectPanel { let folded_directory_drag_target = self.folded_directory_drag_target; let is_highlighted = { - if let Some(highlight_entry_id) = self - .drag_target_entry - .as_ref() - .and_then(|drag_target| drag_target.highlight_entry_id) + if let Some(highlight_entry_id) = + self.drag_target_entry + .as_ref() + .and_then(|drag_target| match drag_target { + DragTarget::Entry { + highlight_entry_id, .. + } => Some(*highlight_entry_id), + DragTarget::Background => self.last_worktree_root_id, + }) { // Highlight if same entry or it's children if entry_id == highlight_entry_id { @@ -4145,7 +4195,10 @@ impl ProjectPanel { .on_drag_move::(cx.listener( move |this, event: &DragMoveEvent, _, cx| { let is_current_target = this.drag_target_entry.as_ref() - .map(|entry| entry.entry_id) == Some(entry_id); + .and_then(|entry| match entry { + DragTarget::Entry { entry_id: target_id, .. } => Some(*target_id), + DragTarget::Background { .. } => None, + }) == Some(entry_id); if !event.bounds.contains(&event.event.position) { // Entry responsible for setting drag target is also responsible to @@ -4160,20 +4213,22 @@ impl ProjectPanel { return; } + this.marked_entries.clear(); + let Some((entry_id, highlight_entry_id)) = maybe!({ let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx); let target_entry = target_worktree.entry_for_path(&path_for_external_paths)?; - let highlight_entry_id = this.highlight_entry_for_external_drag(target_entry, target_worktree); + let highlight_entry_id = this.highlight_entry_for_external_drag(target_entry, target_worktree)?; Some((target_entry.id, highlight_entry_id)) }) else { return; }; - this.drag_target_entry = Some(DragTargetEntry { + this.drag_target_entry = Some(DragTarget::Entry { entry_id, highlight_entry_id, }); - this.marked_entries.clear(); + }, )) .on_drop(cx.listener( @@ -4187,7 +4242,10 @@ impl ProjectPanel { .on_drag_move::(cx.listener( move |this, event: &DragMoveEvent, window, cx| { let is_current_target = this.drag_target_entry.as_ref() - .map(|entry| entry.entry_id) == Some(entry_id); + .and_then(|entry| match entry { + DragTarget::Entry { entry_id: target_id, .. } => Some(*target_id), + DragTarget::Background { .. } => None, + }) == Some(entry_id); if !event.bounds.contains(&event.event.position) { // Entry responsible for setting drag target is also responsible to @@ -4203,23 +4261,26 @@ impl ProjectPanel { } let drag_state = event.drag(cx); + + if drag_state.items().count() == 1 { + this.marked_entries.clear(); + this.marked_entries.push(drag_state.active_selection); + } + let Some((entry_id, highlight_entry_id)) = maybe!({ let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx); let target_entry = target_worktree.entry_for_path(&path_for_dragged_selection)?; - let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, drag_state, cx); + let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, drag_state, cx)?; Some((target_entry.id, highlight_entry_id)) }) else { return; }; - this.drag_target_entry = Some(DragTargetEntry { + this.drag_target_entry = Some(DragTarget::Entry { entry_id, highlight_entry_id, }); - if drag_state.items().count() == 1 { - this.marked_entries.clear(); - this.marked_entries.push(drag_state.active_selection); - } + this.hover_expand_task.take(); if !kind.is_dir() @@ -4239,7 +4300,10 @@ impl ProjectPanel { .await; this.update_in(cx, |this, window, cx| { this.hover_expand_task.take(); - if this.drag_target_entry.as_ref().map(|entry| entry.entry_id) == Some(entry_id) + if this.drag_target_entry.as_ref().and_then(|entry| match entry { + DragTarget::Entry { entry_id: target_id, .. } => Some(*target_id), + DragTarget::Background { .. } => None, + }) == Some(entry_id) && bounds.contains(&window.mouse_position()) { this.expand_entry(worktree_id, entry_id, cx); @@ -4270,7 +4334,7 @@ impl ProjectPanel { this.drag_target_entry = None; this.hover_scroll_task.take(); this.hover_expand_task.take(); - if folded_directory_drag_target.is_some() { + if folded_directory_drag_target.is_some() { return; } this.drag_onto(selections, entry_id, kind.is_file(), window, cx); @@ -5370,178 +5434,305 @@ impl Render for ProjectPanel { ) .track_focus(&self.focus_handle(cx)) .child( - uniform_list("entries", item_count, { - cx.processor(|this, range: Range, window, cx| { - let mut items = Vec::with_capacity(range.end - range.start); - this.for_each_visible_entry( - range, - window, - cx, - |id, details, window, cx| { - items.push(this.render_entry(id, details, window, cx)); - }, - ); - items - }) - }) - .when(show_indent_guides, |list| { - list.with_decoration( - ui::indent_guides(px(indent_size), IndentGuideColors::panel(cx)) - .with_compute_indents_fn(cx.entity(), |this, range, window, cx| { - let mut items = - SmallVec::with_capacity(range.end - range.start); - this.iter_visible_entries( + v_flex() + .child( + uniform_list("entries", item_count, { + cx.processor(|this, range: Range, window, cx| { + let mut items = Vec::with_capacity(range.end - range.start); + this.for_each_visible_entry( range, window, cx, - |entry, _, entries, _, _| { - let (depth, _) = Self::calculate_depth_and_difference( - entry, entries, - ); - items.push(depth); + |id, details, window, cx| { + items.push(this.render_entry(id, details, window, cx)); }, ); items }) - .on_click(cx.listener( - |this, active_indent_guide: &IndentGuideLayout, window, cx| { - if window.modifiers().secondary() { - let ix = active_indent_guide.offset.y; - let Some((target_entry, worktree)) = maybe!({ - let (worktree_id, entry) = - this.entry_at_index(ix)?; - let worktree = this - .project - .read(cx) - .worktree_for_id(worktree_id, cx)?; - let target_entry = worktree - .read(cx) - .entry_for_path(&entry.path.parent()?)?; - Some((target_entry, worktree)) - }) else { - return; - }; - - this.collapse_entry(target_entry.clone(), worktree, cx); - } - }, - )) - .with_render_fn(cx.entity(), move |this, params, _, cx| { - const LEFT_OFFSET: Pixels = px(14.); - const PADDING_Y: Pixels = px(4.); - const HITBOX_OVERDRAW: Pixels = px(3.); - - let active_indent_guide_index = - this.find_active_indent_guide(¶ms.indent_guides, cx); - - let indent_size = params.indent_size; - let item_height = params.item_height; - - params - .indent_guides - .into_iter() - .enumerate() - .map(|(idx, layout)| { - let offset = if layout.continues_offscreen { - px(0.) - } else { - PADDING_Y - }; - let bounds = Bounds::new( - point( - layout.offset.x * indent_size + LEFT_OFFSET, - layout.offset.y * item_height + offset, - ), - size( - px(1.), - layout.length * item_height - offset * 2., - ), + }) + .when(show_indent_guides, |list| { + list.with_decoration( + ui::indent_guides( + px(indent_size), + IndentGuideColors::panel(cx), + ) + .with_compute_indents_fn( + cx.entity(), + |this, range, window, cx| { + let mut items = + SmallVec::with_capacity(range.end - range.start); + this.iter_visible_entries( + range, + window, + cx, + |entry, _, entries, _, _| { + let (depth, _) = + Self::calculate_depth_and_difference( + entry, entries, + ); + items.push(depth); + }, ); - ui::RenderedIndentGuide { - bounds, - layout, - is_active: Some(idx) == active_indent_guide_index, - hitbox: Some(Bounds::new( - point( - bounds.origin.x - HITBOX_OVERDRAW, - bounds.origin.y, - ), - size( - bounds.size.width + HITBOX_OVERDRAW * 2., - bounds.size.height, - ), - )), + items + }, + ) + .on_click(cx.listener( + |this, + active_indent_guide: &IndentGuideLayout, + window, + cx| { + if window.modifiers().secondary() { + let ix = active_indent_guide.offset.y; + let Some((target_entry, worktree)) = maybe!({ + let (worktree_id, entry) = + this.entry_at_index(ix)?; + let worktree = this + .project + .read(cx) + .worktree_for_id(worktree_id, cx)?; + let target_entry = worktree + .read(cx) + .entry_for_path(&entry.path.parent()?)?; + Some((target_entry, worktree)) + }) else { + return; + }; + + this.collapse_entry( + target_entry.clone(), + worktree, + cx, + ); } - }) - .collect() - }), - ) - }) - .when(show_sticky_entries, |list| { - let sticky_items = ui::sticky_items( - cx.entity(), - |this, range, window, cx| { - let mut items = SmallVec::with_capacity(range.end - range.start); - this.iter_visible_entries( - range, - window, - cx, - |entry, index, entries, _, _| { - let (depth, _) = - Self::calculate_depth_and_difference(entry, entries); - let candidate = - StickyProjectPanelCandidate { index, depth }; - items.push(candidate); + }, + )) + .with_render_fn( + cx.entity(), + move |this, params, _, cx| { + const LEFT_OFFSET: Pixels = px(14.); + const PADDING_Y: Pixels = px(4.); + const HITBOX_OVERDRAW: Pixels = px(3.); + + let active_indent_guide_index = this + .find_active_indent_guide( + ¶ms.indent_guides, + cx, + ); + + let indent_size = params.indent_size; + let item_height = params.item_height; + + params + .indent_guides + .into_iter() + .enumerate() + .map(|(idx, layout)| { + let offset = if layout.continues_offscreen { + px(0.) + } else { + PADDING_Y + }; + let bounds = Bounds::new( + point( + layout.offset.x * indent_size + + LEFT_OFFSET, + layout.offset.y * item_height + offset, + ), + size( + px(1.), + layout.length * item_height + - offset * 2., + ), + ); + ui::RenderedIndentGuide { + bounds, + layout, + is_active: Some(idx) + == active_indent_guide_index, + hitbox: Some(Bounds::new( + point( + bounds.origin.x - HITBOX_OVERDRAW, + bounds.origin.y, + ), + size( + bounds.size.width + + HITBOX_OVERDRAW * 2., + bounds.size.height, + ), + )), + } + }) + .collect() + }, + ), + ) + }) + .when(show_sticky_entries, |list| { + let sticky_items = ui::sticky_items( + cx.entity(), + |this, range, window, cx| { + let mut items = + SmallVec::with_capacity(range.end - range.start); + this.iter_visible_entries( + range, + window, + cx, + |entry, index, entries, _, _| { + let (depth, _) = + Self::calculate_depth_and_difference( + entry, entries, + ); + let candidate = + StickyProjectPanelCandidate { index, depth }; + items.push(candidate); + }, + ); + items + }, + |this, marker_entry, window, cx| { + let sticky_entries = + this.render_sticky_entries(marker_entry, window, cx); + this.sticky_items_count = sticky_entries.len(); + sticky_entries }, ); - items - }, - |this, marker_entry, window, cx| { - let sticky_entries = - this.render_sticky_entries(marker_entry, window, cx); - this.sticky_items_count = sticky_entries.len(); - sticky_entries - }, - ); - list.with_decoration(if show_indent_guides { - sticky_items.with_decoration( - ui::indent_guides(px(indent_size), IndentGuideColors::panel(cx)) - .with_render_fn(cx.entity(), move |_, params, _, _| { - const LEFT_OFFSET: Pixels = px(14.); - - let indent_size = params.indent_size; - let item_height = params.item_height; - - params - .indent_guides - .into_iter() - .map(|layout| { - let bounds = Bounds::new( - point( - layout.offset.x * indent_size + LEFT_OFFSET, - layout.offset.y * item_height, - ), - size(px(1.), layout.length * item_height), - ); - ui::RenderedIndentGuide { - bounds, - layout, - is_active: false, - hitbox: None, - } - }) - .collect() - }), + list.with_decoration(if show_indent_guides { + sticky_items.with_decoration( + ui::indent_guides( + px(indent_size), + IndentGuideColors::panel(cx), + ) + .with_render_fn( + cx.entity(), + move |_, params, _, _| { + const LEFT_OFFSET: Pixels = px(14.); + + let indent_size = params.indent_size; + let item_height = params.item_height; + + params + .indent_guides + .into_iter() + .map(|layout| { + let bounds = Bounds::new( + point( + layout.offset.x * indent_size + + LEFT_OFFSET, + layout.offset.y * item_height, + ), + size( + px(1.), + layout.length * item_height, + ), + ); + ui::RenderedIndentGuide { + bounds, + layout, + is_active: false, + hitbox: None, + } + }) + .collect() + }, + ), + ) + } else { + sticky_items + }) + }) + .with_sizing_behavior(ListSizingBehavior::Infer) + .with_horizontal_sizing_behavior( + ListHorizontalSizingBehavior::Unconstrained, ) - } else { - sticky_items - }) - }) - .size_full() - .with_sizing_behavior(ListSizingBehavior::Infer) - .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained) - .with_width_from_item(self.max_width_item_index) - .track_scroll(self.scroll_handle.clone()), + .with_width_from_item(self.max_width_item_index) + .track_scroll(self.scroll_handle.clone()), + ) + .child( + div() + .block_mouse_except_scroll() + .flex_grow() + .when( + self.drag_target_entry.as_ref().is_some_and( + |entry| match entry { + DragTarget::Background => true, + DragTarget::Entry { + highlight_entry_id, .. + } => { + self.last_worktree_root_id.is_some_and(|root_id| { + *highlight_entry_id == root_id + }) + } + }, + ), + |div| div.bg(cx.theme().colors().drop_target_background), + ) + .on_drag_move::(cx.listener( + move |this, event: &DragMoveEvent, _, _| { + let Some(_last_root_id) = this.last_worktree_root_id else { + return; + }; + if event.bounds.contains(&event.event.position) { + this.drag_target_entry = Some(DragTarget::Background); + } else { + if this.drag_target_entry.as_ref().is_some_and(|e| { + matches!(e, DragTarget::Background) + }) { + this.drag_target_entry = None; + } + } + }, + )) + .on_drag_move::(cx.listener( + move |this, event: &DragMoveEvent, _, cx| { + let Some(last_root_id) = this.last_worktree_root_id else { + return; + }; + if event.bounds.contains(&event.event.position) { + let drag_state = event.drag(cx); + if this.should_highlight_background_for_selection_drag( + &drag_state, + last_root_id, + cx, + ) { + this.drag_target_entry = + Some(DragTarget::Background); + } + } else { + if this.drag_target_entry.as_ref().is_some_and(|e| { + matches!(e, DragTarget::Background) + }) { + this.drag_target_entry = None; + } + } + }, + )) + .on_drop(cx.listener( + move |this, external_paths: &ExternalPaths, window, cx| { + this.drag_target_entry = None; + this.hover_scroll_task.take(); + if let Some(entry_id) = this.last_worktree_root_id { + this.drop_external_files( + external_paths.paths(), + entry_id, + window, + cx, + ); + } + cx.stop_propagation(); + }, + )) + .on_drop(cx.listener( + move |this, selections: &DraggedSelection, window, cx| { + this.drag_target_entry = None; + this.hover_scroll_task.take(); + if let Some(entry_id) = this.last_worktree_root_id { + this.drag_onto(selections, entry_id, false, window, cx); + } + cx.stop_propagation(); + }, + )), + ) + .size_full(), ) .children(self.render_vertical_scrollbar(cx)) .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| { diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 49b482e02c5c6d88e4f3d832d254ef5db121c2f9..f1132226cbded31f4179bd1cf3a492559e3cded9 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -5643,6 +5643,241 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) }); } +#[gpui::test] +async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root1", + json!({ + "src": { + "main.rs": "", + "lib.rs": "" + } + }), + ) + .await; + fs.insert_tree( + "/root2", + json!({ + "src": { + "main.rs": "", + "test.rs": "" + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + panel.update(cx, |panel, cx| { + let project = panel.project.read(cx); + let worktrees: Vec<_> = project.visible_worktrees(cx).collect(); + + let worktree_a = &worktrees[0]; + let main_rs_from_a = worktree_a.read(cx).entry_for_path("src/main.rs").unwrap(); + + let worktree_b = &worktrees[1]; + let src_dir_from_b = worktree_b.read(cx).entry_for_path("src").unwrap(); + let main_rs_from_b = worktree_b.read(cx).entry_for_path("src/main.rs").unwrap(); + + // Test dragging file from worktree A onto parent of file with same relative path in worktree B + let dragged_selection = DraggedSelection { + active_selection: SelectedEntry { + worktree_id: worktree_a.read(cx).id(), + entry_id: main_rs_from_a.id, + }, + marked_selections: Arc::new([SelectedEntry { + worktree_id: worktree_a.read(cx).id(), + entry_id: main_rs_from_a.id, + }]), + }; + + let result = panel.highlight_entry_for_selection_drag( + src_dir_from_b, + worktree_b.read(cx), + &dragged_selection, + cx, + ); + assert_eq!( + result, + Some(src_dir_from_b.id), + "Should highlight target directory from different worktree even with same relative path" + ); + + // Test dragging file from worktree A onto file with same relative path in worktree B + let result = panel.highlight_entry_for_selection_drag( + main_rs_from_b, + worktree_b.read(cx), + &dragged_selection, + cx, + ); + assert_eq!( + result, + Some(src_dir_from_b.id), + "Should highlight parent of target file from different worktree" + ); + }); +} + +#[gpui::test] +async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root1", + json!({ + "parent_dir": { + "child_file.txt": "", + "nested_dir": { + "nested_file.txt": "" + } + }, + "root_file.txt": "" + }), + ) + .await; + + fs.insert_tree( + "/root2", + json!({ + "other_dir": { + "other_file.txt": "" + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + panel.update(cx, |panel, cx| { + let project = panel.project.read(cx); + let worktrees: Vec<_> = project.visible_worktrees(cx).collect(); + let worktree1 = worktrees[0].read(cx); + let worktree2 = worktrees[1].read(cx); + let worktree1_id = worktree1.id(); + let _worktree2_id = worktree2.id(); + + let root1_entry = worktree1.root_entry().unwrap(); + let root2_entry = worktree2.root_entry().unwrap(); + let _parent_dir = worktree1.entry_for_path("parent_dir").unwrap(); + let child_file = worktree1 + .entry_for_path("parent_dir/child_file.txt") + .unwrap(); + let nested_file = worktree1 + .entry_for_path("parent_dir/nested_dir/nested_file.txt") + .unwrap(); + let root_file = worktree1.entry_for_path("root_file.txt").unwrap(); + + // Test 1: Multiple entries - should always highlight background + let multiple_dragged_selection = DraggedSelection { + active_selection: SelectedEntry { + worktree_id: worktree1_id, + entry_id: child_file.id, + }, + marked_selections: Arc::new([ + SelectedEntry { + worktree_id: worktree1_id, + entry_id: child_file.id, + }, + SelectedEntry { + worktree_id: worktree1_id, + entry_id: nested_file.id, + }, + ]), + }; + + let result = panel.should_highlight_background_for_selection_drag( + &multiple_dragged_selection, + root1_entry.id, + cx, + ); + assert!(result, "Should highlight background for multiple entries"); + + // Test 2: Single entry with non-empty parent path - should highlight background + let nested_dragged_selection = DraggedSelection { + active_selection: SelectedEntry { + worktree_id: worktree1_id, + entry_id: nested_file.id, + }, + marked_selections: Arc::new([SelectedEntry { + worktree_id: worktree1_id, + entry_id: nested_file.id, + }]), + }; + + let result = panel.should_highlight_background_for_selection_drag( + &nested_dragged_selection, + root1_entry.id, + cx, + ); + assert!(result, "Should highlight background for nested file"); + + // Test 3: Single entry at root level, same worktree - should NOT highlight background + let root_file_dragged_selection = DraggedSelection { + active_selection: SelectedEntry { + worktree_id: worktree1_id, + entry_id: root_file.id, + }, + marked_selections: Arc::new([SelectedEntry { + worktree_id: worktree1_id, + entry_id: root_file.id, + }]), + }; + + let result = panel.should_highlight_background_for_selection_drag( + &root_file_dragged_selection, + root1_entry.id, + cx, + ); + assert!( + !result, + "Should NOT highlight background for root file in same worktree" + ); + + // Test 4: Single entry at root level, different worktree - should highlight background + let result = panel.should_highlight_background_for_selection_drag( + &root_file_dragged_selection, + root2_entry.id, + cx, + ); + assert!( + result, + "Should highlight background for root file from different worktree" + ); + + // Test 5: Single entry in subdirectory - should highlight background + let child_file_dragged_selection = DraggedSelection { + active_selection: SelectedEntry { + worktree_id: worktree1_id, + entry_id: child_file.id, + }, + marked_selections: Arc::new([SelectedEntry { + worktree_id: worktree1_id, + entry_id: child_file.id, + }]), + }; + + let result = panel.should_highlight_background_for_selection_drag( + &child_file_dragged_selection, + root1_entry.id, + cx, + ); + assert!( + result, + "Should highlight background for file with non-empty parent path" + ); + }); +} + #[gpui::test] async fn test_hide_root(cx: &mut gpui::TestAppContext) { init_test(cx);