Refresh worktree-drag highlights on modifier change and in copy mode

- `should_highlight_background_for_selection_drag` now takes
  `is_copy_mode` and returns false for pure-root drags in copy mode,
  matching the existing per-row highlight logic. The blank-area
  on_drag_move handler reads the modifier and threads it through.
- The on_modifiers_changed listener now also clears the cached drag
  target so the next drag-move event recomputes the highlight under
  the new mode. Without this, the on_drag_move fast path skipped the
  recomputation while the pointer stayed put on the same row.
- Extracted the modifier-changed logic into
  `ProjectPanel::handle_drag_modifiers_changed` so it can be tested
  directly.
- Adds tests for both fixes.
This commit is contained in:
eth0net 2026-05-06 10:22:24 +01:00
parent 48cfd34f0c
commit 0611b29559
No known key found for this signature in database
2 changed files with 151 additions and 12 deletions

View file

@ -4467,6 +4467,22 @@ impl ProjectPanel {
.detach();
}
fn handle_drag_modifiers_changed(
&mut self,
modifiers: &Modifiers,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.refresh_drag_cursor_style(modifiers, window, cx);
// The copy modifier flips highlight semantics for worktree-root
// drags. Drop the cached target so the next drag-move event
// recomputes under the new mode — otherwise the on_drag_move
// fast path skips the recomputation while the pointer stays put.
if self.drag_target_entry.take().is_some() {
cx.notify();
}
}
fn refresh_drag_cursor_style(
&self,
modifiers: &Modifiers,
@ -5342,19 +5358,28 @@ impl ProjectPanel {
&self,
drag_state: &DraggedSelection,
last_root_id: ProjectEntryId,
is_copy_mode: bool,
cx: &App,
) -> bool {
let project = self.project.read(cx);
// Worktree roots can't be copied, so a pure-root copy drag is a
// guaranteed no-op — don't advertise the background as a target.
if is_copy_mode
&& drag_state
.items()
.all(|entry| project.entry_is_worktree_root(entry.entry_id, cx))
{
return false;
}
// Always highlight for multiple entries
if drag_state.items().count() > 1 {
return true;
}
// Since root will always have empty relative path
if let Some(entry_path) = self
.project
.read(cx)
.path_for_entry(drag_state.active_selection.entry_id, cx)
{
if let Some(entry_path) = project.path_for_entry(drag_state.active_selection.entry_id, cx) {
if let Some(parent_path) = entry_path.path.parent() {
if !parent_path.is_empty() {
return true;
@ -5363,11 +5388,7 @@ impl ProjectPanel {
}
// If parent is empty, check if different worktree
if let Some(last_root_worktree_id) = self
.project
.read(cx)
.worktree_id_for_entry(last_root_id, cx)
{
if let Some(last_root_worktree_id) = project.worktree_id_for_entry(last_root_id, cx) {
if drag_state.active_selection.worktree_id != last_root_worktree_id {
return true;
}
@ -6751,7 +6772,7 @@ impl Render for ProjectPanel {
.relative()
.on_modifiers_changed(cx.listener(
|this, event: &ModifiersChangedEvent, window, cx| {
this.refresh_drag_cursor_style(&event.modifiers, window, cx);
this.handle_drag_modifiers_changed(&event.modifiers, window, cx);
},
))
.key_context(self.dispatch_context(window, cx))
@ -7094,16 +7115,23 @@ impl Render for ProjectPanel {
},
))
.on_drag_move::<DraggedSelection>(cx.listener(
move |this, event: &DragMoveEvent<DraggedSelection>, _, cx| {
move |this,
event: &DragMoveEvent<DraggedSelection>,
window,
cx| {
let Some(last_root_id) = this.state.last_worktree_root_id
else {
return;
};
if event.bounds.contains(&event.event.position) {
let drag_state = event.drag(cx);
let is_copy_mode = Self::is_copy_modifier_set(
&window.modifiers(),
);
if this.should_highlight_background_for_selection_drag(
&drag_state,
last_root_id,
is_copy_mode,
cx,
) {
this.drag_target_entry =

View file

@ -9480,6 +9480,7 @@ async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::Test
let result = panel.should_highlight_background_for_selection_drag(
&multiple_dragged_selection,
root1_entry.id,
false,
cx,
);
assert!(result, "Should highlight background for multiple entries");
@ -9499,6 +9500,7 @@ async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::Test
let result = panel.should_highlight_background_for_selection_drag(
&nested_dragged_selection,
root1_entry.id,
false,
cx,
);
assert!(result, "Should highlight background for nested file");
@ -9518,6 +9520,7 @@ async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::Test
let result = panel.should_highlight_background_for_selection_drag(
&root_file_dragged_selection,
root1_entry.id,
false,
cx,
);
assert!(
@ -9529,6 +9532,7 @@ async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::Test
let result = panel.should_highlight_background_for_selection_drag(
&root_file_dragged_selection,
root2_entry.id,
false,
cx,
);
assert!(
@ -9551,6 +9555,7 @@ async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::Test
let result = panel.should_highlight_background_for_selection_drag(
&child_file_dragged_selection,
root1_entry.id,
false,
cx,
);
assert!(
@ -9560,6 +9565,112 @@ async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::Test
});
}
// In copy mode, a pure worktree-root drag is a no-op, so the blank area
// below the panel should not light up as a valid drop target.
#[gpui::test]
async fn test_should_highlight_background_suppressed_for_root_drag_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_id = r1.root_entry().unwrap().id;
let r2_root_id = r2.root_entry().unwrap().id;
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: in non-copy mode, the background highlights for a cross-
// worktree root drag (so the user can drop at the end).
assert!(
panel.should_highlight_background_for_selection_drag(
&drag,
r2_root_id,
false,
cx,
),
"non-copy root drag should highlight background"
);
// In copy mode, the same drag is a no-op → no background highlight.
assert!(
!panel.should_highlight_background_for_selection_drag(
&drag,
r2_root_id,
true,
cx,
),
"copy-mode root drag should suppress background highlight"
);
});
}
// Toggling the copy modifier while the cursor stays still must invalidate
// the cached drag target so the next drag-move event recomputes the
// highlight under the new mode (otherwise the row stays in its old
// highlight state until the pointer leaves and re-enters).
#[gpui::test]
async fn test_modifier_change_clears_drag_target_entry(
cx: &mut gpui::TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root1", json!({ "a.txt": "" })).await;
let project = Project::test(fs.clone(), ["/root1".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_in(cx, |panel, window, cx| {
panel.drag_target_entry = Some(DragTarget::Background);
panel.handle_drag_modifiers_changed(
&gpui::Modifiers {
alt: true,
control: true,
..Default::default()
},
window,
cx,
);
assert!(
panel.drag_target_entry.is_none(),
"modifier change should clear drag_target_entry so the next \
drag-move can recompute highlights under the new mode"
);
});
}
#[gpui::test]
async fn test_hide_root(cx: &mut gpui::TestAppContext) {
init_test(cx);