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
+7
View File
@@ -386,6 +386,11 @@ pub enum AppCommand {
Mode { value: ModeValue },
/// Show or set the messages verbosity.
Messages { value: Option<MessagesValue> },
/// 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",
},
}
}
+22
View File
@@ -131,6 +131,14 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, Va
Ok(Command::App(AppCommand::Rebuild))
}
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Undo))
}
const fn build_redo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Redo))
}
fn build_save(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
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"],};
+2
View File
@@ -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),
+16
View File
@@ -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));