diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 98881e09baaa6ac6663386fab97c251008c5eb45..90a87a4480ac97d6495acb20d9ad13f21ed369e4 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -22,7 +22,7 @@ use gpui::{ Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy, Stateful, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions, - anchored, deferred, div, impl_actions, point, px, size, uniform_list, + anchored, deferred, div, impl_actions, point, px, size, transparent_white, uniform_list, }; use indexmap::IndexMap; use language::DiagnosticSeverity; @@ -85,8 +85,7 @@ pub struct ProjectPanel { ancestors: HashMap, folded_directory_drag_target: Option, last_worktree_root_id: Option, - last_selection_drag_over_entry: Option, - last_external_paths_drag_over_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 @@ -112,6 +111,13 @@ pub struct ProjectPanel { hover_expand_task: Option>, } +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, +} + #[derive(Copy, Clone, Debug)] struct FoldedDirectoryDragTarget { entry_id: ProjectEntryId, @@ -472,9 +478,8 @@ impl ProjectPanel { visible_entries: Default::default(), ancestors: Default::default(), folded_directory_drag_target: None, + drag_target_entry: None, 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, @@ -3703,6 +3708,67 @@ impl ProjectPanel { (depth, difference) } + fn highlight_entry_for_external_drag( + &self, + target_entry: &Entry, + target_worktree: &Worktree, + ) -> Option { + // Always highlight directory or parent directory if it's file + if target_entry.is_dir() { + Some(target_entry.id) + } else if let Some(parent_entry) = target_entry + .path + .parent() + .and_then(|parent_path| target_worktree.entry_for_path(parent_path)) + { + Some(parent_entry.id) + } else { + None + } + } + + fn highlight_entry_for_selection_drag( + &self, + target_entry: &Entry, + target_worktree: &Worktree, + dragged_selection: &DraggedSelection, + cx: &Context, + ) -> Option { + let target_parent_path = target_entry.path.parent(); + + // In case of single item drag, we do not highlight existing + // directory which item belongs too + if dragged_selection.items().count() == 1 { + let active_entry_path = self + .project + .read(cx) + .path_for_entry(dragged_selection.active_selection.entry_id, cx)?; + + if let Some(active_parent_path) = active_entry_path.path.parent() { + // Do not highlight active entry parent + if active_parent_path == target_entry.path.as_ref() { + return None; + } + + // Do not highlight active entry sibling files + if Some(active_parent_path) == target_parent_path && target_entry.is_file() { + return None; + } + } + } + + // Always highlight directory or parent directory if it's file + if target_entry.is_dir() { + Some(target_entry.id) + } else if let Some(parent_entry) = + target_parent_path.and_then(|parent_path| target_worktree.entry_for_path(parent_path)) + { + Some(parent_entry.id) + } else { + None + } + } + fn render_entry( &self, entry_id: ProjectEntryId, @@ -3745,6 +3811,8 @@ impl ProjectPanel { .as_ref() .map(|f| f.to_string_lossy().to_string()); let path = details.path.clone(); + let path_for_external_paths = path.clone(); + let path_for_dragged_selection = path.clone(); let depth = details.depth; let worktree_id = details.worktree_id; @@ -3802,6 +3870,27 @@ 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) + { + // Highlight if same entry or it's children + if entry_id == highlight_entry_id { + true + } else { + maybe!({ + let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?; + let highlight_entry = worktree.read(cx).entry_for_id(highlight_entry_id)?; + Some(path.starts_with(&highlight_entry.path)) + }) + .unwrap_or(false) + } + } else { + false + } + }; div() .id(entry_id.to_proto() as usize) @@ -3815,95 +3904,111 @@ impl ProjectPanel { .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color)) .on_drag_move::(cx.listener( move |this, event: &DragMoveEvent, _, cx| { - if event.bounds.contains(&event.event.position) { - if this.last_external_paths_drag_over_entry == Some(entry_id) { - return; + let is_current_target = this.drag_target_entry.as_ref() + .map(|entry| entry.entry_id) == Some(entry_id); + + if !event.bounds.contains(&event.event.position) { + // Entry responsible for setting drag target is also responsible to + // clear it up after drag is out of bounds + if is_current_target { + this.drag_target_entry = None; } - this.last_external_paths_drag_over_entry = Some(entry_id); - this.marked_entries.clear(); - - let Some((worktree, path, entry)) = maybe!({ - let worktree = this - .project - .read(cx) - .worktree_for_id(selection.worktree_id, cx)?; - let worktree = worktree.read(cx); - let entry = worktree.entry_for_path(&path)?; - let path = if entry.is_dir() { - path.as_ref() - } else { - path.parent()? - }; - Some((worktree, path, entry)) - }) else { - return; - }; + return; + } - this.marked_entries.insert(SelectedEntry { - entry_id: entry.id, - worktree_id: worktree.id(), - }); + if is_current_target { + return; + } - for entry in worktree.child_entries(path) { - this.marked_entries.insert(SelectedEntry { - entry_id: entry.id, - worktree_id: worktree.id(), - }); - } + 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); + Some((target_entry.id, highlight_entry_id)) + }) else { + return; + }; - cx.notify(); - } + this.drag_target_entry = Some(DragTargetEntry { + entry_id, + highlight_entry_id, + }); + this.marked_entries.clear(); }, )) .on_drop(cx.listener( move |this, external_paths: &ExternalPaths, window, cx| { + this.drag_target_entry = None; this.hover_scroll_task.take(); - this.last_external_paths_drag_over_entry = None; - this.marked_entries.clear(); this.drop_external_files(external_paths.paths(), entry_id, window, cx); cx.stop_propagation(); }, )) .on_drag_move::(cx.listener( move |this, event: &DragMoveEvent, window, 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 is_current_target = this.drag_target_entry.as_ref() + .map(|entry| entry.entry_id) == Some(entry_id); + + if !event.bounds.contains(&event.event.position) { + // Entry responsible for setting drag target is also responsible to + // clear it up after drag is out of bounds + if is_current_target { + this.drag_target_entry = None; } + return; + } - let bounds = event.bounds; - this.hover_expand_task = - Some(cx.spawn_in(window, async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(500)) - .await; - this.update_in(cx, |this, window, cx| { - this.hover_expand_task.take(); - if this.last_selection_drag_over_entry == Some(entry_id) - && bounds.contains(&window.mouse_position()) - { - this.expand_entry(worktree_id, entry_id, cx); - this.update_visible_entries( - Some((worktree_id, entry_id)), - cx, - ); - cx.notify(); - } - }) - .ok(); - })); + if is_current_target { + return; } + + 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 dragged_selection = event.drag(cx); + let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, dragged_selection, cx); + Some((target_entry.id, highlight_entry_id)) + }) else { + return; + }; + + this.drag_target_entry = Some(DragTargetEntry { + entry_id, + highlight_entry_id, + }); + this.marked_entries.clear(); + 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_in(window, async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(500)) + .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) + && bounds.contains(&window.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( @@ -3917,14 +4022,10 @@ impl ProjectPanel { }) }, ) - .drag_over::(move |style, _, _, _| { - if folded_directory_drag_target.is_some() { - return style; - } - style.bg(item_colors.drag_over) - }) + .when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over)) .on_drop( cx.listener(move |this, selections: &DraggedSelection, window, cx| { + this.drag_target_entry = None; this.hover_scroll_task.take(); this.hover_expand_task.take(); if folded_directory_drag_target.is_some() { @@ -4126,6 +4227,7 @@ impl ProjectPanel { div() .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| { this.hover_scroll_task.take(); + this.drag_target_entry = None; 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); @@ -4208,6 +4310,7 @@ impl ProjectPanel { )) .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| { this.hover_scroll_task.take(); + this.drag_target_entry = None; 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); @@ -4573,13 +4676,14 @@ impl Render for ProjectPanel { .map(|(_, worktree_entries, _)| worktree_entries.len()) .sum(); - fn handle_drag_move_scroll( + fn handle_drag_move( this: &mut ProjectPanel, e: &DragMoveEvent, window: &mut Window, cx: &mut Context, ) { if !e.bounds.contains(&e.event.position) { + this.drag_target_entry = None; return; } this.hover_scroll_task.take(); @@ -4633,8 +4737,8 @@ impl Render for ProjectPanel { h_flex() .id("project-panel") .group("project-panel") - .on_drag_move(cx.listener(handle_drag_move_scroll::)) - .on_drag_move(cx.listener(handle_drag_move_scroll::)) + .on_drag_move(cx.listener(handle_drag_move::)) + .on_drag_move(cx.listener(handle_drag_move::)) .size_full() .relative() .on_hover(cx.listener(|this, hovered, window, cx| { @@ -4890,8 +4994,7 @@ impl Render for ProjectPanel { }) .on_drop(cx.listener( move |this, external_paths: &ExternalPaths, window, cx| { - this.last_external_paths_drag_over_entry = None; - this.marked_entries.clear(); + this.drag_target_entry = None; this.hover_scroll_task.take(); if let Some(task) = this .workspace diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 22176ed9d7e2980f8b6c971768143a9a438eaba6..9a1eda72d997c8e7f159d315936eccb866c8b3db 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -5098,6 +5098,205 @@ async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root", + json!({ + "dir1": { + "file1.txt": "", + "dir2": { + "file2.txt": "" + } + }, + "file3.txt": "" + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".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 worktree = project.visible_worktrees(cx).next().unwrap(); + let worktree = worktree.read(cx); + + // Test 1: Target is a directory, should highlight the directory itself + let dir_entry = worktree.entry_for_path("dir1").unwrap(); + let result = panel.highlight_entry_for_external_drag(dir_entry, worktree); + assert_eq!( + result, + Some(dir_entry.id), + "Should highlight directory itself" + ); + + // Test 2: Target is nested file, should highlight immediate parent + let nested_file = worktree.entry_for_path("dir1/dir2/file2.txt").unwrap(); + let nested_parent = worktree.entry_for_path("dir1/dir2").unwrap(); + let result = panel.highlight_entry_for_external_drag(nested_file, worktree); + assert_eq!( + result, + Some(nested_parent.id), + "Should highlight immediate parent" + ); + + // Test 3: Target is root level file, should highlight root + let root_file = worktree.entry_for_path("file3.txt").unwrap(); + let result = panel.highlight_entry_for_external_drag(root_file, worktree); + assert_eq!( + result, + Some(worktree.root_entry().unwrap().id), + "Root level file should return None" + ); + + // Test 4: Target is root itself, should highlight root + let root_entry = worktree.root_entry().unwrap(); + let result = panel.highlight_entry_for_external_drag(root_entry, worktree); + assert_eq!( + result, + Some(root_entry.id), + "Root level file should return None" + ); + }); +} + +#[gpui::test] +async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root", + json!({ + "parent_dir": { + "child_file.txt": "", + "sibling_file.txt": "", + "child_dir": { + "nested_file.txt": "" + } + }, + "other_dir": { + "other_file.txt": "" + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".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 worktree = project.visible_worktrees(cx).next().unwrap(); + let worktree_id = worktree.read(cx).id(); + let worktree = worktree.read(cx); + + let parent_dir = worktree.entry_for_path("parent_dir").unwrap(); + let child_file = worktree + .entry_for_path("parent_dir/child_file.txt") + .unwrap(); + let sibling_file = worktree + .entry_for_path("parent_dir/sibling_file.txt") + .unwrap(); + let child_dir = worktree.entry_for_path("parent_dir/child_dir").unwrap(); + let other_dir = worktree.entry_for_path("other_dir").unwrap(); + let other_file = worktree.entry_for_path("other_dir/other_file.txt").unwrap(); + + // Test 1: Single item drag, don't highlight parent directory + let dragged_selection = DraggedSelection { + active_selection: SelectedEntry { + worktree_id, + entry_id: child_file.id, + }, + marked_selections: Arc::new(BTreeSet::from([SelectedEntry { + worktree_id, + entry_id: child_file.id, + }])), + }; + let result = + panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx); + assert_eq!(result, None, "Should not highlight parent of dragged item"); + + // Test 2: Single item drag, don't highlight sibling files + let result = panel.highlight_entry_for_selection_drag( + sibling_file, + worktree, + &dragged_selection, + cx, + ); + assert_eq!(result, None, "Should not highlight sibling files"); + + // Test 3: Single item drag, highlight unrelated directory + let result = + panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx); + assert_eq!( + result, + Some(other_dir.id), + "Should highlight unrelated directory" + ); + + // Test 4: Single item drag, highlight sibling directory + let result = + panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx); + assert_eq!( + result, + Some(child_dir.id), + "Should highlight sibling directory" + ); + + // Test 5: Multiple items drag, highlight parent directory + let dragged_selection = DraggedSelection { + active_selection: SelectedEntry { + worktree_id, + entry_id: child_file.id, + }, + marked_selections: Arc::new(BTreeSet::from([ + SelectedEntry { + worktree_id, + entry_id: child_file.id, + }, + SelectedEntry { + worktree_id, + entry_id: sibling_file.id, + }, + ])), + }; + let result = + panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx); + assert_eq!( + result, + Some(parent_dir.id), + "Should highlight parent with multiple items" + ); + + // Test 6: Target is file in different directory, highlight parent + let result = + panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx); + assert_eq!( + result, + Some(other_dir.id), + "Should highlight parent of target file" + ); + + // Test 7: Target is directory, always highlight + let result = + panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx); + assert_eq!( + result, + Some(child_dir.id), + "Should always highlight directories" + ); + }); +} + fn select_path(panel: &Entity, path: impl AsRef, cx: &mut VisualTestContext) { let path = path.as_ref(); panel.update(cx, |panel, cx| {