use std::path::Path;
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use crate::commands::init::model::{CycleOutcome, ExpandOutcome};
use crate::commands::init::tui::{DispatchResult, TuiFocus, TuiState};
pub(super) 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
}
pub(super) 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
}
pub(super) 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()
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use crate::commands::init::model::{Node, SelectionState, Tree};
use crate::commands::init::tui::gitignore::GitignoreMatcher;
use crate::commands::init::tui::{DispatchResult, TuiFocus, TuiState};
use super::dispatch;
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());
}
}