1use std::collections::HashMap;
11use std::ffi::OsString;
12
13use tui_treelistview::{TreeListViewState, TreeQuery};
14
15use crate::cli::ExpandSpec;
16use crate::config::{AppCommand, Binding, BindingAction, Config};
17use crate::jump::{Jump, JumpOutcome};
18use crate::keybindings::{
19 CLOSE_KEY, KeybindingEntry, KeybindingPanelState, TOGGLE_KEY, build_entries,
20};
21use crate::keys::Key;
22use crate::tree::{NodeId, Tree};
23
24#[derive(Clone, PartialEq, Debug)]
26pub enum Effect {
27 None,
28 Quit,
30 PrintAndExit(OsString),
32 Open(OsString),
34 RunShell {
36 cmd: String,
37 path: OsString,
38 relpath: OsString,
39 bg: bool,
40 exit: bool,
41 },
42}
43
44pub enum Mode {
48 Normal,
49 Jump(Jump),
50 Indexing,
53}
54
55const WORK_QUANTUM: usize = 500;
57
58pub struct App {
59 pub tree: Tree,
60 pub state: TreeListViewState<NodeId>,
61 pub query: TreeQuery,
62 pub mode: Mode,
64 keymap: HashMap<Key, Binding>,
65 pub(crate) panel_entries: Vec<KeybindingEntry>,
67 pub(crate) panel_user_entry_count: usize,
69 pub keybinding_panel: KeybindingPanelState,
70 root_history: Vec<Option<NodeId>>,
72 pub page_height: usize,
74 pub palette: Option<crate::ui::Palette>,
76}
77
78impl App {
79 pub fn new(tree: Tree, config: &Config, expand: Option<ExpandSpec>) -> Self {
80 let mut builtin_keymap = Self::default_keymap();
81 let user_keymap: HashMap<_, _> = config
84 .bindings
85 .iter()
86 .filter(|&(&key, _)| key != TOGGLE_KEY)
87 .map(|(&key, binding)| (key, binding.clone()))
88 .collect();
89 for key in user_keymap.keys() {
90 builtin_keymap.remove(key);
91 }
92
93 let mut panel_entries = build_entries(&user_keymap);
94 let panel_user_entry_count = panel_entries.len();
95 panel_entries.extend(build_entries(&builtin_keymap));
96
97 let mut keymap = builtin_keymap;
98 keymap.extend(user_keymap);
99 let mut app = Self {
100 tree,
101 state: TreeListViewState::with_capacity(0),
102 query: TreeQuery::new(),
103 mode: Mode::Normal,
104 keymap,
105 panel_entries,
106 panel_user_entry_count,
107 keybinding_panel: KeybindingPanelState::default(),
108 root_history: Vec::new(),
109 page_height: 20,
110 palette: None,
111 };
112 match expand {
113 None => {}
114 Some(spec) => {
115 app.tree.index_all();
117 let branches: Vec<_> = app.tree.branches().collect();
118 for (id, parent) in branches {
119 let expand = match spec {
120 ExpandSpec::All => true,
121 ExpandSpec::Depth(n) => app.tree.depth(id) < n,
122 };
123 if expand {
124 app.state.set_expanded(id, parent, true);
125 }
126 }
127 }
128 }
129 if app.tree.root_ids().len() == 1 {
131 let root = app.tree.root_ids()[0];
132 app.tree.ensure_children(root);
133 if !app.tree.is_leaf(root) {
134 app.state
135 .set_expanded(root, app.tree.view_parent(root), true);
136 }
137 }
138 app.state.ensure_projection(&app.tree, &app.query);
139 app.state.select_first();
140 app
141 }
142
143 pub fn default_keymap() -> HashMap<Key, Binding> {
145 let cmd = |action: AppCommand| Binding {
146 action: BindingAction::Cmd(action),
147 help: None,
148 exit: false,
149 bg: false,
150 };
151 let mut map = HashMap::new();
152 for (keys, action) in [
153 (&["j", "down"][..], AppCommand::Down),
154 (&["k", "up"], AppCommand::Up),
155 (&["l", "right"], AppCommand::Expand),
156 (&["h", "left"], AppCommand::Collapse),
157 (&["L", "shift+right"], AppCommand::ExpandRecursively),
158 (&["H", "shift+left"], AppCommand::CollapseRecursively),
159 (&["space"], AppCommand::Toggle),
160 (&["ctrl+space"], AppCommand::ToggleRecursively),
161 (&["enter"], AppCommand::Select),
162 (&["ctrl+enter"], AppCommand::Accept),
163 (&["alt+enter"], AppCommand::AcceptAlternate),
164 (&["tab"], AppCommand::Root),
165 (&["shift+tab"], AppCommand::PopRoot),
166 (&["J"], AppCommand::NextSibling),
167 (&["K"], AppCommand::PrevSibling),
168 (&["ctrl+f"], AppCommand::PageDown),
169 (&["ctrl+b"], AppCommand::PageUp),
170 (&["ctrl+d"], AppCommand::HalfPageDown),
171 (&["ctrl+u"], AppCommand::HalfPageUp),
172 (&["g"], AppCommand::First),
173 (&["G"], AppCommand::Last),
174 (&["/"], AppCommand::Jump),
175 (&["o"], AppCommand::Open),
176 (&["?"], AppCommand::ToggleKeybindingPanel),
177 (&["esc"], AppCommand::Back),
178 (&["q", "ctrl+c"], AppCommand::Quit),
179 ] {
180 for key in keys {
181 map.insert(Key::parse(key).expect("valid default key"), cmd(action));
182 }
183 }
184 map
185 }
186
187 pub fn focused_id(&mut self) -> Option<NodeId> {
188 self.state.ensure_projection(&self.tree, &self.query);
189 self.state.selected_id()
190 }
191
192 pub fn visible_names(&mut self) -> Vec<String> {
194 self.state.ensure_projection(&self.tree, &self.query);
195 self.state
196 .visible_ids()
197 .map(|id| self.tree.name(id))
198 .collect()
199 }
200
201 pub fn handle_key(&mut self, key: Key) -> Effect {
203 let _span = crate::profile::span("app::handle_key");
204 if let Mode::Indexing = self.mode {
206 if key == Key::parse("esc").unwrap() || key == Key::parse("ctrl+c").unwrap() {
207 self.mode = Mode::Normal;
208 }
209 return Effect::None;
210 }
211 if let Mode::Jump(jump) = &mut self.mode {
215 return match jump.handle_key(key) {
216 JumpOutcome::Stay => Effect::None,
217 JumpOutcome::Cancel => {
218 self.mode = Mode::Normal;
219 Effect::None
220 }
221 JumpOutcome::Accept(id) => {
222 self.mode = Mode::Normal;
223 self.state.select_by_id(&self.tree, &self.query, id);
224 Effect::None
225 }
226 };
227 }
228
229 if self.keybinding_panel.is_open() && key == CLOSE_KEY {
230 self.keybinding_panel.close();
231 return Effect::None;
232 }
233
234 match self.keymap.get(&key).cloned() {
235 None => Effect::None,
236 Some(binding) => match binding.action {
237 BindingAction::Cmd(cmd) => self.run_command(cmd),
238 BindingAction::Sh(cmd) => match self.focused_id() {
239 None => Effect::None,
240 Some(id) => Effect::RunShell {
241 cmd,
242 path: self.tree.path(id),
243 relpath: self.tree.relpath(id),
244 bg: binding.bg,
245 exit: binding.exit,
246 },
247 },
248 },
249 }
250 }
251
252 pub fn run_command(&mut self, cmd: AppCommand) -> Effect {
254 self.state.ensure_projection(&self.tree, &self.query);
255 match cmd {
256 AppCommand::Down => {
257 self.state.select_next();
258 }
259 AppCommand::Up => {
260 self.state.select_prev();
261 }
262 AppCommand::Expand => {
263 if let Some(id) = self.focused_id() {
264 self.tree.ensure_children(id);
265 let parent = self.tree.view_parent(id);
266 if self.tree.is_leaf(id) {
267 self.move_sibling(1);
269 } else if self.state.node_is_expanded(id, parent) {
270 self.state.select_id(Some(self.tree.children_of(id)[0]));
271 } else {
272 self.expand_and_reveal_children(id);
273 }
274 }
275 }
276 AppCommand::Collapse => {
277 if let Some(id) = self.focused_id() {
278 let parent = self.tree.view_parent(id);
279 if !self.tree.is_leaf(id) && self.state.node_is_expanded(id, parent) {
280 self.state.set_expanded(id, parent, false);
281 } else if let Some(parent) = parent {
282 self.state.select_id(Some(parent));
283 }
284 }
285 }
286 AppCommand::ExpandRecursively => {
287 if let Some(id) = self.focused_id() {
288 self.tree.ensure_children(id);
289 if self.tree.is_leaf(id) {
290 self.move_sibling(1);
292 } else {
293 self.set_expanded_recursively(id, true);
294 }
295 }
296 }
297 AppCommand::CollapseRecursively => {
298 if let Some(id) = self.focused_id() {
299 let parent = self.tree.view_parent(id);
300 if !self.tree.is_leaf(id) && self.state.node_is_expanded(id, parent) {
301 self.set_expanded_recursively(id, false);
302 } else if let Some(parent) = parent {
303 self.set_expanded_recursively(parent, false);
306 self.state.ensure_projection(&self.tree, &self.query);
307 self.state.select_id(Some(parent));
308 }
309 }
310 }
311 AppCommand::Toggle => self.toggle_focused(false),
312 AppCommand::ToggleRecursively => self.toggle_focused(true),
313 AppCommand::Select => {
314 if let Some(id) = self.focused_id() {
315 self.tree.ensure_children(id);
316 if self.tree.is_leaf(id) {
317 return Effect::PrintAndExit(self.tree.output(id));
318 }
319 self.expand_and_reveal_children(id);
320 }
321 }
322 AppCommand::Accept => {
323 if let Some(id) = self.focused_id() {
324 return Effect::PrintAndExit(self.tree.output(id));
325 }
326 }
327 AppCommand::AcceptAlternate => {
328 if let Some(id) = self.focused_id() {
329 return Effect::PrintAndExit(self.tree.alternate_output(id));
330 }
331 }
332 AppCommand::Descend => {
333 if let Some(id) = self.focused_branch() {
334 self.expand_and_reveal_children(id);
335 let first_child = self.tree.children_of(id)[0];
336 self.state.select_id(Some(first_child));
337 }
338 }
339 AppCommand::Root => self.push_root(),
340 AppCommand::PopRoot => {
341 self.pop_root();
342 }
343 AppCommand::Back => {
344 if !self.pop_root() {
345 return Effect::Quit;
346 }
347 }
348 AppCommand::NextSibling => self.move_sibling(1),
349 AppCommand::PrevSibling => self.move_sibling(-1),
350 AppCommand::PageDown => self.move_focus_by(self.page_height as isize),
351 AppCommand::PageUp => self.move_focus_by(-(self.page_height as isize)),
352 AppCommand::HalfPageDown => self.move_focus_by((self.page_height / 2) as isize),
353 AppCommand::HalfPageUp => self.move_focus_by(-((self.page_height / 2) as isize)),
354 AppCommand::First => {
355 self.state.select_first();
356 }
357 AppCommand::Last => {
358 self.state.select_last();
359 }
360 AppCommand::Jump => {
361 if self.tree.fully_indexed() {
364 self.mode = Mode::Jump(Jump::open(&self.tree));
365 } else {
366 self.mode = Mode::Indexing;
367 }
368 }
369 AppCommand::Open => {
370 if let Some(id) = self.focused_id()
373 && self.tree.is_leaf(id)
374 && let Some(path) = self.tree.filesystem_path(id)
375 {
376 return Effect::Open(path);
377 }
378 }
379 AppCommand::ToggleKeybindingPanel => self.keybinding_panel.toggle(),
380 AppCommand::Quit => return Effect::Quit,
381 }
382 Effect::None
383 }
384
385 fn focused_branch(&mut self) -> Option<NodeId> {
387 let id = self.focused_id()?;
388 self.tree.ensure_children(id);
389 (!self.tree.is_leaf(id)).then_some(id)
390 }
391
392 pub fn do_work(&mut self) -> bool {
399 let _span = crate::profile::span("app::do_work");
400 if !self.tree.fully_indexed() {
401 self.state.ensure_projection(&self.tree, &self.query);
402 let visible: Vec<NodeId> = self.state.visible_ids().collect();
403 let mut budget = WORK_QUANTUM;
404 for id in visible {
405 if budget == 0 {
406 break;
407 }
408 if self.tree.ensure_children(id) {
409 budget -= 1;
410 }
411 }
412 self.tree.index_some(budget);
413 }
414 if matches!(self.mode, Mode::Indexing) && self.tree.fully_indexed() {
415 self.mode = Mode::Jump(Jump::open(&self.tree));
416 }
417 !self.tree.fully_indexed()
418 }
419
420 pub fn report_error(&mut self, message: String) {
424 self.tree.record_error(message);
425 }
426
427 pub fn has_work(&self) -> bool {
429 !self.tree.fully_indexed()
430 }
431
432 fn toggle_focused(&mut self, recursive: bool) {
437 let Some(id) = self.focused_id() else { return };
438 self.tree.ensure_children(id);
439 if self.tree.is_leaf(id) {
440 return;
441 }
442 let parent = self.tree.view_parent(id);
443 let expand = !self.state.node_is_expanded(id, parent);
444 if recursive {
445 self.set_expanded_recursively(id, expand);
446 } else if expand {
447 self.expand_and_reveal_children(id);
448 } else {
449 self.state.set_expanded(id, parent, false);
450 }
451 }
452
453 fn set_expanded_recursively(&mut self, root: NodeId, expanded: bool) {
454 let mut stack = vec![root];
455 while let Some(id) = stack.pop() {
456 if expanded {
457 self.tree.ensure_children(id);
458 }
459 if !self.tree.is_leaf(id) {
460 self.state
461 .set_expanded(id, self.tree.view_parent(id), expanded);
462 stack.extend_from_slice(self.tree.children_of(id));
463 }
464 }
465 if expanded {
466 self.reveal_expanded_children(root);
467 }
468 }
469
470 fn expand_and_reveal_children(&mut self, id: NodeId) {
471 self.state.set_expanded(id, self.tree.view_parent(id), true);
472 self.reveal_expanded_children(id);
473 }
474
475 fn reveal_expanded_children(&mut self, id: NodeId) {
479 let height = self.page_height;
480 if height == 0 {
481 return;
482 }
483 self.state.ensure_projection(&self.tree, &self.query);
484 let Some(container_index) = self.state.visible_index_of(id) else {
485 return;
486 };
487 let Some(last_child_index) = self
488 .tree
489 .children_of(id)
490 .last()
491 .and_then(|&child| self.state.visible_index_of(child))
492 else {
493 return;
494 };
495 if last_child_index < self.state.offset().saturating_add(height) {
496 return;
497 }
498
499 let branch_rows = last_child_index.saturating_sub(container_index) + 1;
500 let offset = if branch_rows <= height {
501 last_child_index + 1 - height
502 } else {
503 container_index
504 };
505 self.state.set_offset(offset);
506 }
507
508 fn push_root(&mut self) {
509 let Some(id) = self.focused_id() else {
510 return;
511 };
512 if self.tree.view_root() == Some(id) {
513 return;
514 }
515 self.tree.ensure_children(id);
516
517 self.root_history.push(self.tree.view_root());
518 self.tree.set_view_root(Some(id));
519 if !self.tree.is_leaf(id) {
520 self.state.set_expanded(id, None, true);
521 }
522 self.state.ensure_projection(&self.tree, &self.query);
523 self.state.select_id(Some(id));
524 }
525
526 fn pop_root(&mut self) -> bool {
527 let Some(previous_root) = self.root_history.pop() else {
528 return false;
529 };
530 let selected = self.state.selected_id();
531
532 if let Some(current_root) = self.tree.view_root() {
533 let expanded = self.state.node_is_expanded(current_root, None);
534 self.state
535 .set_expanded(current_root, self.tree.parent(current_root), expanded);
536 }
537
538 self.tree.set_view_root(previous_root);
539 self.state.ensure_projection(&self.tree, &self.query);
540 if !selected.is_some_and(|id| self.state.select_by_id(&self.tree, &self.query, id)) {
541 self.state.select_first();
542 }
543 true
544 }
545
546 fn move_sibling(&mut self, delta: isize) {
547 let Some(id) = self.focused_id() else { return };
548 if self.tree.view_root() == Some(id) {
549 return;
550 }
551 let siblings = match self.tree.parent(id) {
552 Some(parent) => self.tree.children_of(parent),
553 None => self.tree.root_ids(),
554 };
555 let pos = siblings.iter().position(|&s| s == id).unwrap_or(0) as isize;
556 let target = pos + delta;
557 if (0..siblings.len() as isize).contains(&target) {
558 let target = siblings[target as usize];
559 self.state.select_id(Some(target));
560 }
561 }
562
563 fn move_focus_by(&mut self, delta: isize) {
564 let len = self.state.visible_len();
565 if len == 0 {
566 return;
567 }
568 let current = self.state.selected_index().unwrap_or(0) as isize;
569 let target = (current + delta).clamp(0, len as isize - 1);
570 self.state.select_index(Some(target as usize));
571 }
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use crate::fstree;
578
579 fn fixture() -> (tempfile::TempDir, Tree) {
589 let dir = tempfile::tempdir().unwrap();
590 let p = dir.path();
591 std::fs::create_dir_all(p.join("a/aa")).unwrap();
592 std::fs::write(p.join("a/aa/aaa.txt"), "").unwrap();
593 std::fs::write(p.join("a/ab.txt"), "").unwrap();
594 std::fs::create_dir(p.join("b")).unwrap();
595 std::fs::write(p.join("b/ba.txt"), "").unwrap();
596 std::fs::write(p.join("c.txt"), "").unwrap();
597 let tree = fstree::scan(p, false).unwrap();
598 (dir, tree)
599 }
600
601 fn app() -> (tempfile::TempDir, App) {
602 let (dir, tree) = fixture();
603 (dir, App::new(tree, &Config::default(), None))
604 }
605
606 fn app_with_leaf_siblings() -> (tempfile::TempDir, App) {
612 let dir = tempfile::tempdir().unwrap();
613 let p = dir.path();
614 std::fs::create_dir(p.join("d")).unwrap();
615 std::fs::write(p.join("d/d1.txt"), "").unwrap();
616 std::fs::write(p.join("d/d2.txt"), "").unwrap();
617 let tree = fstree::scan(p, false).unwrap();
618 (dir, App::new(tree, &Config::default(), None))
619 }
620
621 fn app_with_branch_at_bottom() -> (tempfile::TempDir, App) {
628 let dir = tempfile::tempdir().unwrap();
629 let p = dir.path();
630 std::fs::create_dir(p.join("a")).unwrap();
631 std::fs::create_dir(p.join("b")).unwrap();
632 std::fs::create_dir(p.join("d")).unwrap();
633 std::fs::write(p.join("d/d1.txt"), "").unwrap();
634 std::fs::write(p.join("d/d2.txt"), "").unwrap();
635 let tree = fstree::scan(p, false).unwrap();
636 (dir, App::new(tree, &Config::default(), None))
637 }
638
639 fn focused_name(app: &mut App) -> String {
640 let id = app.focused_id().expect("something focused");
641 app.tree.name(id)
642 }
643
644 #[test]
645 fn starts_focused_on_first_row_all_collapsed() {
646 let (_d, mut app) = app();
647 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
648 assert_eq!(focused_name(&mut app), "a");
649 }
650
651 #[test]
652 fn down_and_up_move_focus_clamped() {
653 let (_d, mut app) = app();
654 app.run_command(AppCommand::Down);
655 assert_eq!(focused_name(&mut app), "b");
656 app.run_command(AppCommand::Down);
657 assert_eq!(focused_name(&mut app), "c.txt");
658 app.run_command(AppCommand::Down);
659 assert_eq!(focused_name(&mut app), "c.txt");
660 app.run_command(AppCommand::Up);
661 assert_eq!(focused_name(&mut app), "b");
662 }
663
664 #[test]
665 fn expand_reveals_children_and_down_enters_them() {
666 let (_d, mut app) = app();
667 app.run_command(AppCommand::Expand);
668 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
669 app.run_command(AppCommand::Down);
670 assert_eq!(focused_name(&mut app), "aa");
671 }
672
673 #[test]
674 fn expanding_scrolls_just_enough_to_reveal_last_child_when_branch_fits() {
675 let (_d, mut app) = app_with_branch_at_bottom();
676 app.page_height = 4;
677 app.run_command(AppCommand::Down);
678 app.run_command(AppCommand::Down); app.run_command(AppCommand::Expand);
681
682 assert_eq!(app.state.offset(), 1);
683 }
684
685 #[test]
686 fn expanding_an_oversized_branch_places_container_at_viewport_top() {
687 let (_d, mut app) = app_with_branch_at_bottom();
688 app.page_height = 2;
689 app.run_command(AppCommand::Down);
690 app.run_command(AppCommand::Down); app.run_command(AppCommand::Expand);
693
694 assert_eq!(app.state.offset(), 2);
695 }
696
697 #[test]
698 fn l_on_expanded_branch_descends_to_first_child() {
699 let (_d, mut app) = app();
700 app.handle_key(Key::parse("l").unwrap());
701
702 app.handle_key(Key::parse("l").unwrap());
703
704 assert_eq!(focused_name(&mut app), "aa");
705 }
706
707 #[test]
708 fn l_on_leaf_focuses_next_sibling() {
709 let (_d, mut app) = app_with_leaf_siblings();
710 app.run_command(AppCommand::Expand); app.run_command(AppCommand::Down); app.handle_key(Key::parse("l").unwrap());
714
715 assert_eq!(focused_name(&mut app), "d2.txt");
716 assert_eq!(app.visible_names(), ["d", "d1.txt", "d2.txt"]);
717 }
718
719 #[test]
720 fn l_on_last_leaf_of_a_container_stays_inside_it() {
721 let (_d, mut app) = app();
722 app.run_command(AppCommand::Expand); app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.handle_key(Key::parse("l").unwrap());
727
728 assert_eq!(focused_name(&mut app), "ab.txt");
730 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
731 }
732
733 #[test]
734 fn expand_on_the_last_top_level_leaf_is_a_noop() {
735 let (_d, mut app) = app();
736 app.run_command(AppCommand::Last);
737 assert_eq!(focused_name(&mut app), "c.txt");
738 assert_eq!(app.run_command(AppCommand::Expand), Effect::None);
739 assert_eq!(focused_name(&mut app), "c.txt");
740 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
741 }
742
743 #[test]
744 fn collapse_hides_children() {
745 let (_d, mut app) = app();
746 app.run_command(AppCommand::Expand);
747 app.run_command(AppCommand::Collapse);
748 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
749 }
750
751 #[test]
752 fn h_on_leaf_focuses_parent_without_collapsing_it() {
753 let (_d, mut app) = app();
754 app.run_command(AppCommand::Expand);
755 app.run_command(AppCommand::Down); app.run_command(AppCommand::Expand);
757 app.run_command(AppCommand::Down); app.handle_key(Key::parse("h").unwrap());
760
761 assert_eq!(focused_name(&mut app), "aa");
762 assert_eq!(
763 app.visible_names(),
764 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
765 );
766 }
767
768 #[test]
769 fn h_on_collapsed_branch_focuses_parent_without_collapsing_it() {
770 let (_d, mut app) = app();
771 app.run_command(AppCommand::Expand);
772 app.run_command(AppCommand::Down); app.handle_key(Key::parse("h").unwrap());
775
776 assert_eq!(focused_name(&mut app), "a");
777 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
778 }
779
780 #[test]
781 fn expand_recursively_expands_whole_subtree() {
782 let (_d, mut app) = app();
783 app.run_command(AppCommand::ExpandRecursively);
784 assert_eq!(
785 app.visible_names(),
786 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
787 );
788 }
789
790 #[test]
791 fn space_toggles_a_container_open_and_shut() {
792 let (_d, mut app) = app();
793
794 app.handle_key(Key::parse("space").unwrap());
795 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
796 assert_eq!(focused_name(&mut app), "a");
797
798 app.handle_key(Key::parse("space").unwrap());
799 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
800 assert_eq!(focused_name(&mut app), "a");
801 }
802
803 #[test]
804 fn space_on_a_leaf_does_nothing() {
805 let (_d, mut app) = app();
806 app.run_command(AppCommand::Last); app.handle_key(Key::parse("space").unwrap());
809
810 assert_eq!(focused_name(&mut app), "c.txt");
811 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
812 }
813
814 #[test]
815 fn ctrl_space_toggles_a_container_recursively() {
816 let (_d, mut app) = app();
817
818 app.handle_key(Key::parse("ctrl+space").unwrap());
819 assert_eq!(
820 app.visible_names(),
821 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
822 );
823 assert_eq!(focused_name(&mut app), "a");
824
825 app.handle_key(Key::parse("ctrl+space").unwrap());
826 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
827 app.run_command(AppCommand::Expand);
829 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
830 }
831
832 #[test]
833 fn ctrl_space_direction_follows_the_focused_container() {
834 let (_d, mut app) = app();
835 app.run_command(AppCommand::Expand); app.handle_key(Key::parse("ctrl+space").unwrap());
839
840 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
841 }
842
843 #[test]
844 fn shift_l_on_a_container_leaves_focus_on_it() {
845 let (_d, mut app) = app();
846
847 app.handle_key(Key::parse("L").unwrap());
848
849 assert_eq!(focused_name(&mut app), "a");
850 }
851
852 #[test]
853 fn shift_l_on_leaf_focuses_next_sibling() {
854 let (_d, mut app) = app_with_leaf_siblings();
855 app.run_command(AppCommand::Expand); app.run_command(AppCommand::Down); app.handle_key(Key::parse("L").unwrap());
859
860 assert_eq!(focused_name(&mut app), "d2.txt");
861 assert_eq!(app.visible_names(), ["d", "d1.txt", "d2.txt"]);
862 }
863
864 #[test]
865 fn shift_l_on_the_last_top_level_leaf_is_a_noop() {
866 let (_d, mut app) = app();
867 app.run_command(AppCommand::Last); app.handle_key(Key::parse("L").unwrap());
870
871 assert_eq!(focused_name(&mut app), "c.txt");
872 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
873 }
874
875 #[test]
876 fn collapse_recursively_collapses_whole_subtree() {
877 let (_d, mut app) = app();
878 app.run_command(AppCommand::ExpandRecursively);
879 app.run_command(AppCommand::CollapseRecursively);
880 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
881 app.run_command(AppCommand::Expand);
883 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
884 }
885
886 #[test]
887 fn shift_h_on_leaf_collapses_parent_and_focuses_it() {
888 let (_d, mut app) = app();
889 app.run_command(AppCommand::ExpandRecursively);
890 app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.handle_key(Key::parse("H").unwrap());
894
895 assert_eq!(focused_name(&mut app), "aa");
896 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
897 }
898
899 #[test]
900 fn shift_h_on_collapsed_branch_collapses_parent_and_focuses_it() {
901 let (_d, mut app) = app();
902 app.run_command(AppCommand::Expand);
903 app.run_command(AppCommand::Down); app.handle_key(Key::parse("H").unwrap());
906
907 assert_eq!(focused_name(&mut app), "a");
908 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
909 }
910
911 #[test]
912 fn shift_h_collapses_the_parent_recursively() {
913 let (_d, mut app) = app();
914 app.run_command(AppCommand::ExpandRecursively);
915 app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.handle_key(Key::parse("H").unwrap());
920
921 assert_eq!(focused_name(&mut app), "a");
922 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
923 app.run_command(AppCommand::Expand);
925 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
926 }
927
928 #[test]
929 fn shift_h_on_a_top_level_leaf_leaves_focus_alone() {
930 let (_d, mut app) = app();
931 app.run_command(AppCommand::Last); app.handle_key(Key::parse("H").unwrap());
934
935 assert_eq!(focused_name(&mut app), "c.txt");
936 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
937 }
938
939 #[test]
940 fn select_expands_collapsed_dir_and_prints_leaf() {
941 let (_d, mut app) = app();
942 assert_eq!(app.run_command(AppCommand::Select), Effect::None);
943 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
944 app.run_command(AppCommand::Last);
945 let effect = app.run_command(AppCommand::Select);
946 let Effect::PrintAndExit(path) = effect else {
947 panic!("expected PrintAndExit, got {effect:?}");
948 };
949 assert!(std::path::Path::new(&path).is_absolute());
950 assert!(std::path::Path::new(&path).ends_with("c.txt"));
951 }
952
953 #[test]
954 fn select_does_not_descend_into_an_expanded_branch() {
955 let (_d, mut app) = app();
956 app.run_command(AppCommand::Select);
957
958 app.run_command(AppCommand::Select);
959
960 assert_eq!(focused_name(&mut app), "a");
961 }
962
963 #[test]
964 fn accept_prints_even_on_dir() {
965 let (_d, mut app) = app();
966 let effect = app.run_command(AppCommand::Accept);
967 let Effect::PrintAndExit(path) = effect else {
968 panic!("expected PrintAndExit, got {effect:?}");
969 };
970 assert!(std::path::Path::new(&path).ends_with("a"));
971 }
972
973 #[test]
974 fn alt_enter_prints_the_filesystem_basename() {
975 let (_d, mut app) = app();
976
977 assert_eq!(
978 app.handle_key(Key::parse("alt+enter").unwrap()),
979 Effect::PrintAndExit(OsString::from("a"))
980 );
981 }
982
983 #[test]
984 fn o_opens_the_focused_leaf_with_its_absolute_path() {
985 let (_d, mut app) = app();
986 app.run_command(AppCommand::Last); let effect = app.handle_key(Key::parse("o").unwrap());
989
990 let Effect::Open(path) = effect else {
991 panic!("expected Open, got {effect:?}");
992 };
993 assert!(std::path::Path::new(&path).is_absolute());
994 assert!(std::path::Path::new(&path).ends_with("c.txt"));
995 }
996
997 #[test]
998 fn o_on_a_container_does_nothing() {
999 let (_d, mut app) = app();
1000 assert_eq!(focused_name(&mut app), "a"); assert_eq!(app.handle_key(Key::parse("o").unwrap()), Effect::None);
1003 }
1004
1005 #[test]
1006 fn o_on_a_json_leaf_does_nothing() {
1007 let tree = crate::json_tree::from_reader(r#"{"a": 1}"#.as_bytes()).unwrap();
1009 let mut app = App::new(tree, &Config::default(), None);
1010 app.run_command(AppCommand::Last);
1011
1012 assert_eq!(app.handle_key(Key::parse("o").unwrap()), Effect::None);
1013 }
1014
1015 #[test]
1016 fn descend_expands_and_focuses_first_child() {
1017 let (_d, mut app) = app();
1018 app.run_command(AppCommand::Descend);
1019 assert_eq!(focused_name(&mut app), "aa");
1020 }
1021
1022 #[test]
1023 fn tab_pushes_focused_nodes_as_view_roots_and_shift_tab_pops_them() {
1024 let (_d, mut app) = app();
1025
1026 app.handle_key(Key::parse("tab").unwrap());
1027 assert_eq!(focused_name(&mut app), "a");
1028 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt"]);
1029
1030 app.handle_key(Key::parse("l").unwrap()); app.handle_key(Key::parse("tab").unwrap());
1032 assert_eq!(focused_name(&mut app), "aa");
1033 assert_eq!(app.visible_names(), ["aa", "aaa.txt"]);
1034
1035 app.handle_key(Key::parse("h").unwrap()); assert_eq!(app.visible_names(), ["aa"]);
1038 app.handle_key(Key::parse("h").unwrap()); assert_eq!(focused_name(&mut app), "aa");
1040
1041 app.handle_key(Key::parse("l").unwrap()); assert_eq!(app.visible_names(), ["aa", "aaa.txt"]);
1043
1044 app.handle_key(Key::parse("shift+tab").unwrap());
1045 assert_eq!(focused_name(&mut app), "aa");
1046 assert_eq!(app.visible_names(), ["a", "aa", "aaa.txt", "ab.txt"]);
1047
1048 app.handle_key(Key::parse("shift+tab").unwrap());
1049 assert_eq!(focused_name(&mut app), "aa");
1050 assert_eq!(
1051 app.visible_names(),
1052 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
1053 );
1054
1055 app.handle_key(Key::parse("shift+tab").unwrap());
1057 assert_eq!(focused_name(&mut app), "aa");
1058 assert_eq!(
1059 app.visible_names(),
1060 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
1061 );
1062 }
1063
1064 #[test]
1065 fn tab_can_make_a_leaf_the_view_root() {
1066 let (_d, mut app) = app();
1067 app.run_command(AppCommand::Last);
1068
1069 app.handle_key(Key::parse("tab").unwrap());
1070
1071 assert_eq!(focused_name(&mut app), "c.txt");
1072 assert_eq!(app.visible_names(), ["c.txt"]);
1073 }
1074
1075 #[test]
1076 fn escape_pops_one_root_at_a_time_then_quits() {
1077 let (_d, mut app) = app();
1078 app.handle_key(Key::parse("tab").unwrap()); app.handle_key(Key::parse("l").unwrap()); app.handle_key(Key::parse("tab").unwrap()); assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::None);
1083 assert_eq!(focused_name(&mut app), "aa");
1084 assert_eq!(app.visible_names(), ["a", "aa", "aaa.txt", "ab.txt"]);
1085
1086 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::None);
1087 assert_eq!(focused_name(&mut app), "aa");
1088 assert_eq!(
1089 app.visible_names(),
1090 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
1091 );
1092
1093 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::Quit);
1094 }
1095
1096 #[test]
1097 fn sibling_navigation_skips_expanded_children() {
1098 let (_d, mut app) = app();
1099 app.run_command(AppCommand::Expand); app.run_command(AppCommand::NextSibling);
1101 assert_eq!(focused_name(&mut app), "b");
1102 app.run_command(AppCommand::PrevSibling);
1103 assert_eq!(focused_name(&mut app), "a");
1104 app.run_command(AppCommand::PrevSibling);
1106 assert_eq!(focused_name(&mut app), "a");
1107 }
1108
1109 #[test]
1110 fn first_and_last() {
1111 let (_d, mut app) = app();
1112 app.run_command(AppCommand::Last);
1113 assert_eq!(focused_name(&mut app), "c.txt");
1114 app.run_command(AppCommand::First);
1115 assert_eq!(focused_name(&mut app), "a");
1116 }
1117
1118 #[test]
1119 fn paging_moves_focus_by_page_amounts() {
1120 let (_d, mut app) = app();
1121 app.run_command(AppCommand::ExpandRecursively); app.page_height = 4;
1123 app.run_command(AppCommand::HalfPageDown);
1124 assert_eq!(focused_name(&mut app), "aaa.txt"); app.run_command(AppCommand::PageDown);
1126 assert_eq!(focused_name(&mut app), "c.txt"); app.run_command(AppCommand::HalfPageUp);
1128 assert_eq!(focused_name(&mut app), "ab.txt");
1129 app.run_command(AppCommand::PageUp);
1130 assert_eq!(focused_name(&mut app), "a");
1131 }
1132
1133 #[test]
1134 fn default_keys_drive_commands() {
1135 let (_d, mut app) = app();
1136 app.handle_key(Key::parse("j").unwrap());
1137 assert_eq!(focused_name(&mut app), "b");
1138 app.handle_key(Key::parse("k").unwrap());
1139 assert_eq!(focused_name(&mut app), "a");
1140 app.handle_key(Key::parse("l").unwrap());
1141 assert_eq!(app.visible_names().len(), 5);
1142 app.handle_key(Key::parse("h").unwrap());
1143 assert_eq!(app.visible_names().len(), 3);
1144 assert_eq!(app.handle_key(Key::parse("q").unwrap()), Effect::Quit);
1145 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::Quit);
1146 assert_eq!(app.handle_key(Key::parse("ctrl+c").unwrap()), Effect::Quit);
1147 }
1148
1149 #[test]
1150 fn g_goes_to_first_line_without_a_chord() {
1151 let (_d, mut app) = app();
1152 app.run_command(AppCommand::Last);
1153 assert_eq!(app.handle_key(Key::parse("g").unwrap()), Effect::None);
1154 assert_eq!(focused_name(&mut app), "a");
1155 }
1156
1157 #[test]
1158 fn shift_g_goes_to_last_visible_line() {
1159 let (_d, mut app) = app();
1160 app.handle_key(Key::parse("G").unwrap());
1161 assert_eq!(focused_name(&mut app), "c.txt");
1162 }
1163
1164 #[test]
1165 fn user_binding_produces_shell_effect_with_paths() {
1166 let (_d, tree) = fixture();
1167 let config = Config::parse("[ctrl+e]\nsh = \"vim $path\"\nexit = true\n").unwrap();
1168 let mut app = App::new(tree, &config, None);
1169 app.run_command(AppCommand::Down); let effect = app.handle_key(Key::parse("ctrl+e").unwrap());
1171 let Effect::RunShell {
1172 cmd,
1173 path,
1174 relpath,
1175 bg,
1176 exit,
1177 } = effect
1178 else {
1179 panic!("expected RunShell, got {effect:?}");
1180 };
1181 assert_eq!(cmd, "vim $path");
1182 assert!(std::path::Path::new(&path).is_absolute());
1183 assert!(std::path::Path::new(&path).ends_with("b"));
1184 assert_eq!(relpath, OsString::from("b"));
1185 assert!(!bg);
1186 assert!(exit);
1187 }
1188
1189 #[test]
1190 fn user_binding_overrides_default() {
1191 let (_d, tree) = fixture();
1192 let config = Config::parse("[j]\ncmd = \"quit\"\n").unwrap();
1193 let mut app = App::new(tree, &config, None);
1194 assert_eq!(app.handle_key(Key::parse("j").unwrap()), Effect::Quit);
1195 }
1196
1197 #[test]
1198 fn user_can_override_the_new_g_binding() {
1199 let (_d, tree) = fixture();
1200 let config = Config::parse("[g]\ncmd = \"quit\"\n").unwrap();
1201 let mut app = App::new(tree, &config, None);
1202 assert_eq!(app.handle_key(Key::parse("g").unwrap()), Effect::Quit);
1203 }
1204
1205 #[test]
1206 fn question_mark_is_reserved_and_toggles_the_panel() {
1207 let (_d, tree) = fixture();
1208 let config = Config::parse("[?]\ncmd = \"quit\"\nhelp = \"Wrong\"\n").unwrap();
1209 let mut app = App::new(tree, &config, None);
1210
1211 let question: Vec<_> = app
1214 .panel_entries
1215 .iter()
1216 .filter(|entry| entry.key == Key::parse("?").unwrap())
1217 .collect();
1218 assert_eq!(question.len(), 1);
1219 assert_eq!(question[0].description, "Shortcuts");
1220
1221 assert!(!app.keybinding_panel.is_open());
1222 assert_eq!(app.handle_key(Key::parse("?").unwrap()), Effect::None);
1223 assert!(app.keybinding_panel.is_open());
1224 assert_eq!(app.handle_key(Key::parse("?").unwrap()), Effect::None);
1225 assert!(!app.keybinding_panel.is_open());
1226 }
1227
1228 #[test]
1229 fn open_panel_stays_open_while_bindings_run_and_escape_only_closes_it() {
1230 let (_d, mut app) = app();
1231 app.handle_key(Key::parse("?").unwrap());
1232
1233 app.handle_key(Key::parse("j").unwrap());
1234 assert_eq!(focused_name(&mut app), "b");
1235 assert!(app.keybinding_panel.is_open());
1236
1237 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::None);
1238 assert!(!app.keybinding_panel.is_open());
1239 assert_eq!(focused_name(&mut app), "b");
1240 }
1241
1242 #[test]
1243 fn jump_owns_question_mark_and_escape_without_dismissing_the_panel() {
1244 let (_d, mut app) = app();
1245 while app.do_work() {}
1246 app.handle_key(Key::parse("?").unwrap());
1247 app.handle_key(Key::parse("/").unwrap());
1248 app.handle_key(Key::parse("?").unwrap());
1249
1250 let Mode::Jump(jump) = &app.mode else {
1251 panic!("expected jump mode");
1252 };
1253 assert_eq!(jump.query(), "?");
1254 assert!(app.keybinding_panel.is_open());
1255
1256 app.handle_key(Key::parse("esc").unwrap());
1257 assert!(matches!(app.mode, Mode::Normal));
1258 assert!(app.keybinding_panel.is_open());
1259 }
1260
1261 #[test]
1262 fn unbound_key_is_noop() {
1263 let (_d, mut app) = app();
1264 assert_eq!(app.handle_key(Key::parse("x").unwrap()), Effect::None);
1265 }
1266
1267 #[test]
1268 fn initial_expand_depth_one_expands_top_level_only() {
1269 let (_d, tree) = fixture();
1270 let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::Depth(1)));
1271 assert_eq!(
1272 app.visible_names(),
1273 ["a", "aa", "ab.txt", "b", "ba.txt", "c.txt"]
1274 );
1275 }
1276
1277 #[test]
1278 fn initial_expand_all_expands_everything() {
1279 let (_d, tree) = fixture();
1280 let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
1281 assert_eq!(
1282 app.visible_names(),
1283 ["a", "aa", "aaa.txt", "ab.txt", "b", "ba.txt", "c.txt"]
1284 );
1285 }
1286
1287 fn in_jump(app: &App) -> bool {
1288 matches!(app.mode, Mode::Jump(_))
1289 }
1290
1291 #[test]
1292 fn slash_opens_the_jump_picker() {
1293 let (_d, mut app) = app();
1294 assert!(!in_jump(&app));
1295 while app.do_work() {} app.handle_key(Key::parse("/").unwrap());
1297 assert!(in_jump(&app));
1298 }
1299
1300 #[test]
1301 fn cancelling_the_picker_leaves_focus_untouched() {
1302 let (_d, mut app) = app();
1303 app.run_command(AppCommand::Down); assert_eq!(focused_name(&mut app), "b");
1305 app.handle_key(Key::parse("/").unwrap());
1306 app.handle_key(Key::parse("a").unwrap()); app.handle_key(Key::parse("esc").unwrap());
1308 assert!(!in_jump(&app));
1309 assert_eq!(focused_name(&mut app), "b");
1310 }
1311
1312 #[test]
1313 fn accepting_jumps_focus_and_expands_ancestors() {
1314 let (_d, mut app) = app();
1315 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
1317 while app.do_work() {} app.handle_key(Key::parse("/").unwrap());
1319 for k in ["a", "a", "a"] {
1320 app.handle_key(Key::parse(k).unwrap()); }
1322 app.handle_key(Key::parse("enter").unwrap());
1323 assert!(!in_jump(&app));
1324 assert_eq!(focused_name(&mut app), "aaa.txt");
1325 assert!(app.visible_names().contains(&"aaa.txt".to_string()));
1327 }
1328
1329 #[test]
1330 fn a_user_can_rebind_jump_off_slash() {
1331 let (_d, tree) = fixture();
1332 let config = Config::parse("[ctrl+p]\ncmd = \"jump\"\n").unwrap();
1333 let mut app = App::new(tree, &config, None);
1334 while app.do_work() {} app.handle_key(Key::parse("ctrl+p").unwrap());
1336 assert!(in_jump(&app));
1337 }
1338
1339 #[test]
1340 fn a_single_rooted_tree_opens_with_its_first_level_expanded() {
1341 use crate::tree::ActionValues;
1342 let mut tree = Tree::new();
1343 let root = tree.push(None, "root", true, ActionValues::new("", "", ""));
1344 tree.push(Some(root), "child", false, ActionValues::new("", "", ""));
1345 let mut app = App::new(tree, &Config::default(), None);
1346
1347 assert_eq!(app.visible_names(), ["root", "child"]);
1348 assert_eq!(focused_name(&mut app), "root");
1349 }
1350
1351 #[test]
1352 fn expanding_an_unindexed_container_materializes_its_children() {
1353 let tree =
1354 crate::json_tree::from_reader(r#"{"users": [1, 2], "n": 3}"#.as_bytes()).unwrap();
1355 let mut app = App::new(tree, &Config::default(), None);
1356 assert_eq!(app.visible_names(), ["users [2]", "n: 3"]);
1357
1358 app.handle_key(Key::parse("l").unwrap());
1359
1360 assert_eq!(
1361 app.visible_names(),
1362 ["users [2]", "[0]: 1", "[1]: 2", "n: 3"]
1363 );
1364 }
1365
1366 #[test]
1367 fn jump_blocks_on_an_incomplete_index_and_opens_when_done() {
1368 let tree =
1369 crate::json_tree::from_reader(r#"{"a": {"b": 1}, "c": {"d": 2}}"#.as_bytes()).unwrap();
1370 let mut app = App::new(tree, &Config::default(), None);
1371 assert!(app.has_work());
1372
1373 app.handle_key(Key::parse("/").unwrap());
1374 assert!(matches!(app.mode, Mode::Indexing));
1375 assert_eq!(app.handle_key(Key::parse("j").unwrap()), Effect::None);
1377 assert!(matches!(app.mode, Mode::Indexing));
1378
1379 while app.do_work() {}
1380
1381 assert!(matches!(app.mode, Mode::Jump(_)));
1382 assert!(!app.has_work());
1383 }
1384
1385 #[test]
1386 fn escape_cancels_the_indexing_wait() {
1387 let tree =
1388 crate::json_tree::from_reader(r#"{"a": {"b": 1}, "c": {"d": 2}}"#.as_bytes()).unwrap();
1389 let mut app = App::new(tree, &Config::default(), None);
1390 app.handle_key(Key::parse("/").unwrap());
1391 assert!(matches!(app.mode, Mode::Indexing));
1392
1393 app.handle_key(Key::parse("esc").unwrap());
1394
1395 assert!(matches!(app.mode, Mode::Normal));
1396 }
1397}