use std::collections::{BTreeMap, HashSet};
use std::path::Path;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::IgnoreOpts;
use super::fuzzy::{fuzzy_match, workspace_files};
#[derive(Debug, Clone)]
pub struct ExplorerNode {
pub name: String,
pub is_dir: bool,
pub depth: usize,
pub rel_path: String,
}
pub struct ExplorerState {
pub nodes: Vec<ExplorerNode>,
pub expanded: HashSet<String>,
pub query: String,
pub cursor: usize,
pub selected: usize,
pub visible: Vec<usize>,
}
impl ExplorerState {
pub fn new(root: &Path, ignore: IgnoreOpts, _compact: bool) -> Self {
let files = workspace_files(root, ignore);
let nodes = build_nodes(&files);
let mut s = Self {
nodes,
expanded: HashSet::new(),
query: String::new(),
cursor: 0,
selected: 0,
visible: Vec::new(),
};
s.refilter();
s
}
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);
}
}
pub fn selection(&self) -> Option<&ExplorerNode> {
self.visible
.get(self.selected)
.and_then(|&i| self.nodes.get(i))
}
pub fn toggle_selected(&mut self) -> bool {
let Some(&node_idx) = self.visible.get(self.selected) else {
return false;
};
let n = &self.nodes[node_idx];
if !n.is_dir {
return false;
}
if self.expanded.contains(&n.rel_path) {
self.expanded.remove(&n.rel_path);
} else {
self.expanded.insert(n.rel_path.clone());
}
true
}
pub fn move_down(&mut self) {
if !self.visible.is_empty() {
self.selected = (self.selected + 1).min(self.visible.len() - 1);
}
}
pub fn move_up(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn collapse_or_parent(&mut self) {
let Some(&node_idx) = self.visible.get(self.selected) else {
return;
};
let n = &self.nodes[node_idx];
if n.is_dir && self.expanded.contains(&n.rel_path) {
self.expanded.remove(&n.rel_path);
self.refilter();
return;
}
if n.depth == 0 {
return;
}
for i in (0..self.selected).rev() {
let node = &self.nodes[self.visible[i]];
if node.depth < n.depth {
self.selected = i;
return;
}
}
}
pub fn expand_or_descend(&mut self) {
let Some(&node_idx) = self.visible.get(self.selected) else {
return;
};
let (is_dir, rel_path, depth) = {
let n = &self.nodes[node_idx];
(n.is_dir, n.rel_path.clone(), n.depth)
};
if is_dir && !self.expanded.contains(&rel_path) {
self.expanded.insert(rel_path);
self.refilter();
if let Some(&next) = self.visible.get(self.selected + 1)
&& self.nodes[next].depth > depth
{
self.selected += 1;
}
return;
}
self.move_down();
}
fn char_len(&self) -> usize {
self.query.chars().count()
}
fn byte_idx(&self, char_idx: usize) -> usize {
self.query
.char_indices()
.nth(char_idx)
.map(|(i, _)| i)
.unwrap_or(self.query.len())
}
fn insert(&mut self, c: char) {
let byte = self.byte_idx(self.cursor);
self.query.insert(byte, c);
self.cursor += 1;
self.refilter();
}
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.query.replace_range(start..end, "");
self.cursor -= 1;
self.refilter();
}
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.query.replace_range(start..end, "");
self.refilter();
}
pub fn apply_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Left => {
if self.query.is_empty() {
self.collapse_or_parent();
} else {
self.cursor = self.cursor.saturating_sub(1);
}
}
KeyCode::Right => {
if self.query.is_empty() {
self.expand_or_descend();
} else if self.cursor < self.char_len() {
self.cursor += 1;
}
}
KeyCode::Home => self.cursor = 0,
KeyCode::End => self.cursor = self.char_len(),
KeyCode::Backspace => self.backspace(),
KeyCode::Delete => self.delete(),
KeyCode::Up => self.move_up(),
KeyCode::Down => self.move_down(),
KeyCode::Char('p') if ctrl => self.move_up(),
KeyCode::Char('n') if ctrl => self.move_down(),
KeyCode::Char('b') if ctrl => self.cursor = self.cursor.saturating_sub(1),
KeyCode::Char('f') if ctrl && self.cursor < self.char_len() => self.cursor += 1,
KeyCode::Char('a') if ctrl => self.cursor = 0,
KeyCode::Char('e') if ctrl => self.cursor = self.char_len(),
KeyCode::Char(c) if !ctrl => self.insert(c),
_ => {}
}
}
}
fn build_nodes(files: &[String]) -> Vec<ExplorerNode> {
let mut children_by_parent: BTreeMap<String, BTreeMap<String, bool>> = BTreeMap::new();
for f in files {
let parts: Vec<&str> = f.split('/').collect();
let mut acc = String::new();
for (i, part) in parts.iter().enumerate() {
let is_last = i == parts.len() - 1;
children_by_parent
.entry(acc.clone())
.or_default()
.entry((*part).to_string())
.and_modify(|v| {
if !is_last {
*v = false; }
})
.or_insert(is_last); if !acc.is_empty() {
acc.push('/');
}
acc.push_str(part);
}
}
let mut out = Vec::new();
let mut stack: Vec<(String, usize)> = Vec::new();
push_children(&mut out, &children_by_parent, "", 0, &mut stack);
out
}
fn push_children(
out: &mut Vec<ExplorerNode>,
map: &BTreeMap<String, BTreeMap<String, bool>>,
parent: &str,
depth: usize,
_stack: &mut Vec<(String, usize)>,
) {
let Some(children) = map.get(parent) else {
return;
};
let mut entries: Vec<(&String, &bool)> = children.iter().collect();
entries.sort_by(|a, b| match (a.1, b.1) {
(false, true) => std::cmp::Ordering::Less,
(true, false) => std::cmp::Ordering::Greater,
_ => a.0.cmp(b.0),
});
for (name, is_file) in entries {
let is_dir = !*is_file;
let rel_path = if parent.is_empty() {
name.clone()
} else {
format!("{}/{}", parent, name)
};
out.push(ExplorerNode {
name: name.clone(),
is_dir,
depth,
rel_path: rel_path.clone(),
});
if is_dir {
push_children(out, map, &rel_path, depth + 1, _stack);
}
}
}
#[cfg(test)]
mod tests {
use 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(),
]
}
#[test]
fn build_nodes_dirs_before_files() {
let n = build_nodes(&paths());
let rels: Vec<&str> = n.iter().map(|x| x.rel_path.as_str()).collect();
assert_eq!(
rels,
vec![
"src",
"src/finder",
"src/finder/fuzzy.rs",
"src/finder/mod.rs",
"src/ui",
"src/ui/fuzzy",
"src/ui/fuzzy/list.rs",
"Cargo.toml",
]
);
let depths: Vec<usize> = n.iter().map(|x| x.depth).collect();
assert_eq!(depths, vec![0, 1, 2, 2, 1, 2, 3, 0]);
}
#[test]
fn empty_query_collapses_to_top_level() {
let nodes = build_nodes(&paths());
let mut s = ExplorerState {
nodes,
expanded: HashSet::new(),
query: String::new(),
cursor: 0,
selected: 0,
visible: Vec::new(),
};
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 = ExplorerState {
nodes,
expanded: HashSet::new(),
query: "list".into(),
cursor: 4,
selected: 0,
visible: Vec::new(),
};
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"));
}
}