feat: persist & restore per-project input mode (#14)
The input mode always started in simple; a learner who quit in advanced had to re-toggle every launch. Store the mode per-project in project.yaml (project.mode:, optional, default simple) and restore it on every open. Mode is live UI state, not schema: the worker stamps the current mode into project.yaml on every write, so a later command rewrites the live value rather than clobbering it — no db round-trip needed. The mode is persisted on unload (quit + project switch) so the mode you leave a project in is always what reopens; the `mode` command also persists immediately. A switch saves the outgoing mode, then restores the incoming project's stored mode. New --mode simple|advanced CLI flag (precedence --mode > stored > simple; combines with --resume). A teacher can ship a project that opens in advanced mode and export it to students (the mode travels in the zip). ADR-0015 Amendment 1; ADR-0003 note; help banner; requirements L1b.
This commit is contained in:
+62
-5
@@ -196,7 +196,20 @@ pub async fn run(args: Args) -> Result<()> {
|
||||
let display_name = project.display_name().to_string();
|
||||
let project_path = project.path().to_path_buf();
|
||||
let project_is_temp = matches!(project.kind(), ProjectKind::Temp);
|
||||
let persistence = crate::persistence::Persistence::new(project_path.clone());
|
||||
// Resolve the startup input mode (ADR-0015 mode-restore
|
||||
// amendment, issue #14). Precedence: `--mode` flag > the
|
||||
// project's stored mode > the default (`Simple`). A pre-#14
|
||||
// project, or one with no `mode:` field, reads as `None` and
|
||||
// falls through to the default. The resolved mode is given to
|
||||
// `Persistence` so every `project.yaml` write records it, and
|
||||
// set on the `App` so the first render shows the right mode.
|
||||
let resolved_mode = crate::mode::Mode::resolve_startup(
|
||||
args.mode,
|
||||
crate::persistence::Persistence::read_stored_mode(&project_path),
|
||||
);
|
||||
info!(mode = %resolved_mode, "resolved startup input mode");
|
||||
let persistence =
|
||||
crate::persistence::Persistence::new(project_path.clone()).with_mode(resolved_mode);
|
||||
// Capture whether the .db file existed BEFORE we open it —
|
||||
// sqlite creates it on connect, so this is the only honest
|
||||
// signal that we need to rebuild from text (ADR-0015 §7).
|
||||
@@ -259,6 +272,7 @@ pub async fn run(args: Args) -> Result<()> {
|
||||
project_is_temp,
|
||||
initial_events,
|
||||
undo_enabled,
|
||||
resolved_mode,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = teardown_terminal(&mut terminal) {
|
||||
@@ -307,6 +321,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)] // boot params; all inherent to one session
|
||||
async fn run_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
theme: Theme,
|
||||
@@ -315,6 +330,7 @@ async fn run_loop(
|
||||
project_is_temp: bool,
|
||||
initial_events: Vec<AppEvent>,
|
||||
undo_enabled: bool,
|
||||
initial_mode: crate::mode::Mode,
|
||||
) -> Result<Option<String>> {
|
||||
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
|
||||
let reader_handle = spawn_event_reader(event_tx.clone());
|
||||
@@ -323,6 +339,11 @@ async fn run_loop(
|
||||
app.project_name = Some(project_display_name);
|
||||
app.project_is_temp = project_is_temp;
|
||||
app.undo_enabled = undo_enabled;
|
||||
// Start in the resolved input mode (ADR-0015 mode-restore
|
||||
// amendment, issue #14): `--mode` > stored project mode >
|
||||
// default. `Persistence` already carries the same value, so the
|
||||
// worker records it on the next write.
|
||||
app.mode = initial_mode;
|
||||
// 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
|
||||
@@ -384,6 +405,14 @@ async fn run_loop(
|
||||
match action {
|
||||
Action::Quit => {
|
||||
debug!("quit action received");
|
||||
// Persist the mode we're leaving in, so it is
|
||||
// restored next time the project opens (ADR-0015
|
||||
// mode-restore amendment, issue #14 — persist on
|
||||
// unload). Best-effort: a write failure must not
|
||||
// block quitting.
|
||||
if let Err(e) = session.database().set_mode(app.mode).await {
|
||||
tracing::warn!(error = %e, "could not persist input mode on quit");
|
||||
}
|
||||
should_quit = true;
|
||||
}
|
||||
Action::ExecuteDsl {
|
||||
@@ -446,6 +475,7 @@ async fn run_loop(
|
||||
source,
|
||||
&event_tx,
|
||||
undo_enabled,
|
||||
app.mode,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -456,6 +486,7 @@ async fn run_loop(
|
||||
source,
|
||||
&event_tx,
|
||||
undo_enabled,
|
||||
app.mode,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -466,6 +497,7 @@ async fn run_loop(
|
||||
source,
|
||||
&event_tx,
|
||||
undo_enabled,
|
||||
app.mode,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -493,6 +525,7 @@ async fn run_loop(
|
||||
source,
|
||||
&event_tx,
|
||||
undo_enabled,
|
||||
app.mode,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -516,6 +549,14 @@ async fn run_loop(
|
||||
event_tx.clone(),
|
||||
);
|
||||
}
|
||||
Action::PersistMode(mode) => {
|
||||
// Best-effort: the in-memory mode already changed;
|
||||
// a failure to record it must not fatal a UI toggle
|
||||
// (ADR-0015 mode-restore amendment, issue #14).
|
||||
if let Err(e) = session.database().set_mode(mode).await {
|
||||
tracing::warn!(error = %e, "could not persist input mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A keystroke hides the indicator and re-arms the
|
||||
@@ -612,15 +653,25 @@ async fn handle_project_switch(
|
||||
source: String,
|
||||
event_tx: &mpsc::Sender<AppEvent>,
|
||||
undo_enabled: bool,
|
||||
outgoing_mode: crate::mode::Mode,
|
||||
) {
|
||||
// Persist the outgoing project's mode before it is unloaded
|
||||
// (ADR-0015 mode-restore amendment, issue #14 — persist on
|
||||
// unload). Best-effort, and before `perform_switch` drops the
|
||||
// outgoing database. The switched-to project's own stored mode
|
||||
// is restored separately, via the `ProjectSwitched` event.
|
||||
if let Err(e) = session.database().set_mode(outgoing_mode).await {
|
||||
tracing::warn!(error = %e, "could not persist input mode on switch");
|
||||
}
|
||||
match perform_switch(session, req, source, undo_enabled).await {
|
||||
Ok((display_name, is_temp)) => {
|
||||
Ok((display_name, is_temp, mode)) => {
|
||||
let history_entries = read_history_seed(session.project().path());
|
||||
let _ = event_tx
|
||||
.send(AppEvent::ProjectSwitched {
|
||||
display_name,
|
||||
is_temp,
|
||||
history_entries,
|
||||
mode,
|
||||
})
|
||||
.await;
|
||||
if let Ok(tables) = session.database().list_tables().await {
|
||||
@@ -663,7 +714,7 @@ async fn perform_switch(
|
||||
req: SwitchRequest,
|
||||
source: String,
|
||||
undo_enabled: bool,
|
||||
) -> Result<(String, bool), String> {
|
||||
) -> Result<(String, bool, crate::mode::Mode), String> {
|
||||
use crate::persistence::Persistence;
|
||||
|
||||
// For SaveAs we need a resolved target path up front
|
||||
@@ -807,7 +858,13 @@ async fn perform_switch(
|
||||
// had been deleted).
|
||||
let db_path = new_project.db_path();
|
||||
let db_existed = db_path.exists();
|
||||
let persistence = Persistence::new(new_path.clone());
|
||||
// Restore the switched-to project's stored input mode (ADR-0015
|
||||
// mode-restore amendment, issue #14): "loading triggers the mode
|
||||
// switch each time." A switch uses the target's stored mode
|
||||
// directly — the startup `--mode` override applies only at boot,
|
||||
// not to subsequent loads. Absent/pre-#14 → default.
|
||||
let restored_mode = Persistence::read_stored_mode(&new_path).unwrap_or_default();
|
||||
let persistence = Persistence::new(new_path.clone()).with_mode(restored_mode);
|
||||
let new_database =
|
||||
Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -843,7 +900,7 @@ async fn perform_switch(
|
||||
tracing::warn!(error = %e, "could not update last_project after switch");
|
||||
}
|
||||
|
||||
Ok((display_name, is_temp))
|
||||
Ok((display_name, is_temp, restored_mode))
|
||||
}
|
||||
|
||||
/// Resolve the destination directory for an `import`:
|
||||
|
||||
Reference in New Issue
Block a user