project_panel: Allow dropping external files on folded directory components (#65259)

## Summary

Closes #65091

In the project panel, a folded directory path like `a/b/c` is rendered
as separate components. Internal drags (`DraggedSelection`) could
already be dropped on any component or separator, but external files
dragged in from the OS could only be dropped on the deepest directory.
This is the case from the issue: files dragged onto `upper_folder` in a
folded `upper_folder/lower_folder` always ended up in `lower_folder`.

This adds `ExternalPaths` drag-move and drop handlers to each folded
path component and to the separators between them, so external files
land in the directory that was actually hovered. The hover highlight
follows the same path as for internal drags.

The duplicated drag-target bookkeeping in the `DraggedSelection`
handlers is moved into `update_folded_directory_drag_target`, so both
drag types share it.

Before:

<img width="1100" height="688" alt="before"
src="https://github.com/user-attachments/assets/94af5bb9-f727-4bd0-80ed-5713790b7f65"
/>

After:

<img width="1100" height="688" alt="after"
src="https://github.com/user-attachments/assets/dee1f581-ae7d-48b0-9d08-19e671fc63e5"
/>

## Testing

Added `test_drop_external_files_on_folded_directory_components`, which
drops an external file on each of `a`, `b` and `c` in a folded `a/b/c`
and checks that it lands in that directory and that the drag state is
cleared afterwards. The test fails without the new drop handlers (the
drop on `b` ends up in the wrong directory).

Manually tested on macOS 27. The issue was reported on Windows, which I
have not tested.

1. Enable `auto_fold_dirs` and open a project containing a nested chain
of single-child directories, e.g. `a/b/c`.
2. Drag a file from the OS file manager onto `a`, `b`, `c` and onto a
`/` separator.
3. The file is created in the hovered directory, and only that component
is highlighted while dragging.

## Self-review

- [x] I've reviewed my diff for quality, security, reliability, and
performance.
- [x] UI changes follow the
[checklist](https://zed.dev/docs/development/ui-checklist).
- [x] Tests cover the new or changed behavior.

Release Notes:

- Fixed dropping external files onto a specific directory in a folded
path in the project panel

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
This commit is contained in:
Kevin Pierik 2026-10-07 12:27:26 +00:00 • committed by GitHub
parent dc000222a9
commit eb466341a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 148 additions and 38 deletions

View file

@ -222,7 +222,7 @@ enum DragTarget {
Background,
}
#[derive(Copy, Clone, Debug)]
#[derive(Copy, Clone, Debug, PartialEq)]
struct FoldedDirectoryDragTarget {
entry_id: ProjectEntryId,
index: usize,
@ -5046,6 +5046,18 @@ impl ProjectPanel {
}
}
fn update_folded_directory_drag_target(
&mut self,
is_hovered: bool,
target: FoldedDirectoryDragTarget,
) {
if is_hovered {
self.folded_directory_drag_target = Some(target);
} else if self.folded_directory_drag_target == Some(target) {
self.folded_directory_drag_target = None;
}
}
fn clear_drag_state(&mut self, cx: &mut Context<Self>) {
let had_drag_state = self.drag_target_entry.take().is_some()
| self.folded_directory_drag_target.take().is_some()
@ -6749,32 +6761,21 @@ impl ProjectPanel {
.ancestors
.get(components_len - 1 - index)
.cloned();
let drag_target = FoldedDirectoryDragTarget {
entry_id,
index,
is_delimiter_target: false,
};
div.when(drag_and_drop_enabled, |div| {
div.on_drag_move(cx.listener(
move |this,
event: &DragMoveEvent<DraggedSelection>,
_,
_| {
if event.bounds.contains(&event.event.position) {
this.folded_directory_drag_target =
Some(FoldedDirectoryDragTarget {
entry_id,
index,
is_delimiter_target: false,
});
} else {
let is_current_target = this
.folded_directory_drag_target
.as_ref()
.is_some_and(|target| {
target.entry_id == entry_id
&& target.index == index
&& !target.is_delimiter_target
});
if is_current_target {
this.folded_directory_drag_target = None;
}
}
this.update_folded_directory_drag_target(
event.bounds.contains(&event.event.position),
drag_target,
);
},
))
.on_drop(cx.listener(
@ -6791,6 +6792,27 @@ impl ProjectPanel {
}
},
))
.on_drag_move(cx.listener(
move |this, event: &DragMoveEvent<ExternalPaths>, _, _| {
this.update_folded_directory_drag_target(
event.bounds.contains(&event.event.position),
drag_target,
);
},
))
.on_drop(cx.listener(
move |this, external_paths: &ExternalPaths, window, cx| {
this.clear_drag_state(cx);
if let Some(target_entry_id) = target_entry_id {
this.drop_external_files(
external_paths.paths(),
target_entry_id,
window,
cx,
);
}
},
))
.when(
folded_directory_drag_target.is_some_and(|target| {
target.entry_id == entry_id && target.index == index
@ -6820,6 +6842,7 @@ impl ProjectPanel {
}
}),
)
.debug_selector(|| format!("project_panel_path_component_{component}"))
.child(
Label::new(component)
.single_line()
@ -6871,6 +6894,11 @@ impl ProjectPanel {
.ancestors
.get(components_len - 1 - delimiter_target_index)
.cloned();
let drag_target = FoldedDirectoryDragTarget {
entry_id,
index: delimiter_target_index,
is_delimiter_target: true,
};
div()
.when(!is_sticky, |div| {
div.when(drag_and_drop_enabled, |div| {
@ -6884,26 +6912,33 @@ impl ProjectPanel {
))
.on_drag_move(cx.listener(
move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
if event.bounds.contains(&event.event.position) {
this.folded_directory_drag_target =
Some(FoldedDirectoryDragTarget {
entry_id,
index: delimiter_target_index,
is_delimiter_target: true,
});
} else {
let is_current_target =
this.folded_directory_drag_target.is_some_and(|target| {
target.entry_id == entry_id
&& target.index == delimiter_target_index
&& target.is_delimiter_target
});
if is_current_target {
this.folded_directory_drag_target = None;
}
this.update_folded_directory_drag_target(
event.bounds.contains(&event.event.position),
drag_target,
);
},
))
.on_drop(cx.listener(
move |this, external_paths: &ExternalPaths, window, cx| {
this.clear_drag_state(cx);
if let Some(target_entry_id) = target_entry_id {
this.drop_external_files(
external_paths.paths(),
target_entry_id,
window,
cx,
);
}
},
))
.on_drag_move(cx.listener(
move |this, event: &DragMoveEvent<ExternalPaths>, _, _| {
this.update_folded_directory_drag_target(
event.bounds.contains(&event.event.position),
drag_target,
);
},
))
})
})
.child(

View file

@ -13086,6 +13086,81 @@ async fn test_file_drag_state_clears_on_render_after_drag_stops(cx: &mut TestApp
.expect("window is open");
}
#[gpui::test]
async fn test_drop_external_files_on_folded_directory_components(cx: &mut TestAppContext) {
init_test_with_editor(cx);
cx.update(|cx| {
let settings = *ProjectPanelSettings::get_global(cx);
ProjectPanelSettings::override_global(
ProjectPanelSettings {
auto_fold_dirs: true,
..settings
},
cx,
);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/root"), json!({ "a": { "b": { "c": {} } } }))
.await;
let temp_dir = tempfile::tempdir().expect("temporary directory is created");
let deepest_drop_path = temp_dir.path().join("deepest.txt");
let middle_drop_path = temp_dir.path().join("middle.txt");
let topmost_drop_path = temp_dir.path().join("topmost.txt");
for path in [&deepest_drop_path, &middle_drop_path, &topmost_drop_path] {
std::fs::write(path, "").expect("external file is written");
}
fs.insert_tree_from_real_fs(temp_dir.path(), temp_dir.path())
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let (window, panel) = create_drag_test_panel(&project, cx);
let cx = &mut VisualTestContext::from_window(window, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&["v root", " > a/b/c"]
);
for (selector, drop_path) in [
("project_panel_path_component_c", &deepest_drop_path),
("project_panel_path_component_b", &middle_drop_path),
("project_panel_path_component_a", &topmost_drop_path),
] {
let position = cx
.debug_bounds(selector)
.expect("folded path component is rendered")
.center();
cx.simulate_event(FileDropEvent::Entered {
position,
paths: ExternalPaths([drop_path.clone()].into_iter().collect()),
});
cx.simulate_event(FileDropEvent::Submit { position });
cx.run_until_parked();
cx.update(|window, cx| window.draw(cx).clear(cx));
panel.read_with(cx, |panel, _| assert_drag_state_cleared(panel));
}
assert!(
find_project_entry(&panel, "root/a/b/c/deepest.txt", cx).is_some(),
"dropping on `c` should create the file in root/a/b/c"
);
assert!(
find_project_entry(&panel, "root/a/b/middle.txt", cx).is_some(),
"dropping on `b` should create the file in root/a/b"
);
assert!(
find_project_entry(&panel, "root/a/topmost.txt", cx).is_some(),
"dropping on `a` should create the file in root/a"
);
assert!(
find_project_entry(&panel, "root/a/b/c/topmost.txt", cx).is_none(),
"dropping on `a` should not fall through to the deepest directory"
);
}
fn create_drag_test_panel(
project: &Entity<Project>,
cx: &mut TestAppContext,