Skip to main content

ite_cli/
app.rs

1//! I/O-free interaction coordinator. `App` owns the tree-list state, effective
2//! keymap, expansion/focus behavior, modal jump picker, keybinding-panel state,
3//! and the execution of configured `AppCommand`s.
4//!
5//! Key handling mutates application state or returns an `Effect` for the
6//! executable to perform; this module neither draws nor touches the terminal,
7//! filesystem, stdout, or subprocesses. Modal state machines live in their own
8//! modules and are routed from here.
9
10use std::collections::HashMap;
11use std::ffi::OsString;
12
13use tui_treelistview::{TreeListViewState, TreeQuery};
14
15use crate::cli::ExpandSpec;
16use crate::config::{AppCommand, Binding, BindingAction, Config};
17use crate::jump::{Jump, JumpOutcome};
18use crate::keybindings::{
19    CLOSE_KEY, KeybindingEntry, KeybindingPanelState, TOGGLE_KEY, build_entries,
20};
21use crate::keys::Key;
22use crate::tree::{NodeId, Tree};
23
24/// What the event loop must do after a key is handled.
25#[derive(Clone, PartialEq, Debug)]
26pub enum Effect {
27    None,
28    /// Exit without output.
29    Quit,
30    /// The default action: print the node's source-specific value and exit.
31    PrintAndExit(OsString),
32    /// Hand a filesystem path to the platform's default opener.
33    Open(OsString),
34    /// Run a configured shell command on the focused node.
35    RunShell {
36        cmd: String,
37        path: OsString,
38        relpath: OsString,
39        bg: bool,
40        exit: bool,
41    },
42}
43
44/// The app's input mode. `Normal` drives the tree via the keymap; other modes
45/// take over key handling and rendering until they close. Adding a mode is a
46/// new variant plus one dispatch arm in `handle_key` and one in `ui::draw`.
47pub enum Mode {
48    Normal,
49    Jump(Jump),
50    /// Blocking on index completion before the jump picker can open; a
51    /// progress line renders until the sweep finishes (esc cancels).
52    Indexing,
53}
54
55/// Nodes loaded per cooperative work quantum in [`App::do_work`].
56const WORK_QUANTUM: usize = 500;
57
58pub struct App {
59    pub tree: Tree,
60    pub state: TreeListViewState<NodeId>,
61    pub query: TreeQuery,
62    /// The current input mode; see [`Mode`].
63    pub mode: Mode,
64    keymap: HashMap<Key, Binding>,
65    /// User entries followed by non-overridden built-ins, built once at startup.
66    pub(crate) panel_entries: Vec<KeybindingEntry>,
67    /// The leading entries that came from configuration rather than defaults.
68    pub(crate) panel_user_entry_count: usize,
69    pub keybinding_panel: KeybindingPanelState,
70    /// Temporary view roots, from the original forest to the current subtree.
71    root_history: Vec<Option<NodeId>>,
72    /// Rows per screen; the UI updates this every frame.
73    pub page_height: usize,
74    /// Terminal default colors, when the terminal answered the startup query.
75    pub palette: Option<crate::ui::Palette>,
76}
77
78impl App {
79    pub fn new(tree: Tree, config: &Config, expand: Option<ExpandSpec>) -> Self {
80        let mut builtin_keymap = Self::default_keymap();
81        // `?` is reserved for the panel toggle: a configured `[?]` table is
82        // accepted but silently ignored.
83        let user_keymap: HashMap<_, _> = config
84            .bindings
85            .iter()
86            .filter(|&(&key, _)| key != TOGGLE_KEY)
87            .map(|(&key, binding)| (key, binding.clone()))
88            .collect();
89        for key in user_keymap.keys() {
90            builtin_keymap.remove(key);
91        }
92
93        let mut panel_entries = build_entries(&user_keymap);
94        let panel_user_entry_count = panel_entries.len();
95        panel_entries.extend(build_entries(&builtin_keymap));
96
97        let mut keymap = builtin_keymap;
98        keymap.extend(user_keymap);
99        let mut app = Self {
100            tree,
101            state: TreeListViewState::with_capacity(0),
102            query: TreeQuery::new(),
103            mode: Mode::Normal,
104            keymap,
105            panel_entries,
106            panel_user_entry_count,
107            keybinding_panel: KeybindingPanelState::default(),
108            root_history: Vec::new(),
109            page_height: 20,
110            palette: None,
111        };
112        match expand {
113            None => {}
114            Some(spec) => {
115                // Explicit expansion wants the whole tree present up front.
116                app.tree.index_all();
117                let branches: Vec<_> = app.tree.branches().collect();
118                for (id, parent) in branches {
119                    let expand = match spec {
120                        ExpandSpec::All => true,
121                        ExpandSpec::Depth(n) => app.tree.depth(id) < n,
122                    };
123                    if expand {
124                        app.state.set_expanded(id, parent, true);
125                    }
126                }
127            }
128        }
129        // A single-rooted tree opens with its first level showing.
130        if app.tree.root_ids().len() == 1 {
131            let root = app.tree.root_ids()[0];
132            app.tree.ensure_children(root);
133            if !app.tree.is_leaf(root) {
134                app.state
135                    .set_expanded(root, app.tree.view_parent(root), true);
136            }
137        }
138        app.state.ensure_projection(&app.tree, &app.query);
139        app.state.select_first();
140        app
141    }
142
143    /// The default keybindings, before user config is merged.
144    pub fn default_keymap() -> HashMap<Key, Binding> {
145        let cmd = |action: AppCommand| Binding {
146            action: BindingAction::Cmd(action),
147            help: None,
148            exit: false,
149            bg: false,
150        };
151        let mut map = HashMap::new();
152        for (keys, action) in [
153            (&["j", "down"][..], AppCommand::Down),
154            (&["k", "up"], AppCommand::Up),
155            (&["l", "right"], AppCommand::Expand),
156            (&["h", "left"], AppCommand::Collapse),
157            (&["L", "shift+right"], AppCommand::ExpandRecursively),
158            (&["H", "shift+left"], AppCommand::CollapseRecursively),
159            (&["space"], AppCommand::Toggle),
160            (&["ctrl+space"], AppCommand::ToggleRecursively),
161            (&["enter"], AppCommand::Select),
162            (&["ctrl+enter"], AppCommand::Accept),
163            (&["alt+enter"], AppCommand::AcceptAlternate),
164            (&["tab"], AppCommand::Root),
165            (&["shift+tab"], AppCommand::PopRoot),
166            (&["J"], AppCommand::NextSibling),
167            (&["K"], AppCommand::PrevSibling),
168            (&["ctrl+f"], AppCommand::PageDown),
169            (&["ctrl+b"], AppCommand::PageUp),
170            (&["ctrl+d"], AppCommand::HalfPageDown),
171            (&["ctrl+u"], AppCommand::HalfPageUp),
172            (&["z"], AppCommand::Center),
173            (&["g"], AppCommand::First),
174            (&["G"], AppCommand::Last),
175            (&["/"], AppCommand::Jump),
176            (&["o"], AppCommand::Open),
177            (&["?"], AppCommand::ToggleKeybindingPanel),
178            (&["esc"], AppCommand::Back),
179            (&["q", "ctrl+c"], AppCommand::Quit),
180        ] {
181            for key in keys {
182                map.insert(Key::parse(key).expect("valid default key"), cmd(action));
183            }
184        }
185        map
186    }
187
188    pub fn focused_id(&mut self) -> Option<NodeId> {
189        self.state.ensure_projection(&self.tree, &self.query);
190        self.state.selected_id()
191    }
192
193    /// Names of currently visible rows, in on-screen order.
194    pub fn visible_names(&mut self) -> Vec<String> {
195        self.state.ensure_projection(&self.tree, &self.query);
196        self.state
197            .visible_ids()
198            .map(|id| self.tree.name(id))
199            .collect()
200    }
201
202    /// Handle a normalized key through the active mode and effective keymap.
203    pub fn handle_key(&mut self, key: Key) -> Effect {
204        let _span = crate::profile::span("app::handle_key");
205        // Only cancellation means anything while the index builds for `/`.
206        if let Mode::Indexing = self.mode {
207            if key == Key::parse("esc").unwrap() || key == Key::parse("ctrl+c").unwrap() {
208                self.mode = Mode::Normal;
209            }
210            return Effect::None;
211        }
212        // A modal picker takes over key handling until it closes. Accepting
213        // moves focus (expanding ancestors); cancelling leaves focus untouched,
214        // so the user returns to exactly where they opened it.
215        if let Mode::Jump(jump) = &mut self.mode {
216            return match jump.handle_key(key) {
217                JumpOutcome::Stay => Effect::None,
218                JumpOutcome::Cancel => {
219                    self.mode = Mode::Normal;
220                    Effect::None
221                }
222                JumpOutcome::Accept(id) => {
223                    self.mode = Mode::Normal;
224                    self.state.select_by_id(&self.tree, &self.query, id);
225                    Effect::None
226                }
227            };
228        }
229
230        if self.keybinding_panel.is_open() && key == CLOSE_KEY {
231            self.keybinding_panel.close();
232            return Effect::None;
233        }
234
235        match self.keymap.get(&key).cloned() {
236            None => Effect::None,
237            Some(binding) => match binding.action {
238                BindingAction::Cmd(cmd) => self.run_command(cmd),
239                BindingAction::Sh(cmd) => match self.focused_id() {
240                    None => Effect::None,
241                    Some(id) => Effect::RunShell {
242                        cmd,
243                        path: self.tree.path(id),
244                        relpath: self.tree.relpath(id),
245                        bg: binding.bg,
246                        exit: binding.exit,
247                    },
248                },
249            },
250        }
251    }
252
253    /// Execute an app command.
254    pub fn run_command(&mut self, cmd: AppCommand) -> Effect {
255        self.state.ensure_projection(&self.tree, &self.query);
256        match cmd {
257            AppCommand::Down => {
258                self.state.select_next();
259            }
260            AppCommand::Up => {
261                self.state.select_prev();
262            }
263            AppCommand::Expand => {
264                if let Some(id) = self.focused_id() {
265                    self.tree.ensure_children(id);
266                    let parent = self.tree.view_parent(id);
267                    if self.tree.is_leaf(id) {
268                        // Nothing to open: step along to the next sibling.
269                        self.move_sibling(1);
270                    } else if self.state.node_is_expanded(id, parent) {
271                        self.state.select_id(Some(self.tree.children_of(id)[0]));
272                    } else {
273                        self.expand_and_reveal_children(id);
274                    }
275                }
276            }
277            AppCommand::Collapse => {
278                if let Some(id) = self.focused_id() {
279                    let parent = self.tree.view_parent(id);
280                    if !self.tree.is_leaf(id) && self.state.node_is_expanded(id, parent) {
281                        self.state.set_expanded(id, parent, false);
282                    } else if let Some(parent) = parent {
283                        self.state.select_id(Some(parent));
284                    }
285                }
286            }
287            AppCommand::ExpandRecursively => {
288                if let Some(id) = self.focused_id() {
289                    self.tree.ensure_children(id);
290                    if self.tree.is_leaf(id) {
291                        // Nothing to open: step along to the next sibling.
292                        self.move_sibling(1);
293                    } else {
294                        self.set_expanded_recursively(id, true);
295                    }
296                }
297            }
298            AppCommand::CollapseRecursively => {
299                if let Some(id) = self.focused_id() {
300                    let parent = self.tree.view_parent(id);
301                    if !self.tree.is_leaf(id) && self.state.node_is_expanded(id, parent) {
302                        self.set_expanded_recursively(id, false);
303                    } else if let Some(parent) = parent {
304                        // Nothing to collapse here: fold up the enclosing
305                        // container and land on it.
306                        self.set_expanded_recursively(parent, false);
307                        self.state.ensure_projection(&self.tree, &self.query);
308                        self.state.select_id(Some(parent));
309                    }
310                }
311            }
312            AppCommand::Toggle => self.toggle_focused(false),
313            AppCommand::ToggleRecursively => self.toggle_focused(true),
314            AppCommand::Select => {
315                if let Some(id) = self.focused_id() {
316                    self.tree.ensure_children(id);
317                    if self.tree.is_leaf(id) {
318                        return Effect::PrintAndExit(self.tree.output(id));
319                    }
320                    self.expand_and_reveal_children(id);
321                }
322            }
323            AppCommand::Accept => {
324                if let Some(id) = self.focused_id() {
325                    return Effect::PrintAndExit(self.tree.output(id));
326                }
327            }
328            AppCommand::AcceptAlternate => {
329                if let Some(id) = self.focused_id() {
330                    return Effect::PrintAndExit(self.tree.alternate_output(id));
331                }
332            }
333            AppCommand::Descend => {
334                if let Some(id) = self.focused_branch() {
335                    self.expand_and_reveal_children(id);
336                    let first_child = self.tree.children_of(id)[0];
337                    self.state.select_id(Some(first_child));
338                }
339            }
340            AppCommand::Root => self.push_root(),
341            AppCommand::PopRoot => {
342                self.pop_root();
343            }
344            AppCommand::Back => {
345                if !self.pop_root() {
346                    return Effect::Quit;
347                }
348            }
349            AppCommand::NextSibling => self.move_sibling(1),
350            AppCommand::PrevSibling => self.move_sibling(-1),
351            AppCommand::PageDown => self.move_focus_by(self.page_height as isize),
352            AppCommand::PageUp => self.move_focus_by(-(self.page_height as isize)),
353            AppCommand::HalfPageDown => self.move_focus_by((self.page_height / 2) as isize),
354            AppCommand::HalfPageUp => self.move_focus_by(-((self.page_height / 2) as isize)),
355            AppCommand::Center => self.center_focused(),
356            AppCommand::First => {
357                self.state.select_first();
358            }
359            AppCommand::Last => {
360                self.state.select_last();
361            }
362            AppCommand::Jump => {
363                // The picker only ever sees a complete index; block on the
364                // progress screen until the sweep catches up.
365                if self.tree.fully_indexed() {
366                    self.mode = Mode::Jump(Jump::open(&self.tree));
367                } else {
368                    self.mode = Mode::Indexing;
369                }
370            }
371            AppCommand::Open => {
372                // Containers expand rather than open, and a JSON Pointer is
373                // not a path the desktop knows how to follow.
374                if let Some(id) = self.focused_id()
375                    && self.tree.is_leaf(id)
376                    && let Some(path) = self.tree.filesystem_path(id)
377                {
378                    return Effect::Open(path);
379                }
380            }
381            AppCommand::ToggleKeybindingPanel => self.keybinding_panel.toggle(),
382            AppCommand::Quit => return Effect::Quit,
383        }
384        Effect::None
385    }
386
387    /// The focused node if it is expandable, its children materialized.
388    fn focused_branch(&mut self) -> Option<NodeId> {
389        let id = self.focused_id()?;
390        self.tree.ensure_children(id);
391        (!self.tree.is_leaf(id)).then_some(id)
392    }
393
394    /// One cooperative quantum of index building, run by the event loop
395    /// between input polls: load what is on screen first, then advance the
396    /// arena-order sweep. Returns true while more work remains. This is the
397    /// background indexer — cooperative rather than threaded, so the tree
398    /// stays single-threaded and lock-free; a worker thread could later slot
399    /// in behind this same seam.
400    pub fn do_work(&mut self) -> bool {
401        let _span = crate::profile::span("app::do_work");
402        if !self.tree.fully_indexed() {
403            self.state.ensure_projection(&self.tree, &self.query);
404            let visible: Vec<NodeId> = self.state.visible_ids().collect();
405            let mut budget = WORK_QUANTUM;
406            for id in visible {
407                if budget == 0 {
408                    break;
409                }
410                if self.tree.ensure_children(id) {
411                    budget -= 1;
412                }
413            }
414            self.tree.index_some(budget);
415        }
416        if matches!(self.mode, Mode::Indexing) && self.tree.fully_indexed() {
417            self.mode = Mode::Jump(Jump::open(&self.tree));
418        }
419        !self.tree.fully_indexed()
420    }
421
422    /// Record a non-fatal failure from an effect the event loop performed. It
423    /// joins the source errors in the banner and the exit report, so a missing
424    /// opener is reported rather than ending the session.
425    pub fn report_error(&mut self, message: String) {
426        self.tree.record_error(message);
427    }
428
429    /// Whether the event loop should keep scheduling work quanta.
430    pub fn has_work(&self) -> bool {
431        !self.tree.fully_indexed()
432    }
433
434    /// Flip the focused container's expansion, leaving focus on it. A leaf has
435    /// nothing to toggle. The direction comes from the focused node's own
436    /// state, so a recursive toggle on an open container shuts it rather than
437    /// opening what is still shut underneath.
438    fn toggle_focused(&mut self, recursive: bool) {
439        let Some(id) = self.focused_id() else { return };
440        self.tree.ensure_children(id);
441        if self.tree.is_leaf(id) {
442            return;
443        }
444        let parent = self.tree.view_parent(id);
445        let expand = !self.state.node_is_expanded(id, parent);
446        if recursive {
447            self.set_expanded_recursively(id, expand);
448        } else if expand {
449            self.expand_and_reveal_children(id);
450        } else {
451            self.state.set_expanded(id, parent, false);
452        }
453    }
454
455    fn set_expanded_recursively(&mut self, root: NodeId, expanded: bool) {
456        let mut stack = vec![root];
457        while let Some(id) = stack.pop() {
458            if expanded {
459                self.tree.ensure_children(id);
460            }
461            if !self.tree.is_leaf(id) {
462                self.state
463                    .set_expanded(id, self.tree.view_parent(id), expanded);
464                stack.extend_from_slice(self.tree.children_of(id));
465            }
466        }
467        if expanded {
468            self.reveal_expanded_children(root);
469        }
470    }
471
472    fn expand_and_reveal_children(&mut self, id: NodeId) {
473        self.state.set_expanded(id, self.tree.view_parent(id), true);
474        self.reveal_expanded_children(id);
475    }
476
477    /// If expansion pushes a direct child below the viewport, keep both ends
478    /// visible when they fit. Otherwise anchor the expanded container at the
479    /// top so the user can read into the oversized branch from its beginning.
480    fn reveal_expanded_children(&mut self, id: NodeId) {
481        let height = self.page_height;
482        if height == 0 {
483            return;
484        }
485        self.state.ensure_projection(&self.tree, &self.query);
486        let Some(container_index) = self.state.visible_index_of(id) else {
487            return;
488        };
489        let Some(last_child_index) = self
490            .tree
491            .children_of(id)
492            .last()
493            .and_then(|&child| self.state.visible_index_of(child))
494        else {
495            return;
496        };
497        if last_child_index < self.state.offset().saturating_add(height) {
498            return;
499        }
500
501        let branch_rows = last_child_index.saturating_sub(container_index) + 1;
502        let offset = if branch_rows <= height {
503            last_child_index + 1 - height
504        } else {
505            container_index
506        };
507        self.state.set_offset(offset);
508    }
509
510    fn push_root(&mut self) {
511        let Some(id) = self.focused_id() else {
512            return;
513        };
514        if self.tree.view_root() == Some(id) {
515            return;
516        }
517        self.tree.ensure_children(id);
518
519        self.root_history.push(self.tree.view_root());
520        self.tree.set_view_root(Some(id));
521        if !self.tree.is_leaf(id) {
522            self.state.set_expanded(id, None, true);
523        }
524        self.state.ensure_projection(&self.tree, &self.query);
525        self.state.select_id(Some(id));
526    }
527
528    fn pop_root(&mut self) -> bool {
529        let Some(previous_root) = self.root_history.pop() else {
530            return false;
531        };
532        let selected = self.state.selected_id();
533
534        if let Some(current_root) = self.tree.view_root() {
535            let expanded = self.state.node_is_expanded(current_root, None);
536            self.state
537                .set_expanded(current_root, self.tree.parent(current_root), expanded);
538        }
539
540        self.tree.set_view_root(previous_root);
541        self.state.ensure_projection(&self.tree, &self.query);
542        if !selected.is_some_and(|id| self.state.select_by_id(&self.tree, &self.query, id)) {
543            self.state.select_first();
544        }
545        true
546    }
547
548    fn move_sibling(&mut self, delta: isize) {
549        let Some(id) = self.focused_id() else { return };
550        if self.tree.view_root() == Some(id) {
551            return;
552        }
553        let siblings = match self.tree.parent(id) {
554            Some(parent) => self.tree.children_of(parent),
555            None => self.tree.root_ids(),
556        };
557        let pos = siblings.iter().position(|&s| s == id).unwrap_or(0) as isize;
558        let target = pos + delta;
559        if (0..siblings.len() as isize).contains(&target) {
560            let target = siblings[target as usize];
561            self.state.select_id(Some(target));
562        }
563    }
564
565    fn move_focus_by(&mut self, delta: isize) {
566        let len = self.state.visible_len();
567        if len == 0 {
568            return;
569        }
570        let current = self.state.selected_index().unwrap_or(0) as isize;
571        let target = (current + delta).clamp(0, len as isize - 1);
572        self.state.select_index(Some(target as usize));
573    }
574
575    fn center_focused(&mut self) {
576        if self.page_height == 0 {
577            return;
578        }
579        let Some(selected) = self.state.selected_index() else {
580            return;
581        };
582        let maximum = self.state.visible_len().saturating_sub(self.page_height);
583        let centered = selected.saturating_sub(self.page_height / 2);
584        self.state.set_offset(centered.min(maximum));
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use crate::fstree;
592
593    /// Builds:
594    ///   root/
595    ///     a/
596    ///       aa/
597    ///         aaa.txt
598    ///       ab.txt
599    ///     b/
600    ///       ba.txt
601    ///     c.txt
602    fn fixture() -> (tempfile::TempDir, Tree) {
603        let dir = tempfile::tempdir().unwrap();
604        let p = dir.path();
605        std::fs::create_dir_all(p.join("a/aa")).unwrap();
606        std::fs::write(p.join("a/aa/aaa.txt"), "").unwrap();
607        std::fs::write(p.join("a/ab.txt"), "").unwrap();
608        std::fs::create_dir(p.join("b")).unwrap();
609        std::fs::write(p.join("b/ba.txt"), "").unwrap();
610        std::fs::write(p.join("c.txt"), "").unwrap();
611        let tree = fstree::scan(p, false).unwrap();
612        (dir, tree)
613    }
614
615    fn app() -> (tempfile::TempDir, App) {
616        let (dir, tree) = fixture();
617        (dir, App::new(tree, &Config::default(), None))
618    }
619
620    /// The main fixture has no leaf with a following sibling. Builds:
621    ///   root/
622    ///     d/
623    ///       d1.txt
624    ///       d2.txt
625    fn app_with_leaf_siblings() -> (tempfile::TempDir, App) {
626        let dir = tempfile::tempdir().unwrap();
627        let p = dir.path();
628        std::fs::create_dir(p.join("d")).unwrap();
629        std::fs::write(p.join("d/d1.txt"), "").unwrap();
630        std::fs::write(p.join("d/d2.txt"), "").unwrap();
631        let tree = fstree::scan(p, false).unwrap();
632        (dir, App::new(tree, &Config::default(), None))
633    }
634
635    /// Builds a branch below two leaves so expansion can overflow a viewport:
636    ///   a/
637    ///   b/
638    ///   d/
639    ///     d1.txt
640    ///     d2.txt
641    fn app_with_branch_at_bottom() -> (tempfile::TempDir, App) {
642        let dir = tempfile::tempdir().unwrap();
643        let p = dir.path();
644        std::fs::create_dir(p.join("a")).unwrap();
645        std::fs::create_dir(p.join("b")).unwrap();
646        std::fs::create_dir(p.join("d")).unwrap();
647        std::fs::write(p.join("d/d1.txt"), "").unwrap();
648        std::fs::write(p.join("d/d2.txt"), "").unwrap();
649        let tree = fstree::scan(p, false).unwrap();
650        (dir, App::new(tree, &Config::default(), None))
651    }
652
653    fn focused_name(app: &mut App) -> String {
654        let id = app.focused_id().expect("something focused");
655        app.tree.name(id)
656    }
657
658    #[test]
659    fn starts_focused_on_first_row_all_collapsed() {
660        let (_d, mut app) = app();
661        assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
662        assert_eq!(focused_name(&mut app), "a");
663    }
664
665    #[test]
666    fn down_and_up_move_focus_clamped() {
667        let (_d, mut app) = app();
668        app.run_command(AppCommand::Down);
669        assert_eq!(focused_name(&mut app), "b");
670        app.run_command(AppCommand::Down);
671        assert_eq!(focused_name(&mut app), "c.txt");
672        app.run_command(AppCommand::Down);
673        assert_eq!(focused_name(&mut app), "c.txt");
674        app.run_command(AppCommand::Up);
675        assert_eq!(focused_name(&mut app), "b");
676    }
677
678    #[test]
679    fn expand_reveals_children_and_down_enters_them() {
680        let (_d, mut app) = app();
681        app.run_command(AppCommand::Expand);
682        assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
683        app.run_command(AppCommand::Down);
684        assert_eq!(focused_name(&mut app), "aa");
685    }
686
687    #[test]
688    fn expanding_scrolls_just_enough_to_reveal_last_child_when_branch_fits() {
689        let (_d, mut app) = app_with_branch_at_bottom();
690        app.page_height = 4;
691        app.run_command(AppCommand::Down);
692        app.run_command(AppCommand::Down); // focus "d" on the viewport's last row
693
694        app.run_command(AppCommand::Expand);
695
696        assert_eq!(app.state.offset(), 1);
697    }
698
699    #[test]
700    fn expanding_an_oversized_branch_places_container_at_viewport_top() {
701        let (_d, mut app) = app_with_branch_at_bottom();
702        app.page_height = 2;
703        app.run_command(AppCommand::Down);
704        app.run_command(AppCommand::Down); // focus "d" on the viewport's last row
705
706        app.run_command(AppCommand::Expand);
707
708        assert_eq!(app.state.offset(), 2);
709    }
710
711    #[test]
712    fn l_on_expanded_branch_descends_to_first_child() {
713        let (_d, mut app) = app();
714        app.handle_key(Key::parse("l").unwrap());
715
716        app.handle_key(Key::parse("l").unwrap());
717
718        assert_eq!(focused_name(&mut app), "aa");
719    }
720
721    #[test]
722    fn l_on_leaf_focuses_next_sibling() {
723        let (_d, mut app) = app_with_leaf_siblings();
724        app.run_command(AppCommand::Expand); // expand "d"
725        app.run_command(AppCommand::Down); // focus "d1.txt"
726
727        app.handle_key(Key::parse("l").unwrap());
728
729        assert_eq!(focused_name(&mut app), "d2.txt");
730        assert_eq!(app.visible_names(), ["d", "d1.txt", "d2.txt"]);
731    }
732
733    #[test]
734    fn l_on_last_leaf_of_a_container_stays_inside_it() {
735        let (_d, mut app) = app();
736        app.run_command(AppCommand::Expand); // expand "a"
737        app.run_command(AppCommand::Down); // focus "aa"
738        app.run_command(AppCommand::Down); // focus "ab.txt", last child of "a"
739
740        app.handle_key(Key::parse("l").unwrap());
741
742        // Does not spill over to "b": siblings, not the next visible row.
743        assert_eq!(focused_name(&mut app), "ab.txt");
744        assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
745    }
746
747    #[test]
748    fn expand_on_the_last_top_level_leaf_is_a_noop() {
749        let (_d, mut app) = app();
750        app.run_command(AppCommand::Last);
751        assert_eq!(focused_name(&mut app), "c.txt");
752        assert_eq!(app.run_command(AppCommand::Expand), Effect::None);
753        assert_eq!(focused_name(&mut app), "c.txt");
754        assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
755    }
756
757    #[test]
758    fn collapse_hides_children() {
759        let (_d, mut app) = app();
760        app.run_command(AppCommand::Expand);
761        app.run_command(AppCommand::Collapse);
762        assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
763    }
764
765    #[test]
766    fn h_on_leaf_focuses_parent_without_collapsing_it() {
767        let (_d, mut app) = app();
768        app.run_command(AppCommand::Expand);
769        app.run_command(AppCommand::Down); // focus collapsed "aa"
770        app.run_command(AppCommand::Expand);
771        app.run_command(AppCommand::Down); // focus "aaa.txt"
772
773        app.handle_key(Key::parse("h").unwrap());
774
775        assert_eq!(focused_name(&mut app), "aa");
776        assert_eq!(
777            app.visible_names(),
778            ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
779        );
780    }
781
782    #[test]
783    fn h_on_collapsed_branch_focuses_parent_without_collapsing_it() {
784        let (_d, mut app) = app();
785        app.run_command(AppCommand::Expand);
786        app.run_command(AppCommand::Down); // focus collapsed "aa"
787
788        app.handle_key(Key::parse("h").unwrap());
789
790        assert_eq!(focused_name(&mut app), "a");
791        assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
792    }
793
794    #[test]
795    fn expand_recursively_expands_whole_subtree() {
796        let (_d, mut app) = app();
797        app.run_command(AppCommand::ExpandRecursively);
798        assert_eq!(
799            app.visible_names(),
800            ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
801        );
802    }
803
804    #[test]
805    fn space_toggles_a_container_open_and_shut() {
806        let (_d, mut app) = app();
807
808        app.handle_key(Key::parse("space").unwrap());
809        assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
810        assert_eq!(focused_name(&mut app), "a");
811
812        app.handle_key(Key::parse("space").unwrap());
813        assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
814        assert_eq!(focused_name(&mut app), "a");
815    }
816
817    #[test]
818    fn space_on_a_leaf_does_nothing() {
819        let (_d, mut app) = app();
820        app.run_command(AppCommand::Last); // focus "c.txt"
821
822        app.handle_key(Key::parse("space").unwrap());
823
824        assert_eq!(focused_name(&mut app), "c.txt");
825        assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
826    }
827
828    #[test]
829    fn ctrl_space_toggles_a_container_recursively() {
830        let (_d, mut app) = app();
831
832        app.handle_key(Key::parse("ctrl+space").unwrap());
833        assert_eq!(
834            app.visible_names(),
835            ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
836        );
837        assert_eq!(focused_name(&mut app), "a");
838
839        app.handle_key(Key::parse("ctrl+space").unwrap());
840        assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
841        // Descendant expansion was cleared, not just hidden.
842        app.run_command(AppCommand::Expand);
843        assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
844    }
845
846    #[test]
847    fn ctrl_space_direction_follows_the_focused_container() {
848        let (_d, mut app) = app();
849        app.run_command(AppCommand::Expand); // "a" expanded, "aa" still shut
850
851        // "a" is open, so the recursive toggle shuts it rather than reopening.
852        app.handle_key(Key::parse("ctrl+space").unwrap());
853
854        assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
855    }
856
857    #[test]
858    fn shift_l_on_a_container_leaves_focus_on_it() {
859        let (_d, mut app) = app();
860
861        app.handle_key(Key::parse("L").unwrap());
862
863        assert_eq!(focused_name(&mut app), "a");
864    }
865
866    #[test]
867    fn shift_l_on_leaf_focuses_next_sibling() {
868        let (_d, mut app) = app_with_leaf_siblings();
869        app.run_command(AppCommand::Expand); // expand "d"
870        app.run_command(AppCommand::Down); // focus "d1.txt"
871
872        app.handle_key(Key::parse("L").unwrap());
873
874        assert_eq!(focused_name(&mut app), "d2.txt");
875        assert_eq!(app.visible_names(), ["d", "d1.txt", "d2.txt"]);
876    }
877
878    #[test]
879    fn shift_l_on_the_last_top_level_leaf_is_a_noop() {
880        let (_d, mut app) = app();
881        app.run_command(AppCommand::Last); // focus "c.txt"
882
883        app.handle_key(Key::parse("L").unwrap());
884
885        assert_eq!(focused_name(&mut app), "c.txt");
886        assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
887    }
888
889    #[test]
890    fn collapse_recursively_collapses_whole_subtree() {
891        let (_d, mut app) = app();
892        app.run_command(AppCommand::ExpandRecursively);
893        app.run_command(AppCommand::CollapseRecursively);
894        assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
895        // Descendant expansion was cleared, not just hidden.
896        app.run_command(AppCommand::Expand);
897        assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
898    }
899
900    #[test]
901    fn shift_h_on_leaf_collapses_parent_and_focuses_it() {
902        let (_d, mut app) = app();
903        app.run_command(AppCommand::ExpandRecursively);
904        app.run_command(AppCommand::Down); // focus expanded "aa"
905        app.run_command(AppCommand::Down); // focus "aaa.txt"
906
907        app.handle_key(Key::parse("H").unwrap());
908
909        assert_eq!(focused_name(&mut app), "aa");
910        assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
911    }
912
913    #[test]
914    fn shift_h_on_collapsed_branch_collapses_parent_and_focuses_it() {
915        let (_d, mut app) = app();
916        app.run_command(AppCommand::Expand);
917        app.run_command(AppCommand::Down); // focus collapsed "aa"
918
919        app.handle_key(Key::parse("H").unwrap());
920
921        assert_eq!(focused_name(&mut app), "a");
922        assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
923    }
924
925    #[test]
926    fn shift_h_collapses_the_parent_recursively() {
927        let (_d, mut app) = app();
928        app.run_command(AppCommand::ExpandRecursively);
929        app.run_command(AppCommand::Down); // focus expanded "aa"
930        app.run_command(AppCommand::Down); // focus "aaa.txt"
931        app.run_command(AppCommand::Down); // focus "ab.txt"
932
933        app.handle_key(Key::parse("H").unwrap());
934
935        assert_eq!(focused_name(&mut app), "a");
936        assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
937        // "aa" was collapsed along with "a", not just hidden by it.
938        app.run_command(AppCommand::Expand);
939        assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
940    }
941
942    #[test]
943    fn shift_h_on_a_top_level_leaf_leaves_focus_alone() {
944        let (_d, mut app) = app();
945        app.run_command(AppCommand::Last); // focus top-level leaf "c.txt"
946
947        app.handle_key(Key::parse("H").unwrap());
948
949        assert_eq!(focused_name(&mut app), "c.txt");
950        assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
951    }
952
953    #[test]
954    fn select_expands_collapsed_dir_and_prints_leaf() {
955        let (_d, mut app) = app();
956        assert_eq!(app.run_command(AppCommand::Select), Effect::None);
957        assert_eq!(app.visible_names(), ["a", "aa", "ab.txt", "b", "c.txt"]);
958        app.run_command(AppCommand::Last);
959        let effect = app.run_command(AppCommand::Select);
960        let Effect::PrintAndExit(path) = effect else {
961            panic!("expected PrintAndExit, got {effect:?}");
962        };
963        assert!(std::path::Path::new(&path).is_absolute());
964        assert!(std::path::Path::new(&path).ends_with("c.txt"));
965    }
966
967    #[test]
968    fn select_does_not_descend_into_an_expanded_branch() {
969        let (_d, mut app) = app();
970        app.run_command(AppCommand::Select);
971
972        app.run_command(AppCommand::Select);
973
974        assert_eq!(focused_name(&mut app), "a");
975    }
976
977    #[test]
978    fn accept_prints_even_on_dir() {
979        let (_d, mut app) = app();
980        let effect = app.run_command(AppCommand::Accept);
981        let Effect::PrintAndExit(path) = effect else {
982            panic!("expected PrintAndExit, got {effect:?}");
983        };
984        assert!(std::path::Path::new(&path).ends_with("a"));
985    }
986
987    #[test]
988    fn alt_enter_prints_the_filesystem_basename() {
989        let (_d, mut app) = app();
990
991        assert_eq!(
992            app.handle_key(Key::parse("alt+enter").unwrap()),
993            Effect::PrintAndExit(OsString::from("a"))
994        );
995    }
996
997    #[test]
998    fn o_opens_the_focused_leaf_with_its_absolute_path() {
999        let (_d, mut app) = app();
1000        app.run_command(AppCommand::Last); // focus leaf "c.txt"
1001
1002        let effect = app.handle_key(Key::parse("o").unwrap());
1003
1004        let Effect::Open(path) = effect else {
1005            panic!("expected Open, got {effect:?}");
1006        };
1007        assert!(std::path::Path::new(&path).is_absolute());
1008        assert!(std::path::Path::new(&path).ends_with("c.txt"));
1009    }
1010
1011    #[test]
1012    fn o_on_a_container_does_nothing() {
1013        let (_d, mut app) = app();
1014        assert_eq!(focused_name(&mut app), "a"); // a directory
1015
1016        assert_eq!(app.handle_key(Key::parse("o").unwrap()), Effect::None);
1017    }
1018
1019    #[test]
1020    fn o_on_a_json_leaf_does_nothing() {
1021        // A JSON Pointer is not something the desktop can open.
1022        let tree = crate::json_tree::from_reader(r#"{"a": 1}"#.as_bytes()).unwrap();
1023        let mut app = App::new(tree, &Config::default(), None);
1024        app.run_command(AppCommand::Last);
1025
1026        assert_eq!(app.handle_key(Key::parse("o").unwrap()), Effect::None);
1027    }
1028
1029    #[test]
1030    fn descend_expands_and_focuses_first_child() {
1031        let (_d, mut app) = app();
1032        app.run_command(AppCommand::Descend);
1033        assert_eq!(focused_name(&mut app), "aa");
1034    }
1035
1036    #[test]
1037    fn tab_pushes_focused_nodes_as_view_roots_and_shift_tab_pops_them() {
1038        let (_d, mut app) = app();
1039
1040        app.handle_key(Key::parse("tab").unwrap());
1041        assert_eq!(focused_name(&mut app), "a");
1042        assert_eq!(app.visible_names(), ["a", "aa", "ab.txt"]);
1043
1044        app.handle_key(Key::parse("l").unwrap()); // focus "aa"
1045        app.handle_key(Key::parse("tab").unwrap());
1046        assert_eq!(focused_name(&mut app), "aa");
1047        assert_eq!(app.visible_names(), ["aa", "aaa.txt"]);
1048
1049        // The temporary root is a navigation boundary.
1050        app.handle_key(Key::parse("h").unwrap()); // collapse root "aa"
1051        assert_eq!(app.visible_names(), ["aa"]);
1052        app.handle_key(Key::parse("h").unwrap()); // cannot focus parent "a"
1053        assert_eq!(focused_name(&mut app), "aa");
1054
1055        app.handle_key(Key::parse("l").unwrap()); // expand root "aa"
1056        assert_eq!(app.visible_names(), ["aa", "aaa.txt"]);
1057
1058        app.handle_key(Key::parse("shift+tab").unwrap());
1059        assert_eq!(focused_name(&mut app), "aa");
1060        assert_eq!(app.visible_names(), ["a", "aa", "aaa.txt", "ab.txt"]);
1061
1062        app.handle_key(Key::parse("shift+tab").unwrap());
1063        assert_eq!(focused_name(&mut app), "aa");
1064        assert_eq!(
1065            app.visible_names(),
1066            ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
1067        );
1068
1069        // There is no root before the original forest.
1070        app.handle_key(Key::parse("shift+tab").unwrap());
1071        assert_eq!(focused_name(&mut app), "aa");
1072        assert_eq!(
1073            app.visible_names(),
1074            ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
1075        );
1076    }
1077
1078    #[test]
1079    fn tab_can_make_a_leaf_the_view_root() {
1080        let (_d, mut app) = app();
1081        app.run_command(AppCommand::Last);
1082
1083        app.handle_key(Key::parse("tab").unwrap());
1084
1085        assert_eq!(focused_name(&mut app), "c.txt");
1086        assert_eq!(app.visible_names(), ["c.txt"]);
1087    }
1088
1089    #[test]
1090    fn escape_pops_one_root_at_a_time_then_quits() {
1091        let (_d, mut app) = app();
1092        app.handle_key(Key::parse("tab").unwrap()); // root "a"
1093        app.handle_key(Key::parse("l").unwrap()); // focus "aa"
1094        app.handle_key(Key::parse("tab").unwrap()); // root "aa"
1095
1096        assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::None);
1097        assert_eq!(focused_name(&mut app), "aa");
1098        assert_eq!(app.visible_names(), ["a", "aa", "aaa.txt", "ab.txt"]);
1099
1100        assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::None);
1101        assert_eq!(focused_name(&mut app), "aa");
1102        assert_eq!(
1103            app.visible_names(),
1104            ["a", "aa", "aaa.txt", "ab.txt", "b", "c.txt"]
1105        );
1106
1107        assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::Quit);
1108    }
1109
1110    #[test]
1111    fn sibling_navigation_skips_expanded_children() {
1112        let (_d, mut app) = app();
1113        app.run_command(AppCommand::Expand); // "a" expanded, children visible
1114        app.run_command(AppCommand::NextSibling);
1115        assert_eq!(focused_name(&mut app), "b");
1116        app.run_command(AppCommand::PrevSibling);
1117        assert_eq!(focused_name(&mut app), "a");
1118        // No previous sibling: no-op.
1119        app.run_command(AppCommand::PrevSibling);
1120        assert_eq!(focused_name(&mut app), "a");
1121    }
1122
1123    #[test]
1124    fn first_and_last() {
1125        let (_d, mut app) = app();
1126        app.run_command(AppCommand::Last);
1127        assert_eq!(focused_name(&mut app), "c.txt");
1128        app.run_command(AppCommand::First);
1129        assert_eq!(focused_name(&mut app), "a");
1130    }
1131
1132    #[test]
1133    fn paging_moves_focus_by_page_amounts() {
1134        let (_d, mut app) = app();
1135        app.run_command(AppCommand::ExpandRecursively); // 6 visible rows
1136        app.page_height = 4;
1137        app.run_command(AppCommand::HalfPageDown);
1138        assert_eq!(focused_name(&mut app), "aaa.txt"); // moved 2
1139        app.run_command(AppCommand::PageDown);
1140        assert_eq!(focused_name(&mut app), "c.txt"); // clamped at end
1141        app.run_command(AppCommand::HalfPageUp);
1142        assert_eq!(focused_name(&mut app), "ab.txt");
1143        app.run_command(AppCommand::PageUp);
1144        assert_eq!(focused_name(&mut app), "a");
1145    }
1146
1147    #[test]
1148    fn z_centers_the_focused_node_in_the_viewport() {
1149        let (_d, mut app) = app();
1150        app.run_command(AppCommand::ExpandRecursively); // 6 visible rows
1151        app.page_height = 3;
1152        for _ in 0..3 {
1153            app.run_command(AppCommand::Down); // focus row 3
1154        }
1155        app.state.set_offset(0);
1156
1157        app.handle_key(Key::parse("z").unwrap());
1158
1159        assert_eq!(focused_name(&mut app), "ab.txt");
1160        assert_eq!(app.state.offset(), 2);
1161    }
1162
1163    #[test]
1164    fn default_keys_drive_commands() {
1165        let (_d, mut app) = app();
1166        app.handle_key(Key::parse("j").unwrap());
1167        assert_eq!(focused_name(&mut app), "b");
1168        app.handle_key(Key::parse("k").unwrap());
1169        assert_eq!(focused_name(&mut app), "a");
1170        app.handle_key(Key::parse("l").unwrap());
1171        assert_eq!(app.visible_names().len(), 5);
1172        app.handle_key(Key::parse("h").unwrap());
1173        assert_eq!(app.visible_names().len(), 3);
1174        assert_eq!(app.handle_key(Key::parse("q").unwrap()), Effect::Quit);
1175        assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::Quit);
1176        assert_eq!(app.handle_key(Key::parse("ctrl+c").unwrap()), Effect::Quit);
1177    }
1178
1179    #[test]
1180    fn g_goes_to_first_line_without_a_chord() {
1181        let (_d, mut app) = app();
1182        app.run_command(AppCommand::Last);
1183        assert_eq!(app.handle_key(Key::parse("g").unwrap()), Effect::None);
1184        assert_eq!(focused_name(&mut app), "a");
1185    }
1186
1187    #[test]
1188    fn shift_g_goes_to_last_visible_line() {
1189        let (_d, mut app) = app();
1190        app.handle_key(Key::parse("G").unwrap());
1191        assert_eq!(focused_name(&mut app), "c.txt");
1192    }
1193
1194    #[test]
1195    fn user_binding_produces_shell_effect_with_paths() {
1196        let (_d, tree) = fixture();
1197        let config = Config::parse("[ctrl+e]\nsh = \"vim $path\"\nexit = true\n").unwrap();
1198        let mut app = App::new(tree, &config, None);
1199        app.run_command(AppCommand::Down); // focus "b"
1200        let effect = app.handle_key(Key::parse("ctrl+e").unwrap());
1201        let Effect::RunShell {
1202            cmd,
1203            path,
1204            relpath,
1205            bg,
1206            exit,
1207        } = effect
1208        else {
1209            panic!("expected RunShell, got {effect:?}");
1210        };
1211        assert_eq!(cmd, "vim $path");
1212        assert!(std::path::Path::new(&path).is_absolute());
1213        assert!(std::path::Path::new(&path).ends_with("b"));
1214        assert_eq!(relpath, OsString::from("b"));
1215        assert!(!bg);
1216        assert!(exit);
1217    }
1218
1219    #[test]
1220    fn user_binding_overrides_default() {
1221        let (_d, tree) = fixture();
1222        let config = Config::parse("[j]\ncmd = \"quit\"\n").unwrap();
1223        let mut app = App::new(tree, &config, None);
1224        assert_eq!(app.handle_key(Key::parse("j").unwrap()), Effect::Quit);
1225    }
1226
1227    #[test]
1228    fn user_can_override_the_new_g_binding() {
1229        let (_d, tree) = fixture();
1230        let config = Config::parse("[g]\ncmd = \"quit\"\n").unwrap();
1231        let mut app = App::new(tree, &config, None);
1232        assert_eq!(app.handle_key(Key::parse("g").unwrap()), Effect::Quit);
1233    }
1234
1235    #[test]
1236    fn question_mark_is_reserved_and_toggles_the_panel() {
1237        let (_d, tree) = fixture();
1238        let config = Config::parse("[?]\ncmd = \"quit\"\nhelp = \"Wrong\"\n").unwrap();
1239        let mut app = App::new(tree, &config, None);
1240
1241        // The `[?]` table was dropped: the panel lists the reserved binding,
1242        // not the configured one.
1243        let question: Vec<_> = app
1244            .panel_entries
1245            .iter()
1246            .filter(|entry| entry.key == Key::parse("?").unwrap())
1247            .collect();
1248        assert_eq!(question.len(), 1);
1249        assert_eq!(question[0].description, "Shortcuts");
1250
1251        assert!(!app.keybinding_panel.is_open());
1252        assert_eq!(app.handle_key(Key::parse("?").unwrap()), Effect::None);
1253        assert!(app.keybinding_panel.is_open());
1254        assert_eq!(app.handle_key(Key::parse("?").unwrap()), Effect::None);
1255        assert!(!app.keybinding_panel.is_open());
1256    }
1257
1258    #[test]
1259    fn open_panel_stays_open_while_bindings_run_and_escape_only_closes_it() {
1260        let (_d, mut app) = app();
1261        app.handle_key(Key::parse("?").unwrap());
1262
1263        app.handle_key(Key::parse("j").unwrap());
1264        assert_eq!(focused_name(&mut app), "b");
1265        assert!(app.keybinding_panel.is_open());
1266
1267        assert_eq!(app.handle_key(Key::parse("esc").unwrap()), Effect::None);
1268        assert!(!app.keybinding_panel.is_open());
1269        assert_eq!(focused_name(&mut app), "b");
1270    }
1271
1272    #[test]
1273    fn jump_owns_question_mark_and_escape_without_dismissing_the_panel() {
1274        let (_d, mut app) = app();
1275        while app.do_work() {}
1276        app.handle_key(Key::parse("?").unwrap());
1277        app.handle_key(Key::parse("/").unwrap());
1278        app.handle_key(Key::parse("?").unwrap());
1279
1280        let Mode::Jump(jump) = &app.mode else {
1281            panic!("expected jump mode");
1282        };
1283        assert_eq!(jump.query(), "?");
1284        assert!(app.keybinding_panel.is_open());
1285
1286        app.handle_key(Key::parse("esc").unwrap());
1287        assert!(matches!(app.mode, Mode::Normal));
1288        assert!(app.keybinding_panel.is_open());
1289    }
1290
1291    #[test]
1292    fn unbound_key_is_noop() {
1293        let (_d, mut app) = app();
1294        assert_eq!(app.handle_key(Key::parse("x").unwrap()), Effect::None);
1295    }
1296
1297    #[test]
1298    fn initial_expand_depth_one_expands_top_level_only() {
1299        let (_d, tree) = fixture();
1300        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::Depth(1)));
1301        assert_eq!(
1302            app.visible_names(),
1303            ["a", "aa", "ab.txt", "b", "ba.txt", "c.txt"]
1304        );
1305    }
1306
1307    #[test]
1308    fn initial_expand_all_expands_everything() {
1309        let (_d, tree) = fixture();
1310        let mut app = App::new(tree, &Config::default(), Some(ExpandSpec::All));
1311        assert_eq!(
1312            app.visible_names(),
1313            ["a", "aa", "aaa.txt", "ab.txt", "b", "ba.txt", "c.txt"]
1314        );
1315    }
1316
1317    fn in_jump(app: &App) -> bool {
1318        matches!(app.mode, Mode::Jump(_))
1319    }
1320
1321    #[test]
1322    fn slash_opens_the_jump_picker() {
1323        let (_d, mut app) = app();
1324        assert!(!in_jump(&app));
1325        while app.do_work() {} // the picker waits for a complete index
1326        app.handle_key(Key::parse("/").unwrap());
1327        assert!(in_jump(&app));
1328    }
1329
1330    #[test]
1331    fn cancelling_the_picker_leaves_focus_untouched() {
1332        let (_d, mut app) = app();
1333        app.run_command(AppCommand::Down); // focus "b"
1334        assert_eq!(focused_name(&mut app), "b");
1335        app.handle_key(Key::parse("/").unwrap());
1336        app.handle_key(Key::parse("a").unwrap()); // type into the query
1337        app.handle_key(Key::parse("esc").unwrap());
1338        assert!(!in_jump(&app));
1339        assert_eq!(focused_name(&mut app), "b");
1340    }
1341
1342    #[test]
1343    fn accepting_jumps_focus_and_expands_ancestors() {
1344        let (_d, mut app) = app();
1345        // Everything starts collapsed: only the top level is visible.
1346        assert_eq!(app.visible_names(), ["a", "b", "c.txt"]);
1347        while app.do_work() {} // the picker waits for a complete index
1348        app.handle_key(Key::parse("/").unwrap());
1349        for k in ["a", "a", "a"] {
1350            app.handle_key(Key::parse(k).unwrap()); // query "aaa" -> a/aa/aaa.txt
1351        }
1352        app.handle_key(Key::parse("enter").unwrap());
1353        assert!(!in_jump(&app));
1354        assert_eq!(focused_name(&mut app), "aaa.txt");
1355        // The path to it was expanded so the focused node is visible.
1356        assert!(app.visible_names().contains(&"aaa.txt".to_string()));
1357    }
1358
1359    #[test]
1360    fn a_user_can_rebind_jump_off_slash() {
1361        let (_d, tree) = fixture();
1362        let config = Config::parse("[ctrl+p]\ncmd = \"jump\"\n").unwrap();
1363        let mut app = App::new(tree, &config, None);
1364        while app.do_work() {} // the picker waits for a complete index
1365        app.handle_key(Key::parse("ctrl+p").unwrap());
1366        assert!(in_jump(&app));
1367    }
1368
1369    #[test]
1370    fn a_single_rooted_tree_opens_with_its_first_level_expanded() {
1371        use crate::tree::ActionValues;
1372        let mut tree = Tree::new();
1373        let root = tree.push(None, "root", true, ActionValues::new("", "", ""));
1374        tree.push(Some(root), "child", false, ActionValues::new("", "", ""));
1375        let mut app = App::new(tree, &Config::default(), None);
1376
1377        assert_eq!(app.visible_names(), ["root", "child"]);
1378        assert_eq!(focused_name(&mut app), "root");
1379    }
1380
1381    #[test]
1382    fn expanding_an_unindexed_container_materializes_its_children() {
1383        let tree =
1384            crate::json_tree::from_reader(r#"{"users": [1, 2], "n": 3}"#.as_bytes()).unwrap();
1385        let mut app = App::new(tree, &Config::default(), None);
1386        assert_eq!(app.visible_names(), ["users [2]", "n: 3"]);
1387
1388        app.handle_key(Key::parse("l").unwrap());
1389
1390        assert_eq!(
1391            app.visible_names(),
1392            ["users [2]", "[0]: 1", "[1]: 2", "n: 3"]
1393        );
1394    }
1395
1396    #[test]
1397    fn jump_blocks_on_an_incomplete_index_and_opens_when_done() {
1398        let tree =
1399            crate::json_tree::from_reader(r#"{"a": {"b": 1}, "c": {"d": 2}}"#.as_bytes()).unwrap();
1400        let mut app = App::new(tree, &Config::default(), None);
1401        assert!(app.has_work());
1402
1403        app.handle_key(Key::parse("/").unwrap());
1404        assert!(matches!(app.mode, Mode::Indexing));
1405        // Keys other than cancel are ignored while the index builds.
1406        assert_eq!(app.handle_key(Key::parse("j").unwrap()), Effect::None);
1407        assert!(matches!(app.mode, Mode::Indexing));
1408
1409        while app.do_work() {}
1410
1411        assert!(matches!(app.mode, Mode::Jump(_)));
1412        assert!(!app.has_work());
1413    }
1414
1415    #[test]
1416    fn escape_cancels_the_indexing_wait() {
1417        let tree =
1418            crate::json_tree::from_reader(r#"{"a": {"b": 1}, "c": {"d": 2}}"#.as_bytes()).unwrap();
1419        let mut app = App::new(tree, &Config::default(), None);
1420        app.handle_key(Key::parse("/").unwrap());
1421        assert!(matches!(app.mode, Mode::Indexing));
1422
1423        app.handle_key(Key::parse("esc").unwrap());
1424
1425        assert!(matches!(app.mode, Mode::Normal));
1426    }
1427}