feat: retain explorer filters in navigation mode

This commit is contained in:
Valdemar Lorenzen
2026-10-09 09:28:20 +02:00
parent 86395cf5ce
commit 4fe3accc56
6 changed files with 890 additions and 208 deletions
+313 -79
View File
@@ -81,9 +81,9 @@ pub struct FilePicker {
tree_roots: Vec<usize>,
rows: Vec<VisibleRow>,
expanded: HashSet<usize>,
filter_collapsed: HashSet<usize>,
saved_selection: [Option<usize>; 2],
filtered_selection: [Option<usize>; 2],
before_search: Option<usize>,
tree_searching: bool,
warning: Option<String>,
}
@@ -211,9 +211,9 @@ impl FilePicker {
tree_roots: Vec::new(),
rows: Vec::new(),
expanded: HashSet::new(),
filter_collapsed: HashSet::new(),
saved_selection: [None, None],
filtered_selection: [None, None],
before_search: None,
tree_searching: false,
warning,
};
@@ -221,7 +221,7 @@ impl FilePicker {
let tree = picker.compact(root, None);
picker.tree_roots.push(tree);
}
picker.project(None);
picker.project(None, false);
picker.remember_selection(false);
Ok(picker)
}
@@ -280,6 +280,9 @@ impl FilePicker {
return;
};
self.saved_selection[self.view.index()] = Some(node);
if !self.query.is_empty() {
self.filtered_selection[self.view.index()] = Some(node);
}
if explicit && !self.nodes[node].is_dir {
self.saved_selection = [Some(node), Some(node)];
if !self.query.is_empty() {
@@ -288,20 +291,6 @@ impl FilePicker {
}
}
fn is_expanded(&self, tree: usize) -> bool {
let mut node = Some(self.tree[tree].node);
while let Some(index) = node {
if self.tree_for_node[index] != tree {
break;
}
if self.expanded.contains(&index) {
return true;
}
node = self.nodes[index].parent;
}
false
}
fn set_expanded(&mut self, tree: usize, expanded: bool) {
let mut node = Some(self.tree[tree].node);
while let Some(index) = node {
@@ -313,6 +302,13 @@ impl FilePicker {
} else {
self.expanded.remove(&index);
}
if !self.query.is_empty() {
if expanded {
self.filter_collapsed.remove(&index);
} else {
self.filter_collapsed.insert(index);
}
}
node = self.nodes[index].parent;
}
}
@@ -337,15 +333,16 @@ impl FilePicker {
self.tree_searching = self.searching;
}
self.view = view;
let selected = self.filtered_selection[view.index()]
.or(self.saved_selection[view.index()])
.or(current);
let selected = if self.query.is_empty() {
self.saved_selection[view.index()]
} else {
self.filtered_selection[view.index()].or(self.saved_selection[view.index()])
}
.or(current);
if view == FileView::Tree {
self.searching = self.tree_searching || !self.query.is_empty();
if self.searching && self.before_search.is_none() {
self.before_search = self.saved_selection[1];
}
self.searching = self.tree_searching;
if !self.searching
&& self.query.is_empty()
&& let Some(node) = selected
{
self.reveal(node);
@@ -353,7 +350,7 @@ impl FilePicker {
} else {
self.searching = false;
}
self.project(selected);
self.project(selected, false);
}
pub fn toggle_view(&mut self) {
@@ -364,24 +361,53 @@ impl FilePicker {
}
pub(crate) fn start_search(&mut self) {
self.before_search = self.selected_node();
let selected = self.selected_node();
self.searching = true;
self.tree_searching = true;
self.project(self.before_search);
self.project(selected, false);
self.clear_error();
}
pub(crate) fn cancel_search(&mut self) {
self.query.clear();
pub(crate) fn finish_search(&mut self) {
let selected = self
.selected_node()
.or(self.filtered_selection[self.view.index()])
.or(self.saved_selection[self.view.index()]);
self.searching = false;
self.tree_searching = false;
self.filtered_selection = [None, None];
let selected = self.before_search.take().or(self.saved_selection[1]);
self.project(selected);
if self.query.is_empty()
&& let Some(node) = selected
{
self.reveal(node);
}
self.project(selected, false);
self.remember_selection(false);
self.clear_error();
}
fn project(&mut self, selected: Option<usize>) {
pub(crate) fn clear_filter(&mut self) {
if self.query.is_empty() {
return;
}
let selected = self
.selected_node()
.or(self.filtered_selection[self.view.index()])
.or(self.saved_selection[self.view.index()]);
self.query.clear();
self.filter_collapsed.clear();
self.filtered_selection = [None, None];
if self.view == FileView::Tree
&& !self.searching
&& let Some(node) = selected
{
self.reveal(node);
}
self.project(selected, false);
self.remember_selection(false);
self.clear_error();
}
fn project(&mut self, selected: Option<usize>, select_match: bool) {
let query = self.query.to_lowercase();
let tokens: Vec<_> = query.split_whitespace().collect();
let matches = |node: &Node| tokens.iter().all(|token| node.search_label.contains(token));
@@ -425,8 +451,20 @@ impl FilePicker {
continue;
}
let entry = &self.tree[tree];
let mut node = Some(entry.node);
let mut collapsed = false;
let mut saved_expansion = false;
while let Some(index) = node {
if self.tree_for_node[index] != tree {
break;
}
collapsed |= self.filter_collapsed.contains(&index);
saved_expansion |= self.expanded.contains(&index);
node = self.nodes[index].parent;
}
let expanded = self.nodes[entry.node].is_dir
&& (self.is_expanded(tree)
&& !collapsed
&& (saved_expansion
|| (filtered && entry.children.iter().any(|&child| included[child])));
self.rows.push(VisibleRow {
node: entry.node,
@@ -439,17 +477,17 @@ impl FilePicker {
}
}
}
// Filtering selects a real match, never an ancestor present only as context.
// Browsing falls back to the nearest visible ancestor, then the first row.
// Query edits prefer direct matches; navigation keeps context directories.
// Missing selections fall back to a visible ancestor, then the first row.
let mut target = selected;
self.selected = self
.rows
.iter()
.position(|row| matches(&self.nodes[row.node]))
.position(|row| !select_match || matches(&self.nodes[row.node]))
.unwrap_or(0);
while let Some(node) = target {
if let Some(index) = self.rows.iter().position(|row| {
matches(&self.nodes[row.node])
(!select_match || matches(&self.nodes[row.node]))
&& (row.node == node
|| (self.view == FileView::Tree
&& row.tree == Some(self.tree_for_node[node])))
@@ -463,16 +501,24 @@ impl FilePicker {
pub(crate) fn refilter(&mut self) {
let view = self.view.index();
let current = self.selected_node();
let selected = if self.query.is_empty() {
self.filtered_selection[view].take().or(current)
} else {
if self.filtered_selection[view].is_none() {
self.filtered_selection[view] = current;
let selected = self
.selected_node()
.or(self.filtered_selection[view])
.or(self.saved_selection[view]);
self.filter_collapsed.clear();
if self.query.is_empty() {
self.filtered_selection = [None, None];
if self.view == FileView::Tree
&& !self.searching
&& let Some(node) = selected
{
self.reveal(node);
}
self.filtered_selection[view]
};
self.project(selected);
} else if self.filtered_selection[view].is_none() {
self.filtered_selection[view] = selected;
}
self.project(selected, !self.query.is_empty());
self.remember_selection(false);
self.clear_error();
}
@@ -494,12 +540,7 @@ impl FilePicker {
} else {
self.selected - 1
};
if !self.query.is_empty() {
self.filtered_selection[self.view.index()] = self.selected_node();
}
if !self.searching {
self.remember_selection(true);
}
self.remember_selection(true);
self.clear_error();
}
@@ -515,7 +556,7 @@ impl FilePicker {
{
self.selected += 1;
}
self.filtered_selection[self.view.index()] = self.selected_node();
self.remember_selection(false);
self.clear_error();
return;
}
@@ -534,7 +575,7 @@ impl FilePicker {
let node = row.node;
let tree = row.tree.expect("directory navigation is tree-only");
self.set_expanded(tree, true);
self.project(Some(node));
self.project(Some(node), false);
}
self.remember_selection(true);
self.clear_error();
@@ -551,7 +592,7 @@ impl FilePicker {
self.selected = index;
}
}
self.filtered_selection[self.view.index()] = self.selected_node();
self.remember_selection(false);
self.clear_error();
return;
}
@@ -559,7 +600,7 @@ impl FilePicker {
let node = row.node;
let tree = row.tree.expect("directory navigation is tree-only");
self.set_expanded(tree, false);
self.project(Some(node));
self.project(Some(node), false);
} else if let Some(parent) = row.tree.and_then(|tree| self.tree[tree].parent) {
let node = self.tree[parent].node;
if let Some(index) = self.rows.iter().position(|row| row.node == node) {
@@ -570,23 +611,18 @@ impl FilePicker {
self.clear_error();
}
/// Directories toggle in browsing; files retain the authoritative path for the editor.
/// Directories finish query editing and toggle; files retain the authoritative path.
pub(crate) fn accept(&mut self) -> Option<PathBuf> {
self.clear_error();
let node = self.selected_node()?;
if self.nodes[node].is_dir {
if self.searching {
self.query.clear();
self.searching = false;
self.tree_searching = false;
self.before_search = None;
self.filtered_selection = [None, None];
self.reveal(node);
self.finish_search();
}
let tree = self.tree_for_node[node];
let expanded = !self.is_expanded(tree);
let expanded = !self.rows.get(self.selected).is_some_and(|row| row.expanded);
self.set_expanded(tree, expanded);
self.project(Some(node));
self.project(Some(node), false);
self.remember_selection(true);
None
} else {
@@ -627,17 +663,32 @@ impl FilePicker {
.filter(|&node| replacement.nodes[node].is_dir)
})
.collect();
replacement.filter_collapsed = self
.filter_collapsed
.iter()
.filter_map(|&node| {
by_path
.get(&self.nodes[node].path)
.copied()
.filter(|&node| replacement.nodes[node].is_dir)
})
.collect();
replacement.saved_selection =
self.saved_selection.map(|node| node.and_then(surviving));
replacement.filtered_selection =
self.filtered_selection.map(|node| node.and_then(surviving));
replacement.before_search = self.before_search.and_then(surviving);
replacement.query = self.query.clone();
replacement.focused = self.focused;
replacement.view = self.view;
replacement.searching = self.searching;
replacement.tree_searching = self.tree_searching;
replacement.project(self.selected_node().and_then(surviving));
let selected = self
.selected_node()
.or(self.filtered_selection[self.view.index()])
.or(self.saved_selection[self.view.index()])
.and_then(surviving);
replacement.project(selected, false);
replacement.remember_selection(false);
*self = replacement;
}
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
@@ -927,7 +978,6 @@ mod tests {
select(&mut picker, "other");
picker.right();
select(&mut picker, "src");
let browsing = paths(&picker);
let expanded = picker.expanded.clone();
picker.start_search();
picker.query = "NEEDLE".into();
@@ -944,11 +994,12 @@ mod tests {
Path::new("src/module/deep/needle.txt")
);
assert_eq!(picker.expanded, expanded);
select(&mut picker, "src/module/deep/needle.txt");
picker.navigate(false);
picker.cancel_search();
assert_eq!(paths(&picker), browsing);
assert_eq!(selected_path(&picker), Path::new("src"));
picker.left();
let filtered = paths(&picker);
picker.finish_search();
assert_eq!(picker.query, "NEEDLE");
assert_eq!(paths(&picker), filtered);
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
assert_eq!(picker.expanded, expanded);
picker.start_search();
picker.query = "deep".into();
@@ -956,10 +1007,13 @@ mod tests {
select(&mut picker, "src/module/deep");
assert!(picker.accept().is_none());
assert!(!picker.searching);
assert!(picker.query.is_empty());
assert_eq!(picker.query, "deep");
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
assert_eq!(picker.expanded, expanded);
picker.right();
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
assert!(picker.expanded.is_superset(&expanded));
assert_eq!(picker.query, "deep");
let chain = Directory::new();
chain.put("one/two/three/a.txt", "");
@@ -1056,10 +1110,190 @@ mod tests {
picker.refresh();
assert!(picker.searching);
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
picker.cancel_search();
assert_eq!(paths(&picker), [PathBuf::from("src")]);
assert_eq!(selected_path(&picker), Path::new("src"));
let filtered = paths(&picker);
picker.finish_search();
assert!(!picker.searching);
assert_eq!(picker.query, "a.txt");
assert_eq!(paths(&picker), filtered);
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
assert!(picker.expanded.is_empty());
picker.toggle_view();
picker.refresh();
picker.toggle_view();
assert!(!picker.searching);
assert_eq!(picker.query, "a.txt");
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
picker.start_search();
assert!(picker.searching);
assert_eq!(picker.query, "a.txt");
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
}
#[test]
fn clearing_filter_preserves_mode_focus_current_path_and_empty_result_fallback() {
let directory = Directory::new();
directory.put("src/deep/needle.txt", "");
directory.put("src/other.txt", "");
directory.put("other/keep.txt", "");
for (view, searching) in [
(FileView::Tree, false),
(FileView::Tree, true),
(FileView::List, false),
] {
let mut picker = tree(&directory);
select(&mut picker, "other");
picker.right();
let expanded = picker.expanded.clone();
picker.set_view(view);
if view == FileView::Tree {
picker.start_search();
}
picker.query = "needle".into();
picker.refilter();
if !searching && view == FileView::Tree {
picker.finish_search();
}
let expected = Path::new("src/deep/needle.txt");
assert_eq!(selected_path(&picker), expected);
picker.clear_filter();
assert!(picker.query.is_empty());
assert_eq!(picker.searching, searching);
assert!(picker.focused);
assert_eq!(selected_path(&picker), expected);
assert!(picker.expanded.is_superset(&expanded));
assert!(paths(&picker).contains(&PathBuf::from("other/keep.txt")));
let rows = paths(&picker);
let expanded = picker.expanded.clone();
picker.clear_filter();
assert_eq!(paths(&picker), rows);
assert_eq!(picker.expanded, expanded);
assert_eq!(selected_path(&picker), expected);
assert_eq!(picker.searching, searching);
picker.query = "absent".into();
picker.refilter();
picker.navigate(true);
picker.navigate(false);
assert!(picker.rows.is_empty());
picker.refresh();
assert!(picker.rows.is_empty());
picker.clear_filter();
assert_eq!(selected_path(&picker), expected);
assert_eq!(picker.searching, searching);
assert!(picker.focused);
}
let empty = Directory::new();
let mut picker = tree(&empty);
picker.start_search();
picker.query = "absent".into();
picker.refilter();
picker.finish_search();
picker.clear_filter();
assert!(picker.rows.is_empty());
assert_eq!(picker.selected, 0);
assert!(!picker.searching);
assert!(picker.focused);
}
#[test]
fn filtered_navigation_toggles_context_folders_and_query_edits_reveal_matches() {
let directory = Directory::new();
directory.put("src/deep/needle.txt", "");
directory.put("src/other.txt", "");
let mut picker = tree(&directory);
picker.start_search();
picker.query = "needle".into();
picker.refilter();
let expanded = picker.expanded.clone();
picker.left();
assert_eq!(selected_path(&picker), Path::new("src/deep"));
picker.right();
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
assert_eq!(picker.expanded, expanded);
picker.finish_search();
picker.left();
picker.left();
assert_eq!(selected_path(&picker), Path::new("src/deep"));
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
assert_eq!(picker.query, "needle");
assert!(!paths(&picker).contains(&PathBuf::from("src/deep/needle.txt")));
picker.start_search();
picker.finish_search();
assert_eq!(selected_path(&picker), Path::new("src/deep"));
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
picker.right();
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
picker.right();
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
picker.left();
assert!(picker.accept().is_none());
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
assert!(picker.accept().is_none());
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
picker.start_search();
assert!(picker.accept().is_none());
assert!(!picker.searching);
assert_eq!(picker.query, "needle");
assert_eq!(selected_path(&picker), Path::new("src/deep"));
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
picker.start_search();
picker.query.push_str(".txt");
picker.refilter();
assert!(picker.filter_collapsed.is_empty());
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
picker.query.clear();
picker.refilter();
picker.finish_search();
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
}
#[test]
fn filtered_collapse_survives_refresh_compaction_and_view_switching_by_raw_path() {
let directory = Directory::new();
directory.put("one/a.txt", "");
directory.put("one/two/needle.txt", "");
let mut picker = tree(&directory);
picker.start_search();
picker.query = "needle".into();
picker.refilter();
picker.finish_search();
picker.left();
picker.left();
picker.left();
picker.left();
assert_eq!(selected_path(&picker), Path::new("one"));
assert_eq!(paths(&picker), [PathBuf::from("one")]);
picker.toggle_view();
assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt"));
picker.toggle_view();
assert_eq!(paths(&picker), [PathBuf::from("one")]);
assert!(!picker.searching);
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
picker.focused = false;
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("one/two"));
assert_eq!(paths(&picker), [PathBuf::from("one/two")]);
assert!(!picker.searching && !picker.focused);
assert_eq!(picker.query, "needle");
directory.put("one/a.txt", "");
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("one"));
assert_eq!(paths(&picker), [PathBuf::from("one")]);
picker.right();
picker.right();
assert_eq!(selected_path(&picker), Path::new("one/two"));
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
picker.right();
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
picker.right();
assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt"));
fs::remove_file(directory.0.join("one/two/needle.txt")).unwrap();
picker.refresh();
assert!(picker.entries().len() == 0);
picker.clear_filter();
assert_eq!(selected_path(&picker), Path::new("one/two"));
assert!(!picker.searching && !picker.focused);
}
#[test]