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
+125
View File
@@ -48,6 +48,117 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
render_right_column(app, theme, frame, columns[1]);
render_project_label(app, theme, frame, outer[1]);
render_status_bar(app, theme, frame, outer[2]);
// Modal dialogs (rebuild confirm, save-as prompt, load
// picker, …) are drawn last so they overlay the rest of
// the frame.
if let Some(modal) = app.modal.as_ref() {
render_modal(modal, theme, frame, area);
}
}
fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
use crate::app::Modal;
match modal {
Modal::RebuildConfirm(m) => render_rebuild_confirm(&m.summary, theme, frame, area),
}
}
/// Centred dialog with a one-paragraph body and a [Y]es/[N]o
/// hint at the bottom. Sized at min(60 cols, area.width-4)
/// wide and tall enough to fit the wrapped body plus 4 rows
/// of chrome.
fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let dialog_w = area.width.min(60).saturating_sub(0);
let dialog_w = dialog_w.max(20);
let inner_w = dialog_w.saturating_sub(4) as usize;
let body_lines: Vec<String> = wrap_lines(summary, inner_w);
let body_height = body_lines.len() as u16;
// Title row + blank + body + blank + prompt + blank + keys + borders (2).
let dialog_h = body_height.saturating_add(7).min(area.height);
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
let dialog_area = Rect {
x,
y,
width: dialog_w,
height: dialog_h,
};
// Solid background panel so we cover whatever was beneath.
let bg = ratatui::widgets::Clear;
frame.render_widget(bg, dialog_area);
let title_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.fg))
.title(Line::from(vec![Span::styled(" Rebuild project ", title_style)]))
.style(Style::default().bg(theme.bg).fg(theme.fg));
let mut text_lines: Vec<Line<'_>> = Vec::new();
text_lines.push(Line::from(""));
for line in body_lines {
text_lines.push(Line::from(line));
}
text_lines.push(Line::from(""));
text_lines.push(Line::from("Continue?"));
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled(
"[Y]",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
),
Span::raw(" yes "),
Span::styled(
"[N]",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
),
Span::raw(" no "),
Span::styled("Esc", Style::default().fg(theme.muted)),
Span::styled(" cancel", Style::default().fg(theme.muted)),
]));
let paragraph = Paragraph::new(text_lines)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, dialog_area);
}
/// Greedy word-wrap to `width` columns. Sufficient for the
/// short prose modals carry; we don't try to be Unicode-aware
/// (display-width-wise) since the strings we generate are
/// ASCII-friendly.
fn wrap_lines(s: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![s.to_string()];
}
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
for word in s.split_whitespace() {
if !current.is_empty() && current.len() + 1 + word.len() <= width {
current.push(' ');
} else if !current.is_empty() {
lines.push(std::mem::take(&mut current));
}
current.push_str(word);
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
@@ -433,6 +544,20 @@ mod tests {
insta::assert_snapshot!("one_shot_advanced_dark", snapshot);
}
#[test]
fn rebuild_confirm_modal_snapshot() {
use crate::app::{Modal, RebuildConfirmModal};
let mut app = App::new();
app.modal = Some(Modal::RebuildConfirm(RebuildConfirmModal {
summary: "3 tables and 47 rows will be reconstructed; \
the existing playground.db will be replaced"
.to_string(),
}));
let theme = Theme::dark();
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("rebuild_confirm_modal_dark", snapshot);
}
#[test]
fn populated_with_table_snapshot() {
// Items panel lists tables; output panel shows the