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:
+115
-3
@@ -46,7 +46,8 @@ pub async fn run(args: Args) -> Result<()> {
|
||||
.context("open or create project")?;
|
||||
let db_path = project.db_path();
|
||||
let display_name = project.display_name().to_string();
|
||||
let persistence = crate::persistence::Persistence::new(project.path().to_path_buf());
|
||||
let project_path = project.path().to_path_buf();
|
||||
let persistence = crate::persistence::Persistence::new(project_path.clone());
|
||||
// Capture whether the .db file existed BEFORE we open it —
|
||||
// sqlite creates it on connect, so this is the only honest
|
||||
// signal that we need to rebuild from text (ADR-0015 §7).
|
||||
@@ -54,7 +55,9 @@ pub async fn run(args: Args) -> Result<()> {
|
||||
let database = Database::open_with_persistence(db_path.as_path(), persistence)
|
||||
.context("open database")?;
|
||||
if !db_existed
|
||||
&& let Err(e) = database.rebuild_from_text(project.path().to_path_buf()).await
|
||||
&& let Err(e) = database
|
||||
.rebuild_from_text(project_path.clone(), None)
|
||||
.await
|
||||
{
|
||||
// The terminal is still in cooked mode here (we haven't
|
||||
// entered the alternate screen yet), so writing to
|
||||
@@ -72,7 +75,14 @@ pub async fn run(args: Args) -> Result<()> {
|
||||
}
|
||||
|
||||
let mut terminal = setup_terminal().context("setup terminal")?;
|
||||
let result = run_loop(&mut terminal, args.theme, database, display_name).await;
|
||||
let result = run_loop(
|
||||
&mut terminal,
|
||||
args.theme,
|
||||
database,
|
||||
display_name,
|
||||
project_path,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = teardown_terminal(&mut terminal) {
|
||||
// Teardown failures should not mask the primary error.
|
||||
warn!(error = %e, "terminal teardown failed");
|
||||
@@ -95,6 +105,7 @@ async fn run_loop(
|
||||
theme: Theme,
|
||||
database: Database,
|
||||
project_display_name: String,
|
||||
project_path: std::path::PathBuf,
|
||||
) -> Result<Option<String>> {
|
||||
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
|
||||
let reader_handle = spawn_event_reader(event_tx.clone());
|
||||
@@ -125,6 +136,17 @@ async fn run_loop(
|
||||
Action::ExecuteDsl { command, source } => {
|
||||
spawn_dsl_dispatch(database.clone(), event_tx.clone(), command, source);
|
||||
}
|
||||
Action::PrepareRebuild => {
|
||||
spawn_prepare_rebuild(project_path.clone(), event_tx.clone());
|
||||
}
|
||||
Action::Rebuild { source } => {
|
||||
spawn_rebuild(
|
||||
database.clone(),
|
||||
project_path.clone(),
|
||||
event_tx.clone(),
|
||||
source,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
terminal
|
||||
@@ -152,6 +174,96 @@ async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEve
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `project.yaml` + `data/` to compute the rebuild
|
||||
/// summary that the confirmation modal shows. Runs off the
|
||||
/// event loop so the brief I/O doesn't stall input handling
|
||||
/// even on slow filesystems.
|
||||
fn spawn_prepare_rebuild(
|
||||
project_path: std::path::PathBuf,
|
||||
event_tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let summary = match summarize_project(&project_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => format!("(could not read project sources: {e})"),
|
||||
};
|
||||
let _ = event_tx.send(AppEvent::RebuildPrepared { summary }).await;
|
||||
});
|
||||
}
|
||||
|
||||
fn summarize_project(project_path: &std::path::Path) -> Result<String, String> {
|
||||
let yaml_path = project_path.join(crate::project::PROJECT_YAML);
|
||||
let yaml = std::fs::read_to_string(&yaml_path).map_err(|e| e.to_string())?;
|
||||
let snapshot = crate::persistence::parse_schema(&yaml).map_err(|e| e.to_string())?;
|
||||
let table_count = snapshot.tables.len();
|
||||
let data_dir = project_path.join(crate::project::DATA_DIR);
|
||||
let mut row_count: usize = 0;
|
||||
for table in &snapshot.tables {
|
||||
let csv_path = data_dir.join(format!("{}.csv", table.name));
|
||||
let Ok(body) = std::fs::read_to_string(&csv_path) else {
|
||||
continue;
|
||||
};
|
||||
// Header line + one line per row (per Iteration 2's
|
||||
// "no CSV when empty" rule, this is exact).
|
||||
row_count += body.lines().count().saturating_sub(1);
|
||||
}
|
||||
Ok(format!(
|
||||
"{table_count} table{} and {row_count} row{} will be reconstructed; \
|
||||
the existing playground.db will be replaced",
|
||||
if table_count == 1 { "" } else { "s" },
|
||||
if row_count == 1 { "" } else { "s" },
|
||||
))
|
||||
}
|
||||
|
||||
/// Spawn the actual rebuild and forward the typed outcome
|
||||
/// back as an `AppEvent`.
|
||||
fn spawn_rebuild(
|
||||
database: Database,
|
||||
project_path: std::path::PathBuf,
|
||||
event_tx: mpsc::Sender<AppEvent>,
|
||||
source: String,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
match database
|
||||
.rebuild_from_text(project_path.clone(), Some(source))
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let summary = summarize_project(&project_path)
|
||||
.unwrap_or_else(|_| "rebuild complete".to_string());
|
||||
let _ = event_tx
|
||||
.send(AppEvent::RebuildSucceeded { summary })
|
||||
.await;
|
||||
// Refresh the table list so the items panel
|
||||
// reflects whatever the rebuild produced.
|
||||
if let Ok(tables) = database.list_tables().await {
|
||||
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
|
||||
}
|
||||
}
|
||||
Err(DbError::PersistenceFatal {
|
||||
operation,
|
||||
path,
|
||||
message,
|
||||
}) => {
|
||||
let _ = event_tx
|
||||
.send(AppEvent::PersistenceFatal {
|
||||
operation: operation.to_string(),
|
||||
path,
|
||||
message,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Err(other) => {
|
||||
let _ = event_tx
|
||||
.send(AppEvent::RebuildFailed {
|
||||
error: other.friendly_message(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawn a task that runs a DSL command against the database
|
||||
/// and forwards the result back as an `AppEvent`.
|
||||
fn spawn_dsl_dispatch(
|
||||
|
||||
Reference in New Issue
Block a user