use std::cell::Cell;
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
use std::fs;
use std::path::PathBuf;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::IgnoreOpts;
use super::fuzzy::{fuzzy_match, workspace_dirs, workspace_files};
#[derive(Debug, Clone)]
pub struct ExplorerNode {
pub name: String,
pub is_dir: bool,
pub depth: usize,
pub rel_path: String,
}
#[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 error: Option<String>,
}
impl ExplorerState {
pub fn new(root: &Path, ignore: IgnoreOpts, _compact: bool) -> Self {
let files = workspace_files(root, ignore);
let dirs = workspace_dirs(root, ignore);
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,
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);
let dirs = workspace_dirs(&self.root, self.ignore);
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 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) {
match self.mode {
ExplorerMode::Selection => self.apply_selection_key(key),
ExplorerMode::Filter => self.apply_filter_key(key),
ExplorerMode::PendingCreate
| ExplorerMode::PendingRename
| ExplorerMode::PendingMove => self.apply_action_input_key(key),
ExplorerMode::PendingDelete => self.apply_delete_key(key),
}
}
fn apply_selection_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Left | KeyCode::Char('h') if !ctrl => self.collapse_or_parent(),
KeyCode::Right | KeyCode::Char('l') if !ctrl => self.expand_or_descend(),
KeyCode::Up | KeyCode::Char('k') if !ctrl => self.move_up(),
KeyCode::Down | KeyCode::Char('j') if !ctrl => self.move_down(),
KeyCode::Char('p') if ctrl => self.move_up(),
KeyCode::Char('n') if ctrl => self.move_down(),
KeyCode::Char('/') => self.enter_filter_mode(),
KeyCode::Char('a') => self.enter_create_mode(),
KeyCode::Char('d') => self.enter_delete_mode(),
KeyCode::Char('r') => self.enter_rename_mode(),
KeyCode::Char('m') => self.enter_move_mode(),
_ => {}
}
}
fn apply_filter_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
KeyCode::Right 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 apply_action_input_key(&mut self, key: KeyEvent) {
self.error = None;
let Some(input) = self.action.as_mut() else {
return;
};
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Left => input.cursor = input.cursor.saturating_sub(1),
KeyCode::Right if input.cursor < input.char_len() => input.cursor += 1,
KeyCode::Home => input.cursor = 0,
KeyCode::End => input.cursor = input.char_len(),
KeyCode::Backspace => input.backspace(),
KeyCode::Delete => input.delete(),
KeyCode::Char('b') if ctrl => input.cursor = input.cursor.saturating_sub(1),
KeyCode::Char('f') if ctrl && input.cursor < input.char_len() => input.cursor += 1,
KeyCode::Char('a') if ctrl => input.cursor = 0,
KeyCode::Char('e') if ctrl => input.cursor = input.char_len(),
KeyCode::Char(c) if !ctrl => input.insert(c),
_ => {}
}
}
fn apply_delete_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
}
_ => {
self.cancel_pending();
}
}
}
pub fn enter_filter_mode(&mut self) {
self.mode = ExplorerMode::Filter;
self.cursor = self.char_len();
self.error = None;
}
pub fn cancel_pending(&mut self) {
self.mode = ExplorerMode::Selection;
self.action = None;
self.error = None;
}
pub fn enter_create_mode(&mut self) {
let seed = self.create_seed();
self.mode = ExplorerMode::PendingCreate;
self.action = Some(ActionInput::new(&seed));
self.error = None;
}
pub fn enter_rename_mode(&mut self) {
let Some(name) = self.selection().map(|n| n.name.clone()) else {
return;
};
self.mode = ExplorerMode::PendingRename;
self.action = Some(ActionInput::new(&name));
self.error = None;
}
pub fn enter_move_mode(&mut self) {
let Some(rel) = self.selection().map(|n| n.rel_path.clone()) else {
return;
};
self.mode = ExplorerMode::PendingMove;
self.action = Some(ActionInput::new(&rel));
self.error = None;
}
pub fn enter_delete_mode(&mut self) {
if self.selection().is_none() {
return;
}
self.mode = ExplorerMode::PendingDelete;
self.action = None;
self.error = None;
}
fn create_seed(&self) -> String {
let Some(node) = self.selection() else {
return String::new();
};
if node.is_dir {
format!("{}/", node.rel_path)
} else if let Some(slash) = node.rel_path.rfind('/') {
format!("{}/", &node.rel_path[..slash])
} else {
String::new()
}
}
pub fn perform_create(&mut self, rel: &str) -> Result<String, String> {
let trimmed = rel.trim();
if trimmed.is_empty() {
return Err("empty path".into());
}
let is_dir = trimmed.ends_with('/');
let clean = trimmed.trim_end_matches('/').to_string();
if clean.is_empty() || clean.starts_with('/') || clean.contains("..") {
return Err(format!("invalid path: {trimmed}"));
}
let target = self.root.join(&clean);
if target.exists() {
return Err(format!("already exists: {clean}"));
}
if is_dir {
fs::create_dir_all(&target).map_err(|e| format!("mkdir: {e}"))?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
}
fs::File::create(&target).map_err(|e| format!("create: {e}"))?;
}
self.refresh();
self.select_by_path(&clean);
Ok(clean)
}
pub fn perform_delete(&mut self, rel: &str) -> Result<(), String> {
if rel.is_empty() {
return Err("nothing to delete".into());
}
let target = self.root.join(rel);
let meta = fs::symlink_metadata(&target).map_err(|e| format!("stat: {e}"))?;
if meta.file_type().is_dir() {
fs::remove_dir_all(&target).map_err(|e| format!("rmdir: {e}"))?;
} else {
fs::remove_file(&target).map_err(|e| format!("rm: {e}"))?;
}
self.refresh();
Ok(())
}
pub fn perform_rename(&mut self, old_rel: &str, new_name: &str) -> Result<String, String> {
let new_name = new_name.trim();
if new_name.is_empty() || new_name.contains('/') {
return Err(format!("invalid name: {new_name}"));
}
let parent_rel = match old_rel.rfind('/') {
Some(i) => &old_rel[..i],
None => "",
};
let new_rel = if parent_rel.is_empty() {
new_name.to_string()
} else {
format!("{parent_rel}/{new_name}")
};
self.fs_rename(old_rel, &new_rel)
}
pub fn perform_move(&mut self, old_rel: &str, new_rel: &str) -> Result<String, String> {
let new_rel = new_rel.trim().trim_end_matches('/');
if new_rel.is_empty() || new_rel.starts_with('/') || new_rel.contains("..") {
return Err(format!("invalid path: {new_rel}"));
}
self.fs_rename(old_rel, new_rel)
}
fn fs_rename(&mut self, old_rel: &str, new_rel: &str) -> Result<String, String> {
if old_rel == new_rel {
return Err("source and destination are the same".into());
}
let src = self.root.join(old_rel);
let dst = self.root.join(new_rel);
if dst.exists() {
return Err(format!("already exists: {new_rel}"));
}
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
}
fs::rename(&src, &dst).map_err(|e| format!("rename: {e}"))?;
self.refresh();
self.select_by_path(new_rel);
Ok(new_rel.to_string())
}
pub fn select_by_path(&mut self, rel: &str) {
let mut prefix = rel;
while let Some(slash) = prefix.rfind('/') {
prefix = &prefix[..slash];
self.expanded.insert(prefix.to_string());
}
self.refilter();
if let Some(pos) = self
.visible
.iter()
.position(|&i| self.nodes[i].rel_path == rel)
{
self.selected = pos;
}
}
}
fn build_nodes(files: &[String], dirs: &[String]) -> Vec<ExplorerNode> {
let mut children_by_parent: BTreeMap<String, BTreeMap<String, bool>> = BTreeMap::new();
for d in dirs {
let parts: Vec<&str> = d.split('/').collect();
let mut acc = String::new();
for part in &parts {
children_by_parent
.entry(acc.clone())
.or_default()
.insert((*part).to_string(), false);
if !acc.is_empty() {
acc.push('/');
}
acc.push_str(part);
}
}
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]);
}
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,
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 selection_mode_swallows_chars_no_query_change() {
let nodes = build_nodes(&paths(), &[]);
let mut s = make_state(nodes, "");
assert_eq!(s.mode, ExplorerMode::Selection);
s.apply_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
s.apply_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert_eq!(s.query, "");
assert_eq!(s.mode, ExplorerMode::Selection);
}
#[test]
fn slash_enters_filter_mode() {
let nodes = build_nodes(&paths(), &[]);
let mut s = make_state(nodes, "");
s.apply_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
assert_eq!(s.mode, ExplorerMode::Filter);
s.apply_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
assert_eq!(s.query, "l");
}
#[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"));
}
}