From d4c59c292961f9252a0ff8b5cc67f8711db5f265 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Tue, 14 Jul 2026 10:35:00 +0200 Subject: [PATCH] feat(firmware): boot into the last-active file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the active buffer in a device-local marker (/sd/.typoena-last, beside the dirty journal) on every switch; when open_last_on_boot is set, boot resumes it. Prefs are now read before the boot note is chosen. A missing/garbled/stale marker falls back to notes.md — only the default note stays boot-fatal. --- firmware/src/main.rs | 85 ++++++++++++++++++++++++++----------- firmware/src/persistence.rs | 43 +++++++++++++++++++ 2 files changed, 103 insertions(+), 25 deletions(-) diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 1d1b2f8..29b281a 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -79,7 +79,18 @@ fn main() -> anyhow::Result<()> { // card — and treat a missing card / repo / unreadable note as fatal: a // writing appliance that silently started empty would clobber the note on // the next `:w`. See docs/v0.1-mvp-technical.md, boot sequence. - let (storage, saved) = boot_storage(&mut epd); + let storage = boot_storage(&mut epd); + // Editor preferences (.typoena.toml, git-tracked). Read before the boot + // buffer is chosen (`open_last_on_boot` decides which file that is) and + // before the first render (`line_numbers` shapes the opening frame). A + // missing / unreadable / partial file falls back to defaults, so a fresh + // card just works. + let prefs = match storage.load_path(PREFS_PATH) { + Ok(src) => Prefs::parse(&src), + Err(_) => Prefs::default(), + }; + log::info!("prefs: {prefs:?}"); + let (boot_path, boot_scope, saved) = boot_note(&mut epd, &storage, &prefs); // Feed the file palette (Ctrl-P) from a background walk. Enumerating // /sd/repo + /sd/local takes seconds on a big tree (4.3 s at 1098 files, @@ -120,27 +131,15 @@ fn main() -> anyhow::Result<()> { (req_tx, res_rx) }; - // Seed the editor from the saved note. Boots in Normal mode with the caret - // on the last character (the resume point) — press `i`/`a`/`o` to write. - // The boot note is Tracked (`/sd/repo/notes.md`); `:e` / the palette (v0.5) - // open others, Tracked or Local. - let mut ed = Editor::with_file(NOTES.to_string(), Scope::Tracked, saved); + // Seed the editor from the boot note (`boot_note` above: the default + // `/sd/repo/notes.md`, or the resumed last file when `open_last_on_boot` + // is set). Boots in Normal mode with the caret on the last character (the + // resume point) — press `i`/`a`/`o` to write. + let mut ed = Editor::with_file(boot_path.clone(), boot_scope, saved); // Confirm the boot-load on the panel (no serial console in normal use): // "loaded " using the note's filename without its suffix (notes.md -> // notes). Cleared by the first keystroke, like any snackbar. - let name = std::path::Path::new(NOTES) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("notes"); - ed.set_notice(format!("loaded {name}")); - // Editor preferences (.typoena.toml, git-tracked). Read before the first - // render so `line_numbers` shapes the opening frame. A missing / unreadable / - // partial file falls back to defaults, so a fresh card just works. - let prefs = match storage.load_path(PREFS_PATH) { - Ok(src) => Prefs::parse(&src), - Err(_) => Prefs::default(), - }; - log::info!("prefs: {prefs:?}"); + ed.set_notice(format!("loaded {}", file_stem(&boot_path))); ed.set_prefs(prefs); // Snippet library (.typoena.snippets.json, git-tracked). Parsed with // serde_json in the editor crate; a missing / unreadable / malformed file is @@ -164,6 +163,11 @@ fn main() -> anyhow::Result<()> { // fires once per typing burst (and doesn't retry-storm if a save fails). // Reset on the next activity. let mut idle_saved = false; + // What the last-file marker was last written with. Starts empty so the + // first loop pass records the boot buffer — the marker then always names + // the active file, whether `open_last_on_boot` currently reads it or not + // (flipping the pref on works from the very next boot). + let mut last_file = String::new(); // Set when a paint fails (see the refresh block below): the next paint then // does a full refresh to re-establish both RAM banks, since a partial that // died mid-transfer may have left them inconsistent. @@ -297,6 +301,15 @@ fn main() -> anyhow::Result<()> { } } + // Keep the last-file marker on the active named buffer: any switch + // (`:e`, palette pick, `:delete`'s fallback) lands here once its + // effects have drained. An unnamed `:enew` scratch (empty path) keeps + // the previous marker — there is nothing to resume into. + if !ed.path().is_empty() && ed.path() != last_file { + last_file = ed.path().to_string(); + storage.record_last_file(&last_file); + } + // Keyboard attach/detach feeds the panel's disconnect flag. let kbd = usb_kbd::keyboard_present(); ed.set_keyboard_present(kbd); @@ -522,11 +535,11 @@ fn main() -> anyhow::Result<()> { } } -/// Mount the SD card and load the saved note, or halt with the reason on the -/// panel. Everything here is fatal by design (see the boot-sequence comment in -/// `main`): the note is the whole point of the appliance, so we refuse to run -/// in a state where the next save could destroy it. -fn boot_storage(epd: &mut Epd) -> (Storage, String) { +/// Mount the SD card, or halt with the reason on the panel. Everything here is +/// fatal by design (see the boot-sequence comment in `main`): the note is the +/// whole point of the appliance, so we refuse to run in a state where the next +/// save could destroy it. +fn boot_storage(epd: &mut Epd) -> Storage { // A git build shares this mount with the git thread, and libgit2 keeps the // pack + idx descriptors open across a publish — that overruns the // editor's tight 4-FD budget, so mount with the 16-FD one (persistence.rs, @@ -546,12 +559,34 @@ fn boot_storage(epd: &mut Epd) -> (Storage, String) { "Provision it on your computer (just init) and reboot.", ); } + storage +} + +/// Choose and load the boot buffer. With `open_last_on_boot` set and a marker +/// naming a still-existing file (`Storage::last_file`), resume that file; +/// otherwise the default note. Only the default note is fatal (`boot_halt`) — +/// a stale or unreadable last file falls back rather than refusing to boot. +fn boot_note(epd: &mut Epd, storage: &Storage, prefs: &Prefs) -> (String, Scope, String) { + if prefs.open_last_on_boot { + if let Some(path) = storage.last_file() { + match storage.load_path(&path) { + Ok(text) => { + log::info!("boot: resumed {path} ({} bytes)", text.len()); + let scope = if path.starts_with(LOCAL_DIR) { Scope::Local } else { Scope::Tracked }; + return (path, scope, text); + } + // Unreadable (e.g. grown past MAX_FILE_BYTES on a computer) — + // the default note still boots. + Err(e) => log::warn!("boot: can't resume {path} ({e:#}); falling back to {NOTES}"), + } + } + } let note = match storage.load() { Ok(text) => text, Err(e) => boot_halt(epd, "Could not read your note", &format!("{e:#}")), }; log::info!("boot: loaded {} bytes from {NOTES}", note.len()); - (storage, note) + (NOTES.to_string(), Scope::Tracked, note) } /// Show a terminal boot error on the panel and idle forever. Rebooting into the diff --git a/firmware/src/persistence.rs b/firmware/src/persistence.rs index c72c80a..ce67695 100644 --- a/firmware/src/persistence.rs +++ b/firmware/src/persistence.rs @@ -89,6 +89,19 @@ const MOUNT_C: &std::ffi::CStr = c"/sd"; /// so an unrecorded change would never reach the remote. const DIRTY_JOURNAL: &str = "/sd/.typoena-dirty"; +/// Last-active-file marker — the absolute path of the buffer that was active +/// when the device powered off, one line. Read at boot when the +/// `open_last_on_boot` pref is set; rewritten by the main loop on every buffer +/// switch. Device *state*, not shared behaviour, so like the dirty journal it +/// lives at the card root, outside `/sd/repo` — never committed, and two +/// devices never fight over one "last file". +const LAST_FILE: &str = "/sd/.typoena-last"; + +/// Local scratch — [`REPO_DIR`]'s never-published sibling (mirrors the editor +/// crate's `LOCAL_DIR`). Here it bounds what [`Storage::last_file`] will +/// resume. +const LOCAL_DIR: &str = "/sd/local"; + /// VFS open-file budget for the editor path: it opens only a note and its /// `*.tmp`, so a tight budget keeps FatFS's per-file buffers off the heap. const MAX_FILES_EDITOR: i32 = 4; @@ -408,6 +421,36 @@ impl Storage { } } + /// Record `path` as the last-active buffer ([`LAST_FILE`]), so + /// `open_last_on_boot` can resume it. Best-effort: a failed marker write + /// costs "boot where you left off", not data, so it logs instead of + /// erroring the main loop. + pub fn record_last_file(&self, path: &str) { + if let Err(e) = Self::atomic_write(LAST_FILE, path) { + log::warn!("last-file marker not written ({e:#})"); + } + } + + /// The recorded last-active file, if it still names a real note: a path + /// under [`REPO_DIR`] or [`LOCAL_DIR`] whose file exists. Anything else — + /// no marker yet, garbage from an interrupted write, or a file deleted + /// since (here, or on another device via `:gl`) — is `None`, and boot + /// falls back to the default note. + pub fn last_file(&self) -> Option { + let raw = fs::read_to_string(LAST_FILE).ok()?; + let path = raw.trim(); + let in_scope = path + .strip_prefix(REPO_DIR) + .or_else(|| path.strip_prefix(LOCAL_DIR)) + .and_then(|r| r.strip_prefix('/')) + .is_some_and(|r| !r.is_empty()); + if !in_scope { + return None; + } + let is_file = fs::metadata(path).map(|m| m.is_file()).unwrap_or(false); + is_file.then(|| path.to_string()) + } + /// Note a working-copy file as (possibly) differing from HEAD. Paths /// outside `/sd/repo` (`/sd/local`, `/sd/ca.pem`, the journal itself) are /// not git's business and are skipped. The journal is rewritten only when