feat: add multi-file buffer foundation (v0.5 slice 1)

Rework the single Effect return into a drained effect queue
(Save{path,scope,contents} / Load / Publish / Pull) so one action can
ask the host for several ordered steps: opening a non-resident file
queues a Save of the outgoing dirty buffer then a Load of the target.

Keep the active buffer's fields inline on Editor and park inactive
buffers in a small LRU (<=3 resident = active + 2); switching back to a
resident buffer restores its caret/scroll/undo without touching the SD.
A dirty parked buffer is saved before eviction, so nothing leaves RAM
unsaved. `:e <path>` opens by prefix (/sd/repo -> Tracked, /sd/local ->
Local); `:sync` is refused in-core for a Local buffer.

Firmware drains the queue to empty each batch (a Load can cascade an
eviction Save) and persistence generalises the atomic save off the
hard-coded notes.md into load_path/save_path.

Also bump the side panel to FONT_9X15 and the `:` command line to
FONT_10X20 for legibility, word-wrapping the snackbar so a long notice
keeps its actionable tail.
This commit is contained in:
Julien Calixte
2026-07-11 22:26:37 +02:00
parent fa0ea56e1a
commit 2215da939d
5 changed files with 821 additions and 168 deletions

View File

@@ -10,7 +10,7 @@ use esp_idf_svc::hal::spi::{Dma, SpiBusDriver, SpiDriver};
use esp_idf_svc::hal::units::FromValueType;
use display::Frame;
use editor::{Editor, Effect, Mode, CH};
use editor::{Editor, Effect, Mode, Scope, CH};
use firmware::epd::{self, Epd};
use firmware::persistence::{Storage, NOTES};
@@ -101,7 +101,9 @@ fn main() -> anyhow::Result<()> {
// 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.
let mut ed = Editor::with_text(saved);
// 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);
// 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.
@@ -149,41 +151,56 @@ fn main() -> anyhow::Result<()> {
// Drain all queued keystrokes (type-ahead absorbed during a refresh),
// apply them, then do a single refresh for the batch.
let mut keys = 0;
let mut effect = Effect::None;
while let Some(k) = usb_kbd::next_key() {
// A `:` command (only) yields an Effect; keep the last one in the batch.
match ed.handle(k) {
Effect::None => {}
e => effect = e,
}
ed.handle(k);
keys += 1;
}
// Carry out any host-side effect a `:` command asked for. Save is inline
// (fast). `:sync` persists, then hands off to the git thread the push
// is behind the `git` feature, so a light build carries no libgit2/git2.
match effect {
Effect::None => {}
Effect::Save => save_note(&storage, &mut ed),
Effect::Publish => {
// Publishing an unsaved buffer is meaningless, so persist first.
save_note(&storage, &mut ed);
// Then signal the git thread — non-blocking, so the ~10 s push
// never stalls the editor. The outcome returns on `git_rx` and
// updates the snackbar (see the idle branch below).
#[cfg(feature = "git")]
match git_tx.send(firmware::git_sync::PublishRequest) {
Ok(()) => ed.set_notice("syncing..."),
Err(_) => ed.set_notice("sync: git thread down"),
}
#[cfg(not(feature = "git"))]
log::info!(":sync — saved; light build (no `git` feature) — push skipped");
// Service the host-side effects the batch queued, in order. A file open
// queues a Save of the outgoing dirty buffer *then* a Load of the target;
// `:sync` queues a Save of the current buffer *then* Publish. Save/Load
// are inline (fast SD IO); Publish hands off to the git thread — behind
// the `git` feature, so a light build carries no libgit2/git2.
//
// Drain to empty rather than once: servicing a Load can itself queue an
// eviction Save (when the swap pushes a dirty parked buffer out of the
// ≤3 window), and that must be persisted now, not deferred to the next
// keystroke where a power-off could lose it. The queue strictly shrinks
// (a Save/Publish/Pull queues nothing; a Load queues at most one Save),
// so this terminates.
loop {
let effects = ed.take_effects();
if effects.is_empty() {
break;
}
Effect::Pull => {
// `:gl` — fetch + fast-forward from the remote. The on-device
// fetch/fast-forward on the git thread is v0.7 work (git_sync
// only exposes push today), so acknowledge and no-op for now.
ed.set_notice("pull: not wired yet (v0.7)");
for effect in effects {
match effect {
Effect::Save { path, contents, .. } => {
save_buffer(&storage, &mut ed, &path, &contents)
}
Effect::Load { path, scope } => open_buffer(&storage, &mut ed, path, scope),
Effect::Publish => {
// Non-blocking, so the ~10 s push never stalls the editor.
// The outcome returns on `git_rx` and updates the snackbar
// (see the idle branch below). The Save that preceded this
// in the batch already persisted the buffer, so this is a
// pure git push.
#[cfg(feature = "git")]
match git_tx.send(firmware::git_sync::PublishRequest) {
Ok(()) => ed.set_notice("syncing..."),
Err(_) => ed.set_notice("sync: git thread down"),
}
#[cfg(not(feature = "git"))]
log::info!(":sync — saved; light build (no `git` feature) — push skipped");
}
Effect::Pull => {
// `:gl` — fetch + fast-forward from the remote. The
// on-device fetch/fast-forward on the git thread is v0.7
// work (git_sync only exposes push today), so acknowledge
// and no-op for now.
ed.set_notice("pull: not wired yet (v0.7)");
}
}
}
}
@@ -302,7 +319,7 @@ fn main() -> anyhow::Result<()> {
let ms = t0.elapsed().as_millis();
if let Err(e) = result {
// Never fatal — the buffer is the source of truth and safe in RAM,
// exactly like a failed `save_note`. Drop this frame, leave `shown`
// exactly like a failed `save_buffer`. Drop this frame, leave `shown`
// untouched so the next paint repaints the same diff, and force a
// clean full refresh then. Typical cause: internal DMA-capable RAM
// briefly starved by Wi-Fi/TLS during a background `:sync`; it frees
@@ -365,23 +382,51 @@ fn show_message(epd: &mut Epd, msg: &str) -> anyhow::Result<()> {
Ok(())
}
/// Persist the buffer to SD. Errors are logged, never propagated: the in-RAM
/// buffer is the source of truth and must survive a failed write (e.g. a card
/// pulled mid-session) so the user can fix the card and retry `:w`.
fn save_note(storage: &Storage, ed: &mut Editor) {
let n = ed.text().len();
match storage.save(ed.text()) {
/// Persist a buffer to SD at `path`. Errors are logged, never propagated: the
/// in-RAM buffer is the source of truth and must survive a failed write (e.g. a
/// card pulled mid-session) so the user can fix the card and retry `:w`. On
/// success the editor's dirty flag for that path is cleared.
fn save_buffer(storage: &Storage, ed: &mut Editor, path: &str, contents: &str) {
match storage.save_path(path, contents) {
Ok(()) => {
log::info!(":w — saved {n} bytes to {NOTES}");
log::info!(":w — saved {} bytes to {path}", contents.len());
ed.mark_saved(path);
ed.set_notice("saved");
}
Err(e) => {
log::error!(":w — save FAILED ({e:#}); buffer kept in RAM, retry :w");
log::error!("save FAILED ({e:#}); buffer kept in RAM, retry :w");
ed.set_notice("save FAILED - retry :w");
}
}
}
/// Read `path` from SD and install it as the active buffer (the multi-file open
/// path, from `:e` / the palette). A read failure keeps the current buffer and
/// surfaces the reason on the snackbar rather than swapping to an empty screen.
fn open_buffer(storage: &Storage, ed: &mut Editor, path: String, scope: Scope) {
match storage.load_path(&path) {
Ok(text) => {
log::info!("opened {path} ({} bytes, {scope:?})", text.len());
let name = file_stem(&path);
ed.set_notice(format!("loaded {name}"));
ed.install_loaded(path, scope, text);
}
Err(e) => {
log::error!("open {path} FAILED ({e:#})");
ed.set_notice(format!("can't open {}", file_stem(&path)));
}
}
}
/// A file's display name — its basename without extension (`/sd/repo/notes.md`
/// → `notes`), for the snackbar. Falls back to the raw path if it has no stem.
fn file_stem(path: &str) -> &str {
std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(path)
}
/// First and last (inclusive) framebuffer rows that differ between two frames,
/// or `None` if identical. Lets the partial refresh target just the band a
/// keystroke touched instead of all 272 rows.

View File

@@ -250,42 +250,61 @@ impl Storage {
Path::new(REPO_DIR).is_dir()
}
/// Read `notes.md` into a `String`. Returns an empty string if the file
/// doesn't exist yet (fresh, but provisioned, repo). Refuses a file larger
/// than [`MAX_FILE_BYTES`] rather than loading it.
/// Read `notes.md` into a `String` — the boot default note. Thin wrapper over
/// [`Storage::load_path`].
pub fn load(&self) -> Result<String> {
match fs::metadata(NOTES) {
self.load_path(NOTES)
}
/// Read an arbitrary file under `/sd` into a `String`. Returns an empty string
/// if the file doesn't exist yet (a `:e` of a not-yet-created name, or a fresh
/// repo). Refuses a file larger than [`MAX_FILE_BYTES`] rather than loading it.
///
/// The multi-file (v0.5) load path: the editor names the file, the host reads
/// it here and hands the text back through `Editor::install_loaded`.
pub fn load_path(&self, path: &str) -> Result<String> {
match fs::metadata(path) {
Ok(m) if m.len() > MAX_FILE_BYTES => bail!(
"{NOTES} is {} KiB — over the {} KiB v0.1 limit; open it on a computer to split it",
"{path} is {} KiB — over the {} KiB limit; open it on a computer to split it",
m.len() / 1024,
MAX_FILE_BYTES / 1024
),
Ok(_) => fs::read_to_string(NOTES).with_context(|| format!("reading {NOTES}")),
Ok(_) => fs::read_to_string(path).with_context(|| format!("reading {path}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(e) => Err(e).with_context(|| format!("stat {NOTES}")),
Err(e) => Err(e).with_context(|| format!("stat {path}")),
}
}
/// Atomically persist `contents` to `notes.md`: write the tmp, fsync,
/// unlink the target, rename over it. See the module docs for why the unlink
/// is mandatory on FAT and [`Storage::recover`] for the crash window it opens.
/// Atomically persist `contents` to `notes.md`. Thin wrapper over
/// [`Storage::save_path`].
pub fn save(&self, contents: &str) -> Result<()> {
self.save_path(NOTES, contents)
}
/// Atomically persist `contents` to an arbitrary file under `/sd`: write the
/// tmp, fsync, unlink the target, rename over it. See the module docs for why
/// the unlink is mandatory on FAT. Boot recovery ([`Storage::recover`]) still
/// only covers the default `notes.md`; per-file recovery for the other v0.5
/// buffers is deferred to the v0.9 crash-safety work — the atomic swap here
/// already protects each individual save.
pub fn save_path(&self, path: &str, contents: &str) -> Result<()> {
let tmp = format!("{path}.tmp");
{
let mut f = fs::File::create(NOTES_TMP)
.with_context(|| format!("create {NOTES_TMP} (is {REPO_DIR} present?)"))?;
let mut f = fs::File::create(&tmp)
.with_context(|| format!("create {tmp} (does its directory exist?)"))?;
f.write_all(contents.as_bytes())
.with_context(|| format!("write {NOTES_TMP}"))?;
.with_context(|| format!("write {tmp}"))?;
// FatFS f_sync — flush the tmp fully before it can replace the target.
f.sync_all().with_context(|| format!("fsync {NOTES_TMP}"))?;
f.sync_all().with_context(|| format!("fsync {tmp}"))?;
}
// FatFS f_rename won't overwrite, so unlink the target first (tolerate a
// missing target: the first-ever save has nothing to remove).
match fs::remove_file(NOTES) {
match fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("unlink {NOTES} before rename")),
Err(e) => return Err(e).with_context(|| format!("unlink {path} before rename")),
}
fs::rename(NOTES_TMP, NOTES).with_context(|| format!("rename {NOTES_TMP} -> {NOTES}"))?;
fs::rename(&tmp, path).with_context(|| format!("rename {tmp} -> {path}"))?;
Ok(())
}