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:
+80
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user