Skip to main content

ite_cli/
app.rs

1//! Application state: focus/expansion driven by app commands and keybindings.
2
3use 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/// What the event loop must do after a key is handled.
18#[derive(Clone, PartialEq, Debug)]
19pub enum Effect {
20    None,
21    /// Exit without output.
22    Quit,
23    /// The default action: print the node's source-specific value and exit.
24    PrintAndExit(OsString),
25    /// Run a configured shell command on the focused node.
26    RunShell {
27        cmd: String,
28        path: OsString,
29        relpath: OsString,
30        bg: bool,
31        exit: bool,
32    },
33}
34
35/// The app's input mode. `Normal` drives the tree via the keymap; other modes
36/// take over key handling and rendering until they close. Adding a mode is a
37/// new variant plus one dispatch arm in `handle_key` and one in `ui::draw`.
38pub 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    /// The current input mode; see [`Mode`].
48    pub mode: Mode,
49    keymap: HashMap<Key, Binding>,
50    /// Panel rows derived from the effective keymap, built once at startup.
51    pub(crate) panel_entries: Vec<KeybindingEntry>,
52    pub keybinding_panel: KeybindingPanelState,
53    /// Temporary view roots, from the original forest to the current subtree.
54    root_history: Vec<Option<NodeId>>,
55    /// Rows per screen; the UI updates this every frame.
56    pub page_height: usize,
57    /// Terminal default colors, when the terminal answered the startup query.
58    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        // `?` is reserved for the panel toggle: a configured `[?]` table is
65        // accepted but silently ignored.
66        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    /// The default keybindings, before user config is merged.
109    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    /// Names of currently visible rows, in on-screen order.
157    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    /// Handle a normalized key through the active mode and effective keymap.
166    pub fn handle_key(&mut self, key: Key) -> Effect {
167        let _span = crate::profile::span("app::handle_key");
168        // A modal picker takes over key handling until it closes. Accepting
169        // moves focus (expanding ancestors); cancelling leaves focus untouched,
170        // so the user returns to exactly where they opened it.
171        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    /// Execute an app command.
210    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                        // Nothing to open: step along to the next sibling.
224                        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                        // Nothing to open: step along to the next sibling.
246                        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                        // Nothing to collapse here: fold up the enclosing
259                        // container and land on it.
260                        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    /// The focused node if it is expandable.
327    fn focused_branch(&mut self) -> Option<NodeId> {
328        self.focused_id().filter(|&id| !self.tree.is_leaf(id))
329    }
330
331    /// Flip the focused container's expansion, leaving focus on it. A leaf has
332    /// nothing to toggle. The direction comes from the focused node's own
333    /// state, so a recursive toggle on an open container shuts it rather than
334    /// opening what is still shut underneath.
335    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    /// Builds:
431    ///   root/
432    ///     a/
433    ///       aa/
434    ///         aaa.txt
435    ///       ab.txt
436    ///     b/
437    ///       ba.txt
438    ///     c.txt
439    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    /// The main fixture has no leaf with a following sibling. Builds:
458    ///   root/
459    ///     d/
460    ///       d1.txt
461    ///       d2.txt
462    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); // expand "d"
520        app.run_command(AppCommand::Down); // focus "d1.txt"
521
522        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); // expand "a"
532        app.run_command(AppCommand::Down); // focus "aa"
533        app.run_command(AppCommand::Down); // focus "ab.txt", last child of "a"
534
535        app.handle_key(Key::parse("l").unwrap());
536
537        // Does not spill over to "b": siblings, not the next visible row.
538        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); // focus collapsed "aa"
565        app.run_command(AppCommand::Expand);
566        app.run_command(AppCommand::Down); // focus "aaa.txt"
567
568        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); // focus collapsed "aa"
582
583        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); // focus "c.txt"
616
617        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        // Descendant expansion was cleared, not just hidden.
637        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); // "a" expanded, "aa" still shut
645
646        // "a" is open, so the recursive toggle shuts it rather than reopening.
647        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); // expand "d"
665        app.run_command(AppCommand::Down); // focus "d1.txt"
666
667        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); // focus "c.txt"
677
678        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        // Descendant expansion was cleared, not just hidden.
691        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); // focus expanded "aa"
700        app.run_command(AppCommand::Down); // focus "aaa.txt"
701
702        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); // focus collapsed "aa"
713
714        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); // focus expanded "aa"
725        app.run_command(AppCommand::Down); // focus "aaa.txt"
726        app.run_command(AppCommand::Down); // focus "ab.txt"
727
728        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        // "aa" was collapsed along with "a", not just hidden by it.
733        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); // focus top-level leaf "c.txt"
741
742        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()); // focus "aa"
808        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        // The temporary root is a navigation boundary.
813        app.handle_key(Key::parse("h").unwrap()); // collapse root "aa"
814        assert_eq!(app.visible_names(), ["aa"]);
815        app.handle_key(Key::parse("h").unwrap()); // cannot focus parent "a"
816        assert_eq!(focused_name(&mut app), "aa");
817
818        app.handle_key(Key::parse("l").unwrap()); // expand root "aa"
819        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        // There is no root before the original forest.
833        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()); // root "a"
856        app.handle_key(Key::parse("l").unwrap()); // focus "aa"
857        app.handle_key(Key::parse("tab").unwrap()); // root "aa"
858
859        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); // "a" expanded, children visible
877        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        // No previous sibling: no-op.
882        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); // 6 visible rows
899        app.page_height = 4;
900        app.run_command(AppCommand::HalfPageDown);
901        assert_eq!(focused_name(&mut app), "aaa.txt"); // moved 2
902        app.run_command(AppCommand::PageDown);
903        assert_eq!(focused_name(&mut app), "c.txt"); // clamped at end
904        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); // focus "b"
947        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        // The `[?]` table was dropped: the panel lists the reserved binding,
989        // not the configured one.
990        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); // focus "b"
1079        assert_eq!(focused_name(&mut app), "b");
1080        app.handle_key(Key::parse("/").unwrap());
1081        app.handle_key(Key::parse("a").unwrap()); // type into the query
1082        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        // Everything starts collapsed: only the top level is visible.
1091        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()); // query "aaa" -> a/aa/aaa.txt
1095        }
1096        app.handle_key(Key::parse("enter").unwrap());
1097        assert!(!in_jump(&app));
1098        assert_eq!(focused_name(&mut app), "aaa.txt");
1099        // The path to it was expanded so the focused node is visible.
1100        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}