fix(persistence): show a saved note's trailing newline as an empty line

Read notes verbatim and insert the final newline only when it is missing,
instead of stripping the terminator on load and appending it unconditionally
on save. The editor's `rows = #\n + 1` model then renders a file's POSIX
terminator as a visible trailing blank line — what a writer expects: open a
note and see (and land the caret on) the empty line the newline stands for.

Supersedes the strip-on-load / unconditional-append handling that shipped
with the prefs work (c535864), which kept the buffer newline-free and hid the
terminator. Load + save are now an identity round-trip for any device-written
file (all end in '\n'); files stay git-clean (exactly one terminator); and a
trailing blank line the writer leaves is mirrored, never doubled.

- load_path: read verbatim (drop the strip)
- save_path: guarded final-newline (drop the unconditional append)
- Prefs::to_toml: ends in a newline again — the guarded save leaves exactly
  one, so the prefs file is byte-identical to before and its device-verified
  round-trip still holds
- sd_fat spike: payload ends in '\n' so its exact-equality round-trip holds
This commit is contained in:
Julien Calixte
2026-07-12 02:02:46 +02:00
parent d46cdb7e7f
commit d14d9e77a5
3 changed files with 27 additions and 36 deletions

View File

@@ -94,11 +94,12 @@ fn write_test(storage: &Storage) -> Result<()> {
log::info!("{REPO_DIR} missing — creating it (bench setup) so the write test can run");
fs::create_dir_all(REPO_DIR).with_context(|| format!("create {REPO_DIR}"))?;
}
// Newline-free, matching a real editor buffer: `save` appends one POSIX
// terminator and `load` strips one back, so the round-trip is byte-for-byte
// identical for any payload (one ending in '\n' would too — it's just cleaner
// to keep the fixture terminator-free, mirroring the buffer convention).
let payload = format!("typoena spike 3\n{BUILD_TAG}\ndedicated SPI3: SCK14 MOSI15 MISO13 CS10");
// End the payload with '\n', like a real editor buffer (whose visible trailing
// blank line is exactly that terminating newline). `save` inserts a final
// newline only when one is missing and `load` reads verbatim, so a payload that
// already ends in '\n' round-trips byte-for-byte — which this exact-equality
// check relies on.
let payload = format!("typoena spike 3\n{BUILD_TAG}\ndedicated SPI3: SCK14 MOSI15 MISO13 CS10\n");
storage.save(&payload).context("Storage::save")?;
let back = storage.load().context("Storage::load after save")?;
if back != payload {

View File

@@ -269,21 +269,13 @@ impl Storage {
m.len() / 1024,
MAX_FILE_BYTES / 1024
),
Ok(_) => {
let mut s =
fs::read_to_string(path).with_context(|| format!("reading {path}"))?;
// Drop the single POSIX line terminator that `save_path` adds so the
// editor buffer stays newline-free (it counts rows as `#\n + 1`, so a
// trailing '\n' would render a phantom blank last line). Also handles a
// file authored elsewhere: it loads without that phantom line.
if s.ends_with('\n') {
s.pop();
if s.ends_with('\r') {
s.pop(); // tolerate a CRLF terminator
}
}
Ok(s)
}
// Read the file verbatim. The editor's `rows = #\n + 1` model renders a
// trailing '\n' as an empty last line, and we *want* that: a note ends
// with a visible blank line that reflects its POSIX terminator. Since
// `save_path` guarantees that terminator, this load and that save form an
// identity round-trip for any device-written file (which always ends in
// '\n') — no strip needed, and none wanted.
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 {path}")),
}
@@ -308,17 +300,16 @@ impl Storage {
.with_context(|| format!("create {tmp} (does its directory exist?)"))?;
f.write_all(contents.as_bytes())
.with_context(|| format!("write {tmp}"))?;
// Append exactly one POSIX terminator, unconditionally the symmetric
// inverse of `load_path`, which always strips one back off. This keeps
// git from flagging "No newline at end of file" and makes the buffer
// round-trip byte-for-byte: a buffer that ends in '\n' (a trailing blank
// line the writer left) becomes "…\n\n" on disk, so that blank line
// survives the next load instead of being swallowed. Everything handed
// to `save_path` is content *without* its terminator by convention (the
// editor buffer is newline-free, and `Prefs::to_toml` omits its trailing
// newline for the same reason), so this never double-terminates.
f.write_all(b"\n")
.with_context(|| format!("write final newline to {tmp}"))?;
// Insert a final newline only if the buffer lacks one (POSIX text
// convention; keeps git from flagging "No newline at end of file").
// `load_path` reads verbatim, so this is the sole place the terminator is
// guaranteed — and because it's guarded, the file mirrors the buffer's
// trailing newlines exactly: one visible trailing blank line stays one,
// never doubled. A buffer that already ends in '\n' passes through as-is.
if !contents.ends_with('\n') {
f.write_all(b"\n")
.with_context(|| format!("write final newline to {tmp}"))?;
}
// FatFS f_sync — flush the tmp fully before it can replace the target.
f.sync_all().with_context(|| format!("fsync {tmp}"))?;
}