edamame 0.1.5

A fast TUI Markdown editor and viewer
Documentation
# Machine state (`state.toml`)

> Part of the edamame contributor deep-dives. Index and project-wide conventions: [`AGENTS.md`](../../AGENTS.md). Sibling docs live in [`docs/dev/`](.).


[`crate::config::State`] holds the values edamame writes on its own — `seen_terminal_fingerprints`, `last_update_check`, `update_notified_for`, `last_version_seen`, `cursors` (the per-file cursor positions), and the daily-tip stamps `seen_daily_tips` and `last_tip_shown` — persisted to `state.toml` in the platform *data* directory, separate from the user-facing `config.toml`. The user-facing view is in [`configuration.md`](../configuration.md#machine-state-statetoml); the mechanics live here.

- **Why a separate file, not `[editor]` keys.** These are bookkeeping, never settings, and some (`seen_terminal_fingerprints`, `cursors`) are inherently per-machine — so they don't belong in a `config.toml` users hand-edit and share across machines from a dotfiles repo. The on/off switches (`check_for_updates`, `daily_tips`, `remember_cursor`) are real settings and stay on `Config`.
- **The daily-tip fields (`seen_daily_tips: Vec<u32>`, `last_tip_shown: u64`).** `seen_daily_tips` is the ids ([`crate::app::tips::Tip::id`]) already shown, so a tip never repeats; `last_tip_shown` is the once-a-day gate stamp, read exactly as `last_update_check` is (via [`crate::app::update_check::interval_elapsed`], the gate both share). Both were born in `State` (as was `cursors`), so they need no migration and are absent from `BOOKKEEPING_KEYS_BARE`. A `Vec`, not a set: TOML has no set type, the list stays small, and a stable order keeps diffs quiet. The rotation and the update-vs-tip precedence live in [`crate::app::tip_notice`].
- **`cursors: Vec<RememberedCursor>` — where each file's cursor was left.** Keyed by [`crate::config::state::cursor_key`] (the canonical path when the file exists, else the path as given), so `edamame notes.md` and an absolute rerun resolve to one entry. A `Vec` rather than a map because *write* order is the recency order [`MAX_REMEMBERED_CURSORS`] evicts by — no timestamp needed. With `editor.remember_cursor` off nothing is read or written and existing entries are left alone (off is a pause, not a purge). Written by [`crate::app::App::record_cursor_position`] (on a file switch and at the end of `App::run`), read once in `App::new`, and applied on the first frame by [`crate::app::App::apply_pending_cursor_restore`] — the same park-and-consume shape as the command line's `#section` anchor, which is why only the file named on the command line restores. Two drop rules: a pending `startup_anchor` (an explicit `#section`) drops the restore entirely, since the restore runs *before* the anchor each frame and would re-centre it away on a later settle frame; and an offset past the file's current end (the file was rewritten shorter) is dropped and the file opens at the top. `record_cursor_position` records offset `0` only to *update* a file that already has an entry; a never-remembered file left at the top earns none, since an offset-`0` restore is indistinguishable from no restore.
- **The restore re-centres for a few frames, because the row space moves under it.** `ImageCache::reserved_rows` answers with the `image_max_height` fallback until a decode lands, then each figure collapses to its real height; a centre computed once on frame 0 left the viewport past the *end* of the settled document, painting an empty screen. So `apply_pending_cursor_restore` stays armed while any image block is still on the fallback (`row_space_still_settling`, which counts a block whose decode is not yet *requested* — the dispatch runs later in the same `prepare_viewport`) and re-centres each frame, bounded by [`CURSOR_RESTORE_FRAMES`] so media that never resolves cannot re-centre forever. [`EditorState::clamp_scroll_to_document`], called at the end of every `refresh_parsed`, is the second line of defence: whatever a rebuild does to the row space, the viewport is never left past the last row.
- **`State` is a plain serialize, unlike `Config::save`.** The file is machine-only, so there are no comments to preserve and no `toml_edit` merge — `State::save` writes `toml::to_string_pretty` outright. A missing or partial file loads as defaults (`#[serde(default)]`), and a parse error is edamame's own bug: logged, then treated as "no state recorded", with no warning modal (a machine file the user never touched can't have a user-facing error).
- **The data dir is [`Config::data_dir`], shared with the tracing log** — `$XDG_DATA_HOME/edamame` or the platform equivalent, distinct from the config dir. Relocating it is a change to that one function.
- **`--no-config` and test isolation suppress it too.** `State::load`/`State::save` gate on `config_reads_allowed()` / `config_writes_allowed()` — the [`persistence`](../../src/config/persistence.rs) switch ([cli.md](cli.md)), whose scope is deliberately broader than its name: every file edamame persists, config dir *and* data dir. A suppressed session reads defaults and writes nothing.
- **One-time migration: seed before strip.** `Config::load` seeds `state.toml` from a legacy `config.toml`'s `[editor]` bookkeeping (`seed_state_from_legacy_config`) *before* `migrate_config_file_in_place` strips those keys — order matters, since the strip destroys the source. Seeding is a no-op once `state.toml` exists (an existing state file is authoritative and must never be clobbered by a stale `config.toml`), when the config file is absent/unparseable, or when it carries none of the keys (a fresh install stays fileless until edamame writes bookkeeping of its own).
- **The migration is fail-soft, and that is safe.** The strip runs regardless of whether the seed write succeeded, so a failed seed does drop the values from disk — but they are re-derivable bookkeeping, never user settings, so the worst cost is a repeated capabilities notice or a re-run update check on the next launch, not lost configuration.
- **Stripped keys must not warn.** [`BOOKKEEPING_KEYS_BARE`] is the single source of truth for the four *migrated* names. It drives both the strip (in `config::config`) and the unknown-key filter in `read_main_config` (which prefixes each with `editor.`), so an un-migrated file — including one a `--no-config` run can't rewrite — never greets the user with a modal about keys edamame itself moved. The filter stays narrow: a genuinely unknown key beside them still warns.
- **`App` owns a `State` beside its `Config`.** Writes go through `save_state_bookkeeping` → `State::save` (a bare save, no "Configuration updated" flash — the user changed no setting; shared by the update check, the post-upgrade stamp, and the daily tip), or `save_state_with_flash` when a state-only outcome does want the flash. The external-editor reload ignores the reloaded `LoadedConfig::state`: the user edited `config.toml`, not `state.toml`.

See also [`update-check.md`](update-check.md) and [`post-upgrade.md`](post-upgrade.md), whose stamps are the writers of these fields.