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:
@@ -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
@@ -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
@@ -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()),
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", &[]),
|
||||
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user