Iteration 4a: rebuild command with confirmation modal

Adds the explicit `rebuild` app-level command (ADR-0015 §7, §11)
and a modal UI infrastructure to host its confirmation dialog.
Typing `rebuild` emits Action::PrepareRebuild; the runtime reads
project.yaml + data/ to compute a summary ("3 tables and 47 rows
will be reconstructed; the existing playground.db will be
replaced") and posts AppEvent::RebuildPrepared, which opens the
modal. Y confirms, N/Esc cancels. While the modal is open,
normal input is gated.

The worker's do_rebuild_from_text now wipes existing user tables
and metadata before reloading from text, so it works on both
fresh and populated databases. Source text is plumbed through
rebuild_from_text so the explicit rebuild logs to history.log
while the silent on-load rebuild from Iteration 3 stays silent.

Modal infrastructure (App.modal field + key routing + centered
overlay rendering + word-wrap) is reused by Iteration 4b's save
/ save as / load / new flows.

Tests: 314 passing (268 lib + 9 + 5 + 6 new + 9 + 17),
0 failing, 0 skipped. Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-07 22:27:37 +00:00
parent f0fc063756
commit ba93d3c7d8
9 changed files with 638 additions and 20 deletions
+80 -2
View File
@@ -111,6 +111,32 @@ pub struct App {
/// loop exits and prints it to stderr post-teardown so the
/// banner remains above the shell prompt.
pub fatal_message: Option<String>,
/// Active modal dialog (rebuild confirmation, save-as path
/// prompt, load picker, …). While `Some`, `update`
/// dispatches keys to the modal instead of the input
/// field.
pub modal: Option<Modal>,
}
/// Dialogs that take over keyboard input when active.
///
/// Track-2 lifecycle commands (`rebuild`, `save as`, `load`,
/// `new`) need confirmation prompts or path entry that the
/// single-line input field can't naturally express. Each
/// modal owns a small state machine; the renderer draws an
/// overlay and `App::update` routes keys through it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Modal {
/// `rebuild` confirmation. Shows a summary of what would
/// be reconstructed; `Y` confirms, `N` / `Esc` dismisses.
RebuildConfirm(RebuildConfirmModal),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RebuildConfirmModal {
/// One-line summary derived from `project.yaml` + `data/`
/// (e.g. `"3 tables, 47 rows will be reconstructed"`).
pub summary: String,
}
const PAGE_SCROLL_LINES: usize = 5;
@@ -142,6 +168,7 @@ impl App {
last_output_total_wrapped: 0,
project_name: None,
fatal_message: None,
modal: None,
}
}
@@ -228,6 +255,20 @@ impl App {
self.fatal_message = Some(banner);
vec![Action::Quit]
}
AppEvent::RebuildPrepared { summary } => {
self.modal = Some(Modal::RebuildConfirm(RebuildConfirmModal { summary }));
Vec::new()
}
AppEvent::RebuildSucceeded { summary } => {
self.modal = None;
self.note_system(format!("[ok] rebuild — {summary}"));
Vec::new()
}
AppEvent::RebuildFailed { error } => {
self.modal = None;
self.note_error(format!("rebuild failed: {error}"));
Vec::new()
}
}
}
@@ -239,6 +280,13 @@ impl App {
return Vec::new();
}
trace!(?key, "handle_key");
// While a modal is open it owns the keyboard. Normal
// input editing, history navigation, and command
// submission are all gated behind closing the modal.
if self.modal.is_some() {
return self.handle_modal_key(key);
}
match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
(KeyCode::Enter, _) => self.submit(),
@@ -449,10 +497,12 @@ impl App {
}
// Canonical app-level commands recognised in both modes.
// The current iteration implements `quit` and `mode`;
// the rest of the canonical list lands in later iterations.
// Track-2's full lifecycle command set (`save`, `load`,
// `new`, `export`, `import`) lands across Iterations 4
// and 5; this iteration adds `rebuild`.
match effective_input.as_str() {
"quit" | "q" => return vec![Action::Quit],
"rebuild" => return vec![Action::PrepareRebuild],
other if other.starts_with("mode") => {
self.handle_mode_command(other);
return Vec::new();
@@ -610,6 +660,34 @@ impl App {
));
}
/// Route a keypress through whichever modal is active.
///
/// Each modal owns its own tiny state machine. On
/// confirmation, the modal yields one or more `Action`s
/// for the runtime to enact. On dismissal it simply
/// closes itself.
fn handle_modal_key(&mut self, key: KeyEvent) -> Vec<Action> {
let Some(modal) = self.modal.as_ref() else {
return Vec::new();
};
match modal {
Modal::RebuildConfirm(_) => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
self.modal = None;
vec![Action::Rebuild {
source: "rebuild".to_string(),
}]
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.modal = None;
self.note_system("rebuild cancelled");
Vec::new()
}
_ => Vec::new(),
},
}
}
fn handle_mode_command(&mut self, raw: &str) {
let arg = raw.strip_prefix("mode").unwrap_or(raw).trim();
match arg {