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 smallvec::SmallVec;
|
||||||
use std::{
|
use std::{
|
||||||
cell::OnceCell,
|
cell::OnceCell,
|
||||||
|
cmp,
|
||||||
collections::HashSet,
|
collections::HashSet,
|
||||||
ffi::OsStr,
|
ffi::OsStr,
|
||||||
ops::Range,
|
ops::Range,
|
||||||
|
@ -53,7 +54,7 @@ use ui::{
|
||||||
IndentGuideColors, IndentGuideLayout, KeyBinding, Label, ListItem, Scrollbar, ScrollbarState,
|
IndentGuideColors, IndentGuideLayout, KeyBinding, Label, ListItem, Scrollbar, ScrollbarState,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
};
|
};
|
||||||
use util::{maybe, ResultExt, TryFutureExt};
|
use util::{maybe, paths::compare_paths, ResultExt, TryFutureExt};
|
||||||
use workspace::{
|
use workspace::{
|
||||||
dock::{DockPosition, Panel, PanelEvent},
|
dock::{DockPosition, Panel, PanelEvent},
|
||||||
notifications::{DetachAndPromptErr, NotifyTaskExt},
|
notifications::{DetachAndPromptErr, NotifyTaskExt},
|
||||||
|
@ -550,7 +551,7 @@ impl ProjectPanel {
|
||||||
.entry((project_path.worktree_id, path_buffer.clone()))
|
.entry((project_path.worktree_id, path_buffer.clone()))
|
||||||
.and_modify(|strongest_diagnostic_severity| {
|
.and_modify(|strongest_diagnostic_severity| {
|
||||||
*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);
|
.or_insert(diagnostic_severity);
|
||||||
}
|
}
|
||||||
|
@ -1184,15 +1185,15 @@ impl ProjectPanel {
|
||||||
|
|
||||||
fn remove(&mut self, trash: bool, skip_prompt: bool, cx: &mut ViewContext<'_, ProjectPanel>) {
|
fn remove(&mut self, trash: bool, skip_prompt: bool, cx: &mut ViewContext<'_, ProjectPanel>) {
|
||||||
maybe!({
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
let project = self.project.read(cx);
|
let project = self.project.read(cx);
|
||||||
let items_to_delete = self.marked_entries();
|
|
||||||
|
|
||||||
let mut dirty_buffers = 0;
|
let mut dirty_buffers = 0;
|
||||||
let file_paths = items_to_delete
|
let file_paths = items_to_delete
|
||||||
.into_iter()
|
.iter()
|
||||||
.filter_map(|selection| {
|
.filter_map(|selection| {
|
||||||
let project_path = project.path_for_entry(selection.entry_id, cx)?;
|
let project_path = project.path_for_entry(selection.entry_id, cx)?;
|
||||||
dirty_buffers +=
|
dirty_buffers +=
|
||||||
|
@ -1261,28 +1262,120 @@ impl ProjectPanel {
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let next_selection = self.find_next_selection_after_deletion(items_to_delete, cx);
|
||||||
cx.spawn(|this, mut cx| async move {
|
cx.spawn(|panel, mut cx| async move {
|
||||||
if let Some(answer) = answer {
|
if let Some(answer) = answer {
|
||||||
if answer.await != Ok(0) {
|
if answer.await != Ok(0) {
|
||||||
return Result::<(), anyhow::Error>::Ok(());
|
return anyhow::Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (entry_id, _) in file_paths {
|
for (entry_id, _) in file_paths {
|
||||||
this.update(&mut cx, |this, cx| {
|
panel
|
||||||
this.project
|
.update(&mut cx, |panel, cx| {
|
||||||
|
panel
|
||||||
|
.project
|
||||||
.update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
|
.update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
|
||||||
.ok_or_else(|| anyhow!("no such entry"))
|
.context("no such entry")
|
||||||
})??
|
})??
|
||||||
.await?;
|
.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);
|
.detach_and_log_err(cx);
|
||||||
Some(())
|
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>) {
|
fn unfold_directory(&mut self, _: &UnfoldDirectory, cx: &mut ViewContext<Self>) {
|
||||||
if let Some((worktree, entry)) = self.selected_entry(cx) {
|
if let Some((worktree, entry)) = self.selected_entry(cx) {
|
||||||
self.unfolded_dir_ids.insert(entry.id);
|
self.unfolded_dir_ids.insert(entry.id);
|
||||||
|
@ -1835,6 +1928,54 @@ impl ProjectPanel {
|
||||||
None
|
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.
|
// 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.
|
// When currently selected entry is not marked, it's treated as the only marked entry.
|
||||||
fn marked_entries(&self) -> BTreeSet<SelectedEntry> {
|
fn marked_entries(&self) -> BTreeSet<SelectedEntry> {
|
||||||
|
@ -5080,14 +5221,13 @@ mod tests {
|
||||||
&[
|
&[
|
||||||
"v src",
|
"v src",
|
||||||
" v test",
|
" v test",
|
||||||
" second.rs",
|
" second.rs <== selected",
|
||||||
" third.rs"
|
" third.rs"
|
||||||
],
|
],
|
||||||
"Project panel should have no deleted file, no other file is selected in it"
|
"Project panel should have no deleted file, no other file is selected in it"
|
||||||
);
|
);
|
||||||
ensure_no_open_items_and_panes(&workspace, cx);
|
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));
|
panel.update(cx, |panel, cx| panel.open(&Open, cx));
|
||||||
cx.executor().run_until_parked();
|
cx.executor().run_until_parked();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
@ -5121,7 +5261,7 @@ mod tests {
|
||||||
submit_deletion_skipping_prompt(&panel, cx);
|
submit_deletion_skipping_prompt(&panel, cx);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
visible_entries_as_strings(&panel, 0..10, cx),
|
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"
|
"Project panel should have no deleted file, with one last file remaining"
|
||||||
);
|
);
|
||||||
ensure_no_open_items_and_panes(&workspace, cx);
|
ensure_no_open_items_and_panes(&workspace, cx);
|
||||||
|
@ -5630,7 +5770,11 @@ mod tests {
|
||||||
submit_deletion(&panel, cx);
|
submit_deletion(&panel, cx);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
visible_entries_as_strings(&panel, 0..10, cx),
|
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]
|
#[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(
|
fn toggle_expand_dir(
|
||||||
panel: &View<ProjectPanel>,
|
panel: &View<ProjectPanel>,
|
||||||
path: impl AsRef<Path>,
|
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(
|
fn find_project_entry(
|
||||||
panel: &View<ProjectPanel>,
|
panel: &View<ProjectPanel>,
|
||||||
path: impl AsRef<Path>,
|
path: impl AsRef<Path>,
|
||||||
|
|
|
@ -378,7 +378,15 @@ pub fn compare_paths(
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(NumericPrefixWithSuffix::from_numeric_prefixed_str);
|
.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() {
|
if !ordering.is_eq() {
|
||||||
return ordering;
|
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]
|
#[test]
|
||||||
fn compare_paths_case_semi_sensitive() {
|
fn compare_paths_case_semi_sensitive() {
|
||||||
let mut paths = vec![
|
let mut paths = vec![
|
||||||
|
|
|
@ -48,7 +48,7 @@ use ui::{v_flex, ContextMenu};
|
||||||
use util::{debug_panic, maybe, truncate_and_remove_front, ResultExt};
|
use util::{debug_panic, maybe, truncate_and_remove_front, ResultExt};
|
||||||
|
|
||||||
/// A selected entry in e.g. project panel.
|
/// 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 struct SelectedEntry {
|
||||||
pub worktree_id: WorktreeId,
|
pub worktree_id: WorktreeId,
|
||||||
pub entry_id: ProjectEntryId,
|
pub entry_id: ProjectEntryId,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue