Skip to main content

haz_cli/commands/init/
tui.rs

1//! Interactive scaffold TUI for `haz init` per
2//! `AUX-003`..`AUX-005`.
3//!
4//! Two layers live here:
5//!
6//! - [`dispatch`] is a pure function from a crossterm event and a
7//!   [`TuiState`] to a [`DispatchResult`]. It mutates the state
8//!   in-place and performs no IO; the side effects the driver
9//!   needs to perform (loading children from disk, leaving the
10//!   tree screen to commit) are returned as enum variants. The
11//!   purity makes every key binding exhaustively unit-testable
12//!   without a terminal.
13//! - [`run_interactive_scaffold`] is the IO-driven loop that owns
14//!   the terminal: it sets up raw mode + the alternate screen,
15//!   draws each frame, reads events, calls [`dispatch`], and
16//!   performs the IO actions the dispatcher requests.
17//!
18//! The screen layout is a single navigable tree with a synthetic
19//! `[ Commit selection ]` button just below it; pressing `Enter`
20//! from any focus position commits, while arrow / space cycle
21//! state for tree rows.
22//!
23//! Hidden-directory handling: by default the tree omits
24//! dotfile-prefixed entries (`.git`, `.cache`, ...) and entries
25//! matched by the cwd's `.gitignore` (and git's global excludes).
26//! `H` toggles their visibility; when shown, dotfile rows render
27//! dim and gitignored rows render dim with a warning-coloured
28//! foreground so the user can still distinguish them from
29//! ordinary content. The classification is captured per-row via
30//! [`super::model::HiddenKind`].
31
32use std::ffi::OsString;
33use std::fs;
34use std::io::{self, Stdout, Write};
35use std::path::{Path, PathBuf};
36
37use crossterm::{
38    event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
39    execute,
40    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
41};
42use ignore::gitignore::{Gitignore, GitignoreBuilder};
43use miette::{IntoDiagnostic, Result, WrapErr, miette};
44use ratatui::Terminal;
45use ratatui::backend::CrosstermBackend;
46use ratatui::layout::{Constraint, Direction, Layout, Rect};
47use ratatui::style::{Color, Modifier, Style, Stylize};
48use ratatui::text::{Line, Span};
49use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap};
50use ratatui::{Frame, TerminalOptions, Viewport};
51
52use super::commit::{CommitPlan, plan_commit};
53use super::model::{
54    CycleOutcome, ExpandOutcome, HiddenKind, Node, SelectionState, Tree, VisibleNode,
55};
56
57/// What the dispatcher's caller (the driver loop) should do next.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum DispatchResult {
60    /// Stay in the loop: redraw, then read another event.
61    Continue,
62    /// The driver reads the focused node's children and then
63    /// retries the expand in the same keystroke. Used when Right
64    /// was pressed on a directory whose children were not yet
65    /// loaded: without the retry, the keypress would silently
66    /// flip `expanded` (or do nothing for an empty directory)
67    /// without giving the user the AUX-004-style refusal signal
68    /// the empty case warrants.
69    LoadChildrenThenRetryExpand,
70    /// The driver reads the focused node's children and then
71    /// retries `cycle_focused` in the same keystroke. Used when
72    /// Space was pressed on a node whose children were not yet
73    /// loaded: without the retry the keypress would only do a
74    /// silent `read_dir` and the user would see "nothing happen".
75    LoadChildrenThenRetryCycle,
76    /// The user toggled `hidden_visible` and the tree's
77    /// currently-loaded subtrees must be re-read against the new
78    /// filter so the visual state matches the flag. The driver
79    /// walks every already-loaded directory, re-reads its
80    /// children with the new visibility, preserves
81    /// expanded/state by basename, then re-anchors focus.
82    ReloadTreeForVisibility,
83    /// Leave the tree screen and move to the commit pass.
84    ExitCommit,
85    /// User declined the scaffold (`q` / `Esc`). Exit cleanly.
86    ExitCancel,
87    /// User aborted (`Ctrl+C`). Exit with the signal-interrupt
88    /// code.
89    Abort,
90}
91
92/// Final outcome of the scaffold flow as far as the executor is
93/// concerned.
94#[derive(Debug)]
95pub enum ScaffoldOutcome {
96    /// User confirmed the plan. Apply it.
97    Confirmed(CommitPlan),
98    /// User declined either the tree screen (`q`) or the
99    /// confirmation prompt (`n`). Exit 0 with no filesystem
100    /// changes per `AUX-005` step 4 and `AUX-008`.
101    Declined,
102    /// User aborted via `Ctrl+C`. Exit 130 per `EXEC-021`.
103    Aborted,
104}
105
106/// Which on-screen element currently carries the focus highlight.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum TuiFocus {
109    /// A tree row, identified by the underlying [`Tree`]'s focus
110    /// path.
111    Tree,
112    /// The synthetic `[ Commit selection ]` button below the tree.
113    CommitButton,
114}
115
116/// Combined gitignore matcher used to classify directories as
117/// `HiddenKind::Gitignored` at read time.
118///
119/// Honours the cwd's local `.gitignore` plus git's global
120/// excludes (`core.excludesFile`, typically
121/// `~/.config/git/ignore`). Sub-directory `.gitignore` files are
122/// NOT cascaded for the v1 of this feature: the common case
123/// (excludes declared at the repo root: `node_modules/`,
124/// `target/`, etc.) is covered, and deeper cascading would
125/// require either a pre-walk of the whole tree or per-directory
126/// matcher rebuilds, both of which cost more than this feature
127/// pays back.
128#[derive(Debug, Clone)]
129pub struct GitignoreMatcher {
130    local: Gitignore,
131    global: Gitignore,
132}
133
134impl GitignoreMatcher {
135    /// Construct a matcher rooted at `cwd_host`. A missing
136    /// `.gitignore` is not an error; the matcher then matches
137    /// only entries the global gitignore would also match (if
138    /// any).
139    #[must_use]
140    pub fn new(cwd_host: &Path) -> Self {
141        let mut builder = GitignoreBuilder::new(cwd_host);
142        // The builder's `add` returns `Option<Error>`; we ignore
143        // a missing file (the common case) and any malformed
144        // ignore file (would produce a typed error if needed,
145        // but for now we degrade gracefully to an empty
146        // matcher).
147        let _ = builder.add(cwd_host.join(".gitignore"));
148        let local = builder.build().unwrap_or_else(|_| Gitignore::empty());
149        let (global, _) = Gitignore::global();
150        Self { local, global }
151    }
152
153    /// Whether the (host-side) `path` is treated as ignored.
154    /// `is_dir` mirrors `gitignore`'s directory-vs-file
155    /// distinction so `dir/`-style patterns match correctly.
156    #[must_use]
157    pub fn is_ignored(&self, host_path: &Path, is_dir: bool) -> bool {
158        self.local.matched(host_path, is_dir).is_ignore()
159            || self.global.matched(host_path, is_dir).is_ignore()
160    }
161}
162
163/// The full mutable TUI state. Held by the driver, mutated by
164/// [`dispatch`].
165#[derive(Debug)]
166pub struct TuiState {
167    /// The interactive tree per [`super::model`].
168    pub tree: Tree,
169    /// Which on-screen element gets the focus highlight.
170    pub focus: TuiFocus,
171    /// Host filesystem path of the cwd. Displayed in the header;
172    /// used by the commit-pass plumbing when leaving the tree
173    /// screen.
174    pub cwd_host: PathBuf,
175    /// Gitignore matcher consulted at directory-read time to
176    /// classify entries as `HiddenKind::Gitignored`.
177    pub gitignore: GitignoreMatcher,
178    /// Whether dotfile-prefixed and gitignored sibling
179    /// directories are visible in the tree. Defaults to `false`.
180    /// Toggled by `H`; the toggle returns
181    /// [`DispatchResult::ReloadTreeForVisibility`] so the driver
182    /// can re-read every already-loaded directory against the new
183    /// flag (state and expanded-ness are preserved by basename).
184    pub hidden_visible: bool,
185    /// Transient status / refusal message rendered above the
186    /// keybinding hint. Cleared on every new keystroke before
187    /// dispatch.
188    pub status: Option<String>,
189    /// Whether the help overlay is currently open. Any keystroke
190    /// dismisses it.
191    pub help_visible: bool,
192    /// Whether the driver should ring the terminal bell on the
193    /// next render. Cleared after the bell fires.
194    pub bell_pending: bool,
195}
196
197impl TuiState {
198    /// Construct an initial state rooted at `cwd_host`. Loads the
199    /// cwd's direct subdirectories (filtered per [`Self::hidden_visible`])
200    /// so the user has something to navigate immediately.
201    fn new(cwd_host: PathBuf) -> Result<Self> {
202        let gitignore = GitignoreMatcher::new(&cwd_host);
203        let children = read_dir_children(&cwd_host, Path::new("/"), &gitignore, false)?;
204        let mut root = Node::new(OsString::from("/"), PathBuf::from("/"));
205        root.children = children;
206        root.children_loaded = true;
207        root.expanded = true;
208        Ok(Self {
209            tree: Tree::new(root),
210            focus: TuiFocus::Tree,
211            cwd_host,
212            gitignore,
213            hidden_visible: false,
214            status: None,
215            help_visible: false,
216            bell_pending: false,
217        })
218    }
219}
220
221// ---------------------------------------------------------------
222// Dispatcher (pure)
223// ---------------------------------------------------------------
224
225/// Apply a single crossterm event to the TUI state and report
226/// what the driver should do next.
227///
228/// The function clears the state's transient fields (`status`,
229/// `bell_pending`) at the start of every call so each keystroke
230/// produces a fresh signal. Set both inside this function when
231/// the binding warrants them.
232pub fn dispatch(event: &Event, state: &mut TuiState) -> DispatchResult {
233    state.status = None;
234    state.bell_pending = false;
235
236    let Event::Key(key) = event else {
237        return DispatchResult::Continue;
238    };
239    if key.kind != KeyEventKind::Press {
240        return DispatchResult::Continue;
241    }
242
243    // Ctrl+C aborts before any other interpretation so the user
244    // can always bail out from anywhere, including the help
245    // overlay.
246    if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c' | 'C'))
247    {
248        return DispatchResult::Abort;
249    }
250
251    // Help overlay swallows the next keystroke and closes.
252    if state.help_visible {
253        state.help_visible = false;
254        return DispatchResult::Continue;
255    }
256
257    match key.code {
258        KeyCode::Up => {
259            move_focus(state, FocusDirection::Prev);
260            DispatchResult::Continue
261        }
262        KeyCode::Down => {
263            move_focus(state, FocusDirection::Next);
264            DispatchResult::Continue
265        }
266        KeyCode::Right => expand_current(state),
267        KeyCode::Left => {
268            collapse_current(state);
269            DispatchResult::Continue
270        }
271        KeyCode::Char(' ') => cycle_current(state),
272        KeyCode::Enter => DispatchResult::ExitCommit,
273        KeyCode::Char('h' | 'H') => {
274            state.hidden_visible = !state.hidden_visible;
275            state.status = Some(if state.hidden_visible {
276                "hidden directories shown".to_string()
277            } else {
278                "hidden directories hidden".to_string()
279            });
280            DispatchResult::ReloadTreeForVisibility
281        }
282        KeyCode::Char('?') => {
283            state.help_visible = true;
284            DispatchResult::Continue
285        }
286        KeyCode::Char('q' | 'Q') | KeyCode::Esc => DispatchResult::ExitCancel,
287        _ => DispatchResult::Continue,
288    }
289}
290
291#[derive(Clone, Copy)]
292enum FocusDirection {
293    Next,
294    Prev,
295}
296
297fn move_focus(state: &mut TuiState, dir: FocusDirection) {
298    let visible = state.tree.visible_nodes().len();
299    if visible == 0 {
300        // Cannot happen with a well-formed tree (the root is
301        // always visible) but stay defensive.
302        return;
303    }
304    let total = visible + 1; // commit button is the (visible)th slot
305    let current = match state.focus {
306        TuiFocus::Tree => state.tree.focused_visible_index(),
307        TuiFocus::CommitButton => visible,
308    };
309    let next = match dir {
310        FocusDirection::Next => (current + 1) % total,
311        FocusDirection::Prev => {
312            if current == 0 {
313                total - 1
314            } else {
315                current - 1
316            }
317        }
318    };
319    if next == visible {
320        state.focus = TuiFocus::CommitButton;
321    } else {
322        state.focus = TuiFocus::Tree;
323        state.tree.focus_at_visible_index(next);
324    }
325}
326
327fn expand_current(state: &mut TuiState) -> DispatchResult {
328    if state.focus != TuiFocus::Tree {
329        return DispatchResult::Continue;
330    }
331    let outcome = state.tree.try_expand_focused();
332    if matches!(outcome, ExpandOutcome::NeedsChildrenLoaded) {
333        // The driver does the read and retries the expand in
334        // the same keystroke so the user sees a single decisive
335        // outcome per Right-arrow press.
336        return DispatchResult::LoadChildrenThenRetryExpand;
337    }
338    apply_expand_outcome_signal(state, outcome);
339    DispatchResult::Continue
340}
341
342/// Translate an `ExpandOutcome` into the user-visible signal.
343/// `Expanded` and `AlreadyExpanded` are silent (success or
344/// no-op); `RefusedNoSubdirs` rings the bell and shows a status
345/// message; `NeedsChildrenLoaded` is treated as a no-op here
346/// because the driver only calls this helper after a load.
347fn apply_expand_outcome_signal(state: &mut TuiState, outcome: ExpandOutcome) {
348    match outcome {
349        ExpandOutcome::Expanded
350        | ExpandOutcome::AlreadyExpanded
351        | ExpandOutcome::NeedsChildrenLoaded => {}
352        ExpandOutcome::RefusedNoSubdirs => {
353            let path_display = display_workspace_path(&state.tree.focused().path);
354            state.bell_pending = true;
355            state.status = Some(format!("cannot expand `{path_display}`: no subdirectories"));
356        }
357    }
358}
359
360fn collapse_current(state: &mut TuiState) {
361    if state.focus != TuiFocus::Tree {
362        return;
363    }
364    state.tree.collapse_focused();
365}
366
367fn cycle_current(state: &mut TuiState) -> DispatchResult {
368    if state.focus != TuiFocus::Tree {
369        return DispatchResult::Continue;
370    }
371    let outcome = state.tree.cycle_focused();
372    if matches!(outcome, CycleOutcome::NeedsChildrenLoaded) {
373        // The driver does the disk read and retries the cycle in
374        // the same keystroke so the user sees a single, decisive
375        // transition per Space press.
376        return DispatchResult::LoadChildrenThenRetryCycle;
377    }
378    apply_cycle_outcome_signal(state, outcome);
379    DispatchResult::Continue
380}
381
382/// Translate a `CycleOutcome` into the user-visible signal:
383/// terminal bell and status message for the two refusal cases,
384/// no-op for `Advanced`. `NeedsChildrenLoaded` is treated as a
385/// no-op here because the driver only calls this helper after a
386/// load (where the outcome cannot be `NeedsChildrenLoaded`
387/// again).
388fn apply_cycle_outcome_signal(state: &mut TuiState, outcome: CycleOutcome) {
389    match outcome {
390        CycleOutcome::Advanced(_) | CycleOutcome::NeedsChildrenLoaded => {}
391        CycleOutcome::RefusedEmptyGlob => {
392            let path_display = display_workspace_path(&state.tree.focused().path);
393            state.bell_pending = true;
394            state.status = Some(format!(
395                "skipped projects-parent for `{path_display}`: no subdirectories",
396            ));
397        }
398        CycleOutcome::RefusedSubtreeLock => {
399            let path_display = display_workspace_path(&state.tree.focused().path);
400            state.bell_pending = true;
401            state.status = Some(format!(
402                "cannot cycle `{path_display}`: under a projects-parent directory",
403            ));
404        }
405    }
406}
407
408fn display_workspace_path(p: &Path) -> String {
409    p.to_string_lossy().into_owned()
410}
411
412// ---------------------------------------------------------------
413// Driver (terminal IO)
414// ---------------------------------------------------------------
415
416/// Run the full interactive scaffold flow against `cwd_host`.
417///
418/// Sets up a ratatui terminal on stdout, drives the tree screen
419/// to end-of-selection, builds a [`CommitPlan`], and then drives
420/// the confirmation screen. The terminal is unconditionally
421/// restored on return via the [`TerminalGuard`] RAII helper.
422///
423/// # Errors
424///
425/// Propagates the dispatcher's IO errors (`read_dir` failures,
426/// raw mode setup) plus any [`super::commit::CommitPlanError`]
427/// surfaced during the commit pass.
428pub fn run_interactive_scaffold(cwd_host: &Path) -> Result<ScaffoldOutcome> {
429    let mut state = TuiState::new(cwd_host.to_path_buf())?;
430
431    let mut terminal = enter_terminal()?;
432    let mut guard = TerminalGuard::armed();
433
434    let outcome = drive_tree_screen(&mut terminal, &mut state);
435
436    // Whichever outcome we got, we leave the tree screen with the
437    // terminal still in raw mode. The confirmation pass reuses
438    // the same terminal; the guard restores on the way out.
439    let outcome = match outcome {
440        Ok(DriverExit::Commit) => {
441            let plan = plan_commit(&state.tree, cwd_host)
442                .map_err(|err| miette!("{err}"))
443                .wrap_err("haz init: AUX-005 commit-plan construction failed")?;
444            drive_confirmation_screen(&mut terminal, &plan)?
445        }
446        Ok(DriverExit::Cancel) => ScaffoldOutcome::Declined,
447        Ok(DriverExit::Abort) => ScaffoldOutcome::Aborted,
448        Err(e) => {
449            guard.disarm();
450            restore_terminal();
451            return Err(e);
452        }
453    };
454
455    guard.disarm();
456    restore_terminal();
457    Ok(outcome)
458}
459
460#[derive(Debug)]
461enum DriverExit {
462    Commit,
463    Cancel,
464    Abort,
465}
466
467fn drive_tree_screen(
468    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
469    state: &mut TuiState,
470) -> Result<DriverExit> {
471    loop {
472        terminal
473            .draw(|frame| render_tree_screen(frame, state))
474            .into_diagnostic()
475            .wrap_err("haz init: failed to draw tree screen")?;
476
477        if state.bell_pending {
478            ring_bell();
479            state.bell_pending = false;
480        }
481
482        let event = read_event_blocking()?;
483        match dispatch(&event, state) {
484            DispatchResult::Continue => {}
485            DispatchResult::LoadChildrenThenRetryExpand => {
486                load_children_for_focused(state)?;
487                let outcome = state.tree.try_expand_focused();
488                apply_expand_outcome_signal(state, outcome);
489            }
490            DispatchResult::LoadChildrenThenRetryCycle => {
491                load_children_for_focused(state)?;
492                let outcome = state.tree.cycle_focused();
493                apply_cycle_outcome_signal(state, outcome);
494            }
495            DispatchResult::ReloadTreeForVisibility => {
496                reload_tree_for_visibility(state)?;
497            }
498            DispatchResult::ExitCommit => return Ok(DriverExit::Commit),
499            DispatchResult::ExitCancel => return Ok(DriverExit::Cancel),
500            DispatchResult::Abort => return Ok(DriverExit::Abort),
501        }
502    }
503}
504
505fn drive_confirmation_screen(
506    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
507    plan: &CommitPlan,
508) -> Result<ScaffoldOutcome> {
509    loop {
510        terminal
511            .draw(|frame| render_confirmation_screen(frame, plan))
512            .into_diagnostic()
513            .wrap_err("haz init: failed to draw confirmation screen")?;
514
515        let event = read_event_blocking()?;
516        let Event::Key(key) = event else {
517            continue;
518        };
519        if key.kind != KeyEventKind::Press {
520            continue;
521        }
522        if key.modifiers.contains(KeyModifiers::CONTROL)
523            && matches!(key.code, KeyCode::Char('c' | 'C'))
524        {
525            return Ok(ScaffoldOutcome::Aborted);
526        }
527        match key.code {
528            KeyCode::Char('y' | 'Y') => {
529                return Ok(ScaffoldOutcome::Confirmed(plan.clone()));
530            }
531            KeyCode::Char('n' | 'N' | 'q' | 'Q') | KeyCode::Esc => {
532                return Ok(ScaffoldOutcome::Declined);
533            }
534            _ => {}
535        }
536    }
537}
538
539fn read_event_blocking() -> Result<Event> {
540    // crossterm::event::read() blocks; the poll-with-timeout
541    // pattern is for non-blocking redraws driven by external
542    // state. Our redraws are entirely event-driven, so a plain
543    // blocking read keeps the loop simple.
544    event::read()
545        .into_diagnostic()
546        .wrap_err("haz init: failed to read terminal event")
547}
548
549fn ring_bell() {
550    let _ = io::stderr().write_all(b"\x07");
551    let _ = io::stderr().flush();
552}
553
554// ---------------------------------------------------------------
555// Filesystem
556// ---------------------------------------------------------------
557
558fn load_children_for_focused(state: &mut TuiState) -> Result<()> {
559    let focused_path = state.tree.focused().path.clone();
560    let host_path = host_join(&state.cwd_host, &focused_path);
561    let children = read_dir_children(
562        &host_path,
563        &focused_path,
564        &state.gitignore,
565        state.hidden_visible,
566    )?;
567    state.tree.populate_focused_children(children);
568    Ok(())
569}
570
571/// Re-read every currently-loaded directory in the tree against
572/// the new `hidden_visible` value. New entries appear with
573/// default state; entries previously present retain their
574/// `state`, `expanded`, `children_loaded`, and `children`
575/// (matched by basename). Entries no longer in the new read are
576/// dropped. After reloading, focus is restored to the
577/// previously-focused workspace path when that node is still
578/// visible, otherwise it falls back to the root.
579fn reload_tree_for_visibility(state: &mut TuiState) -> Result<()> {
580    let focused_workspace_path = state.tree.focused().path.clone();
581    let cwd_host = state.cwd_host.clone();
582    let show_hidden = state.hidden_visible;
583    reload_loaded_subtree(
584        state.tree.root_mut(),
585        &cwd_host,
586        &state.gitignore,
587        show_hidden,
588    )?;
589    let new_path =
590        locate_visible_path(state.tree.root(), &focused_workspace_path).unwrap_or_default();
591    state.tree.set_focus_path(new_path);
592    Ok(())
593}
594
595/// Recursive reload helper. Only descends into directories
596/// previously loaded (`children_loaded == true`); never reads a
597/// directory the user has not yet expanded.
598fn reload_loaded_subtree(
599    node: &mut Node,
600    cwd_host: &Path,
601    gitignore: &GitignoreMatcher,
602    show_hidden: bool,
603) -> Result<()> {
604    if !node.children_loaded {
605        return Ok(());
606    }
607    let host_path = host_join(cwd_host, &node.path);
608    let mut new_children = read_dir_children(&host_path, &node.path, gitignore, show_hidden)?;
609
610    let mut old_by_name: std::collections::HashMap<std::ffi::OsString, Node> =
611        std::collections::HashMap::new();
612    for child in std::mem::take(&mut node.children) {
613        old_by_name.insert(child.name.clone(), child);
614    }
615
616    for new_child in &mut new_children {
617        if let Some(old) = old_by_name.remove(&new_child.name) {
618            new_child.state = old.state;
619            new_child.expanded = old.expanded;
620            new_child.children_loaded = old.children_loaded;
621            new_child.children = old.children;
622            reload_loaded_subtree(new_child, cwd_host, gitignore, show_hidden)?;
623        }
624    }
625    node.children = new_children;
626    Ok(())
627}
628
629/// Locate the focus path (list of child indices) leading to the
630/// node whose workspace-absolute path equals `target`. Returns
631/// `None` if no such node is visible (collapsed ancestor or
632/// removed by the reload).
633fn locate_visible_path(root: &Node, target: &Path) -> Option<Vec<usize>> {
634    let mut path: Vec<usize> = Vec::new();
635    if locate_visible_path_rec(root, target, &mut path) {
636        Some(path)
637    } else {
638        None
639    }
640}
641
642fn locate_visible_path_rec(node: &Node, target: &Path, path: &mut Vec<usize>) -> bool {
643    if node.path == target {
644        return true;
645    }
646    if !node.expanded {
647        return false;
648    }
649    for (i, child) in node.children.iter().enumerate() {
650        path.push(i);
651        if locate_visible_path_rec(child, target, path) {
652            return true;
653        }
654        path.pop();
655    }
656    false
657}
658
659fn read_dir_children(
660    host_dir: &Path,
661    workspace_dir: &Path,
662    gitignore: &GitignoreMatcher,
663    show_hidden: bool,
664) -> Result<Vec<Node>> {
665    let mut entries: Vec<Node> = Vec::new();
666    let read = fs::read_dir(host_dir)
667        .into_diagnostic()
668        .wrap_err_with(|| format!("failed to read directory `{}`", host_dir.display()))?;
669    for entry in read {
670        let entry = entry
671            .into_diagnostic()
672            .wrap_err_with(|| format!("failed to read entry under `{}`", host_dir.display()))?;
673        let file_type = entry
674            .file_type()
675            .into_diagnostic()
676            .wrap_err_with(|| format!("failed to stat `{}`", entry.path().display()))?;
677        if !file_type.is_dir() {
678            continue;
679        }
680        let name = entry.file_name();
681        let kind = classify_hidden(&name, &entry.path(), gitignore);
682        if kind != HiddenKind::Visible && !show_hidden {
683            continue;
684        }
685        let mut child_workspace = workspace_dir.to_path_buf();
686        child_workspace.push(&name);
687        let mut node = Node::new(name, child_workspace);
688        node.hidden_kind = kind;
689        entries.push(node);
690    }
691    entries.sort_by(|a, b| a.name.cmp(&b.name));
692    Ok(entries)
693}
694
695/// Classify a directory entry by how it would be hidden.
696///
697/// `Gitignored` wins over `Dotfile` when both apply, so the
698/// warning-colour styling reaches the user. Pure helper; the
699/// caller decides whether the entry is suppressed or shown.
700fn classify_hidden(name: &OsString, host_path: &Path, gitignore: &GitignoreMatcher) -> HiddenKind {
701    if gitignore.is_ignored(host_path, true) {
702        return HiddenKind::Gitignored;
703    }
704    if is_hidden_name(name) {
705        return HiddenKind::Dotfile;
706    }
707    HiddenKind::Visible
708}
709
710fn is_hidden_name(name: &OsString) -> bool {
711    let bytes = name.as_encoded_bytes();
712    bytes.first().copied() == Some(b'.')
713}
714
715fn host_join(cwd_host_root: &Path, workspace_absolute: &Path) -> PathBuf {
716    let stripped = workspace_absolute
717        .strip_prefix("/")
718        .unwrap_or(workspace_absolute);
719    if stripped.as_os_str().is_empty() {
720        cwd_host_root.to_path_buf()
721    } else {
722        cwd_host_root.join(stripped)
723    }
724}
725
726// ---------------------------------------------------------------
727// Terminal setup / teardown
728// ---------------------------------------------------------------
729
730fn enter_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
731    enable_raw_mode()
732        .into_diagnostic()
733        .wrap_err("haz init: failed to enable terminal raw mode")?;
734    let mut stdout = io::stdout();
735    execute!(stdout, EnterAlternateScreen)
736        .into_diagnostic()
737        .wrap_err("haz init: failed to enter the alternate screen")?;
738    let backend = CrosstermBackend::new(stdout);
739    let terminal = Terminal::with_options(
740        backend,
741        TerminalOptions {
742            viewport: Viewport::Fullscreen,
743        },
744    )
745    .into_diagnostic()
746    .wrap_err("haz init: failed to initialise the ratatui terminal")?;
747    Ok(terminal)
748}
749
750fn restore_terminal() {
751    let _ = disable_raw_mode();
752    let _ = execute!(io::stdout(), LeaveAlternateScreen);
753}
754
755struct TerminalGuard {
756    armed: bool,
757}
758
759impl TerminalGuard {
760    fn armed() -> Self {
761        Self { armed: true }
762    }
763
764    fn disarm(&mut self) {
765        self.armed = false;
766    }
767}
768
769impl Drop for TerminalGuard {
770    fn drop(&mut self) {
771        if self.armed {
772            restore_terminal();
773        }
774    }
775}
776
777// ---------------------------------------------------------------
778// Rendering
779// ---------------------------------------------------------------
780
781const COLOR_PROJECT: Color = Color::Green;
782const COLOR_PROJECTS_PARENT: Color = Color::Cyan;
783
784/// Lines occupied by chrome ABOVE the tree (title + cwd).
785const CHROME_ABOVE_TREE: u16 = 2;
786/// Lines occupied by chrome BELOW the tree (spacer + commit
787/// button + status + keys). The tree is sized to fit its
788/// visible rows up to the height that leaves room for this
789/// chrome; remaining vertical space falls into the trailing
790/// `Min(0)` chunk so the chrome sits close to the tree on
791/// tall terminals instead of being pushed to the screen bottom.
792const CHROME_BELOW_TREE: u16 = 4;
793
794fn render_tree_screen(frame: &mut Frame<'_>, state: &TuiState) {
795    let area = frame.area();
796
797    let visible_count = u16::try_from(state.tree.visible_nodes().len()).unwrap_or(u16::MAX);
798    let available_for_tree = area
799        .height
800        .saturating_sub(CHROME_ABOVE_TREE + CHROME_BELOW_TREE);
801    let tree_height = visible_count.min(available_for_tree).max(1);
802
803    let chunks = Layout::default()
804        .direction(Direction::Vertical)
805        .constraints([
806            Constraint::Length(1),           // 0: title
807            Constraint::Length(1),           // 1: cwd
808            Constraint::Length(tree_height), // 2: tree
809            Constraint::Length(1),           // 3: spacer
810            Constraint::Length(1),           // 4: commit button
811            Constraint::Length(1),           // 5: status
812            Constraint::Length(1),           // 6: keybindings
813            Constraint::Min(0),              // 7: empty trailer
814        ])
815        .split(area);
816
817    render_title(frame, chunks[0]);
818    render_cwd(frame, chunks[1], &state.cwd_host);
819    render_tree(frame, chunks[2], state);
820    render_commit_button(frame, chunks[4], state);
821    render_status(frame, chunks[5], state.status.as_deref());
822    render_keys(frame, chunks[6]);
823
824    if state.help_visible {
825        render_help_overlay(frame, area);
826    }
827}
828
829fn render_title(frame: &mut Frame<'_>, area: Rect) {
830    let para = Paragraph::new(Line::from(Span::styled(
831        " haz init \u{2014} select directories to register as projects ",
832        Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
833    )));
834    frame.render_widget(para, area);
835}
836
837fn render_cwd(frame: &mut Frame<'_>, area: Rect, cwd: &Path) {
838    let text = format!(" cwd: {}", cwd.display());
839    let para = Paragraph::new(Line::from(Span::raw(text)));
840    frame.render_widget(para, area);
841}
842
843fn render_tree(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
844    let visible = state.tree.visible_nodes();
845    let width = area.width as usize;
846    let items: Vec<ListItem<'static>> = visible
847        .iter()
848        .map(|vn| ListItem::new(build_row_line(vn, width)))
849        .collect();
850
851    let list = List::new(items).highlight_style(
852        Style::default()
853            .add_modifier(Modifier::REVERSED)
854            .add_modifier(Modifier::BOLD),
855    );
856
857    let mut list_state = ListState::default();
858    if state.focus == TuiFocus::Tree {
859        list_state.select(Some(state.tree.focused_visible_index()));
860    }
861    frame.render_stateful_widget(list, area, &mut list_state);
862}
863
864/// Fixed column gap between the row's content and its italic
865/// state label. The label sits this many columns to the right of
866/// the name, regardless of terminal width, so it does not
867/// migrate across the screen on resize and stays close to the
868/// row it describes.
869const GAP_TO_RIGHT_LABEL: usize = 4;
870
871fn build_row_line(vn: &VisibleNode<'_>, area_width: usize) -> Line<'static> {
872    let (state_glyph, state_glyph_style, state_text, state_text_style) = state_decor(vn.node.state);
873
874    // Every Node in the model is a directory (the read_dir
875    // filter drops non-directories), so every row is
876    // expandable: the triangle just reflects expanded vs
877    // collapsed, never "leaf".
878    let expand_glyph = if vn.node.expanded {
879        "\u{25BC}" // â–¼
880    } else {
881        "\u{25B6}" // â–¶
882    };
883
884    let indent: String = " ".repeat(vn.depth * 2);
885    let name = vn.node.name.to_string_lossy().into_owned();
886
887    // Left-hand block: state glyph, depth indent, expand glyph, name.
888    let left_text = format!(" {state_glyph} {indent}{expand_glyph} {name}");
889    let left_len = left_text.chars().count();
890
891    // Italic state label sits at a fixed gap after the row's
892    // name. When even that minimum-gap layout overflows the
893    // terminal, drop the label rather than let ratatui clip it
894    // mid-glyph.
895    let right_text = state_text.to_string();
896    let right_len = right_text.chars().count();
897    let has_room_for_right_label =
898        !right_text.is_empty() && left_len + GAP_TO_RIGHT_LABEL + right_len <= area_width;
899    let pad_width = GAP_TO_RIGHT_LABEL;
900
901    let mut row_modifier = Modifier::empty();
902    if vn.locked {
903        row_modifier |= Modifier::DIM;
904    }
905    let hidden_fg = match vn.node.hidden_kind {
906        HiddenKind::Visible => None,
907        HiddenKind::Dotfile => {
908            // Dotfile rows dim only; no foreground recolour, so
909            // they read as "background" content.
910            row_modifier |= Modifier::DIM;
911            None
912        }
913        HiddenKind::Gitignored => {
914            // Gitignored rows dim AND recolour to a warning
915            // orange (RGB 204/102/0), per the design's
916            // "obviously here but you probably don't want it"
917            // intent.
918            row_modifier |= Modifier::DIM;
919            Some(Color::Rgb(204, 102, 0))
920        }
921    };
922
923    let glyph_style = state_glyph_style.add_modifier(row_modifier);
924    let plain_style = match hidden_fg {
925        Some(fg) => Style::default().fg(fg).add_modifier(row_modifier),
926        None => Style::default().add_modifier(row_modifier),
927    };
928    let italic_style = state_text_style.add_modifier(row_modifier);
929
930    let mut spans = vec![
931        Span::styled(" ", plain_style),
932        Span::styled(state_glyph.to_string(), glyph_style),
933        Span::styled(format!(" {indent}{expand_glyph} {name}"), plain_style),
934    ];
935    if has_room_for_right_label {
936        spans.push(Span::styled(" ".repeat(pad_width), plain_style));
937        spans.push(Span::styled(right_text, italic_style));
938    }
939    Line::from(spans)
940}
941
942fn state_decor(state: SelectionState) -> (&'static str, Style, &'static str, Style) {
943    match state {
944        SelectionState::None => (
945            "\u{25A1}", // â–¡
946            Style::default(),
947            "",
948            Style::default(),
949        ),
950        SelectionState::Project => (
951            "\u{25A0}", // â– 
952            Style::default()
953                .fg(COLOR_PROJECT)
954                .add_modifier(Modifier::BOLD),
955            "(project)",
956            Style::default()
957                .fg(COLOR_PROJECT)
958                .add_modifier(Modifier::ITALIC),
959        ),
960        SelectionState::ProjectsParent => (
961            "\u{25A3}", // â–£
962            Style::default()
963                .fg(COLOR_PROJECTS_PARENT)
964                .add_modifier(Modifier::BOLD),
965            "(projects parent)",
966            Style::default()
967                .fg(COLOR_PROJECTS_PARENT)
968                .add_modifier(Modifier::ITALIC),
969        ),
970    }
971}
972
973fn render_commit_button(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
974    let count = state.tree.project_root_set().len();
975    let label = format!(
976        "  [ Commit selection ({count} project{plural}) ]",
977        count = count,
978        plural = if count == 1 { "" } else { "s" },
979    );
980    let style = if state.focus == TuiFocus::CommitButton {
981        Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD)
982    } else {
983        Style::default().add_modifier(Modifier::BOLD)
984    };
985    let para = Paragraph::new(Line::from(Span::styled(label, style)));
986    frame.render_widget(para, area);
987}
988
989fn render_status(frame: &mut Frame<'_>, area: Rect, status: Option<&str>) {
990    let text = status.unwrap_or("");
991    let para = Paragraph::new(Line::from(Span::styled(
992        format!(" {text}"),
993        Style::default().fg(Color::Yellow),
994    )));
995    frame.render_widget(para, area);
996}
997
998fn render_keys(frame: &mut Frame<'_>, area: Rect) {
999    let line = Line::from(vec![
1000        Span::raw(" \u{2191}\u{2193} move  "),
1001        Span::raw("\u{2192} expand  "),
1002        Span::raw("\u{2190} collapse  "),
1003        Span::raw("Space cycle selection  "),
1004        Span::raw("Enter commit  "),
1005        Span::raw("H hidden  "),
1006        Span::raw("q cancel  "),
1007        Span::raw("? help"),
1008    ])
1009    .style(Style::default().add_modifier(Modifier::DIM));
1010    let para = Paragraph::new(line);
1011    frame.render_widget(para, area);
1012}
1013
1014fn render_help_overlay(frame: &mut Frame<'_>, area: Rect) {
1015    let inner = centred_rect(area, 60, 18);
1016    let block = Block::bordered().title(" help ").bold();
1017    let body = vec![
1018        Line::from("Navigation"),
1019        Line::from("  \u{2191} / \u{2193}        move focus through visible rows"),
1020        Line::from("  \u{2192}            expand focused directory"),
1021        Line::from("  \u{2190}            collapse focused directory"),
1022        Line::from(""),
1023        Line::from("Selection (AUX-003 / AUX-004)"),
1024        Line::from("  Space        cycle: none -> project -> projects-parent"),
1025        Line::from(""),
1026        Line::from("Commit"),
1027        Line::from("  Enter        leave selection, show confirmation"),
1028        Line::from(""),
1029        Line::from("Other"),
1030        Line::from("  H            toggle hidden directories"),
1031        Line::from("  ?            toggle this overlay"),
1032        Line::from("  q / Esc      cancel (exit with no changes)"),
1033        Line::from("  Ctrl+C       abort"),
1034    ];
1035    let para = Paragraph::new(body).block(block).wrap(Wrap { trim: false });
1036    frame.render_widget(Clear, inner);
1037    frame.render_widget(para, inner);
1038}
1039
1040fn centred_rect(area: Rect, width: u16, height: u16) -> Rect {
1041    let w = width.min(area.width.saturating_sub(4));
1042    let h = height.min(area.height.saturating_sub(2));
1043    let x = area.x + area.width.saturating_sub(w) / 2;
1044    let y = area.y + area.height.saturating_sub(h) / 2;
1045    Rect {
1046        x,
1047        y,
1048        width: w,
1049        height: h,
1050    }
1051}
1052
1053fn render_confirmation_screen(frame: &mut Frame<'_>, plan: &CommitPlan) {
1054    let area = frame.area();
1055    let chunks = Layout::default()
1056        .direction(Direction::Vertical)
1057        .constraints([
1058            Constraint::Length(1), // title
1059            Constraint::Min(0),    // body
1060            Constraint::Length(1), // keys
1061        ])
1062        .split(area);
1063
1064    let title = Paragraph::new(Line::from(Span::styled(
1065        " haz init \u{2014} confirm scaffold ",
1066        Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
1067    )));
1068    frame.render_widget(title, chunks[0]);
1069
1070    let mut lines: Vec<Line<'static>> = Vec::new();
1071    lines.push(Line::from(""));
1072    lines.push(Line::from(Span::styled(
1073        " will write .haz/workspace.yml:",
1074        Style::default().add_modifier(Modifier::BOLD),
1075    )));
1076    for line in plan.workspace_yml_body.lines() {
1077        lines.push(Line::from(format!("   {line}")));
1078    }
1079    lines.push(Line::from(""));
1080    if plan.project_ymls.is_empty() {
1081        lines.push(Line::from(Span::styled(
1082            " will scaffold no haz.yml files (empty projects: list).",
1083            Style::default().add_modifier(Modifier::ITALIC),
1084        )));
1085    } else {
1086        lines.push(Line::from(Span::styled(
1087            " will scaffold haz.yml:",
1088            Style::default().add_modifier(Modifier::BOLD),
1089        )));
1090        for scaffold in &plan.project_ymls {
1091            lines.push(Line::from(format!(
1092                "   {root}        name: {name}",
1093                root = scaffold.project_root.display(),
1094                name = scaffold.sanitised_name,
1095            )));
1096        }
1097    }
1098    lines.push(Line::from(""));
1099    lines.push(Line::from(Span::styled(
1100        " Proceed?  y = write, n / q = decline",
1101        Style::default().add_modifier(Modifier::BOLD),
1102    )));
1103
1104    let para = Paragraph::new(lines).wrap(Wrap { trim: false });
1105    frame.render_widget(para, chunks[1]);
1106
1107    let keys = Paragraph::new(Line::from(vec![
1108        Span::raw(" y proceed  "),
1109        Span::raw("n decline  "),
1110        Span::raw("q / Esc cancel  "),
1111        Span::raw("Ctrl+C abort"),
1112    ]))
1113    .style(Style::default().add_modifier(Modifier::DIM));
1114    frame.render_widget(keys, chunks[2]);
1115}
1116
1117// ---------------------------------------------------------------
1118// Tests (dispatcher only, render functions are exercised by
1119// hand against a real terminal)
1120// ---------------------------------------------------------------
1121
1122#[cfg(test)]
1123mod tests {
1124    use std::ffi::OsString;
1125    use std::fs;
1126    use std::path::{Path, PathBuf};
1127
1128    use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
1129    use tempfile::TempDir;
1130
1131    use super::super::model::{HiddenKind, Node, SelectionState, Tree};
1132    use super::{
1133        DispatchResult, GitignoreMatcher, TuiFocus, TuiState, dispatch, read_dir_children,
1134    };
1135
1136    fn press(code: KeyCode) -> Event {
1137        press_with(code, KeyModifiers::NONE)
1138    }
1139
1140    fn press_with(code: KeyCode, mods: KeyModifiers) -> Event {
1141        Event::Key(KeyEvent {
1142            code,
1143            modifiers: mods,
1144            kind: KeyEventKind::Press,
1145            state: KeyEventState::NONE,
1146        })
1147    }
1148
1149    fn release(code: KeyCode) -> Event {
1150        Event::Key(KeyEvent {
1151            code,
1152            modifiers: KeyModifiers::NONE,
1153            kind: KeyEventKind::Release,
1154            state: KeyEventState::NONE,
1155        })
1156    }
1157
1158    fn dir(name: &str, path: &str) -> Node {
1159        Node::new(OsString::from(name), PathBuf::from(path))
1160    }
1161
1162    fn loaded_dir(name: &str, path: &str, children: Vec<Node>) -> Node {
1163        let mut node = dir(name, path);
1164        node.children = children;
1165        node.children_loaded = true;
1166        node
1167    }
1168
1169    fn loaded_empty(name: &str, path: &str) -> Node {
1170        loaded_dir(name, path, Vec::new())
1171    }
1172
1173    fn build_state(root: Node) -> TuiState {
1174        let mut state = TuiState {
1175            tree: Tree::new(root),
1176            focus: TuiFocus::Tree,
1177            cwd_host: PathBuf::from("/host"),
1178            gitignore: GitignoreMatcher::new(Path::new("/host")),
1179            hidden_visible: false,
1180            status: None,
1181            help_visible: false,
1182            bell_pending: false,
1183        };
1184        // Make sure we are starting in a known clean state.
1185        let _ = dispatch(&release(KeyCode::Esc), &mut state);
1186        state.status = None;
1187        state.bell_pending = false;
1188        state
1189    }
1190
1191    // --- key release events are no-ops -------------------------
1192
1193    #[test]
1194    fn release_events_do_not_mutate_state() {
1195        let mut root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
1196        root.expanded = true;
1197        let mut state = build_state(root);
1198        let before_focus = state.focus;
1199        let result = dispatch(&release(KeyCode::Down), &mut state);
1200        assert_eq!(result, DispatchResult::Continue);
1201        assert_eq!(state.focus, before_focus);
1202    }
1203
1204    // --- Ctrl+C aborts regardless of overlay state -------------
1205
1206    #[test]
1207    fn ctrl_c_returns_abort() {
1208        let mut state = build_state(loaded_empty("root", "/"));
1209        let result = dispatch(
1210            &press_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
1211            &mut state,
1212        );
1213        assert_eq!(result, DispatchResult::Abort);
1214    }
1215
1216    #[test]
1217    fn ctrl_c_aborts_even_when_help_overlay_is_open() {
1218        let mut state = build_state(loaded_empty("root", "/"));
1219        dispatch(&press(KeyCode::Char('?')), &mut state);
1220        assert!(state.help_visible);
1221        let result = dispatch(
1222            &press_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
1223            &mut state,
1224        );
1225        assert_eq!(result, DispatchResult::Abort);
1226    }
1227
1228    // --- help overlay swallows the next keystroke --------------
1229
1230    #[test]
1231    fn help_overlay_closes_on_next_keystroke() {
1232        let mut state = build_state(loaded_empty("root", "/"));
1233        dispatch(&press(KeyCode::Char('?')), &mut state);
1234        assert!(state.help_visible);
1235        let result = dispatch(&press(KeyCode::Char('q')), &mut state);
1236        assert_eq!(result, DispatchResult::Continue);
1237        assert!(!state.help_visible);
1238    }
1239
1240    // --- navigation wraps via the commit button ----------------
1241
1242    #[test]
1243    fn down_at_last_visible_tree_row_moves_to_commit_button() {
1244        let mut root = loaded_dir(
1245            "root",
1246            "/",
1247            vec![loaded_empty("a", "/a"), loaded_empty("b", "/b")],
1248        );
1249        root.expanded = true;
1250        let mut state = build_state(root);
1251        // root -> a
1252        dispatch(&press(KeyCode::Down), &mut state);
1253        // a -> b
1254        dispatch(&press(KeyCode::Down), &mut state);
1255        assert_eq!(state.focus, TuiFocus::Tree);
1256        // b -> commit button
1257        dispatch(&press(KeyCode::Down), &mut state);
1258        assert_eq!(state.focus, TuiFocus::CommitButton);
1259        // commit button -> root (wrap)
1260        dispatch(&press(KeyCode::Down), &mut state);
1261        assert_eq!(state.focus, TuiFocus::Tree);
1262        assert_eq!(state.tree.focused().path, PathBuf::from("/"));
1263    }
1264
1265    #[test]
1266    fn up_from_root_wraps_to_commit_button() {
1267        let mut state = build_state(loaded_empty("root", "/"));
1268        // Root only, one visible row -> Up wraps to commit button.
1269        let result = dispatch(&press(KeyCode::Up), &mut state);
1270        assert_eq!(result, DispatchResult::Continue);
1271        assert_eq!(state.focus, TuiFocus::CommitButton);
1272    }
1273
1274    // --- Space cycle (AUX-003) ---------------------------------
1275
1276    #[test]
1277    fn space_advances_state_when_focused_on_tree_row() {
1278        let mut state = build_state(loaded_empty("root", "/"));
1279        dispatch(&press(KeyCode::Char(' ')), &mut state);
1280        assert_eq!(state.tree.focused().state, SelectionState::Project);
1281    }
1282
1283    #[test]
1284    fn space_on_commit_button_is_noop() {
1285        let mut state = build_state(loaded_empty("root", "/"));
1286        // Move focus to commit button.
1287        dispatch(&press(KeyCode::Down), &mut state);
1288        assert_eq!(state.focus, TuiFocus::CommitButton);
1289        dispatch(&press(KeyCode::Char(' ')), &mut state);
1290        assert_eq!(state.tree.focused().state, SelectionState::None);
1291    }
1292
1293    // --- Space on an unloaded directory requests the load and retry ---
1294
1295    #[test]
1296    fn space_on_unloaded_directory_at_project_state_requests_load_then_retry() {
1297        // children_loaded=false. From Project state the next
1298        // cycle step is ProjectsParent, which the model cannot
1299        // evaluate without knowing whether subdirs exist. The
1300        // dispatcher signals the driver to load and re-issue
1301        // the cycle so the user sees a single decisive
1302        // transition per Space press.
1303        let mut root = Node::new(OsString::from("root"), PathBuf::from("/"));
1304        root.state = SelectionState::Project;
1305        let mut state = build_state(root);
1306        let result = dispatch(&press(KeyCode::Char(' ')), &mut state);
1307        assert_eq!(result, DispatchResult::LoadChildrenThenRetryCycle);
1308        // State unchanged at this point; the driver does the
1309        // load + retry, which the dispatcher unit test cannot
1310        // exercise.
1311        assert_eq!(state.tree.focused().state, SelectionState::Project);
1312    }
1313
1314    // --- AUX-004 rule 1: empty-glob refusal --------------------
1315
1316    #[test]
1317    fn space_refusal_when_no_subdirs_skips_to_none_and_signals() {
1318        // On a leaf, ProjectsParent is unreachable: the cycle
1319        // moves past it to None, AND the refusal channels (bell
1320        // + status message) still fire so the user perceives
1321        // why ProjectsParent didn't take.
1322        let mut state = build_state(loaded_empty("root", "/"));
1323        dispatch(&press(KeyCode::Char(' ')), &mut state); // -> Project
1324        let result = dispatch(&press(KeyCode::Char(' ')), &mut state);
1325        assert_eq!(result, DispatchResult::Continue);
1326        assert_eq!(state.tree.focused().state, SelectionState::None);
1327        assert!(state.bell_pending, "AUX-004 refusal must ring the bell");
1328        let status = state.status.as_deref().unwrap_or("");
1329        assert!(
1330            status.contains("no subdirectories"),
1331            "status message should explain the refusal: {status}",
1332        );
1333    }
1334
1335    // --- AUX-004 rule 2: sub-tree lockout ----------------------
1336
1337    #[test]
1338    fn space_refusal_under_locked_subtree_sets_status_and_bell() {
1339        let mut src = loaded_empty("src", "/lib/src");
1340        let mut lib = loaded_dir("lib", "/lib", vec![src.clone()]);
1341        lib.state = SelectionState::ProjectsParent;
1342        lib.expanded = true;
1343        let mut root = loaded_dir("root", "/", vec![lib]);
1344        root.expanded = true;
1345        // Tweak src state to verify it stays put under lockout.
1346        src.state = SelectionState::None;
1347        let mut state = build_state(root);
1348        // Move focus down to lib/src (root -> lib -> lib/src).
1349        dispatch(&press(KeyCode::Down), &mut state);
1350        dispatch(&press(KeyCode::Down), &mut state);
1351        assert_eq!(state.tree.focused().path, PathBuf::from("/lib/src"));
1352        let result = dispatch(&press(KeyCode::Char(' ')), &mut state);
1353        assert_eq!(result, DispatchResult::Continue);
1354        assert!(state.bell_pending, "lockout refusal must ring the bell");
1355        let status = state.status.as_deref().unwrap_or("");
1356        assert!(
1357            status.contains("projects-parent"),
1358            "lockout status should explain the cause: {status}",
1359        );
1360        assert_eq!(state.tree.focused().state, SelectionState::None);
1361    }
1362
1363    // --- right-arrow expansion ---------------------------------
1364
1365    #[test]
1366    fn right_arrow_expands_focused_directory_with_subdirectories() {
1367        let root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
1368        let mut state = build_state(root);
1369        assert!(!state.tree.focused().expanded);
1370        let result = dispatch(&press(KeyCode::Right), &mut state);
1371        assert_eq!(result, DispatchResult::Continue);
1372        assert!(state.tree.focused().expanded);
1373    }
1374
1375    #[test]
1376    fn right_arrow_on_loaded_leaf_refuses_with_bell_and_status() {
1377        // No subdirectories: expansion would reveal nothing, so
1378        // it is refused. `expanded` MUST stay false; the user
1379        // gets the bell + status message.
1380        let mut state = build_state(loaded_empty("root", "/"));
1381        let result = dispatch(&press(KeyCode::Right), &mut state);
1382        assert_eq!(result, DispatchResult::Continue);
1383        assert!(!state.tree.focused().expanded);
1384        assert!(state.bell_pending);
1385        let status = state.status.as_deref().unwrap_or("");
1386        assert!(
1387            status.contains("no subdirectories"),
1388            "refusal status should explain the reason: {status}",
1389        );
1390    }
1391
1392    #[test]
1393    fn right_arrow_on_unloaded_directory_requests_load_then_retry() {
1394        let root = Node::new(OsString::from("root"), PathBuf::from("/"));
1395        // children_loaded is false by default.
1396        let mut state = build_state(root);
1397        let result = dispatch(&press(KeyCode::Right), &mut state);
1398        assert_eq!(result, DispatchResult::LoadChildrenThenRetryExpand);
1399        // expanded MUST stay false until the driver loads and
1400        // retries: the empty-vs-non-empty decision is not made
1401        // until after the load.
1402        assert!(!state.tree.focused().expanded);
1403    }
1404
1405    #[test]
1406    fn right_arrow_on_commit_button_is_noop() {
1407        let mut state = build_state(loaded_empty("root", "/"));
1408        dispatch(&press(KeyCode::Down), &mut state); // focus button
1409        let result = dispatch(&press(KeyCode::Right), &mut state);
1410        assert_eq!(result, DispatchResult::Continue);
1411        assert_eq!(state.focus, TuiFocus::CommitButton);
1412    }
1413
1414    // --- left-arrow collapse -----------------------------------
1415
1416    #[test]
1417    fn left_arrow_collapses_focused_directory() {
1418        let mut root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
1419        root.expanded = true;
1420        let mut state = build_state(root);
1421        assert!(state.tree.focused().expanded);
1422        let result = dispatch(&press(KeyCode::Left), &mut state);
1423        assert_eq!(result, DispatchResult::Continue);
1424        assert!(!state.tree.focused().expanded);
1425    }
1426
1427    // --- Enter always commits ----------------------------------
1428
1429    #[test]
1430    fn enter_on_tree_row_returns_exit_commit() {
1431        let mut state = build_state(loaded_empty("root", "/"));
1432        let result = dispatch(&press(KeyCode::Enter), &mut state);
1433        assert_eq!(result, DispatchResult::ExitCommit);
1434    }
1435
1436    #[test]
1437    fn enter_on_commit_button_returns_exit_commit() {
1438        let mut state = build_state(loaded_empty("root", "/"));
1439        dispatch(&press(KeyCode::Down), &mut state);
1440        assert_eq!(state.focus, TuiFocus::CommitButton);
1441        let result = dispatch(&press(KeyCode::Enter), &mut state);
1442        assert_eq!(result, DispatchResult::ExitCommit);
1443    }
1444
1445    // --- H toggle ----------------------------------------------
1446
1447    #[test]
1448    fn h_toggles_hidden_visibility_and_requests_reload() {
1449        let mut state = build_state(loaded_empty("root", "/"));
1450        assert!(!state.hidden_visible);
1451        let result = dispatch(&press(KeyCode::Char('h')), &mut state);
1452        assert!(state.hidden_visible);
1453        assert_eq!(result, DispatchResult::ReloadTreeForVisibility);
1454        assert!(state.status.as_deref().unwrap_or("").contains("shown"));
1455        let result = dispatch(&press(KeyCode::Char('h')), &mut state);
1456        assert!(!state.hidden_visible);
1457        assert_eq!(result, DispatchResult::ReloadTreeForVisibility);
1458        assert!(state.status.as_deref().unwrap_or("").contains("hidden"));
1459    }
1460
1461    #[test]
1462    fn capital_h_also_toggles_hidden() {
1463        let mut state = build_state(loaded_empty("root", "/"));
1464        let result = dispatch(&press(KeyCode::Char('H')), &mut state);
1465        assert!(state.hidden_visible);
1466        assert_eq!(result, DispatchResult::ReloadTreeForVisibility);
1467    }
1468
1469    // --- q / Esc cancel ----------------------------------------
1470
1471    #[test]
1472    fn q_returns_exit_cancel() {
1473        let mut state = build_state(loaded_empty("root", "/"));
1474        let result = dispatch(&press(KeyCode::Char('q')), &mut state);
1475        assert_eq!(result, DispatchResult::ExitCancel);
1476    }
1477
1478    #[test]
1479    fn esc_returns_exit_cancel() {
1480        let mut state = build_state(loaded_empty("root", "/"));
1481        let result = dispatch(&press(KeyCode::Esc), &mut state);
1482        assert_eq!(result, DispatchResult::ExitCancel);
1483    }
1484
1485    // --- transient state cleared on every dispatch -------------
1486
1487    #[test]
1488    fn status_cleared_on_each_dispatch_unless_set_by_handler() {
1489        let mut state = build_state(loaded_empty("root", "/"));
1490        // Set status via H toggle.
1491        dispatch(&press(KeyCode::Char('h')), &mut state);
1492        assert!(state.status.is_some());
1493        // An unrelated keystroke (Up) clears it.
1494        dispatch(&press(KeyCode::Up), &mut state);
1495        assert!(state.status.is_none());
1496    }
1497
1498    // --- HiddenKind classification at read_dir time ------------
1499
1500    fn names_of(nodes: &[Node]) -> Vec<String> {
1501        nodes
1502            .iter()
1503            .map(|n| n.name.to_string_lossy().into_owned())
1504            .collect()
1505    }
1506
1507    fn kind_of(nodes: &[Node], name: &str) -> Option<HiddenKind> {
1508        nodes
1509            .iter()
1510            .find(|n| n.name.as_os_str() == std::ffi::OsStr::new(name))
1511            .map(|n| n.hidden_kind)
1512    }
1513
1514    fn write_gitignore(dir: &Path, body: &str) {
1515        fs::write(dir.join(".gitignore"), body).unwrap();
1516    }
1517
1518    #[test]
1519    fn read_dir_children_skips_dotfile_and_gitignored_when_show_hidden_false() {
1520        let tmp = TempDir::new().unwrap();
1521        fs::create_dir(tmp.path().join("src")).unwrap();
1522        fs::create_dir(tmp.path().join(".git")).unwrap();
1523        fs::create_dir(tmp.path().join("node_modules")).unwrap();
1524        write_gitignore(tmp.path(), "node_modules/\n");
1525        let matcher = GitignoreMatcher::new(tmp.path());
1526        let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, false).unwrap();
1527        assert_eq!(names_of(&children), vec!["src".to_owned()]);
1528    }
1529
1530    #[test]
1531    fn read_dir_children_includes_hidden_with_kinds_when_show_hidden_true() {
1532        let tmp = TempDir::new().unwrap();
1533        fs::create_dir(tmp.path().join("src")).unwrap();
1534        fs::create_dir(tmp.path().join(".git")).unwrap();
1535        fs::create_dir(tmp.path().join("node_modules")).unwrap();
1536        write_gitignore(tmp.path(), "node_modules/\n");
1537        let matcher = GitignoreMatcher::new(tmp.path());
1538        let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, true).unwrap();
1539        // All three present, alphabetically sorted by basename.
1540        assert_eq!(
1541            names_of(&children),
1542            vec![
1543                ".git".to_owned(),
1544                "node_modules".to_owned(),
1545                "src".to_owned()
1546            ],
1547        );
1548        assert_eq!(kind_of(&children, ".git"), Some(HiddenKind::Dotfile));
1549        assert_eq!(
1550            kind_of(&children, "node_modules"),
1551            Some(HiddenKind::Gitignored),
1552        );
1553        assert_eq!(kind_of(&children, "src"), Some(HiddenKind::Visible));
1554    }
1555
1556    #[test]
1557    fn read_dir_children_classifies_dotfile_dir_that_is_also_gitignored_as_gitignored() {
1558        // Gitignored wins when both apply, so the warning channel
1559        // reaches the user.
1560        let tmp = TempDir::new().unwrap();
1561        fs::create_dir(tmp.path().join(".cache")).unwrap();
1562        write_gitignore(tmp.path(), ".cache/\n");
1563        let matcher = GitignoreMatcher::new(tmp.path());
1564        let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, true).unwrap();
1565        assert_eq!(kind_of(&children, ".cache"), Some(HiddenKind::Gitignored));
1566    }
1567
1568    #[test]
1569    fn read_dir_children_with_no_gitignore_file_still_skips_dotfiles() {
1570        let tmp = TempDir::new().unwrap();
1571        fs::create_dir(tmp.path().join("lib")).unwrap();
1572        fs::create_dir(tmp.path().join(".hidden")).unwrap();
1573        // No .gitignore on disk; the matcher degrades to "match
1574        // nothing" via Gitignore::empty.
1575        let matcher = GitignoreMatcher::new(tmp.path());
1576        let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, false).unwrap();
1577        assert_eq!(names_of(&children), vec!["lib".to_owned()]);
1578    }
1579
1580    #[test]
1581    fn read_dir_children_ignores_regular_files() {
1582        // Files are never offered to the user: only directories
1583        // carry selection state.
1584        let tmp = TempDir::new().unwrap();
1585        fs::create_dir(tmp.path().join("lib")).unwrap();
1586        fs::write(tmp.path().join("README.md"), "").unwrap();
1587        let matcher = GitignoreMatcher::new(tmp.path());
1588        let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, true).unwrap();
1589        assert_eq!(names_of(&children), vec!["lib".to_owned()]);
1590    }
1591}