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::keys::Key;
12use crate::tree::{NodeId, Tree};
13
14#[derive(Clone, PartialEq, Debug)]
16pub enum Effect {
17 None,
18 Quit,
20 PrintAndExit(OsString),
22 RunShell {
24 cmd: String,
25 path: OsString,
26 relpath: OsString,
27 bg: bool,
28 exit: bool,
29 },
30}
31
32pub enum Mode {
36 Normal,
37 Jump(Jump),
38}
39
40pub struct App {
41 pub tree: Tree,
42 pub state: TreeListViewState<NodeId>,
43 pub query: TreeQuery,
44 pub mode: Mode,
46 keymap: HashMap<Key, Binding>,
47 pending_g: bool,
49 pub page_height: usize,
51 pub palette: Option<crate::ui::Palette>,
53}
54
55impl App {
56 pub fn new(tree: Tree, config: &Config, expand: Option<ExpandSpec>) -> Self {
57 let mut keymap = Self::default_keymap();
58 keymap.extend(config.bindings.clone());
59 let mut app = Self {
60 tree,
61 state: TreeListViewState::with_capacity(0),
62 query: TreeQuery::new(),
63 mode: Mode::Normal,
64 keymap,
65 pending_g: false,
66 page_height: 20,
67 palette: None,
68 };
69 match expand {
70 None => {}
71 Some(ExpandSpec::All) => {
72 let branches: Vec<_> = app.tree.branches().collect();
73 for (id, parent) in branches {
74 app.state.set_expanded(id, parent, true);
75 }
76 }
77 Some(ExpandSpec::Depth(n)) => {
78 let branches: Vec<_> = app.tree.branches().collect();
79 for (id, parent) in branches {
80 if app.tree.node(id).depth < n {
81 app.state.set_expanded(id, parent, true);
82 }
83 }
84 }
85 }
86 app.state.ensure_projection(&app.tree, &app.query);
87 app.state.select_first();
88 app
89 }
90
91 pub fn default_keymap() -> HashMap<Key, Binding> {
93 let cmd = |action: AppCommand| Binding {
94 action: BindingAction::Cmd(action),
95 exit: false,
96 bg: false,
97 };
98 let mut map = HashMap::new();
99 for (keys, action) in [
100 (&["j", "down"][..], AppCommand::Down),
101 (&["k", "up"], AppCommand::Up),
102 (&["l", "right"], AppCommand::Expand),
103 (&["h", "left"], AppCommand::Collapse),
104 (&["L", "shift+right"], AppCommand::ExpandRecursively),
105 (&["H", "shift+left"], AppCommand::CollapseRecursively),
106 (&["enter"], AppCommand::Select),
107 (&["ctrl+enter"], AppCommand::Accept),
108 (&["alt+enter"], AppCommand::AcceptAlternate),
109 (&["tab"], AppCommand::Descend),
110 (&["J"], AppCommand::NextSibling),
111 (&["K"], AppCommand::PrevSibling),
112 (&["ctrl+f"], AppCommand::PageDown),
113 (&["ctrl+b"], AppCommand::PageUp),
114 (&["ctrl+d"], AppCommand::HalfPageDown),
115 (&["ctrl+u"], AppCommand::HalfPageUp),
116 (&["G"], AppCommand::Last),
117 (&["/"], AppCommand::Jump),
118 (&["q", "esc", "ctrl+c"], AppCommand::Quit),
119 ] {
120 for key in keys {
121 map.insert(Key::parse(key).expect("valid default key"), cmd(action));
122 }
123 }
124 map
125 }
126
127 pub fn focused_id(&mut self) -> Option<NodeId> {
128 self.state.ensure_projection(&self.tree, &self.query);
129 self.state.selected_id()
130 }
131
132 pub fn visible_names(&mut self) -> Vec<String> {
134 self.state.ensure_projection(&self.tree, &self.query);
135 self.state
136 .visible_ids()
137 .map(|id| self.tree.node(id).name.clone())
138 .collect()
139 }
140
141 pub fn handle_key(&mut self, key: Key) -> Effect {
143 let _span = crate::profile::span("app::handle_key");
144 if let Mode::Jump(jump) = &mut self.mode {
148 return match jump.handle_key(key) {
149 JumpOutcome::Stay => Effect::None,
150 JumpOutcome::Cancel => {
151 self.mode = Mode::Normal;
152 Effect::None
153 }
154 JumpOutcome::Accept(id) => {
155 self.mode = Mode::Normal;
156 self.state.select_by_id(&self.tree, &self.query, id);
157 Effect::None
158 }
159 };
160 }
161 let g = Key::parse("g").unwrap();
162 if self.pending_g {
163 self.pending_g = false;
164 if key == g {
165 return self.run_command(AppCommand::First);
166 }
167 } else if key == g && !self.keymap.contains_key(&g) {
169 self.pending_g = true;
170 return Effect::None;
171 }
172 match self.keymap.get(&key).cloned() {
173 None => Effect::None,
174 Some(binding) => match binding.action {
175 BindingAction::Cmd(cmd) => self.run_command(cmd),
176 BindingAction::Sh(cmd) => match self.focused_id() {
177 None => Effect::None,
178 Some(id) => Effect::RunShell {
179 cmd,
180 path: self.tree.node(id).action.path.clone(),
181 relpath: self.tree.node(id).action.relpath.clone(),
182 bg: binding.bg,
183 exit: binding.exit,
184 },
185 },
186 },
187 }
188 }
189
190 pub fn run_command(&mut self, cmd: AppCommand) -> Effect {
192 self.state.ensure_projection(&self.tree, &self.query);
193 match cmd {
194 AppCommand::Down => {
195 self.state.select_next();
196 }
197 AppCommand::Up => {
198 self.state.select_prev();
199 }
200 AppCommand::Expand => {
201 if let Some(id) = self.focused_branch() {
202 let parent = self.tree.node(id).parent;
203 if self.state.node_is_expanded(id, parent) {
204 self.state.select_id(Some(self.tree.node(id).children[0]));
205 } else {
206 self.state.set_expanded(id, parent, true);
207 }
208 }
209 }
210 AppCommand::Collapse => {
211 if let Some(id) = self.focused_id() {
212 let parent = self.tree.node(id).parent;
213 if !self.tree.is_leaf(id) && self.state.node_is_expanded(id, parent) {
214 self.state.set_expanded(id, parent, false);
215 } else if let Some(parent) = parent {
216 self.state.select_id(Some(parent));
217 }
218 }
219 }
220 AppCommand::ExpandRecursively => self.set_expanded_recursively(true),
221 AppCommand::CollapseRecursively => self.set_expanded_recursively(false),
222 AppCommand::Select => {
223 if let Some(id) = self.focused_id() {
224 if self.tree.is_leaf(id) {
225 return Effect::PrintAndExit(self.tree.node(id).action.output.clone());
226 }
227 self.state.set_expanded(id, self.tree.node(id).parent, true);
228 }
229 }
230 AppCommand::Accept => {
231 if let Some(id) = self.focused_id() {
232 return Effect::PrintAndExit(self.tree.node(id).action.output.clone());
233 }
234 }
235 AppCommand::AcceptAlternate => {
236 if let Some(id) = self.focused_id() {
237 return Effect::PrintAndExit(
238 self.tree.node(id).action.alternate_output.clone(),
239 );
240 }
241 }
242 AppCommand::Descend => {
243 if let Some(id) = self.focused_branch() {
244 self.state.set_expanded(id, self.tree.node(id).parent, true);
245 self.state.ensure_projection(&self.tree, &self.query);
246 let first_child = self.tree.node(id).children[0];
247 self.state.select_id(Some(first_child));
248 }
249 }
250 AppCommand::NextSibling => self.move_sibling(1),
251 AppCommand::PrevSibling => self.move_sibling(-1),
252 AppCommand::PageDown => self.move_focus_by(self.page_height as isize),
253 AppCommand::PageUp => self.move_focus_by(-(self.page_height as isize)),
254 AppCommand::HalfPageDown => self.move_focus_by((self.page_height / 2) as isize),
255 AppCommand::HalfPageUp => self.move_focus_by(-((self.page_height / 2) as isize)),
256 AppCommand::First => {
257 self.state.select_first();
258 }
259 AppCommand::Last => {
260 self.state.select_last();
261 }
262 AppCommand::Jump => {
263 self.mode = Mode::Jump(Jump::open(&self.tree));
264 }
265 AppCommand::Quit => return Effect::Quit,
266 }
267 Effect::None
268 }
269
270 fn focused_branch(&mut self) -> Option<NodeId> {
272 self.focused_id().filter(|&id| !self.tree.is_leaf(id))
273 }
274
275 fn set_expanded_recursively(&mut self, expanded: bool) {
276 let Some(root) = self.focused_id() else {
277 return;
278 };
279 let mut stack = vec![root];
280 while let Some(id) = stack.pop() {
281 if !self.tree.is_leaf(id) {
282 self.state
283 .set_expanded(id, self.tree.node(id).parent, expanded);
284 stack.extend_from_slice(&self.tree.node(id).children);
285 }
286 }
287 }
288
289 fn move_sibling(&mut self, delta: isize) {
290 let Some(id) = self.focused_id() else { return };
291 let siblings = match self.tree.node(id).parent {
292 Some(parent) => self.tree.node(parent).children.as_slice(),
293 None => self.tree.root_ids(),
294 };
295 let pos = siblings.iter().position(|&s| s == id).unwrap_or(0) as isize;
296 let target = pos + delta;
297 if (0..siblings.len() as isize).contains(&target) {
298 let target = siblings[target as usize];
299 self.state.select_id(Some(target));
300 }
301 }
302
303 fn move_focus_by(&mut self, delta: isize) {
304 let len = self.state.visible_len();
305 if len == 0 {
306 return;
307 }
308 let current = self.state.selected_index().unwrap_or(0) as isize;
309 let target = (current + delta).clamp(0, len as isize - 1);
310 self.state.select_index(Some(target as usize));
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::fstree;
318
319 fn fixture() -> (tempfile::TempDir, Tree) {
329 let dir = tempfile::tempdir().unwrap();
330 let p = dir.path();
331 std::fs::create_dir_all(p.join("a/aa")).unwrap();
332 std::fs::write(p.join("a/aa/aaa.txt"), "").unwrap();
333 std::fs::write(p.join("a/ab.txt"), "").unwrap();
334 std::fs::create_dir(p.join("b")).unwrap();
335 std::fs::write(p.join("b/ba.txt"), "").unwrap();
336 std::fs::write(p.join("c.txt"), "").unwrap();
337 let tree = fstree::scan(p, false).unwrap();
338 (dir, tree)
339 }
340
341 fn app() -> (tempfile::TempDir, App) {
342 let (dir, tree) = fixture();
343 (dir, App::new(tree, &Config::default(), None))
344 }
345
346 fn focused_name(app: &mut App) -> String {
347 let id = app.focused_id().expect("something focused");
348 app.tree.node(id).name.clone()
349 }
350
351 #[test]
352 fn starts_focused_on_first_row_all_collapsed() {
353 let (_d, mut app) = app();
354 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
355 assert_eq!(focused_name(&mut app), "a");
356 }
357
358 #[test]
359 fn down_and_up_move_focus_clamped() {
360 let (_d, mut app) = app();
361 app.run_command(AppCommand::Down);
362 assert_eq!(focused_name(&mut app), "b");
363 app.run_command(AppCommand::Down);
364 assert_eq!(focused_name(&mut app), "c.txt");
365 app.run_command(AppCommand::Down);
366 assert_eq!(focused_name(&mut app), "c.txt");
367 app.run_command(AppCommand::Up);
368 assert_eq!(focused_name(&mut app), "b");
369 }
370
371 #[test]
372 fn expand_reveals_children_and_down_enters_them() {
373 let (_d, mut app) = app();
374 app.run_command(AppCommand::Expand);
375 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
376 app.run_command(AppCommand::Down);
377 assert_eq!(focused_name(&mut app), "aa");
378 }
379
380 #[test]
381 fn l_on_expanded_branch_descends_to_first_child() {
382 let (_d, mut app) = app();
383 app.handle_key(Key::parse("l").unwrap());
384
385 app.handle_key(Key::parse("l").unwrap());
386
387 assert_eq!(focused_name(&mut app), "aa");
388 }
389
390 #[test]
391 fn expand_is_noop_on_leaf() {
392 let (_d, mut app) = app();
393 app.run_command(AppCommand::Last);
394 assert_eq!(focused_name(&mut app), "c.txt");
395 assert_eq!(app.run_command(AppCommand::Expand), Effect::None);
396 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
397 }
398
399 #[test]
400 fn collapse_hides_children() {
401 let (_d, mut app) = app();
402 app.run_command(AppCommand::Expand);
403 app.run_command(AppCommand::Collapse);
404 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
405 }
406
407 #[test]
408 fn h_on_leaf_focuses_parent_without_collapsing_it() {
409 let (_d, mut app) = app();
410 app.run_command(AppCommand::Expand);
411 app.run_command(AppCommand::Down); app.run_command(AppCommand::Expand);
413 app.run_command(AppCommand::Down); app.handle_key(Key::parse("h").unwrap());
416
417 assert_eq!(focused_name(&mut app), "aa");
418 assert_eq!(
419 app.visible_names(),
420 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
421 );
422 }
423
424 #[test]
425 fn h_on_collapsed_branch_focuses_parent_without_collapsing_it() {
426 let (_d, mut app) = app();
427 app.run_command(AppCommand::Expand);
428 app.run_command(AppCommand::Down); app.handle_key(Key::parse("h").unwrap());
431
432 assert_eq!(focused_name(&mut app), "a");
433 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
434 }
435
436 #[test]
437 fn expand_recursively_expands_whole_subtree() {
438 let (_d, mut app) = app();
439 app.run_command(AppCommand::ExpandRecursively);
440 assert_eq!(
441 app.visible_names(),
442 ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
443 );
444 }
445
446 #[test]
447 fn collapse_recursively_collapses_whole_subtree() {
448 let (_d, mut app) = app();
449 app.run_command(AppCommand::ExpandRecursively);
450 app.run_command(AppCommand::CollapseRecursively);
451 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
452 app.run_command(AppCommand::Expand);
454 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
455 }
456
457 #[test]
458 fn select_expands_collapsed_dir_and_prints_leaf() {
459 let (_d, mut app) = app();
460 assert_eq!(app.run_command(AppCommand::Select), Effect::None);
461 assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
462 app.run_command(AppCommand::Last);
463 let effect = app.run_command(AppCommand::Select);
464 let Effect::PrintAndExit(path) = effect else {
465 panic!("expected PrintAndExit, got {effect:?}");
466 };
467 assert!(std::path::Path::new(&path).is_absolute());
468 assert!(std::path::Path::new(&path).ends_with("c.txt"));
469 }
470
471 #[test]
472 fn select_does_not_descend_into_an_expanded_branch() {
473 let (_d, mut app) = app();
474 app.run_command(AppCommand::Select);
475
476 app.run_command(AppCommand::Select);
477
478 assert_eq!(focused_name(&mut app), "a");
479 }
480
481 #[test]
482 fn accept_prints_even_on_dir() {
483 let (_d, mut app) = app();
484 let effect = app.run_command(AppCommand::Accept);
485 let Effect::PrintAndExit(path) = effect else {
486 panic!("expected PrintAndExit, got {effect:?}");
487 };
488 assert!(std::path::Path::new(&path).ends_with("a"));
489 }
490
491 #[test]
492 fn alt_enter_prints_the_filesystem_basename() {
493 let (_d, mut app) = app();
494
495 assert_eq!(
496 app.handle_key(Key::parse("alt+enter").unwrap()),
497 Effect::PrintAndExit(OsString::from("a"))
498 );
499 }
500
501 #[test]
502 fn descend_expands_and_focuses_first_child() {
503 let (_d, mut app) = app();
504 app.run_command(AppCommand::Descend);
505 assert_eq!(focused_name(&mut app), "aa");
506 }
507
508 #[test]
509 fn sibling_navigation_skips_expanded_children() {
510 let (_d, mut app) = app();
511 app.run_command(AppCommand::Expand); app.run_command(AppCommand::NextSibling);
513 assert_eq!(focused_name(&mut app), "b");
514 app.run_command(AppCommand::PrevSibling);
515 assert_eq!(focused_name(&mut app), "a");
516 app.run_command(AppCommand::PrevSibling);
518 assert_eq!(focused_name(&mut app), "a");
519 }
520
521 #[test]
522 fn first_and_last() {
523 let (_d, mut app) = app();
524 app.run_command(AppCommand::Last);
525 assert_eq!(focused_name(&mut app), "c.txt");
526 app.run_command(AppCommand::First);
527 assert_eq!(focused_name(&mut app), "a");
528 }
529
530 #[test]
531 fn paging_moves_focus_by_page_amounts() {
532 let (_d, mut app) = app();
533 app.run_command(AppCommand::ExpandRecursively); app.page_height = 4;
535 app.run_command(AppCommand::HalfPageDown);
536 assert_eq!(focused_name(&mut app), "aaa.txt"); app.run_command(AppCommand::PageDown);
538 assert_eq!(focused_name(&mut app), "c.txt"); app.run_command(AppCommand::HalfPageUp);
540 assert_eq!(focused_name(&mut app), "ab.txt");
541 app.run_command(AppCommand::PageUp);
542 assert_eq!(focused_name(&mut app), "a");
543 }
544
545 #[test]
546 fn default_keys_drive_commands() {
547 let (_d, mut app) = app();
548 app.handle_key(Key::parse("j").unwrap());
549 assert_eq!(focused_name(&mut app), "b");
550 app.handle_key(Key::parse("k").unwrap());
551 assert_eq!(focused_name(&mut app), "a");
552 app.handle_key(Key::parse("l").unwrap());
553 assert_eq!(app.visible_names().len(), 5);
554 app.handle_key(Key::parse("h").unwrap());
555 assert_eq!(app.visible_names().len(), 3);
556 assert_eq!(app.handle_key(Key::parse("q").unwrap()), Effect::Quit);
557 assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::Quit);
558 assert_eq!(app.handle_key(Key::parse("ctrl+c").unwrap()), Effect::Quit);
559 }
560
561 #[test]
562 fn gg_chord_goes_to_first_line() {
563 let (_d, mut app) = app();
564 app.run_command(AppCommand::Last);
565 assert_eq!(app.handle_key(Key::parse("g").unwrap()), Effect::None);
566 app.handle_key(Key::parse("g").unwrap());
567 assert_eq!(focused_name(&mut app), "a");
568 app.run_command(AppCommand::Last);
570 app.handle_key(Key::parse("g").unwrap());
571 app.handle_key(Key::parse("j").unwrap());
572 assert_eq!(focused_name(&mut app), "c.txt");
573 }
574
575 #[test]
576 fn shift_g_goes_to_last_visible_line() {
577 let (_d, mut app) = app();
578 app.handle_key(Key::parse("G").unwrap());
579 assert_eq!(focused_name(&mut app), "c.txt");
580 }
581
582 #[test]
583 fn user_binding_produces_shell_effect_with_paths() {
584 let (_d, tree) = fixture();
585 let config = Config::parse("[ctrl+e]\nsh = \"vim $path\"\nexit = true\n").unwrap();
586 let mut app = App::new(tree, &config, None);
587 app.run_command(AppCommand::Down); let effect = app.handle_key(Key::parse("ctrl+e").unwrap());
589 let Effect::RunShell {
590 cmd,
591 path,
592 relpath,
593 bg,
594 exit,
595 } = effect
596 else {
597 panic!("expected RunShell, got {effect:?}");
598 };
599 assert_eq!(cmd, "vim $path");
600 assert!(std::path::Path::new(&path).is_absolute());
601 assert!(std::path::Path::new(&path).ends_with("b"));
602 assert_eq!(relpath, OsString::from("b"));
603 assert!(!bg);
604 assert!(exit);
605 }
606
607 #[test]
608 fn user_binding_overrides_default() {
609 let (_d, tree) = fixture();
610 let config = Config::parse("[j]\ncmd = \"quit\"\n").unwrap();
611 let mut app = App::new(tree, &config, None);
612 assert_eq!(app.handle_key(Key::parse("j").unwrap()), Effect::Quit);
613 }
614
615 #[test]
616 fn unbound_key_is_noop() {
617 let (_d, mut app) = app();
618 assert_eq!(app.handle_key(Key::parse("x").unwrap()), Effect::None);
619 }
620
621 #[test]
622 fn initial_expand_depth_one_expands_top_level_only() {
623 let (_d, tree) = fixture();
624 let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::Depth(1)));
625 assert_eq!(
626 app.visible_names(),
627 ["a", "aa", "ab.txt", "b", "ba.txt", "c.txt"]
628 );
629 }
630
631 #[test]
632 fn initial_expand_all_expands_everything() {
633 let (_d, tree) = fixture();
634 let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
635 assert_eq!(
636 app.visible_names(),
637 ["a", "aa", "aaa.txt", "ab.txt", "b", "ba.txt", "c.txt"]
638 );
639 }
640
641 fn in_jump(app: &App) -> bool {
642 matches!(app.mode, Mode::Jump(_))
643 }
644
645 #[test]
646 fn slash_opens_the_jump_picker() {
647 let (_d, mut app) = app();
648 assert!(!in_jump(&app));
649 app.handle_key(Key::parse("/").unwrap());
650 assert!(in_jump(&app));
651 }
652
653 #[test]
654 fn cancelling_the_picker_leaves_focus_untouched() {
655 let (_d, mut app) = app();
656 app.run_command(AppCommand::Down); assert_eq!(focused_name(&mut app), "b");
658 app.handle_key(Key::parse("/").unwrap());
659 app.handle_key(Key::parse("a").unwrap()); app.handle_key(Key::parse("esc").unwrap());
661 assert!(!in_jump(&app));
662 assert_eq!(focused_name(&mut app), "b");
663 }
664
665 #[test]
666 fn accepting_jumps_focus_and_expands_ancestors() {
667 let (_d, mut app) = app();
668 assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
670 app.handle_key(Key::parse("/").unwrap());
671 for k in ["a", "a", "a"] {
672 app.handle_key(Key::parse(k).unwrap()); }
674 app.handle_key(Key::parse("enter").unwrap());
675 assert!(!in_jump(&app));
676 assert_eq!(focused_name(&mut app), "aaa.txt");
677 assert!(app.visible_names().contains(&"aaa.txt".to_string()));
679 }
680
681 #[test]
682 fn a_user_can_rebind_jump_off_slash() {
683 let (_d, tree) = fixture();
684 let config = Config::parse("[ctrl+p]\ncmd = \"jump\"\n").unwrap();
685 let mut app = App::new(tree, &config, None);
686 app.handle_key(Key::parse("ctrl+p").unwrap());
687 assert!(in_jump(&app));
688 }
689}