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