project_panel: Allow dragging folded directories onto other items (#38070)

In https://github.com/zed-industries/zed/pull/22983 we made it possible
to drag items onto folded directories.

This PR handles the reverse: dragging folded directories onto other
items.

Release Notes:

- Improved drag-and-drop support by allowing folded directories to be
dragged onto other items in Project Panel.
This commit is contained in:
Smit Barmase
2025-09-12 22:28:43 +05:30
committed by GitHub
parent 8ebe812c24
commit 7377a898e8
2 changed files with 235 additions and 38 deletions
+59 -38
View File
@@ -310,6 +310,27 @@ impl FoldedAncestors {
fn max_ancestor_depth(&self) -> usize {
self.ancestors.len()
}
/// Note: This returns None for last item in ancestors list
fn active_ancestor(&self) -> Option<ProjectEntryId> {
if self.current_ancestor_depth == 0 {
return None;
}
self.ancestors.get(self.current_ancestor_depth).copied()
}
fn active_index(&self) -> usize {
self.max_ancestor_depth()
.saturating_sub(1)
.saturating_sub(self.current_ancestor_depth)
}
fn active_component(&self, file_name: &str) -> Option<String> {
Path::new(file_name)
.components()
.nth(self.active_index())
.map(|comp| comp.as_os_str().to_string_lossy().into_owned())
}
}
pub fn init_settings(cx: &mut App) {
@@ -393,7 +414,8 @@ struct SerializedProjectPanel {
struct DraggedProjectEntryView {
selection: SelectedEntry,
details: EntryDetails,
icon: Option<SharedString>,
filename: String,
click_offset: Point<Pixels>,
selections: Arc<[SelectedEntry]>,
}
@@ -2943,13 +2965,7 @@ impl ProjectPanel {
fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId {
self.ancestors
.get(&id)
.and_then(|ancestors| {
if ancestors.current_ancestor_depth == 0 {
return None;
}
ancestors.ancestors.get(ancestors.current_ancestor_depth)
})
.copied()
.and_then(|ancestors| ancestors.active_ancestor())
.unwrap_or(id)
}
@@ -4090,7 +4106,10 @@ impl ProjectPanel {
let depth = details.depth;
let worktree_id = details.worktree_id;
let dragged_selection = DraggedSelection {
active_selection: selection,
active_selection: SelectedEntry {
worktree_id: selection.worktree_id,
entry_id: self.resolve_entry(selection.entry_id),
},
marked_selections: Arc::from(self.marked_entries.clone()),
};
@@ -4320,14 +4339,19 @@ impl ProjectPanel {
))
.on_drag(
dragged_selection,
move |selection, click_offset, _window, cx| {
cx.new(|_| DraggedProjectEntryView {
details: details.clone(),
click_offset,
selection: selection.active_selection,
selections: selection.marked_selections.clone(),
})
},
{
let active_component = self.ancestors.get(&entry_id).and_then(|ancestors| ancestors.active_component(&details.filename));
move |selection, click_offset, _window, cx| {
let filename = active_component.as_ref().unwrap_or_else(|| &details.filename);
cx.new(|_| DraggedProjectEntryView {
icon: details.icon.clone(),
filename: filename.clone(),
click_offset,
selection: selection.active_selection,
selections: selection.marked_selections.clone(),
})
}
}
)
.on_drop(
cx.listener(move |this, selections: &DraggedSelection, window, cx| {
@@ -4530,19 +4554,13 @@ impl ProjectPanel {
if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
let components = Path::new(&file_name)
.components()
.map(|comp| {
comp.as_os_str().to_string_lossy().into_owned()
})
.map(|comp| comp.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>();
let active_index = folded_ancestors.active_index();
let components_len = components.len();
// TODO this can underflow
let active_index = components_len
- 1
- folded_ancestors.current_ancestor_depth;
const DELIMITER: SharedString =
SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
for (index, component) in components.into_iter().enumerate() {
for (index, component) in components.iter().enumerate() {
if index != 0 {
let delimiter_target_index = index - 1;
let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned();
@@ -4643,16 +4661,19 @@ impl ProjectPanel {
}))
})
})
.on_click(cx.listener(move |this, _, _, cx| {
if index != active_index
&& let Some(folds) =
this.ancestors.get_mut(&entry_id)
{
folds.current_ancestor_depth =
components_len - 1 - index;
cx.notify();
}
}))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _, cx| {
if index != active_index
&& let Some(folds) =
this.ancestors.get_mut(&entry_id)
{
folds.current_ancestor_depth =
components_len - 1 - index;
cx.notify();
}
}),
)
.child(
Label::new(component)
.single_line()
@@ -5846,12 +5867,12 @@ impl Render for DraggedProjectEntryView {
if self.selections.len() > 1 && self.selections.contains(&self.selection) {
this.child(Label::new(format!("{} entries", self.selections.len())))
} else {
this.child(if let Some(icon) = &self.details.icon {
this.child(if let Some(icon) = &self.icon {
div().child(Icon::from_path(icon.clone()))
} else {
div()
})
.child(Label::new(self.details.filename.clone()))
.child(Label::new(self.filename.clone()))
}
}),
)
@@ -3162,6 +3162,182 @@ async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
]
);
}
#[gpui::test]
async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"a": {
"b": {
"c": {
"d": {}
}
}
},
"target_destination": {}
}),
)
.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);
cx.update(|_, cx| {
let settings = *ProjectPanelSettings::get_global(cx);
ProjectPanelSettings::override_global(
ProjectPanelSettings {
auto_fold_dirs: true,
..settings
},
cx,
);
});
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
// Case 1: Move last dir 'd' - should move only 'd', leaving 'a/b/c'
select_path(&panel, "root/a/b/c/d", cx);
panel.update_in(cx, |panel, window, cx| {
let drag = DraggedSelection {
active_selection: SelectedEntry {
worktree_id: panel.selection.as_ref().unwrap().worktree_id,
entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
},
marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
};
let target_entry = panel
.project
.read(cx)
.visible_worktrees(cx)
.next()
.unwrap()
.read(cx)
.entry_for_path("target_destination")
.unwrap();
panel.drag_onto(&drag, target_entry.id, false, window, cx);
});
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root",
" > a/b/c",
" > target_destination/d <== selected"
],
"Moving last empty directory 'd' should leave 'a/b/c' and move only 'd'"
);
// Reset
select_path(&panel, "root/target_destination/d", cx);
panel.update_in(cx, |panel, window, cx| {
let drag = DraggedSelection {
active_selection: SelectedEntry {
worktree_id: panel.selection.as_ref().unwrap().worktree_id,
entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
},
marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
};
let target_entry = panel
.project
.read(cx)
.visible_worktrees(cx)
.next()
.unwrap()
.read(cx)
.entry_for_path("a/b/c")
.unwrap();
panel.drag_onto(&drag, target_entry.id, false, window, cx);
});
cx.executor().run_until_parked();
// Case 2: Move middle dir 'b' - should move 'b/c/d', leaving only 'a'
select_path(&panel, "root/a/b", cx);
panel.update_in(cx, |panel, window, cx| {
let drag = DraggedSelection {
active_selection: SelectedEntry {
worktree_id: panel.selection.as_ref().unwrap().worktree_id,
entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
},
marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
};
let target_entry = panel
.project
.read(cx)
.visible_worktrees(cx)
.next()
.unwrap()
.read(cx)
.entry_for_path("target_destination")
.unwrap();
panel.drag_onto(&drag, target_entry.id, false, window, cx);
});
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&["v root", " v a", " > target_destination/b/c/d"],
"Moving middle directory 'b' should leave only 'a' and move 'b/c/d'"
);
// Reset
select_path(&panel, "root/target_destination/b", cx);
panel.update_in(cx, |panel, window, cx| {
let drag = DraggedSelection {
active_selection: SelectedEntry {
worktree_id: panel.selection.as_ref().unwrap().worktree_id,
entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
},
marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
};
let target_entry = panel
.project
.read(cx)
.visible_worktrees(cx)
.next()
.unwrap()
.read(cx)
.entry_for_path("a")
.unwrap();
panel.drag_onto(&drag, target_entry.id, false, window, cx);
});
cx.executor().run_until_parked();
// Case 3: Move first dir 'a' - should move whole 'a/b/c/d'
select_path(&panel, "root/a", cx);
panel.update_in(cx, |panel, window, cx| {
let drag = DraggedSelection {
active_selection: SelectedEntry {
worktree_id: panel.selection.as_ref().unwrap().worktree_id,
entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
},
marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
};
let target_entry = panel
.project
.read(cx)
.visible_worktrees(cx)
.next()
.unwrap()
.read(cx)
.entry_for_path("target_destination")
.unwrap();
panel.drag_onto(&drag, target_entry.id, false, window, cx);
});
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&["v root", " > target_destination/a/b/c/d"],
"Moving first directory 'a' should move whole 'a/b/c/d' chain"
);
}
#[gpui::test]
async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);