mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
Compare commits
23 commits
fb29089b52
...
4e46604ab3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e46604ab3 | ||
|
|
09165c15dc | ||
|
|
fe635735b6 | ||
|
|
f1b7b04fa3 | ||
|
|
bef2eb4814 | ||
|
|
239953eb42 | ||
|
|
28ac11f4be | ||
|
|
9a4b56edaa | ||
|
|
4874fd1761 | ||
|
|
465e096e15 | ||
|
|
0611b29559 | ||
|
|
48cfd34f0c | ||
|
|
e495df4172 | ||
|
|
15117d76dc | ||
|
|
f0c7ee837c | ||
|
|
017c649340 | ||
|
|
4faab9c17e | ||
|
|
d23a2ccf81 | ||
|
|
a8d9c97dc4 | ||
|
|
214d929281 | ||
|
|
0388bc8c74 | ||
|
|
adae3985eb | ||
|
|
2bd5879345 |
7 changed files with 1654 additions and 85 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
.move_worktree(worktree_id, destination_id, cx)
|
||||
.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_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,9 +3764,9 @@ impl ProjectPanel {
|
|||
}
|
||||
|
||||
let project = self.project.read(cx);
|
||||
let entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> = entries
|
||||
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
|
||||
|
|
@ -3723,11 +3775,14 @@ impl ProjectPanel {
|
|||
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
Loading…
Reference in a new issue