1use std::collections::BTreeSet;
17use std::ffi::OsString;
18use std::path::PathBuf;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum HiddenKind {
30 #[default]
32 Visible,
33 Dotfile,
35 Gitignored,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum SelectionState {
45 #[default]
47 None,
48 Project,
50 ProjectsParent,
53}
54
55impl SelectionState {
56 #[must_use]
59 pub fn next(self) -> Self {
60 match self {
61 Self::None => Self::Project,
62 Self::Project => Self::ProjectsParent,
63 Self::ProjectsParent => Self::None,
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
76pub struct Node {
77 pub name: OsString,
79 pub path: PathBuf,
82 pub state: SelectionState,
84 pub expanded: bool,
87 pub children_loaded: bool,
93 pub children: Vec<Node>,
95 pub hidden_kind: HiddenKind,
99}
100
101impl Node {
102 #[must_use]
104 pub fn new(name: OsString, path: PathBuf) -> Self {
105 Self {
106 name,
107 path,
108 state: SelectionState::default(),
109 expanded: false,
110 children_loaded: false,
111 children: Vec::new(),
112 hidden_kind: HiddenKind::default(),
113 }
114 }
115
116 #[must_use]
124 pub fn has_subdirectories(&self) -> bool {
125 self.children_loaded && !self.children.is_empty()
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum CycleOutcome {
135 Advanced(SelectionState),
138 RefusedEmptyGlob,
142 RefusedSubtreeLock,
148 NeedsChildrenLoaded,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum ExpandOutcome {
164 Expanded,
166 AlreadyExpanded,
168 RefusedNoSubdirs,
173 NeedsChildrenLoaded,
177}
178
179#[derive(Debug, Clone)]
184pub struct Tree {
185 root: Node,
186 focus_path: Vec<usize>,
187}
188
189impl Tree {
190 #[must_use]
192 pub fn new(root: Node) -> Self {
193 Self {
194 root,
195 focus_path: Vec::new(),
196 }
197 }
198
199 #[must_use]
201 pub fn root(&self) -> &Node {
202 &self.root
203 }
204
205 #[must_use]
207 pub fn focused(&self) -> &Node {
208 let mut node = &self.root;
209 for &idx in &self.focus_path {
210 node = &node.children[idx];
211 }
212 node
213 }
214
215 pub fn cycle_focused(&mut self) -> CycleOutcome {
232 if self.focused_under_projects_parent_ancestor() {
233 return CycleOutcome::RefusedSubtreeLock;
234 }
235 let current = self.focused().state;
236 let next = current.next();
237 if next == SelectionState::ProjectsParent {
238 if !self.focused().children_loaded {
239 return CycleOutcome::NeedsChildrenLoaded;
240 }
241 if !self.focused().has_subdirectories() {
242 let after = next.next();
243 self.focused_mut().state = after;
244 return CycleOutcome::RefusedEmptyGlob;
245 }
246 }
247 let focused = self.focused_mut();
248 focused.state = next;
249 if next == SelectionState::ProjectsParent {
250 clear_descendant_states(focused);
251 }
252 CycleOutcome::Advanced(next)
253 }
254
255 pub fn focus_next(&mut self) {
261 let total = self.visible_count();
262 if total == 0 {
263 return;
264 }
265 let current = self.focused_index();
266 let next = (current + 1) % total;
267 self.set_focused_index(next);
268 }
269
270 pub fn focus_prev(&mut self) {
273 let total = self.visible_count();
274 if total == 0 {
275 return;
276 }
277 let current = self.focused_index();
278 let prev = if current == 0 { total - 1 } else { current - 1 };
279 self.set_focused_index(prev);
280 }
281
282 pub fn try_expand_focused(&mut self) -> ExpandOutcome {
287 let node = self.focused();
288 if node.expanded {
289 return ExpandOutcome::AlreadyExpanded;
290 }
291 if !node.children_loaded {
292 return ExpandOutcome::NeedsChildrenLoaded;
293 }
294 if node.children.is_empty() {
295 return ExpandOutcome::RefusedNoSubdirs;
296 }
297 self.focused_mut().expanded = true;
298 ExpandOutcome::Expanded
299 }
300
301 pub fn collapse_focused(&mut self) {
305 self.focused_mut().expanded = false;
306 }
307
308 #[must_use]
312 pub fn focused_needs_children_loaded(&self) -> bool {
313 !self.focused().children_loaded
314 }
315
316 pub fn populate_focused_children(&mut self, children: Vec<Node>) {
321 let node = self.focused_mut();
322 node.children = children;
323 node.children_loaded = true;
324 }
325
326 #[must_use]
332 pub fn visible_nodes(&self) -> Vec<VisibleNode<'_>> {
333 let mut out: Vec<VisibleNode<'_>> = Vec::new();
334 walk_visible(
335 &self.root,
336 &mut Vec::new(),
337 &self.focus_path,
338 false,
339 0,
340 &mut out,
341 );
342 out
343 }
344
345 #[must_use]
354 pub fn project_root_set(&self) -> BTreeSet<PathBuf> {
355 let mut out: BTreeSet<PathBuf> = BTreeSet::new();
356 collect_roots(&self.root, &mut out);
357 out
358 }
359
360 #[must_use]
365 pub fn focused_visible_index(&self) -> usize {
366 self.focused_index()
367 }
368
369 pub fn focus_at_visible_index(&mut self, target: usize) {
374 self.set_focused_index(target);
375 }
376
377 pub fn root_mut(&mut self) -> &mut Node {
381 &mut self.root
382 }
383
384 pub fn set_focus_path(&mut self, path: Vec<usize>) {
390 if Self::is_valid_visible_path(&self.root, &path) {
391 self.focus_path = path;
392 } else {
393 self.focus_path = Vec::new();
394 }
395 }
396
397 fn is_valid_visible_path(root: &Node, path: &[usize]) -> bool {
398 let mut node = root;
399 for &idx in path {
400 if !node.expanded || idx >= node.children.len() {
401 return false;
402 }
403 node = &node.children[idx];
404 }
405 true
406 }
407
408 fn focused_mut(&mut self) -> &mut Node {
411 let mut node = &mut self.root;
412 for &idx in &self.focus_path {
413 node = &mut node.children[idx];
414 }
415 node
416 }
417
418 fn focused_under_projects_parent_ancestor(&self) -> bool {
419 let mut node = &self.root;
420 for &idx in &self.focus_path {
421 if node.state == SelectionState::ProjectsParent {
424 return true;
425 }
426 node = &node.children[idx];
427 }
428 false
429 }
430
431 fn visible_count(&self) -> usize {
432 self.visible_nodes().len()
433 }
434
435 fn focused_index(&self) -> usize {
436 let mut count = 0usize;
442 find_index(&self.root, &mut Vec::new(), &self.focus_path, &mut count)
443 .expect("focus path must always identify a visible node")
444 }
445
446 fn set_focused_index(&mut self, target: usize) {
447 let mut count = 0usize;
448 let mut path: Vec<usize> = Vec::new();
449 find_path_at_index(&self.root, &mut path, target, &mut count)
450 .expect("target index in [0, visible_count) is always reachable");
451 self.focus_path = path;
452 }
453}
454
455#[derive(Debug, Clone, Copy)]
458pub struct VisibleNode<'a> {
459 pub node: &'a Node,
461 pub depth: usize,
463 pub focused: bool,
465 pub locked: bool,
469}
470
471fn walk_visible<'a>(
474 node: &'a Node,
475 here: &mut Vec<usize>,
476 focus: &[usize],
477 inherited_lock: bool,
478 depth: usize,
479 out: &mut Vec<VisibleNode<'a>>,
480) {
481 let focused = here.as_slice() == focus;
482 out.push(VisibleNode {
483 node,
484 depth,
485 focused,
486 locked: inherited_lock,
487 });
488 if !node.expanded {
489 return;
490 }
491 let child_lock = inherited_lock || node.state == SelectionState::ProjectsParent;
492 for (i, child) in node.children.iter().enumerate() {
493 here.push(i);
494 walk_visible(child, here, focus, child_lock, depth + 1, out);
495 here.pop();
496 }
497}
498
499fn clear_descendant_states(node: &mut Node) {
500 for child in &mut node.children {
501 child.state = SelectionState::None;
502 clear_descendant_states(child);
503 }
504}
505
506fn collect_roots(node: &Node, out: &mut BTreeSet<PathBuf>) {
507 match node.state {
508 SelectionState::None => {}
509 SelectionState::Project => {
510 out.insert(node.path.clone());
511 }
512 SelectionState::ProjectsParent => {
513 for child in &node.children {
514 out.insert(child.path.clone());
515 }
516 }
517 }
518 for child in &node.children {
519 collect_roots(child, out);
520 }
521}
522
523fn find_index(
524 node: &Node,
525 here: &mut Vec<usize>,
526 focus: &[usize],
527 count: &mut usize,
528) -> Option<usize> {
529 if here.as_slice() == focus {
530 return Some(*count);
531 }
532 *count += 1;
533 if !node.expanded {
534 return None;
535 }
536 for (i, child) in node.children.iter().enumerate() {
537 here.push(i);
538 if let Some(found) = find_index(child, here, focus, count) {
539 return Some(found);
540 }
541 here.pop();
542 }
543 None
544}
545
546fn find_path_at_index(
547 node: &Node,
548 path: &mut Vec<usize>,
549 target: usize,
550 count: &mut usize,
551) -> Option<()> {
552 if *count == target {
553 return Some(());
554 }
555 *count += 1;
556 if !node.expanded {
557 return None;
558 }
559 for (i, child) in node.children.iter().enumerate() {
560 path.push(i);
561 if find_path_at_index(child, path, target, count).is_some() {
562 return Some(());
563 }
564 path.pop();
565 }
566 None
567}
568
569#[cfg(test)]
570mod tests {
571 use std::collections::BTreeSet;
572 use std::ffi::OsString;
573 use std::path::PathBuf;
574
575 use super::{CycleOutcome, ExpandOutcome, Node, SelectionState, Tree, VisibleNode};
576
577 fn dir(name: &str, path: &str) -> Node {
578 Node::new(OsString::from(name), PathBuf::from(path))
579 }
580
581 fn loaded_dir(name: &str, path: &str, children: Vec<Node>) -> Node {
582 let mut node = dir(name, path);
583 node.children = children;
584 node.children_loaded = true;
585 node
586 }
587
588 fn loaded_empty(name: &str, path: &str) -> Node {
589 loaded_dir(name, path, Vec::new())
590 }
591
592 fn visible_paths(vn: &[VisibleNode<'_>]) -> Vec<PathBuf> {
593 vn.iter().map(|v| v.node.path.clone()).collect()
594 }
595
596 fn pb(s: &str) -> PathBuf {
597 PathBuf::from(s)
598 }
599
600 fn pbs(paths: &[&str]) -> BTreeSet<PathBuf> {
601 paths.iter().map(|s| pb(s)).collect()
602 }
603
604 #[test]
607 fn selection_state_cycles_none_project_projects_parent_none() {
608 assert_eq!(SelectionState::None.next(), SelectionState::Project);
609 assert_eq!(
610 SelectionState::Project.next(),
611 SelectionState::ProjectsParent,
612 );
613 assert_eq!(SelectionState::ProjectsParent.next(), SelectionState::None);
614 }
615
616 #[test]
619 fn visible_nodes_lists_only_root_when_unexpanded() {
620 let tree = Tree::new(dir("root", "/"));
621 let vis = tree.visible_nodes();
622 assert_eq!(vis.len(), 1);
623 assert_eq!(vis[0].node.name, OsString::from("root"));
624 assert_eq!(vis[0].depth, 0);
625 assert!(vis[0].focused);
626 }
627
628 #[test]
629 fn visible_nodes_includes_children_when_root_expanded() {
630 let mut root = loaded_dir(
631 "root",
632 "/",
633 vec![loaded_empty("lib", "/lib"), loaded_empty("web", "/web")],
634 );
635 root.expanded = true;
636 let tree = Tree::new(root);
637 let vis = tree.visible_nodes();
638 assert_eq!(visible_paths(&vis), vec![pb("/"), pb("/lib"), pb("/web")]);
639 assert_eq!(
640 vis.iter().map(|v| v.depth).collect::<Vec<_>>(),
641 vec![0, 1, 1]
642 );
643 }
644
645 #[test]
646 fn collapse_hides_descendants_without_dropping_their_state() {
647 let mut root = loaded_dir("root", "/", vec![loaded_empty("lib", "/lib")]);
648 root.expanded = true;
649 root.children[0].state = SelectionState::Project;
650 let mut tree = Tree::new(root);
651 tree.focus_next(); tree.set_focused_to_root_for_test();
653 tree.collapse_focused();
654 let vis = tree.visible_nodes();
655 assert_eq!(visible_paths(&vis), vec![pb("/")]);
656 assert_eq!(tree.try_expand_focused(), ExpandOutcome::Expanded);
658 let vis = tree.visible_nodes();
659 assert_eq!(vis[1].node.state, SelectionState::Project);
660 }
661
662 #[test]
665 fn focus_next_walks_visible_list_then_wraps_to_root() {
666 let mut root = loaded_dir(
667 "root",
668 "/",
669 vec![loaded_empty("a", "/a"), loaded_empty("b", "/b")],
670 );
671 root.expanded = true;
672 let mut tree = Tree::new(root);
673 assert_eq!(tree.focused().path, pb("/"));
674 tree.focus_next();
675 assert_eq!(tree.focused().path, pb("/a"));
676 tree.focus_next();
677 assert_eq!(tree.focused().path, pb("/b"));
678 tree.focus_next(); assert_eq!(tree.focused().path, pb("/"));
680 }
681
682 #[test]
683 fn focus_prev_wraps_from_root_to_last_visible_node() {
684 let mut root = loaded_dir(
685 "root",
686 "/",
687 vec![loaded_empty("a", "/a"), loaded_empty("b", "/b")],
688 );
689 root.expanded = true;
690 let mut tree = Tree::new(root);
691 tree.focus_prev();
692 assert_eq!(tree.focused().path, pb("/b"));
693 }
694
695 #[test]
698 fn aux_003_cycle_focused_advances_none_to_project() {
699 let tree_root = loaded_empty("root", "/");
700 let mut tree = Tree::new(tree_root);
701 let outcome = tree.cycle_focused();
702 assert_eq!(outcome, CycleOutcome::Advanced(SelectionState::Project));
703 assert_eq!(tree.focused().state, SelectionState::Project);
704 }
705
706 #[test]
707 fn aux_003_cycle_focused_advances_project_to_projects_parent_when_subdirs_exist() {
708 let root = loaded_dir(
709 "root",
710 "/",
711 vec![loaded_empty("a", "/a"), loaded_empty("b", "/b")],
712 );
713 let mut tree = Tree::new(root);
714 tree.cycle_focused(); let outcome = tree.cycle_focused();
716 assert_eq!(
717 outcome,
718 CycleOutcome::Advanced(SelectionState::ProjectsParent),
719 );
720 }
721
722 #[test]
723 fn aux_003_cycle_focused_advances_projects_parent_back_to_none() {
724 let root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
725 let mut tree = Tree::new(root);
726 tree.cycle_focused(); tree.cycle_focused(); let outcome = tree.cycle_focused();
729 assert_eq!(outcome, CycleOutcome::Advanced(SelectionState::None));
730 }
731
732 #[test]
735 fn try_expand_succeeds_on_directory_with_subdirectories() {
736 let root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
737 let mut tree = Tree::new(root);
738 assert!(!tree.focused().expanded);
739 assert_eq!(tree.try_expand_focused(), ExpandOutcome::Expanded);
740 assert!(tree.focused().expanded);
741 }
742
743 #[test]
744 fn try_expand_returns_already_expanded_when_open() {
745 let mut root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
746 root.expanded = true;
747 let mut tree = Tree::new(root);
748 assert_eq!(tree.try_expand_focused(), ExpandOutcome::AlreadyExpanded);
749 }
750
751 #[test]
752 fn try_expand_refuses_when_no_subdirectories() {
753 let tree_root = loaded_empty("root", "/");
757 let mut tree = Tree::new(tree_root);
758 assert!(!tree.focused().expanded);
759 assert_eq!(tree.try_expand_focused(), ExpandOutcome::RefusedNoSubdirs);
760 assert!(!tree.focused().expanded);
761 }
762
763 #[test]
764 fn try_expand_signals_needs_load_when_children_unknown() {
765 let root = dir("root", "/");
766 let mut tree = Tree::new(root);
767 assert!(!tree.focused().children_loaded);
768 assert_eq!(
769 tree.try_expand_focused(),
770 ExpandOutcome::NeedsChildrenLoaded
771 );
772 assert!(!tree.focused().expanded);
773 }
774
775 #[test]
778 fn aux_004_rule_1_skips_projects_parent_when_no_subdirectories() {
779 let tree_root = loaded_empty("root", "/");
784 let mut tree = Tree::new(tree_root);
785 tree.cycle_focused(); let outcome = tree.cycle_focused();
787 assert_eq!(outcome, CycleOutcome::RefusedEmptyGlob);
788 assert_eq!(tree.focused().state, SelectionState::None);
789 }
790
791 #[test]
792 fn aux_004_rule_1_request_signals_needs_load_when_children_unknown() {
793 let mut root = dir("root", "/");
794 root.state = SelectionState::Project;
796 let mut tree = Tree::new(root);
797 let outcome = tree.cycle_focused();
798 assert_eq!(outcome, CycleOutcome::NeedsChildrenLoaded);
799 assert_eq!(tree.focused().state, SelectionState::Project);
801 }
802
803 #[test]
804 fn populate_focused_children_marks_children_loaded() {
805 let root = dir("root", "/");
806 let mut tree = Tree::new(root);
807 assert!(tree.focused_needs_children_loaded());
808 tree.populate_focused_children(vec![loaded_empty("a", "/a")]);
809 assert!(!tree.focused_needs_children_loaded());
810 assert_eq!(tree.focused().children.len(), 1);
811 }
812
813 #[test]
816 fn aux_004_rule_2_lockout_refuses_cycle_when_ancestor_is_projects_parent() {
817 let mut root = loaded_dir(
818 "root",
819 "/",
820 vec![loaded_dir(
821 "lib",
822 "/lib",
823 vec![loaded_empty("src", "/lib/src")],
824 )],
825 );
826 root.expanded = true;
827 root.children[0].expanded = true;
828 root.children[0].state = SelectionState::ProjectsParent;
829 let mut tree = Tree::new(root);
830 tree.focus_next(); tree.focus_next(); let outcome = tree.cycle_focused();
834 assert_eq!(outcome, CycleOutcome::RefusedSubtreeLock);
835 assert_eq!(tree.focused().state, SelectionState::None);
836 }
837
838 #[test]
839 fn aux_004_rule_2_cycling_into_projects_parent_clears_direct_child_state() {
840 let mut src = loaded_empty("src", "/lib/src");
845 src.state = SelectionState::Project;
846 let lib = loaded_dir("lib", "/lib", vec![src]);
847 let mut root = loaded_dir("root", "/", vec![lib]);
848 root.expanded = true;
849 let mut tree = Tree::new(root);
850 tree.focus_next(); assert_eq!(
852 tree.cycle_focused(),
853 CycleOutcome::Advanced(SelectionState::Project),
854 );
855 assert_eq!(tree.focused().children[0].state, SelectionState::Project);
857 assert_eq!(
860 tree.cycle_focused(),
861 CycleOutcome::Advanced(SelectionState::ProjectsParent),
862 );
863 assert_eq!(tree.focused().state, SelectionState::ProjectsParent);
864 assert_eq!(tree.focused().children[0].state, SelectionState::None);
865 }
866
867 #[test]
868 fn aux_004_rule_2_clearing_recurses_through_subtree() {
869 let mut c = loaded_empty("c", "/a/b/c");
875 c.state = SelectionState::Project;
876 let mut b = loaded_dir("b", "/a/b", vec![c]);
877 b.state = SelectionState::Project;
878 let a = loaded_dir("a", "/a", vec![b]);
879 let mut root = loaded_dir("root", "/", vec![a]);
880 root.expanded = true;
881 let mut tree = Tree::new(root);
882 tree.focus_next(); tree.cycle_focused(); tree.cycle_focused(); assert_eq!(tree.focused().state, SelectionState::ProjectsParent);
886 assert_eq!(tree.focused().children[0].state, SelectionState::None);
887 assert_eq!(
888 tree.focused().children[0].children[0].state,
889 SelectionState::None,
890 );
891 }
892
893 #[test]
894 fn aux_004_rule_2_clearing_is_one_way_on_cycle_out() {
895 let mut child = loaded_empty("child", "/parent/child");
899 child.state = SelectionState::Project;
900 let parent = loaded_dir("parent", "/parent", vec![child]);
901 let mut root = loaded_dir("root", "/", vec![parent]);
902 root.expanded = true;
903 let mut tree = Tree::new(root);
904 tree.focus_next(); tree.cycle_focused(); tree.cycle_focused(); tree.cycle_focused(); assert_eq!(tree.focused().state, SelectionState::None);
909 assert_eq!(tree.focused().children[0].state, SelectionState::None);
910 }
911
912 #[test]
913 fn aux_004_rule_2_descendants_marked_locked_in_visible_nodes() {
914 let mut lib = loaded_dir("lib", "/lib", vec![loaded_empty("src", "/lib/src")]);
915 lib.state = SelectionState::ProjectsParent;
916 lib.expanded = true;
917 let mut root = loaded_dir("root", "/", vec![lib]);
918 root.expanded = true;
919 let tree = Tree::new(root);
920 let vis = tree.visible_nodes();
921 assert!(!vis[0].locked, "root not locked");
924 assert!(!vis[1].locked, "lib itself is not locked");
925 assert!(vis[2].locked, "lib/src locked under lib");
926 }
927
928 #[test]
931 fn aux_005_project_root_set_collects_project_nodes() {
932 let mut a = loaded_empty("a", "/a");
933 a.state = SelectionState::Project;
934 let mut b = loaded_empty("b", "/b");
935 b.state = SelectionState::Project;
936 let mut root = loaded_dir("root", "/", vec![a, b]);
937 root.expanded = true;
938 let tree = Tree::new(root);
939 assert_eq!(tree.project_root_set(), pbs(&["/a", "/b"]));
940 }
941
942 #[test]
943 fn aux_005_project_root_set_expands_projects_parent_to_direct_subdirs() {
944 let mut apps = loaded_dir(
945 "apps",
946 "/apps",
947 vec![
948 loaded_empty("api", "/apps/api"),
949 loaded_empty("web", "/apps/web"),
950 ],
951 );
952 apps.state = SelectionState::ProjectsParent;
953 apps.expanded = true;
954 let mut root = loaded_dir("root", "/", vec![apps]);
955 root.expanded = true;
956 let tree = Tree::new(root);
957 assert_eq!(tree.project_root_set(), pbs(&["/apps/api", "/apps/web"]),);
958 }
959
960 #[test]
961 fn aux_005_project_root_set_mixes_project_and_projects_parent() {
962 let mut lib = loaded_empty("lib", "/lib");
963 lib.state = SelectionState::Project;
964 let mut apps = loaded_dir("apps", "/apps", vec![loaded_empty("api", "/apps/api")]);
965 apps.state = SelectionState::ProjectsParent;
966 let mut root = loaded_dir("root", "/", vec![lib, apps]);
967 root.expanded = true;
968 let tree = Tree::new(root);
969 assert_eq!(tree.project_root_set(), pbs(&["/apps/api", "/lib"]),);
970 }
971
972 #[test]
973 fn aux_005_project_root_set_is_empty_when_nothing_selected() {
974 let tree = Tree::new(loaded_empty("root", "/"));
975 assert!(tree.project_root_set().is_empty());
976 }
977
978 impl Tree {
981 fn set_focused_to_root_for_test(&mut self) {
982 self.focus_path.clear();
983 }
984 }
985}