1478 lines
52 KiB
Rust
1478 lines
52 KiB
Rust
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<usize>,
|
|
pub is_dir: bool,
|
|
pub depth: usize,
|
|
pub expanded: bool,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Node {
|
|
path: PathBuf,
|
|
label: String,
|
|
search_label: String,
|
|
catalog_index: Option<usize>,
|
|
is_dir: bool,
|
|
parent: Option<usize>,
|
|
children: Vec<usize>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct TreeNode {
|
|
node: usize,
|
|
label: String,
|
|
parent: Option<usize>,
|
|
children: Vec<usize>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct VisibleRow {
|
|
node: usize,
|
|
tree: Option<usize>,
|
|
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<String>,
|
|
nodes: Vec<Node>,
|
|
tree: Vec<TreeNode>,
|
|
tree_for_node: Vec<usize>,
|
|
tree_roots: Vec<usize>,
|
|
rows: Vec<VisibleRow>,
|
|
expanded: HashSet<usize>,
|
|
filter_collapsed: HashSet<usize>,
|
|
saved_selection: [Option<usize>; 2],
|
|
filtered_selection: [Option<usize>; 2],
|
|
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, 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 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<Item = PathBuf>) -> 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<Node>,
|
|
presentation: FilePresentation,
|
|
view: FileView,
|
|
warning: Option<String>,
|
|
) -> 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>) -> 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,
|
|
catalog_index: node.catalog_index,
|
|
depth: row.depth,
|
|
expanded: row.expanded,
|
|
}
|
|
})
|
|
}
|
|
|
|
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 !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<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));
|
|
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<PathBuf> {
|
|
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::<Vec<_>>(),
|
|
["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<PathBuf> {
|
|
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::<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 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);
|
|
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::<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"));
|
|
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);
|
|
}
|
|
}
|