From f0c7ee837c60d51777ae7cfb8600146f589b9396 Mon Sep 17 00:00:00 2001 From: eth0net Date: Tue, 5 May 2026 16:55:05 +0100 Subject: [PATCH] Send root group to the end on blank-area drops that include the last worktree Dropping a multi-root selection on the blank area below the panel previously forwarded the gesture as `destination = last_worktree_root_id` and then hit the self-drop guard in `move_worktrees` if the dragged group already contained the last worktree, silently no-opping the operation. Adds `WorktreeStore::move_worktrees_to_end` (remove sources, append in original order) and a panel-side helper that the blank-area drop handler routes to when the drag is roots-only and includes the last worktree. Explicit row drops keep the self-drop no-op for the ambiguous "[A, B] dropped onto B" gesture. --- crates/project/src/project.rs | 13 +++ crates/project/src/worktree_store.rs | 60 ++++++++++++ crates/project_panel/src/project_panel.rs | 57 +++++++++++- .../project_panel/src/project_panel_tests.rs | 93 +++++++++++++++++++ 4 files changed, 222 insertions(+), 1 deletion(-) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index e4c379b4796..2d1f4de6bbb 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -4593,6 +4593,19 @@ impl Project { }) } + /// Moves multiple worktrees to the end of the worktree list, preserving + /// their relative order. Used for "drop on the blank area below the + /// project panel" gestures. + pub fn move_worktrees_to_end( + &mut self, + sources: &[WorktreeId], + cx: &mut Context, + ) -> Result<()> { + self.worktree_store.update(cx, |worktree_store, cx| { + worktree_store.move_worktrees_to_end(sources, cx) + }) + } + /// Attempts to convert the input path to a WSL path if this is a wsl remote project and the input path is a host windows path. pub fn try_windows_path_to_wsl( &self, diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index e3d54cb5813..4a1858f10bd 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -1134,6 +1134,66 @@ impl WorktreeStore { Ok(()) } + /// Removes every source from the worktree list and appends them to the + /// end in their original relative order. Intended for "drop on the empty + /// area below the panel" gestures, where the user's intent is "send this + /// group to the end" rather than reordering relative to a specific row. + pub fn move_worktrees_to_end( + &mut self, + sources: &[WorktreeId], + cx: &mut Context, + ) -> Result<()> { + if sources.is_empty() { + return Ok(()); + } + + for &source in sources { + if !self + .worktrees + .iter() + .any(|wt| wt.upgrade().is_some_and(|wt| wt.read(cx).id() == source)) + { + anyhow::bail!("Missing worktree for id {source}"); + } + } + + let source_indices: Vec = self + .worktrees + .iter() + .enumerate() + .filter_map(|(i, wt)| { + let id = wt.upgrade()?.read(cx).id(); + sources.contains(&id).then_some(i) + }) + .collect(); + + if source_indices.is_empty() { + return Ok(()); + } + + // Already a contiguous suffix → nothing to do. + let len = self.worktrees.len(); + let already_at_end = source_indices + .iter() + .enumerate() + .all(|(offset, &i)| i == len - source_indices.len() + offset); + if already_at_end { + return Ok(()); + } + + let mut to_append = Vec::with_capacity(source_indices.len()); + for &i in source_indices.iter().rev() { + to_append.push(self.worktrees.remove(i)); + } + to_append.reverse(); + self.worktrees.extend(to_append); + + cx.emit(WorktreeStoreEvent::WorktreeOrderChanged); + cx.notify(); + self.send_project_updates(cx); + Ok(()) + } + pub fn disconnected_from_host(&mut self, cx: &mut App) { for worktree in &self.worktrees { if let Some(worktree) = worktree.upgrade() { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index f4d8232f0b7..9678713c961 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -3630,6 +3630,52 @@ impl ProjectPanel { }); } + /// Moves all root-only entries in the drag to the end of the worktree + /// list. Used by the blank-area drop handler so dropping a multi-root + /// selection that already contains the last worktree (e.g. `[A, D]` in + /// `[A, B, C, D]`) settles the group at the end instead of falling on + /// the self-drop guard in `move_worktrees`. + fn reorder_worktree_roots_to_end( + &mut self, + selections: &DraggedSelection, + cx: &mut Context, + ) { + self.project.update(cx, |project, cx| { + let source_ids: Vec = selections + .items() + .filter_map(|entry| { + project + .worktree_for_entry(entry.entry_id, cx) + .map(|wt| wt.read(cx).id()) + }) + .collect(); + if source_ids.is_empty() { + return; + } + project.move_worktrees_to_end(&source_ids, cx).log_err(); + }); + } + + fn drag_includes_last_worktree(&self, selections: &DraggedSelection, cx: &App) -> bool { + let project = self.project.read(cx); + let drag_is_root_only = selections + .items() + .all(|entry| project.entry_is_worktree_root(entry.entry_id, cx)); + if !drag_is_root_only { + return false; + } + let Some(last_worktree_id) = project + .visible_worktrees(cx) + .next_back() + .map(|wt| wt.read(cx).id()) + else { + return false; + }; + selections + .items() + .any(|entry| entry.worktree_id == last_worktree_id) + } + fn move_worktree_entry( &mut self, entry_to_move: ProjectEntryId, @@ -7083,7 +7129,16 @@ impl Render for ProjectPanel { move |this, selections: &DraggedSelection, window, cx| { this.drag_target_entry = None; this.hover_scroll_task.take(); - if let Some(entry_id) = this.state.last_worktree_root_id { + // A pure-root drag that contains the + // last worktree would otherwise hit + // the self-drop guard in + // `move_worktrees` and become a no-op. + // Send the group to the end instead. + if this.drag_includes_last_worktree(selections, cx) { + this.reorder_worktree_roots_to_end(selections, cx); + } else if let Some(entry_id) = + this.state.last_worktree_root_id + { this.drag_onto(selections, entry_id, false, window, cx); } cx.stop_propagation(); diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 29b9e3cd953..0d8a4dc1cf8 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -4417,6 +4417,99 @@ async fn test_drag_worktree_root_onto_nested_entry_is_rejected( ); } +// Dropping a pure-root selection that includes the last worktree onto the +// blank area below the panel should send the group to the end of the +// worktree list, preserving relative order. Without the special-case +// blank-area handling, the destination resolves to the last worktree's +// root, which is one of the dragged roots, and `move_worktrees`'s +// self-drop guard would silently no-op the gesture. +#[gpui::test] +async fn test_drag_root_group_with_last_worktree_to_blank_area( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root1", json!({ "a.txt": "" })).await; + fs.insert_tree("/root2", json!({ "b.txt": "" })).await; + fs.insert_tree("/root3", json!({ "c.txt": "" })).await; + fs.insert_tree("/root4", json!({ "d.txt": "" })).await; + + let project = Project::test( + fs.clone(), + [ + "/root1".as_ref(), + "/root2".as_ref(), + "/root3".as_ref(), + "/root4".as_ref(), + ], + cx, + ) + .await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); + cx.run_until_parked(); + + let (r1_id, r2_id, r3_id, r4_id) = panel.update_in(cx, |panel, _, cx| { + let worktrees = panel + .project + .read(cx) + .visible_worktrees(cx) + .collect::>(); + ( + worktrees[0].read(cx).id(), + worktrees[1].read(cx).id(), + worktrees[2].read(cx).id(), + worktrees[3].read(cx).id(), + ) + }); + + // Drag [r1, r4] onto the blank area: route via the panel's blank-area + // helper since the drag includes the last worktree. + panel.update_in(cx, |panel, _, cx| { + let project = panel.project.read(cx); + let worktrees = project.visible_worktrees(cx).collect::>(); + let r1 = worktrees[0].read(cx); + let r4 = worktrees[3].read(cx); + let r1_entry = SelectedEntry { + worktree_id: r1.id(), + entry_id: r1.root_entry().unwrap().id, + }; + let r4_entry = SelectedEntry { + worktree_id: r4.id(), + entry_id: r4.root_entry().unwrap().id, + }; + let drag = DraggedSelection { + active_selection: r1_entry, + marked_selections: Arc::new([r1_entry, r4_entry]), + }; + assert!( + panel.drag_includes_last_worktree(&drag, cx), + "the drag should be flagged as containing the last worktree" + ); + panel.reorder_worktree_roots_to_end(&drag, cx); + }); + cx.run_until_parked(); + + let order = panel.update_in(cx, |panel, _, cx| { + panel + .project + .read(cx) + .visible_worktrees(cx) + .map(|wt| wt.read(cx).id()) + .collect::>() + }); + assert_eq!( + order, + vec![r2_id, r3_id, r1_id, r4_id], + "marked roots [r1, r4] should land at the end with their original relative order" + ); +} + // Dropping a multi-root selection onto one of its own members has no // well-defined intent, so it should leave the order untouched rather than // reorder the remaining roots around the destination.