Skip to main content

haz_cli/commands/init/tui/
mod.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
32pub(super) mod dispatch;
33pub(super) mod driver;
34pub(super) mod gitignore;
35pub(super) mod render;
36pub(super) mod tree_io;
37
38use std::ffi::OsString;
39use std::path::{Path, PathBuf};
40
41use miette::Result;
42
43use super::commit::CommitPlan;
44use super::model::{Node, Tree};
45
46use crate::commands::init::tui::gitignore::GitignoreMatcher;
47use crate::commands::init::tui::tree_io::read_dir_children;
48
49pub use crate::commands::init::tui::driver::run_interactive_scaffold;
50
51/// What the dispatcher's caller (the driver loop) should do next.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum DispatchResult {
54    /// Stay in the loop: redraw, then read another event.
55    Continue,
56    /// The driver reads the focused node's children and then
57    /// retries the expand in the same keystroke. Used when Right
58    /// was pressed on a directory whose children were not yet
59    /// loaded: without the retry, the keypress would silently
60    /// flip `expanded` (or do nothing for an empty directory)
61    /// without giving the user the AUX-004-style refusal signal
62    /// the empty case warrants.
63    LoadChildrenThenRetryExpand,
64    /// The driver reads the focused node's children and then
65    /// retries `cycle_focused` in the same keystroke. Used when
66    /// Space was pressed on a node whose children were not yet
67    /// loaded: without the retry the keypress would only do a
68    /// silent `read_dir` and the user would see "nothing happen".
69    LoadChildrenThenRetryCycle,
70    /// The user toggled `hidden_visible` and the tree's
71    /// currently-loaded subtrees must be re-read against the new
72    /// filter so the visual state matches the flag. The driver
73    /// walks every already-loaded directory, re-reads its
74    /// children with the new visibility, preserves
75    /// expanded/state by basename, then re-anchors focus.
76    ReloadTreeForVisibility,
77    /// Leave the tree screen and move to the commit pass.
78    ExitCommit,
79    /// User declined the scaffold (`q` / `Esc`). Exit cleanly.
80    ExitCancel,
81    /// User aborted (`Ctrl+C`). Exit with the signal-interrupt
82    /// code.
83    Abort,
84}
85
86/// Final outcome of the scaffold flow as far as the executor is
87/// concerned.
88#[derive(Debug)]
89pub enum ScaffoldOutcome {
90    /// User confirmed the plan. Apply it.
91    Confirmed(CommitPlan),
92    /// User declined either the tree screen (`q`) or the
93    /// confirmation prompt (`n`). Exit 0 with no filesystem
94    /// changes per `AUX-005` step 4 and `AUX-008`.
95    Declined,
96    /// User aborted via `Ctrl+C`. Exit 130 per `EXEC-021`.
97    Aborted,
98}
99
100/// Which on-screen element currently carries the focus highlight.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum TuiFocus {
103    /// A tree row, identified by the underlying [`Tree`]'s focus
104    /// path.
105    Tree,
106    /// The synthetic `[ Commit selection ]` button below the tree.
107    CommitButton,
108}
109
110/// The full mutable TUI state. Held by the driver, mutated by
111/// `dispatch`.
112#[derive(Debug)]
113pub struct TuiState {
114    /// The interactive tree per [`super::model`].
115    pub tree: Tree,
116    /// Which on-screen element gets the focus highlight.
117    pub focus: TuiFocus,
118    /// Host filesystem path of the cwd. Displayed in the header;
119    /// used by the commit-pass plumbing when leaving the tree
120    /// screen.
121    pub cwd_host: PathBuf,
122    /// Gitignore matcher consulted at directory-read time to
123    /// classify entries as `HiddenKind::Gitignored`.
124    pub gitignore: GitignoreMatcher,
125    /// Whether dotfile-prefixed and gitignored sibling
126    /// directories are visible in the tree. Defaults to `false`.
127    /// Toggled by `H`; the toggle returns
128    /// [`DispatchResult::ReloadTreeForVisibility`] so the driver
129    /// can re-read every already-loaded directory against the new
130    /// flag (state and expanded-ness are preserved by basename).
131    pub hidden_visible: bool,
132    /// Transient status / refusal message rendered above the
133    /// keybinding hint. Cleared on every new keystroke before
134    /// dispatch.
135    pub status: Option<String>,
136    /// Whether the help overlay is currently open. Any keystroke
137    /// dismisses it.
138    pub help_visible: bool,
139    /// Whether the driver should ring the terminal bell on the
140    /// next render. Cleared after the bell fires.
141    pub bell_pending: bool,
142}
143
144impl TuiState {
145    /// Construct an initial state rooted at `cwd_host`. Loads the
146    /// cwd's direct subdirectories (filtered per [`Self::hidden_visible`])
147    /// so the user has something to navigate immediately.
148    fn new(cwd_host: PathBuf) -> Result<Self> {
149        let gitignore = GitignoreMatcher::new(&cwd_host);
150        let children = read_dir_children(&cwd_host, Path::new("/"), &gitignore, false)?;
151        let mut root = Node::new(OsString::from("/"), PathBuf::from("/"));
152        root.children = children;
153        root.children_loaded = true;
154        root.expanded = true;
155        Ok(Self {
156            tree: Tree::new(root),
157            focus: TuiFocus::Tree,
158            cwd_host,
159            gitignore,
160            hidden_visible: false,
161            status: None,
162            help_visible: false,
163            bell_pending: false,
164        })
165    }
166}