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 RunShell {
34 cmd: String,
35 path: OsString,
36 relpath: OsString,
37 bg: bool,
38 exit: bool,
39 },
40}
41
42pub enum Mode {
46 Normal,
47 Jump(Jump),
48 Indexing,
51}
52
53const WORK_QUANTUM: usize = 500;
55
56pub struct App {
57 pub tree: Tree,
58 pub state: TreeListViewState<NodeId>,
59 pub query: TreeQuery,
60 pub mode: Mode,
62 keymap: HashMap<Key, Binding>,
63 pub(crate) panel_entries: Vec<KeybindingEntry>,
65 pub(crate) panel_user_entry_count: usize,
67 pub keybinding_panel: KeybindingPanelState,
68 root_history: Vec<Option<NodeId>>,
70 pub page_height: usize,
72 pub palette: Option<crate::ui::Palette>,
74}
75
76impl App {
77 pub fn new(tree: Tree, config: &Config, expand: Option<ExpandSpec>) -> Self {
78 let mut builtin_keymap = Self::default_keymap();
79 let user_keymap: HashMap<_, _> = config
82 .bindings
83 .iter()
84 .filter(|&(&key, _)| key != TOGGLE_KEY)
85 .map(|(&key, binding)| (key, binding.clone()))
86 .collect();
87 for key in user_keymap.keys() {
88 builtin_keymap.remove(key);
89 }
90
91 let mut panel_entries = build_entries(&user_keymap);
92 let panel_user_entry_count = panel_entries.len();
93 panel_entries.extend(build_entries(&builtin_keymap));
94
95 let mut keymap = builtin_keymap;
96 keymap.extend(user_keymap);
97 let mut app = Self {
98 tree,
99 state: TreeListViewState::with_capacity(0),
100 query: TreeQuery::new(),
101 mode: Mode::Normal,
102 keymap,
103 panel_entries,
104 panel_user_entry_count,
105 keybinding_panel: KeybindingPanelState::default(),
106 root_history: Vec::new(),
107 page_height: 20,
108 palette: None,
109 };
110 match expand {
111 None => {}
112 Some(spec) => {
113 app.tree.index_all();
115 let branches: Vec<_> = app.tree.branches().collect();
116 for (id, parent) in branches {
117 let expand = match spec {
118 ExpandSpec::All => true,
119 ExpandSpec::Depth(n) => app.tree.depth(id) < n,
120 };
121 if expand {
122 app.state.set_expanded(id, parent, true);
123 }
124 }
125 }
126 }
127 if app.tree.root_ids().len() == 1 {
129 let root = app.tree.root_ids()[0];
130 app.tree.ensure_children(root);
131 if !app.tree.is_leaf(root) {
132 app.state
133 .set_expanded(root, app.tree.view_parent(root), true);
134 }
135 }
136 app.state.ensure_projection(&app.tree, &app.query);
137 app.state.select_first();
138 app
139 }
140
141 pub fn default_keymap() -> HashMap<Key, Binding> {
143 let cmd = |action: AppCommand| Binding {
144 action: BindingAction::Cmd(action),
145 help: None,
146 exit: false,
147 bg: false,
148 };
149 let mut map = HashMap::new();
150 for (keys, action) in [
151 (&["j", "down"][..], AppCommand::Down),
152 (&["k", "up"], AppCommand::Up),
153 (&["l", "right"], AppCommand::Expand),
154 (&["h", "left"], AppCommand::Collapse),
155 (&["L", "shift+right"], AppCommand::ExpandRecursively),
156 (&["H", "shift+left"], AppCommand::CollapseRecursively),
157 (&["space"], AppCommand::Toggle),
158 (&["ctrl+space"], AppCommand::ToggleRecursively),
159 (&["enter"], AppCommand::Select),
160 (&["ctrl+enter"], AppCommand::Accept),
161 (&["alt+enter"], AppCommand::AcceptAlternate),
162 (&["tab"], AppCommand::Root),
163 (&["shift+tab"], AppCommand::PopRoot),
164 (&["J"], AppCommand::NextSibling),
165 (&["K"], AppCommand::PrevSibling),
166 (&["ctrl+f"], AppCommand::PageDown),
167 (&["ctrl+b"], AppCommand::PageUp),
168 (&["ctrl+d"], AppCommand::HalfPageDown),
169 (&["ctrl+u"], AppCommand::HalfPageUp),
170 (&["g"], AppCommand::First),
171 (&["G"], AppCommand::Last),
172 (&["/"], AppCommand::Jump),
173 (&["?"], AppCommand::ToggleKeybindingPanel),
174 (&["esc"], AppCommand::Back),
175 (&["q", "ctrl+c"], AppCommand::Quit),
176 ] {
177 for key in keys {
178 map.insert(Key::parse(key).expect("valid default key"), cmd(action));
179 }
180 }
181 map
182 }
183
184 pub fn focused_id(&mut self) -> Option<NodeId> {
185 self.state.ensure_projection(&self.tree, &self.query);
186 self.state.selected_id()
187 }
188
189 pub fn visible_names(&mut self) -> Vec<String> {
191 self.state.ensure_projection(&self.tree, &self.query);
192 self.state
193 .visible_ids()
194 .map(|id| self.tree.name(id))
195 .collect()
196 }
197
198 pub fn handle_key(&mut self, key: Key) -> Effect {
200 let _span = crate::profile::span("app::handle_key");
201 if let Mode::Indexing = self.mode {
203 if key == Key::parse("esc").unwrap() || key == Key::parse("ctrl+c").unwrap() {
204 self.mode = Mode::Normal;
205 }
206 return Effect::None;
207 }
208 if let Mode::Jump(jump) = &mut self.mode {
212 return match jump.handle_key(key) {
213 JumpOutcome::Stay => Effect::None,
214 JumpOutcome::Cancel => {
215 self.mode = Mode::Normal;
216 Effect::None
217 }
218 JumpOutcome::Accept(id) => {
219 self.mode = Mode::Normal;
220 self.state.select_by_id(&self.tree, &self.query, id);
221 Effect::None
222 }
223 };
224 }
225
226 if self.keybinding_panel.is_open() && key == CLOSE_KEY {
227 self.keybinding_panel.close();
228 return Effect::None;
229 }
230
231 match self.keymap.get(&key).cloned() {
232 None => Effect::None,
233 Some(binding) => match binding.action {
234 BindingAction::Cmd(cmd) => self.run_command(cmd),
235 BindingAction::Sh(cmd) => match self.focused_id() {
236 None => Effect::None,
237 Some(id) => Effect::RunShell {
238 cmd,
239 path: self.tree.path(id),
240 relpath: self.tree.relpath(id),
241 bg: binding.bg,
242 exit: binding.exit,
243 },
244 },
245 },
246 }
247 }
248
249 pub fn run_command(&mut self, cmd: AppCommand) -> Effect {
251 self.state.ensure_projection(&self.tree, &self.query);
252 match cmd {
253 AppCommand::Down => {
254 self.state.select_next();
255 }
256 AppCommand::Up => {
257 self.state.select_prev();
258 }
259 AppCommand::Expand => {
260 if let Some(id) = self.focused_id() {
261 self.tree.ensure_children(id);
262 let parent = self.tree.view_parent(id);
263 if self.tree.is_leaf(id) {
264 self.move_sibling(1);
266 } else if self.state.node_is_expanded(id, parent) {
267 self.state.select_id(Some(self.tree.children_of(id)[0]));
268 } else {
269 self.state.set_expanded(id, parent, true);
270 }
271 }
272 }
273 AppCommand::Collapse => {
274 if let Some(id) = self.focused_id() {
275 let parent = self.tree.view_parent(id);
276 if !self.tree.is_leaf(id) && self.state.node_is_expanded(id, parent) {
277 self.state.set_expanded(id, parent, false);
278 } else if let Some(parent) = parent {
279 self.state.select_id(Some(parent));
280 }
281 }
282 }
283 AppCommand::ExpandRecursively => {
284 if let Some(id) = self.focused_id() {
285 self.tree.ensure_children(id);
286 if self.tree.is_leaf(id) {
287 self.move_sibling(1);
289 } else {
290 self.set_expanded_recursively(id, true);
291 }
292 }
293 }
294 AppCommand::CollapseRecursively => {
295 if let Some(id) = self.focused_id() {
296 let parent = self.tree.view_parent(id);
297 if !self.tree.is_leaf(id) && self.state.node_is_expanded(id, parent) {
298 self.set_expanded_recursively(id, false);
299 } else if let Some(parent) = parent {
300 self.set_expanded_recursively(parent, false);
303 self.state.ensure_projection(&self.tree, &self.query);
304 self.state.select_id(Some(parent));
305 }
306 }
307 }
308 AppCommand::Toggle => self.toggle_focused(false),
309 AppCommand::ToggleRecursively => self.toggle_focused(true),
310 AppCommand::Select => {
311 if let Some(id) = self.focused_id() {
312 self.tree.ensure_children(id);
313 if self.tree.is_leaf(id) {
314 return Effect::PrintAndExit(self.tree.output(id));
315 }
316 self.state.set_expanded(id, self.tree.view_parent(id), true);
317 }
318 }
319 AppCommand::Accept => {
320 if let Some(id) = self.focused_id() {
321 return Effect::PrintAndExit(self.tree.output(id));
322 }
323 }
324 AppCommand::AcceptAlternate => {
325 if let Some(id) = self.focused_id() {
326 return Effect::PrintAndExit(self.tree.alternate_output(id));
327 }
328 }
329 AppCommand::Descend => {
330 if let Some(id) = self.focused_branch() {
331 self.state.set_expanded(id, self.tree.view_parent(id), true);
332 self.state.ensure_projection(&self.tree, &self.query);
333 let first_child = self.tree.children_of(id)[0];
334 self.state.select_id(Some(first_child));
335 }
336 }
337 AppCommand::Root => self.push_root(),
338 AppCommand::PopRoot => {
339 self.pop_root();
340 }
341 AppCommand::Back => {
342 if !self.pop_root() {
343 return Effect::Quit;
344 }
345 }
346 AppCommand::NextSibling => self.move_sibling(1),
347 AppCommand::PrevSibling => self.move_sibling(-1),
348 AppCommand::PageDown => self.move_focus_by(self.page_height as isize),
349 AppCommand::PageUp => self.move_focus_by(-(self.page_height as isize)),
350 AppCommand::HalfPageDown => self.move_focus_by((self.page_height / 2) as isize),
351 AppCommand::HalfPageUp => self.move_focus_by(-((self.page_height / 2) as isize)),
352 AppCommand::First => {
353 self.state.select_first();
354 }
355 AppCommand::Last => {
356 self.state.select_last();
357 }
358 AppCommand::Jump => {
359 if self.tree.fully_indexed() {
362 self.mode = Mode::Jump(Jump::open(&self.tree));
363 } else {
364 self.mode = Mode::Indexing;
365 }
366 }
367 AppCommand::ToggleKeybindingPanel => self.keybinding_panel.toggle(),
368 AppCommand::Quit => return Effect::Quit,
369 }
370 Effect::None
371 }
372
373 fn focused_branch(&mut self) -> Option<NodeId> {
375 let id = self.focused_id()?;
376 self.tree.ensure_children(id);
377 (!self.tree.is_leaf(id)).then_some(id)
378 }
379
380 pub fn do_work(&mut self) -> bool {
387 let _span = crate::profile::span("app::do_work");
388 if !self.tree.fully_indexed() {
389 self.state.ensure_projection(&self.tree, &self.query);
390 let visible: Vec<NodeId> = self.state.visible_ids().collect();
391 let mut budget = WORK_QUANTUM;
392 for id in visible {
393 if budget == 0 {
394 break;
395 }
396 if self.tree.ensure_children(id) {
397 budget -= 1;
398 }
399 }
400 self.tree.index_some(budget);
401 }
402 if matches!(self.mode, Mode::Indexing) && self.tree.fully_indexed() {
403 self.mode = Mode::Jump(Jump::open(&self.tree));
404 }
405 !self.tree.fully_indexed()
406 }
407
408 pub fn has_work(&self) -> bool {
410 !self.tree.fully_indexed()
411 }
412
413 fn toggle_focused(&mut self, recursive: bool) {
418 let Some(id) = self.focused_id() else { return };
419 self.tree.ensure_children(id);
420 if self.tree.is_leaf(id) {
421 return;
422 }
423 let parent = self.tree.view_parent(id);
424 let expand = !self.state.node_is_expanded(id, parent);
425 if recursive {
426 self.set_expanded_recursively(id, expand);
427 } else {
428 self.state.set_expanded(id, parent, expand);
429 }
430 }
431
432 fn set_expanded_recursively(&mut self, root: NodeId, expanded: bool) {
433 let mut stack = vec![root];
434 while let Some(id) = stack.pop() {
435 if expanded {
436 self.tree.ensure_children(id);
437 }
438 if !self.tree.is_leaf(id) {
439 self.state
440 .set_expanded(id, self.tree.view_parent(id), expanded);
441 stack.extend_from_slice(self.tree.children_of(id));
442 }
443 }
444 }
445
446 fn push_root(&mut self) {
447 let Some(id) = self.focused_id() else {
448 return;
449 };
450 if self.tree.view_root() == Some(id) {
451 return;
452 }
453 self.tree.ensure_children(id);
454
455 self.root_history.push(self.tree.view_root());
456 self.tree.set_view_root(Some(id));
457 if !self.tree.is_leaf(id) {
458 self.state.set_expanded(id, None, true);
459 }
460 self.state.ensure_projection(&self.tree, &self.query);
461 self.state.select_id(Some(id));
462 }
463
464 fn pop_root(&mut self) -> bool {
465 let Some(previous_root) = self.root_history.pop() else {
466 return false;
467 };
468 let selected = self.state.selected_id();
469
470 if let Some(current_root) = self.tree.view_root() {
471 let expanded = self.state.node_is_expanded(current_root, None);
472 self.state
473 .set_expanded(current_root, self.tree.parent(current_root), expanded);
474 }
475
476 self.tree.set_view_root(previous_root);
477 self.state.ensure_projection(&self.tree, &self.query);
478 if !selected.is_some_and(|id| self.state.select_by_id(&self.tree, &self.query, id)) {
479 self.state.select_first();
480 }
481 true
482 }
483
484 fn move_sibling(&mut self, delta: isize) {
485 let Some(id) = self.focused_id() else { return };
486 if self.tree.view_root() == Some(id) {
487 return;
488 }
489 let siblings = match self.tree.parent(id) {
490 Some(parent) => self.tree.children_of(parent),
491 None => self.tree.root_ids(),
492 };
493 let pos = siblings.iter().position(|&s| s == id).unwrap_or(0) as isize;
494 let target = pos + delta;
495 if (0..siblings.len() as isize).contains(&target) {
496 let target = siblings[target as usize];
497 self.state.select_id(Some(target));
498 }
499 }
500
501 fn move_focus_by(&mut self, delta: isize) {
502 let len = self.state.visible_len();
503 if len == 0 {
504 return;
505 }
506 let current = self.state.selected_index().unwrap_or(0) as isize;
507 let target = (current + delta).clamp(0, len as isize - 1);
508 self.state.select_index(Some(target as usize));
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use crate::fstree;
516
517 fn fixture() -> (tempfile::TempDir, Tree) {
527 let dir = tempfile::tempdir().unwrap();
528 let p = dir.path();
529 std::fs::create_dir_all(p.join("a/aa")).unwrap();
530 std::fs::write(p.join("a/aa/aaa.txt"), "").unwrap();
531 std::fs::write(p.join("a/ab.txt"), "").unwrap();
532 std::fs::create_dir(p.join("b")).unwrap();
533 std::fs::write(p.join("b/ba.txt"), "").unwrap();
534 std::fs::write(p.join("c.txt"), "").unwrap();
535 let tree = fstree::scan(p, false).unwrap();
536 (dir, tree)
537 }
538
539 fn app() -> (tempfile::TempDir, App) {
540 let (dir, tree) = fixture();
541 (dir, App::new(tree, &Config::default(), None))
542 }
543
544 fn app_with_leaf_siblings() -> (tempfile::TempDir, App) {
550 let dir = tempfile::tempdir().unwrap();
551 let p = dir.path();
552 std::fs::create_dir(p.join("d")).unwrap();
553 std::fs::write(p.join("d/d1.txt"), "").unwrap();
554 std::fs::write(p.join("d/d2.txt"), "").unwrap();
555 let tree = fstree::scan(p, false).unwrap();
556 (dir, App::new(tree, &Config::default(), None))
557 }
558
559 fn focused_name(app: &mut App) -> String {
560 let id = app.focused_id().expect("something focused");
561 app.tree.name(id)
562 }
563
564 #[test]
565 fn starts_focused_on_first_row_all_collapsed() {
566 let (_d, mut app) = app();
567 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
568 assert_eq!(focused_name(&mut app), "a");
569 }
570
571 #[test]
572 fn down_and_up_move_focus_clamped() {
573 let (_d, mut app) = app();
574 app.run_command(AppCommand::Down);
575 assert_eq!(focused_name(&mut app), "b");
576 app.run_command(AppCommand::Down);
577 assert_eq!(focused_name(&mut app), "c.txt");
578 app.run_command(AppCommand::Down);
579 assert_eq!(focused_name(&mut app), "c.txt");
580 app.run_command(AppCommand::Up);
581 assert_eq!(focused_name(&mut app), "b");
582 }
583
584 #[test]
585 fn expand_reveals_children_and_down_enters_them() {
586 let (_d, mut app) = app();
587 app.run_command(AppCommand::Expand);
588 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
589 app.run_command(AppCommand::Down);
590 assert_eq!(focused_name(&mut app), "aa");
591 }
592
593 #[test]
594 fn l_on_expanded_branch_descends_to_first_child() {
595 let (_d, mut app) = app();
596 app.handle_key(Key::parse("l").unwrap());
597
598 app.handle_key(Key::parse("l").unwrap());
599
600 assert_eq!(focused_name(&mut app), "aa");
601 }
602
603 #[test]
604 fn l_on_leaf_focuses_next_sibling() {
605 let (_d, mut app) = app_with_leaf_siblings();
606 app.run_command(AppCommand::Expand); app.run_command(AppCommand::Down); app.handle_key(Key::parse("l").unwrap());
610
611 assert_eq!(focused_name(&mut app), "d2.txt");
612 assert_eq!(app.visible_names(), ["d", "d1.txt", "d2.txt"]);
613 }
614
615 #[test]
616 fn l_on_last_leaf_of_a_container_stays_inside_it() {
617 let (_d, mut app) = app();
618 app.run_command(AppCommand::Expand); app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.handle_key(Key::parse("l").unwrap());
623
624 assert_eq!(focused_name(&mut app), "ab.txt");
626 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
627 }
628
629 #[test]
630 fn expand_on_the_last_top_level_leaf_is_a_noop() {
631 let (_d, mut app) = app();
632 app.run_command(AppCommand::Last);
633 assert_eq!(focused_name(&mut app), "c.txt");
634 assert_eq!(app.run_command(AppCommand::Expand), Effect::None);
635 assert_eq!(focused_name(&mut app), "c.txt");
636 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
637 }
638
639 #[test]
640 fn collapse_hides_children() {
641 let (_d, mut app) = app();
642 app.run_command(AppCommand::Expand);
643 app.run_command(AppCommand::Collapse);
644 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
645 }
646
647 #[test]
648 fn h_on_leaf_focuses_parent_without_collapsing_it() {
649 let (_d, mut app) = app();
650 app.run_command(AppCommand::Expand);
651 app.run_command(AppCommand::Down); app.run_command(AppCommand::Expand);
653 app.run_command(AppCommand::Down); app.handle_key(Key::parse("h").unwrap());
656
657 assert_eq!(focused_name(&mut app), "aa");
658 assert_eq!(
659 app.visible_names(),
660 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
661 );
662 }
663
664 #[test]
665 fn h_on_collapsed_branch_focuses_parent_without_collapsing_it() {
666 let (_d, mut app) = app();
667 app.run_command(AppCommand::Expand);
668 app.run_command(AppCommand::Down); app.handle_key(Key::parse("h").unwrap());
671
672 assert_eq!(focused_name(&mut app), "a");
673 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
674 }
675
676 #[test]
677 fn expand_recursively_expands_whole_subtree() {
678 let (_d, mut app) = app();
679 app.run_command(AppCommand::ExpandRecursively);
680 assert_eq!(
681 app.visible_names(),
682 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
683 );
684 }
685
686 #[test]
687 fn space_toggles_a_container_open_and_shut() {
688 let (_d, mut app) = app();
689
690 app.handle_key(Key::parse("space").unwrap());
691 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
692 assert_eq!(focused_name(&mut app), "a");
693
694 app.handle_key(Key::parse("space").unwrap());
695 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
696 assert_eq!(focused_name(&mut app), "a");
697 }
698
699 #[test]
700 fn space_on_a_leaf_does_nothing() {
701 let (_d, mut app) = app();
702 app.run_command(AppCommand::Last); app.handle_key(Key::parse("space").unwrap());
705
706 assert_eq!(focused_name(&mut app), "c.txt");
707 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
708 }
709
710 #[test]
711 fn ctrl_space_toggles_a_container_recursively() {
712 let (_d, mut app) = app();
713
714 app.handle_key(Key::parse("ctrl+space").unwrap());
715 assert_eq!(
716 app.visible_names(),
717 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
718 );
719 assert_eq!(focused_name(&mut app), "a");
720
721 app.handle_key(Key::parse("ctrl+space").unwrap());
722 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
723 app.run_command(AppCommand::Expand);
725 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
726 }
727
728 #[test]
729 fn ctrl_space_direction_follows_the_focused_container() {
730 let (_d, mut app) = app();
731 app.run_command(AppCommand::Expand); app.handle_key(Key::parse("ctrl+space").unwrap());
735
736 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
737 }
738
739 #[test]
740 fn shift_l_on_a_container_leaves_focus_on_it() {
741 let (_d, mut app) = app();
742
743 app.handle_key(Key::parse("L").unwrap());
744
745 assert_eq!(focused_name(&mut app), "a");
746 }
747
748 #[test]
749 fn shift_l_on_leaf_focuses_next_sibling() {
750 let (_d, mut app) = app_with_leaf_siblings();
751 app.run_command(AppCommand::Expand); app.run_command(AppCommand::Down); app.handle_key(Key::parse("L").unwrap());
755
756 assert_eq!(focused_name(&mut app), "d2.txt");
757 assert_eq!(app.visible_names(), ["d", "d1.txt", "d2.txt"]);
758 }
759
760 #[test]
761 fn shift_l_on_the_last_top_level_leaf_is_a_noop() {
762 let (_d, mut app) = app();
763 app.run_command(AppCommand::Last); app.handle_key(Key::parse("L").unwrap());
766
767 assert_eq!(focused_name(&mut app), "c.txt");
768 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
769 }
770
771 #[test]
772 fn collapse_recursively_collapses_whole_subtree() {
773 let (_d, mut app) = app();
774 app.run_command(AppCommand::ExpandRecursively);
775 app.run_command(AppCommand::CollapseRecursively);
776 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
777 app.run_command(AppCommand::Expand);
779 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
780 }
781
782 #[test]
783 fn shift_h_on_leaf_collapses_parent_and_focuses_it() {
784 let (_d, mut app) = app();
785 app.run_command(AppCommand::ExpandRecursively);
786 app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.handle_key(Key::parse("H").unwrap());
790
791 assert_eq!(focused_name(&mut app), "aa");
792 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
793 }
794
795 #[test]
796 fn shift_h_on_collapsed_branch_collapses_parent_and_focuses_it() {
797 let (_d, mut app) = app();
798 app.run_command(AppCommand::Expand);
799 app.run_command(AppCommand::Down); app.handle_key(Key::parse("H").unwrap());
802
803 assert_eq!(focused_name(&mut app), "a");
804 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
805 }
806
807 #[test]
808 fn shift_h_collapses_the_parent_recursively() {
809 let (_d, mut app) = app();
810 app.run_command(AppCommand::ExpandRecursively);
811 app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.handle_key(Key::parse("H").unwrap());
816
817 assert_eq!(focused_name(&mut app), "a");
818 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
819 app.run_command(AppCommand::Expand);
821 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
822 }
823
824 #[test]
825 fn shift_h_on_a_top_level_leaf_leaves_focus_alone() {
826 let (_d, mut app) = app();
827 app.run_command(AppCommand::Last); app.handle_key(Key::parse("H").unwrap());
830
831 assert_eq!(focused_name(&mut app), "c.txt");
832 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
833 }
834
835 #[test]
836 fn select_expands_collapsed_dir_and_prints_leaf() {
837 let (_d, mut app) = app();
838 assert_eq!(app.run_command(AppCommand::Select), Effect::None);
839 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
840 app.run_command(AppCommand::Last);
841 let effect = app.run_command(AppCommand::Select);
842 let Effect::PrintAndExit(path) = effect else {
843 panic!("expected PrintAndExit, got {effect:?}");
844 };
845 assert!(std::path::Path::new(&path).is_absolute());
846 assert!(std::path::Path::new(&path).ends_with("c.txt"));
847 }
848
849 #[test]
850 fn select_does_not_descend_into_an_expanded_branch() {
851 let (_d, mut app) = app();
852 app.run_command(AppCommand::Select);
853
854 app.run_command(AppCommand::Select);
855
856 assert_eq!(focused_name(&mut app), "a");
857 }
858
859 #[test]
860 fn accept_prints_even_on_dir() {
861 let (_d, mut app) = app();
862 let effect = app.run_command(AppCommand::Accept);
863 let Effect::PrintAndExit(path) = effect else {
864 panic!("expected PrintAndExit, got {effect:?}");
865 };
866 assert!(std::path::Path::new(&path).ends_with("a"));
867 }
868
869 #[test]
870 fn alt_enter_prints_the_filesystem_basename() {
871 let (_d, mut app) = app();
872
873 assert_eq!(
874 app.handle_key(Key::parse("alt+enter").unwrap()),
875 Effect::PrintAndExit(OsString::from("a"))
876 );
877 }
878
879 #[test]
880 fn descend_expands_and_focuses_first_child() {
881 let (_d, mut app) = app();
882 app.run_command(AppCommand::Descend);
883 assert_eq!(focused_name(&mut app), "aa");
884 }
885
886 #[test]
887 fn tab_pushes_focused_nodes_as_view_roots_and_shift_tab_pops_them() {
888 let (_d, mut app) = app();
889
890 app.handle_key(Key::parse("tab").unwrap());
891 assert_eq!(focused_name(&mut app), "a");
892 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt"]);
893
894 app.handle_key(Key::parse("l").unwrap()); app.handle_key(Key::parse("tab").unwrap());
896 assert_eq!(focused_name(&mut app), "aa");
897 assert_eq!(app.visible_names(), ["aa", "aaa.txt"]);
898
899 app.handle_key(Key::parse("h").unwrap()); assert_eq!(app.visible_names(), ["aa"]);
902 app.handle_key(Key::parse("h").unwrap()); assert_eq!(focused_name(&mut app), "aa");
904
905 app.handle_key(Key::parse("l").unwrap()); assert_eq!(app.visible_names(), ["aa", "aaa.txt"]);
907
908 app.handle_key(Key::parse("shift+tab").unwrap());
909 assert_eq!(focused_name(&mut app), "aa");
910 assert_eq!(app.visible_names(), ["a", "aa", "aaa.txt", "ab.txt"]);
911
912 app.handle_key(Key::parse("shift+tab").unwrap());
913 assert_eq!(focused_name(&mut app), "aa");
914 assert_eq!(
915 app.visible_names(),
916 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
917 );
918
919 app.handle_key(Key::parse("shift+tab").unwrap());
921 assert_eq!(focused_name(&mut app), "aa");
922 assert_eq!(
923 app.visible_names(),
924 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
925 );
926 }
927
928 #[test]
929 fn tab_can_make_a_leaf_the_view_root() {
930 let (_d, mut app) = app();
931 app.run_command(AppCommand::Last);
932
933 app.handle_key(Key::parse("tab").unwrap());
934
935 assert_eq!(focused_name(&mut app), "c.txt");
936 assert_eq!(app.visible_names(), ["c.txt"]);
937 }
938
939 #[test]
940 fn escape_pops_one_root_at_a_time_then_quits() {
941 let (_d, mut app) = app();
942 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);
947 assert_eq!(focused_name(&mut app), "aa");
948 assert_eq!(app.visible_names(), ["a", "aa", "aaa.txt", "ab.txt"]);
949
950 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::None);
951 assert_eq!(focused_name(&mut app), "aa");
952 assert_eq!(
953 app.visible_names(),
954 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
955 );
956
957 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::Quit);
958 }
959
960 #[test]
961 fn sibling_navigation_skips_expanded_children() {
962 let (_d, mut app) = app();
963 app.run_command(AppCommand::Expand); app.run_command(AppCommand::NextSibling);
965 assert_eq!(focused_name(&mut app), "b");
966 app.run_command(AppCommand::PrevSibling);
967 assert_eq!(focused_name(&mut app), "a");
968 app.run_command(AppCommand::PrevSibling);
970 assert_eq!(focused_name(&mut app), "a");
971 }
972
973 #[test]
974 fn first_and_last() {
975 let (_d, mut app) = app();
976 app.run_command(AppCommand::Last);
977 assert_eq!(focused_name(&mut app), "c.txt");
978 app.run_command(AppCommand::First);
979 assert_eq!(focused_name(&mut app), "a");
980 }
981
982 #[test]
983 fn paging_moves_focus_by_page_amounts() {
984 let (_d, mut app) = app();
985 app.run_command(AppCommand::ExpandRecursively); app.page_height = 4;
987 app.run_command(AppCommand::HalfPageDown);
988 assert_eq!(focused_name(&mut app), "aaa.txt"); app.run_command(AppCommand::PageDown);
990 assert_eq!(focused_name(&mut app), "c.txt"); app.run_command(AppCommand::HalfPageUp);
992 assert_eq!(focused_name(&mut app), "ab.txt");
993 app.run_command(AppCommand::PageUp);
994 assert_eq!(focused_name(&mut app), "a");
995 }
996
997 #[test]
998 fn default_keys_drive_commands() {
999 let (_d, mut app) = app();
1000 app.handle_key(Key::parse("j").unwrap());
1001 assert_eq!(focused_name(&mut app), "b");
1002 app.handle_key(Key::parse("k").unwrap());
1003 assert_eq!(focused_name(&mut app), "a");
1004 app.handle_key(Key::parse("l").unwrap());
1005 assert_eq!(app.visible_names().len(), 5);
1006 app.handle_key(Key::parse("h").unwrap());
1007 assert_eq!(app.visible_names().len(), 3);
1008 assert_eq!(app.handle_key(Key::parse("q").unwrap()), Effect::Quit);
1009 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::Quit);
1010 assert_eq!(app.handle_key(Key::parse("ctrl+c").unwrap()), Effect::Quit);
1011 }
1012
1013 #[test]
1014 fn g_goes_to_first_line_without_a_chord() {
1015 let (_d, mut app) = app();
1016 app.run_command(AppCommand::Last);
1017 assert_eq!(app.handle_key(Key::parse("g").unwrap()), Effect::None);
1018 assert_eq!(focused_name(&mut app), "a");
1019 }
1020
1021 #[test]
1022 fn shift_g_goes_to_last_visible_line() {
1023 let (_d, mut app) = app();
1024 app.handle_key(Key::parse("G").unwrap());
1025 assert_eq!(focused_name(&mut app), "c.txt");
1026 }
1027
1028 #[test]
1029 fn user_binding_produces_shell_effect_with_paths() {
1030 let (_d, tree) = fixture();
1031 let config = Config::parse("[ctrl+e]\nsh = \"vim $path\"\nexit = true\n").unwrap();
1032 let mut app = App::new(tree, &config, None);
1033 app.run_command(AppCommand::Down); let effect = app.handle_key(Key::parse("ctrl+e").unwrap());
1035 let Effect::RunShell {
1036 cmd,
1037 path,
1038 relpath,
1039 bg,
1040 exit,
1041 } = effect
1042 else {
1043 panic!("expected RunShell, got {effect:?}");
1044 };
1045 assert_eq!(cmd, "vim $path");
1046 assert!(std::path::Path::new(&path).is_absolute());
1047 assert!(std::path::Path::new(&path).ends_with("b"));
1048 assert_eq!(relpath, OsString::from("b"));
1049 assert!(!bg);
1050 assert!(exit);
1051 }
1052
1053 #[test]
1054 fn user_binding_overrides_default() {
1055 let (_d, tree) = fixture();
1056 let config = Config::parse("[j]\ncmd = \"quit\"\n").unwrap();
1057 let mut app = App::new(tree, &config, None);
1058 assert_eq!(app.handle_key(Key::parse("j").unwrap()), Effect::Quit);
1059 }
1060
1061 #[test]
1062 fn user_can_override_the_new_g_binding() {
1063 let (_d, tree) = fixture();
1064 let config = Config::parse("[g]\ncmd = \"quit\"\n").unwrap();
1065 let mut app = App::new(tree, &config, None);
1066 assert_eq!(app.handle_key(Key::parse("g").unwrap()), Effect::Quit);
1067 }
1068
1069 #[test]
1070 fn question_mark_is_reserved_and_toggles_the_panel() {
1071 let (_d, tree) = fixture();
1072 let config = Config::parse("[?]\ncmd = \"quit\"\nhelp = \"Wrong\"\n").unwrap();
1073 let mut app = App::new(tree, &config, None);
1074
1075 let question: Vec<_> = app
1078 .panel_entries
1079 .iter()
1080 .filter(|entry| entry.key == Key::parse("?").unwrap())
1081 .collect();
1082 assert_eq!(question.len(), 1);
1083 assert_eq!(question[0].description, "Shortcuts");
1084
1085 assert!(!app.keybinding_panel.is_open());
1086 assert_eq!(app.handle_key(Key::parse("?").unwrap()), Effect::None);
1087 assert!(app.keybinding_panel.is_open());
1088 assert_eq!(app.handle_key(Key::parse("?").unwrap()), Effect::None);
1089 assert!(!app.keybinding_panel.is_open());
1090 }
1091
1092 #[test]
1093 fn open_panel_stays_open_while_bindings_run_and_escape_only_closes_it() {
1094 let (_d, mut app) = app();
1095 app.handle_key(Key::parse("?").unwrap());
1096
1097 app.handle_key(Key::parse("j").unwrap());
1098 assert_eq!(focused_name(&mut app), "b");
1099 assert!(app.keybinding_panel.is_open());
1100
1101 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::None);
1102 assert!(!app.keybinding_panel.is_open());
1103 assert_eq!(focused_name(&mut app), "b");
1104 }
1105
1106 #[test]
1107 fn jump_owns_question_mark_and_escape_without_dismissing_the_panel() {
1108 let (_d, mut app) = app();
1109 while app.do_work() {}
1110 app.handle_key(Key::parse("?").unwrap());
1111 app.handle_key(Key::parse("/").unwrap());
1112 app.handle_key(Key::parse("?").unwrap());
1113
1114 let Mode::Jump(jump) = &app.mode else {
1115 panic!("expected jump mode");
1116 };
1117 assert_eq!(jump.query(), "?");
1118 assert!(app.keybinding_panel.is_open());
1119
1120 app.handle_key(Key::parse("esc").unwrap());
1121 assert!(matches!(app.mode, Mode::Normal));
1122 assert!(app.keybinding_panel.is_open());
1123 }
1124
1125 #[test]
1126 fn unbound_key_is_noop() {
1127 let (_d, mut app) = app();
1128 assert_eq!(app.handle_key(Key::parse("x").unwrap()), Effect::None);
1129 }
1130
1131 #[test]
1132 fn initial_expand_depth_one_expands_top_level_only() {
1133 let (_d, tree) = fixture();
1134 let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::Depth(1)));
1135 assert_eq!(
1136 app.visible_names(),
1137 ["a", "aa", "ab.txt", "b", "ba.txt", "c.txt"]
1138 );
1139 }
1140
1141 #[test]
1142 fn initial_expand_all_expands_everything() {
1143 let (_d, tree) = fixture();
1144 let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
1145 assert_eq!(
1146 app.visible_names(),
1147 ["a", "aa", "aaa.txt", "ab.txt", "b", "ba.txt", "c.txt"]
1148 );
1149 }
1150
1151 fn in_jump(app: &App) -> bool {
1152 matches!(app.mode, Mode::Jump(_))
1153 }
1154
1155 #[test]
1156 fn slash_opens_the_jump_picker() {
1157 let (_d, mut app) = app();
1158 assert!(!in_jump(&app));
1159 while app.do_work() {} app.handle_key(Key::parse("/").unwrap());
1161 assert!(in_jump(&app));
1162 }
1163
1164 #[test]
1165 fn cancelling_the_picker_leaves_focus_untouched() {
1166 let (_d, mut app) = app();
1167 app.run_command(AppCommand::Down); assert_eq!(focused_name(&mut app), "b");
1169 app.handle_key(Key::parse("/").unwrap());
1170 app.handle_key(Key::parse("a").unwrap()); app.handle_key(Key::parse("esc").unwrap());
1172 assert!(!in_jump(&app));
1173 assert_eq!(focused_name(&mut app), "b");
1174 }
1175
1176 #[test]
1177 fn accepting_jumps_focus_and_expands_ancestors() {
1178 let (_d, mut app) = app();
1179 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
1181 while app.do_work() {} app.handle_key(Key::parse("/").unwrap());
1183 for k in ["a", "a", "a"] {
1184 app.handle_key(Key::parse(k).unwrap()); }
1186 app.handle_key(Key::parse("enter").unwrap());
1187 assert!(!in_jump(&app));
1188 assert_eq!(focused_name(&mut app), "aaa.txt");
1189 assert!(app.visible_names().contains(&"aaa.txt".to_string()));
1191 }
1192
1193 #[test]
1194 fn a_user_can_rebind_jump_off_slash() {
1195 let (_d, tree) = fixture();
1196 let config = Config::parse("[ctrl+p]\ncmd = \"jump\"\n").unwrap();
1197 let mut app = App::new(tree, &config, None);
1198 while app.do_work() {} app.handle_key(Key::parse("ctrl+p").unwrap());
1200 assert!(in_jump(&app));
1201 }
1202
1203 #[test]
1204 fn a_single_rooted_tree_opens_with_its_first_level_expanded() {
1205 use crate::tree::ActionValues;
1206 let mut tree = Tree::new();
1207 let root = tree.push(None, "root", true, ActionValues::new("", "", ""));
1208 tree.push(Some(root), "child", false, ActionValues::new("", "", ""));
1209 let mut app = App::new(tree, &Config::default(), None);
1210
1211 assert_eq!(app.visible_names(), ["root", "child"]);
1212 assert_eq!(focused_name(&mut app), "root");
1213 }
1214
1215 #[test]
1216 fn expanding_an_unindexed_container_materializes_its_children() {
1217 let tree =
1218 crate::json_tree::from_reader(r#"{"users": [1, 2], "n": 3}"#.as_bytes()).unwrap();
1219 let mut app = App::new(tree, &Config::default(), None);
1220 assert_eq!(app.visible_names(), ["users [2]", "n: 3"]);
1221
1222 app.handle_key(Key::parse("l").unwrap());
1223
1224 assert_eq!(
1225 app.visible_names(),
1226 ["users [2]", "[0]: 1", "[1]: 2", "n: 3"]
1227 );
1228 }
1229
1230 #[test]
1231 fn jump_blocks_on_an_incomplete_index_and_opens_when_done() {
1232 let tree =
1233 crate::json_tree::from_reader(r#"{"a": {"b": 1}, "c": {"d": 2}}"#.as_bytes()).unwrap();
1234 let mut app = App::new(tree, &Config::default(), None);
1235 assert!(app.has_work());
1236
1237 app.handle_key(Key::parse("/").unwrap());
1238 assert!(matches!(app.mode, Mode::Indexing));
1239 assert_eq!(app.handle_key(Key::parse("j").unwrap()), Effect::None);
1241 assert!(matches!(app.mode, Mode::Indexing));
1242
1243 while app.do_work() {}
1244
1245 assert!(matches!(app.mode, Mode::Jump(_)));
1246 assert!(!app.has_work());
1247 }
1248
1249 #[test]
1250 fn escape_cancels_the_indexing_wait() {
1251 let tree =
1252 crate::json_tree::from_reader(r#"{"a": {"b": 1}, "c": {"d": 2}}"#.as_bytes()).unwrap();
1253 let mut app = App::new(tree, &Config::default(), None);
1254 app.handle_key(Key::parse("/").unwrap());
1255 assert!(matches!(app.mode, Mode::Indexing));
1256
1257 app.handle_key(Key::parse("esc").unwrap());
1258
1259 assert!(matches!(app.mode, Mode::Normal));
1260 }
1261}