From 25800e3eb5b83e83cde647c21dfa1940df6d0ec5 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 24 May 2026 20:48:30 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20ADR-0006=20=C2=A78=20steps=204-5=20?= =?UTF-8?q?=E2=80=94=20undo/redo=20commands=20+=20confirm-modal=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/action.rs | 13 ++ src/app.rs | 217 ++++++++++++++++++++++++++++++++ src/completion.rs | 2 +- src/dsl/command.rs | 7 ++ src/dsl/grammar/app.rs | 22 ++++ src/dsl/grammar/mod.rs | 2 + src/dsl/walker/mod.rs | 16 +++ src/event.rs | 27 ++++ src/friendly/keys.rs | 20 +++ src/friendly/strings/en-US.yaml | 25 ++++ src/runtime.rs | 108 +++++++++++++++- src/ui.rs | 80 ++++++++++++ tests/replay_command.rs | 9 +- tests/typing_surface/mod.rs | 2 + 14 files changed, 541 insertions(+), 9 deletions(-) diff --git a/src/action.rs b/src/action.rs index 5772880..acb890b 100644 --- a/src/action.rs +++ b/src/action.rs @@ -109,4 +109,17 @@ pub enum Action { Replay { path: String, }, + /// User issued `undo` (`PrepareUndo`) or `redo` (`PrepareRedo`) + /// (ADR-0006 Amendment 1). The runtime peeks the snapshot the + /// command would restore and posts `AppEvent::UndoPrepared` + /// (opening the confirmation modal) or `AppEvent::UndoUnavailable` + /// (nothing to undo/redo). Only emitted when undo is enabled — + /// the `App` notes "undo is off" itself under `--no-undo`. + PrepareUndo, + PrepareRedo, + /// User confirmed `undo` / `redo` from inside the modal. The + /// runtime restores the snapshot through the worker, then + /// refreshes the table list + schema cache. + Undo, + Redo, } diff --git a/src/app.rs b/src/app.rs index d66a884..faa9517 100644 --- a/src/app.rs +++ b/src/app.rs @@ -211,6 +211,11 @@ pub struct App { /// by default; refreshed by the runtime on project load /// and after successful DDL. pub schema_cache: crate::completion::SchemaCache, + /// Whether the undo/snapshot machinery is active this session + /// (ADR-0006 Amendment 1). `false` under the `--no-undo` CLI + /// flag; the `undo` / `redo` commands then report undo is off + /// rather than emitting a prepare action. + pub undo_enabled: bool, } /// Dialogs that take over keyboard input when active. @@ -232,6 +237,21 @@ pub enum Modal { /// data root; `b` switches to a path-entry sub-mode for /// projects outside the data root (ADR-0015 §7). LoadPicker(LoadPickerModal), + /// `undo` / `redo` confirmation (ADR-0006 Amendment 1). Names + /// the command that will be undone / re-applied; `Y` confirms, + /// `N` / `Esc` dismisses. + UndoConfirm(UndoConfirmModal), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UndoConfirmModal { + /// The command text of the snapshot being restored — the thing + /// that will be undone (or re-applied, for redo). + pub command: String, + /// When that snapshot was taken (ISO-8601 `Z`). + pub timestamp: String, + /// `false` for undo, `true` for redo — selects the wording. + pub is_redo: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -323,6 +343,9 @@ impl App { modal: None, last_completion: None, schema_cache: crate::completion::SchemaCache::default(), + // Undo is on by default; the runtime flips this off for + // a `--no-undo` session (ADR-0006 Amendment 1). + undo_enabled: true, } } @@ -508,6 +531,44 @@ impl App { self.note_error(crate::t!("project.rebuild_failed", error = error)); Vec::new() } + AppEvent::UndoPrepared { + command, + timestamp, + is_redo, + } => { + self.modal = Some(Modal::UndoConfirm(UndoConfirmModal { + command, + timestamp, + is_redo, + })); + Vec::new() + } + AppEvent::UndoUnavailable { is_redo } => { + if is_redo { + self.note_system(crate::t!("undo.nothing_to_redo")); + } else { + self.note_system(crate::t!("undo.nothing_to_undo")); + } + Vec::new() + } + AppEvent::UndoSucceeded { command, is_redo } => { + self.modal = None; + if is_redo { + self.note_system(crate::t!("undo.redone_ok", command = command)); + } else { + self.note_system(crate::t!("undo.undone_ok", command = command)); + } + Vec::new() + } + AppEvent::UndoFailed { error, is_redo } => { + self.modal = None; + if is_redo { + self.note_error(crate::t!("undo.redo_failed", error = error)); + } else { + self.note_error(crate::t!("undo.undo_failed", error = error)); + } + Vec::new() + } AppEvent::LoadPickerReady { entries } => { if entries.is_empty() { // Empty data root: jump straight to path-entry @@ -1110,6 +1171,24 @@ impl App { self.handle_messages_command(&raw); Vec::new() } + AppCommand::Undo => self.handle_undo_command(false), + AppCommand::Redo => self.handle_undo_command(true), + } + } + + /// `undo` / `redo` dispatch. When undo is disabled (`--no-undo`) + /// the command reports that and does nothing; otherwise it asks + /// the runtime to peek the snapshot and open the confirmation + /// modal (ADR-0006 Amendment 1). + fn handle_undo_command(&mut self, is_redo: bool) -> Vec { + if !self.undo_enabled { + self.note_system(crate::t!("undo.disabled")); + return Vec::new(); + } + if is_redo { + vec![Action::PrepareRedo] + } else { + vec![Action::PrepareUndo] } } @@ -1620,6 +1699,30 @@ impl App { Modal::RebuildConfirm(_) => self.handle_rebuild_confirm_key(key), Modal::PathEntry(state) => self.handle_path_entry_key(key, state), Modal::LoadPicker(state) => self.handle_load_picker_key(key, state), + Modal::UndoConfirm(state) => self.handle_undo_confirm_key(key, &state), + } + } + + fn handle_undo_confirm_key(&mut self, key: KeyEvent, state: &UndoConfirmModal) -> Vec { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + self.modal = None; + if state.is_redo { + vec![Action::Redo] + } else { + vec![Action::Undo] + } + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + self.modal = None; + if state.is_redo { + self.note_system(crate::t!("modal.redo_cancelled")); + } else { + self.note_system(crate::t!("modal.undo_cancelled")); + } + Vec::new() + } + _ => Vec::new(), } } @@ -2092,6 +2195,120 @@ mod tests { .join("\n") } + fn output_contains(app: &App, needle: &str) -> bool { + app.output.iter().any(|l| l.text.contains(needle)) + } + + // ---- undo / redo dispatch + modal (ADR-0006 Amendment 1) ---- + + #[test] + fn undo_command_emits_prepare_undo() { + let mut app = App::new(); + type_str(&mut app, "undo"); + assert_eq!(submit(&mut app), vec![Action::PrepareUndo]); + } + + #[test] + fn redo_command_emits_prepare_redo() { + let mut app = App::new(); + type_str(&mut app, "redo"); + assert_eq!(submit(&mut app), vec![Action::PrepareRedo]); + } + + #[test] + fn undo_when_disabled_notes_and_emits_no_action() { + let mut app = App::new(); + app.undo_enabled = false; + type_str(&mut app, "undo"); + let actions = submit(&mut app); + assert!(actions.is_empty(), "no action when disabled: {actions:?}"); + assert!( + output_contains(&app, "turned off"), + "expected a 'turned off' note, output: {:?}", + app.output + ); + } + + #[test] + fn undo_prepared_opens_modal_naming_the_command() { + let mut app = App::new(); + let actions = app.update(AppEvent::UndoPrepared { + command: "delete from Customers where id = 2".to_string(), + timestamp: "2026-05-24T10:00:00Z".to_string(), + is_redo: false, + }); + assert!(actions.is_empty()); + match &app.modal { + Some(Modal::UndoConfirm(m)) => { + assert_eq!(m.command, "delete from Customers where id = 2"); + assert!(!m.is_redo); + } + other => panic!("expected UndoConfirm modal, got {other:?}"), + } + } + + #[test] + fn undo_modal_y_confirms_and_emits_undo() { + let mut app = App::new(); + app.update(AppEvent::UndoPrepared { + command: "x".to_string(), + timestamp: "t".to_string(), + is_redo: false, + }); + let actions = app.update(key(KeyCode::Char('y'))); + assert_eq!(actions, vec![Action::Undo]); + assert!(app.modal.is_none(), "modal closes on confirm"); + } + + #[test] + fn redo_modal_y_emits_redo() { + let mut app = App::new(); + app.update(AppEvent::UndoPrepared { + command: "x".to_string(), + timestamp: "t".to_string(), + is_redo: true, + }); + assert_eq!(app.update(key(KeyCode::Char('y'))), vec![Action::Redo]); + } + + #[test] + fn undo_modal_esc_cancels_without_action() { + let mut app = App::new(); + app.update(AppEvent::UndoPrepared { + command: "x".to_string(), + timestamp: "t".to_string(), + is_redo: false, + }); + let actions = app.update(key(KeyCode::Esc)); + assert!(actions.is_empty()); + assert!(app.modal.is_none()); + assert!(output_contains(&app, "cancelled")); + } + + #[test] + fn undo_unavailable_notes_nothing_to_undo() { + let mut app = App::new(); + let actions = app.update(AppEvent::UndoUnavailable { is_redo: false }); + assert!(actions.is_empty()); + assert!(output_contains(&app, "nothing to undo")); + } + + #[test] + fn undo_succeeded_closes_modal_and_notes_command() { + let mut app = App::new(); + app.modal = Some(Modal::UndoConfirm(UndoConfirmModal { + command: "x".to_string(), + timestamp: "t".to_string(), + is_redo: false, + })); + app.update(AppEvent::UndoSucceeded { + command: "delete from T --all-rows".to_string(), + is_redo: false, + }); + assert!(app.modal.is_none()); + assert!(output_contains(&app, "delete from T --all-rows")); + } + // ---- ADR-0022 stage 8: Tab completion + Esc/Backspace undo ---- #[test] diff --git a/src/completion.rs b/src/completion.rs index 0055cd0..804497c 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -1303,7 +1303,7 @@ mod tests { // commands in the entry-keyword set. for expected in &[ "quit", "help", "rebuild", "save", "new", "load", "export", - "import", "mode", "messages", + "import", "mode", "messages", "undo", "redo", ] { assert!( cs.contains(&expected.to_string()), diff --git a/src/dsl/command.rs b/src/dsl/command.rs index fbe6e72..ff6e27d 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -386,6 +386,11 @@ pub enum AppCommand { Mode { value: ModeValue }, /// Show or set the messages verbosity. Messages { value: Option }, + /// Undo the most recent change, restoring the previous snapshot + /// after a confirmation prompt (ADR-0006 Amendment 1). + Undo, + /// Re-apply the most recently undone change, after confirmation. + Redo, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -654,6 +659,8 @@ impl Command { AppCommand::Import { .. } => "import", AppCommand::Mode { .. } => "mode", AppCommand::Messages { .. } => "messages", + AppCommand::Undo => "undo", + AppCommand::Redo => "redo", }, } } diff --git a/src/dsl/grammar/app.rs b/src/dsl/grammar/app.rs index ee33111..5601545 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -131,6 +131,14 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result Result { + Ok(Command::App(AppCommand::Undo)) +} + +const fn build_redo(_path: &MatchedPath, _source: &str) -> Result { + Ok(Command::App(AppCommand::Redo)) +} + fn build_save(path: &MatchedPath, _source: &str) -> Result { if path.contains_word("as") { Ok(Command::App(AppCommand::SaveAs)) @@ -265,3 +273,17 @@ pub static MESSAGES: CommandNode = CommandNode { ast_builder: build_messages, help_id: Some("app.messages"), usage_ids: &["parse.usage.messages"],}; + +pub static UNDO: CommandNode = CommandNode { + entry: Word::keyword("undo"), + shape: EMPTY_SEQ, + ast_builder: build_undo, + help_id: Some("app.undo"), + usage_ids: &["parse.usage.undo"],}; + +pub static REDO: CommandNode = CommandNode { + entry: Word::keyword("redo"), + shape: EMPTY_SEQ, + ast_builder: build_redo, + help_id: Some("app.redo"), + usage_ids: &["parse.usage.redo"],}; diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index 4284229..1e5638f 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -560,6 +560,8 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[ (&app::IMPORT, CommandCategory::Simple), (&app::MODE, CommandCategory::Simple), (&app::MESSAGES, CommandCategory::Simple), + (&app::UNDO, CommandCategory::Simple), + (&app::REDO, CommandCategory::Simple), (&ddl::DROP, CommandCategory::Simple), (&ddl::ADD, CommandCategory::Simple), (&ddl::RENAME, CommandCategory::Simple), diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index f36eec9..eef1833 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -2750,6 +2750,22 @@ mod tests { assert_eq!(parse("rebuild").unwrap(), Command::App(AppCommand::Rebuild)); } + #[test] + fn walker_parses_undo() { + assert_eq!(parse("undo").unwrap(), Command::App(AppCommand::Undo)); + } + + #[test] + fn walker_parses_redo() { + assert_eq!(parse("redo").unwrap(), Command::App(AppCommand::Redo)); + } + + #[test] + fn walker_parses_undo_case_insensitive() { + // Keywords are case-insensitive (ADR-0009). + assert_eq!(parse("UNDO").unwrap(), Command::App(AppCommand::Undo)); + } + #[test] fn walker_parses_new() { assert_eq!(parse("new").unwrap(), Command::App(AppCommand::New)); diff --git a/src/event.rs b/src/event.rs index 79481f3..e30b009 100644 --- a/src/event.rs +++ b/src/event.rs @@ -115,6 +115,33 @@ pub enum AppEvent { RebuildFailed { error: String, }, + /// Runtime peeked the snapshot `undo`/`redo` would restore and + /// is ready for the user to confirm (ADR-0006 Amendment 1). App + /// opens the confirmation modal naming `command`. `is_redo` + /// selects undo vs redo wording. + UndoPrepared { + command: String, + timestamp: String, + is_redo: bool, + }, + /// Nothing to undo / redo (the ring or redo stack was empty). + /// App surfaces a friendly note. + UndoUnavailable { + is_redo: bool, + }, + /// Undo / redo completed. App closes the modal, notes the + /// command that was undone/redone; the runtime also refreshes + /// the table list + schema cache. + UndoSucceeded { + command: String, + is_redo: bool, + }, + /// Undo / redo failed (a rare restore error). App closes the + /// modal and surfaces the error. + UndoFailed { + error: String, + is_redo: bool, + }, /// Runtime has gathered the list of available projects /// for the load picker. App opens the picker modal. LoadPickerReady { diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 2ba2863..e1db53d 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -168,6 +168,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("help.app.import", &[]), ("help.app.mode", &[]), ("help.app.messages", &[]), + ("help.app.undo", &[]), + ("help.app.redo", &[]), ("help.ddl.create", &[]), ("help.ddl.drop", &[]), ("help.ddl.add", &[]), @@ -257,7 +259,9 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("parse.usage.new", &[]), ("parse.usage.quit", &[]), ("parse.usage.rebuild", &[]), + ("parse.usage.redo", &[]), ("parse.usage.replay", &[]), + ("parse.usage.undo", &[]), ("parse.usage.save", &[]), ("parse.usage.select", &[]), ("parse.usage.show_data", &[]), @@ -353,6 +357,22 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("modal.rebuild_cancelled", &[]), ("modal.rebuild_confirm_prompt", &[]), ("modal.rebuild_confirm_title", &[]), + ("modal.redo_cancelled", &[]), + ("modal.redo_confirm_command", &[]), + ("modal.redo_confirm_title", &[]), + ("modal.undo_cancelled", &[]), + ("modal.undo_confirm_command", &[]), + ("modal.undo_confirm_prompt", &[]), + ("modal.undo_confirm_title", &[]), + ("modal.undo_confirm_when", &["timestamp"]), + // ---- Undo / redo command surfaces (ADR-0006 Amendment 1) ---- + ("undo.disabled", &[]), + ("undo.nothing_to_undo", &[]), + ("undo.nothing_to_redo", &[]), + ("undo.undone_ok", &["command"]), + ("undo.redone_ok", &["command"]), + ("undo.undo_failed", &["error"]), + ("undo.redo_failed", &["error"]), // ---- Status bar + panels ---- ("panel.hint_empty", &[]), ("panel.hint_title", &[]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index cf23959..3c7f647 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -253,6 +253,10 @@ help: mode simple|advanced — switch input mode messages: |- messages [short|verbose] — show or switch error-message verbosity (verbose is the default) + undo: |- + undo — undo the last change (with confirmation) + redo: |- + redo — redo the last undone change (with confirmation) ddl: create: |- create table with pk [(), ...] — create a table @@ -484,6 +488,8 @@ parse: import: "import [as ]" mode: "mode simple | mode advanced" messages: "messages | messages short | messages verbose" + undo: "undo" + redo: "redo" # ---- Pre-submit diagnostics (ADR-0027) ------------------------------- # Surfaced by the validity indicator and the hint panel before @@ -665,6 +671,25 @@ modal: load_picker_path_prompt: "Path to project directory:" rebuild_confirm_title: "Rebuild project" rebuild_confirm_prompt: "Continue?" + # Undo / redo confirmation (ADR-0006 Amendment 1). + undo_confirm_title: "Undo last change" + redo_confirm_title: "Redo last undone change" + undo_confirm_command: "This will undo:" + redo_confirm_command: "This will re-apply:" + undo_confirm_when: "snapshot taken {timestamp}" + undo_confirm_prompt: "Restore that earlier state?" + undo_cancelled: "undo cancelled" + redo_cancelled: "redo cancelled" + +# ---- Undo / redo command surfaces (ADR-0006 Amendment 1) ------------- +undo: + disabled: "undo is turned off for this session (started with --no-undo)" + nothing_to_undo: "nothing to undo" + nothing_to_redo: "nothing to redo" + undone_ok: "undone: {command}" + redone_ok: "re-applied: {command}" + undo_failed: "could not undo: {error}" + redo_failed: "could not redo: {error}" # ---- Save / save-as command surfaces --------------------------------- save: diff --git a/src/runtime.rs b/src/runtime.rs index 122b836..c2e6924 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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 = 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, + undo_enabled: bool, ) -> Result> { let (event_tx, mut event_rx) = mpsc::channel::(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, + 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, 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, 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" ) } diff --git a/src/ui.rs b/src/ui.rs index 8bd85c1..593da91 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -63,6 +63,7 @@ fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>, Modal::RebuildConfirm(m) => render_rebuild_confirm(&m.summary, theme, frame, area), Modal::PathEntry(m) => render_path_entry(m, theme, frame, area), Modal::LoadPicker(m) => render_load_picker(m, theme, frame, area), + Modal::UndoConfirm(m) => render_undo_confirm(m, theme, frame, area), } } @@ -316,6 +317,85 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a frame.render_widget(paragraph, dialog_area); } +/// `undo` / `redo` confirmation modal (ADR-0006 Amendment 1). Names +/// the command that will be undone / re-applied and when its +/// snapshot was taken, then prompts `Y` / `N`. +fn render_undo_confirm( + m: &crate::app::UndoConfirmModal, + theme: &Theme, + frame: &mut Frame<'_>, + area: Rect, +) { + let dialog_w = area.width.clamp(20, 60); + let inner_w = dialog_w.saturating_sub(4) as usize; + + let intro = if m.is_redo { + crate::t!("modal.redo_confirm_command") + } else { + crate::t!("modal.undo_confirm_command") + }; + let mut body_lines: Vec = wrap_lines(&format!("{intro} {}", m.command), inner_w); + body_lines.extend(wrap_lines( + &crate::t!("modal.undo_confirm_when", timestamp = m.timestamp), + 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, + }; + + frame.render_widget(ratatui::widgets::Clear, dialog_area); + + let title = if m.is_redo { + crate::t!("modal.redo_confirm_title") + } else { + crate::t!("modal.undo_confirm_title") + }; + 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( + format!(" {title} "), + title_style, + )])) + .style(Style::default().bg(theme.bg).fg(theme.fg)); + + let mut text_lines: Vec> = 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(crate::t!("modal.undo_confirm_prompt"))); + 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(format!(" {} ", crate::t!("shortcut.yes"))), + Span::styled("[N]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)), + Span::raw(format!(" {} ", crate::t!("shortcut.no"))), + Span::styled("Esc", Style::default().fg(theme.muted)), + Span::styled( + format!(" {}", crate::t!("shortcut.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 diff --git a/tests/replay_command.rs b/tests/replay_command.rs index bb9ea1e..9410d4a 100644 --- a/tests/replay_command.rs +++ b/tests/replay_command.rs @@ -151,7 +151,8 @@ fn replay_of_actual_history_log_runs_ok_commands_and_skips_err() { #[test] fn replay_skips_app_lifecycle_commands_silently() { // ADR-0034: a real `history.log` contains app-lifecycle commands - // (`save as` / `load` / `new` / `export` / `mode` / `rebuild` …). + // (`save as` / `load` / `new` / `export` / `mode` / `rebuild` / + // `undo` / `redo` …). // Replay skips them — they are session navigation, not schema/data // reconstruction, and the worker dispatch cannot run them (it would // panic on a parsed app command, or abort on the modal forms that @@ -177,8 +178,10 @@ fn replay_skips_app_lifecycle_commands_silently() { 2026-05-24T10:00:08Z|ok|rebuild\n\ 2026-05-24T10:00:09Z|ok|help\n\ 2026-05-24T10:00:10Z|ok|quit\n\ - 2026-05-24T10:00:11Z|ok|add column T: v (text)\n\ - 2026-05-24T10:00:12Z|ok|insert into T (id, v) values (1, 'alpha')\n", + 2026-05-24T10:00:11Z|ok|undo\n\ + 2026-05-24T10:00:12Z|ok|redo\n\ + 2026-05-24T10:00:13Z|ok|add column T: v (text)\n\ + 2026-05-24T10:00:14Z|ok|insert into T (id, v) values (1, 'alpha')\n", ); let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await }); diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index 55b7f58..06d7fc0 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -250,6 +250,8 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String { AppCommand::Import { .. } => "App(Import)".into(), AppCommand::Mode { .. } => "App(Mode)".into(), AppCommand::Messages { .. } => "App(Messages)".into(), + AppCommand::Undo => "App(Undo)".into(), + AppCommand::Redo => "App(Redo)".into(), }, } }