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:
claude@clouddev1
2026-06-02 06:47:34 +00:00
parent ae57c6fc82
commit 4cd574b909
16 changed files with 769 additions and 14 deletions
+62 -5
View File
@@ -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`: