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

@@ -34,6 +34,25 @@ stay Local for their lifetime. Lives under `/sd/local/`.
_Avoid_: draft, private, untracked, scratch (these all imply impermanence or
promotability, which is not the model).
### Editing model
**Buffer**:
A **File** loaded into memory for editing, with its own caret, scroll position,
and undo history. Opening a file makes it the **active buffer** — the one the
**Writing column** shows. Up to three buffers stay **resident** at once (the
active one plus two parked in the background); switching back to a resident
buffer restores its caret and undo without re-reading the card. A fourth open
**evicts** the least-recently-used resident buffer — saved first if it has
unsaved edits, so nothing is lost.
_Avoid_: tab, window, document (a buffer is not a UI chrome element); "the file"
when you mean the in-memory copy rather than the bytes on the card.
**Open**:
Bringing a **File** into the **active buffer**, via `Ctrl-P` (the file palette)
or `:e`. Scope is read from where the file lives (`/sd/repo`**Tracked**,
`/sd/local`**Local**), never chosen at open time.
_Avoid_: load (implementation talk for the disk read behind an Open).
### User-facing actions
**Save**:
@@ -68,7 +87,8 @@ _Avoid_: edit area, text area, main pane (superseded — they named the old
full-width text region before the side panel carved out its right edge).
**Side panel**:
The right region (~160 px / ~25 cols, full height) holding all metadata:
The right region (~160 px / ~17 cols at its FONT_9X15 metadata font, full
height) holding all metadata:
filename + dirty dot, word count, elapsed time, clock, Wi-Fi,
keyboard-disconnect flag, publish state, and the mode indicator at its
bottom-left. Sits entirely in the master half

View File

@@ -316,20 +316,42 @@ pending-`g` machinery, no vim clash. View mode stays; `v`/`V` are now Visual.
- [x] Ahead of schedule / unscheduled: `:fmt` Markdown formatter
(table alignment, blank-line collapse, trailing-whitespace strip)
## v0.5 — File palette + multi-file — [ ]
## v0.5 — File palette + multi-file — [~]
**Status:** not started (Spikes 11 + 14 retire the panel-mechanism and
buffer-lifecycle risk first).
**Status:** buffer **foundation** landed in core 2026-07-11 (slice 1 of 4),
host-tested; the palette + transient panel (Spike 11) and delete → git-staging
(Spike 14) remain the on-device gates. The single-file `Effect` return became a
drained **effect queue** (`Save{path,contents}` / `Load{path}` / `Publish` /
`Pull`), so one action can ask the host for several steps in order — opening a
non-resident file queues a `Save` of the outgoing dirty buffer *then* a `Load` of
the target. The multi-buffer state deliberately avoids a rope-per-buffer rewrite:
the active buffer keeps its fields inline on `Editor`, inactive buffers park in a
small LRU `Vec<Buffer>` (≤ 3 resident = active + 2), and a switch marshals fields
in/out so the ~3k-line editing engine is untouched. A dirty parked buffer is
saved before it is evicted (nothing leaves RAM unsaved); `:e <path>` opens by
prefix (`/sd/repo` → Tracked, `/sd/local` → Local); `:sync` is refused in-core in
a Local buffer. Firmware drains the queue **to empty** each batch (a `Load` can
cascade an eviction `Save`), and `persistence::{load_path,save_path}` generalise
the atomic save off the hard-coded `notes.md`. 93 editor tests pass; the default
(no-git) firmware binary builds clean. Remaining v0.5 slices: 2 palette + fuzzy +
transient panel, 3 `:enew` + delete, 4 prefs + palette command mode.
- [ ] `Ctrl-P` opens fuzzy file palette over **both** `/sd/repo/` and
`/sd/local/`, with a scope marker (e.g. `[git]` / `[local]`) per result
- [ ] Open, switch, close buffers (keep ≤ 3 in memory)
- [ ] `:e` and palette share the same recent-files list
- [~] Open, switch, close buffers (keep ≤ 3 in memory) — **open + switch + the
≤ 3 LRU-resident model with dirty-aware save-before-evict done in core**
(host-tested); `:e <path>` drives it today. Explicit **close** (and the
palette that surfaces switching) still to come.
- [~] `:e` and palette share the same recent-files list — **`:e <path>` landed**
(prefix → scope); the shared recent-files list arrives with the palette.
- [ ] `:enew` creates a new file — prompts for scope (tracked vs local)
- [ ] Delete a file — removes it from the SD card; for a Tracked file the
removal reaches the next `Ctrl-G` Publish's staged set (`git rm` / `add -A`
semantics, not plain `git add .`); a Local file is just unlinked
- [ ] `Ctrl-G` is disabled / hidden when the current buffer is local-scope
- [~] `Ctrl-G` is disabled / hidden when the current buffer is local-scope
**`:sync` / Publish is blocked in-core for a Local buffer** (posts "Publish
unavailable (Local)"); the side-panel affordance that hides/greys the
gesture is the remaining half.
- [ ] The side panel briefly shows file count on `Ctrl-G` when the publish bundles
more than one dirty Tracked file (e.g. `"publishing 3 files: abc1234"`),
so workspace-scoped behaviour stays visible to the user

View File

@@ -13,7 +13,7 @@
// ISO-8859-15 (Latin-9) rather than the ascii subset: same glyph cells, but it
// carries the accented Latin glyphs (à é ê ç … plus œ €) that international
// input will emit. ASCII rendering is byte-for-byte unchanged.
use embedded_graphics::mono_font::iso_8859_15::{FONT_6X10, FONT_10X20};
use embedded_graphics::mono_font::iso_8859_15::{FONT_9X15, FONT_10X20};
use embedded_graphics::mono_font::MonoTextStyle;
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
@@ -31,14 +31,16 @@ pub const CH: i32 = 20;
/// [`Editor::text_cols`]). The right **side panel** holds metadata (see
/// CONTEXT.md § Screen regions). 63 cols × 10 px = 630 px; the driver's
/// `x = 396` seam runs through it invisibly. The remaining 162 px (right of the
/// divider) hold the ~25-col side panel. Widened from 60 so the gutter doesn't
/// narrow the text: a ≤ 99-line file keeps a full 60-col text column.
/// divider) hold the ~17-col side panel (at its FONT_9X15 metadata font — see
/// [`PANEL_COLS`]). Widened from 60 so the gutter doesn't narrow the text: a
/// ≤ 99-line file keeps a full 60-col text column.
const WRITE_COLS: usize = 63;
/// Minimum digit columns in the line-number gutter (before the 1-col separator).
/// Files up to 99 lines still get a 2-wide gutter so short notes don't jitter.
const GUTTER_MIN_DIGITS: usize = 2;
/// Visible writing rows. 13 × 20 px = 260 px; the bottom 12 px is the transient
/// `:` command line (the only thing left of the old status band).
/// Visible writing rows. 13 × 20 px = 260 px. The transient `:` command line is
/// drawn at body size over the **bottom** writing row (see [`Editor::draw_cmdline`]),
/// so no rows are permanently reserved for it.
const ROWS: usize = (HEIGHT / 20) as usize; // 13
/// Half-page scroll distance for `Ctrl-d`/`Ctrl-u`, in **display rows** — vim's
/// `'scroll'` default (half the visible window). Fixed, not configurable: a
@@ -48,10 +50,22 @@ const HALF_PAGE: usize = ROWS / 2; // 6
/// of panel text (a small gutter past the rule).
const DIVIDER_X: i32 = WRITE_COLS as i32 * CW; // 630
const PANEL_X: i32 = DIVIDER_X + 8; // 638
/// Side-panel text width in 6 px (`FONT_6X10`) columns, for clamping panel
/// strings — the snackbar notice, word count — so they never draw past the
/// right edge of the panel.
const PANEL_COLS: usize = (WIDTH as usize - PANEL_X as usize) / 6; // 25
/// Side-panel font cell: **FONT_9X15** — a middle size between the old squint-y
/// 6×10 and the body 10×20. Legible metadata without eating as many columns as
/// the body font would (the `:` command line, being text you type, stays at the
/// body 10×20 — see [`Editor::draw_cmdline`]). Kept as its own pair (not reusing
/// `CW`/`CH`) so the panel font tunes independently of the writing font; change
/// these **and** the `MonoTextStyle` font in `draw_panel` together.
const PANEL_CW: i32 = 9;
const PANEL_CH: i32 = 15;
/// Side-panel text width in [`PANEL_CW`]-px columns, for clamping panel strings —
/// the snackbar notice, word count — so they never draw past the right edge of
/// the panel.
const PANEL_COLS: usize = (WIDTH as usize - PANEL_X as usize) / PANEL_CW as usize; // 15
/// Max wrapped lines the snackbar draws under the word count, so a long notice
/// can't run down into the bottom mode strip. Four PANEL_CH rows ≈ 60 chars,
/// enough for any current message.
const NOTICE_MAX_LINES: usize = 4;
/// Tab stop, in spaces. Tabs never enter the buffer — they expand on insert so
/// the buffer stays 1 char = 1 column.
const TAB: &str = " ";
@@ -77,19 +91,41 @@ pub enum Mode {
Command,
}
/// A side effect the host (firmware) must carry out after a `:` command. The
/// editor core is pure and does no IO, so persistence and publishing can't
/// happen here — they're signalled out through [`Editor::handle`]'s return
/// value and actioned by the main loop. `:fmt` is pure text work and stays
/// in-core, so it yields [`Effect::None`].
/// Which of the two file scopes ([`CONTEXT.md`]) a buffer belongs to. Fixed at
/// creation — there is no move-between-scopes operation. **Tracked** files live
/// under [`REPO_DIR`] and can be Published (`:sync`); **Local** files live under
/// [`LOCAL_DIR`] and never leave the device, so `:sync` is refused in-core.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Tracked,
Local,
}
/// A side effect the host (firmware) must carry out. The editor core is pure and
/// does no IO, so persistence, publishing, and file reads can't happen here —
/// they are queued and drained by [`Editor::take_effects`] after a key batch,
/// then actioned by the main loop. `:fmt` is pure text work and stays in-core,
/// so it queues nothing.
///
/// A single key can queue more than one effect: opening a file that isn't
/// resident queues a [`Save`](Effect::Save) of the outgoing dirty buffer *and* a
/// [`Load`](Effect::Load) of the target. Effects are serviced in order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
/// Nothing for the host to do — ordinary keys, `:fmt`, unknown commands.
None,
/// `:w` (and the `:wq`/`:x` aliases) — persist the buffer to storage.
Save,
/// `:sync` — publish the buffer (save, then git push). The host saves
/// first: publishing an unsaved buffer is meaningless.
/// Persist `contents` to `path` (an atomic save on the host). Queued by `:w`
/// (and the `:wq`/`:x` aliases), by save-before-switch, and by
/// save-before-evict. The contents ride along because the buffer being saved
/// is not always the active one — an evicted buffer's text is no longer
/// reachable through [`Editor::text`]. On success the host calls
/// [`Editor::mark_saved`].
Save { path: String, scope: Scope, contents: String },
/// Read `path` from disk; on success the host installs it with
/// [`Editor::install_loaded`]. Queued when switching to a file that is not
/// resident in memory (`:e`, palette pick).
Load { path: String, scope: Scope },
/// `:sync` — publish the Tracked working copy (git push). Preceded by a
/// [`Save`](Effect::Save) of the current buffer in the same batch. Never
/// queued from a Local buffer (blocked in-core).
Publish,
/// `:gl` — pull from the remote: fetch, then **fast-forward only**. The host
/// refuses (and surfaces) a divergence rather than merging, and never
@@ -97,6 +133,64 @@ pub enum Effect {
Pull,
}
/// Tracked files live here (the git working copy).
pub const REPO_DIR: &str = "/sd/repo";
/// Local files live here (never published).
pub const LOCAL_DIR: &str = "/sd/local";
/// Resolve a `:e` argument (or palette pick) to an absolute path + [`Scope`]. An
/// absolute path under [`LOCAL_DIR`] is Local; any other absolute path (including
/// under [`REPO_DIR`]) is Tracked. A bare name (no `/`) is joined onto the current
/// buffer's scope directory, so `:e draft.md` opens a sibling of the file you're
/// in.
fn resolve_path(arg: &str, current: Scope) -> (String, Scope) {
if arg.starts_with(&format!("{LOCAL_DIR}/")) {
(arg.to_string(), Scope::Local)
} else if arg.starts_with('/') {
(arg.to_string(), Scope::Tracked)
} else {
let dir = match current {
Scope::Tracked => REPO_DIR,
Scope::Local => LOCAL_DIR,
};
(format!("{dir}/{arg}"), current)
}
}
/// Word-wrap `text` to lines of at most `width` characters, for the side-panel
/// snackbar. Packs whole words greedily; a word longer than `width` is hard-split
/// across lines (so a long path or oid still shows in full rather than being
/// truncated). Empty input yields no lines.
fn wrap_text(text: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut lines: Vec<String> = Vec::new();
let mut cur = String::new();
for word in text.split_whitespace() {
if word.chars().count() > width {
if !cur.is_empty() {
lines.push(core::mem::take(&mut cur));
}
let mut chars = word.chars().peekable();
while chars.peek().is_some() {
lines.push(chars.by_ref().take(width).collect());
}
continue;
}
let sep = usize::from(!cur.is_empty());
if cur.chars().count() + sep + word.chars().count() > width {
lines.push(core::mem::take(&mut cur));
}
if !cur.is_empty() {
cur.push(' ');
}
cur.push_str(word);
}
if !cur.is_empty() {
lines.push(cur);
}
lines
}
/// A pending operator awaiting a motion or text object (`d`elete / `c`hange /
/// `y`ank).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -173,8 +267,50 @@ pub struct Editor {
/// True while `.` is replaying [`dot`], so the replayed keys are neither
/// re-recorded nor able to re-trigger `.`.
replaying: bool,
/// Absolute path of the active buffer on the SD card (e.g.
/// `/sd/repo/notes.md`). Empty for an unnamed scratch buffer (the boot-message
/// layout use); `:w` on an empty path posts "no file name" rather than saving.
path: String,
/// The active buffer's scope. Gates Publish — `:sync` is refused in Local.
scope: Scope,
/// Whether the active buffer has unsaved edits. Set at each change-group
/// ([`checkpoint`](Self::checkpoint)) and cleared when the host confirms a
/// save ([`mark_saved`](Self::mark_saved)). Decides whether a switch/evict
/// persists the buffer first. Deliberately over-eager: entering Insert and
/// leaving without typing marks it dirty, costing at most one redundant
/// (idempotent) save — cheaper than tracking every mutation site.
dirty: bool,
/// Inactive-but-resident buffers, least-recently-used first. The active
/// buffer plus these is capped at [`MAX_RESIDENT`]; switching away parks the
/// active buffer here (with its caret, scroll, and undo), switching back
/// restores it without touching the disk. A parked buffer pushed over the cap
/// is evicted — saved first (via an [`Effect::Save`]) if it is dirty.
parked: Vec<Buffer>,
/// Host-effect queue, drained by [`take_effects`](Self::take_effects) after a
/// key batch. See [`Effect`].
requests: Vec<Effect>,
}
/// A resident-but-inactive buffer: everything needed to restore a file's editing
/// state when the user switches back, without re-reading the disk. The active
/// buffer holds these same fields inline on [`Editor`]; parking marshals them
/// out to here, activation marshals them back.
struct Buffer {
path: String,
scope: Scope,
text: String,
caret: usize,
scroll_top: usize,
dirty: bool,
undo: Vec<(String, usize)>,
redo: Vec<(String, usize)>,
}
/// Buffers kept resident at once — the active one plus [`MAX_RESIDENT`] 1
/// parked (v0.5 keeps ≤ 3). Beyond this the least-recently-used parked buffer is
/// evicted; it is saved first if dirty, so an evicted buffer is never lost.
const MAX_RESIDENT: usize = 3;
/// Maximum undo depth (change-groups). A full-buffer snapshot per group means
/// worst-case memory is `UNDO_DEPTH × buffer size`; for note-sized files on the
/// 8 MB PSRAM this is negligible, and prose editing rarely nears 100 groups
@@ -211,6 +347,11 @@ impl Editor {
dot: Vec::new(),
dot_recording: None,
replaying: false,
path: String::new(),
scope: Scope::Tracked,
dirty: false,
parked: Vec::new(),
requests: Vec::new(),
}
}
@@ -221,7 +362,16 @@ impl Editor {
/// cell past the end. The first [`Editor::draw`] scrolls it into view. An
/// empty string is equivalent to [`Editor::new`].
pub fn with_text(text: String) -> Self {
let mut ed = Editor { text, ..Editor::new() };
Self::with_file(String::new(), Scope::Tracked, text)
}
/// Seed a fresh editor from a named file's saved text — the boot-load and
/// file-open path. Same boot posture as [`with_text`](Self::with_text)
/// (Normal mode, caret on the last character) but records the file's `path`
/// and `scope` so `:w` knows where to persist and `:sync` knows whether
/// Publish is offered.
pub fn with_file(path: String, scope: Scope, text: String) -> Self {
let mut ed = Editor { text, path, scope, ..Editor::new() };
ed.caret = ed.text.len();
if ed.caret > ed.line_start(ed.caret) {
ed.caret = ed.prev_char(ed.caret);
@@ -238,6 +388,55 @@ impl Editor {
&self.text
}
/// Absolute path of the active buffer (empty for an unnamed scratch buffer).
pub fn path(&self) -> &str {
&self.path
}
/// The active buffer's [`Scope`]. The host hides/greys `Ctrl-G` in Local.
pub fn scope(&self) -> Scope {
self.scope
}
/// Whether the active buffer has unsaved edits.
pub fn dirty(&self) -> bool {
self.dirty
}
/// Drain the queued host effects (save/load/publish/pull). The main loop
/// calls this after applying a key batch and services them in order.
pub fn take_effects(&mut self) -> Vec<Effect> {
core::mem::take(&mut self.requests)
}
/// The host confirms `path` was persisted; clear its dirty flag wherever that
/// buffer is resident (active or parked). A no-op for a path that is no longer
/// in memory (already-evicted buffers were saved on the way out).
pub fn mark_saved(&mut self, path: &str) {
if self.path == path {
self.dirty = false;
}
if let Some(b) = self.parked.iter_mut().find(|b| b.path == path) {
b.dirty = false;
}
}
/// Install a file the host read from disk in response to an [`Effect::Load`]:
/// park the current buffer and make the loaded one active. If the target
/// turned resident in the meantime, switch to that copy instead (its in-memory
/// edits win over a stale disk read).
pub fn install_loaded(&mut self, path: String, scope: Scope, contents: String) {
if path == self.path {
return;
}
if self.parked.iter().any(|b| b.path == path) {
self.open_path(path, scope);
return;
}
self.park_active();
self.set_active(path, scope, contents);
}
pub fn scroll_top(&self) -> usize {
self.scroll_top
}
@@ -267,13 +466,14 @@ impl Editor {
self.text.split_whitespace().count()
}
/// Dispatch one decoded key event according to the current mode, returning
/// any [`Effect`] the host must carry out (only `:` commands produce one;
/// every other key yields [`Effect::None`]).
pub fn handle(&mut self, key: Key) -> Effect {
/// Dispatch one decoded key event according to the current mode. Any host
/// effect a `:` command (or a buffer switch) triggers is pushed to the queue
/// drained by [`take_effects`](Self::take_effects); ordinary keys queue
/// nothing.
pub fn handle(&mut self, key: Key) {
// Any keystroke dismisses the transient notice ("snackbar"). The host
// sets a fresh one *after* handle() returns (on a `:` command's effect),
// so a save/publish message survives to the next draw, then clears the
// sets a fresh one *after* the key batch (on a `:` command's effect), so
// a save/publish message survives to the next draw, then clears the
// moment you move on — no timed repaint (which on e-ink would cost a
// full ~630 ms flash just to erase text).
self.notice = None;
@@ -290,7 +490,7 @@ impl Editor {
&& key == Key::Char('.')
{
self.repeat_last_change();
return Effect::None;
return;
}
// State before dispatch, so `record_dot` can read the transition a key
@@ -298,30 +498,17 @@ impl Editor {
let before_mode = self.mode;
let before_pending = self.pending_op.is_some() || self.pending_obj.is_some();
let effect = match self.mode {
Mode::Insert => {
self.insert_key(key);
Effect::None
}
Mode::Normal => {
self.normal_key(key);
Effect::None
}
Mode::Visual | Mode::VisualLine => {
self.visual_key(key);
Effect::None
}
Mode::View => {
self.view_key(key);
Effect::None
}
match self.mode {
Mode::Insert => self.insert_key(key),
Mode::Normal => self.normal_key(key),
Mode::Visual | Mode::VisualLine => self.visual_key(key),
Mode::View => self.view_key(key),
Mode::Command => self.command_key(key),
};
}
if !self.replaying {
self.record_dot(key, before_mode, before_pending);
}
effect
}
/// Record `key` into the in-progress change for `.`. Called after dispatch
@@ -631,7 +818,7 @@ impl Editor {
// --- Command mode (`:`) ------------------------------------------------
fn command_key(&mut self, key: Key) -> Effect {
fn command_key(&mut self, key: Key) {
match key {
Key::Char(c) => self.cmdline.push(c),
Key::Backspace => {
@@ -641,10 +828,9 @@ impl Editor {
}
}
Key::Enter => {
let effect = self.execute_command();
self.execute_command();
self.cmdline.clear();
self.mode = Mode::Normal;
return effect;
}
Key::Escape => {
self.cmdline.clear();
@@ -666,38 +852,61 @@ impl Editor {
// Tab isn't meaningful on a short command line.
_ => {}
}
Effect::None
}
/// Run the typed `:` command, returning any [`Effect`] the host must carry
/// out. Unknown commands are silently ignored. The `:q` quit family is
/// deliberately absent — an always-on writing appliance has nothing to
/// quit to; `:wq`/`:x` therefore just save (the "quit" half is dropped).
fn execute_command(&mut self) -> Effect {
match self.cmdline.trim() {
"fmt" => {
self.format_buffer();
Effect::None
}
/// Run the typed `:` command, queuing any [`Effect`] the host must carry out.
/// Unknown commands are silently ignored. The `:q` quit family is deliberately
/// absent — an always-on writing appliance has nothing to quit to; `:wq`/`:x`
/// therefore just save (the "quit" half is dropped).
fn execute_command(&mut self) {
let cmd = self.cmdline.trim().to_string();
// `:e <path>` — open another file (multi-file, v0.5).
if let Some(arg) = cmd.strip_prefix("e ") {
self.edit_file(arg);
return;
}
match cmd.as_str() {
"fmt" => self.format_buffer(),
"w" | "wq" | "x" => {
if self.format_on_save {
self.format_buffer();
}
Effect::Save
self.request_save_active();
}
"sync" => {
// fmt → save → commit → push: format in-core here, then the host
// persists and publishes the already-formatted buffer.
// Publish is Tracked-only (CONTEXT.md): a Local buffer never
// reaches the remote, so `:sync` there is a no-op with a notice.
if self.scope == Scope::Local {
self.set_notice("Publish unavailable (Local)");
return;
}
// fmt → save → push: format in-core, queue the save of the current
// buffer, then the git publish. The host services them in order.
if self.format_on_save {
self.format_buffer();
}
Effect::Publish
self.request_save_active();
self.requests.push(Effect::Publish);
}
"gl" => Effect::Pull,
_ => Effect::None,
"gl" => self.requests.push(Effect::Pull),
_ => {}
}
}
/// Queue an [`Effect::Save`] of the active buffer. Posts "no file name" for an
/// unnamed scratch buffer (nothing to save to) rather than writing to `""`.
fn request_save_active(&mut self) {
if self.path.is_empty() {
self.set_notice("no file name");
return;
}
self.requests.push(Effect::Save {
path: self.path.clone(),
scope: self.scope,
contents: self.text.clone(),
});
}
/// `:fmt` — normalize the buffer (align tables, collapse duplicate blank
/// lines, strip trailing whitespace) and keep the caret on roughly the same
/// line (buffer length changes, so exact restoration isn't possible).
@@ -742,6 +951,10 @@ impl Editor {
/// `:fmt`). If the buffer is unchanged since the last baseline it is a no-op,
/// so calling it more than once before a mutation records only one group.
fn checkpoint(&mut self) {
// A change-group is about to begin, so the buffer is (or is about to be)
// modified relative to the last save. See the `dirty` field note on why
// this is deliberately slightly over-eager.
self.dirty = true;
if self.undo.last().is_some_and(|(t, _)| t == &self.text) {
return; // nothing changed since the last baseline
}
@@ -782,6 +995,115 @@ impl Editor {
self.reset_pending();
}
// --- Buffers (multi-file) ----------------------------------------------
/// Switch the active buffer to `path`. If it is already resident (parked),
/// restore that copy with its caret/scroll/undo intact — no disk read. If it
/// is not resident, queue an [`Effect::Load`]; the host reads the file and
/// calls [`install_loaded`](Self::install_loaded), which does the park + swap.
/// A dirty outgoing buffer is preserved in RAM (parked) and persisted only
/// when it is later evicted, so switching itself never blocks on IO.
fn open_path(&mut self, path: String, scope: Scope) {
if path == self.path {
return; // already the active buffer
}
match self.parked.iter().position(|b| b.path == path) {
Some(i) => {
let target = self.parked.remove(i);
self.park_active();
self.activate(target);
}
None => self.requests.push(Effect::Load { path, scope }),
}
}
/// Move the active buffer's editing state into a parked [`Buffer`], leaving
/// the active fields empty for a subsequent [`activate`](Self::activate) or
/// [`set_active`](Self::set_active). Evicts the least-recently-used parked
/// buffer if that pushes residency over [`MAX_RESIDENT`]; an evicted dirty
/// buffer queues a [`Effect::Save`] so no unsaved work leaves memory.
fn park_active(&mut self) {
let buf = Buffer {
path: core::mem::take(&mut self.path),
scope: self.scope,
text: core::mem::take(&mut self.text),
caret: self.caret,
scroll_top: self.scroll_top,
dirty: self.dirty,
undo: core::mem::take(&mut self.undo),
redo: core::mem::take(&mut self.redo),
};
self.parked.push(buf);
// Active is currently empty, so residency == parked.len(); keep it under
// MAX_RESIDENT so the buffer about to become active fits.
while self.parked.len() >= MAX_RESIDENT {
let evicted = self.parked.remove(0);
if evicted.dirty {
self.requests.push(Effect::Save {
path: evicted.path,
scope: evicted.scope,
contents: evicted.text,
});
}
}
}
/// Restore a parked buffer into the active fields (its caret, scroll, undo,
/// and dirty flag come back with it). Lands in Normal with input state reset.
fn activate(&mut self, b: Buffer) {
self.path = b.path;
self.scope = b.scope;
self.text = b.text;
self.caret = b.caret;
self.scroll_top = b.scroll_top;
self.dirty = b.dirty;
self.undo = b.undo;
self.redo = b.redo;
self.reset_active_input();
}
/// Make a freshly-loaded file the active buffer: same boot posture as
/// [`with_file`](Self::with_file) (Normal, caret on the last char) with empty
/// undo history and a clean dirty flag.
fn set_active(&mut self, path: String, scope: Scope, text: String) {
self.path = path;
self.scope = scope;
self.text = text;
self.caret = self.text.len();
if self.caret > self.line_start(self.caret) {
self.caret = self.prev_char(self.caret);
}
self.scroll_top = 0;
self.dirty = false;
self.undo.clear();
self.redo.clear();
self.reset_active_input();
}
/// Reset the transient per-keystroke input state (mode, pending operator,
/// visual anchor, command line) on a buffer swap, so nothing leaks across.
/// The register and `.` history are deliberately left alone — they are global
/// (vim-like), so a yank in one file pastes in another.
fn reset_active_input(&mut self) {
self.mode = Mode::Normal;
self.visual_anchor = None;
self.cmdline.clear();
self.reset_pending();
}
/// `:e <arg>` — resolve `arg` to an absolute path + scope and open it. A path
/// under `/sd/local/` is Local, one under `/sd/repo/` is Tracked; a bare name
/// (no slash) lands in the current buffer's scope directory.
fn edit_file(&mut self, arg: &str) {
let arg = arg.trim();
if arg.is_empty() {
self.set_notice("usage: :e <file>");
return;
}
let (path, scope) = resolve_path(arg, self.scope);
self.open_path(path, scope);
}
// --- Visual mode -------------------------------------------------------
/// True while a Visual selection is active (charwise or linewise).
@@ -1804,7 +2126,7 @@ impl Editor {
.draw(f)
.unwrap();
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
let style = MonoTextStyle::new(&FONT_9X15, BinaryColor::On);
// Word count, from the throttled snapshot (never per keystroke).
let words = format!("{} words", self.shown_words);
@@ -1813,13 +2135,21 @@ impl Editor {
.unwrap();
// Transient notice ("snackbar"), just under the word count: the last
// save/publish result. Clamped to the panel width so a long message
// can't spill past the right edge; cleared on the next keystroke.
// save/publish result. Word-wrapped to the panel width (so a message like
// "save FAILED - retry :w" keeps its actionable tail instead of clipping
// mid-word) and capped at a few lines so it can't reach the bottom mode
// strip; cleared on the next keystroke.
if let Some(msg) = &self.notice {
let shown: String = msg.chars().take(PANEL_COLS).collect();
Text::with_baseline(&shown, Point::new(PANEL_X, 16), style, Baseline::Top)
.draw(f)
.unwrap();
for (i, line) in wrap_text(msg, PANEL_COLS)
.into_iter()
.take(NOTICE_MAX_LINES)
.enumerate()
{
let y = 2 + PANEL_CH + 2 + i as i32 * PANEL_CH;
Text::with_baseline(&line, Point::new(PANEL_X, y), style, Baseline::Top)
.draw(f)
.unwrap();
}
}
// Keyboard-disconnect flag, just above the mode line, shown only while
@@ -1827,7 +2157,7 @@ impl Editor {
if !self.keyboard_present {
Text::with_baseline(
"NO KBD",
Point::new(PANEL_X, HEIGHT as i32 - 24),
Point::new(PANEL_X, HEIGHT as i32 - 2 * PANEL_CH),
style,
Baseline::Top,
)
@@ -1867,7 +2197,7 @@ impl Editor {
}
Text::with_baseline(
&s,
Point::new(PANEL_X, HEIGHT as i32 - 12),
Point::new(PANEL_X, HEIGHT as i32 - PANEL_CH),
style,
Baseline::Top,
)
@@ -1876,15 +2206,30 @@ impl Editor {
}
}
/// The transient `:` command line, in the bottom strip below the writing
/// column (vim-style). Shown only while composing a command.
/// The transient `:` command line, drawn at body size (FONT_10X20) along the
/// bottom of the writing column (vim-style). Shown only while composing a
/// command. At this size it no longer fits the old 12 px sliver, so it
/// overlays the bottom writing row: blank that row to white first, then draw
/// `:cmd` over it. The row's text reappears on the next render once the
/// command finishes (Enter/Esc) — you never read the last line while typing a
/// command. Blanks only the writing column (left of the divider), so the
/// panel is untouched (and in Command mode its mode line isn't drawn anyway).
fn draw_cmdline(&self, f: &mut Frame) {
if self.mode != Mode::Command {
return;
}
let band_top = (ROWS as i32 - 1) * CH; // start of the last writing row
Rectangle::new(
Point::new(0, band_top),
Size::new(DIVIDER_X as u32, (HEIGHT as i32 - band_top) as u32),
)
.into_styled(PrimitiveStyle::with_fill(BinaryColor::Off))
.draw(f)
.unwrap();
let s = format!(":{}", self.cmdline);
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
Text::with_baseline(&s, Point::new(2, ROWS as i32 * CH + 1), style, Baseline::Top)
let style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
Text::with_baseline(&s, Point::new(2, HEIGHT as i32 - CH), style, Baseline::Top)
.draw(f)
.unwrap();
}
@@ -2088,17 +2433,39 @@ mod tests {
e
}
/// From a fresh editor, run `:{cmd}<Enter>`, returning the editor and the
/// [`Effect`] the Enter produced.
fn command(cmd: &str) -> (Editor, Effect) {
let mut e = Editor::new();
e.handle(Key::Escape); // ensure Normal (power-on already is)
/// From a fresh editor over a named Tracked file, run `:{cmd}<Enter>`,
/// returning the editor and the drained [`Effect`]s the command queued.
fn command(cmd: &str) -> (Editor, Vec<Effect>) {
let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, String::new());
e.handle(Key::Char(':')); // Normal -> Command
for c in cmd.chars() {
e.handle(Key::Char(c));
}
let effect = e.handle(Key::Enter);
(e, effect)
e.handle(Key::Enter);
let effects = e.take_effects();
(e, effects)
}
/// Coarse kind of an [`Effect`], ignoring `Save`/`Load` payloads, so the
/// command tests can assert intent without pinning path/scope/contents.
#[derive(Debug, PartialEq)]
enum Kind {
Save,
Load,
Publish,
Pull,
}
fn kinds(effects: &[Effect]) -> Vec<Kind> {
effects
.iter()
.map(|e| match e {
Effect::Save { .. } => Kind::Save,
Effect::Load { .. } => Kind::Load,
Effect::Publish => Kind::Publish,
Effect::Pull => Kind::Pull,
})
.collect()
}
#[test]
@@ -2247,63 +2614,105 @@ mod tests {
#[test]
fn w_command_signals_save_and_returns_to_normal() {
let (e, eff) = command("w");
assert_eq!(eff, Effect::Save);
let (e, effs) = command("w");
assert_eq!(
effs,
vec![Effect::Save {
path: "/sd/repo/notes.md".into(),
scope: Scope::Tracked,
contents: String::new(),
}]
);
assert_eq!(e.mode(), Mode::Normal);
}
#[test]
fn sync_command_signals_publish() {
assert_eq!(command("sync").1, Effect::Publish);
fn sync_command_saves_then_publishes() {
// `:sync` queues a save of the current buffer, then the git publish.
assert_eq!(kinds(&command("sync").1), vec![Kind::Save, Kind::Publish]);
}
#[test]
fn gl_command_signals_pull() {
assert_eq!(command("gl").1, Effect::Pull);
assert_eq!(kinds(&command("gl").1), vec![Kind::Pull]);
}
#[test]
fn sync_formats_the_buffer_before_publishing() {
// fmt → save → commit → push: `:sync` runs :fmt in-core first (default on).
let mut e = Editor::with_text("hello \nworld".to_string()); // trailing spaces
let mut e = Editor::with_file(
"/sd/repo/notes.md".into(),
Scope::Tracked,
"hello \nworld".to_string(), // trailing spaces
);
e.handle(Key::Char(':'));
for c in "sync".chars() {
e.handle(Key::Char(c));
}
let eff = e.handle(Key::Enter);
assert_eq!(eff, Effect::Publish);
e.handle(Key::Enter);
assert_eq!(kinds(&e.take_effects()), vec![Kind::Save, Kind::Publish]);
assert_eq!(e.text(), "hello\nworld"); // :fmt stripped the trailing whitespace
}
#[test]
fn sync_is_refused_in_a_local_buffer() {
// Publish is Tracked-only; `:sync` in Local queues nothing and warns.
let mut e = Editor::with_file(
"/sd/local/journal.md".into(),
Scope::Local,
"dear diary".to_string(),
);
e.handle(Key::Char(':'));
for c in "sync".chars() {
e.handle(Key::Char(c));
}
e.handle(Key::Enter);
assert!(e.take_effects().is_empty());
}
#[test]
fn format_on_save_off_leaves_the_buffer_untouched() {
let mut e = Editor::with_text("hello \nworld".to_string());
let mut e = Editor::with_file(
"/sd/repo/notes.md".into(),
Scope::Tracked,
"hello \nworld".to_string(),
);
e.format_on_save = false;
e.handle(Key::Char(':'));
e.handle(Key::Char('w'));
let eff = e.handle(Key::Enter);
assert_eq!(eff, Effect::Save);
e.handle(Key::Enter);
assert_eq!(kinds(&e.take_effects()), vec![Kind::Save]);
assert_eq!(e.text(), "hello \nworld"); // unchanged when the pref is off
}
#[test]
fn wq_and_x_alias_save_dropping_the_quit() {
assert_eq!(command("wq").1, Effect::Save);
assert_eq!(command("x").1, Effect::Save);
assert_eq!(kinds(&command("wq").1), vec![Kind::Save]);
assert_eq!(kinds(&command("x").1), vec![Kind::Save]);
}
#[test]
fn fmt_stays_in_core_and_asks_the_host_for_nothing() {
assert_eq!(command("fmt").1, Effect::None);
assert!(command("fmt").1.is_empty());
}
#[test]
fn unknown_command_is_ignored() {
let (e, eff) = command("q"); // quit is deliberately unimplemented
assert_eq!(eff, Effect::None);
let (e, effs) = command("q"); // quit is deliberately unimplemented
assert!(effs.is_empty());
assert_eq!(e.mode(), Mode::Normal);
}
#[test]
fn w_on_an_unnamed_buffer_posts_no_file_name() {
// A scratch buffer (empty path) has nowhere to save to.
let mut e = Editor::new();
e.handle(Key::Char(':'));
e.handle(Key::Char('w'));
e.handle(Key::Enter);
assert!(e.take_effects().is_empty());
}
#[test]
fn with_text_boots_normal_with_caret_on_last_char() {
let e = Editor::with_text("resumed draft".to_string());
@@ -2967,4 +3376,142 @@ mod tests {
let _ = e.draw(true); // must not panic on the empty-row highlight path
assert_eq!(e.mode(), Mode::VisualLine);
}
// ---- Multi-file buffers (v0.5) ----
/// Drive `:e {arg}<Enter>` from Normal.
fn edit(e: &mut Editor, arg: &str) {
e.handle(Key::Char(':'));
for c in format!("e {arg}").chars() {
e.handle(Key::Char(c));
}
e.handle(Key::Enter);
}
#[test]
fn wrap_text_packs_words_and_splits_overlong_tokens() {
// Short message: one line.
assert_eq!(wrap_text("saved", 15), vec!["saved"]);
// Word-wraps on the space, keeping the actionable tail.
assert_eq!(
wrap_text("save FAILED - retry :w", 15),
vec!["save FAILED -", "retry :w"]
);
// A token longer than the width is hard-split rather than truncated.
assert_eq!(
wrap_text("supercalifragilistic", 8),
vec!["supercal", "ifragili", "stic"]
);
assert!(wrap_text("", 15).is_empty());
}
#[test]
fn resolve_path_maps_prefixes_and_bare_names() {
assert_eq!(
resolve_path("/sd/local/j.md", Scope::Tracked),
("/sd/local/j.md".to_string(), Scope::Local)
);
assert_eq!(
resolve_path("/sd/repo/n.md", Scope::Local),
("/sd/repo/n.md".to_string(), Scope::Tracked)
);
// A bare name lands in the current buffer's scope directory.
assert_eq!(
resolve_path("draft.md", Scope::Local),
("/sd/local/draft.md".to_string(), Scope::Local)
);
assert_eq!(
resolve_path("draft.md", Scope::Tracked),
("/sd/repo/draft.md".to_string(), Scope::Tracked)
);
}
#[test]
fn an_edit_marks_dirty_and_mark_saved_clears_it() {
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "hi".into());
assert!(!e.dirty()); // a freshly loaded buffer is clean
e.handle(Key::Char('x')); // delete a char
assert!(e.dirty());
e.mark_saved("/sd/repo/a.md");
assert!(!e.dirty());
}
#[test]
fn e_command_queues_a_load_for_a_nonresident_file() {
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "A".into());
edit(&mut e, "/sd/local/j.md");
assert_eq!(
e.take_effects(),
vec![Effect::Load {
path: "/sd/local/j.md".into(),
scope: Scope::Local,
}]
);
// The active buffer does not change until the host loads and installs it.
assert_eq!(e.path(), "/sd/repo/a.md");
}
#[test]
fn install_loaded_parks_current_and_activates_the_target() {
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "A".into());
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "hello B".into());
assert_eq!(e.path(), "/sd/repo/b.md");
assert_eq!(e.text(), "hello B");
assert_eq!(e.mode(), Mode::Normal);
}
#[test]
fn switching_back_to_a_resident_buffer_needs_no_load() {
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "AAA".into());
assert_eq!(e.caret, 2); // caret on A's last char
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "BBBBB".into());
// A is parked (resident) — switching back reads memory, not disk.
edit(&mut e, "/sd/repo/a.md");
assert!(e.take_effects().is_empty());
assert_eq!(e.path(), "/sd/repo/a.md");
assert_eq!(e.text(), "AAA");
assert_eq!(e.caret, 2); // its caret came back with it
}
#[test]
fn the_register_is_global_across_buffers() {
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "word".into());
e.handle(Key::Char('y')); // yy — yank the line
e.handle(Key::Char('y'));
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, String::new());
e.handle(Key::Char('p')); // paste it into the other buffer
assert!(e.text().contains("word"));
}
#[test]
fn a_dirty_parked_buffer_is_saved_when_evicted() {
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "A".into());
// Dirty the active buffer, then push it out of the ≤3 resident window.
e.handle(Key::Char('i'));
e.handle(Key::Char('!'));
e.handle(Key::Escape);
assert!(e.dirty());
e.take_effects(); // discard anything queued so far
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "B".into()); // parks A(dirty)
e.install_loaded("/sd/repo/c.md".into(), Scope::Tracked, "C".into()); // parked: [A,B]
assert!(e.take_effects().is_empty()); // nothing evicted yet
e.install_loaded("/sd/repo/d.md".into(), Scope::Tracked, "D".into()); // evicts A
let effs = e.take_effects();
assert_eq!(effs.len(), 1, "the evicted dirty buffer must be saved");
match &effs[0] {
Effect::Save { path, .. } => assert_eq!(path, "/sd/repo/a.md"),
other => panic!("expected a Save of A, got {other:?}"),
}
}
#[test]
fn a_clean_parked_buffer_is_dropped_silently_on_eviction() {
let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "A".into());
// A is never edited (clean); filling past ≤3 must evict it without a Save.
e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "B".into());
e.install_loaded("/sd/repo/c.md".into(), Scope::Tracked, "C".into());
e.take_effects();
e.install_loaded("/sd/repo/d.md".into(), Scope::Tracked, "D".into());
assert!(e.take_effects().is_empty()); // clean buffer: no save on evict
}
}

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(())
}