use std::ffi::OsString;
use std::fs;
use std::io::{self, Stdout, Write};
use std::path::{Path, PathBuf};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use miette::{IntoDiagnostic, Result, WrapErr, miette};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::{Frame, TerminalOptions, Viewport};
use super::commit::{CommitPlan, plan_commit};
use super::model::{
CycleOutcome, ExpandOutcome, HiddenKind, Node, SelectionState, Tree, VisibleNode,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DispatchResult {
Continue,
LoadChildrenThenRetryExpand,
LoadChildrenThenRetryCycle,
ReloadTreeForVisibility,
ExitCommit,
ExitCancel,
Abort,
}
#[derive(Debug)]
pub enum ScaffoldOutcome {
Confirmed(CommitPlan),
Declined,
Aborted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiFocus {
Tree,
CommitButton,
}
#[derive(Debug, Clone)]
pub struct GitignoreMatcher {
local: Gitignore,
global: Gitignore,
}
impl GitignoreMatcher {
#[must_use]
pub fn new(cwd_host: &Path) -> Self {
let mut builder = GitignoreBuilder::new(cwd_host);
let _ = builder.add(cwd_host.join(".gitignore"));
let local = builder.build().unwrap_or_else(|_| Gitignore::empty());
let (global, _) = Gitignore::global();
Self { local, global }
}
#[must_use]
pub fn is_ignored(&self, host_path: &Path, is_dir: bool) -> bool {
self.local.matched(host_path, is_dir).is_ignore()
|| self.global.matched(host_path, is_dir).is_ignore()
}
}
#[derive(Debug)]
pub struct TuiState {
pub tree: Tree,
pub focus: TuiFocus,
pub cwd_host: PathBuf,
pub gitignore: GitignoreMatcher,
pub hidden_visible: bool,
pub status: Option<String>,
pub help_visible: bool,
pub bell_pending: bool,
}
impl TuiState {
fn new(cwd_host: PathBuf) -> Result<Self> {
let gitignore = GitignoreMatcher::new(&cwd_host);
let children = read_dir_children(&cwd_host, Path::new("/"), &gitignore, false)?;
let mut root = Node::new(OsString::from("/"), PathBuf::from("/"));
root.children = children;
root.children_loaded = true;
root.expanded = true;
Ok(Self {
tree: Tree::new(root),
focus: TuiFocus::Tree,
cwd_host,
gitignore,
hidden_visible: false,
status: None,
help_visible: false,
bell_pending: false,
})
}
}
pub fn dispatch(event: &Event, state: &mut TuiState) -> DispatchResult {
state.status = None;
state.bell_pending = false;
let Event::Key(key) = event else {
return DispatchResult::Continue;
};
if key.kind != KeyEventKind::Press {
return DispatchResult::Continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c' | 'C'))
{
return DispatchResult::Abort;
}
if state.help_visible {
state.help_visible = false;
return DispatchResult::Continue;
}
match key.code {
KeyCode::Up => {
move_focus(state, FocusDirection::Prev);
DispatchResult::Continue
}
KeyCode::Down => {
move_focus(state, FocusDirection::Next);
DispatchResult::Continue
}
KeyCode::Right => expand_current(state),
KeyCode::Left => {
collapse_current(state);
DispatchResult::Continue
}
KeyCode::Char(' ') => cycle_current(state),
KeyCode::Enter => DispatchResult::ExitCommit,
KeyCode::Char('h' | 'H') => {
state.hidden_visible = !state.hidden_visible;
state.status = Some(if state.hidden_visible {
"hidden directories shown".to_string()
} else {
"hidden directories hidden".to_string()
});
DispatchResult::ReloadTreeForVisibility
}
KeyCode::Char('?') => {
state.help_visible = true;
DispatchResult::Continue
}
KeyCode::Char('q' | 'Q') | KeyCode::Esc => DispatchResult::ExitCancel,
_ => DispatchResult::Continue,
}
}
#[derive(Clone, Copy)]
enum FocusDirection {
Next,
Prev,
}
fn move_focus(state: &mut TuiState, dir: FocusDirection) {
let visible = state.tree.visible_nodes().len();
if visible == 0 {
return;
}
let total = visible + 1; let current = match state.focus {
TuiFocus::Tree => state.tree.focused_visible_index(),
TuiFocus::CommitButton => visible,
};
let next = match dir {
FocusDirection::Next => (current + 1) % total,
FocusDirection::Prev => {
if current == 0 {
total - 1
} else {
current - 1
}
}
};
if next == visible {
state.focus = TuiFocus::CommitButton;
} else {
state.focus = TuiFocus::Tree;
state.tree.focus_at_visible_index(next);
}
}
fn expand_current(state: &mut TuiState) -> DispatchResult {
if state.focus != TuiFocus::Tree {
return DispatchResult::Continue;
}
let outcome = state.tree.try_expand_focused();
if matches!(outcome, ExpandOutcome::NeedsChildrenLoaded) {
return DispatchResult::LoadChildrenThenRetryExpand;
}
apply_expand_outcome_signal(state, outcome);
DispatchResult::Continue
}
fn apply_expand_outcome_signal(state: &mut TuiState, outcome: ExpandOutcome) {
match outcome {
ExpandOutcome::Expanded
| ExpandOutcome::AlreadyExpanded
| ExpandOutcome::NeedsChildrenLoaded => {}
ExpandOutcome::RefusedNoSubdirs => {
let path_display = display_workspace_path(&state.tree.focused().path);
state.bell_pending = true;
state.status = Some(format!("cannot expand `{path_display}`: no subdirectories"));
}
}
}
fn collapse_current(state: &mut TuiState) {
if state.focus != TuiFocus::Tree {
return;
}
state.tree.collapse_focused();
}
fn cycle_current(state: &mut TuiState) -> DispatchResult {
if state.focus != TuiFocus::Tree {
return DispatchResult::Continue;
}
let outcome = state.tree.cycle_focused();
if matches!(outcome, CycleOutcome::NeedsChildrenLoaded) {
return DispatchResult::LoadChildrenThenRetryCycle;
}
apply_cycle_outcome_signal(state, outcome);
DispatchResult::Continue
}
fn apply_cycle_outcome_signal(state: &mut TuiState, outcome: CycleOutcome) {
match outcome {
CycleOutcome::Advanced(_) | CycleOutcome::NeedsChildrenLoaded => {}
CycleOutcome::RefusedEmptyGlob => {
let path_display = display_workspace_path(&state.tree.focused().path);
state.bell_pending = true;
state.status = Some(format!(
"skipped projects-parent for `{path_display}`: no subdirectories",
));
}
CycleOutcome::RefusedSubtreeLock => {
let path_display = display_workspace_path(&state.tree.focused().path);
state.bell_pending = true;
state.status = Some(format!(
"cannot cycle `{path_display}`: under a projects-parent directory",
));
}
}
}
fn display_workspace_path(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
pub fn run_interactive_scaffold(cwd_host: &Path) -> Result<ScaffoldOutcome> {
let mut state = TuiState::new(cwd_host.to_path_buf())?;
let mut terminal = enter_terminal()?;
let mut guard = TerminalGuard::armed();
let outcome = drive_tree_screen(&mut terminal, &mut state);
let outcome = match outcome {
Ok(DriverExit::Commit) => {
let plan = plan_commit(&state.tree, cwd_host)
.map_err(|err| miette!("{err}"))
.wrap_err("haz init: AUX-005 commit-plan construction failed")?;
drive_confirmation_screen(&mut terminal, &plan)?
}
Ok(DriverExit::Cancel) => ScaffoldOutcome::Declined,
Ok(DriverExit::Abort) => ScaffoldOutcome::Aborted,
Err(e) => {
guard.disarm();
restore_terminal();
return Err(e);
}
};
guard.disarm();
restore_terminal();
Ok(outcome)
}
#[derive(Debug)]
enum DriverExit {
Commit,
Cancel,
Abort,
}
fn drive_tree_screen(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
state: &mut TuiState,
) -> Result<DriverExit> {
loop {
terminal
.draw(|frame| render_tree_screen(frame, state))
.into_diagnostic()
.wrap_err("haz init: failed to draw tree screen")?;
if state.bell_pending {
ring_bell();
state.bell_pending = false;
}
let event = read_event_blocking()?;
match dispatch(&event, state) {
DispatchResult::Continue => {}
DispatchResult::LoadChildrenThenRetryExpand => {
load_children_for_focused(state)?;
let outcome = state.tree.try_expand_focused();
apply_expand_outcome_signal(state, outcome);
}
DispatchResult::LoadChildrenThenRetryCycle => {
load_children_for_focused(state)?;
let outcome = state.tree.cycle_focused();
apply_cycle_outcome_signal(state, outcome);
}
DispatchResult::ReloadTreeForVisibility => {
reload_tree_for_visibility(state)?;
}
DispatchResult::ExitCommit => return Ok(DriverExit::Commit),
DispatchResult::ExitCancel => return Ok(DriverExit::Cancel),
DispatchResult::Abort => return Ok(DriverExit::Abort),
}
}
}
fn drive_confirmation_screen(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
plan: &CommitPlan,
) -> Result<ScaffoldOutcome> {
loop {
terminal
.draw(|frame| render_confirmation_screen(frame, plan))
.into_diagnostic()
.wrap_err("haz init: failed to draw confirmation screen")?;
let event = read_event_blocking()?;
let Event::Key(key) = event else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c' | 'C'))
{
return Ok(ScaffoldOutcome::Aborted);
}
match key.code {
KeyCode::Char('y' | 'Y') => {
return Ok(ScaffoldOutcome::Confirmed(plan.clone()));
}
KeyCode::Char('n' | 'N' | 'q' | 'Q') | KeyCode::Esc => {
return Ok(ScaffoldOutcome::Declined);
}
_ => {}
}
}
}
fn read_event_blocking() -> Result<Event> {
event::read()
.into_diagnostic()
.wrap_err("haz init: failed to read terminal event")
}
fn ring_bell() {
let _ = io::stderr().write_all(b"\x07");
let _ = io::stderr().flush();
}
fn load_children_for_focused(state: &mut TuiState) -> Result<()> {
let focused_path = state.tree.focused().path.clone();
let host_path = host_join(&state.cwd_host, &focused_path);
let children = read_dir_children(
&host_path,
&focused_path,
&state.gitignore,
state.hidden_visible,
)?;
state.tree.populate_focused_children(children);
Ok(())
}
fn reload_tree_for_visibility(state: &mut TuiState) -> Result<()> {
let focused_workspace_path = state.tree.focused().path.clone();
let cwd_host = state.cwd_host.clone();
let show_hidden = state.hidden_visible;
reload_loaded_subtree(
state.tree.root_mut(),
&cwd_host,
&state.gitignore,
show_hidden,
)?;
let new_path =
locate_visible_path(state.tree.root(), &focused_workspace_path).unwrap_or_default();
state.tree.set_focus_path(new_path);
Ok(())
}
fn reload_loaded_subtree(
node: &mut Node,
cwd_host: &Path,
gitignore: &GitignoreMatcher,
show_hidden: bool,
) -> Result<()> {
if !node.children_loaded {
return Ok(());
}
let host_path = host_join(cwd_host, &node.path);
let mut new_children = read_dir_children(&host_path, &node.path, gitignore, show_hidden)?;
let mut old_by_name: std::collections::HashMap<std::ffi::OsString, Node> =
std::collections::HashMap::new();
for child in std::mem::take(&mut node.children) {
old_by_name.insert(child.name.clone(), child);
}
for new_child in &mut new_children {
if let Some(old) = old_by_name.remove(&new_child.name) {
new_child.state = old.state;
new_child.expanded = old.expanded;
new_child.children_loaded = old.children_loaded;
new_child.children = old.children;
reload_loaded_subtree(new_child, cwd_host, gitignore, show_hidden)?;
}
}
node.children = new_children;
Ok(())
}
fn locate_visible_path(root: &Node, target: &Path) -> Option<Vec<usize>> {
let mut path: Vec<usize> = Vec::new();
if locate_visible_path_rec(root, target, &mut path) {
Some(path)
} else {
None
}
}
fn locate_visible_path_rec(node: &Node, target: &Path, path: &mut Vec<usize>) -> bool {
if node.path == target {
return true;
}
if !node.expanded {
return false;
}
for (i, child) in node.children.iter().enumerate() {
path.push(i);
if locate_visible_path_rec(child, target, path) {
return true;
}
path.pop();
}
false
}
fn read_dir_children(
host_dir: &Path,
workspace_dir: &Path,
gitignore: &GitignoreMatcher,
show_hidden: bool,
) -> Result<Vec<Node>> {
let mut entries: Vec<Node> = Vec::new();
let read = fs::read_dir(host_dir)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read directory `{}`", host_dir.display()))?;
for entry in read {
let entry = entry
.into_diagnostic()
.wrap_err_with(|| format!("failed to read entry under `{}`", host_dir.display()))?;
let file_type = entry
.file_type()
.into_diagnostic()
.wrap_err_with(|| format!("failed to stat `{}`", entry.path().display()))?;
if !file_type.is_dir() {
continue;
}
let name = entry.file_name();
let kind = classify_hidden(&name, &entry.path(), gitignore);
if kind != HiddenKind::Visible && !show_hidden {
continue;
}
let mut child_workspace = workspace_dir.to_path_buf();
child_workspace.push(&name);
let mut node = Node::new(name, child_workspace);
node.hidden_kind = kind;
entries.push(node);
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
fn classify_hidden(name: &OsString, host_path: &Path, gitignore: &GitignoreMatcher) -> HiddenKind {
if gitignore.is_ignored(host_path, true) {
return HiddenKind::Gitignored;
}
if is_hidden_name(name) {
return HiddenKind::Dotfile;
}
HiddenKind::Visible
}
fn is_hidden_name(name: &OsString) -> bool {
let bytes = name.as_encoded_bytes();
bytes.first().copied() == Some(b'.')
}
fn host_join(cwd_host_root: &Path, workspace_absolute: &Path) -> PathBuf {
let stripped = workspace_absolute
.strip_prefix("/")
.unwrap_or(workspace_absolute);
if stripped.as_os_str().is_empty() {
cwd_host_root.to_path_buf()
} else {
cwd_host_root.join(stripped)
}
}
fn enter_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()
.into_diagnostic()
.wrap_err("haz init: failed to enable terminal raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)
.into_diagnostic()
.wrap_err("haz init: failed to enter the alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Fullscreen,
},
)
.into_diagnostic()
.wrap_err("haz init: failed to initialise the ratatui terminal")?;
Ok(terminal)
}
fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
}
struct TerminalGuard {
armed: bool,
}
impl TerminalGuard {
fn armed() -> Self {
Self { armed: true }
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
if self.armed {
restore_terminal();
}
}
}
const COLOR_PROJECT: Color = Color::Green;
const COLOR_PROJECTS_PARENT: Color = Color::Cyan;
const CHROME_ABOVE_TREE: u16 = 2;
const CHROME_BELOW_TREE: u16 = 4;
fn render_tree_screen(frame: &mut Frame<'_>, state: &TuiState) {
let area = frame.area();
let visible_count = u16::try_from(state.tree.visible_nodes().len()).unwrap_or(u16::MAX);
let available_for_tree = area
.height
.saturating_sub(CHROME_ABOVE_TREE + CHROME_BELOW_TREE);
let tree_height = visible_count.min(available_for_tree).max(1);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(tree_height), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(area);
render_title(frame, chunks[0]);
render_cwd(frame, chunks[1], &state.cwd_host);
render_tree(frame, chunks[2], state);
render_commit_button(frame, chunks[4], state);
render_status(frame, chunks[5], state.status.as_deref());
render_keys(frame, chunks[6]);
if state.help_visible {
render_help_overlay(frame, area);
}
}
fn render_title(frame: &mut Frame<'_>, area: Rect) {
let para = Paragraph::new(Line::from(Span::styled(
" haz init \u{2014} select directories to register as projects ",
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
)));
frame.render_widget(para, area);
}
fn render_cwd(frame: &mut Frame<'_>, area: Rect, cwd: &Path) {
let text = format!(" cwd: {}", cwd.display());
let para = Paragraph::new(Line::from(Span::raw(text)));
frame.render_widget(para, area);
}
fn render_tree(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
let visible = state.tree.visible_nodes();
let width = area.width as usize;
let items: Vec<ListItem<'static>> = visible
.iter()
.map(|vn| ListItem::new(build_row_line(vn, width)))
.collect();
let list = List::new(items).highlight_style(
Style::default()
.add_modifier(Modifier::REVERSED)
.add_modifier(Modifier::BOLD),
);
let mut list_state = ListState::default();
if state.focus == TuiFocus::Tree {
list_state.select(Some(state.tree.focused_visible_index()));
}
frame.render_stateful_widget(list, area, &mut list_state);
}
const GAP_TO_RIGHT_LABEL: usize = 4;
fn build_row_line(vn: &VisibleNode<'_>, area_width: usize) -> Line<'static> {
let (state_glyph, state_glyph_style, state_text, state_text_style) = state_decor(vn.node.state);
let expand_glyph = if vn.node.expanded {
"\u{25BC}" } else {
"\u{25B6}" };
let indent: String = " ".repeat(vn.depth * 2);
let name = vn.node.name.to_string_lossy().into_owned();
let left_text = format!(" {state_glyph} {indent}{expand_glyph} {name}");
let left_len = left_text.chars().count();
let right_text = state_text.to_string();
let right_len = right_text.chars().count();
let has_room_for_right_label =
!right_text.is_empty() && left_len + GAP_TO_RIGHT_LABEL + right_len <= area_width;
let pad_width = GAP_TO_RIGHT_LABEL;
let mut row_modifier = Modifier::empty();
if vn.locked {
row_modifier |= Modifier::DIM;
}
let hidden_fg = match vn.node.hidden_kind {
HiddenKind::Visible => None,
HiddenKind::Dotfile => {
row_modifier |= Modifier::DIM;
None
}
HiddenKind::Gitignored => {
row_modifier |= Modifier::DIM;
Some(Color::Rgb(204, 102, 0))
}
};
let glyph_style = state_glyph_style.add_modifier(row_modifier);
let plain_style = match hidden_fg {
Some(fg) => Style::default().fg(fg).add_modifier(row_modifier),
None => Style::default().add_modifier(row_modifier),
};
let italic_style = state_text_style.add_modifier(row_modifier);
let mut spans = vec![
Span::styled(" ", plain_style),
Span::styled(state_glyph.to_string(), glyph_style),
Span::styled(format!(" {indent}{expand_glyph} {name}"), plain_style),
];
if has_room_for_right_label {
spans.push(Span::styled(" ".repeat(pad_width), plain_style));
spans.push(Span::styled(right_text, italic_style));
}
Line::from(spans)
}
fn state_decor(state: SelectionState) -> (&'static str, Style, &'static str, Style) {
match state {
SelectionState::None => (
"\u{25A1}", Style::default(),
"",
Style::default(),
),
SelectionState::Project => (
"\u{25A0}", Style::default()
.fg(COLOR_PROJECT)
.add_modifier(Modifier::BOLD),
"(project)",
Style::default()
.fg(COLOR_PROJECT)
.add_modifier(Modifier::ITALIC),
),
SelectionState::ProjectsParent => (
"\u{25A3}", Style::default()
.fg(COLOR_PROJECTS_PARENT)
.add_modifier(Modifier::BOLD),
"(projects parent)",
Style::default()
.fg(COLOR_PROJECTS_PARENT)
.add_modifier(Modifier::ITALIC),
),
}
}
fn render_commit_button(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
let count = state.tree.project_root_set().len();
let label = format!(
" [ Commit selection ({count} project{plural}) ]",
count = count,
plural = if count == 1 { "" } else { "s" },
);
let style = if state.focus == TuiFocus::CommitButton {
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::BOLD)
};
let para = Paragraph::new(Line::from(Span::styled(label, style)));
frame.render_widget(para, area);
}
fn render_status(frame: &mut Frame<'_>, area: Rect, status: Option<&str>) {
let text = status.unwrap_or("");
let para = Paragraph::new(Line::from(Span::styled(
format!(" {text}"),
Style::default().fg(Color::Yellow),
)));
frame.render_widget(para, area);
}
fn render_keys(frame: &mut Frame<'_>, area: Rect) {
let line = Line::from(vec![
Span::raw(" \u{2191}\u{2193} move "),
Span::raw("\u{2192} expand "),
Span::raw("\u{2190} collapse "),
Span::raw("Space cycle selection "),
Span::raw("Enter commit "),
Span::raw("H hidden "),
Span::raw("q cancel "),
Span::raw("? help"),
])
.style(Style::default().add_modifier(Modifier::DIM));
let para = Paragraph::new(line);
frame.render_widget(para, area);
}
fn render_help_overlay(frame: &mut Frame<'_>, area: Rect) {
let inner = centred_rect(area, 60, 18);
let block = Block::bordered().title(" help ").bold();
let body = vec![
Line::from("Navigation"),
Line::from(" \u{2191} / \u{2193} move focus through visible rows"),
Line::from(" \u{2192} expand focused directory"),
Line::from(" \u{2190} collapse focused directory"),
Line::from(""),
Line::from("Selection (AUX-003 / AUX-004)"),
Line::from(" Space cycle: none -> project -> projects-parent"),
Line::from(""),
Line::from("Commit"),
Line::from(" Enter leave selection, show confirmation"),
Line::from(""),
Line::from("Other"),
Line::from(" H toggle hidden directories"),
Line::from(" ? toggle this overlay"),
Line::from(" q / Esc cancel (exit with no changes)"),
Line::from(" Ctrl+C abort"),
];
let para = Paragraph::new(body).block(block).wrap(Wrap { trim: false });
frame.render_widget(Clear, inner);
frame.render_widget(para, inner);
}
fn centred_rect(area: Rect, width: u16, height: u16) -> Rect {
let w = width.min(area.width.saturating_sub(4));
let h = height.min(area.height.saturating_sub(2));
let x = area.x + area.width.saturating_sub(w) / 2;
let y = area.y + area.height.saturating_sub(h) / 2;
Rect {
x,
y,
width: w,
height: h,
}
}
fn render_confirmation_screen(frame: &mut Frame<'_>, plan: &CommitPlan) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
let title = Paragraph::new(Line::from(Span::styled(
" haz init \u{2014} confirm scaffold ",
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
)));
frame.render_widget(title, chunks[0]);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" will write .haz/workspace.yml:",
Style::default().add_modifier(Modifier::BOLD),
)));
for line in plan.workspace_yml_body.lines() {
lines.push(Line::from(format!(" {line}")));
}
lines.push(Line::from(""));
if plan.project_ymls.is_empty() {
lines.push(Line::from(Span::styled(
" will scaffold no haz.yml files (empty projects: list).",
Style::default().add_modifier(Modifier::ITALIC),
)));
} else {
lines.push(Line::from(Span::styled(
" will scaffold haz.yml:",
Style::default().add_modifier(Modifier::BOLD),
)));
for scaffold in &plan.project_ymls {
lines.push(Line::from(format!(
" {root} name: {name}",
root = scaffold.project_root.display(),
name = scaffold.sanitised_name,
)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Proceed? y = write, n / q = decline",
Style::default().add_modifier(Modifier::BOLD),
)));
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
frame.render_widget(para, chunks[1]);
let keys = Paragraph::new(Line::from(vec![
Span::raw(" y proceed "),
Span::raw("n decline "),
Span::raw("q / Esc cancel "),
Span::raw("Ctrl+C abort"),
]))
.style(Style::default().add_modifier(Modifier::DIM));
frame.render_widget(keys, chunks[2]);
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use tempfile::TempDir;
use super::super::model::{HiddenKind, Node, SelectionState, Tree};
use super::{
DispatchResult, GitignoreMatcher, TuiFocus, TuiState, dispatch, read_dir_children,
};
fn press(code: KeyCode) -> Event {
press_with(code, KeyModifiers::NONE)
}
fn press_with(code: KeyCode, mods: KeyModifiers) -> Event {
Event::Key(KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
})
}
fn release(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
})
}
fn dir(name: &str, path: &str) -> Node {
Node::new(OsString::from(name), PathBuf::from(path))
}
fn loaded_dir(name: &str, path: &str, children: Vec<Node>) -> Node {
let mut node = dir(name, path);
node.children = children;
node.children_loaded = true;
node
}
fn loaded_empty(name: &str, path: &str) -> Node {
loaded_dir(name, path, Vec::new())
}
fn build_state(root: Node) -> TuiState {
let mut state = TuiState {
tree: Tree::new(root),
focus: TuiFocus::Tree,
cwd_host: PathBuf::from("/host"),
gitignore: GitignoreMatcher::new(Path::new("/host")),
hidden_visible: false,
status: None,
help_visible: false,
bell_pending: false,
};
let _ = dispatch(&release(KeyCode::Esc), &mut state);
state.status = None;
state.bell_pending = false;
state
}
#[test]
fn release_events_do_not_mutate_state() {
let mut root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
root.expanded = true;
let mut state = build_state(root);
let before_focus = state.focus;
let result = dispatch(&release(KeyCode::Down), &mut state);
assert_eq!(result, DispatchResult::Continue);
assert_eq!(state.focus, before_focus);
}
#[test]
fn ctrl_c_returns_abort() {
let mut state = build_state(loaded_empty("root", "/"));
let result = dispatch(
&press_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut state,
);
assert_eq!(result, DispatchResult::Abort);
}
#[test]
fn ctrl_c_aborts_even_when_help_overlay_is_open() {
let mut state = build_state(loaded_empty("root", "/"));
dispatch(&press(KeyCode::Char('?')), &mut state);
assert!(state.help_visible);
let result = dispatch(
&press_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut state,
);
assert_eq!(result, DispatchResult::Abort);
}
#[test]
fn help_overlay_closes_on_next_keystroke() {
let mut state = build_state(loaded_empty("root", "/"));
dispatch(&press(KeyCode::Char('?')), &mut state);
assert!(state.help_visible);
let result = dispatch(&press(KeyCode::Char('q')), &mut state);
assert_eq!(result, DispatchResult::Continue);
assert!(!state.help_visible);
}
#[test]
fn down_at_last_visible_tree_row_moves_to_commit_button() {
let mut root = loaded_dir(
"root",
"/",
vec![loaded_empty("a", "/a"), loaded_empty("b", "/b")],
);
root.expanded = true;
let mut state = build_state(root);
dispatch(&press(KeyCode::Down), &mut state);
dispatch(&press(KeyCode::Down), &mut state);
assert_eq!(state.focus, TuiFocus::Tree);
dispatch(&press(KeyCode::Down), &mut state);
assert_eq!(state.focus, TuiFocus::CommitButton);
dispatch(&press(KeyCode::Down), &mut state);
assert_eq!(state.focus, TuiFocus::Tree);
assert_eq!(state.tree.focused().path, PathBuf::from("/"));
}
#[test]
fn up_from_root_wraps_to_commit_button() {
let mut state = build_state(loaded_empty("root", "/"));
let result = dispatch(&press(KeyCode::Up), &mut state);
assert_eq!(result, DispatchResult::Continue);
assert_eq!(state.focus, TuiFocus::CommitButton);
}
#[test]
fn space_advances_state_when_focused_on_tree_row() {
let mut state = build_state(loaded_empty("root", "/"));
dispatch(&press(KeyCode::Char(' ')), &mut state);
assert_eq!(state.tree.focused().state, SelectionState::Project);
}
#[test]
fn space_on_commit_button_is_noop() {
let mut state = build_state(loaded_empty("root", "/"));
dispatch(&press(KeyCode::Down), &mut state);
assert_eq!(state.focus, TuiFocus::CommitButton);
dispatch(&press(KeyCode::Char(' ')), &mut state);
assert_eq!(state.tree.focused().state, SelectionState::None);
}
#[test]
fn space_on_unloaded_directory_at_project_state_requests_load_then_retry() {
let mut root = Node::new(OsString::from("root"), PathBuf::from("/"));
root.state = SelectionState::Project;
let mut state = build_state(root);
let result = dispatch(&press(KeyCode::Char(' ')), &mut state);
assert_eq!(result, DispatchResult::LoadChildrenThenRetryCycle);
assert_eq!(state.tree.focused().state, SelectionState::Project);
}
#[test]
fn space_refusal_when_no_subdirs_skips_to_none_and_signals() {
let mut state = build_state(loaded_empty("root", "/"));
dispatch(&press(KeyCode::Char(' ')), &mut state); let result = dispatch(&press(KeyCode::Char(' ')), &mut state);
assert_eq!(result, DispatchResult::Continue);
assert_eq!(state.tree.focused().state, SelectionState::None);
assert!(state.bell_pending, "AUX-004 refusal must ring the bell");
let status = state.status.as_deref().unwrap_or("");
assert!(
status.contains("no subdirectories"),
"status message should explain the refusal: {status}",
);
}
#[test]
fn space_refusal_under_locked_subtree_sets_status_and_bell() {
let mut src = loaded_empty("src", "/lib/src");
let mut lib = loaded_dir("lib", "/lib", vec![src.clone()]);
lib.state = SelectionState::ProjectsParent;
lib.expanded = true;
let mut root = loaded_dir("root", "/", vec![lib]);
root.expanded = true;
src.state = SelectionState::None;
let mut state = build_state(root);
dispatch(&press(KeyCode::Down), &mut state);
dispatch(&press(KeyCode::Down), &mut state);
assert_eq!(state.tree.focused().path, PathBuf::from("/lib/src"));
let result = dispatch(&press(KeyCode::Char(' ')), &mut state);
assert_eq!(result, DispatchResult::Continue);
assert!(state.bell_pending, "lockout refusal must ring the bell");
let status = state.status.as_deref().unwrap_or("");
assert!(
status.contains("projects-parent"),
"lockout status should explain the cause: {status}",
);
assert_eq!(state.tree.focused().state, SelectionState::None);
}
#[test]
fn right_arrow_expands_focused_directory_with_subdirectories() {
let root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
let mut state = build_state(root);
assert!(!state.tree.focused().expanded);
let result = dispatch(&press(KeyCode::Right), &mut state);
assert_eq!(result, DispatchResult::Continue);
assert!(state.tree.focused().expanded);
}
#[test]
fn right_arrow_on_loaded_leaf_refuses_with_bell_and_status() {
let mut state = build_state(loaded_empty("root", "/"));
let result = dispatch(&press(KeyCode::Right), &mut state);
assert_eq!(result, DispatchResult::Continue);
assert!(!state.tree.focused().expanded);
assert!(state.bell_pending);
let status = state.status.as_deref().unwrap_or("");
assert!(
status.contains("no subdirectories"),
"refusal status should explain the reason: {status}",
);
}
#[test]
fn right_arrow_on_unloaded_directory_requests_load_then_retry() {
let root = Node::new(OsString::from("root"), PathBuf::from("/"));
let mut state = build_state(root);
let result = dispatch(&press(KeyCode::Right), &mut state);
assert_eq!(result, DispatchResult::LoadChildrenThenRetryExpand);
assert!(!state.tree.focused().expanded);
}
#[test]
fn right_arrow_on_commit_button_is_noop() {
let mut state = build_state(loaded_empty("root", "/"));
dispatch(&press(KeyCode::Down), &mut state); let result = dispatch(&press(KeyCode::Right), &mut state);
assert_eq!(result, DispatchResult::Continue);
assert_eq!(state.focus, TuiFocus::CommitButton);
}
#[test]
fn left_arrow_collapses_focused_directory() {
let mut root = loaded_dir("root", "/", vec![loaded_empty("a", "/a")]);
root.expanded = true;
let mut state = build_state(root);
assert!(state.tree.focused().expanded);
let result = dispatch(&press(KeyCode::Left), &mut state);
assert_eq!(result, DispatchResult::Continue);
assert!(!state.tree.focused().expanded);
}
#[test]
fn enter_on_tree_row_returns_exit_commit() {
let mut state = build_state(loaded_empty("root", "/"));
let result = dispatch(&press(KeyCode::Enter), &mut state);
assert_eq!(result, DispatchResult::ExitCommit);
}
#[test]
fn enter_on_commit_button_returns_exit_commit() {
let mut state = build_state(loaded_empty("root", "/"));
dispatch(&press(KeyCode::Down), &mut state);
assert_eq!(state.focus, TuiFocus::CommitButton);
let result = dispatch(&press(KeyCode::Enter), &mut state);
assert_eq!(result, DispatchResult::ExitCommit);
}
#[test]
fn h_toggles_hidden_visibility_and_requests_reload() {
let mut state = build_state(loaded_empty("root", "/"));
assert!(!state.hidden_visible);
let result = dispatch(&press(KeyCode::Char('h')), &mut state);
assert!(state.hidden_visible);
assert_eq!(result, DispatchResult::ReloadTreeForVisibility);
assert!(state.status.as_deref().unwrap_or("").contains("shown"));
let result = dispatch(&press(KeyCode::Char('h')), &mut state);
assert!(!state.hidden_visible);
assert_eq!(result, DispatchResult::ReloadTreeForVisibility);
assert!(state.status.as_deref().unwrap_or("").contains("hidden"));
}
#[test]
fn capital_h_also_toggles_hidden() {
let mut state = build_state(loaded_empty("root", "/"));
let result = dispatch(&press(KeyCode::Char('H')), &mut state);
assert!(state.hidden_visible);
assert_eq!(result, DispatchResult::ReloadTreeForVisibility);
}
#[test]
fn q_returns_exit_cancel() {
let mut state = build_state(loaded_empty("root", "/"));
let result = dispatch(&press(KeyCode::Char('q')), &mut state);
assert_eq!(result, DispatchResult::ExitCancel);
}
#[test]
fn esc_returns_exit_cancel() {
let mut state = build_state(loaded_empty("root", "/"));
let result = dispatch(&press(KeyCode::Esc), &mut state);
assert_eq!(result, DispatchResult::ExitCancel);
}
#[test]
fn status_cleared_on_each_dispatch_unless_set_by_handler() {
let mut state = build_state(loaded_empty("root", "/"));
dispatch(&press(KeyCode::Char('h')), &mut state);
assert!(state.status.is_some());
dispatch(&press(KeyCode::Up), &mut state);
assert!(state.status.is_none());
}
fn names_of(nodes: &[Node]) -> Vec<String> {
nodes
.iter()
.map(|n| n.name.to_string_lossy().into_owned())
.collect()
}
fn kind_of(nodes: &[Node], name: &str) -> Option<HiddenKind> {
nodes
.iter()
.find(|n| n.name.as_os_str() == std::ffi::OsStr::new(name))
.map(|n| n.hidden_kind)
}
fn write_gitignore(dir: &Path, body: &str) {
fs::write(dir.join(".gitignore"), body).unwrap();
}
#[test]
fn read_dir_children_skips_dotfile_and_gitignored_when_show_hidden_false() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("src")).unwrap();
fs::create_dir(tmp.path().join(".git")).unwrap();
fs::create_dir(tmp.path().join("node_modules")).unwrap();
write_gitignore(tmp.path(), "node_modules/\n");
let matcher = GitignoreMatcher::new(tmp.path());
let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, false).unwrap();
assert_eq!(names_of(&children), vec!["src".to_owned()]);
}
#[test]
fn read_dir_children_includes_hidden_with_kinds_when_show_hidden_true() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("src")).unwrap();
fs::create_dir(tmp.path().join(".git")).unwrap();
fs::create_dir(tmp.path().join("node_modules")).unwrap();
write_gitignore(tmp.path(), "node_modules/\n");
let matcher = GitignoreMatcher::new(tmp.path());
let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, true).unwrap();
assert_eq!(
names_of(&children),
vec![
".git".to_owned(),
"node_modules".to_owned(),
"src".to_owned()
],
);
assert_eq!(kind_of(&children, ".git"), Some(HiddenKind::Dotfile));
assert_eq!(
kind_of(&children, "node_modules"),
Some(HiddenKind::Gitignored),
);
assert_eq!(kind_of(&children, "src"), Some(HiddenKind::Visible));
}
#[test]
fn read_dir_children_classifies_dotfile_dir_that_is_also_gitignored_as_gitignored() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".cache")).unwrap();
write_gitignore(tmp.path(), ".cache/\n");
let matcher = GitignoreMatcher::new(tmp.path());
let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, true).unwrap();
assert_eq!(kind_of(&children, ".cache"), Some(HiddenKind::Gitignored));
}
#[test]
fn read_dir_children_with_no_gitignore_file_still_skips_dotfiles() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("lib")).unwrap();
fs::create_dir(tmp.path().join(".hidden")).unwrap();
let matcher = GitignoreMatcher::new(tmp.path());
let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, false).unwrap();
assert_eq!(names_of(&children), vec!["lib".to_owned()]);
}
#[test]
fn read_dir_children_ignores_regular_files() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("lib")).unwrap();
fs::write(tmp.path().join("README.md"), "").unwrap();
let matcher = GitignoreMatcher::new(tmp.path());
let children = read_dir_children(tmp.path(), Path::new("/"), &matcher, true).unwrap();
assert_eq!(names_of(&children), vec!["lib".to_owned()]);
}
}