mod fs_ops;
mod input;
mod tree;
pub use tree::ExplorerNode;
use std::cell::Cell;
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
use std::path::PathBuf;
use super::IgnoreOpts;
use super::fuzzy::{fuzzy_match, workspace_dirs, workspace_files};
use tree::build_nodes;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExplorerMode {
Selection,
Filter,
PendingCreate,
PendingRename,
PendingMove,
PendingDelete,
}
#[derive(Default, Debug)]
pub struct ActionInput {
pub text: String,
pub cursor: usize,
}
impl ActionInput {
fn new(initial: &str) -> Self {
Self {
text: initial.to_string(),
cursor: initial.chars().count(),
}
}
fn char_len(&self) -> usize {
self.text.chars().count()
}
fn byte_idx(&self, char_idx: usize) -> usize {
self.text
.char_indices()
.nth(char_idx)
.map(|(i, _)| i)
.unwrap_or(self.text.len())
}
fn insert(&mut self, c: char) {
let byte = self.byte_idx(self.cursor);
self.text.insert(byte, c);
self.cursor += 1;
}
fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let end = self.byte_idx(self.cursor);
let start = self.byte_idx(self.cursor - 1);
self.text.replace_range(start..end, "");
self.cursor -= 1;
}
fn delete(&mut self) {
if self.cursor >= self.char_len() {
return;
}
let start = self.byte_idx(self.cursor);
let end = self.byte_idx(self.cursor + 1);
self.text.replace_range(start..end, "");
}
}
pub struct ExplorerState {
pub nodes: Vec<ExplorerNode>,
pub expanded: HashSet<String>,
pub query: String,
pub cursor: usize,
pub selected: usize,
pub visible: Vec<usize>,
pub scroll: Cell<usize>,
pub mode: ExplorerMode,
pub action: Option<ActionInput>,
pub root: PathBuf,
pub ignore: IgnoreOpts,
pub hidden_patterns: Vec<String>,
pub max_items: usize,
pub error: Option<String>,
}
impl ExplorerState {
pub fn new(
root: &Path,
ignore: IgnoreOpts,
hidden_patterns: Vec<String>,
max_items: usize,
_compact: bool,
) -> Self {
let files = workspace_files(root, ignore, &hidden_patterns, max_items);
let dirs = workspace_dirs(root, ignore, &hidden_patterns, max_items);
let nodes = build_nodes(&files, &dirs);
let mut s = Self {
nodes,
expanded: HashSet::new(),
query: String::new(),
cursor: 0,
selected: 0,
visible: Vec::new(),
scroll: Cell::new(0),
mode: ExplorerMode::Selection,
action: None,
root: root.to_path_buf(),
ignore,
hidden_patterns,
max_items,
error: None,
};
s.refilter();
s
}
pub fn refresh(&mut self) {
let prev_path = self.selection().map(|n| n.rel_path.clone());
let files = workspace_files(
&self.root,
self.ignore,
&self.hidden_patterns,
self.max_items,
);
let dirs = workspace_dirs(
&self.root,
self.ignore,
&self.hidden_patterns,
self.max_items,
);
self.nodes = build_nodes(&files, &dirs);
let alive: HashSet<String> = self
.nodes
.iter()
.filter(|n| n.is_dir)
.map(|n| n.rel_path.clone())
.collect();
self.expanded.retain(|p| alive.contains(p));
self.refilter();
if let Some(path) = prev_path
&& let Some(pos) = self
.visible
.iter()
.position(|&i| self.nodes[i].rel_path == path)
{
self.selected = pos;
}
}
pub fn toggle_hidden(&mut self) {
self.ignore.hidden = !self.ignore.hidden;
self.refresh();
}
pub fn toggle_vcs(&mut self) {
self.ignore.vcs = !self.ignore.vcs;
self.refresh();
}
pub fn refilter(&mut self) {
let prev_selected_node = self.visible.get(self.selected).copied();
self.visible.clear();
if self.query.is_empty() {
let mut cut_depth: Option<usize> = None;
for (i, n) in self.nodes.iter().enumerate() {
if let Some(cd) = cut_depth {
if n.depth > cd {
continue;
}
cut_depth = None;
}
self.visible.push(i);
if n.is_dir && !self.expanded.contains(&n.rel_path) {
cut_depth = Some(n.depth);
}
}
} else {
let mut keep: HashSet<usize> = HashSet::new();
let path_to_idx: BTreeMap<&str, usize> = self
.nodes
.iter()
.enumerate()
.map(|(i, n)| (n.rel_path.as_str(), i))
.collect();
for (i, n) in self.nodes.iter().enumerate() {
if n.is_dir {
continue;
}
if fuzzy_match(&n.rel_path, &self.query).is_none() {
continue;
}
keep.insert(i);
let mut prefix = n.rel_path.as_str();
while let Some(slash) = prefix.rfind('/') {
prefix = &prefix[..slash];
if let Some(&ix) = path_to_idx.get(prefix) {
keep.insert(ix);
self.expanded.insert(prefix.to_string());
}
}
}
for (i, _) in self.nodes.iter().enumerate() {
if keep.contains(&i) {
self.visible.push(i);
}
}
}
if let Some(node_idx) = prev_selected_node {
if let Some(pos) = self.visible.iter().position(|&i| i == node_idx) {
self.selected = pos;
} else if self.selected >= self.visible.len() {
self.selected = self.visible.len().saturating_sub(1);
}
} else if self.selected >= self.visible.len() {
self.selected = self.visible.len().saturating_sub(1);
}
}
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
pub(super) fn paths() -> Vec<String> {
vec![
"Cargo.toml".into(),
"src/finder/fuzzy.rs".into(),
"src/finder/mod.rs".into(),
"src/ui/fuzzy/list.rs".into(),
]
}
pub(super) fn default_patterns() -> Vec<String> {
vec![
".*".into(),
"node_modules".into(),
"target".into(),
"dist".into(),
"build".into(),
]
}
pub(super) fn make_state(nodes: Vec<ExplorerNode>, query: &str) -> ExplorerState {
ExplorerState {
nodes,
expanded: HashSet::new(),
query: query.to_string(),
cursor: query.chars().count(),
selected: 0,
visible: Vec::new(),
scroll: Cell::new(0),
mode: if query.is_empty() {
ExplorerMode::Selection
} else {
ExplorerMode::Filter
},
action: None,
root: PathBuf::from("/tmp/vorto-test"),
ignore: IgnoreOpts::DEFAULT,
hidden_patterns: default_patterns(),
max_items: 5000,
error: None,
}
}
#[test]
fn empty_query_collapses_to_top_level() {
let nodes = build_nodes(&paths(), &[]);
let mut s = make_state(nodes, "");
s.refilter();
let visible_paths: Vec<&str> = s
.visible
.iter()
.map(|&i| s.nodes[i].rel_path.as_str())
.collect();
assert_eq!(visible_paths, vec!["src", "Cargo.toml"]);
}
#[test]
fn query_filters_and_expands_ancestors() {
let nodes = build_nodes(&paths(), &[]);
let mut s = make_state(nodes, "list");
s.refilter();
let visible_paths: Vec<&str> = s
.visible
.iter()
.map(|&i| s.nodes[i].rel_path.as_str())
.collect();
assert_eq!(
visible_paths,
vec!["src", "src/ui", "src/ui/fuzzy", "src/ui/fuzzy/list.rs"]
);
assert!(s.expanded.contains("src"));
assert!(s.expanded.contains("src/ui"));
assert!(s.expanded.contains("src/ui/fuzzy"));
}
}