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.
This commit is contained in:
eth0net 2026-05-05 16:55:05 +01:00
parent 017c649340
commit f0c7ee837c
No known key found for this signature in database
4 changed files with 222 additions and 1 deletions

View file

@ -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<Self>,
) -> 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,

View file

@ -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<Self>,
) -> 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<usize> = 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() {

View file

@ -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>,
) {
self.project.update(cx, |project, cx| {
let source_ids: Vec<WorktreeId> = 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();

View file

@ -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::<Vec<_>>();
(
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::<Vec<_>>();
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::<Vec<_>>()
});
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.