feat(firmware): boot into the last-active file

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.
This commit is contained in:
Julien Calixte
2026-07-14 10:35:00 +02:00
parent 72b9615105
commit d4c59c2929
2 changed files with 103 additions and 25 deletions

View File

@@ -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 <name>" 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

View File

@@ -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<String> {
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