1use std::collections::HashMap;
4use std::ffi::OsString;
5
6use tui_treelistview::{TreeListViewState, TreeQuery};
7
8use crate::cli::ExpandSpec;
9use crate::config::{AppCommand, Binding, BindingAction, Config};
10use crate::jump::{Jump, JumpOutcome};
11use crate::keybindings::{
12 CLOSE_KEY, KeybindingEntry, KeybindingPanelState, TOGGLE_KEY, build_entries,
13};
14use crate::keys::Key;
15use crate::tree::{NodeId, Tree};
16
17#[derive(Clone, PartialEq, Debug)]
19pub enum Effect {
20 None,
21 Quit,
23 PrintAndExit(OsString),
25 RunShell {
27 cmd: String,
28 path: OsString,
29 relpath: OsString,
30 bg: bool,
31 exit: bool,
32 },
33}
34
35pub enum Mode {
39 Normal,
40 Jump(Jump),
41}
42
43pub struct App {
44 pub tree: Tree,
45 pub state: TreeListViewState<NodeId>,
46 pub query: TreeQuery,
47 pub mode: Mode,
49 keymap: HashMap<Key, Binding>,
50 pub(crate) panel_entries: Vec<KeybindingEntry>,
52 pub keybinding_panel: KeybindingPanelState,
53 root_history: Vec<Option<NodeId>>,
55 pub page_height: usize,
57 pub palette: Option<crate::ui::Palette>,
59}
60
61impl App {
62 pub fn new(tree: Tree, config: &Config, expand: Option<ExpandSpec>) -> Self {
63 let mut keymap = Self::default_keymap();
64 keymap.extend(
67 config
68 .bindings
69 .iter()
70 .filter(|&(&key, _)| key != TOGGLE_KEY)
71 .map(|(&key, binding)| (key, binding.clone())),
72 );
73 let panel_entries = build_entries(&keymap);
74 let mut app = Self {
75 tree,
76 state: TreeListViewState::with_capacity(0),
77 query: TreeQuery::new(),
78 mode: Mode::Normal,
79 keymap,
80 panel_entries,
81 keybinding_panel: KeybindingPanelState::default(),
82 root_history: Vec::new(),
83 page_height: 20,
84 palette: None,
85 };
86 match expand {
87 None => {}
88 Some(ExpandSpec::All) => {
89 let branches: Vec<_> = app.tree.branches().collect();
90 for (id, parent) in branches {
91 app.state.set_expanded(id, parent, true);
92 }
93 }
94 Some(ExpandSpec::Depth(n)) => {
95 let branches: Vec<_> = app.tree.branches().collect();
96 for (id, parent) in branches {
97 if app.tree.node(id).depth < n {
98 app.state.set_expanded(id, parent, true);
99 }
100 }
101 }
102 }
103 app.state.ensure_projection(&app.tree, &app.query);
104 app.state.select_first();
105 app
106 }
107
108 pub fn default_keymap() -> HashMap<Key, Binding> {
110 let cmd = |action: AppCommand| Binding {
111 action: BindingAction::Cmd(action),
112 help: None,
113 exit: false,
114 bg: false,
115 };
116 let mut map = HashMap::new();
117 for (keys, action) in [
118 (&["j", "down"][..], AppCommand::Down),
119 (&["k", "up"], AppCommand::Up),
120 (&["l", "right"], AppCommand::Expand),
121 (&["h", "left"], AppCommand::Collapse),
122 (&["L", "shift+right"], AppCommand::ExpandRecursively),
123 (&["H", "shift+left"], AppCommand::CollapseRecursively),
124 (&["space"], AppCommand::Toggle),
125 (&["ctrl+space"], AppCommand::ToggleRecursively),
126 (&["enter"], AppCommand::Select),
127 (&["ctrl+enter"], AppCommand::Accept),
128 (&["alt+enter"], AppCommand::AcceptAlternate),
129 (&["tab"], AppCommand::Root),
130 (&["shift+tab"], AppCommand::PopRoot),
131 (&["J"], AppCommand::NextSibling),
132 (&["K"], AppCommand::PrevSibling),
133 (&["ctrl+f"], AppCommand::PageDown),
134 (&["ctrl+b"], AppCommand::PageUp),
135 (&["ctrl+d"], AppCommand::HalfPageDown),
136 (&["ctrl+u"], AppCommand::HalfPageUp),
137 (&["g"], AppCommand::First),
138 (&["G"], AppCommand::Last),
139 (&["/"], AppCommand::Jump),
140 (&["?"], AppCommand::ToggleKeybindingPanel),
141 (&["esc"], AppCommand::Back),
142 (&["q", "ctrl+c"], AppCommand::Quit),
143 ] {
144 for key in keys {
145 map.insert(Key::parse(key).expect("valid default key"), cmd(action));
146 }
147 }
148 map
149 }
150
151 pub fn focused_id(&mut self) -> Option<NodeId> {
152 self.state.ensure_projection(&self.tree, &self.query);
153 self.state.selected_id()
154 }
155
156 pub fn visible_names(&mut self) -> Vec<String> {
158 self.state.ensure_projection(&self.tree, &self.query);
159 self.state
160 .visible_ids()
161 .map(|id| self.tree.node(id).name.clone())
162 .collect()
163 }
164
165 pub fn handle_key(&mut self, key: Key) -> Effect {
167 let _span = crate::profile::span("app::handle_key");
168 if let Mode::Jump(jump) = &mut self.mode {
172 return match jump.handle_key(key) {
173 JumpOutcome::Stay => Effect::None,
174 JumpOutcome::Cancel => {
175 self.mode = Mode::Normal;
176 Effect::None
177 }
178 JumpOutcome::Accept(id) => {
179 self.mode = Mode::Normal;
180 self.state.select_by_id(&self.tree, &self.query, id);
181 Effect::None
182 }
183 };
184 }
185
186 if self.keybinding_panel.is_open() && key == CLOSE_KEY {
187 self.keybinding_panel.close();
188 return Effect::None;
189 }
190
191 match self.keymap.get(&key).cloned() {
192 None => Effect::None,
193 Some(binding) => match binding.action {
194 BindingAction::Cmd(cmd) => self.run_command(cmd),
195 BindingAction::Sh(cmd) => match self.focused_id() {
196 None => Effect::None,
197 Some(id) => Effect::RunShell {
198 cmd,
199 path: self.tree.node(id).action.path.clone(),
200 relpath: self.tree.node(id).action.relpath.clone(),
201 bg: binding.bg,
202 exit: binding.exit,
203 },
204 },
205 },
206 }
207 }
208
209 pub fn run_command(&mut self, cmd: AppCommand) -> Effect {
211 self.state.ensure_projection(&self.tree, &self.query);
212 match cmd {
213 AppCommand::Down => {
214 self.state.select_next();
215 }
216 AppCommand::Up => {
217 self.state.select_prev();
218 }
219 AppCommand::Expand => {
220 if let Some(id) = self.focused_id() {
221 let parent = self.tree.view_parent(id);
222 if self.tree.is_leaf(id) {
223 self.move_sibling(1);
225 } else if self.state.node_is_expanded(id, parent) {
226 self.state.select_id(Some(self.tree.node(id).children[0]));
227 } else {
228 self.state.set_expanded(id, parent, true);
229 }
230 }
231 }
232 AppCommand::Collapse => {
233 if let Some(id) = self.focused_id() {
234 let parent = self.tree.view_parent(id);
235 if !self.tree.is_leaf(id) && self.state.node_is_expanded(id, parent) {
236 self.state.set_expanded(id, parent, false);
237 } else if let Some(parent) = parent {
238 self.state.select_id(Some(parent));
239 }
240 }
241 }
242 AppCommand::ExpandRecursively => {
243 if let Some(id) = self.focused_id() {
244 if self.tree.is_leaf(id) {
245 self.move_sibling(1);
247 } else {
248 self.set_expanded_recursively(id, true);
249 }
250 }
251 }
252 AppCommand::CollapseRecursively => {
253 if let Some(id) = self.focused_id() {
254 let parent = self.tree.view_parent(id);
255 if !self.tree.is_leaf(id) && self.state.node_is_expanded(id, parent) {
256 self.set_expanded_recursively(id, false);
257 } else if let Some(parent) = parent {
258 self.set_expanded_recursively(parent, false);
261 self.state.ensure_projection(&self.tree, &self.query);
262 self.state.select_id(Some(parent));
263 }
264 }
265 }
266 AppCommand::Toggle => self.toggle_focused(false),
267 AppCommand::ToggleRecursively => self.toggle_focused(true),
268 AppCommand::Select => {
269 if let Some(id) = self.focused_id() {
270 if self.tree.is_leaf(id) {
271 return Effect::PrintAndExit(self.tree.node(id).action.output.clone());
272 }
273 self.state.set_expanded(id, self.tree.view_parent(id), true);
274 }
275 }
276 AppCommand::Accept => {
277 if let Some(id) = self.focused_id() {
278 return Effect::PrintAndExit(self.tree.node(id).action.output.clone());
279 }
280 }
281 AppCommand::AcceptAlternate => {
282 if let Some(id) = self.focused_id() {
283 return Effect::PrintAndExit(
284 self.tree.node(id).action.alternate_output.clone(),
285 );
286 }
287 }
288 AppCommand::Descend => {
289 if let Some(id) = self.focused_branch() {
290 self.state.set_expanded(id, self.tree.view_parent(id), true);
291 self.state.ensure_projection(&self.tree, &self.query);
292 let first_child = self.tree.node(id).children[0];
293 self.state.select_id(Some(first_child));
294 }
295 }
296 AppCommand::Root => self.push_root(),
297 AppCommand::PopRoot => {
298 self.pop_root();
299 }
300 AppCommand::Back => {
301 if !self.pop_root() {
302 return Effect::Quit;
303 }
304 }
305 AppCommand::NextSibling => self.move_sibling(1),
306 AppCommand::PrevSibling => self.move_sibling(-1),
307 AppCommand::PageDown => self.move_focus_by(self.page_height as isize),
308 AppCommand::PageUp => self.move_focus_by(-(self.page_height as isize)),
309 AppCommand::HalfPageDown => self.move_focus_by((self.page_height / 2) as isize),
310 AppCommand::HalfPageUp => self.move_focus_by(-((self.page_height / 2) as isize)),
311 AppCommand::First => {
312 self.state.select_first();
313 }
314 AppCommand::Last => {
315 self.state.select_last();
316 }
317 AppCommand::Jump => {
318 self.mode = Mode::Jump(Jump::open(&self.tree));
319 }
320 AppCommand::ToggleKeybindingPanel => self.keybinding_panel.toggle(),
321 AppCommand::Quit => return Effect::Quit,
322 }
323 Effect::None
324 }
325
326 fn focused_branch(&mut self) -> Option<NodeId> {
328 self.focused_id().filter(|&id| !self.tree.is_leaf(id))
329 }
330
331 fn toggle_focused(&mut self, recursive: bool) {
336 let Some(id) = self.focused_id() else { return };
337 if self.tree.is_leaf(id) {
338 return;
339 }
340 let parent = self.tree.view_parent(id);
341 let expand = !self.state.node_is_expanded(id, parent);
342 if recursive {
343 self.set_expanded_recursively(id, expand);
344 } else {
345 self.state.set_expanded(id, parent, expand);
346 }
347 }
348
349 fn set_expanded_recursively(&mut self, root: NodeId, expanded: bool) {
350 let mut stack = vec![root];
351 while let Some(id) = stack.pop() {
352 if !self.tree.is_leaf(id) {
353 self.state
354 .set_expanded(id, self.tree.view_parent(id), expanded);
355 stack.extend_from_slice(&self.tree.node(id).children);
356 }
357 }
358 }
359
360 fn push_root(&mut self) {
361 let Some(id) = self.focused_id() else {
362 return;
363 };
364 if self.tree.view_root() == Some(id) {
365 return;
366 }
367
368 self.root_history.push(self.tree.view_root());
369 self.tree.set_view_root(Some(id));
370 if !self.tree.is_leaf(id) {
371 self.state.set_expanded(id, None, true);
372 }
373 self.state.ensure_projection(&self.tree, &self.query);
374 self.state.select_id(Some(id));
375 }
376
377 fn pop_root(&mut self) -> bool {
378 let Some(previous_root) = self.root_history.pop() else {
379 return false;
380 };
381 let selected = self.state.selected_id();
382
383 if let Some(current_root) = self.tree.view_root() {
384 let expanded = self.state.node_is_expanded(current_root, None);
385 self.state
386 .set_expanded(current_root, self.tree.node(current_root).parent, expanded);
387 }
388
389 self.tree.set_view_root(previous_root);
390 self.state.ensure_projection(&self.tree, &self.query);
391 if !selected.is_some_and(|id| self.state.select_by_id(&self.tree, &self.query, id)) {
392 self.state.select_first();
393 }
394 true
395 }
396
397 fn move_sibling(&mut self, delta: isize) {
398 let Some(id) = self.focused_id() else { return };
399 if self.tree.view_root() == Some(id) {
400 return;
401 }
402 let siblings = match self.tree.node(id).parent {
403 Some(parent) => self.tree.node(parent).children.as_slice(),
404 None => self.tree.root_ids(),
405 };
406 let pos = siblings.iter().position(|&s| s == id).unwrap_or(0) as isize;
407 let target = pos + delta;
408 if (0..siblings.len() as isize).contains(&target) {
409 let target = siblings[target as usize];
410 self.state.select_id(Some(target));
411 }
412 }
413
414 fn move_focus_by(&mut self, delta: isize) {
415 let len = self.state.visible_len();
416 if len == 0 {
417 return;
418 }
419 let current = self.state.selected_index().unwrap_or(0) as isize;
420 let target = (current + delta).clamp(0, len as isize - 1);
421 self.state.select_index(Some(target as usize));
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use crate::fstree;
429
430 fn fixture() -> (tempfile::TempDir, Tree) {
440 let dir = tempfile::tempdir().unwrap();
441 let p = dir.path();
442 std::fs::create_dir_all(p.join("a/aa")).unwrap();
443 std::fs::write(p.join("a/aa/aaa.txt"), "").unwrap();
444 std::fs::write(p.join("a/ab.txt"), "").unwrap();
445 std::fs::create_dir(p.join("b")).unwrap();
446 std::fs::write(p.join("b/ba.txt"), "").unwrap();
447 std::fs::write(p.join("c.txt"), "").unwrap();
448 let tree = fstree::scan(p, false).unwrap();
449 (dir, tree)
450 }
451
452 fn app() -> (tempfile::TempDir, App) {
453 let (dir, tree) = fixture();
454 (dir, App::new(tree, &Config::default(), None))
455 }
456
457 fn app_with_leaf_siblings() -> (tempfile::TempDir, App) {
463 let dir = tempfile::tempdir().unwrap();
464 let p = dir.path();
465 std::fs::create_dir(p.join("d")).unwrap();
466 std::fs::write(p.join("d/d1.txt"), "").unwrap();
467 std::fs::write(p.join("d/d2.txt"), "").unwrap();
468 let tree = fstree::scan(p, false).unwrap();
469 (dir, App::new(tree, &Config::default(), None))
470 }
471
472 fn focused_name(app: &mut App) -> String {
473 let id = app.focused_id().expect("something focused");
474 app.tree.node(id).name.clone()
475 }
476
477 #[test]
478 fn starts_focused_on_first_row_all_collapsed() {
479 let (_d, mut app) = app();
480 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
481 assert_eq!(focused_name(&mut app), "a");
482 }
483
484 #[test]
485 fn down_and_up_move_focus_clamped() {
486 let (_d, mut app) = app();
487 app.run_command(AppCommand::Down);
488 assert_eq!(focused_name(&mut app), "b");
489 app.run_command(AppCommand::Down);
490 assert_eq!(focused_name(&mut app), "c.txt");
491 app.run_command(AppCommand::Down);
492 assert_eq!(focused_name(&mut app), "c.txt");
493 app.run_command(AppCommand::Up);
494 assert_eq!(focused_name(&mut app), "b");
495 }
496
497 #[test]
498 fn expand_reveals_children_and_down_enters_them() {
499 let (_d, mut app) = app();
500 app.run_command(AppCommand::Expand);
501 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
502 app.run_command(AppCommand::Down);
503 assert_eq!(focused_name(&mut app), "aa");
504 }
505
506 #[test]
507 fn l_on_expanded_branch_descends_to_first_child() {
508 let (_d, mut app) = app();
509 app.handle_key(Key::parse("l").unwrap());
510
511 app.handle_key(Key::parse("l").unwrap());
512
513 assert_eq!(focused_name(&mut app), "aa");
514 }
515
516 #[test]
517 fn l_on_leaf_focuses_next_sibling() {
518 let (_d, mut app) = app_with_leaf_siblings();
519 app.run_command(AppCommand::Expand); app.run_command(AppCommand::Down); app.handle_key(Key::parse("l").unwrap());
523
524 assert_eq!(focused_name(&mut app), "d2.txt");
525 assert_eq!(app.visible_names(), ["d", "d1.txt", "d2.txt"]);
526 }
527
528 #[test]
529 fn l_on_last_leaf_of_a_container_stays_inside_it() {
530 let (_d, mut app) = app();
531 app.run_command(AppCommand::Expand); app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.handle_key(Key::parse("l").unwrap());
536
537 assert_eq!(focused_name(&mut app), "ab.txt");
539 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
540 }
541
542 #[test]
543 fn expand_on_the_last_top_level_leaf_is_a_noop() {
544 let (_d, mut app) = app();
545 app.run_command(AppCommand::Last);
546 assert_eq!(focused_name(&mut app), "c.txt");
547 assert_eq!(app.run_command(AppCommand::Expand), Effect::None);
548 assert_eq!(focused_name(&mut app), "c.txt");
549 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
550 }
551
552 #[test]
553 fn collapse_hides_children() {
554 let (_d, mut app) = app();
555 app.run_command(AppCommand::Expand);
556 app.run_command(AppCommand::Collapse);
557 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
558 }
559
560 #[test]
561 fn h_on_leaf_focuses_parent_without_collapsing_it() {
562 let (_d, mut app) = app();
563 app.run_command(AppCommand::Expand);
564 app.run_command(AppCommand::Down); app.run_command(AppCommand::Expand);
566 app.run_command(AppCommand::Down); app.handle_key(Key::parse("h").unwrap());
569
570 assert_eq!(focused_name(&mut app), "aa");
571 assert_eq!(
572 app.visible_names(),
573 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
574 );
575 }
576
577 #[test]
578 fn h_on_collapsed_branch_focuses_parent_without_collapsing_it() {
579 let (_d, mut app) = app();
580 app.run_command(AppCommand::Expand);
581 app.run_command(AppCommand::Down); app.handle_key(Key::parse("h").unwrap());
584
585 assert_eq!(focused_name(&mut app), "a");
586 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
587 }
588
589 #[test]
590 fn expand_recursively_expands_whole_subtree() {
591 let (_d, mut app) = app();
592 app.run_command(AppCommand::ExpandRecursively);
593 assert_eq!(
594 app.visible_names(),
595 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
596 );
597 }
598
599 #[test]
600 fn space_toggles_a_container_open_and_shut() {
601 let (_d, mut app) = app();
602
603 app.handle_key(Key::parse("space").unwrap());
604 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
605 assert_eq!(focused_name(&mut app), "a");
606
607 app.handle_key(Key::parse("space").unwrap());
608 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
609 assert_eq!(focused_name(&mut app), "a");
610 }
611
612 #[test]
613 fn space_on_a_leaf_does_nothing() {
614 let (_d, mut app) = app();
615 app.run_command(AppCommand::Last); app.handle_key(Key::parse("space").unwrap());
618
619 assert_eq!(focused_name(&mut app), "c.txt");
620 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
621 }
622
623 #[test]
624 fn ctrl_space_toggles_a_container_recursively() {
625 let (_d, mut app) = app();
626
627 app.handle_key(Key::parse("ctrl+space").unwrap());
628 assert_eq!(
629 app.visible_names(),
630 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
631 );
632 assert_eq!(focused_name(&mut app), "a");
633
634 app.handle_key(Key::parse("ctrl+space").unwrap());
635 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
636 app.run_command(AppCommand::Expand);
638 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
639 }
640
641 #[test]
642 fn ctrl_space_direction_follows_the_focused_container() {
643 let (_d, mut app) = app();
644 app.run_command(AppCommand::Expand); app.handle_key(Key::parse("ctrl+space").unwrap());
648
649 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
650 }
651
652 #[test]
653 fn shift_l_on_a_container_leaves_focus_on_it() {
654 let (_d, mut app) = app();
655
656 app.handle_key(Key::parse("L").unwrap());
657
658 assert_eq!(focused_name(&mut app), "a");
659 }
660
661 #[test]
662 fn shift_l_on_leaf_focuses_next_sibling() {
663 let (_d, mut app) = app_with_leaf_siblings();
664 app.run_command(AppCommand::Expand); app.run_command(AppCommand::Down); app.handle_key(Key::parse("L").unwrap());
668
669 assert_eq!(focused_name(&mut app), "d2.txt");
670 assert_eq!(app.visible_names(), ["d", "d1.txt", "d2.txt"]);
671 }
672
673 #[test]
674 fn shift_l_on_the_last_top_level_leaf_is_a_noop() {
675 let (_d, mut app) = app();
676 app.run_command(AppCommand::Last); app.handle_key(Key::parse("L").unwrap());
679
680 assert_eq!(focused_name(&mut app), "c.txt");
681 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
682 }
683
684 #[test]
685 fn collapse_recursively_collapses_whole_subtree() {
686 let (_d, mut app) = app();
687 app.run_command(AppCommand::ExpandRecursively);
688 app.run_command(AppCommand::CollapseRecursively);
689 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
690 app.run_command(AppCommand::Expand);
692 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
693 }
694
695 #[test]
696 fn shift_h_on_leaf_collapses_parent_and_focuses_it() {
697 let (_d, mut app) = app();
698 app.run_command(AppCommand::ExpandRecursively);
699 app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.handle_key(Key::parse("H").unwrap());
703
704 assert_eq!(focused_name(&mut app), "aa");
705 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
706 }
707
708 #[test]
709 fn shift_h_on_collapsed_branch_collapses_parent_and_focuses_it() {
710 let (_d, mut app) = app();
711 app.run_command(AppCommand::Expand);
712 app.run_command(AppCommand::Down); app.handle_key(Key::parse("H").unwrap());
715
716 assert_eq!(focused_name(&mut app), "a");
717 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
718 }
719
720 #[test]
721 fn shift_h_collapses_the_parent_recursively() {
722 let (_d, mut app) = app();
723 app.run_command(AppCommand::ExpandRecursively);
724 app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.run_command(AppCommand::Down); app.handle_key(Key::parse("H").unwrap());
729
730 assert_eq!(focused_name(&mut app), "a");
731 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
732 app.run_command(AppCommand::Expand);
734 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
735 }
736
737 #[test]
738 fn shift_h_on_a_top_level_leaf_leaves_focus_alone() {
739 let (_d, mut app) = app();
740 app.run_command(AppCommand::Last); app.handle_key(Key::parse("H").unwrap());
743
744 assert_eq!(focused_name(&mut app), "c.txt");
745 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
746 }
747
748 #[test]
749 fn select_expands_collapsed_dir_and_prints_leaf() {
750 let (_d, mut app) = app();
751 assert_eq!(app.run_command(AppCommand::Select), Effect::None);
752 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
753 app.run_command(AppCommand::Last);
754 let effect = app.run_command(AppCommand::Select);
755 let Effect::PrintAndExit(path) = effect else {
756 panic!("expected PrintAndExit, got {effect:?}");
757 };
758 assert!(std::path::Path::new(&path).is_absolute());
759 assert!(std::path::Path::new(&path).ends_with("c.txt"));
760 }
761
762 #[test]
763 fn select_does_not_descend_into_an_expanded_branch() {
764 let (_d, mut app) = app();
765 app.run_command(AppCommand::Select);
766
767 app.run_command(AppCommand::Select);
768
769 assert_eq!(focused_name(&mut app), "a");
770 }
771
772 #[test]
773 fn accept_prints_even_on_dir() {
774 let (_d, mut app) = app();
775 let effect = app.run_command(AppCommand::Accept);
776 let Effect::PrintAndExit(path) = effect else {
777 panic!("expected PrintAndExit, got {effect:?}");
778 };
779 assert!(std::path::Path::new(&path).ends_with("a"));
780 }
781
782 #[test]
783 fn alt_enter_prints_the_filesystem_basename() {
784 let (_d, mut app) = app();
785
786 assert_eq!(
787 app.handle_key(Key::parse("alt+enter").unwrap()),
788 Effect::PrintAndExit(OsString::from("a"))
789 );
790 }
791
792 #[test]
793 fn descend_expands_and_focuses_first_child() {
794 let (_d, mut app) = app();
795 app.run_command(AppCommand::Descend);
796 assert_eq!(focused_name(&mut app), "aa");
797 }
798
799 #[test]
800 fn tab_pushes_focused_nodes_as_view_roots_and_shift_tab_pops_them() {
801 let (_d, mut app) = app();
802
803 app.handle_key(Key::parse("tab").unwrap());
804 assert_eq!(focused_name(&mut app), "a");
805 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt"]);
806
807 app.handle_key(Key::parse("l").unwrap()); app.handle_key(Key::parse("tab").unwrap());
809 assert_eq!(focused_name(&mut app), "aa");
810 assert_eq!(app.visible_names(), ["aa", "aaa.txt"]);
811
812 app.handle_key(Key::parse("h").unwrap()); assert_eq!(app.visible_names(), ["aa"]);
815 app.handle_key(Key::parse("h").unwrap()); assert_eq!(focused_name(&mut app), "aa");
817
818 app.handle_key(Key::parse("l").unwrap()); assert_eq!(app.visible_names(), ["aa", "aaa.txt"]);
820
821 app.handle_key(Key::parse("shift+tab").unwrap());
822 assert_eq!(focused_name(&mut app), "aa");
823 assert_eq!(app.visible_names(), ["a", "aa", "aaa.txt", "ab.txt"]);
824
825 app.handle_key(Key::parse("shift+tab").unwrap());
826 assert_eq!(focused_name(&mut app), "aa");
827 assert_eq!(
828 app.visible_names(),
829 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
830 );
831
832 app.handle_key(Key::parse("shift+tab").unwrap());
834 assert_eq!(focused_name(&mut app), "aa");
835 assert_eq!(
836 app.visible_names(),
837 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
838 );
839 }
840
841 #[test]
842 fn tab_can_make_a_leaf_the_view_root() {
843 let (_d, mut app) = app();
844 app.run_command(AppCommand::Last);
845
846 app.handle_key(Key::parse("tab").unwrap());
847
848 assert_eq!(focused_name(&mut app), "c.txt");
849 assert_eq!(app.visible_names(), ["c.txt"]);
850 }
851
852 #[test]
853 fn escape_pops_one_root_at_a_time_then_quits() {
854 let (_d, mut app) = app();
855 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);
860 assert_eq!(focused_name(&mut app), "aa");
861 assert_eq!(app.visible_names(), ["a", "aa", "aaa.txt", "ab.txt"]);
862
863 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::None);
864 assert_eq!(focused_name(&mut app), "aa");
865 assert_eq!(
866 app.visible_names(),
867 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
868 );
869
870 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::Quit);
871 }
872
873 #[test]
874 fn sibling_navigation_skips_expanded_children() {
875 let (_d, mut app) = app();
876 app.run_command(AppCommand::Expand); app.run_command(AppCommand::NextSibling);
878 assert_eq!(focused_name(&mut app), "b");
879 app.run_command(AppCommand::PrevSibling);
880 assert_eq!(focused_name(&mut app), "a");
881 app.run_command(AppCommand::PrevSibling);
883 assert_eq!(focused_name(&mut app), "a");
884 }
885
886 #[test]
887 fn first_and_last() {
888 let (_d, mut app) = app();
889 app.run_command(AppCommand::Last);
890 assert_eq!(focused_name(&mut app), "c.txt");
891 app.run_command(AppCommand::First);
892 assert_eq!(focused_name(&mut app), "a");
893 }
894
895 #[test]
896 fn paging_moves_focus_by_page_amounts() {
897 let (_d, mut app) = app();
898 app.run_command(AppCommand::ExpandRecursively); app.page_height = 4;
900 app.run_command(AppCommand::HalfPageDown);
901 assert_eq!(focused_name(&mut app), "aaa.txt"); app.run_command(AppCommand::PageDown);
903 assert_eq!(focused_name(&mut app), "c.txt"); app.run_command(AppCommand::HalfPageUp);
905 assert_eq!(focused_name(&mut app), "ab.txt");
906 app.run_command(AppCommand::PageUp);
907 assert_eq!(focused_name(&mut app), "a");
908 }
909
910 #[test]
911 fn default_keys_drive_commands() {
912 let (_d, mut app) = app();
913 app.handle_key(Key::parse("j").unwrap());
914 assert_eq!(focused_name(&mut app), "b");
915 app.handle_key(Key::parse("k").unwrap());
916 assert_eq!(focused_name(&mut app), "a");
917 app.handle_key(Key::parse("l").unwrap());
918 assert_eq!(app.visible_names().len(), 5);
919 app.handle_key(Key::parse("h").unwrap());
920 assert_eq!(app.visible_names().len(), 3);
921 assert_eq!(app.handle_key(Key::parse("q").unwrap()), Effect::Quit);
922 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::Quit);
923 assert_eq!(app.handle_key(Key::parse("ctrl+c").unwrap()), Effect::Quit);
924 }
925
926 #[test]
927 fn g_goes_to_first_line_without_a_chord() {
928 let (_d, mut app) = app();
929 app.run_command(AppCommand::Last);
930 assert_eq!(app.handle_key(Key::parse("g").unwrap()), Effect::None);
931 assert_eq!(focused_name(&mut app), "a");
932 }
933
934 #[test]
935 fn shift_g_goes_to_last_visible_line() {
936 let (_d, mut app) = app();
937 app.handle_key(Key::parse("G").unwrap());
938 assert_eq!(focused_name(&mut app), "c.txt");
939 }
940
941 #[test]
942 fn user_binding_produces_shell_effect_with_paths() {
943 let (_d, tree) = fixture();
944 let config = Config::parse("[ctrl+e]\nsh = \"vim $path\"\nexit = true\n").unwrap();
945 let mut app = App::new(tree, &config, None);
946 app.run_command(AppCommand::Down); let effect = app.handle_key(Key::parse("ctrl+e").unwrap());
948 let Effect::RunShell {
949 cmd,
950 path,
951 relpath,
952 bg,
953 exit,
954 } = effect
955 else {
956 panic!("expected RunShell, got {effect:?}");
957 };
958 assert_eq!(cmd, "vim $path");
959 assert!(std::path::Path::new(&path).is_absolute());
960 assert!(std::path::Path::new(&path).ends_with("b"));
961 assert_eq!(relpath, OsString::from("b"));
962 assert!(!bg);
963 assert!(exit);
964 }
965
966 #[test]
967 fn user_binding_overrides_default() {
968 let (_d, tree) = fixture();
969 let config = Config::parse("[j]\ncmd = \"quit\"\n").unwrap();
970 let mut app = App::new(tree, &config, None);
971 assert_eq!(app.handle_key(Key::parse("j").unwrap()), Effect::Quit);
972 }
973
974 #[test]
975 fn user_can_override_the_new_g_binding() {
976 let (_d, tree) = fixture();
977 let config = Config::parse("[g]\ncmd = \"quit\"\n").unwrap();
978 let mut app = App::new(tree, &config, None);
979 assert_eq!(app.handle_key(Key::parse("g").unwrap()), Effect::Quit);
980 }
981
982 #[test]
983 fn question_mark_is_reserved_and_toggles_the_panel() {
984 let (_d, tree) = fixture();
985 let config = Config::parse("[?]\ncmd = \"quit\"\nhelp = \"Wrong\"\n").unwrap();
986 let mut app = App::new(tree, &config, None);
987
988 let question: Vec<_> = app
991 .panel_entries
992 .iter()
993 .filter(|entry| entry.key == Key::parse("?").unwrap())
994 .collect();
995 assert_eq!(question.len(), 1);
996 assert_eq!(question[0].description, "Shortcuts");
997
998 assert!(!app.keybinding_panel.is_open());
999 assert_eq!(app.handle_key(Key::parse("?").unwrap()), Effect::None);
1000 assert!(app.keybinding_panel.is_open());
1001 assert_eq!(app.handle_key(Key::parse("?").unwrap()), Effect::None);
1002 assert!(!app.keybinding_panel.is_open());
1003 }
1004
1005 #[test]
1006 fn open_panel_stays_open_while_bindings_run_and_escape_only_closes_it() {
1007 let (_d, mut app) = app();
1008 app.handle_key(Key::parse("?").unwrap());
1009
1010 app.handle_key(Key::parse("j").unwrap());
1011 assert_eq!(focused_name(&mut app), "b");
1012 assert!(app.keybinding_panel.is_open());
1013
1014 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::None);
1015 assert!(!app.keybinding_panel.is_open());
1016 assert_eq!(focused_name(&mut app), "b");
1017 }
1018
1019 #[test]
1020 fn jump_owns_question_mark_and_escape_without_dismissing_the_panel() {
1021 let (_d, mut app) = app();
1022 app.handle_key(Key::parse("?").unwrap());
1023 app.handle_key(Key::parse("/").unwrap());
1024 app.handle_key(Key::parse("?").unwrap());
1025
1026 let Mode::Jump(jump) = &app.mode else {
1027 panic!("expected jump mode");
1028 };
1029 assert_eq!(jump.query(), "?");
1030 assert!(app.keybinding_panel.is_open());
1031
1032 app.handle_key(Key::parse("esc").unwrap());
1033 assert!(matches!(app.mode, Mode::Normal));
1034 assert!(app.keybinding_panel.is_open());
1035 }
1036
1037 #[test]
1038 fn unbound_key_is_noop() {
1039 let (_d, mut app) = app();
1040 assert_eq!(app.handle_key(Key::parse("x").unwrap()), Effect::None);
1041 }
1042
1043 #[test]
1044 fn initial_expand_depth_one_expands_top_level_only() {
1045 let (_d, tree) = fixture();
1046 let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::Depth(1)));
1047 assert_eq!(
1048 app.visible_names(),
1049 ["a", "aa", "ab.txt", "b", "ba.txt", "c.txt"]
1050 );
1051 }
1052
1053 #[test]
1054 fn initial_expand_all_expands_everything() {
1055 let (_d, tree) = fixture();
1056 let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
1057 assert_eq!(
1058 app.visible_names(),
1059 ["a", "aa", "aaa.txt", "ab.txt", "b", "ba.txt", "c.txt"]
1060 );
1061 }
1062
1063 fn in_jump(app: &App) -> bool {
1064 matches!(app.mode, Mode::Jump(_))
1065 }
1066
1067 #[test]
1068 fn slash_opens_the_jump_picker() {
1069 let (_d, mut app) = app();
1070 assert!(!in_jump(&app));
1071 app.handle_key(Key::parse("/").unwrap());
1072 assert!(in_jump(&app));
1073 }
1074
1075 #[test]
1076 fn cancelling_the_picker_leaves_focus_untouched() {
1077 let (_d, mut app) = app();
1078 app.run_command(AppCommand::Down); assert_eq!(focused_name(&mut app), "b");
1080 app.handle_key(Key::parse("/").unwrap());
1081 app.handle_key(Key::parse("a").unwrap()); app.handle_key(Key::parse("esc").unwrap());
1083 assert!(!in_jump(&app));
1084 assert_eq!(focused_name(&mut app), "b");
1085 }
1086
1087 #[test]
1088 fn accepting_jumps_focus_and_expands_ancestors() {
1089 let (_d, mut app) = app();
1090 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
1092 app.handle_key(Key::parse("/").unwrap());
1093 for k in ["a", "a", "a"] {
1094 app.handle_key(Key::parse(k).unwrap()); }
1096 app.handle_key(Key::parse("enter").unwrap());
1097 assert!(!in_jump(&app));
1098 assert_eq!(focused_name(&mut app), "aaa.txt");
1099 assert!(app.visible_names().contains(&"aaa.txt".to_string()));
1101 }
1102
1103 #[test]
1104 fn a_user_can_rebind_jump_off_slash() {
1105 let (_d, tree) = fixture();
1106 let config = Config::parse("[ctrl+p]\ncmd = \"jump\"\n").unwrap();
1107 let mut app = App::new(tree, &config, None);
1108 app.handle_key(Key::parse("ctrl+p").unwrap());
1109 assert!(in_jump(&app));
1110 }
1111}