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:
+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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user