use std::collections::BTreeSet;
use std::ffi::OsString;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HiddenKind {
#[default]
Visible,
Dotfile,
Gitignored,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SelectionState {
#[default]
None,
Project,
ProjectsParent,
}
impl SelectionState {
#[must_use]
pub fn next(self) -> Self {
match self {
Self::None => Self::Project,
Self::Project => Self::ProjectsParent,
Self::ProjectsParent => Self::None,
}
}
}
#[derive(Debug, Clone)]
pub struct Node {
pub name: OsString,
pub path: PathBuf,
pub state: SelectionState,
pub expanded: bool,
pub children_loaded: bool,
pub children: Vec<Node>,
pub hidden_kind: HiddenKind,
}
impl Node {
#[must_use]
pub fn new(name: OsString, path: PathBuf) -> Self {
Self {
name,
path,
state: SelectionState::default(),
expanded: false,
children_loaded: false,
children: Vec::new(),
hidden_kind: HiddenKind::default(),
}
}
#[must_use]
pub fn has_subdirectories(&self) -> bool {
self.children_loaded && !self.children.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CycleOutcome {
Advanced(SelectionState),
RefusedEmptyGlob,
RefusedSubtreeLock,
NeedsChildrenLoaded,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExpandOutcome {
Expanded,
AlreadyExpanded,
RefusedNoSubdirs,
NeedsChildrenLoaded,
}
#[derive(Debug, Clone)]
pub struct Tree {
root: Node,
focus_path: Vec<usize>,
}
impl Tree {
#[must_use]
pub fn new(root: Node) -> Self {
Self {
root,
focus_path: Vec::new(),
}
}
#[must_use]
pub fn root(&self) -> &Node {
&self.root
}
#[must_use]
pub fn focused(&self) -> &Node {
let mut node = &self.root;
for &idx in &self.focus_path {
node = &node.children[idx];
}
node
}
pub fn cycle_focused(&mut self) -> CycleOutcome {
if self.focused_under_projects_parent_ancestor() {
return CycleOutcome::RefusedSubtreeLock;
}
let current = self.focused().state;
let next = current.next();
if next == SelectionState::ProjectsParent {
if !self.focused().children_loaded {
return CycleOutcome::NeedsChildrenLoaded;
}
if !self.focused().has_subdirectories() {
let after = next.next();
self.focused_mut().state = after;
return CycleOutcome::RefusedEmptyGlob;
}
}
let focused = self.focused_mut();
focused.state = next;
if next == SelectionState::ProjectsParent {
clear_descendant_states(focused);
}
CycleOutcome::Advanced(next)
}
pub fn focus_next(&mut self) {
let total = self.visible_count();
if total == 0 {
return;
}
let current = self.focused_index();
let next = (current + 1) % total;
self.set_focused_index(next);
}
pub fn focus_prev(&mut self) {
let total = self.visible_count();
if total == 0 {
return;
}
let current = self.focused_index();
let prev = if current == 0 { total - 1 } else { current - 1 };
self.set_focused_index(prev);
}
pub fn try_expand_focused(&mut self) -> ExpandOutcome {
let node = self.focused();
if node.expanded {
return ExpandOutcome::AlreadyExpanded;
}
if !node.children_loaded {
return ExpandOutcome::NeedsChildrenLoaded;
}
if node.children.is_empty() {
return ExpandOutcome::RefusedNoSubdirs;
}
self.focused_mut().expanded = true;
ExpandOutcome::Expanded
}
pub fn collapse_focused(&mut self) {
self.focused_mut().expanded = false;
}
#[must_use]
pub fn focused_needs_children_loaded(&self) -> bool {
!self.focused().children_loaded
}
pub fn populate_focused_children(&mut self, children: Vec<Node>) {
let node = self.focused_mut();
node.children = children;
node.children_loaded = true;
}
#[must_use]
pub fn visible_nodes(&self) -> Vec<VisibleNode<'_>> {
let mut out: Vec<VisibleNode<'_>> = Vec::new();
walk_visible(
&self.root,
&mut Vec::new(),
&self.focus_path,
false,
0,
&mut out,
);
out
}
#[must_use]
pub fn project_root_set(&self) -> BTreeSet<PathBuf> {
let mut out: BTreeSet<PathBuf> = BTreeSet::new();
collect_roots(&self.root, &mut out);
out
}
#[must_use]
pub fn focused_visible_index(&self) -> usize {
self.focused_index()
}
pub fn focus_at_visible_index(&mut self, target: usize) {
self.set_focused_index(target);
}
pub fn root_mut(&mut self) -> &mut Node {
&mut self.root
}
pub fn set_focus_path(&mut self, path: Vec<usize>) {
if Self::is_valid_visible_path(&self.root, &path) {
self.focus_path = path;
} else {
self.focus_path = Vec::new();
}
}
fn is_valid_visible_path(root: &Node, path: &[usize]) -> bool {
let mut node = root;
for &idx in path {
if !node.expanded || idx >= node.children.len() {
return false;
}
node = &node.children[idx];
}
true
}
fn focused_mut(&mut self) -> &mut Node {
let mut node = &mut self.root;
for &idx in &self.focus_path {
node = &mut node.children[idx];
}
node
}
fn focused_under_projects_parent_ancestor(&self) -> bool {
let mut node = &self.root;
for &idx in &self.focus_path {
if node.state == SelectionState::ProjectsParent {
return true;
}
node = &node.children[idx];
}
false
}
fn visible_count(&self) -> usize {
self.visible_nodes().len()
}
fn focused_index(&self) -> usize {
let mut count = 0usize;
find_index(&self.root, &mut Vec::new(), &self.focus_path, &mut count)
.expect("focus path must always identify a visible node")
}
fn set_focused_index(&mut self, target: usize) {
let mut count = 0usize;
let mut path: Vec<usize> = Vec::new();
find_path_at_index(&self.root, &mut path, target, &mut count)
.expect("target index in [0, visible_count) is always reachable");
self.focus_path = path;
}
}
#[derive(Debug, Clone, Copy)]
pub struct VisibleNode<'a> {
pub node: &'a Node,
pub depth: usize,
pub focused: bool,
pub locked: bool,
}
fn walk_visible<'a>(
node: &'a Node,
here: &mut Vec<usize>,
focus: &[usize],
inherited_lock: bool,
depth: usize,
out: &mut Vec<VisibleNode<'a>>,
) {
let focused = here.as_slice() == focus;
out.push(VisibleNode {
node,
depth,
focused,
locked: inherited_lock,
});
if !node.expanded {
return;
}
let child_lock = inherited_lock || node.state == SelectionState::ProjectsParent;
for (i, child) in node.children.iter().enumerate() {
here.push(i);
walk_visible(child, here, focus, child_lock, depth + 1, out);
here.pop();
}
}
fn clear_descendant_states(node: &mut Node) {
for child in &mut node.children {
child.state = SelectionState::None;
clear_descendant_states(child);
}
}
fn collect_roots(node: &Node, out: &mut BTreeSet<PathBuf>) {
match node.state {
SelectionState::None => {}
SelectionState::Project => {
out.insert(node.path.clone());
}
SelectionState::ProjectsParent => {
for child in &node.children {
out.insert(child.path.clone());
}
}
}
for child in &node.children {
collect_roots(child, out);
}
}
fn find_index(
node: &Node,
here: &mut Vec<usize>,
focus: &[usize],
count: &mut usize,
) -> Option<usize> {
if here.as_slice() == focus {
return Some(*count);
}
*count += 1;
if !node.expanded {
return None;
}
for (i, child) in node.children.iter().enumerate() {
here.push(i);
if let Some(found) = find_index(child, here, focus, count) {
return Some(found);
}
here.pop();
}
None
}
fn find_path_at_index(
node: &Node,
path: &mut Vec<usize>,
target: usize,
count: &mut usize,
) -> Option<()> {
if *count == target {
return Some(());
}
*count += 1;
if !node.expanded {
return None;
}
for (i, child) in node.children.iter().enumerate() {
path.push(i);
if find_path_at_index(child, path, target, count).is_some() {
return Some(());
}
path.pop();
}
None
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::ffi::OsString;
use std::path::PathBuf;
use super::{CycleOutcome, ExpandOutcome, Node, SelectionState, Tree, VisibleNode};
fn dir(name: &str, path: &str) -> Node {
Node::new(OsString::from(name), PathBuf::from(path))
}
fn loaded_dir(name: &str, path: &str, children: Vec<Node>) -> Node {
let mut node = dir(name, path);
node.children = children;
node.children_loaded = true;
node
}
fn loaded_empty(name: &str, path: &str) -> Node {
loaded_dir(name, path, Vec::new())
}
fn visible_paths(vn: &[VisibleNode<'_>]) -> Vec<PathBuf> {
vn.iter().map(|v| v.node.path.clone()).collect()
}
fn pb(s: &str) -> PathBuf {
PathBuf::from(s)
}
fn pbs(paths: &[&str]) -> BTreeSet<PathBuf> {
paths.iter().map(|s| pb(s)).collect()
}
#[test]
fn selection_state_cycles_none_project_projects_parent_none() {
assert_eq!(SelectionState::None.next(), SelectionState::Project);
assert_eq!(
SelectionState::Project.next(),
SelectionState::ProjectsParent,
);
assert_eq!(SelectionState::ProjectsParent.next(), SelectionState::None);
}
#[test]
fn visible_nodes_lists_only_root_when_unexpanded() {
let tree = Tree::new(dir("root", "/"));
let vis = tree.visible_nodes();
assert_eq!(vis.len(), 1);
assert_eq!(vis[0].node.name, OsString::from("root"));
assert_eq!(vis[0].depth, 0);
assert!(vis[0].focused);
}
#[test]
fn visible_nodes_includes_children_when_root_expanded() {
let mut root = loaded_dir(
"root",
"/",
vec![loaded_empty("lib", "/lib"), loaded_empty("web", "/web")],
);
root.expanded = true;
let tree = Tree::new(root);
let vis = tree.visible_nodes();
assert_eq!(visible_paths(&vis), vec![pb("/"), pb("/lib"), pb("/web")]);
assert_eq!(
vis.iter().map(|v| v.depth).collect::<Vec<_>>(),
vec![0, 1, 1]
);
}
#[test]
fn collapse_hides_descendants_without_dropping_their_state() {
let mut root = loaded_dir("root", "/", vec![loaded_empty("lib", "/lib")]);
root.expanded = true;
root.children[0].state = SelectionState::Project;
let mut tree = Tree::new(root);
tree.focus_next(); tree.set_focused_to_root_for_test();
tree.collapse_focused();
let vis = tree.visible_nodes();
assert_eq!(visible_paths(&vis), vec![pb("/")]);
assert_eq!(tree.try_expand_focused(), ExpandOutcome::Expanded);
let vis = tree.visible_nodes();
assert_eq!(vis[1].node.state, SelectionState::Project);
}
#[test]
fn focus_next_walks_visible_list_then_wraps_to_root() {
let mut root = loaded_dir(
"root",
"/",
vec![loaded_empty("a", "/a"), loaded_empty("b", "/b")],
);
root.expanded = true;
let mut tree = Tree::new(root);
assert_eq!(tree.focused().path, pb("/"));
tree.focus_next();
assert_eq!(tree.focused().path, pb("/a"));
tree.focus_next();
assert_eq!(tree.focused().path, pb("/b"));
tree.focus_next(); assert_eq!(tree.focused().path, pb("/"));
}
#[test]
fn focus_prev_wraps_from_root_to_last_visible_node() {
let mut root = loaded_dir(
"root",
"/",
vec![loaded_empty("a", "/a"), loaded_empty("b", "/b")],
);
root.expanded = true;
let mut tree = Tree::new(root);
tree.focus_prev();
assert_eq!(tree.focused().path, pb("/b"));
}
#[test]
fn aux_003_cycle_focused_advances_none_to_project() {
let tree_root = loaded_empty("root", "/");
let mut tree = Tree::new(tree_root);
let outcome = tree.cycle_focused();
assert_eq!(outcome, CycleOutcome::Advanced(SelectionState::Project));
assert_eq!(tree.focused().state, SelectionState::Project);
}
#[test]
fn aux_003_cycle_focused_advances_project_to_projects_parent_when_subdirs_exist() {
let root = loaded_dir(
"root",
"/",
vec![loaded_empty("a", "/a"), loaded_empty("b", "/b")],
);
let mut tree = Tree::new(root);
tree.cycle_focused(); let outcome = tree.cycle_focused();
assert_eq!(
outcome,
CycleOutcome::Advanced(SelectionState::ProjectsParent),
);
}
#[test]
fn aux_003_cycle_focused_advances_projects_parent_back_to_none() {
let root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
let mut tree = Tree::new(root);
tree.cycle_focused(); tree.cycle_focused(); let outcome = tree.cycle_focused();
assert_eq!(outcome, CycleOutcome::Advanced(SelectionState::None));
}
#[test]
fn try_expand_succeeds_on_directory_with_subdirectories() {
let root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
let mut tree = Tree::new(root);
assert!(!tree.focused().expanded);
assert_eq!(tree.try_expand_focused(), ExpandOutcome::Expanded);
assert!(tree.focused().expanded);
}
#[test]
fn try_expand_returns_already_expanded_when_open() {
let mut root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
root.expanded = true;
let mut tree = Tree::new(root);
assert_eq!(tree.try_expand_focused(), ExpandOutcome::AlreadyExpanded);
}
#[test]
fn try_expand_refuses_when_no_subdirectories() {
let tree_root = loaded_empty("root", "/");
let mut tree = Tree::new(tree_root);
assert!(!tree.focused().expanded);
assert_eq!(tree.try_expand_focused(), ExpandOutcome::RefusedNoSubdirs);
assert!(!tree.focused().expanded);
}
#[test]
fn try_expand_signals_needs_load_when_children_unknown() {
let root = dir("root", "/");
let mut tree = Tree::new(root);
assert!(!tree.focused().children_loaded);
assert_eq!(
tree.try_expand_focused(),
ExpandOutcome::NeedsChildrenLoaded
);
assert!(!tree.focused().expanded);
}
#[test]
fn aux_004_rule_1_skips_projects_parent_when_no_subdirectories() {
let tree_root = loaded_empty("root", "/");
let mut tree = Tree::new(tree_root);
tree.cycle_focused(); let outcome = tree.cycle_focused();
assert_eq!(outcome, CycleOutcome::RefusedEmptyGlob);
assert_eq!(tree.focused().state, SelectionState::None);
}
#[test]
fn aux_004_rule_1_request_signals_needs_load_when_children_unknown() {
let mut root = dir("root", "/");
root.state = SelectionState::Project;
let mut tree = Tree::new(root);
let outcome = tree.cycle_focused();
assert_eq!(outcome, CycleOutcome::NeedsChildrenLoaded);
assert_eq!(tree.focused().state, SelectionState::Project);
}
#[test]
fn populate_focused_children_marks_children_loaded() {
let root = dir("root", "/");
let mut tree = Tree::new(root);
assert!(tree.focused_needs_children_loaded());
tree.populate_focused_children(vec![loaded_empty("a", "/a")]);
assert!(!tree.focused_needs_children_loaded());
assert_eq!(tree.focused().children.len(), 1);
}
#[test]
fn aux_004_rule_2_lockout_refuses_cycle_when_ancestor_is_projects_parent() {
let mut root = loaded_dir(
"root",
"/",
vec![loaded_dir(
"lib",
"/lib",
vec![loaded_empty("src", "/lib/src")],
)],
);
root.expanded = true;
root.children[0].expanded = true;
root.children[0].state = SelectionState::ProjectsParent;
let mut tree = Tree::new(root);
tree.focus_next(); tree.focus_next(); let outcome = tree.cycle_focused();
assert_eq!(outcome, CycleOutcome::RefusedSubtreeLock);
assert_eq!(tree.focused().state, SelectionState::None);
}
#[test]
fn aux_004_rule_2_cycling_into_projects_parent_clears_direct_child_state() {
let mut src = loaded_empty("src", "/lib/src");
src.state = SelectionState::Project;
let lib = loaded_dir("lib", "/lib", vec![src]);
let mut root = loaded_dir("root", "/", vec![lib]);
root.expanded = true;
let mut tree = Tree::new(root);
tree.focus_next(); assert_eq!(
tree.cycle_focused(),
CycleOutcome::Advanced(SelectionState::Project),
);
assert_eq!(tree.focused().children[0].state, SelectionState::Project);
assert_eq!(
tree.cycle_focused(),
CycleOutcome::Advanced(SelectionState::ProjectsParent),
);
assert_eq!(tree.focused().state, SelectionState::ProjectsParent);
assert_eq!(tree.focused().children[0].state, SelectionState::None);
}
#[test]
fn aux_004_rule_2_clearing_recurses_through_subtree() {
let mut c = loaded_empty("c", "/a/b/c");
c.state = SelectionState::Project;
let mut b = loaded_dir("b", "/a/b", vec![c]);
b.state = SelectionState::Project;
let a = loaded_dir("a", "/a", vec![b]);
let mut root = loaded_dir("root", "/", vec![a]);
root.expanded = true;
let mut tree = Tree::new(root);
tree.focus_next(); tree.cycle_focused(); tree.cycle_focused(); assert_eq!(tree.focused().state, SelectionState::ProjectsParent);
assert_eq!(tree.focused().children[0].state, SelectionState::None);
assert_eq!(
tree.focused().children[0].children[0].state,
SelectionState::None,
);
}
#[test]
fn aux_004_rule_2_clearing_is_one_way_on_cycle_out() {
let mut child = loaded_empty("child", "/parent/child");
child.state = SelectionState::Project;
let parent = loaded_dir("parent", "/parent", vec![child]);
let mut root = loaded_dir("root", "/", vec![parent]);
root.expanded = true;
let mut tree = Tree::new(root);
tree.focus_next(); tree.cycle_focused(); tree.cycle_focused(); tree.cycle_focused(); assert_eq!(tree.focused().state, SelectionState::None);
assert_eq!(tree.focused().children[0].state, SelectionState::None);
}
#[test]
fn aux_004_rule_2_descendants_marked_locked_in_visible_nodes() {
let mut lib = loaded_dir("lib", "/lib", vec![loaded_empty("src", "/lib/src")]);
lib.state = SelectionState::ProjectsParent;
lib.expanded = true;
let mut root = loaded_dir("root", "/", vec![lib]);
root.expanded = true;
let tree = Tree::new(root);
let vis = tree.visible_nodes();
assert!(!vis[0].locked, "root not locked");
assert!(!vis[1].locked, "lib itself is not locked");
assert!(vis[2].locked, "lib/src locked under lib");
}
#[test]
fn aux_005_project_root_set_collects_project_nodes() {
let mut a = loaded_empty("a", "/a");
a.state = SelectionState::Project;
let mut b = loaded_empty("b", "/b");
b.state = SelectionState::Project;
let mut root = loaded_dir("root", "/", vec![a, b]);
root.expanded = true;
let tree = Tree::new(root);
assert_eq!(tree.project_root_set(), pbs(&["/a", "/b"]));
}
#[test]
fn aux_005_project_root_set_expands_projects_parent_to_direct_subdirs() {
let mut apps = loaded_dir(
"apps",
"/apps",
vec![
loaded_empty("api", "/apps/api"),
loaded_empty("web", "/apps/web"),
],
);
apps.state = SelectionState::ProjectsParent;
apps.expanded = true;
let mut root = loaded_dir("root", "/", vec![apps]);
root.expanded = true;
let tree = Tree::new(root);
assert_eq!(tree.project_root_set(), pbs(&["/apps/api", "/apps/web"]),);
}
#[test]
fn aux_005_project_root_set_mixes_project_and_projects_parent() {
let mut lib = loaded_empty("lib", "/lib");
lib.state = SelectionState::Project;
let mut apps = loaded_dir("apps", "/apps", vec![loaded_empty("api", "/apps/api")]);
apps.state = SelectionState::ProjectsParent;
let mut root = loaded_dir("root", "/", vec![lib, apps]);
root.expanded = true;
let tree = Tree::new(root);
assert_eq!(tree.project_root_set(), pbs(&["/apps/api", "/lib"]),);
}
#[test]
fn aux_005_project_root_set_is_empty_when_nothing_selected() {
let tree = Tree::new(loaded_empty("root", "/"));
assert!(tree.project_root_set().is_empty());
}
impl Tree {
fn set_focused_to_root_for_test(&mut self) {
self.focus_path.clear();
}
}
}