This commit is contained in:
Elliot Thomas 2026-05-31 10:33:04 +01:00 committed by GitHub
commit 4e46604ab3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1549 additions and 79 deletions

View file

@ -4625,6 +4625,32 @@ impl Project {
}) })
} }
/// Moves multiple worktrees as a group to `destination`'s position,
/// preserving their relative order.
pub fn move_worktrees(
&mut self,
sources: &[WorktreeId],
destination: WorktreeId,
active_source: Option<WorktreeId>,
cx: &mut Context<Self>,
) -> Result<()> {
self.worktree_store.update(cx, |worktree_store, cx| {
worktree_store.move_worktrees(sources, destination, active_source, cx)
})
}
/// Moves multiple worktrees to the end of the worktree list, preserving
/// their relative order.
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. /// 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( pub fn try_windows_path_to_wsl(
&self, &self,

View file

@ -1038,42 +1038,159 @@ impl WorktreeStore {
destination: WorktreeId, destination: WorktreeId,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Result<()> { ) -> Result<()> {
if source == destination { self.move_worktrees(&[source], destination, Some(source), cx)
}
/// Moves multiple worktrees as a group to `destination`'s position,
/// 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 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],
destination: WorktreeId,
active_source: Option<WorktreeId>,
cx: &mut Context<Self>,
) -> Result<()> {
if sources.is_empty() {
return Ok(());
}
// Self-drop of any selection member is a no-op: the user dropping a
// multi-selection onto one of its own roots has no well-defined
// intent.
if sources.contains(&destination) {
return Ok(()); return Ok(());
} }
let mut source_index = None; let destination_index = self
let mut destination_index = None; .worktrees
for (i, worktree) in self.worktrees.iter().enumerate() { .iter()
if let Some(worktree) = worktree.upgrade() { .position(|wt| {
let worktree_id = worktree.read(cx).id(); wt.upgrade()
if worktree_id == source { .is_some_and(|wt| wt.read(cx).id() == destination)
source_index = Some(i); })
if destination_index.is_some() { .with_context(|| format!("Missing worktree for id {destination}"))?;
break;
} for &source in sources {
} else if worktree_id == destination { if !self
destination_index = Some(i); .worktrees
if source_index.is_some() { .iter()
break; .any(|wt| wt.upgrade().is_some_and(|wt| wt.read(cx).id() == source))
} {
} anyhow::bail!("Missing worktree for id {source}");
} }
} }
let source_index = let source_indices: Vec<usize> = self
source_index.with_context(|| format!("Missing worktree for id {source}"))?; .worktrees
let destination_index = .iter()
destination_index.with_context(|| format!("Missing worktree for id {destination}"))?; .enumerate()
.filter_map(|(i, wt)| {
let id = wt.upgrade()?.read(cx).id();
sources.contains(&id).then_some(i)
})
.collect();
if source_index == destination_index { if source_indices.is_empty() {
return Ok(()); return Ok(());
} }
let worktree_to_move = self.worktrees.remove(source_index); let direction_index = active_source
self.worktrees.insert(destination_index, worktree_to_move); .filter(|id| sources.contains(id))
.and_then(|id| {
self.worktrees
.iter()
.position(|wt| wt.upgrade().is_some_and(|wt| wt.read(cx).id() == id))
})
.unwrap_or(source_indices[0]);
let insert_after_destination = direction_index < destination_index;
let mut to_insert = Vec::with_capacity(source_indices.len());
for &i in source_indices.iter().rev() {
to_insert.push(self.worktrees.remove(i));
}
to_insert.reverse();
let removed_before_destination = source_indices
.iter()
.filter(|&&i| i < destination_index)
.count();
let new_destination_index = destination_index - removed_before_destination;
let insert_at = if insert_after_destination {
new_destination_index + 1
} else {
new_destination_index
};
for (offset, handle) in to_insert.into_iter().enumerate() {
self.worktrees.insert(insert_at + offset, handle);
}
cx.emit(WorktreeStoreEvent::WorktreeOrderChanged); cx.emit(WorktreeStoreEvent::WorktreeOrderChanged);
cx.notify(); cx.notify();
self.send_project_updates(cx);
Ok(())
}
/// Removes every source from the worktree list and appends them to the
/// 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],
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(()) Ok(())
} }

View file

@ -9019,6 +9019,130 @@ async fn test_reordering_worktrees(cx: &mut gpui::TestAppContext) {
}); });
} }
#[gpui::test]
async fn test_move_worktrees_to_end(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/dir",
json!({
"a.rs": "",
"b.rs": "",
"c.rs": "",
"d.rs": "",
}),
)
.await;
let project = Project::test(
fs,
[
"/dir/a.rs".as_ref(),
"/dir/b.rs".as_ref(),
"/dir/c.rs".as_ref(),
"/dir/d.rs".as_ref(),
],
cx,
)
.await;
let (a_id, b_id, c_id, d_id) = project.update(cx, |project, cx| {
let worktrees = project.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(),
)
});
// Non-contiguous selection [a, c] → group lands at end as [b, d, a, c].
project
.update(cx, |project, cx| {
project.move_worktrees_to_end(&[a_id, c_id], cx)
})
.expect("moving non-contiguous group to end");
project.update(cx, |project, cx| {
let order: Vec<_> = project
.visible_worktrees(cx)
.map(|wt| wt.read(cx).id())
.collect();
assert_eq!(order, vec![b_id, d_id, a_id, c_id]);
});
// Already-suffix selection [a, c] → no-op (current order is [b, d, a, c]).
project
.update(cx, |project, cx| {
project.move_worktrees_to_end(&[a_id, c_id], cx)
})
.expect("contiguous-suffix call should still succeed");
project.update(cx, |project, cx| {
let order: Vec<_> = project
.visible_worktrees(cx)
.map(|wt| wt.read(cx).id())
.collect();
assert_eq!(order, vec![b_id, d_id, a_id, c_id]);
});
// Single source [b] → moves to end: [d, a, c, b].
project
.update(cx, |project, cx| {
project.move_worktrees_to_end(&[b_id], cx)
})
.expect("moving a single source to end");
project.update(cx, |project, cx| {
let order: Vec<_> = project
.visible_worktrees(cx)
.map(|wt| wt.read(cx).id())
.collect();
assert_eq!(order, vec![d_id, a_id, c_id, b_id]);
});
// Invalid source id → error.
project.update(cx, |project, cx| {
let invalid = worktree::WorktreeId::from_usize(99_999);
assert!(
project.move_worktrees_to_end(&[invalid], cx).is_err(),
"moving an unknown source should error"
);
});
}
#[gpui::test]
async fn test_move_worktree_with_invalid_source_errors(cx: &mut gpui::TestAppContext) {
use worktree::WorktreeId;
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/dir",
json!({
"a.rs": "",
"b.rs": "",
}),
)
.await;
let project =
Project::test(fs, ["/dir/a.rs".as_ref(), "/dir/b.rs".as_ref()], cx).await;
project.update(cx, |project, cx| {
let valid_id = project.visible_worktrees(cx).next().unwrap().read(cx).id();
let invalid_id = WorktreeId::from_usize(99_999);
assert!(
project.move_worktree(invalid_id, valid_id, cx).is_err(),
"moving an unknown source worktree should error"
);
assert!(
project.move_worktree(valid_id, invalid_id, cx).is_err(),
"moving onto an unknown destination should error"
);
});
}
#[gpui::test] #[gpui::test]
async fn test_unstaged_diff_for_buffer(cx: &mut gpui::TestAppContext) { async fn test_unstaged_diff_for_buffer(cx: &mut gpui::TestAppContext) {
init_test(cx); init_test(cx);

View file

@ -3596,48 +3596,94 @@ impl ProjectPanel {
} }
} }
fn move_entry( fn reorder_worktree_roots(
&mut self, &mut self,
entry_to_move: ProjectEntryId, source_entries: &[ProjectEntryId],
destination: ProjectEntryId,
destination_is_file: bool,
cx: &mut Context<Self>,
) -> Option<Task<Result<CreatedEntry>>> {
if self
.project
.read(cx)
.entry_is_worktree_root(entry_to_move, cx)
{
self.move_worktree_root(entry_to_move, destination, cx);
None
} else {
self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
}
}
fn move_worktree_root(
&mut self,
entry_to_move: ProjectEntryId,
destination: ProjectEntryId, destination: ProjectEntryId,
active_entry_id: ProjectEntryId,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.project.update(cx, |project, cx| { self.project.update(cx, |project, cx| {
let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else { // Only reorder when the destination resolves to a worktree root.
// Nested entries are rejected; the empty area below the panel
// resolves to the last worktree's root, which still satisfies
// this check.
if !project.entry_is_worktree_root(destination, cx) {
return; return;
}; }
let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else { let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
return; return;
}; };
let worktree_id = worktree_to_move.read(cx).id();
let destination_id = destination_worktree.read(cx).id(); let destination_id = destination_worktree.read(cx).id();
let source_ids: Vec<WorktreeId> = source_entries
.iter()
.filter_map(|entry_id| {
project
.worktree_for_entry(*entry_id, cx)
.map(|wt| wt.read(cx).id())
})
.collect();
if source_ids.is_empty() {
return;
}
let active_source = project
.worktree_for_entry(active_entry_id, cx)
.map(|wt| wt.read(cx).id());
project project
.move_worktree(worktree_id, destination_id, cx) .move_worktrees(&source_ids, destination_id, active_source, cx)
.log_err(); .log_err();
}); });
} }
/// 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( fn move_worktree_entry(
&mut self, &mut self,
entry_to_move: ProjectEntryId, entry_to_move: ProjectEntryId,
@ -3698,7 +3744,13 @@ impl ProjectPanel {
} }
fn disjoint_effective_entries(&self, cx: &App) -> BTreeSet<SelectedEntry> { fn disjoint_effective_entries(&self, cx: &App) -> BTreeSet<SelectedEntry> {
self.disjoint_entries(self.effective_entries(), cx) let project = self.project.read(cx);
let entries = self
.effective_entries()
.into_iter()
.filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
.collect();
self.disjoint_entries(entries, cx)
} }
fn disjoint_entries( fn disjoint_entries(
@ -3712,22 +3764,25 @@ impl ProjectPanel {
} }
let project = self.project.read(cx); let project = self.project.read(cx);
let entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> = entries let entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> =
.into_iter() entries
.filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx)) .into_iter()
.fold(HashMap::default(), |mut map, entry| { .fold(HashMap::default(), |mut map, entry| {
map.entry(entry.worktree_id).or_default().push(entry); map.entry(entry.worktree_id).or_default().push(entry);
map map
}); });
for (worktree_id, worktree_entries) in entries_by_worktree { for (worktree_id, worktree_entries) in entries_by_worktree {
if let Some(worktree) = project.worktree_for_id(worktree_id, cx) { if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
let worktree = worktree.read(cx); let worktree = worktree.read(cx);
// Skip the worktree root: its empty path would consume every
// other selected entry in the same worktree as "nested inside
// a selected directory" and silently drop them.
let dir_paths = worktree_entries let dir_paths = worktree_entries
.iter() .iter()
.filter_map(|entry| { .filter_map(|entry| {
worktree.entry_for_id(entry.entry_id).and_then(|entry| { worktree.entry_for_id(entry.entry_id).and_then(|entry| {
if entry.is_dir() { if entry.is_dir() && !entry.path.is_empty() {
Some(entry.path.as_ref()) Some(entry.path.as_ref())
} else { } else {
None None
@ -4420,6 +4475,22 @@ impl ProjectPanel {
.detach(); .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( fn refresh_drag_cursor_style(
&self, &self,
modifiers: &Modifiers, modifiers: &Modifiers,
@ -4461,6 +4532,18 @@ impl ProjectPanel {
let entries = self.disjoint_entries(resolved_selections, cx); let entries = self.disjoint_entries(resolved_selections, cx);
if Self::is_copy_modifier_set(&window.modifiers()) { if Self::is_copy_modifier_set(&window.modifiers()) {
// Worktree roots can't be copied — leaving them in would make
// `create_paste_path` return None and `?` would abort the whole copy.
let entries: BTreeSet<SelectedEntry> = {
let project = self.project.read(cx);
entries
.into_iter()
.filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
.collect()
};
if entries.is_empty() {
return;
}
let _ = maybe!({ let _ = maybe!({
let project = self.project.read(cx); let project = self.project.read(cx);
let target_worktree = project.worktree_for_entry(target_entry_id, cx)?; let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
@ -4521,6 +4604,27 @@ impl ProjectPanel {
} else { } else {
let update_marks = !self.marked_entries.is_empty(); let update_marks = !self.marked_entries.is_empty();
let active_selection = selections.active_selection; let active_selection = selections.active_selection;
let active_entry_id = self.resolve_entry(active_selection.entry_id);
// Reorder marked worktree roots together so their relative order is
// preserved; non-roots fall through to the normal per-entry move flow.
let (root_entry_ids, entries) = {
let project = self.project.read(cx);
let mut roots = Vec::new();
let mut non_roots = BTreeSet::new();
for entry in entries {
if project.entry_is_worktree_root(entry.entry_id, cx) {
roots.push(entry.entry_id);
} else {
non_roots.insert(entry);
}
}
(roots, non_roots)
};
if !root_entry_ids.is_empty() {
self.reorder_worktree_roots(&root_entry_ids, target_entry_id, active_entry_id, cx);
}
// For folded selections, track the leaf suffix relative to the resolved // For folded selections, track the leaf suffix relative to the resolved
// entry so we can refresh it after the move completes. // entry so we can refresh it after the move completes.
@ -4577,7 +4681,9 @@ impl ProjectPanel {
// results with folded selections that need refreshing. // results with folded selections that need refreshing.
let mut move_tasks: Vec<(ProjectEntryId, Task<Result<CreatedEntry>>)> = Vec::new(); let mut move_tasks: Vec<(ProjectEntryId, Task<Result<CreatedEntry>>)> = Vec::new();
for entry in entries { for entry in entries {
if let Some(task) = self.move_entry(entry.entry_id, target_entry_id, is_file, cx) { if let Some(task) =
self.move_worktree_entry(entry.entry_id, target_entry_id, is_file, cx)
{
move_tasks.push((entry.entry_id, task)); move_tasks.push((entry.entry_id, task));
} }
} }
@ -5191,8 +5297,36 @@ impl ProjectPanel {
target_entry: &Entry, target_entry: &Entry,
target_worktree: &Worktree, target_worktree: &Worktree,
drag_state: &DraggedSelection, drag_state: &DraggedSelection,
is_copy_mode: bool,
cx: &Context<Self>, cx: &Context<Self>,
) -> Option<ProjectEntryId> { ) -> Option<ProjectEntryId> {
// Pure worktree-root drags are only meaningful when dropped on
// another worktree's root; suppress highlights elsewhere. Mixed drags
// (e.g. a root with a marked file) fall through so the file portion
// can still receive feedback on directory targets.
let project = self.project.read(cx);
let drag_is_root_only = drag_state
.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.
let target_worktree_id = target_worktree.id();
let target_in_drag = drag_state
.items()
.any(|entry| entry.worktree_id == target_worktree_id);
if target_entry.id == root_id && !target_in_drag {
return Some(root_id);
}
return None;
}
let target_parent_path = target_entry.path.parent(); let target_parent_path = target_entry.path.parent();
// In case of single item drag, we do not highlight existing // In case of single item drag, we do not highlight existing
@ -5232,19 +5366,28 @@ impl ProjectPanel {
&self, &self,
drag_state: &DraggedSelection, drag_state: &DraggedSelection,
last_root_id: ProjectEntryId, last_root_id: ProjectEntryId,
is_copy_mode: bool,
cx: &App, cx: &App,
) -> bool { ) -> 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 // Always highlight for multiple entries
if drag_state.items().count() > 1 { if drag_state.items().count() > 1 {
return true; return true;
} }
// Since root will always have empty relative path // Since root will always have empty relative path
if let Some(entry_path) = self if let Some(entry_path) = project.path_for_entry(drag_state.active_selection.entry_id, cx) {
.project
.read(cx)
.path_for_entry(drag_state.active_selection.entry_id, cx)
{
if let Some(parent_path) = entry_path.path.parent() { if let Some(parent_path) = entry_path.path.parent() {
if !parent_path.is_empty() { if !parent_path.is_empty() {
return true; return true;
@ -5253,11 +5396,7 @@ impl ProjectPanel {
} }
// If parent is empty, check if different worktree // If parent is empty, check if different worktree
if let Some(last_root_worktree_id) = self if let Some(last_root_worktree_id) = project.worktree_id_for_entry(last_root_id, cx) {
.project
.read(cx)
.worktree_id_for_entry(last_root_id, cx)
{
if drag_state.active_selection.worktree_id != last_root_worktree_id { if drag_state.active_selection.worktree_id != last_root_worktree_id {
return true; return true;
} }
@ -5525,6 +5664,7 @@ impl ProjectPanel {
this.marked_entries.push(drag_state.active_selection); 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 Some((entry_id, highlight_entry_id)) = maybe!({
let target_worktree = this let target_worktree = this
.project .project
@ -5537,6 +5677,7 @@ impl ProjectPanel {
target_entry, target_entry,
target_worktree, target_worktree,
drag_state, drag_state,
is_copy_mode,
cx, cx,
)?; )?;
Some((target_entry.id, highlight_entry_id)) Some((target_entry.id, highlight_entry_id))
@ -6639,7 +6780,7 @@ impl Render for ProjectPanel {
.relative() .relative()
.on_modifiers_changed(cx.listener( .on_modifiers_changed(cx.listener(
|this, event: &ModifiersChangedEvent, window, cx| { |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)) .key_context(self.dispatch_context(window, cx))
@ -6982,16 +7123,23 @@ impl Render for ProjectPanel {
}, },
)) ))
.on_drag_move::<DraggedSelection>(cx.listener( .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 let Some(last_root_id) = this.state.last_worktree_root_id
else { else {
return; return;
}; };
if event.bounds.contains(&event.event.position) { if event.bounds.contains(&event.event.position) {
let drag_state = event.drag(cx); 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( if this.should_highlight_background_for_selection_drag(
&drag_state, &drag_state,
last_root_id, last_root_id,
is_copy_mode,
cx, cx,
) { ) {
this.drag_target_entry = this.drag_target_entry =
@ -7025,7 +7173,20 @@ impl Render for ProjectPanel {
move |this, selections: &DraggedSelection, window, cx| { move |this, selections: &DraggedSelection, window, cx| {
this.drag_target_entry = None; this.drag_target_entry = None;
this.hover_scroll_task.take(); this.hover_scroll_task.take();
if let Some(entry_id) = this.state.last_worktree_root_id { let is_copy_mode =
Self::is_copy_modifier_set(&window.modifiers());
// For move drags whose root group includes the last
// worktree, route to the move-to-end path so we don't
// hit the self-drop guard in `move_worktrees`. Copy
// drags fall through to `drag_onto`, which will
// filter the roots out as a no-op.
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
{
this.drag_onto(selections, entry_id, false, window, cx); this.drag_onto(selections, entry_id, false, window, cx);
} }
cx.stop_propagation(); cx.stop_propagation();

File diff suppressed because it is too large Load diff