feat: default explorer to compact directory tree

This commit is contained in:
Valdemar Lorenzen
2026-10-08 15:51:25 +02:00
parent 9ba8b38898
commit 6d7be0c169
9 changed files with 2036 additions and 145 deletions
+787 -47
View File
@@ -1,7 +1,8 @@
use ignore::WalkBuilder;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use unicode_segmentation::UnicodeSegmentation;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -10,6 +11,21 @@ pub enum FilePresentation {
Docked,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FileView {
List,
Tree,
}
impl FileView {
fn index(self) -> usize {
match self {
Self::List => 0,
Self::Tree => 1,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DockSide {
Left,
@@ -18,11 +34,36 @@ pub enum DockSide {
Bottom,
}
pub struct FileEntry {
pub struct FileRow<'a> {
/// The actual project-relative OS path, never reconstructed from the label.
pub path: PathBuf,
pub label: String,
pub path: &'a Path,
pub label: &'a str,
pub is_dir: bool,
pub depth: usize,
pub expanded: bool,
}
struct Node {
path: PathBuf,
label: String,
search_label: String,
is_dir: bool,
parent: Option<usize>,
children: Vec<usize>,
}
struct TreeNode {
node: usize,
label: String,
parent: Option<usize>,
children: Vec<usize>,
}
struct VisibleRow {
node: usize,
tree: Option<usize>,
depth: usize,
expanded: bool,
}
pub struct FilePicker {
@@ -30,22 +71,39 @@ pub struct FilePicker {
pub query: String,
pub selected: usize,
pub presentation: FilePresentation,
pub view: FileView,
pub searching: bool,
pub focused: bool,
pub error: Option<String>,
files: Vec<FileEntry>,
matches: Vec<usize>,
nodes: Vec<Node>,
tree: Vec<TreeNode>,
tree_for_node: Vec<usize>,
tree_roots: Vec<usize>,
rows: Vec<VisibleRow>,
expanded: HashSet<usize>,
saved_selection: [Option<usize>; 2],
filtered_selection: [Option<usize>; 2],
before_search: Option<usize>,
tree_searching: bool,
warning: Option<String>,
}
fn label(path: &Path) -> String {
path.to_string_lossy()
.chars()
.map(|ch| if ch.is_control() { '�' } else { ch })
.collect()
}
impl FilePicker {
pub fn scan(root: PathBuf, presentation: FilePresentation) -> io::Result<Self> {
pub fn scan(root: PathBuf, presentation: FilePresentation, view: FileView) -> io::Result<Self> {
if !fs::metadata(&root)?.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
"Project root is not a directory",
));
}
// Root failures are fatal; descendant failures leave a visibly incomplete list.
// Root failures are fatal; descendant failures leave a visibly incomplete catalog.
fs::read_dir(&root)?;
// ponytail: synchronous scan; move indexing off the input thread if large projects stall.
let mut walk = WalkBuilder::new(&root);
@@ -57,7 +115,7 @@ impl FilePicker {
.filter_entry(|entry| {
entry.depth() == 0 || (entry.file_name() != ".git" && entry.file_name() != ".jj")
});
let mut files = Vec::new();
let mut nodes = Vec::new();
let mut warning = None;
for entry in walk.build() {
let entry = match entry {
@@ -73,8 +131,12 @@ impl FilePicker {
if let Some(error) = entry.error() {
warning.get_or_insert_with(|| format!("Incomplete scan: {error}"));
}
// Symlinks (including file symlinks) are intentionally excluded.
if !entry.file_type().is_some_and(|kind| kind.is_file()) {
// Symlinks (including file symlinks) and the implicit project root are excluded.
if entry.depth() == 0
|| !entry
.file_type()
.is_some_and(|kind| kind.is_file() || kind.is_dir())
{
continue;
}
let path = entry
@@ -82,49 +144,335 @@ impl FilePicker {
.strip_prefix(&root)
.expect("walk stays within root")
.to_owned();
let label: String = path
.to_string_lossy()
.chars()
.map(|ch| if ch.is_control() { '�' } else { ch })
.collect();
let search_label = label.to_lowercase();
files.push(FileEntry {
let label = label(&path);
nodes.push(Node {
path,
search_label: label.to_lowercase(),
label,
search_label,
is_dir: entry.file_type().unwrap().is_dir(),
parent: None,
children: Vec::new(),
});
}
files.sort_by(|left, right| left.path.cmp(&right.path));
let matches = (0..files.len()).collect();
Ok(Self {
nodes.sort_by(|left, right| left.path.cmp(&right.path));
let by_path: HashMap<_, _> = nodes
.iter()
.enumerate()
.map(|(index, node)| (node.path.clone(), index))
.collect();
let mut roots = Vec::new();
for index in 0..nodes.len() {
let parent = nodes[index]
.path
.parent()
.and_then(|path| by_path.get(path))
.copied();
nodes[index].parent = parent;
if let Some(parent) = parent {
nodes[parent].children.push(index);
} else {
roots.push(index);
}
}
let mut children: Vec<_> = nodes
.iter_mut()
.map(|node| std::mem::take(&mut node.children))
.collect();
let order = |left: &usize, right: &usize| {
nodes[*right]
.is_dir
.cmp(&nodes[*left].is_dir)
.then_with(|| {
nodes[*left]
.path
.file_name()
.cmp(&nodes[*right].path.file_name())
})
};
roots.sort_by(order);
for children in &mut children {
children.sort_by(order);
}
for (node, children) in nodes.iter_mut().zip(children) {
node.children = children;
}
let mut picker = Self {
root,
query: String::new(),
selected: 0,
presentation,
view,
searching: false,
focused: true,
error: warning.clone(),
files,
matches,
tree_for_node: vec![0; nodes.len()],
nodes,
tree: Vec::new(),
tree_roots: Vec::new(),
rows: Vec::new(),
expanded: HashSet::new(),
saved_selection: [None, None],
filtered_selection: [None, None],
before_search: None,
tree_searching: false,
warning,
};
for root in roots {
let tree = picker.compact(root, None);
picker.tree_roots.push(tree);
}
picker.project(None);
picker.remember_selection(false);
Ok(picker)
}
fn compact(&mut self, start: usize, parent: Option<usize>) -> usize {
let tree = self.tree.len();
let mut terminal = start;
let mut name = label(Path::new(self.nodes[start].path.file_name().unwrap()));
self.tree_for_node[start] = tree;
while self.nodes[terminal].children.len() == 1 {
let child = self.nodes[terminal].children[0];
if !self.nodes[terminal].is_dir || !self.nodes[child].is_dir {
break;
}
terminal = child;
self.tree_for_node[terminal] = tree;
name.push('/');
name.push_str(&label(Path::new(
self.nodes[terminal].path.file_name().unwrap(),
)));
}
self.tree.push(TreeNode {
node: terminal,
label: name,
parent,
children: Vec::new(),
});
for index in 0..self.nodes[terminal].children.len() {
let child = self.compact(self.nodes[terminal].children[index], Some(tree));
self.tree[tree].children.push(child);
}
tree
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = FileRow<'_>> + '_ {
self.rows.iter().map(|row| {
let node = &self.nodes[row.node];
FileRow {
path: &node.path,
label: row
.tree
.map_or(node.label.as_str(), |tree| self.tree[tree].label.as_str()),
is_dir: node.is_dir,
depth: row.depth,
expanded: row.expanded,
}
})
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = &FileEntry> + '_ {
self.matches.iter().map(|&index| &self.files[index])
fn selected_node(&self) -> Option<usize> {
self.rows.get(self.selected).map(|row| row.node)
}
fn remember_selection(&mut self, explicit: bool) {
let Some(node) = self.selected_node() else {
return;
};
self.saved_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() {
self.filtered_selection = [Some(node), Some(node)];
}
}
}
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 {
if self.tree_for_node[index] != tree {
break;
}
if expanded {
self.expanded.insert(index);
} else {
self.expanded.remove(&index);
}
node = self.nodes[index].parent;
}
}
fn reveal(&mut self, node: usize) {
let mut parent = self.tree[self.tree_for_node[node]].parent;
while let Some(tree) = parent {
self.set_expanded(tree, true);
parent = self.tree[tree].parent;
}
}
pub fn set_view(&mut self, view: FileView) {
if self.view == view {
return;
}
let current = self.selected_node();
if !self.searching {
self.remember_selection(false);
}
if self.view == FileView::Tree {
self.tree_searching = self.searching;
}
self.view = view;
let selected = 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];
}
if !self.searching
&& let Some(node) = selected
{
self.reveal(node);
}
} else {
self.searching = false;
}
self.project(selected);
}
pub fn toggle_view(&mut self) {
self.set_view(match self.view {
FileView::List => FileView::Tree,
FileView::Tree => FileView::List,
});
}
pub(crate) fn start_search(&mut self) {
self.before_search = self.selected_node();
self.searching = true;
self.tree_searching = true;
self.project(self.before_search);
self.clear_error();
}
pub(crate) fn cancel_search(&mut self) {
self.query.clear();
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);
self.clear_error();
}
fn project(&mut self, selected: Option<usize>) {
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));
self.rows.clear();
if self.view == FileView::List {
self.rows
.extend(self.nodes.iter().enumerate().filter_map(|(node, entry)| {
(!entry.is_dir && matches(entry)).then_some(VisibleRow {
node,
tree: None,
depth: 0,
expanded: false,
})
}));
} else {
let mut included = vec![false; self.tree.len()];
for (index, _) in self
.nodes
.iter()
.enumerate()
.filter(|(_, node)| matches(node))
{
let mut tree = Some(self.tree_for_node[index]);
while let Some(index) = tree {
if included[index] {
break;
}
included[index] = true;
tree = self.tree[index].parent;
}
}
let filtered = self.searching || !tokens.is_empty();
let mut pending: Vec<_> = self
.tree_roots
.iter()
.rev()
.map(|&tree| (tree, 0))
.collect();
while let Some((tree, depth)) = pending.pop() {
if !included[tree] {
continue;
}
let entry = &self.tree[tree];
let expanded = self.nodes[entry.node].is_dir
&& (self.is_expanded(tree)
|| (filtered && entry.children.iter().any(|&child| included[child])));
self.rows.push(VisibleRow {
node: entry.node,
tree: Some(tree),
depth,
expanded,
});
if expanded {
pending.extend(entry.children.iter().rev().map(|&child| (child, depth + 1)));
}
}
}
// Filtering selects a real match, never an ancestor present only as context.
// Browsing falls back to the nearest visible ancestor, then the first row.
let mut target = selected;
self.selected = self
.rows
.iter()
.position(|row| 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])
&& (row.node == node
|| (self.view == FileView::Tree
&& row.tree == Some(self.tree_for_node[node])))
}) {
self.selected = index;
break;
}
target = self.nodes[node].parent;
}
}
pub(crate) fn refilter(&mut self) {
let query = self.query.to_lowercase();
let tokens: Vec<_> = query.split_whitespace().collect();
self.matches.clear();
self.matches
.extend(self.files.iter().enumerate().filter_map(|(index, entry)| {
tokens
.iter()
.all(|token| entry.search_label.contains(token))
.then_some(index)
}));
self.selected = 0;
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;
}
self.filtered_selection[view]
};
self.project(selected);
self.clear_error();
}
@@ -136,7 +484,7 @@ impl FilePicker {
}
pub(crate) fn navigate(&mut self, forward: bool) {
let count = self.matches.len();
let count = self.rows.len();
self.selected = if count == 0 {
0
} else if forward {
@@ -146,19 +494,127 @@ 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.clear_error();
}
pub(crate) fn right(&mut self) {
let Some(row) = self.rows.get(self.selected) else {
return;
};
if !self.nodes[row.node].is_dir {
return;
}
if row.expanded {
if self
.rows
.get(self.selected + 1)
.is_some_and(|child| child.depth > row.depth)
{
self.selected += 1;
}
} else {
let node = row.node;
let tree = row.tree.expect("directory navigation is tree-only");
self.set_expanded(tree, true);
self.project(Some(node));
}
self.remember_selection(true);
self.clear_error();
}
pub(crate) fn left(&mut self) {
let Some(row) = self.rows.get(self.selected) else {
return;
};
if row.expanded {
let node = row.node;
let tree = row.tree.expect("directory navigation is tree-only");
self.set_expanded(tree, false);
self.project(Some(node));
} 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) {
self.selected = index;
}
}
self.remember_selection(true);
self.clear_error();
}
/// Directories toggle in browsing; files retain the authoritative path for the editor.
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);
}
let tree = self.tree_for_node[node];
let expanded = !self.is_expanded(tree);
self.set_expanded(tree, expanded);
self.project(Some(node));
self.remember_selection(true);
None
} else {
self.remember_selection(true);
Some(self.root.join(&self.nodes[node].path))
}
}
pub(crate) fn clear_error(&mut self) {
self.error = self.warning.clone();
}
pub(crate) fn refresh(&mut self) {
match Self::scan(self.root.clone(), self.presentation) {
match Self::scan(self.root.clone(), self.presentation, self.view) {
Ok(mut replacement) => {
let by_path: HashMap<_, _> = replacement
.nodes
.iter()
.enumerate()
.map(|(index, node)| (node.path.clone(), index))
.collect();
let surviving = |node: usize| {
let mut path = self.nodes[node].path.as_path();
loop {
if let Some(&node) = by_path.get(path) {
return Some(node);
}
path = path.parent()?;
}
};
replacement.expanded = self
.expanded
.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.refilter();
replacement.view = self.view;
replacement.searching = self.searching;
replacement.tree_searching = self.tree_searching;
replacement.project(self.selected_node().and_then(surviving));
*self = replacement;
}
Err(error) => self.error = Some(format!("Refresh failed: {error}")),
@@ -187,7 +643,7 @@ mod tests {
fs::write(path, text).unwrap();
}
fn scan(&self) -> FilePicker {
FilePicker::scan(self.0.clone(), FilePresentation::Floating).unwrap()
FilePicker::scan(self.0.clone(), FilePresentation::Floating, FileView::List).unwrap()
}
}
impl Drop for Directory {
@@ -196,7 +652,10 @@ mod tests {
}
}
fn paths(picker: &FilePicker) -> Vec<PathBuf> {
picker.entries().map(|entry| entry.path.clone()).collect()
picker
.entries()
.map(|entry| entry.path.to_owned())
.collect()
}
#[test]
@@ -280,7 +739,14 @@ mod tests {
picker.refresh();
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
assert!(picker.error.as_ref().unwrap().contains("Refresh failed"));
assert!(FilePicker::scan(directory.0.clone(), FilePresentation::Docked).is_err());
assert!(
FilePicker::scan(
directory.0.clone(),
FilePresentation::Docked,
FileView::Tree
)
.is_err()
);
}
#[test]
@@ -306,12 +772,13 @@ mod tests {
directory.put(".gitignore", "*.txt\n");
directory.put("project/visible.txt", "text");
let root = directory.0.join("project");
let picker = FilePicker::scan(root, FilePresentation::Floating).unwrap();
let picker = FilePicker::scan(root, FilePresentation::Floating, FileView::List).unwrap();
assert_eq!(paths(&picker), [PathBuf::from("visible.txt")]);
assert!(
FilePicker::scan(
directory.0.join("project/visible.txt"),
FilePresentation::Docked
FilePresentation::Docked,
FileView::Tree,
)
.is_err()
);
@@ -329,11 +796,284 @@ mod tests {
fs::write(directory.0.join(&raw), "text").unwrap();
let picker = directory.scan();
assert_eq!(picker.entries().len(), 2);
let entry = picker.entries().find(|entry| entry.path == raw).unwrap();
let entry = picker
.entries()
.find(|entry| entry.path == raw.as_path())
.unwrap();
assert!(!entry.label.chars().any(char::is_control));
assert_eq!(
fs::read_to_string(directory.0.join(&entry.path)).unwrap(),
fs::read_to_string(directory.0.join(entry.path)).unwrap(),
"text"
);
}
fn tree(directory: &Directory) -> FilePicker {
FilePicker::scan(
directory.0.clone(),
FilePresentation::Docked,
FileView::Tree,
)
.unwrap()
}
fn select(picker: &mut FilePicker, path: &str) {
let selected = picker
.entries()
.position(|row| row.path == Path::new(path))
.unwrap();
picker.selected = selected;
}
fn selected_path(picker: &FilePicker) -> &Path {
picker.entries().nth(picker.selected).unwrap().path
}
#[test]
fn tree_orders_folders_first_keeps_empty_dirs_and_compacts_only_directory_chains() {
let directory = Directory::new();
directory.put("a.txt", "");
directory.put("folder/only.txt", "");
directory.put("chain/one/two/a.txt", "");
directory.put("chain/one/two/z.txt", "");
fs::create_dir(directory.0.join("empty")).unwrap();
let mut picker = tree(&directory);
assert_eq!(picker.view, FileView::Tree);
assert!(!picker.searching);
assert_eq!(
picker
.entries()
.map(|row| (row.label, row.is_dir, row.depth))
.collect::<Vec<_>>(),
[
("chain/one/two", true, 0),
("empty", true, 0),
("folder", true, 0),
("a.txt", false, 0)
]
);
assert_eq!(selected_path(&picker), Path::new("chain/one/two"));
picker.right();
assert_eq!(
picker
.entries()
.map(|row| (row.label, row.depth))
.collect::<Vec<_>>(),
[
("chain/one/two", 0),
("a.txt", 1),
("z.txt", 1),
("empty", 0),
("folder", 0),
("a.txt", 0)
]
);
picker.right();
assert_eq!(selected_path(&picker), Path::new("chain/one/two/a.txt"));
let rows = paths(&picker);
picker.right();
assert_eq!(paths(&picker), rows);
picker.left();
assert_eq!(selected_path(&picker), Path::new("chain/one/two"));
picker.left();
assert!(!picker.entries().next().unwrap().expanded);
picker.left();
assert_eq!(picker.selected, 0);
select(&mut picker, "folder");
assert!(picker.accept().is_none());
assert_eq!(
picker.entries().nth(picker.selected + 1).unwrap().label,
"only.txt"
);
picker.right();
assert_eq!(picker.accept(), Some(directory.0.join("folder/only.txt")));
select(&mut picker, "empty");
picker.right();
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
picker.right();
assert_eq!(selected_path(&picker), Path::new("empty"));
}
#[test]
fn tree_search_reveals_ancestors_without_mutating_saved_expansion_or_compaction() {
let directory = Directory::new();
directory.put("src/module/deep/needle.txt", "");
directory.put("src/module/sibling.txt", "");
directory.put("src/root.txt", "");
directory.put("other/keep.txt", "");
let mut picker = tree(&directory);
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();
picker.refilter();
assert_eq!(
picker
.entries()
.map(|row| (row.label, row.depth))
.collect::<Vec<_>>(),
[("src", 0), ("module", 1), ("deep", 2), ("needle.txt", 3)]
);
assert_eq!(
selected_path(&picker),
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"));
assert_eq!(picker.expanded, expanded);
picker.start_search();
picker.query = "deep".into();
picker.refilter();
select(&mut picker, "src/module/deep");
assert!(picker.accept().is_none());
assert!(!picker.searching);
assert!(picker.query.is_empty());
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
assert!(picker.expanded.is_superset(&expanded));
let chain = Directory::new();
chain.put("one/two/three/a.txt", "");
chain.put("one/two/three/b.txt", "");
let mut picker = tree(&chain);
picker.start_search();
picker.query = "a.txt".into();
picker.refilter();
assert_eq!(
picker.entries().map(|row| row.label).collect::<Vec<_>>(),
["one/two/three", "a.txt"]
);
}
#[test]
fn view_switches_restore_directory_choices_and_reveal_explicit_file_choices() {
let directory = Directory::new();
directory.put("src/a.txt", "");
directory.put("src/b.txt", "");
directory.put("z.txt", "");
let mut picker = tree(&directory);
picker.right();
let open = paths(&picker);
picker.set_view(FileView::List);
assert_eq!(
paths(&picker),
["src/a.txt", "src/b.txt", "z.txt"].map(PathBuf::from)
);
picker.set_view(FileView::Tree);
assert_eq!(selected_path(&picker), Path::new("src"));
assert_eq!(paths(&picker), open);
picker.left();
picker.toggle_view();
picker.navigate(true);
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
picker.toggle_view();
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
assert!(picker.entries().next().unwrap().expanded);
picker.left();
picker.toggle_view();
picker.toggle_view();
assert_eq!(selected_path(&picker), Path::new("src"));
}
#[test]
fn filtering_and_refresh_preserve_raw_selection_and_surviving_expansion() {
let directory = Directory::new();
directory.put("src/a.txt", "");
directory.put("src/b.txt", "");
directory.put("z.txt", "");
let mut picker = directory.scan();
select(&mut picker, "src/b.txt");
picker.query = "absent".into();
picker.refilter();
assert_eq!(picker.entries().len(), 0);
picker.query.clear();
picker.refilter();
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
picker.set_view(FileView::Tree);
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
fs::remove_file(directory.0.join("src/b.txt")).unwrap();
directory.put("src/c.txt", "");
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("src"));
assert!(picker.entries().next().unwrap().expanded);
assert!(paths(&picker).contains(&PathBuf::from("src/c.txt")));
fs::remove_dir_all(directory.0.join("src")).unwrap();
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("z.txt"));
assert!(picker.expanded.is_empty());
picker.toggle_view();
picker.toggle_view();
assert_eq!(selected_path(&picker), Path::new("z.txt"));
}
#[test]
fn tree_search_subfocus_and_expansion_survive_view_toggles_and_refresh() {
let directory = Directory::new();
directory.put("src/deep/a.txt", "");
directory.put("src/other.txt", "");
let mut picker = tree(&directory);
picker.start_search();
assert!(picker.searching);
assert_eq!(picker.entries().len(), 4);
assert!(picker.expanded.is_empty());
picker.toggle_view();
assert_eq!(picker.view, FileView::List);
picker.toggle_view();
assert!(picker.searching);
assert!(picker.expanded.is_empty());
picker.query = "a.txt".into();
picker.refilter();
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
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"));
assert!(picker.expanded.is_empty());
}
#[test]
fn refresh_preserves_expansion_when_directory_chains_merge_and_split() {
let directory = Directory::new();
directory.put("one/a.txt", "");
directory.put("one/two/b.txt", "");
let mut picker = tree(&directory);
picker.right();
assert_eq!(
paths(&picker),
["one", "one/two", "one/a.txt"].map(PathBuf::from)
);
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("one/two"));
assert_eq!(
paths(&picker),
["one/two", "one/two/b.txt"].map(PathBuf::from)
);
assert!(picker.entries().next().unwrap().expanded);
directory.put("one/a.txt", "");
picker.refresh();
assert_eq!(selected_path(&picker), Path::new("one/two"));
assert_eq!(
paths(&picker),
["one", "one/two", "one/a.txt"].map(PathBuf::from)
);
assert!(picker.entries().next().unwrap().expanded);
assert!(!picker.entries().nth(1).unwrap().expanded);
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
picker.refresh();
picker.left();
assert_eq!(paths(&picker), [PathBuf::from("one/two")]);
directory.put("one/a.txt", "");
picker.refresh();
assert_eq!(paths(&picker), [PathBuf::from("one")]);
assert!(!picker.entries().next().unwrap().expanded);
}
}