use ignore::WalkBuilder; use std::collections::{HashMap, HashSet}; use std::fs; use std::io; use std::path::{Path, PathBuf}; use unicode_segmentation::UnicodeSegmentation; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FilePresentation { Floating, 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, Right, Top, Bottom, } pub struct FileRow<'a> { /// The actual project-relative OS path, never reconstructed from the label. pub path: &'a Path, pub label: &'a str, /// Original virtual-catalog input index; absent for scanned entries and directories. pub catalog_index: Option, pub is_dir: bool, pub depth: usize, pub expanded: bool, } #[derive(Debug)] struct Node { path: PathBuf, label: String, search_label: String, catalog_index: Option, is_dir: bool, parent: Option, children: Vec, } #[derive(Debug)] struct TreeNode { node: usize, label: String, parent: Option, children: Vec, } #[derive(Debug)] struct VisibleRow { node: usize, tree: Option, depth: usize, expanded: bool, } #[derive(Debug)] pub struct FilePicker { pub root: PathBuf, pub query: String, pub selected: usize, pub presentation: FilePresentation, pub view: FileView, pub searching: bool, pub focused: bool, pub error: Option, nodes: Vec, tree: Vec, tree_for_node: Vec, tree_roots: Vec, rows: Vec, expanded: HashSet, filter_collapsed: HashSet, saved_selection: [Option; 2], filtered_selection: [Option; 2], tree_searching: bool, warning: Option, } 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, view: FileView) -> io::Result { 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 catalog. fs::read_dir(&root)?; // ponytail: synchronous scan; move indexing off the input thread if large projects stall. let mut walk = WalkBuilder::new(&root); walk.require_git(false) .parents(false) .hidden(false) .git_global(false) .follow_links(false) .filter_entry(|entry| { entry.depth() == 0 || (entry.file_name() != ".git" && entry.file_name() != ".jj") }); let mut nodes = Vec::new(); let mut warning = None; for entry in walk.build() { let entry = match entry { Ok(entry) => entry, Err(error) => { if error.depth() == Some(0) { return Err(io::Error::other(error)); } warning.get_or_insert_with(|| format!("Incomplete scan: {error}")); continue; } }; if let Some(error) = entry.error() { warning.get_or_insert_with(|| format!("Incomplete scan: {error}")); } // 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 .path() .strip_prefix(&root) .expect("walk stays within root") .to_owned(); let label = label(&path); nodes.push(Node { path, search_label: label.to_lowercase(), label, catalog_index: None, is_dir: entry.file_type().unwrap().is_dir(), parent: None, children: Vec::new(), }); } Ok(Self::from_nodes(root, nodes, presentation, view, warning)) } /// Builds the changed-path catalog without consulting the working tree. pub fn from_paths(root: PathBuf, paths: impl IntoIterator) -> Self { let mut catalog = HashMap::new(); for (index, path) in paths.into_iter().enumerate() { for parent in path .ancestors() .skip(1) .filter(|path| !path.as_os_str().is_empty()) { catalog.insert((parent.to_owned(), true), None); } catalog.entry((path, false)).or_insert(Some(index)); } let nodes = catalog .into_iter() .map(|((path, is_dir), catalog_index)| { let label = label(&path); Node { path, search_label: label.to_lowercase(), label, catalog_index, is_dir, parent: None, children: Vec::new(), } }) .collect(); Self::from_nodes(root, nodes, FilePresentation::Docked, FileView::Tree, None) } fn from_nodes( root: PathBuf, mut nodes: Vec, presentation: FilePresentation, view: FileView, warning: Option, ) -> Self { nodes.sort_by(|left, right| left.path.cmp(&right.path)); let by_path: HashMap<_, _> = nodes .iter() .enumerate() .filter(|(_, node)| node.is_dir) .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(), tree_for_node: vec![0; nodes.len()], nodes, tree: Vec::new(), tree_roots: Vec::new(), rows: Vec::new(), expanded: HashSet::new(), filter_collapsed: HashSet::new(), saved_selection: [None, None], filtered_selection: [None, None], tree_searching: false, warning, }; for root in roots { let tree = picker.compact(root, None); picker.tree_roots.push(tree); } picker.project(None, false); picker.remember_selection(false); picker } fn compact(&mut self, start: usize, parent: Option) -> 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> + '_ { 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, catalog_index: node.catalog_index, depth: row.depth, expanded: row.expanded, } }) } fn selected_node(&self) -> Option { 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 !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() { self.filtered_selection = [Some(node), Some(node)]; } } } 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); } if !self.query.is_empty() { if expanded { self.filter_collapsed.remove(&index); } else { self.filter_collapsed.insert(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(crate) fn reveal_path(&mut self, path: &Path) { if let Some(node) = self .nodes .iter() .position(|node| node.path == path && !node.is_dir) { self.reveal(node); self.project(Some(node), false); self.remember_selection(true); } } pub(crate) fn handle_filter_input(&mut self, input: &crate::editor::Input) -> bool { use crate::editor::Input; match input { Input::Ctrl('u') => self.clear_filter(), Input::Backspace if self.view == FileView::List || self.searching => self.backspace(), Input::Char('i') if self.view == FileView::Tree && !self.searching => { self.start_search() } Input::Char(ch) if !ch.is_control() && (self.view == FileView::List || self.searching) => { self.query.push(*ch); self.refilter(); } _ => return false, } true } 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 = 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; if !self.searching && self.query.is_empty() && let Some(node) = selected { self.reveal(node); } } else { self.searching = false; } self.project(selected, false); } 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) { let selected = self.selected_node(); self.searching = true; self.tree_searching = true; self.project(selected, false); self.clear_error(); } 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; if self.query.is_empty() && let Some(node) = selected { self.reveal(node); } self.project(selected, false); self.remember_selection(false); self.clear_error(); } 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, 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)); 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 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 && !collapsed && (saved_expansion || (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))); } } } // 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| !select_match || matches(&self.nodes[row.node])) .unwrap_or(0); while let Some(node) = target { if let Some(index) = self.rows.iter().position(|row| { (!select_match || 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 view = self.view.index(); 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); } } 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(); } pub(crate) fn backspace(&mut self) { if let Some((byte, _)) = self.query.grapheme_indices(true).next_back() { self.query.truncate(byte); self.refilter(); } } pub(crate) fn navigate(&mut self, forward: bool) { let count = self.rows.len(); self.selected = if count == 0 { 0 } else if forward { (self.selected + 1) % count } else if self.selected == 0 { count - 1 } else { self.selected - 1 }; 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.searching { if self .rows .get(self.selected + 1) .is_some_and(|child| child.depth > row.depth) { self.selected += 1; } self.remember_selection(false); self.clear_error(); 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), false); } self.remember_selection(true); self.clear_error(); } pub(crate) fn left(&mut self) { let Some(row) = self.rows.get(self.selected) else { return; }; if self.searching { 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(false); self.clear_error(); 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), 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) { self.selected = index; } } self.remember_selection(true); self.clear_error(); } /// Directories finish query editing and toggle; files retain the authoritative path. pub(crate) fn accept(&mut self) -> Option { self.clear_error(); let node = self.selected_node()?; if self.nodes[node].is_dir { if self.searching { self.finish_search(); } let tree = self.tree_for_node[node]; let expanded = !self.rows.get(self.selected).is_some_and(|row| row.expanded); self.set_expanded(tree, expanded); self.project(Some(node), false); 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, 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.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.query = self.query.clone(); replacement.focused = self.focused; replacement.view = self.view; replacement.searching = self.searching; replacement.tree_searching = self.tree_searching; 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}")), } } } #[cfg(test)] mod tests { use super::*; #[test] fn virtual_catalog_compacts_and_retains_file_directory_collisions() { let mut picker = FilePicker::from_paths( PathBuf::new(), ["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"].map(PathBuf::from), ); assert_eq!( picker .entries() .map(|row| row.label.to_owned()) .collect::>(), ["src/deep", "x", "x"] ); picker.reveal_path(Path::new("x")); assert!(!picker.entries().nth(picker.selected).unwrap().is_dir); assert_eq!(picker.accept(), Some(PathBuf::from("x"))); picker.reveal_path(Path::new("x/y")); assert_eq!(picker.accept(), Some(PathBuf::from("x/y"))); picker.reveal_path(Path::new("src/deep/deleted.rs")); assert_eq!(picker.accept(), Some(PathBuf::from("src/deep/deleted.rs"))); assert_eq!(picker.entries().filter(|row| !row.is_dir).count(), 3); let expected_index = |row: &FileRow<'_>| { if row.is_dir { None } else { Some(match row.path.to_str().unwrap() { "x" => 0, "x/y" => 1, "src/deep/deleted.rs" => 2, path => panic!("unexpected path {path}"), }) } }; for view in [FileView::List, FileView::Tree] { picker.set_view(view); for query in ["", "x", "deleted"] { picker.query = query.into(); picker.refilter(); let rows: Vec<_> = picker.entries().collect(); assert!(!rows.is_empty()); for row in rows { assert_eq!(row.catalog_index, expected_index(&row)); } } } } use std::sync::atomic::{AtomicU64, Ordering}; static TEMP_ID: AtomicU64 = AtomicU64::new(0); struct Directory(PathBuf); impl Directory { fn new() -> Self { let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("vamp-files-test-{}-{id}", std::process::id())); fs::create_dir(&path).unwrap(); Self(path) } fn put(&self, name: &str, text: &str) { let path = self.0.join(name); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, text).unwrap(); } fn scan(&self) -> FilePicker { FilePicker::scan(self.0.clone(), FilePresentation::Floating, FileView::List).unwrap() } } impl Drop for Directory { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn paths(picker: &FilePicker) -> Vec { picker .entries() .map(|entry| entry.path.to_owned()) .collect() } #[test] fn scanned_files_and_directories_have_no_catalog_index() { let directory = Directory::new(); directory.put("nested/file.txt", ""); let mut picker = directory.scan(); for view in [FileView::List, FileView::Tree] { picker.set_view(view); picker.reveal_path(Path::new("nested/file.txt")); assert!(picker.entries().all(|row| row.catalog_index.is_none())); } } #[test] fn nested_ignore_negation_hidden_files_and_vcs_metadata() { let directory = Directory::new(); directory.put(".gitignore", "*.log\nignored/\n"); directory.put("nested/.gitignore", "!keep.log\nlocal.txt\n"); for name in [ "gone.log", "nested/gone.log", "nested/keep.log", "nested/local.txt", "nested/ok.txt", "ignored/hidden.txt", ".hidden", ".git/objects/secret", "nested/.git/secret", ".jj/secret", "nested/.jj/secret", ] { directory.put(name, "text"); } let picker = directory.scan(); assert_eq!( paths(&picker), [ ".gitignore", ".hidden", "nested/.gitignore", "nested/keep.log", "nested/ok.txt" ] .map(PathBuf::from) ); assert!(picker.error.is_none()); } #[test] fn literal_unicode_tokens_grapheme_deletion_and_navigation() { let directory = Directory::new(); for name in ["a space η•Œ[e\u{301}].txt", "b.txt", "c.txt"] { directory.put(name, "text"); } let mut picker = directory.scan(); picker.navigate(false); assert_eq!(picker.selected, 2); picker.navigate(true); assert_eq!(picker.selected, 0); picker.query = "η•Œ SPACE [E\u{301}]".into(); picker.refilter(); assert_eq!(paths(&picker), [PathBuf::from("a space η•Œ[e\u{301}].txt")]); picker.query = "e\u{301}πŸ‘©β€πŸ’»".into(); picker.backspace(); assert_eq!(picker.query, "e\u{301}"); picker.backspace(); assert_eq!(picker.query, ""); picker.backspace(); assert_eq!(picker.entries().len(), 3); picker.query = "no match".into(); picker.refilter(); for forward in [true, false] { picker.navigate(forward); assert_eq!(picker.selected, 0); assert_eq!(picker.entries().len(), 0); } } #[test] fn empty_scan_refresh_and_failure_preserve_reachable_state() { let directory = Directory::new(); let mut picker = directory.scan(); picker.navigate(false); picker.backspace(); assert_eq!(picker.entries().len(), 0); directory.put("new.txt", "text"); picker.query = "new".into(); picker.refresh(); assert_eq!(picker.query, "new"); assert_eq!(paths(&picker), [PathBuf::from("new.txt")]); fs::remove_dir_all(&directory.0).unwrap(); 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, FileView::Tree ) .is_err() ); } #[test] fn malformed_ignore_warning_survives_query_and_navigation() { let directory = Directory::new(); directory.put(".gitignore", "[z-a]\n"); directory.put("ok.txt", "text"); let mut picker = directory.scan(); assert!(picker.error.as_ref().unwrap().contains("Incomplete scan")); picker.query = "ok".into(); picker.refilter(); assert_eq!(paths(&picker), [PathBuf::from("ok.txt")]); picker.navigate(true); assert!(picker.error.as_ref().unwrap().contains("Incomplete scan")); directory.put(".gitignore", ""); picker.refresh(); assert!(picker.error.is_none()); } #[test] fn only_project_ignore_rules_apply_and_non_directory_roots_fail() { let directory = Directory::new(); directory.put(".gitignore", "*.txt\n"); directory.put("project/visible.txt", "text"); let root = directory.0.join("project"); 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, FileView::Tree, ) .is_err() ); } #[cfg(unix)] #[test] fn symlink_loops_and_file_links_are_not_followed_and_raw_paths_survive() { use std::os::unix::fs::symlink; let directory = Directory::new(); directory.put("real.txt", "text"); symlink(&directory.0, directory.0.join("loop")).unwrap(); symlink(directory.0.join("real.txt"), directory.0.join("alias.txt")).unwrap(); let raw = PathBuf::from("rawη•Œ\n.txt"); 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.as_path()) .unwrap(); assert!(!entry.label.chars().any(char::is_control)); assert_eq!( 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::>(), [ ("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::>(), [ ("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 expanded = picker.expanded.clone(); picker.start_search(); picker.query = "NEEDLE".into(); picker.refilter(); assert_eq!( picker .entries() .map(|row| (row.label, row.depth)) .collect::>(), [("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); 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(); picker.refilter(); select(&mut picker, "src/module/deep"); assert!(picker.accept().is_none()); assert!(!picker.searching); 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_eq!(picker.query, "deep"); 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::>(), ["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")); 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] 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); } }