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:
@@ -561,3 +561,130 @@ note pointing to this ADR.
|
||||
- `history.log` becomes the persistent history surface.
|
||||
Once `replay` (OOS-2) and `undo` (OOS-1) land, they read
|
||||
from the same file with no schema changes.
|
||||
|
||||
## Amendment 1 — Persist & restore the input mode (2026-05-31, issue #14)
|
||||
|
||||
### Problem
|
||||
|
||||
`--resume` (and any project open) always started in the default
|
||||
`simple` input mode. A learner who quit in advanced mode had to
|
||||
re-toggle on every launch. The mode the user was in is a real,
|
||||
restorable preference; losing it each session is a small but
|
||||
repeated UX cost.
|
||||
|
||||
### Decision
|
||||
|
||||
The **input mode is per-project state stored in `project.yaml`**,
|
||||
restored on every open and persisted as it changes. A teacher can
|
||||
prepare a project that opens ready in advanced mode and hand it to
|
||||
students; a learner who works across projects has each one's mode
|
||||
restored when it loads ("loading triggers the mode switch each
|
||||
time" — the user's framing). This is a deliberate, useful form of
|
||||
the "mode travels with the project" property, *not* an accidental
|
||||
leak (the alternative — a private per-user file — was rejected for
|
||||
exactly that reason: it would prevent the teacher-prep use case).
|
||||
|
||||
**1. Storage — `project.yaml`, alongside `created_at`.** A new
|
||||
optional `mode:` key under the `project:` mapping:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
project:
|
||||
created_at: 2026-05-31T00:00:00Z
|
||||
mode: advanced
|
||||
```
|
||||
|
||||
It is **project metadata, not schema** — `rebuild` ignores it
|
||||
(reconstructs the db from tables/data; the mode plays no part).
|
||||
The field is **optional with a `simple` default**: pre-#14 files
|
||||
(no `mode:`) parse unchanged, no version bump, no migrator — the
|
||||
same backward-compatible pattern as the `unique` index flag. It
|
||||
**does** travel in an `export` zip (it is part of `project.yaml`),
|
||||
which is the intended teacher→student behaviour.
|
||||
|
||||
**2. Mode is live UI state, not stored in the database.** Unlike
|
||||
`created_at` (whose source of truth is gone after creation, so it
|
||||
round-trips through the internal metadata table), the mode's
|
||||
source of truth is always live — it is `App.mode`. So it is **not**
|
||||
put in the database. Instead the persistence handle carries the
|
||||
**current mode**, and the worker **stamps it into `project.yaml`
|
||||
on every write**. Because every schema-mutating command rewrites
|
||||
the whole file, writing the *current* mode each time means a later
|
||||
command can never clobber it back to the default — there is
|
||||
nothing to "preserve", only the live value to write. (This
|
||||
replaced an initial over-engineered design that mirrored the mode
|
||||
in the db metadata table; the simpler "write what we're in"
|
||||
approach is correct because the mode is never reconstructed from
|
||||
text.)
|
||||
|
||||
**3. Restore precedence: `--mode` > stored > `simple`.** A new
|
||||
CLI flag `--mode simple|advanced` overrides the stored mode at
|
||||
startup; it combines with `--resume` and a positional path (not
|
||||
mutually exclusive — on collision the flag wins). At boot the
|
||||
runtime reads the stored mode (`Persistence::read_stored_mode`,
|
||||
which returns `None` for an absent field so "no preference" stays
|
||||
distinct from an explicit `simple`), applies the flag, sets
|
||||
`App.mode`, and seeds the persistence handle so the resolved mode
|
||||
is what subsequent writes record. The `--mode` override applies
|
||||
**only at boot** — a later project switch restores that project's
|
||||
own stored mode.
|
||||
|
||||
**4. Mid-session changes.** The `mode` command emits a new
|
||||
`Action::PersistMode`; the runtime records it through the worker
|
||||
(`Database::set_mode`), which updates the live mode and writes
|
||||
`project.yaml` immediately (crash-safe). Persisting the mode is
|
||||
**best-effort** throughout: a failure must never escalate a UI
|
||||
action into a fatal (the in-memory mode has already changed).
|
||||
|
||||
**5. Persist on unload (the deciding rule).** The mode is written
|
||||
whenever the current project is **unloaded** — on quit and on a
|
||||
project switch (load / new / save-as / import), the runtime calls
|
||||
`set_mode(App.mode)` on the outgoing database before it is dropped.
|
||||
This is what makes the stored mode **deterministic and
|
||||
non-confusing**: by the time you leave a project, the mode you were
|
||||
in is always recorded — including a bare `--mode` override or a
|
||||
read-only session that ran no command. (An earlier "persist only on
|
||||
the `mode` command or a schema-changing command" rule was rejected
|
||||
as confusingly *selective* — whether a `--mode` override stuck
|
||||
depended on whether you happened to run a DDL. "On unload" was
|
||||
chosen over "on every command" to avoid rewriting `project.yaml` —
|
||||
and bumping its mtime, which orders the load picker — on every
|
||||
read-only `select`/`show data`.) On a switch the **outgoing**
|
||||
project's mode is saved first, then the **incoming** project's
|
||||
stored mode is restored and carried to the `App` via the
|
||||
`ProjectSwitched` event ("loading triggers the mode switch each
|
||||
time").
|
||||
|
||||
### Scope / non-changes
|
||||
|
||||
- No new project file, no database schema change, no migration.
|
||||
- `rebuild` is unaffected (mode is not schema; the round-trip
|
||||
through text never touches it).
|
||||
- The default for a brand-new project is unchanged (`simple`).
|
||||
- Coverage: `mode.rs` keyword parse/round-trip +
|
||||
`resolve_startup_applies_flag_then_stored_then_default`
|
||||
(precedence helper);
|
||||
`yaml.rs::{mode_round_trips_through_serialize_and_parse,
|
||||
parse_schema_defaults_mode_to_simple_when_field_absent,
|
||||
parse_stored_mode_distinguishes_absent_from_explicit,
|
||||
parse_stored_mode_falls_back_to_none_on_unknown_value}`;
|
||||
`persistence::{read_stored_mode_round_trips_a_written_project_yaml,
|
||||
read_stored_mode_is_none_for_a_missing_project_yaml}`;
|
||||
`db::{set_mode_persists_and_survives_a_later_ddl_command (the
|
||||
core no-clobber guarantee),
|
||||
set_mode_persists_even_with_no_prior_command (the persist-on-unload
|
||||
guarantee)}`; `archive::export_carries_the_stored_input_mode`
|
||||
(the teacher-export round-trip); `cli` `--mode` parse/precedence;
|
||||
`app::{mode_command_changes_mode_and_emits_persist_action,
|
||||
mode_command_via_one_shot_escape_persists_advanced,
|
||||
project_switched_event_restores_the_stored_mode}`. The runtime's
|
||||
unload call sites (quit + `handle_project_switch`) are thin
|
||||
wiring over the tested `Database::set_mode`.
|
||||
|
||||
### Relationship to the Iteration 6 backlog
|
||||
|
||||
Issue #14 named this an Iteration 6 piece (persistent input
|
||||
history / `--resume`). `--resume` itself already shipped; this
|
||||
amendment adds the mode dimension. It is independent of the
|
||||
`history.log`-based input-history hydration (§12), which remains
|
||||
its own piece of Iteration 6.
|
||||
|
||||
Reference in New Issue
Block a user