From 7377a898e8592c5b60e479baf49556cfa669428b Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Fri, 12 Sep 2025 09:58:43 -0700 Subject: [PATCH] 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. --- crates/project_panel/src/project_panel.rs | 97 ++++++---- .../project_panel/src/project_panel_tests.rs | 176 ++++++++++++++++++ 2 files changed, 235 insertions(+), 38 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index f82901595b82a07684185e161e6198b63c4e46ca..97d7c6fcbb6bf16d73977c1451d3481edbf89715 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -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 { + 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 { + 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, + filename: String, click_offset: Point, 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::>(); - + 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())) } }), ) diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index f1132226cbded31f4179bd1cf3a492559e3cded9..ad2a7d12ecce31cf1aa4458b3fd59e23f63ab08b 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -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);