mirror of
https://github.com/zed-industries/zed.git
synced 2025-02-05 02:20:10 +00:00
Maintain selection on file/dir deletion in project panel (#20577)
Closes #20444 - Focus on next file/dir on deletion. - Focus on prev file/dir in case where it's last item in worktree. - Tested when multiple files/dirs are being deleted. Release Notes: - Maintain selection on file/dir deletion in project panel. --------- Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
parent
933c11a9b2
commit
114c462143
3 changed files with 813 additions and 21 deletions
|
@ -40,6 +40,7 @@ use serde::{Deserialize, Serialize};
|
|||
use smallvec::SmallVec;
|
||||
use std::{
|
||||
cell::OnceCell,
|
||||
cmp,
|
||||
collections::HashSet,
|
||||
ffi::OsStr,
|
||||
ops::Range,
|
||||
|
@ -53,7 +54,7 @@ use ui::{
|
|||
IndentGuideColors, IndentGuideLayout, KeyBinding, Label, ListItem, Scrollbar, ScrollbarState,
|
||||
Tooltip,
|
||||
};
|
||||
use util::{maybe, ResultExt, TryFutureExt};
|
||||
use util::{maybe, paths::compare_paths, ResultExt, TryFutureExt};
|
||||
use workspace::{
|
||||
dock::{DockPosition, Panel, PanelEvent},
|
||||
notifications::{DetachAndPromptErr, NotifyTaskExt},
|
||||
|
@ -550,7 +551,7 @@ impl ProjectPanel {
|
|||
.entry((project_path.worktree_id, path_buffer.clone()))
|
||||
.and_modify(|strongest_diagnostic_severity| {
|
||||
*strongest_diagnostic_severity =
|
||||
std::cmp::min(*strongest_diagnostic_severity, diagnostic_severity);
|
||||
cmp::min(*strongest_diagnostic_severity, diagnostic_severity);
|
||||
})
|
||||
.or_insert(diagnostic_severity);
|
||||
}
|
||||
|
@ -1184,15 +1185,15 @@ impl ProjectPanel {
|
|||
|
||||
fn remove(&mut self, trash: bool, skip_prompt: bool, cx: &mut ViewContext<'_, ProjectPanel>) {
|
||||
maybe!({
|
||||
if self.marked_entries.is_empty() && self.selection.is_none() {
|
||||
let items_to_delete = self.disjoint_entries_for_removal(cx);
|
||||
if items_to_delete.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let project = self.project.read(cx);
|
||||
let items_to_delete = self.marked_entries();
|
||||
|
||||
let mut dirty_buffers = 0;
|
||||
let file_paths = items_to_delete
|
||||
.into_iter()
|
||||
.iter()
|
||||
.filter_map(|selection| {
|
||||
let project_path = project.path_for_entry(selection.entry_id, cx)?;
|
||||
dirty_buffers +=
|
||||
|
@ -1261,28 +1262,120 @@ impl ProjectPanel {
|
|||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let next_selection = self.find_next_selection_after_deletion(items_to_delete, cx);
|
||||
cx.spawn(|panel, mut cx| async move {
|
||||
if let Some(answer) = answer {
|
||||
if answer.await != Ok(0) {
|
||||
return Result::<(), anyhow::Error>::Ok(());
|
||||
return anyhow::Ok(());
|
||||
}
|
||||
}
|
||||
for (entry_id, _) in file_paths {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.project
|
||||
.update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
|
||||
.ok_or_else(|| anyhow!("no such entry"))
|
||||
})??
|
||||
.await?;
|
||||
panel
|
||||
.update(&mut cx, |panel, cx| {
|
||||
panel
|
||||
.project
|
||||
.update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
|
||||
.context("no such entry")
|
||||
})??
|
||||
.await?;
|
||||
}
|
||||
Result::<(), anyhow::Error>::Ok(())
|
||||
panel.update(&mut cx, |panel, cx| {
|
||||
if let Some(next_selection) = next_selection {
|
||||
panel.selection = Some(next_selection);
|
||||
panel.autoscroll(cx);
|
||||
} else {
|
||||
panel.select_last(&SelectLast {}, cx);
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
Some(())
|
||||
});
|
||||
}
|
||||
|
||||
fn find_next_selection_after_deletion(
|
||||
&self,
|
||||
sanitized_entries: BTreeSet<SelectedEntry>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Option<SelectedEntry> {
|
||||
if sanitized_entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let project = self.project.read(cx);
|
||||
let (worktree_id, worktree) = sanitized_entries
|
||||
.iter()
|
||||
.map(|entry| entry.worktree_id)
|
||||
.filter_map(|id| project.worktree_for_id(id, cx).map(|w| (id, w.read(cx))))
|
||||
.max_by(|(_, a), (_, b)| a.root_name().cmp(b.root_name()))?;
|
||||
|
||||
let marked_entries_in_worktree = sanitized_entries
|
||||
.iter()
|
||||
.filter(|e| e.worktree_id == worktree_id)
|
||||
.collect::<HashSet<_>>();
|
||||
let latest_entry = marked_entries_in_worktree
|
||||
.iter()
|
||||
.max_by(|a, b| {
|
||||
match (
|
||||
worktree.entry_for_id(a.entry_id),
|
||||
worktree.entry_for_id(b.entry_id),
|
||||
) {
|
||||
(Some(a), Some(b)) => {
|
||||
compare_paths((&a.path, a.is_file()), (&b.path, b.is_file()))
|
||||
}
|
||||
_ => cmp::Ordering::Equal,
|
||||
}
|
||||
})
|
||||
.and_then(|e| worktree.entry_for_id(e.entry_id))?;
|
||||
|
||||
let parent_path = latest_entry.path.parent()?;
|
||||
let parent_entry = worktree.entry_for_path(parent_path)?;
|
||||
|
||||
// Remove all siblings that are being deleted except the last marked entry
|
||||
let mut siblings: Vec<Entry> = worktree
|
||||
.snapshot()
|
||||
.child_entries(parent_path)
|
||||
.filter(|sibling| {
|
||||
sibling.id == latest_entry.id
|
||||
|| !marked_entries_in_worktree.contains(&&SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: sibling.id,
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
project::sort_worktree_entries(&mut siblings);
|
||||
let sibling_entry_index = siblings
|
||||
.iter()
|
||||
.position(|sibling| sibling.id == latest_entry.id)?;
|
||||
|
||||
if let Some(next_sibling) = sibling_entry_index
|
||||
.checked_add(1)
|
||||
.and_then(|i| siblings.get(i))
|
||||
{
|
||||
return Some(SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: next_sibling.id,
|
||||
});
|
||||
}
|
||||
if let Some(prev_sibling) = sibling_entry_index
|
||||
.checked_sub(1)
|
||||
.and_then(|i| siblings.get(i))
|
||||
{
|
||||
return Some(SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: prev_sibling.id,
|
||||
});
|
||||
}
|
||||
// No neighbour sibling found, fall back to parent
|
||||
Some(SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: parent_entry.id,
|
||||
})
|
||||
}
|
||||
|
||||
fn unfold_directory(&mut self, _: &UnfoldDirectory, cx: &mut ViewContext<Self>) {
|
||||
if let Some((worktree, entry)) = self.selected_entry(cx) {
|
||||
self.unfolded_dir_ids.insert(entry.id);
|
||||
|
@ -1835,6 +1928,54 @@ impl ProjectPanel {
|
|||
None
|
||||
}
|
||||
|
||||
fn disjoint_entries_for_removal(&self, cx: &AppContext) -> BTreeSet<SelectedEntry> {
|
||||
let marked_entries = self.marked_entries();
|
||||
let mut sanitized_entries = BTreeSet::new();
|
||||
if marked_entries.is_empty() {
|
||||
return sanitized_entries;
|
||||
}
|
||||
|
||||
let project = self.project.read(cx);
|
||||
let marked_entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> = marked_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
|
||||
});
|
||||
|
||||
for (worktree_id, marked_entries) in marked_entries_by_worktree {
|
||||
if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
|
||||
let worktree = worktree.read(cx);
|
||||
let marked_dir_paths = marked_entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
worktree.entry_for_id(entry.entry_id).and_then(|entry| {
|
||||
if entry.is_dir() {
|
||||
Some(entry.path.as_ref())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
sanitized_entries.extend(marked_entries.into_iter().filter(|entry| {
|
||||
let Some(entry_info) = worktree.entry_for_id(entry.entry_id) else {
|
||||
return false;
|
||||
};
|
||||
let entry_path = entry_info.path.as_ref();
|
||||
let inside_marked_dir = marked_dir_paths.iter().any(|&marked_dir_path| {
|
||||
entry_path != marked_dir_path && entry_path.starts_with(marked_dir_path)
|
||||
});
|
||||
!inside_marked_dir
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
sanitized_entries
|
||||
}
|
||||
|
||||
// Returns list of entries that should be affected by an operation.
|
||||
// When currently selected entry is not marked, it's treated as the only marked entry.
|
||||
fn marked_entries(&self) -> BTreeSet<SelectedEntry> {
|
||||
|
@ -5080,14 +5221,13 @@ mod tests {
|
|||
&[
|
||||
"v src",
|
||||
" v test",
|
||||
" second.rs",
|
||||
" second.rs <== selected",
|
||||
" third.rs"
|
||||
],
|
||||
"Project panel should have no deleted file, no other file is selected in it"
|
||||
);
|
||||
ensure_no_open_items_and_panes(&workspace, cx);
|
||||
|
||||
select_path(&panel, "src/test/second.rs", cx);
|
||||
panel.update(cx, |panel, cx| panel.open(&Open, cx));
|
||||
cx.executor().run_until_parked();
|
||||
assert_eq!(
|
||||
|
@ -5121,7 +5261,7 @@ mod tests {
|
|||
submit_deletion_skipping_prompt(&panel, cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..10, cx),
|
||||
&["v src", " v test", " third.rs"],
|
||||
&["v src", " v test", " third.rs <== selected"],
|
||||
"Project panel should have no deleted file, with one last file remaining"
|
||||
);
|
||||
ensure_no_open_items_and_panes(&workspace, cx);
|
||||
|
@ -5630,7 +5770,11 @@ mod tests {
|
|||
submit_deletion(&panel, cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..10, cx),
|
||||
&["v project_root", " v dir_1", " v nested_dir",]
|
||||
&[
|
||||
"v project_root",
|
||||
" v dir_1",
|
||||
" v nested_dir <== selected",
|
||||
]
|
||||
);
|
||||
}
|
||||
#[gpui::test]
|
||||
|
@ -6327,6 +6471,598 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
|
||||
init_test_with_editor(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor().clone());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"dir1": {
|
||||
"subdir1": {},
|
||||
"file1.txt": "",
|
||||
"file2.txt": "",
|
||||
},
|
||||
"dir2": {
|
||||
"subdir2": {},
|
||||
"file3.txt": "",
|
||||
"file4.txt": "",
|
||||
},
|
||||
"file5.txt": "",
|
||||
"file6.txt": "",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
|
||||
let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
|
||||
|
||||
toggle_expand_dir(&panel, "root/dir1", cx);
|
||||
toggle_expand_dir(&panel, "root/dir2", cx);
|
||||
|
||||
// Test Case 1: Delete middle file in directory
|
||||
select_path(&panel, "root/dir1/file1.txt", cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..15, cx),
|
||||
&[
|
||||
"v root",
|
||||
" v dir1",
|
||||
" > subdir1",
|
||||
" file1.txt <== selected",
|
||||
" file2.txt",
|
||||
" v dir2",
|
||||
" > subdir2",
|
||||
" file3.txt",
|
||||
" file4.txt",
|
||||
" file5.txt",
|
||||
" file6.txt",
|
||||
],
|
||||
"Initial state before deleting middle file"
|
||||
);
|
||||
|
||||
submit_deletion(&panel, cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..15, cx),
|
||||
&[
|
||||
"v root",
|
||||
" v dir1",
|
||||
" > subdir1",
|
||||
" file2.txt <== selected",
|
||||
" v dir2",
|
||||
" > subdir2",
|
||||
" file3.txt",
|
||||
" file4.txt",
|
||||
" file5.txt",
|
||||
" file6.txt",
|
||||
],
|
||||
"Should select next file after deleting middle file"
|
||||
);
|
||||
|
||||
// Test Case 2: Delete last file in directory
|
||||
submit_deletion(&panel, cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..15, cx),
|
||||
&[
|
||||
"v root",
|
||||
" v dir1",
|
||||
" > subdir1 <== selected",
|
||||
" v dir2",
|
||||
" > subdir2",
|
||||
" file3.txt",
|
||||
" file4.txt",
|
||||
" file5.txt",
|
||||
" file6.txt",
|
||||
],
|
||||
"Should select next directory when last file is deleted"
|
||||
);
|
||||
|
||||
// Test Case 3: Delete root level file
|
||||
select_path(&panel, "root/file6.txt", cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..15, cx),
|
||||
&[
|
||||
"v root",
|
||||
" v dir1",
|
||||
" > subdir1",
|
||||
" v dir2",
|
||||
" > subdir2",
|
||||
" file3.txt",
|
||||
" file4.txt",
|
||||
" file5.txt",
|
||||
" file6.txt <== selected",
|
||||
],
|
||||
"Initial state before deleting root level file"
|
||||
);
|
||||
|
||||
submit_deletion(&panel, cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..15, cx),
|
||||
&[
|
||||
"v root",
|
||||
" v dir1",
|
||||
" > subdir1",
|
||||
" v dir2",
|
||||
" > subdir2",
|
||||
" file3.txt",
|
||||
" file4.txt",
|
||||
" file5.txt <== selected",
|
||||
],
|
||||
"Should select prev entry at root level"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
|
||||
init_test_with_editor(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor().clone());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"dir1": {
|
||||
"subdir1": {
|
||||
"a.txt": "",
|
||||
"b.txt": ""
|
||||
},
|
||||
"file1.txt": "",
|
||||
},
|
||||
"dir2": {
|
||||
"subdir2": {
|
||||
"c.txt": "",
|
||||
"d.txt": ""
|
||||
},
|
||||
"file2.txt": "",
|
||||
},
|
||||
"file3.txt": "",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
|
||||
let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
|
||||
|
||||
toggle_expand_dir(&panel, "root/dir1", cx);
|
||||
toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
|
||||
toggle_expand_dir(&panel, "root/dir2", cx);
|
||||
toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
|
||||
|
||||
// Test Case 1: Select and delete nested directory with parent
|
||||
cx.simulate_modifiers_change(gpui::Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
});
|
||||
select_path_with_mark(&panel, "root/dir1/subdir1", cx);
|
||||
select_path_with_mark(&panel, "root/dir1", cx);
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..15, cx),
|
||||
&[
|
||||
"v root",
|
||||
" v dir1 <== selected <== marked",
|
||||
" v subdir1 <== marked",
|
||||
" a.txt",
|
||||
" b.txt",
|
||||
" file1.txt",
|
||||
" v dir2",
|
||||
" v subdir2",
|
||||
" c.txt",
|
||||
" d.txt",
|
||||
" file2.txt",
|
||||
" file3.txt",
|
||||
],
|
||||
"Initial state before deleting nested directory with parent"
|
||||
);
|
||||
|
||||
submit_deletion(&panel, cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..15, cx),
|
||||
&[
|
||||
"v root",
|
||||
" v dir2 <== selected",
|
||||
" v subdir2",
|
||||
" c.txt",
|
||||
" d.txt",
|
||||
" file2.txt",
|
||||
" file3.txt",
|
||||
],
|
||||
"Should select next directory after deleting directory with parent"
|
||||
);
|
||||
|
||||
// Test Case 2: Select mixed files and directories across levels
|
||||
select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
|
||||
select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
|
||||
select_path_with_mark(&panel, "root/file3.txt", cx);
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..15, cx),
|
||||
&[
|
||||
"v root",
|
||||
" v dir2",
|
||||
" v subdir2",
|
||||
" c.txt <== marked",
|
||||
" d.txt",
|
||||
" file2.txt <== marked",
|
||||
" file3.txt <== selected <== marked",
|
||||
],
|
||||
"Initial state before deleting"
|
||||
);
|
||||
|
||||
submit_deletion(&panel, cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..15, cx),
|
||||
&[
|
||||
"v root",
|
||||
" v dir2 <== selected",
|
||||
" v subdir2",
|
||||
" d.txt",
|
||||
],
|
||||
"Should select sibling directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
|
||||
init_test_with_editor(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor().clone());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"dir1": {
|
||||
"subdir1": {
|
||||
"a.txt": "",
|
||||
"b.txt": ""
|
||||
},
|
||||
"file1.txt": "",
|
||||
},
|
||||
"dir2": {
|
||||
"subdir2": {
|
||||
"c.txt": "",
|
||||
"d.txt": ""
|
||||
},
|
||||
"file2.txt": "",
|
||||
},
|
||||
"file3.txt": "",
|
||||
"file4.txt": "",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
|
||||
let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
|
||||
|
||||
toggle_expand_dir(&panel, "root/dir1", cx);
|
||||
toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
|
||||
toggle_expand_dir(&panel, "root/dir2", cx);
|
||||
toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
|
||||
|
||||
// Test Case 1: Select all root files and directories
|
||||
cx.simulate_modifiers_change(gpui::Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
});
|
||||
select_path_with_mark(&panel, "root/dir1", cx);
|
||||
select_path_with_mark(&panel, "root/dir2", cx);
|
||||
select_path_with_mark(&panel, "root/file3.txt", cx);
|
||||
select_path_with_mark(&panel, "root/file4.txt", cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..20, cx),
|
||||
&[
|
||||
"v root",
|
||||
" v dir1 <== marked",
|
||||
" v subdir1",
|
||||
" a.txt",
|
||||
" b.txt",
|
||||
" file1.txt",
|
||||
" v dir2 <== marked",
|
||||
" v subdir2",
|
||||
" c.txt",
|
||||
" d.txt",
|
||||
" file2.txt",
|
||||
" file3.txt <== marked",
|
||||
" file4.txt <== selected <== marked",
|
||||
],
|
||||
"State before deleting all contents"
|
||||
);
|
||||
|
||||
submit_deletion(&panel, cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..20, cx),
|
||||
&["v root <== selected"],
|
||||
"Only empty root directory should remain after deleting all contents"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
|
||||
init_test_with_editor(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor().clone());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"dir1": {
|
||||
"subdir1": {
|
||||
"file_a.txt": "content a",
|
||||
"file_b.txt": "content b",
|
||||
},
|
||||
"subdir2": {
|
||||
"file_c.txt": "content c",
|
||||
},
|
||||
"file1.txt": "content 1",
|
||||
},
|
||||
"dir2": {
|
||||
"file2.txt": "content 2",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
|
||||
let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
|
||||
|
||||
toggle_expand_dir(&panel, "root/dir1", cx);
|
||||
toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
|
||||
toggle_expand_dir(&panel, "root/dir2", cx);
|
||||
cx.simulate_modifiers_change(gpui::Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
|
||||
select_path_with_mark(&panel, "root/dir1", cx);
|
||||
select_path_with_mark(&panel, "root/dir1/subdir1", cx);
|
||||
select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..20, cx),
|
||||
&[
|
||||
"v root",
|
||||
" v dir1 <== marked",
|
||||
" v subdir1 <== marked",
|
||||
" file_a.txt <== selected <== marked",
|
||||
" file_b.txt",
|
||||
" > subdir2",
|
||||
" file1.txt",
|
||||
" v dir2",
|
||||
" file2.txt",
|
||||
],
|
||||
"State with parent dir, subdir, and file selected"
|
||||
);
|
||||
submit_deletion(&panel, cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..20, cx),
|
||||
&["v root", " v dir2 <== selected", " file2.txt",],
|
||||
"Only dir2 should remain after deletion"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
|
||||
init_test_with_editor(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor().clone());
|
||||
// First worktree
|
||||
fs.insert_tree(
|
||||
"/root1",
|
||||
json!({
|
||||
"dir1": {
|
||||
"file1.txt": "content 1",
|
||||
"file2.txt": "content 2",
|
||||
},
|
||||
"dir2": {
|
||||
"file3.txt": "content 3",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Second worktree
|
||||
fs.insert_tree(
|
||||
"/root2",
|
||||
json!({
|
||||
"dir3": {
|
||||
"file4.txt": "content 4",
|
||||
"file5.txt": "content 5",
|
||||
},
|
||||
"file6.txt": "content 6",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
|
||||
let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
|
||||
|
||||
// Expand all directories for testing
|
||||
toggle_expand_dir(&panel, "root1/dir1", cx);
|
||||
toggle_expand_dir(&panel, "root1/dir2", cx);
|
||||
toggle_expand_dir(&panel, "root2/dir3", cx);
|
||||
|
||||
// Test Case 1: Delete files across different worktrees
|
||||
cx.simulate_modifiers_change(gpui::Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
});
|
||||
select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
|
||||
select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..20, cx),
|
||||
&[
|
||||
"v root1",
|
||||
" v dir1",
|
||||
" file1.txt <== marked",
|
||||
" file2.txt",
|
||||
" v dir2",
|
||||
" file3.txt",
|
||||
"v root2",
|
||||
" v dir3",
|
||||
" file4.txt <== selected <== marked",
|
||||
" file5.txt",
|
||||
" file6.txt",
|
||||
],
|
||||
"Initial state with files selected from different worktrees"
|
||||
);
|
||||
|
||||
submit_deletion(&panel, cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..20, cx),
|
||||
&[
|
||||
"v root1",
|
||||
" v dir1",
|
||||
" file2.txt",
|
||||
" v dir2",
|
||||
" file3.txt",
|
||||
"v root2",
|
||||
" v dir3",
|
||||
" file5.txt <== selected",
|
||||
" file6.txt",
|
||||
],
|
||||
"Should select next file in the last worktree after deletion"
|
||||
);
|
||||
|
||||
// Test Case 2: Delete directories from different worktrees
|
||||
select_path_with_mark(&panel, "root1/dir1", cx);
|
||||
select_path_with_mark(&panel, "root2/dir3", cx);
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..20, cx),
|
||||
&[
|
||||
"v root1",
|
||||
" v dir1 <== marked",
|
||||
" file2.txt",
|
||||
" v dir2",
|
||||
" file3.txt",
|
||||
"v root2",
|
||||
" v dir3 <== selected <== marked",
|
||||
" file5.txt",
|
||||
" file6.txt",
|
||||
],
|
||||
"State with directories marked from different worktrees"
|
||||
);
|
||||
|
||||
submit_deletion(&panel, cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..20, cx),
|
||||
&[
|
||||
"v root1",
|
||||
" v dir2",
|
||||
" file3.txt",
|
||||
"v root2",
|
||||
" file6.txt <== selected",
|
||||
],
|
||||
"Should select remaining file in last worktree after directory deletion"
|
||||
);
|
||||
|
||||
// Test Case 4: Delete all remaining files except roots
|
||||
select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
|
||||
select_path_with_mark(&panel, "root2/file6.txt", cx);
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..20, cx),
|
||||
&[
|
||||
"v root1",
|
||||
" v dir2",
|
||||
" file3.txt <== marked",
|
||||
"v root2",
|
||||
" file6.txt <== selected <== marked",
|
||||
],
|
||||
"State with all remaining files marked"
|
||||
);
|
||||
|
||||
submit_deletion(&panel, cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..20, cx),
|
||||
&["v root1", " v dir2", "v root2 <== selected"],
|
||||
"Second parent root should be selected after deleting"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
|
||||
init_test_with_editor(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor().clone());
|
||||
fs.insert_tree(
|
||||
"/root_b",
|
||||
json!({
|
||||
"dir1": {
|
||||
"file1.txt": "content 1",
|
||||
"file2.txt": "content 2",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
fs.insert_tree(
|
||||
"/root_c",
|
||||
json!({
|
||||
"dir2": {},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
|
||||
let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
|
||||
|
||||
toggle_expand_dir(&panel, "root_b/dir1", cx);
|
||||
toggle_expand_dir(&panel, "root_c/dir2", cx);
|
||||
|
||||
cx.simulate_modifiers_change(gpui::Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
});
|
||||
select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
|
||||
select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..20, cx),
|
||||
&[
|
||||
"v root_b",
|
||||
" v dir1",
|
||||
" file1.txt <== marked",
|
||||
" file2.txt <== selected <== marked",
|
||||
"v root_c",
|
||||
" v dir2",
|
||||
],
|
||||
"Initial state with files marked in root_b"
|
||||
);
|
||||
|
||||
submit_deletion(&panel, cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..20, cx),
|
||||
&[
|
||||
"v root_b",
|
||||
" v dir1 <== selected",
|
||||
"v root_c",
|
||||
" v dir2",
|
||||
],
|
||||
"After deletion in root_b as it's last deletion, selection should be in root_b"
|
||||
);
|
||||
|
||||
select_path_with_mark(&panel, "root_c/dir2", cx);
|
||||
|
||||
submit_deletion(&panel, cx);
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&panel, 0..20, cx),
|
||||
&["v root_b", " v dir1", "v root_c <== selected",],
|
||||
"After deleting from root_c, it should remain in root_c"
|
||||
);
|
||||
}
|
||||
|
||||
fn toggle_expand_dir(
|
||||
panel: &View<ProjectPanel>,
|
||||
path: impl AsRef<Path>,
|
||||
|
@ -6364,6 +7100,32 @@ mod tests {
|
|||
});
|
||||
}
|
||||
|
||||
fn select_path_with_mark(
|
||||
panel: &View<ProjectPanel>,
|
||||
path: impl AsRef<Path>,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
let path = path.as_ref();
|
||||
panel.update(cx, |panel, cx| {
|
||||
for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
|
||||
let worktree = worktree.read(cx);
|
||||
if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
|
||||
let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
|
||||
let entry = crate::SelectedEntry {
|
||||
worktree_id: worktree.id(),
|
||||
entry_id,
|
||||
};
|
||||
if !panel.marked_entries.contains(&entry) {
|
||||
panel.marked_entries.insert(entry);
|
||||
}
|
||||
panel.selection = Some(entry);
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("no worktree for path {:?}", path);
|
||||
});
|
||||
}
|
||||
|
||||
fn find_project_entry(
|
||||
panel: &View<ProjectPanel>,
|
||||
path: impl AsRef<Path>,
|
||||
|
|
|
@ -378,7 +378,15 @@ pub fn compare_paths(
|
|||
.as_deref()
|
||||
.map(NumericPrefixWithSuffix::from_numeric_prefixed_str);
|
||||
|
||||
num_and_remainder_a.cmp(&num_and_remainder_b)
|
||||
num_and_remainder_a.cmp(&num_and_remainder_b).then_with(|| {
|
||||
if a_is_file && b_is_file {
|
||||
let ext_a = path_a.extension().unwrap_or_default();
|
||||
let ext_b = path_b.extension().unwrap_or_default();
|
||||
ext_a.cmp(ext_b)
|
||||
} else {
|
||||
cmp::Ordering::Equal
|
||||
}
|
||||
})
|
||||
});
|
||||
if !ordering.is_eq() {
|
||||
return ordering;
|
||||
|
@ -433,6 +441,28 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_paths_with_same_name_different_extensions() {
|
||||
let mut paths = vec![
|
||||
(Path::new("test_dirs/file.rs"), true),
|
||||
(Path::new("test_dirs/file.txt"), true),
|
||||
(Path::new("test_dirs/file.md"), true),
|
||||
(Path::new("test_dirs/file"), true),
|
||||
(Path::new("test_dirs/file.a"), true),
|
||||
];
|
||||
paths.sort_by(|&a, &b| compare_paths(a, b));
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
(Path::new("test_dirs/file"), true),
|
||||
(Path::new("test_dirs/file.a"), true),
|
||||
(Path::new("test_dirs/file.md"), true),
|
||||
(Path::new("test_dirs/file.rs"), true),
|
||||
(Path::new("test_dirs/file.txt"), true),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_paths_case_semi_sensitive() {
|
||||
let mut paths = vec![
|
||||
|
|
|
@ -48,7 +48,7 @@ use ui::{v_flex, ContextMenu};
|
|||
use util::{debug_panic, maybe, truncate_and_remove_front, ResultExt};
|
||||
|
||||
/// A selected entry in e.g. project panel.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct SelectedEntry {
|
||||
pub worktree_id: WorktreeId,
|
||||
pub entry_id: ProjectEntryId,
|
||||
|
|
Loading…
Reference in a new issue