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}