1use 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#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum DispatchResult {
60 Continue,
62 LoadChildrenThenRetryExpand,
70 LoadChildrenThenRetryCycle,
76 ReloadTreeForVisibility,
83 ExitCommit,
85 ExitCancel,
87 Abort,
90}
91
92#[derive(Debug)]
95pub enum ScaffoldOutcome {
96 Confirmed(CommitPlan),
98 Declined,
102 Aborted,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum TuiFocus {
109 Tree,
112 CommitButton,
114}
115
116#[derive(Debug, Clone)]
129pub struct GitignoreMatcher {
130 local: Gitignore,
131 global: Gitignore,
132}
133
134impl GitignoreMatcher {
135 #[must_use]
140 pub fn new(cwd_host: &Path) -> Self {
141 let mut builder = GitignoreBuilder::new(cwd_host);
142 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 #[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#[derive(Debug)]
166pub struct TuiState {
167 pub tree: Tree,
169 pub focus: TuiFocus,
171 pub cwd_host: PathBuf,
175 pub gitignore: GitignoreMatcher,
178 pub hidden_visible: bool,
185 pub status: Option<String>,
189 pub help_visible: bool,
192 pub bell_pending: bool,
195}
196
197impl TuiState {
198 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
221pub 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 if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c' | 'C'))
247 {
248 return DispatchResult::Abort;
249 }
250
251 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 return;
303 }
304 let total = visible + 1; 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 return DispatchResult::LoadChildrenThenRetryExpand;
337 }
338 apply_expand_outcome_signal(state, outcome);
339 DispatchResult::Continue
340}
341
342fn 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 return DispatchResult::LoadChildrenThenRetryCycle;
377 }
378 apply_cycle_outcome_signal(state, outcome);
379 DispatchResult::Continue
380}
381
382fn 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
412pub 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 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 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
554fn 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
571fn 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
595fn 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
629fn 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
695fn 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
726fn 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
777const COLOR_PROJECT: Color = Color::Green;
782const COLOR_PROJECTS_PARENT: Color = Color::Cyan;
783
784const CHROME_ABOVE_TREE: u16 = 2;
786const 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), Constraint::Length(1), Constraint::Length(tree_height), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
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
864const 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 let expand_glyph = if vn.node.expanded {
879 "\u{25BC}" } else {
881 "\u{25B6}" };
883
884 let indent: String = " ".repeat(vn.depth * 2);
885 let name = vn.node.name.to_string_lossy().into_owned();
886
887 let left_text = format!(" {state_glyph} {indent}{expand_glyph} {name}");
889 let left_len = left_text.chars().count();
890
891 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 row_modifier |= Modifier::DIM;
911 None
912 }
913 HiddenKind::Gitignored => {
914 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}", Style::default(),
947 "",
948 Style::default(),
949 ),
950 SelectionState::Project => (
951 "\u{25A0}", 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}", 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), Constraint::Min(0), Constraint::Length(1), ])
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#[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 let _ = dispatch(&release(KeyCode::Esc), &mut state);
1186 state.status = None;
1187 state.bell_pending = false;
1188 state
1189 }
1190
1191 #[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 #[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 #[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 #[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 dispatch(&press(KeyCode::Down), &mut state);
1253 dispatch(&press(KeyCode::Down), &mut state);
1255 assert_eq!(state.focus, TuiFocus::Tree);
1256 dispatch(&press(KeyCode::Down), &mut state);
1258 assert_eq!(state.focus, TuiFocus::CommitButton);
1259 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 let result = dispatch(&press(KeyCode::Up), &mut state);
1270 assert_eq!(result, DispatchResult::Continue);
1271 assert_eq!(state.focus, TuiFocus::CommitButton);
1272 }
1273
1274 #[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 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 #[test]
1296 fn space_on_unloaded_directory_at_project_state_requests_load_then_retry() {
1297 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 assert_eq!(state.tree.focused().state, SelectionState::Project);
1312 }
1313
1314 #[test]
1317 fn space_refusal_when_no_subdirs_skips_to_none_and_signals() {
1318 let mut state = build_state(loaded_empty("root", "/"));
1323 dispatch(&press(KeyCode::Char(' ')), &mut state); 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 #[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 src.state = SelectionState::None;
1347 let mut state = build_state(root);
1348 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 #[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 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 let mut state = build_state(root);
1397 let result = dispatch(&press(KeyCode::Right), &mut state);
1398 assert_eq!(result, DispatchResult::LoadChildrenThenRetryExpand);
1399 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); let result = dispatch(&press(KeyCode::Right), &mut state);
1410 assert_eq!(result, DispatchResult::Continue);
1411 assert_eq!(state.focus, TuiFocus::CommitButton);
1412 }
1413
1414 #[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 #[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 #[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 #[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 #[test]
1488 fn status_cleared_on_each_dispatch_unless_set_by_handler() {
1489 let mut state = build_state(loaded_empty("root", "/"));
1490 dispatch(&press(KeyCode::Char('h')), &mut state);
1492 assert!(state.status.is_some());
1493 dispatch(&press(KeyCode::Up), &mut state);
1495 assert!(state.status.is_none());
1496 }
1497
1498 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 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 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 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 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}