diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 7c8e8bfd1927e80f65195cc33d34bcfad7863855..896ffd0cfdf59c74c6571e4f3775a32ffbb04df6 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -2176,7 +2176,7 @@ impl ProjectPanel { cx: &mut Context, ) { maybe!({ - let items_to_delete = self.disjoint_entries(cx); + let items_to_delete = self.disjoint_effective_entries(cx); if items_to_delete.is_empty() { return None; } @@ -2830,7 +2830,7 @@ impl ProjectPanel { } fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context) { - let entries = self.disjoint_entries(cx); + let entries = self.disjoint_effective_entries(cx); if !entries.is_empty() { self.clipboard = Some(ClipboardEntry::Cut(entries)); cx.notify(); @@ -2838,7 +2838,7 @@ impl ProjectPanel { } fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { - let entries = self.disjoint_entries(cx); + let entries = self.disjoint_effective_entries(cx); if !entries.is_empty() { self.clipboard = Some(ClipboardEntry::Copied(entries)); cx.notify(); @@ -3291,15 +3291,22 @@ impl ProjectPanel { self.index_for_entry(selection.entry_id, selection.worktree_id) } - fn disjoint_entries(&self, cx: &App) -> BTreeSet { - let marked_entries = self.effective_entries(); + fn disjoint_effective_entries(&self, cx: &App) -> BTreeSet { + self.disjoint_entries(self.effective_entries(), cx) + } + + fn disjoint_entries( + &self, + entries: BTreeSet, + cx: &App, + ) -> BTreeSet { let mut sanitized_entries = BTreeSet::new(); - if marked_entries.is_empty() { + if entries.is_empty() { return sanitized_entries; } let project = self.project.read(cx); - let marked_entries_by_worktree: HashMap> = marked_entries + let entries_by_worktree: HashMap> = entries .into_iter() .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx)) .fold(HashMap::default(), |mut map, entry| { @@ -3307,10 +3314,10 @@ impl ProjectPanel { map }); - for (worktree_id, marked_entries) in marked_entries_by_worktree { + for (worktree_id, worktree_entries) in entries_by_worktree { if let Some(worktree) = project.worktree_for_id(worktree_id, cx) { let worktree = worktree.read(cx); - let marked_dir_paths = marked_entries + let dir_paths = worktree_entries .iter() .filter_map(|entry| { worktree.entry_for_id(entry.entry_id).and_then(|entry| { @@ -3323,15 +3330,15 @@ impl ProjectPanel { }) .collect::>(); - sanitized_entries.extend(marked_entries.into_iter().filter(|entry| { + sanitized_entries.extend(worktree_entries.into_iter().filter(|entry| { let Some(entry_info) = worktree.entry_for_id(entry.entry_id) else { return false; }; let entry_path = entry_info.path.as_ref(); - let inside_marked_dir = marked_dir_paths.iter().any(|&marked_dir_path| { - entry_path != marked_dir_path && entry_path.starts_with(marked_dir_path) + let inside_selected_dir = dir_paths.iter().any(|&dir_path| { + entry_path != dir_path && entry_path.starts_with(dir_path) }); - !inside_marked_dir + !inside_selected_dir })); } } @@ -3923,6 +3930,15 @@ impl ProjectPanel { window: &mut Window, cx: &mut Context, ) { + let resolved_selections = selections + .items() + .map(|entry| SelectedEntry { + entry_id: self.resolve_entry(entry.entry_id), + worktree_id: entry.worktree_id, + }) + .collect::>(); + let entries = self.disjoint_entries(resolved_selections, cx); + if Self::is_copy_modifier_set(&window.modifiers()) { let _ = maybe!({ let project = self.project.read(cx); @@ -3935,7 +3951,7 @@ impl ProjectPanel { let mut copy_tasks = Vec::new(); let mut disambiguation_range = None; - for selection in selections.items() { + for selection in &entries { let (new_path, new_disambiguation_range) = self.create_paste_path( selection, (target_worktree.clone(), &target_entry), @@ -3979,8 +3995,8 @@ impl ProjectPanel { Some(()) }); } else { - for selection in selections.items() { - self.move_entry(selection.entry_id, target_entry_id, is_file, cx); + for entry in entries { + self.move_entry(entry.entry_id, target_entry_id, is_file, cx); } } } @@ -4604,7 +4620,7 @@ impl ProjectPanel { let dragged_selection = DraggedSelection { active_selection: SelectedEntry { worktree_id: selection.worktree_id, - entry_id: self.resolve_entry(selection.entry_id), + entry_id: selection.entry_id, }, marked_selections: Arc::from(self.marked_entries.clone()), }; diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 3f54e01927d67541fb3b17e88facadd1e6776bb6..6403ef357232c6b0fc920e745fc23bc517a18b4b 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -4053,10 +4053,7 @@ async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) { 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.state.selection.as_ref().unwrap().worktree_id, - entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id), - }, + active_selection: *panel.state.selection.as_ref().unwrap(), marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]), }; let target_entry = panel @@ -4086,10 +4083,7 @@ async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) { select_path(&panel, "root/target_destination/d", cx); panel.update_in(cx, |panel, window, cx| { let drag = DraggedSelection { - active_selection: SelectedEntry { - worktree_id: panel.state.selection.as_ref().unwrap().worktree_id, - entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id), - }, + active_selection: *panel.state.selection.as_ref().unwrap(), marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]), }; let target_entry = panel @@ -4109,10 +4103,7 @@ async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) { select_path(&panel, "root/a/b", cx); panel.update_in(cx, |panel, window, cx| { let drag = DraggedSelection { - active_selection: SelectedEntry { - worktree_id: panel.state.selection.as_ref().unwrap().worktree_id, - entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id), - }, + active_selection: *panel.state.selection.as_ref().unwrap(), marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]), }; let target_entry = panel @@ -4138,10 +4129,7 @@ async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) { select_path(&panel, "root/target_destination/b", cx); panel.update_in(cx, |panel, window, cx| { let drag = DraggedSelection { - active_selection: SelectedEntry { - worktree_id: panel.state.selection.as_ref().unwrap().worktree_id, - entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id), - }, + active_selection: *panel.state.selection.as_ref().unwrap(), marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]), }; let target_entry = panel @@ -4161,10 +4149,7 @@ async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) { select_path(&panel, "root/a", cx); panel.update_in(cx, |panel, window, cx| { let drag = DraggedSelection { - active_selection: SelectedEntry { - worktree_id: panel.state.selection.as_ref().unwrap().worktree_id, - entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id), - }, + active_selection: *panel.state.selection.as_ref().unwrap(), marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]), }; let target_entry = panel @@ -4187,6 +4172,88 @@ async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +async fn test_drag_marked_entries_in_folded_directories(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "a": { + "b": { + "c": {} + } + }, + "e": { + "f": { + "g": {} + } + }, + "target": {} + }), + ) + .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(); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v root", " > a/b/c", " > e/f/g", " > target"] + ); + + select_folded_path_with_mark(&panel, "root/a/b/c", "root/a/b", cx); + select_folded_path_with_mark(&panel, "root/e/f/g", "root/e/f", cx); + + panel.update_in(cx, |panel, window, cx| { + let drag = DraggedSelection { + active_selection: *panel.state.selection.as_ref().unwrap(), + marked_selections: panel.marked_entries.clone().into(), + }; + let target_entry = panel + .project + .read(cx) + .visible_worktrees(cx) + .next() + .unwrap() + .read(cx) + .entry_for_path(rel_path("target")) + .unwrap(); + panel.drag_onto(&drag, target_entry.id, false, window, cx); + }); + cx.executor().run_until_parked(); + + // After dragging 'b/c' and 'f/g' should be moved to target + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root", + " > a", + " > e", + " v target", + " > b/c", + " > f/g <== selected <== marked" + ], + "Should move 'b/c' and 'f/g' to target, leaving 'a' and 'e'" + ); +} + #[gpui::test] async fn test_drag_entries_between_different_worktrees(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -4287,6 +4354,89 @@ async fn test_drag_entries_between_different_worktrees(cx: &mut gpui::TestAppCon ); } +#[gpui::test] +async fn test_drag_multiple_entries(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "src": { + "folder1": { + "mod.rs": "// folder1 mod" + }, + "folder2": { + "mod.rs": "// folder2 mod" + }, + "folder3": { + "mod.rs": "// folder3 mod", + "helper.rs": "// helper" + }, + "main.rs": "" + } + }), + ) + .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(); + cx.run_until_parked(); + + toggle_expand_dir(&panel, "root/src", cx); + toggle_expand_dir(&panel, "root/src/folder1", cx); + toggle_expand_dir(&panel, "root/src/folder2", cx); + toggle_expand_dir(&panel, "root/src/folder3", cx); + cx.run_until_parked(); + + // Case 1: Dragging a folder and a file from a sibling folder together. + panel.update(cx, |panel, _| panel.marked_entries.clear()); + select_path_with_mark(&panel, "root/src/folder1", cx); + select_path_with_mark(&panel, "root/src/folder2/mod.rs", cx); + + drag_selection_to(&panel, "root", false, cx); + + assert!( + find_project_entry(&panel, "root/folder1", cx).is_some(), + "folder1 should be at root after drag" + ); + assert!( + find_project_entry(&panel, "root/folder1/mod.rs", cx).is_some(), + "folder1/mod.rs should still be inside folder1 after drag" + ); + assert_eq!( + find_project_entry(&panel, "root/src/folder1", cx), + None, + "folder1 should no longer be in src" + ); + assert!( + find_project_entry(&panel, "root/mod.rs", cx).is_some(), + "mod.rs from folder2 should be at root" + ); + + // Case 2: Dragging a folder and its own child together. + panel.update(cx, |panel, _| panel.marked_entries.clear()); + select_path_with_mark(&panel, "root/src/folder3", cx); + select_path_with_mark(&panel, "root/src/folder3/mod.rs", cx); + + drag_selection_to(&panel, "root", false, cx); + + assert!( + find_project_entry(&panel, "root/folder3", cx).is_some(), + "folder3 should be at root after drag" + ); + assert!( + find_project_entry(&panel, "root/folder3/mod.rs", cx).is_some(), + "folder3/mod.rs should still be inside folder3" + ); + assert!( + find_project_entry(&panel, "root/folder3/helper.rs", cx).is_some(), + "folder3/helper.rs should still be inside folder3" + ); +} + #[gpui::test] async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx); @@ -7678,6 +7828,40 @@ fn select_path_with_mark(panel: &Entity, path: &str, cx: &mut Visu }); } +/// `leaf_path` is the full path to the leaf entry (e.g., "root/a/b/c") +/// `active_ancestor_path` is the path to the ancestor that should be "active" (e.g., "root/a/b") +fn select_folded_path_with_mark( + panel: &Entity, + leaf_path: &str, + active_ancestor_path: &str, + cx: &mut VisualTestContext, +) { + select_path_with_mark(panel, leaf_path, cx); + let active_ancestor_path = rel_path(active_ancestor_path); + panel.update(cx, |panel, cx| { + let leaf_entry_id = panel.state.selection.unwrap().entry_id; + if let Some(folded_ancestors) = panel.state.ancestors.get_mut(&leaf_entry_id) { + for worktree in panel.project.read(cx).worktrees(cx).collect::>() { + let worktree = worktree.read(cx); + if let Ok(active_relative_path) = + active_ancestor_path.strip_prefix(worktree.root_name()) + { + let active_entry_id = worktree.entry_for_path(active_relative_path).unwrap().id; + if let Some(index) = folded_ancestors + .ancestors + .iter() + .position(|&id| id == active_entry_id) + { + folded_ancestors.current_ancestor_depth = + folded_ancestors.ancestors.len() - 1 - index; + } + return; + } + } + } + }); +} + fn drag_selection_to( panel: &Entity, target_path: &str,