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:
+217
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user