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.state.set_expanded(id, parent, true);
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.state.set_expanded(id, self.tree.view_parent(id), true);
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.state.set_expanded(id, self.tree.view_parent(id), true);
335 self.state.ensure_projection(&self.tree, &self.query);
336 let first_child = self.tree.children_of(id)[0];
337 self.state.select_id(Some(first_child));
338 }
339 }
340 AppCommand::Root => self.push_root(),
341 AppCommand::PopRoot => {
342 self.pop_root();
343 }
344 AppCommand::Back => {
345 if !self.pop_root() {
346 return Effect::Quit;
347 }
348 }
349 AppCommand::NextSibling => self.move_sibling(1),
350 AppCommand::PrevSibling => self.move_sibling(-1),
351 AppCommand::PageDown => self.move_focus_by(self.page_height as isize),
352 AppCommand::PageUp => self.move_focus_by(-(self.page_height as isize)),
353 AppCommand::HalfPageDown => self.move_focus_by((self.page_height / 2) as isize),
354 AppCommand::HalfPageUp => self.move_focus_by(-((self.page_height / 2) as isize)),
355 AppCommand::First => {
356 self.state.select_first();
357 }
358 AppCommand::Last => {
359 self.state.select_last();
360 }
361 AppCommand::Jump => {
362 if self.tree.fully_indexed() {
365 self.mode = Mode::Jump(Jump::open(&self.tree));
366 } else {
367 self.mode = Mode::Indexing;
368 }
369 }
370 AppCommand::Open => {
371 if let Some(id) = self.focused_id()
374 && self.tree.is_leaf(id)
375 && let Some(path) = self.tree.filesystem_path(id)
376 {
377 return Effect::Open(path);
378 }
379 }
380 AppCommand::ToggleKeybindingPanel => self.keybinding_panel.toggle(),
381 AppCommand::Quit => return Effect::Quit,
382 }
383 Effect::None
384 }
385
386 fn focused_branch(&mut self) -> Option<NodeId> {
388 let id = self.focused_id()?;
389 self.tree.ensure_children(id);
390 (!self.tree.is_leaf(id)).then_some(id)
391 }
392
393 pub fn do_work(&mut self) -> bool {
400 let _span = crate::profile::span("app::do_work");
401 if !self.tree.fully_indexed() {
402 self.state.ensure_projection(&self.tree, &self.query);
403 let visible: Vec<NodeId> = self.state.visible_ids().collect();
404 let mut budget = WORK_QUANTUM;
405 for id in visible {
406 if budget == 0 {
407 break;
408 }
409 if self.tree.ensure_children(id) {
410 budget -= 1;
411 }
412 }
413 self.tree.index_some(budget);
414 }
415 if matches!(self.mode, Mode::Indexing) && self.tree.fully_indexed() {
416 self.mode = Mode::Jump(Jump::open(&self.tree));
417 }
418 !self.tree.fully_indexed()
419 }
420
421 pub fn report_error(&mut self, message: String) {
425 self.tree.record_error(message);
426 }
427
428 pub fn has_work(&self) -> bool {
430 !self.tree.fully_indexed()
431 }
432
433 fn toggle_focused(&mut self, recursive: bool) {
438 let Some(id) = self.focused_id() else { return };
439 self.tree.ensure_children(id);
440 if self.tree.is_leaf(id) {
441 return;
442 }
443 let parent = self.tree.view_parent(id);
444 let expand = !self.state.node_is_expanded(id, parent);
445 if recursive {
446 self.set_expanded_recursively(id, expand);
447 } else {
448 self.state.set_expanded(id, parent, expand);
449 }
450 }
451
452 fn set_expanded_recursively(&mut self, root: NodeId, expanded: bool) {
453 let mut stack = vec![root];
454 while let Some(id) = stack.pop() {
455 if expanded {
456 self.tree.ensure_children(id);
457 }
458 if !self.tree.is_leaf(id) {
459 self.state
460 .set_expanded(id, self.tree.view_parent(id), expanded);
461 stack.extend_from_slice(self.tree.children_of(id));
462 }
463 }
464 }
465
466 fn push_root(&mut self) {
467 let Some(id) = self.focused_id() else {
468 return;
469 };
470 if self.tree.view_root() == Some(id) {
471 return;
472 }
473 self.tree.ensure_children(id);
474
475 self.root_history.push(self.tree.view_root());
476 self.tree.set_view_root(Some(id));
477 if !self.tree.is_leaf(id) {
478 self.state.set_expanded(id, None, true);
479 }
480 self.state.ensure_projection(&self.tree, &self.query);
481 self.state.select_id(Some(id));
482 }
483
484 fn pop_root(&mut self) -> bool {
485 let Some(previous_root) = self.root_history.pop() else {
486 return false;
487 };
488 let selected = self.state.selected_id();
489
490 if let Some(current_root) = self.tree.view_root() {
491 let expanded = self.state.node_is_expanded(current_root, None);
492 self.state
493 .set_expanded(current_root, self.tree.parent(current_root), expanded);
494 }
495
496 self.tree.set_view_root(previous_root);
497 self.state.ensure_projection(&self.tree, &self.query);
498 if !selected.is_some_and(|id| self.state.select_by_id(&self.tree, &self.query, id)) {
499 self.state.select_first();
500 }
501 true
502 }
503
504 fn move_sibling(&mut self, delta: isize) {
505 let Some(id) = self.focused_id() else { return };
506 if self.tree.view_root() == Some(id) {
507 return;
508 }
509 let siblings = match self.tree.parent(id) {
510 Some(parent) => self.tree.children_of(parent),
511 None => self.tree.root_ids(),
512 };
513 let pos = siblings.iter().position(|&s| s == id).unwrap_or(0) as isize;
514 let target = pos + delta;
515 if (0..siblings.len() as isize).contains(&target) {
516 let target = siblings[target as usize];
517 self.state.select_id(Some(target));
518 }
519 }
520
521 fn move_focus_by(&mut self, delta: isize) {
522 let len = self.state.visible_len();
523 if len == 0 {
524 return;
525 }
526 let current = self.state.selected_index().unwrap_or(0) as isize;
527 let target = (current + delta).clamp(0, len as isize - 1);
528 self.state.select_index(Some(target as usize));
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use crate::fstree;
536
537 fn fixture() -> (tempfile::TempDir, Tree) {
547 let dir = tempfile::tempdir().unwrap();
548 let p = dir.path();
549 std::fs::create_dir_all(p.join("a/aa")).unwrap();
550 std::fs::write(p.join("a/aa/aaa.txt"), "").unwrap();
551 std::fs::write(p.join("a/ab.txt"), "").unwrap();
552 std::fs::create_dir(p.join("b")).unwrap();
553 std::fs::write(p.join("b/ba.txt"), "").unwrap();
554 std::fs::write(p.join("c.txt"), "").unwrap();
555 let tree = fstree::scan(p, false).unwrap();
556 (dir, tree)
557 }
558
559 fn app() -> (tempfile::TempDir, App) {
560 let (dir, tree) = fixture();
561 (dir, App::new(tree, &Config::default(), None))
562 }
563
564 fn app_with_leaf_siblings() -> (tempfile::TempDir, App) {
570 let dir = tempfile::tempdir().unwrap();
571 let p = dir.path();
572 std::fs::create_dir(p.join("d")).unwrap();
573 std::fs::write(p.join("d/d1.txt"), "").unwrap();
574 std::fs::write(p.join("d/d2.txt"), "").unwrap();
575 let tree = fstree::scan(p, false).unwrap();
576 (dir, App::new(tree, &Config::default(), None))
577 }
578
579 fn focused_name(app: &mut App) -> String {
580 let id = app.focused_id().expect("something focused");
581 app.tree.name(id)
582 }
583
584 #[test]
585 fn starts_focused_on_first_row_all_collapsed() {
586 let (_d, mut app) = app();
587 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
588 assert_eq!(focused_name(&mut app), "a");
589 }
590
591 #[test]
592 fn down_and_up_move_focus_clamped() {
593 let (_d, mut app) = app();
594 app.run_command(AppCommand::Down);
595 assert_eq!(focused_name(&mut app), "b");
596 app.run_command(AppCommand::Down);
597 assert_eq!(focused_name(&mut app), "c.txt");
598 app.run_command(AppCommand::Down);
599 assert_eq!(focused_name(&mut app), "c.txt");
600 app.run_command(AppCommand::Up);
601 assert_eq!(focused_name(&mut app), "b");
602 }
603
604 #[test]
605 fn expand_reveals_children_and_down_enters_them() {
606 let (_d, mut app) = app();
607 app.run_command(AppCommand::Expand);
608 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
609 app.run_command(AppCommand::Down);
610 assert_eq!(focused_name(&mut app), "aa");
611 }
612
613 #[test]
614 fn l_on_expanded_branch_descends_to_first_child() {
615 let (_d, mut app) = app();
616 app.handle_key(Key::parse("l").unwrap());
617
618 app.handle_key(Key::parse("l").unwrap());
619
620 assert_eq!(focused_name(&mut app), "aa");
621 }
622
623 #[test]
624 fn l_on_leaf_focuses_next_sibling() {
625 let (_d, mut app) = app_with_leaf_siblings();
626 app.run_command(AppCommand::Expand); app.run_command(AppCommand::Down); app.handle_key(Key::parse("l").unwrap());
630
631 assert_eq!(focused_name(&mut app), "d2.txt");
632 assert_eq!(app.visible_names(), ["d", "d1.txt", "d2.txt"]);
633 }
634
635 #[test]
636 fn l_on_last_leaf_of_a_container_stays_inside_it() {
637 let (_d, mut app) = app();
638 app.run_command(AppCommand::Expand); app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.handle_key(Key::parse("l").unwrap());
643
644 assert_eq!(focused_name(&mut app), "ab.txt");
646 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
647 }
648
649 #[test]
650 fn expand_on_the_last_top_level_leaf_is_a_noop() {
651 let (_d, mut app) = app();
652 app.run_command(AppCommand::Last);
653 assert_eq!(focused_name(&mut app), "c.txt");
654 assert_eq!(app.run_command(AppCommand::Expand), Effect::None);
655 assert_eq!(focused_name(&mut app), "c.txt");
656 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
657 }
658
659 #[test]
660 fn collapse_hides_children() {
661 let (_d, mut app) = app();
662 app.run_command(AppCommand::Expand);
663 app.run_command(AppCommand::Collapse);
664 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
665 }
666
667 #[test]
668 fn h_on_leaf_focuses_parent_without_collapsing_it() {
669 let (_d, mut app) = app();
670 app.run_command(AppCommand::Expand);
671 app.run_command(AppCommand::Down); app.run_command(AppCommand::Expand);
673 app.run_command(AppCommand::Down); app.handle_key(Key::parse("h").unwrap());
676
677 assert_eq!(focused_name(&mut app), "aa");
678 assert_eq!(
679 app.visible_names(),
680 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
681 );
682 }
683
684 #[test]
685 fn h_on_collapsed_branch_focuses_parent_without_collapsing_it() {
686 let (_d, mut app) = app();
687 app.run_command(AppCommand::Expand);
688 app.run_command(AppCommand::Down); app.handle_key(Key::parse("h").unwrap());
691
692 assert_eq!(focused_name(&mut app), "a");
693 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
694 }
695
696 #[test]
697 fn expand_recursively_expands_whole_subtree() {
698 let (_d, mut app) = app();
699 app.run_command(AppCommand::ExpandRecursively);
700 assert_eq!(
701 app.visible_names(),
702 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
703 );
704 }
705
706 #[test]
707 fn space_toggles_a_container_open_and_shut() {
708 let (_d, mut app) = app();
709
710 app.handle_key(Key::parse("space").unwrap());
711 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
712 assert_eq!(focused_name(&mut app), "a");
713
714 app.handle_key(Key::parse("space").unwrap());
715 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
716 assert_eq!(focused_name(&mut app), "a");
717 }
718
719 #[test]
720 fn space_on_a_leaf_does_nothing() {
721 let (_d, mut app) = app();
722 app.run_command(AppCommand::Last); app.handle_key(Key::parse("space").unwrap());
725
726 assert_eq!(focused_name(&mut app), "c.txt");
727 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
728 }
729
730 #[test]
731 fn ctrl_space_toggles_a_container_recursively() {
732 let (_d, mut app) = app();
733
734 app.handle_key(Key::parse("ctrl+space").unwrap());
735 assert_eq!(
736 app.visible_names(),
737 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
738 );
739 assert_eq!(focused_name(&mut app), "a");
740
741 app.handle_key(Key::parse("ctrl+space").unwrap());
742 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
743 app.run_command(AppCommand::Expand);
745 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
746 }
747
748 #[test]
749 fn ctrl_space_direction_follows_the_focused_container() {
750 let (_d, mut app) = app();
751 app.run_command(AppCommand::Expand); app.handle_key(Key::parse("ctrl+space").unwrap());
755
756 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
757 }
758
759 #[test]
760 fn shift_l_on_a_container_leaves_focus_on_it() {
761 let (_d, mut app) = app();
762
763 app.handle_key(Key::parse("L").unwrap());
764
765 assert_eq!(focused_name(&mut app), "a");
766 }
767
768 #[test]
769 fn shift_l_on_leaf_focuses_next_sibling() {
770 let (_d, mut app) = app_with_leaf_siblings();
771 app.run_command(AppCommand::Expand); app.run_command(AppCommand::Down); app.handle_key(Key::parse("L").unwrap());
775
776 assert_eq!(focused_name(&mut app), "d2.txt");
777 assert_eq!(app.visible_names(), ["d", "d1.txt", "d2.txt"]);
778 }
779
780 #[test]
781 fn shift_l_on_the_last_top_level_leaf_is_a_noop() {
782 let (_d, mut app) = app();
783 app.run_command(AppCommand::Last); app.handle_key(Key::parse("L").unwrap());
786
787 assert_eq!(focused_name(&mut app), "c.txt");
788 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
789 }
790
791 #[test]
792 fn collapse_recursively_collapses_whole_subtree() {
793 let (_d, mut app) = app();
794 app.run_command(AppCommand::ExpandRecursively);
795 app.run_command(AppCommand::CollapseRecursively);
796 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
797 app.run_command(AppCommand::Expand);
799 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
800 }
801
802 #[test]
803 fn shift_h_on_leaf_collapses_parent_and_focuses_it() {
804 let (_d, mut app) = app();
805 app.run_command(AppCommand::ExpandRecursively);
806 app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.handle_key(Key::parse("H").unwrap());
810
811 assert_eq!(focused_name(&mut app), "aa");
812 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
813 }
814
815 #[test]
816 fn shift_h_on_collapsed_branch_collapses_parent_and_focuses_it() {
817 let (_d, mut app) = app();
818 app.run_command(AppCommand::Expand);
819 app.run_command(AppCommand::Down); app.handle_key(Key::parse("H").unwrap());
822
823 assert_eq!(focused_name(&mut app), "a");
824 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
825 }
826
827 #[test]
828 fn shift_h_collapses_the_parent_recursively() {
829 let (_d, mut app) = app();
830 app.run_command(AppCommand::ExpandRecursively);
831 app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.handle_key(Key::parse("H").unwrap());
836
837 assert_eq!(focused_name(&mut app), "a");
838 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
839 app.run_command(AppCommand::Expand);
841 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
842 }
843
844 #[test]
845 fn shift_h_on_a_top_level_leaf_leaves_focus_alone() {
846 let (_d, mut app) = app();
847 app.run_command(AppCommand::Last); app.handle_key(Key::parse("H").unwrap());
850
851 assert_eq!(focused_name(&mut app), "c.txt");
852 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
853 }
854
855 #[test]
856 fn select_expands_collapsed_dir_and_prints_leaf() {
857 let (_d, mut app) = app();
858 assert_eq!(app.run_command(AppCommand::Select), Effect::None);
859 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
860 app.run_command(AppCommand::Last);
861 let effect = app.run_command(AppCommand::Select);
862 let Effect::PrintAndExit(path) = effect else {
863 panic!("expected PrintAndExit, got {effect:?}");
864 };
865 assert!(std::path::Path::new(&path).is_absolute());
866 assert!(std::path::Path::new(&path).ends_with("c.txt"));
867 }
868
869 #[test]
870 fn select_does_not_descend_into_an_expanded_branch() {
871 let (_d, mut app) = app();
872 app.run_command(AppCommand::Select);
873
874 app.run_command(AppCommand::Select);
875
876 assert_eq!(focused_name(&mut app), "a");
877 }
878
879 #[test]
880 fn accept_prints_even_on_dir() {
881 let (_d, mut app) = app();
882 let effect = app.run_command(AppCommand::Accept);
883 let Effect::PrintAndExit(path) = effect else {
884 panic!("expected PrintAndExit, got {effect:?}");
885 };
886 assert!(std::path::Path::new(&path).ends_with("a"));
887 }
888
889 #[test]
890 fn alt_enter_prints_the_filesystem_basename() {
891 let (_d, mut app) = app();
892
893 assert_eq!(
894 app.handle_key(Key::parse("alt+enter").unwrap()),
895 Effect::PrintAndExit(OsString::from("a"))
896 );
897 }
898
899 #[test]
900 fn o_opens_the_focused_leaf_with_its_absolute_path() {
901 let (_d, mut app) = app();
902 app.run_command(AppCommand::Last); let effect = app.handle_key(Key::parse("o").unwrap());
905
906 let Effect::Open(path) = effect else {
907 panic!("expected Open, got {effect:?}");
908 };
909 assert!(std::path::Path::new(&path).is_absolute());
910 assert!(std::path::Path::new(&path).ends_with("c.txt"));
911 }
912
913 #[test]
914 fn o_on_a_container_does_nothing() {
915 let (_d, mut app) = app();
916 assert_eq!(focused_name(&mut app), "a"); assert_eq!(app.handle_key(Key::parse("o").unwrap()), Effect::None);
919 }
920
921 #[test]
922 fn o_on_a_json_leaf_does_nothing() {
923 let tree = crate::json_tree::from_reader(r#"{"a": 1}"#.as_bytes()).unwrap();
925 let mut app = App::new(tree, &Config::default(), None);
926 app.run_command(AppCommand::Last);
927
928 assert_eq!(app.handle_key(Key::parse("o").unwrap()), Effect::None);
929 }
930
931 #[test]
932 fn descend_expands_and_focuses_first_child() {
933 let (_d, mut app) = app();
934 app.run_command(AppCommand::Descend);
935 assert_eq!(focused_name(&mut app), "aa");
936 }
937
938 #[test]
939 fn tab_pushes_focused_nodes_as_view_roots_and_shift_tab_pops_them() {
940 let (_d, mut app) = app();
941
942 app.handle_key(Key::parse("tab").unwrap());
943 assert_eq!(focused_name(&mut app), "a");
944 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt"]);
945
946 app.handle_key(Key::parse("l").unwrap()); app.handle_key(Key::parse("tab").unwrap());
948 assert_eq!(focused_name(&mut app), "aa");
949 assert_eq!(app.visible_names(), ["aa", "aaa.txt"]);
950
951 app.handle_key(Key::parse("h").unwrap()); assert_eq!(app.visible_names(), ["aa"]);
954 app.handle_key(Key::parse("h").unwrap()); assert_eq!(focused_name(&mut app), "aa");
956
957 app.handle_key(Key::parse("l").unwrap()); assert_eq!(app.visible_names(), ["aa", "aaa.txt"]);
959
960 app.handle_key(Key::parse("shift+tab").unwrap());
961 assert_eq!(focused_name(&mut app), "aa");
962 assert_eq!(app.visible_names(), ["a", "aa", "aaa.txt", "ab.txt"]);
963
964 app.handle_key(Key::parse("shift+tab").unwrap());
965 assert_eq!(focused_name(&mut app), "aa");
966 assert_eq!(
967 app.visible_names(),
968 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
969 );
970
971 app.handle_key(Key::parse("shift+tab").unwrap());
973 assert_eq!(focused_name(&mut app), "aa");
974 assert_eq!(
975 app.visible_names(),
976 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
977 );
978 }
979
980 #[test]
981 fn tab_can_make_a_leaf_the_view_root() {
982 let (_d, mut app) = app();
983 app.run_command(AppCommand::Last);
984
985 app.handle_key(Key::parse("tab").unwrap());
986
987 assert_eq!(focused_name(&mut app), "c.txt");
988 assert_eq!(app.visible_names(), ["c.txt"]);
989 }
990
991 #[test]
992 fn escape_pops_one_root_at_a_time_then_quits() {
993 let (_d, mut app) = app();
994 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);
999 assert_eq!(focused_name(&mut app), "aa");
1000 assert_eq!(app.visible_names(), ["a", "aa", "aaa.txt", "ab.txt"]);
1001
1002 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::None);
1003 assert_eq!(focused_name(&mut app), "aa");
1004 assert_eq!(
1005 app.visible_names(),
1006 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
1007 );
1008
1009 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::Quit);
1010 }
1011
1012 #[test]
1013 fn sibling_navigation_skips_expanded_children() {
1014 let (_d, mut app) = app();
1015 app.run_command(AppCommand::Expand); app.run_command(AppCommand::NextSibling);
1017 assert_eq!(focused_name(&mut app), "b");
1018 app.run_command(AppCommand::PrevSibling);
1019 assert_eq!(focused_name(&mut app), "a");
1020 app.run_command(AppCommand::PrevSibling);
1022 assert_eq!(focused_name(&mut app), "a");
1023 }
1024
1025 #[test]
1026 fn first_and_last() {
1027 let (_d, mut app) = app();
1028 app.run_command(AppCommand::Last);
1029 assert_eq!(focused_name(&mut app), "c.txt");
1030 app.run_command(AppCommand::First);
1031 assert_eq!(focused_name(&mut app), "a");
1032 }
1033
1034 #[test]
1035 fn paging_moves_focus_by_page_amounts() {
1036 let (_d, mut app) = app();
1037 app.run_command(AppCommand::ExpandRecursively); app.page_height = 4;
1039 app.run_command(AppCommand::HalfPageDown);
1040 assert_eq!(focused_name(&mut app), "aaa.txt"); app.run_command(AppCommand::PageDown);
1042 assert_eq!(focused_name(&mut app), "c.txt"); app.run_command(AppCommand::HalfPageUp);
1044 assert_eq!(focused_name(&mut app), "ab.txt");
1045 app.run_command(AppCommand::PageUp);
1046 assert_eq!(focused_name(&mut app), "a");
1047 }
1048
1049 #[test]
1050 fn default_keys_drive_commands() {
1051 let (_d, mut app) = app();
1052 app.handle_key(Key::parse("j").unwrap());
1053 assert_eq!(focused_name(&mut app), "b");
1054 app.handle_key(Key::parse("k").unwrap());
1055 assert_eq!(focused_name(&mut app), "a");
1056 app.handle_key(Key::parse("l").unwrap());
1057 assert_eq!(app.visible_names().len(), 5);
1058 app.handle_key(Key::parse("h").unwrap());
1059 assert_eq!(app.visible_names().len(), 3);
1060 assert_eq!(app.handle_key(Key::parse("q").unwrap()), Effect::Quit);
1061 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::Quit);
1062 assert_eq!(app.handle_key(Key::parse("ctrl+c").unwrap()), Effect::Quit);
1063 }
1064
1065 #[test]
1066 fn g_goes_to_first_line_without_a_chord() {
1067 let (_d, mut app) = app();
1068 app.run_command(AppCommand::Last);
1069 assert_eq!(app.handle_key(Key::parse("g").unwrap()), Effect::None);
1070 assert_eq!(focused_name(&mut app), "a");
1071 }
1072
1073 #[test]
1074 fn shift_g_goes_to_last_visible_line() {
1075 let (_d, mut app) = app();
1076 app.handle_key(Key::parse("G").unwrap());
1077 assert_eq!(focused_name(&mut app), "c.txt");
1078 }
1079
1080 #[test]
1081 fn user_binding_produces_shell_effect_with_paths() {
1082 let (_d, tree) = fixture();
1083 let config = Config::parse("[ctrl+e]\nsh = \"vim $path\"\nexit = true\n").unwrap();
1084 let mut app = App::new(tree, &config, None);
1085 app.run_command(AppCommand::Down); let effect = app.handle_key(Key::parse("ctrl+e").unwrap());
1087 let Effect::RunShell {
1088 cmd,
1089 path,
1090 relpath,
1091 bg,
1092 exit,
1093 } = effect
1094 else {
1095 panic!("expected RunShell, got {effect:?}");
1096 };
1097 assert_eq!(cmd, "vim $path");
1098 assert!(std::path::Path::new(&path).is_absolute());
1099 assert!(std::path::Path::new(&path).ends_with("b"));
1100 assert_eq!(relpath, OsString::from("b"));
1101 assert!(!bg);
1102 assert!(exit);
1103 }
1104
1105 #[test]
1106 fn user_binding_overrides_default() {
1107 let (_d, tree) = fixture();
1108 let config = Config::parse("[j]\ncmd = \"quit\"\n").unwrap();
1109 let mut app = App::new(tree, &config, None);
1110 assert_eq!(app.handle_key(Key::parse("j").unwrap()), Effect::Quit);
1111 }
1112
1113 #[test]
1114 fn user_can_override_the_new_g_binding() {
1115 let (_d, tree) = fixture();
1116 let config = Config::parse("[g]\ncmd = \"quit\"\n").unwrap();
1117 let mut app = App::new(tree, &config, None);
1118 assert_eq!(app.handle_key(Key::parse("g").unwrap()), Effect::Quit);
1119 }
1120
1121 #[test]
1122 fn question_mark_is_reserved_and_toggles_the_panel() {
1123 let (_d, tree) = fixture();
1124 let config = Config::parse("[?]\ncmd = \"quit\"\nhelp = \"Wrong\"\n").unwrap();
1125 let mut app = App::new(tree, &config, None);
1126
1127 let question: Vec<_> = app
1130 .panel_entries
1131 .iter()
1132 .filter(|entry| entry.key == Key::parse("?").unwrap())
1133 .collect();
1134 assert_eq!(question.len(), 1);
1135 assert_eq!(question[0].description, "Shortcuts");
1136
1137 assert!(!app.keybinding_panel.is_open());
1138 assert_eq!(app.handle_key(Key::parse("?").unwrap()), Effect::None);
1139 assert!(app.keybinding_panel.is_open());
1140 assert_eq!(app.handle_key(Key::parse("?").unwrap()), Effect::None);
1141 assert!(!app.keybinding_panel.is_open());
1142 }
1143
1144 #[test]
1145 fn open_panel_stays_open_while_bindings_run_and_escape_only_closes_it() {
1146 let (_d, mut app) = app();
1147 app.handle_key(Key::parse("?").unwrap());
1148
1149 app.handle_key(Key::parse("j").unwrap());
1150 assert_eq!(focused_name(&mut app), "b");
1151 assert!(app.keybinding_panel.is_open());
1152
1153 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::None);
1154 assert!(!app.keybinding_panel.is_open());
1155 assert_eq!(focused_name(&mut app), "b");
1156 }
1157
1158 #[test]
1159 fn jump_owns_question_mark_and_escape_without_dismissing_the_panel() {
1160 let (_d, mut app) = app();
1161 while app.do_work() {}
1162 app.handle_key(Key::parse("?").unwrap());
1163 app.handle_key(Key::parse("/").unwrap());
1164 app.handle_key(Key::parse("?").unwrap());
1165
1166 let Mode::Jump(jump) = &app.mode else {
1167 panic!("expected jump mode");
1168 };
1169 assert_eq!(jump.query(), "?");
1170 assert!(app.keybinding_panel.is_open());
1171
1172 app.handle_key(Key::parse("esc").unwrap());
1173 assert!(matches!(app.mode, Mode::Normal));
1174 assert!(app.keybinding_panel.is_open());
1175 }
1176
1177 #[test]
1178 fn unbound_key_is_noop() {
1179 let (_d, mut app) = app();
1180 assert_eq!(app.handle_key(Key::parse("x").unwrap()), Effect::None);
1181 }
1182
1183 #[test]
1184 fn initial_expand_depth_one_expands_top_level_only() {
1185 let (_d, tree) = fixture();
1186 let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::Depth(1)));
1187 assert_eq!(
1188 app.visible_names(),
1189 ["a", "aa", "ab.txt", "b", "ba.txt", "c.txt"]
1190 );
1191 }
1192
1193 #[test]
1194 fn initial_expand_all_expands_everything() {
1195 let (_d, tree) = fixture();
1196 let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
1197 assert_eq!(
1198 app.visible_names(),
1199 ["a", "aa", "aaa.txt", "ab.txt", "b", "ba.txt", "c.txt"]
1200 );
1201 }
1202
1203 fn in_jump(app: &App) -> bool {
1204 matches!(app.mode, Mode::Jump(_))
1205 }
1206
1207 #[test]
1208 fn slash_opens_the_jump_picker() {
1209 let (_d, mut app) = app();
1210 assert!(!in_jump(&app));
1211 while app.do_work() {} app.handle_key(Key::parse("/").unwrap());
1213 assert!(in_jump(&app));
1214 }
1215
1216 #[test]
1217 fn cancelling_the_picker_leaves_focus_untouched() {
1218 let (_d, mut app) = app();
1219 app.run_command(AppCommand::Down); assert_eq!(focused_name(&mut app), "b");
1221 app.handle_key(Key::parse("/").unwrap());
1222 app.handle_key(Key::parse("a").unwrap()); app.handle_key(Key::parse("esc").unwrap());
1224 assert!(!in_jump(&app));
1225 assert_eq!(focused_name(&mut app), "b");
1226 }
1227
1228 #[test]
1229 fn accepting_jumps_focus_and_expands_ancestors() {
1230 let (_d, mut app) = app();
1231 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
1233 while app.do_work() {} app.handle_key(Key::parse("/").unwrap());
1235 for k in ["a", "a", "a"] {
1236 app.handle_key(Key::parse(k).unwrap()); }
1238 app.handle_key(Key::parse("enter").unwrap());
1239 assert!(!in_jump(&app));
1240 assert_eq!(focused_name(&mut app), "aaa.txt");
1241 assert!(app.visible_names().contains(&"aaa.txt".to_string()));
1243 }
1244
1245 #[test]
1246 fn a_user_can_rebind_jump_off_slash() {
1247 let (_d, tree) = fixture();
1248 let config = Config::parse("[ctrl+p]\ncmd = \"jump\"\n").unwrap();
1249 let mut app = App::new(tree, &config, None);
1250 while app.do_work() {} app.handle_key(Key::parse("ctrl+p").unwrap());
1252 assert!(in_jump(&app));
1253 }
1254
1255 #[test]
1256 fn a_single_rooted_tree_opens_with_its_first_level_expanded() {
1257 use crate::tree::ActionValues;
1258 let mut tree = Tree::new();
1259 let root = tree.push(None, "root", true, ActionValues::new("", "", ""));
1260 tree.push(Some(root), "child", false, ActionValues::new("", "", ""));
1261 let mut app = App::new(tree, &Config::default(), None);
1262
1263 assert_eq!(app.visible_names(), ["root", "child"]);
1264 assert_eq!(focused_name(&mut app), "root");
1265 }
1266
1267 #[test]
1268 fn expanding_an_unindexed_container_materializes_its_children() {
1269 let tree =
1270 crate::json_tree::from_reader(r#"{"users": [1, 2], "n": 3}"#.as_bytes()).unwrap();
1271 let mut app = App::new(tree, &Config::default(), None);
1272 assert_eq!(app.visible_names(), ["users [2]", "n: 3"]);
1273
1274 app.handle_key(Key::parse("l").unwrap());
1275
1276 assert_eq!(
1277 app.visible_names(),
1278 ["users [2]", "[0]: 1", "[1]: 2", "n: 3"]
1279 );
1280 }
1281
1282 #[test]
1283 fn jump_blocks_on_an_incomplete_index_and_opens_when_done() {
1284 let tree =
1285 crate::json_tree::from_reader(r#"{"a": {"b": 1}, "c": {"d": 2}}"#.as_bytes()).unwrap();
1286 let mut app = App::new(tree, &Config::default(), None);
1287 assert!(app.has_work());
1288
1289 app.handle_key(Key::parse("/").unwrap());
1290 assert!(matches!(app.mode, Mode::Indexing));
1291 assert_eq!(app.handle_key(Key::parse("j").unwrap()), Effect::None);
1293 assert!(matches!(app.mode, Mode::Indexing));
1294
1295 while app.do_work() {}
1296
1297 assert!(matches!(app.mode, Mode::Jump(_)));
1298 assert!(!app.has_work());
1299 }
1300
1301 #[test]
1302 fn escape_cancels_the_indexing_wait() {
1303 let tree =
1304 crate::json_tree::from_reader(r#"{"a": {"b": 1}, "c": {"d": 2}}"#.as_bytes()).unwrap();
1305 let mut app = App::new(tree, &Config::default(), None);
1306 app.handle_key(Key::parse("/").unwrap());
1307 assert!(matches!(app.mode, Mode::Indexing));
1308
1309 app.handle_key(Key::parse("esc").unwrap());
1310
1311 assert!(matches!(app.mode, Mode::Normal));
1312 }
1313}