Compare commits

...

23 commits

Author SHA1 Message Date
Elliot Thomas
4e46604ab3
Merge fe635735b6 into 09165c15dc 2026-05-31 10:33:04 +01:00
Nathan Sobo
09165c15dc
gpui: Support prompt_for_paths in TestPlatform (#58139)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Has been skipped
Implements the previously-`unimplemented!()`
`TestPlatform::prompt_for_paths` so tests can drive the platform Open
dialog deterministically.

Adds `TestAppContext::simulate_path_prompt_response` and
`did_prompt_for_paths`, mirroring the existing `prompt_for_new_path`
test helpers (`simulate_new_path_selection`). The simulated response
validates that callers don't return multiple paths when
`PathPromptOptions::multiple` is false.

Release Notes:

- N/A
2026-05-30 20:37:39 +00:00
Elliot Thomas
fe635735b6
Merge branch 'main' into fix-worktree-drag-reorder 2026-05-22 12:30:51 +01:00
Elliot Thomas
f1b7b04fa3
Merge branch 'main' into fix-worktree-drag-reorder 2026-05-18 14:06:05 +01:00
Elliot Thomas
bef2eb4814
Merge branch 'main' into fix-worktree-drag-reorder 2026-05-15 11:30:35 +01:00
Elliot Thomas
239953eb42
Merge branch 'main' into fix-worktree-drag-reorder 2026-05-11 11:45:59 +01:00
Elliot Thomas
28ac11f4be
Merge branch 'main' into fix-worktree-drag-reorder 2026-05-07 16:58:12 +01:00
Elliot Thomas
9a4b56edaa
Merge branch 'main' into fix-worktree-drag-reorder 2026-05-07 09:20:10 +01:00
Elliot Thomas
4874fd1761
Merge branch 'main' into fix-worktree-drag-reorder 2026-05-06 10:32:16 +01:00
eth0net
465e096e15
Tighten worktree drag-and-drop tests and docs
- Add `test_move_worktrees_to_end` to the project integration tests,
  covering non-contiguous reorder, the already-suffix no-op, single
  source, and the missing-source error path.
- Add `test_copy_drag_root_onto_blank_area_is_no_op` exercising the
  end-to-end behaviour through `drag_onto`.
- Trim the implementation-leaky doc on `Project::move_worktrees_to_end`
  and reflow the blank-area drop-handler comment.
2026-05-06 10:31:41 +01:00
eth0net
0611b29559
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.
2026-05-06 10:23:43 +01:00
Elliot Thomas
48cfd34f0c
Merge branch 'main' into fix-worktree-drag-reorder 2026-05-05 17:33:46 +01:00
eth0net
e495df4172
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`.
2026-05-05 17:32:11 +01:00
Elliot Thomas
15117d76dc
Merge branch 'main' into fix-worktree-drag-reorder 2026-05-05 17:09:25 +01:00
eth0net
f0c7ee837c
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.
2026-05-05 16:55:05 +01:00
Elliot Thomas
017c649340
Merge branch 'main' into fix-worktree-drag-reorder 2026-05-05 16:28:12 +01:00
eth0net
4faab9c17e
Address follow-up review feedback
- Multi-root drag highlight now suppresses every dragged worktree, not
  just the active one. Hovering a marked-but-not-active root previously
  showed a misleading highlight even though `move_worktrees` would
  treat that drop as a no-op.
- Update misleading inline comments in the worktree-reorder tests and
  in `reorder_worktree_roots`.
- Add `test_highlight_entry_for_multi_root_drag_excludes_marked_worktrees`.
2026-05-05 16:25:22 +01:00
Elliot Thomas
d23a2ccf81
Merge branch 'main' into fix-worktree-drag-reorder 2026-05-05 16:03:22 +01:00
eth0net
a8d9c97dc4
Address review feedback on worktree drag-and-drop
- `move_worktrees` now validates every source up front and errors on
  missing IDs, matching the original `move_worktree` contract instead
  of silently dropping unknown sources.
- A self-drop of a multi-root selection (dropping the group onto one
  of its own members) is now a no-op rather than reordering the
  remaining roots around it.
- The reorder path now calls `send_project_updates` so collaborators
  see the new worktree order.
- `disjoint_entries` skips the worktree root when building `dir_paths`,
  so a marked root no longer silently filters out its own selected
  descendants before the move/copy branches see them.
- The drag-over highlight only restricts itself to worktree-root
  targets when the entire drag is roots-only; mixed drags continue to
  highlight directory targets so the file portion still gets feedback.
- Adds regression tests: invalid-source error, multi-root self-drop
  no-op, and a marked-root + nested-file drag that exercises both the
  reorder and per-entry move paths.
2026-05-05 15:58:01 +01:00
Elliot Thomas
214d929281
Merge branch 'main' into fix-worktree-drag-reorder 2026-05-05 15:10:16 +01:00
eth0net
0388bc8c74
Tighten worktree root drag-and-drop semantics
- Reorder marked worktree roots together as a group, preserving their
  relative order; direction follows the active source's position.
- Reject worktree-root drops onto nested entries and suppress the
  drag-over highlight for invalid targets, so the operation matches
  the visual feedback.
- Filter worktree roots from copy-mode drags so a mixed selection
  still copies its non-root entries instead of being silently
  cancelled by the `?` in `create_paste_path`.
- Replace the per-entry root-routing in drag_onto's move loop with
  a partition + batch reorder, removing the now-dead `move_entry`
  and `move_worktree_root` wrappers.
- Add regression tests for multi-root group reorder, nested-target
  rejection, copy-mode mixed selections, and mixed root+file drags
  with a non-root active selection.
2026-05-05 15:07:49 +01:00
Elliot Thomas
adae3985eb
Merge branch 'main' into fix-worktree-drag-reorder 2026-05-05 11:58:49 +01:00
eth0net
2bd5879345
Fix drag and drop to reorder worktrees
This functionality was lost previously due to a change filtering out
worktree roots from drag and drop events. This change restores the drag
and drop behaviour by moving the filter out of disjoint_entries and into
disjoint_effective_entries.
2026-05-05 11:52:17 +01:00
7 changed files with 1654 additions and 85 deletions

View file

@ -336,6 +336,20 @@ impl TestAppContext {
self.test_platform.simulate_new_path_selection(select_path);
}
/// Simulates responding to a `prompt_for_paths` ("Open") dialog.
pub fn simulate_path_prompt_response(
&self,
select_paths: impl FnOnce(&crate::PathPromptOptions) -> Option<Vec<std::path::PathBuf>>,
) {
self.test_platform
.simulate_path_prompt_response(select_paths);
}
/// Returns true if there's a path selection dialog pending.
pub fn did_prompt_for_paths(&self) -> bool {
self.test_platform.did_prompt_for_paths()
}
/// Simulates clicking a button in an platform-level alert dialog.
#[track_caller]
pub fn simulate_prompt_answer(&self, button: &str) {
@ -1098,3 +1112,54 @@ impl AnyWindowHandle {
.unwrap()
}
}
#[cfg(test)]
mod tests {
use crate::{PathPromptOptions, TestAppContext};
use std::path::PathBuf;
#[gpui::test]
async fn test_simulate_path_prompt_response(cx: &mut TestAppContext) {
assert!(!cx.did_prompt_for_paths());
let receiver = cx.update(|cx| {
cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: true,
prompt: None,
})
});
assert!(cx.did_prompt_for_paths());
let selected = vec![PathBuf::from("/a"), PathBuf::from("/b")];
cx.simulate_path_prompt_response({
let selected = selected.clone();
move |options| {
assert!(options.multiple);
Some(selected)
}
});
assert!(!cx.did_prompt_for_paths());
let response = receiver.await.unwrap().unwrap();
assert_eq!(response, Some(selected));
}
#[gpui::test]
async fn test_simulate_path_prompt_cancellation(cx: &mut TestAppContext) {
let receiver = cx.update(|cx| {
cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
})
});
cx.simulate_path_prompt_response(|_options| None);
let response = receiver.await.unwrap().unwrap();
assert_eq!(response, None);
}
}

View file

@ -1,9 +1,10 @@
use crate::{
AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels,
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay,
PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata,
Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size,
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, PathPromptOptions, Platform,
PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper,
PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream,
SourceMetadata, Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams,
size,
};
use anyhow::Result;
use collections::VecDeque;
@ -85,6 +86,10 @@ struct TestPrompt {
pub(crate) struct TestPrompts {
multiple_choice: VecDeque<TestPrompt>,
new_path: VecDeque<(PathBuf, oneshot::Sender<Result<Option<PathBuf>>>)>,
paths: VecDeque<(
PathPromptOptions,
oneshot::Sender<Result<Option<Vec<PathBuf>>>>,
)>,
}
impl TestPlatform {
@ -147,6 +152,33 @@ impl TestPlatform {
tx.send(Ok(select_path(&path))).ok();
}
pub(crate) fn simulate_path_prompt_response(
&self,
select_paths: impl FnOnce(&PathPromptOptions) -> Option<Vec<std::path::PathBuf>>,
) {
let (options, tx) = self
.prompts
.borrow_mut()
.paths
.pop_front()
.expect("no pending paths prompt");
let selection = select_paths(&options);
if let Some(paths) = &selection
&& !options.multiple
&& paths.len() > 1
{
panic!(
"selected {} paths for a prompt that does not allow multiple selection",
paths.len()
);
}
tx.send(Ok(selection)).ok();
}
pub(crate) fn did_prompt_for_paths(&self) -> bool {
!self.prompts.borrow().paths.is_empty()
}
#[track_caller]
pub(crate) fn simulate_prompt_answer(&self, response: &str) {
let prompt = self
@ -348,9 +380,11 @@ impl Platform for TestPlatform {
fn prompt_for_paths(
&self,
_options: crate::PathPromptOptions,
options: crate::PathPromptOptions,
) -> oneshot::Receiver<Result<Option<Vec<std::path::PathBuf>>>> {
unimplemented!()
let (tx, rx) = oneshot::channel();
self.prompts.borrow_mut().paths.push_back((options, tx));
rx
}
fn prompt_for_new_path(

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.
pub fn try_windows_path_to_wsl(
&self,

View file

@ -1038,42 +1038,159 @@ impl WorktreeStore {
destination: WorktreeId,
cx: &mut Context<Self>,
) -> 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(());
}
let mut source_index = None;
let mut destination_index = None;
for (i, worktree) in self.worktrees.iter().enumerate() {
if let Some(worktree) = worktree.upgrade() {
let worktree_id = worktree.read(cx).id();
if worktree_id == source {
source_index = Some(i);
if destination_index.is_some() {
break;
}
} else if worktree_id == destination {
destination_index = Some(i);
if source_index.is_some() {
break;
}
}
let destination_index = self
.worktrees
.iter()
.position(|wt| {
wt.upgrade()
.is_some_and(|wt| wt.read(cx).id() == destination)
})
.with_context(|| format!("Missing worktree for id {destination}"))?;
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_index =
source_index.with_context(|| format!("Missing worktree for id {source}"))?;
let destination_index =
destination_index.with_context(|| format!("Missing worktree for id {destination}"))?;
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_index == destination_index {
if source_indices.is_empty() {
return Ok(());
}
let worktree_to_move = self.worktrees.remove(source_index);
self.worktrees.insert(destination_index, worktree_to_move);
let direction_index = active_source
.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.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(())
}

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]
async fn test_unstaged_diff_for_buffer(cx: &mut gpui::TestAppContext) {
init_test(cx);

View file

@ -3596,48 +3596,94 @@ impl ProjectPanel {
}
}
fn move_entry(
fn reorder_worktree_roots(
&mut self,
entry_to_move: 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,
source_entries: &[ProjectEntryId],
destination: ProjectEntryId,
active_entry_id: ProjectEntryId,
cx: &mut Context<Self>,
) {
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;
};
}
let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
return;
};
let worktree_id = worktree_to_move.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
.move_worktree(worktree_id, destination_id, cx)
.move_worktrees(&source_ids, destination_id, active_source, cx)
.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(
&mut self,
entry_to_move: ProjectEntryId,
@ -3698,7 +3744,13 @@ impl ProjectPanel {
}
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(
@ -3712,22 +3764,25 @@ impl ProjectPanel {
}
let project = self.project.read(cx);
let entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> = entries
.into_iter()
.filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
.fold(HashMap::default(), |mut map, entry| {
map.entry(entry.worktree_id).or_default().push(entry);
map
});
let entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> =
entries
.into_iter()
.fold(HashMap::default(), |mut map, entry| {
map.entry(entry.worktree_id).or_default().push(entry);
map
});
for (worktree_id, worktree_entries) in entries_by_worktree {
if let Some(worktree) = project.worktree_for_id(worktree_id, 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
.iter()
.filter_map(|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())
} else {
None
@ -4420,6 +4475,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,
@ -4461,6 +4532,18 @@ impl ProjectPanel {
let entries = self.disjoint_entries(resolved_selections, cx);
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 project = self.project.read(cx);
let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
@ -4521,6 +4604,27 @@ impl ProjectPanel {
} else {
let update_marks = !self.marked_entries.is_empty();
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
// entry so we can refresh it after the move completes.
@ -4577,7 +4681,9 @@ impl ProjectPanel {
// results with folded selections that need refreshing.
let mut move_tasks: Vec<(ProjectEntryId, Task<Result<CreatedEntry>>)> = Vec::new();
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));
}
}
@ -5191,8 +5297,36 @@ 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
// 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();
// In case of single item drag, we do not highlight existing
@ -5232,19 +5366,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;
@ -5253,11 +5396,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;
}
@ -5525,6 +5664,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
@ -5537,6 +5677,7 @@ impl ProjectPanel {
target_entry,
target_worktree,
drag_state,
is_copy_mode,
cx,
)?;
Some((target_entry.id, highlight_entry_id))
@ -6639,7 +6780,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))
@ -6982,16 +7123,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 =
@ -7025,7 +7173,20 @@ 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 {
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);
}
cx.stop_propagation();

File diff suppressed because it is too large Load diff