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
+13
View File
@@ -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,
}
+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]
+1 -1
View File
@@ -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()),
+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));
+27
View File
@@ -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 {
+20
View File
@@ -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", &[]),
+25
View File
@@ -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 <T> with pk [<col>(<type>), ...] — create a table
@@ -484,6 +488,8 @@ parse:
import: "import <zip-path> [as <target>]"
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:
+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"
)
}
+80
View File
@@ -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<String> = 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<Line<'_>> = 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