@@ -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<ProjectEntryId, FoldedAncestors>,
folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
last_worktree_root_id: Option<ProjectEntryId>,
- last_selection_drag_over_entry: Option<ProjectEntryId>,
- last_external_paths_drag_over_entry: Option<ProjectEntryId>,
+ drag_target_entry: Option<DragTargetEntry>,
expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
unfolded_dir_ids: HashSet<ProjectEntryId>,
// 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<Task<()>>,
}
+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<ProjectEntryId>,
+}
+
#[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<ProjectEntryId> {
+ // 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<Self>,
+ ) -> Option<ProjectEntryId> {
+ 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::<ExternalPaths>(cx.listener(
move |this, event: &DragMoveEvent<ExternalPaths>, _, 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::<DraggedSelection>(cx.listener(
move |this, event: &DragMoveEvent<DraggedSelection>, 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::<DraggedSelection>(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<T: 'static>(
+ fn handle_drag_move<T: 'static>(
this: &mut ProjectPanel,
e: &DragMoveEvent<T>,
window: &mut Window,
cx: &mut Context<ProjectPanel>,
) {
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::<ExternalPaths>))
- .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
+ .on_drag_move(cx.listener(handle_drag_move::<ExternalPaths>))
+ .on_drag_move(cx.listener(handle_drag_move::<DraggedSelection>))
.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
@@ -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<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
let path = path.as_ref();
panel.update(cx, |panel, cx| {