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
+217
View File
@@ -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<Action> {
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<Action> {
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]