feat: ADR-0006 §8 steps 4-5 — undo/redo commands + confirm-modal flow

Commands & grammar (step 4):
- AppCommand::Undo/Redo, grammar nodes + REGISTRY entries, catalog
  help/usage + keys; parse tests
- replay skips undo/redo (is_app_lifecycle_entry_word) + completion
  entry-keyword lockstep; replay-skip test extended

Wiring (step 5):
- Action::{PrepareUndo,PrepareRedo,Undo,Redo} + AppEvent::{UndoPrepared,
  UndoUnavailable,UndoSucceeded,UndoFailed}
- App: undo_enabled flag, Modal::UndoConfirm, dispatch + event handling
  + confirm-key handler (Y confirms / N/Esc cancels); "turned off" when
  --no-undo; "nothing to undo/redo" when empty
- ui::render_undo_confirm names the command + snapshot time
- runtime: opens with undo enabled (!--no-undo), threads it through the
  project-switch path, spawn_prepare_undo/spawn_undo (peek->modal,
  restore->refresh tables + schema cache)
- 9 Tier-1 app tests + 3 parse tests

1692 passed / 0 failed / 1 ignored; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-24 20:48:30 +00:00
parent a97069c02e
commit 25800e3eb5
14 changed files with 541 additions and 9 deletions
+103 -5
View File
@@ -197,8 +197,11 @@ pub async fn run(args: Args) -> Result<()> {
// sqlite creates it on connect, so this is the only honest
// signal that we need to rebuild from text (ADR-0015 §7).
let db_existed = db_path.exists();
let database = Database::open_with_persistence(db_path.as_path(), persistence)
.context("open database")?;
// Undo is on unless `--no-undo` (ADR-0006 Amendment 1).
let undo_enabled = !args.no_undo;
let database =
Database::open_with_persistence_and_undo(db_path.as_path(), persistence, undo_enabled)
.context("open database")?;
let mut initial_events: Vec<AppEvent> = Vec::new();
if !db_existed {
match database.rebuild_from_text(project_path.clone(), None).await {
@@ -251,6 +254,7 @@ pub async fn run(args: Args) -> Result<()> {
display_name,
project_is_temp,
initial_events,
undo_enabled,
)
.await;
if let Err(e) = teardown_terminal(&mut terminal) {
@@ -306,6 +310,7 @@ async fn run_loop(
project_display_name: String,
project_is_temp: bool,
initial_events: Vec<AppEvent>,
undo_enabled: bool,
) -> Result<Option<String>> {
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
let reader_handle = spawn_event_reader(event_tx.clone());
@@ -313,6 +318,7 @@ async fn run_loop(
let mut app = App::new();
app.project_name = Some(project_display_name);
app.project_is_temp = project_is_temp;
app.undo_enabled = undo_enabled;
// Seed the in-memory navigable history from the
// initial project's history.log (I2-persist, ADR-0015
// §12). Subsequent project switches re-seed via the
@@ -430,6 +436,7 @@ async fn run_loop(
SwitchRequest::Load { path },
source,
&event_tx,
undo_enabled,
)
.await;
}
@@ -439,6 +446,7 @@ async fn run_loop(
SwitchRequest::SaveAs { target },
source,
&event_tx,
undo_enabled,
)
.await;
}
@@ -448,6 +456,7 @@ async fn run_loop(
SwitchRequest::NewTemp,
source,
&event_tx,
undo_enabled,
)
.await;
}
@@ -474,9 +483,22 @@ async fn run_loop(
},
source,
&event_tx,
undo_enabled,
)
.await;
}
Action::PrepareUndo => {
spawn_prepare_undo(session.database().clone(), event_tx.clone(), false);
}
Action::PrepareRedo => {
spawn_prepare_undo(session.database().clone(), event_tx.clone(), true);
}
Action::Undo => {
spawn_undo(session.database().clone(), event_tx.clone(), false);
}
Action::Redo => {
spawn_undo(session.database().clone(), event_tx.clone(), true);
}
Action::Replay { path } => {
spawn_replay(
session.database().clone(),
@@ -580,8 +602,9 @@ async fn handle_project_switch(
req: SwitchRequest,
source: String,
event_tx: &mpsc::Sender<AppEvent>,
undo_enabled: bool,
) {
match perform_switch(session, req, source).await {
match perform_switch(session, req, source, undo_enabled).await {
Ok((display_name, is_temp)) => {
let history_entries = read_history_seed(session.project().path());
let _ = event_tx
@@ -630,6 +653,7 @@ async fn perform_switch(
session: &mut Session,
req: SwitchRequest,
source: String,
undo_enabled: bool,
) -> Result<(String, bool), String> {
use crate::persistence::Persistence;
@@ -776,7 +800,8 @@ async fn perform_switch(
let db_existed = db_path.exists();
let persistence = Persistence::new(new_path.clone());
let new_database =
Database::open_with_persistence(&db_path, persistence).map_err(|e| e.to_string())?;
Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled)
.map_err(|e| e.to_string())?;
if !db_existed
&& let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await
{
@@ -1147,6 +1172,69 @@ fn spawn_rebuild(
});
}
/// Peek the snapshot `undo`/`redo` would restore and post
/// `UndoPrepared` (open the confirmation modal) or
/// `UndoUnavailable` (ADR-0006 Amendment 1).
fn spawn_prepare_undo(database: Database, event_tx: mpsc::Sender<AppEvent>, is_redo: bool) {
tokio::spawn(async move {
let peek = if is_redo {
database.peek_redo().await
} else {
database.peek_undo().await
};
let event = match peek {
Ok(Some(meta)) => AppEvent::UndoPrepared {
command: meta.command,
timestamp: meta.timestamp,
is_redo,
},
Ok(None) => AppEvent::UndoUnavailable { is_redo },
Err(e) => AppEvent::UndoFailed {
error: e.friendly_message(),
is_redo,
},
};
let _ = event_tx.send(event).await;
});
}
/// Restore the snapshot `undo`/`redo` selects, then refresh the
/// table list + schema cache so the TUI shows the restored state.
fn spawn_undo(database: Database, event_tx: mpsc::Sender<AppEvent>, is_redo: bool) {
tokio::spawn(async move {
let result = if is_redo {
database.redo().await
} else {
database.undo().await
};
match result {
Ok(Some(meta)) => {
let _ = event_tx
.send(AppEvent::UndoSucceeded {
command: meta.command,
is_redo,
})
.await;
if let Ok(tables) = database.list_tables().await {
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
}
refresh_schema_cache(&database, &event_tx).await;
}
Ok(None) => {
let _ = event_tx.send(AppEvent::UndoUnavailable { is_redo }).await;
}
Err(e) => {
let _ = event_tx
.send(AppEvent::UndoFailed {
error: e.friendly_message(),
is_redo,
})
.await;
}
}
});
}
/// Spawn a task that runs a DSL command against the database
/// and forwards the result back as an `AppEvent`.
fn spawn_dsl_dispatch(
@@ -1777,7 +1865,17 @@ pub async fn run_replay(
fn is_app_lifecycle_entry_word(entry: &str) -> bool {
matches!(
entry,
"save" | "load" | "new" | "export" | "mode" | "messages" | "rebuild" | "help" | "quit"
"save"
| "load"
| "new"
| "export"
| "mode"
| "messages"
| "rebuild"
| "help"
| "quit"
| "undo"
| "redo"
)
}