Honor copy modifier in worktree-root drag feedback and blank-area path

- The blank-area "send group to end" fast path now only triggers for
  move gestures. Holding the copy modifier falls through to `drag_onto`
  so the copy branch's existing root-filter no-op stays in effect.
- `highlight_entry_for_selection_drag` takes an `is_copy_mode` flag and
  suppresses every target for pure-root drags in copy mode, so users
  no longer see a copy cursor with a root-row highlight for an
  operation that will silently do nothing.
- Tighten the doc comment on `move_worktrees` to describe the actual
  fallback (earliest source in worktree order) and trim the
  implementation-leaky doc on `move_worktrees_to_end`.
- Add `test_highlight_entry_for_root_drag_suppressed_in_copy_mode`.
This commit is contained in:
eth0net 2026-05-05 17:31:48 +01:00
parent 15117d76dc
commit e495df4172
No known key found for this signature in database
3 changed files with 99 additions and 17 deletions

View file

@ -1045,8 +1045,9 @@ impl WorktreeStore {
/// preserving their relative order. The `active_source` (if provided and
/// in `sources`) decides whether the group lands before or after the
/// destination, mirroring the single-source semantics: a source originally
/// before destination ends up after it, and vice versa. When no active
/// source is supplied, the first source's position is used.
/// before destination ends up after it, and vice versa. When no usable
/// active source is supplied, the earliest source in the current worktree
/// order is used as the direction reference.
pub fn move_worktrees(
&mut self,
sources: &[WorktreeId],
@ -1135,9 +1136,8 @@ impl WorktreeStore {
}
/// 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.
/// end in their original relative order. Returns early when the sources
/// already form a contiguous suffix.
pub fn move_worktrees_to_end(
&mut self,
sources: &[WorktreeId],

View file

@ -5273,6 +5273,7 @@ impl ProjectPanel {
target_entry: &Entry,
target_worktree: &Worktree,
drag_state: &DraggedSelection,
is_copy_mode: bool,
cx: &Context<Self>,
) -> Option<ProjectEntryId> {
// Pure worktree-root drags are only meaningful when dropped on
@ -5284,6 +5285,11 @@ impl ProjectPanel {
.items()
.all(|entry| project.entry_is_worktree_root(entry.entry_id, cx));
if drag_is_root_only {
// Worktree roots can't be copied; in copy mode the drop is a
// guaranteed no-op, so don't highlight any target.
if is_copy_mode {
return None;
}
let root_id = target_worktree.root_entry()?.id;
// Hovering any worktree that's part of the drag (active or just
// marked) is a no-op in `move_worktrees`, so don't highlight it.
@ -5629,6 +5635,7 @@ impl ProjectPanel {
this.marked_entries.push(drag_state.active_selection);
}
let is_copy_mode = Self::is_copy_modifier_set(&window.modifiers());
let Some((entry_id, highlight_entry_id)) = maybe!({
let target_worktree = this
.project
@ -5641,6 +5648,7 @@ impl ProjectPanel {
target_entry,
target_worktree,
drag_state,
is_copy_mode,
cx,
)?;
Some((target_entry.id, highlight_entry_id))
@ -7133,8 +7141,16 @@ impl Render for ProjectPanel {
// 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) {
// Send the group to the end — but
// only for move gestures; copy mode
// can't operate on roots and should
// continue to no-op via `drag_onto`.
let is_copy_mode = Self::is_copy_modifier_set(
&window.modifiers(),
);
if !is_copy_mode
&& 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

View file

@ -9077,7 +9077,7 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
}]),
};
let result =
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, false, cx);
assert_eq!(result, None, "Should not highlight parent of dragged item");
// Test 2: Single item drag, don't highlight sibling files
@ -9085,13 +9085,14 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
sibling_file,
worktree,
&dragged_selection,
false,
cx,
);
assert_eq!(result, None, "Should not highlight sibling files");
// Test 3: Single item drag, highlight unrelated directory
let result =
panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, false, cx);
assert_eq!(
result,
Some(other_dir.id),
@ -9100,7 +9101,7 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
// Test 4: Single item drag, highlight sibling directory
let result =
panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, false, cx);
assert_eq!(
result,
Some(child_dir.id),
@ -9125,7 +9126,7 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
]),
};
let result =
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, false, cx);
assert_eq!(
result,
Some(parent_dir.id),
@ -9134,7 +9135,7 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
// Test 6: Target is file in different directory, highlight parent
let result =
panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, false, cx);
assert_eq!(
result,
Some(other_dir.id),
@ -9143,7 +9144,7 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
// Test 7: Target is directory, always highlight
let result =
panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, false, cx);
assert_eq!(
result,
Some(child_dir.id),
@ -9220,6 +9221,7 @@ async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::T
src_dir_from_b,
worktree_b.read(cx),
&dragged_selection,
false,
cx,
);
assert_eq!(
@ -9233,6 +9235,7 @@ async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::T
main_rs_from_b,
worktree_b.read(cx),
&dragged_selection,
false,
cx,
);
assert_eq!(
@ -9308,34 +9311,97 @@ async fn test_highlight_entry_for_multi_root_drag_excludes_marked_worktrees(
// Hovering r2 (not in the drag): highlight allowed.
assert_eq!(
panel.highlight_entry_for_selection_drag(r2_root, r2, &drag, cx),
panel.highlight_entry_for_selection_drag(r2_root, r2, &drag, false, cx),
Some(r2_root.id),
"non-dragged worktree root should highlight"
);
// Hovering r4 (not in the drag): highlight allowed.
assert_eq!(
panel.highlight_entry_for_selection_drag(r4_root, r4, &drag, cx),
panel.highlight_entry_for_selection_drag(r4_root, r4, &drag, false, cx),
Some(r4_root.id),
"non-dragged worktree root should highlight"
);
// Hovering r1 (active source): no-op, no highlight.
assert_eq!(
panel.highlight_entry_for_selection_drag(r1_root, r1, &drag, cx),
panel.highlight_entry_for_selection_drag(r1_root, r1, &drag, false, cx),
None,
"active source's worktree should not highlight"
);
// Hovering r3 (marked but not active): no-op, no highlight.
assert_eq!(
panel.highlight_entry_for_selection_drag(r3_root, r3, &drag, cx),
panel.highlight_entry_for_selection_drag(r3_root, r3, &drag, false, cx),
None,
"marked source's worktree should not highlight"
);
});
}
// In copy mode, a pure worktree-root drag is a guaranteed no-op (the copy
// branch in `drag_onto` filters worktree roots out). The highlight should
// reflect this by suppressing every target instead of advertising root
// rows as valid drops.
#[gpui::test]
async fn test_highlight_entry_for_root_drag_suppressed_in_copy_mode(
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;
let project =
Project::test(fs.clone(), ["/root1".as_ref(), "/root2".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();
panel.update(cx, |panel, cx| {
let worktrees: Vec<_> = panel.project.read(cx).visible_worktrees(cx).collect();
let r1 = worktrees[0].read(cx);
let r2 = worktrees[1].read(cx);
let r1_root = r1.root_entry().unwrap();
let r2_root = r2.root_entry().unwrap();
let drag = DraggedSelection {
active_selection: SelectedEntry {
worktree_id: r1.id(),
entry_id: r1_root.id,
},
marked_selections: Arc::new([SelectedEntry {
worktree_id: r1.id(),
entry_id: r1_root.id,
}]),
};
// Sanity: without the copy modifier, hovering r2's root highlights it.
assert_eq!(
panel.highlight_entry_for_selection_drag(r2_root, r2, &drag, false, cx),
Some(r2_root.id),
"non-copy root drag should highlight a different worktree's root"
);
// With the copy modifier, no target highlights — copy can't act on roots.
assert_eq!(
panel.highlight_entry_for_selection_drag(r2_root, r2, &drag, true, cx),
None,
"copy-mode root drag should suppress highlights on other roots"
);
assert_eq!(
panel.highlight_entry_for_selection_drag(r1_root, r1, &drag, true, cx),
None,
"copy-mode root drag should suppress highlights on its own worktree"
);
});
}
#[gpui::test]
async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::TestAppContext) {
init_test(cx);