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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user