From c9c07165e0343009c481173693d63e5c007073d8 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 12 Jul 2026 00:44:39 +0200 Subject: [PATCH] feat(editor): add :enew and :delete with real git-staging (v0.5 slice 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :enew creates a new file (empty, dirty, added to the palette list); :delete unlinks the current file via a new Effect::Delete and switches to a parked buffer or scratch. Scope is read from the path (local/x, repo/x) rather than a modal prompt, and the /sd prefix is now optional in resolve_path. On-device testing showed deletions never reached the remote: add_all(["*"]) alone does not stage a removal on this libgit2, so the tree came back unchanged and the push was a silent no-op. stage_and_commit now runs add_all then update_all(["*"]) (git add -u) — together git add -A. The :delete snackbar now confirms the scoped file and flags that a Tracked file is local until :sync. --- docs/macroplan.md | 59 ++++++-- editor/src/lib.rs | 279 ++++++++++++++++++++++++++++++++++-- firmware/src/git_sync.rs | 28 ++-- firmware/src/main.rs | 28 ++++ firmware/src/persistence.rs | 14 ++ 5 files changed, 377 insertions(+), 31 deletions(-) diff --git a/docs/macroplan.md b/docs/macroplan.md index 37046e0..a5126d8 100644 --- a/docs/macroplan.md +++ b/docs/macroplan.md @@ -350,25 +350,60 @@ down/up line motions in Normal mode** (vim `CTRL-N`≡`j`, `CTRL-P`≡`k`, count-aware), which is why the palette opener moved to `Cmd-P` alone. Scope shows as the inline `repo/…` vs `local/…` label rather than the planned `[git]`/`[local]` badge — it also disambiguates subpaths, not just scope. 111 -editor tests + 28 keymap tests pass; the no-git firmware binary builds clean. The -transient-panel refresh on the panel is the **Spike 11** on-device gate (still -pending); file-list refresh after create/delete arrives with slice 3. Remaining -v0.5 slices: 3 `:enew` + delete, 4 prefs + palette command mode. +editor tests + 28 keymap tests pass; the no-git firmware binary builds clean. +The transient-panel refresh (**Spike 11**) is **CONFIRMED ON DEVICE 2026-07-12 — +no ghosting** (user flashed it and eyeballed the full-area partial the palette +forces); Cmd-P opens it on-device too. Remaining v0.5 slice: 4 prefs + +palette command mode. -- [~] `Cmd-P` opens fuzzy file palette over **both** `/sd/repo/` and - `/sd/local/` — **landed in core (host-tested)**; scope shows as the inline - `repo/…` / `local/…` label instead of a `[git]`/`[local]` badge. On-device - transient-panel refresh (Spike 11) is the remaining gate. +**Slice 3 (`:enew` + delete) COMPLETE in core 2026-07-12, HOST-TESTED not yet +on-device.** `:enew ` creates a new file: empty, active, marked **dirty** +so eviction/`:w` persists it, and added to the in-core file list so the palette +finds it without a disk re-enumeration — no card IO until it is saved. `:delete` +unlinks the **current** file (a new `Effect::Delete` the host services), then +switches to the most-recently-parked buffer or an empty scratch; the discarded +buffer is never saved even when dirty. **Scope for a new file is read from the +path, not a modal prompt** — `local/x` / `repo/x` (the palette label form) select +the scope, a bare name uses the current buffer's scope. Same change made the +**`/sd` prefix optional everywhere** in `resolve_path`: `/sd/repo/x`, `/repo/x`, +and `repo/x` all name one file and nothing resolves outside `/sd` (the writer +can't reach anything else). **Spike 14 (delete → git-staging) DID need a firmware +fix.** The first on-device test found `add_all(["*"])` alone does **not** stage a +deletion on this libgit2 build (the tree came back unchanged, so the second push +was a silent "up to date" no-op — the "delete didn't work" report). Fix: +`stage_and_commit` now runs `add_all` **then `update_all(["*"])`** (`git add -u`), +which removes index entries whose working-tree file is gone — together they are +`git add -A`. Also, `:delete` gave no clear feedback, so the snackbar now names +the scoped file and, for a Tracked file, that it is local until `:sync` +(`deleted repo/notes.md - :sync to publish`). Deferred to later: greying the +Publish affordance for a Local buffer, and the multi-file publish count. 123 +editor tests + 28 keymap tests pass; the no-git firmware binary builds clean (the +`update_all` fix is behind `--features git`, unbuildable locally — on-device +re-test pending). + +- [x] `Cmd-P` opens fuzzy file palette over **both** `/sd/repo/` and + `/sd/local/` — **landed and CONFIRMED ON DEVICE 2026-07-12** (Spike 11: no + ghosting on the transient panel); scope shows as the inline + `repo/…` / `local/…` label instead of a `[git]`/`[local]` badge. - [~] 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 ` **and the palette** drive it today. Explicit **close** still to come. - [x] `:e` and palette share the same recent-files list — both open via `open_path`, which pushes to the in-core MRU that orders 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 +- [x] `:enew` creates a new file — **done in core (host-tested) 2026-07-12.** + Scope is read from the path (`local/x` / `repo/x` select it, the palette + label form; a bare name uses the current scope) rather than a modal + prompt — the resolved scope is echoed in the snackbar. The `/sd` prefix is + optional throughout (`/sd/repo/x` = `/repo/x` = `repo/x`). +- [x] Delete a file — **core done (host-tested) 2026-07-12;** `:delete` unlinks + the current file via `Effect::Delete`. For a Tracked file the removal reaches + the next `:sync` Publish's staged set. **Spike 14 (on-device) found the + staging incomplete:** `add_all(["*"])` alone did not stage the deletion, so + `stage_and_commit` now also runs `update_all(["*"])` (`git add -u`) — the + two together are `git add -A`. A Local file is just unlinked. The snackbar + now confirms the delete and flags that a Tracked file needs `:sync`. + **On-device re-test of the fix pending** (build is `--features git`). - [~] `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 diff --git a/editor/src/lib.rs b/editor/src/lib.rs index 4ce68dd..3e0e7e5 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -136,6 +136,13 @@ pub enum Effect { /// refuses (and surfaces) a divergence rather than merging, and never /// touches local commits. Complements `:sync` (push) as the download half. Pull, + /// `:delete` — unlink `path` from the card. For a **Tracked** file the removal + /// lands in the git working copy, so the next [`Publish`](Effect::Publish)'s + /// `add --all` stages the deletion (no eager `git rm` needed); a **Local** file + /// is just unlinked. The editor has already dropped the file from its model and + /// switched away by the time this drains, so `scope` is informational; the host + /// reports the outcome on the snackbar (mirrors [`Save`](Effect::Save)). + Delete { path: String, scope: Scope }, } /// Tracked files live here (the git working copy). @@ -143,22 +150,35 @@ 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. +/// Resolve a `:e`/`:enew` argument (or palette pick) to an absolute path + +/// [`Scope`]. Everything the writer can reach lives on the card under `/sd`, so +/// the `/sd` prefix is **optional**: `/sd/repo/x`, `/repo/x`, and `repo/x` all +/// name the same file, and nothing resolves outside `/sd`. The arg is normalized +/// to a scope-relative form (peel an optional `/sd`, then an optional leading +/// `/`), then: +/// - a leading `local/` or `repo/` segment **selects the scope** and names the +/// file in it — the same labels the palette shows (`local/journal.md`, +/// `repo/notes.md`), so a name read off the palette is typeable verbatim. Safe +/// because scopes are flat: there are no real `local/`/`repo/` subdirectories; +/// - otherwise a bare name joins 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) + // Peel the optional `/sd` prefix, then an optional leading `/`, leaving a + // scope-relative remainder (`repo/…`, `local/…`, or a bare name). + let rel = arg + .strip_prefix("/sd/") + .or_else(|| arg.strip_prefix('/')) + .unwrap_or(arg); + if let Some(name) = rel.strip_prefix("local/") { + (format!("{LOCAL_DIR}/{name}"), Scope::Local) + } else if let Some(name) = rel.strip_prefix("repo/") { + (format!("{REPO_DIR}/{name}"), Scope::Tracked) } else { let dir = match current { Scope::Tracked => REPO_DIR, Scope::Local => LOCAL_DIR, }; - (format!("{dir}/{arg}"), current) + (format!("{dir}/{rel}"), current) } } @@ -966,12 +986,20 @@ impl Editor { /// therefore just save (the "quit" half is dropped). fn execute_command(&mut self) { let cmd = self.cmdline.trim().to_string(); + // `:enew ` — create a new file (checked before `:e ` so it isn't + // swallowed by it; "enew foo" never starts with "e "). + if let Some(arg) = cmd.strip_prefix("enew ") { + self.new_file(arg); + return; + } // `:e ` — open another file (multi-file, v0.5). if let Some(arg) = cmd.strip_prefix("e ") { self.edit_file(arg); return; } match cmd.as_str() { + "enew" => self.set_notice("usage: :enew "), + "delete" => self.delete_current(), "fmt" => self.format_buffer(), "w" | "wq" | "x" => { if self.format_on_save { @@ -1211,6 +1239,80 @@ impl Editor { self.open_path(path, scope); } + /// `:enew ` — create a new file and make it the active buffer. Scope is + /// read from the path exactly like `:e` (`local/…` → Local, else Tracked; + /// a bare name lands in the current buffer's scope), so no scope prompt is + /// needed — the resolved scope is echoed in the snackbar instead. If the name + /// already resolves to the active or a parked buffer, this just switches to it + /// (no clobber); otherwise the buffer starts empty and **dirty**, so it is + /// durable (a later eviction or `:w` persists it) and shows in the palette at + /// once. The file is not written to disk until then — `:enew` alone allocates + /// no card IO. + fn new_file(&mut self, arg: &str) { + let arg = arg.trim(); + if arg.is_empty() { + self.set_notice("usage: :enew "); + return; + } + let (path, scope) = resolve_path(arg, self.scope); + // Already open (active or parked) — treat `:enew` of an existing name as a + // switch rather than replacing its contents with an empty buffer. + if path == self.path || self.parked.iter().any(|b| b.path == path) { + self.open_path(path, scope); + return; + } + self.note_recent(&path); + self.add_to_file_list(&path); + self.park_active(); + self.set_active(path.clone(), scope, String::new()); + // A fresh file is unsaved: mark it dirty so eviction/`:w` persists it and + // it never silently vanishes (unlike an `:e` of a missing name). + self.dirty = true; + self.set_notice(format!("new {}", palette_label(&path))); + } + + /// `:delete` — unlink the **current** file from the card and leave it. Queues + /// an [`Effect::Delete`] (the host does the removal + reports the outcome) and + /// updates the in-core model now: the path is dropped from the file list and + /// MRU, and the active buffer switches to the most-recently-parked buffer, or + /// an empty unnamed scratch if none is resident. An unnamed scratch buffer has + /// nothing on disk, so it is a no-op with a notice. Deleting an arbitrary + /// (non-current) file is deferred — this is the file you are looking at. + fn delete_current(&mut self) { + if self.path.is_empty() { + self.set_notice("no file to delete"); + return; + } + let path = core::mem::take(&mut self.path); + let scope = self.scope; + self.requests.push(Effect::Delete { path: path.clone(), scope }); + self.remove_from_file_list(&path); + self.recent.retain(|p| p != &path); + // The current buffer is being discarded, not parked: restore the most + // recently parked buffer if one is resident, else fall back to scratch. + match self.parked.pop() { + Some(b) => { + self.note_recent(&b.path); + self.activate(b); + } + None => self.set_active(String::new(), Scope::Tracked, String::new()), + } + } + + /// Insert `path` into the palette's file list, keeping it sorted and unique + /// (matches [`set_file_list`](Self::set_file_list)'s invariant). Used by + /// `:enew` so a just-created file is findable without a disk re-enumeration. + fn add_to_file_list(&mut self, path: &str) { + if let Err(i) = self.files.binary_search(&path.to_string()) { + self.files.insert(i, path.to_string()); + } + } + + /// Drop `path` from the palette's file list (used by `:delete`). + fn remove_from_file_list(&mut self, path: &str) { + self.files.retain(|f| f != path); + } + // --- File palette (Ctrl-P) --------------------------------------------- /// Feed the palette its file list: every openable file as an absolute path, @@ -2780,6 +2882,7 @@ mod tests { Load, Publish, Pull, + Delete, } fn kinds(effects: &[Effect]) -> Vec { @@ -2790,6 +2893,7 @@ mod tests { Effect::Load { .. } => Kind::Load, Effect::Publish => Kind::Publish, Effect::Pull => Kind::Pull, + Effect::Delete { .. } => Kind::Delete, }) .collect() } @@ -3707,8 +3811,13 @@ mod tests { /// Drive `:e {arg}` from Normal. fn edit(e: &mut Editor, arg: &str) { + ex(e, &format!("e {arg}")); + } + + /// Drive an arbitrary `:{cmd}` from Normal. + fn ex(e: &mut Editor, cmd: &str) { e.handle(Key::Char(':')); - for c in format!("e {arg}").chars() { + for c in cmd.chars() { e.handle(Key::Char(c)); } e.handle(Key::Enter); @@ -3741,6 +3850,26 @@ mod tests { resolve_path("/sd/repo/n.md", Scope::Local), ("/sd/repo/n.md".to_string(), Scope::Tracked) ); + // A leading `local/` or `repo/` segment selects scope (the palette label + // form), independent of the current buffer's scope. + assert_eq!( + resolve_path("local/j.md", Scope::Tracked), + ("/sd/local/j.md".to_string(), Scope::Local) + ); + assert_eq!( + resolve_path("repo/n.md", Scope::Local), + ("/sd/repo/n.md".to_string(), Scope::Tracked) + ); + // The `/sd` prefix is optional: `/repo/x` and `/local/x` (leading slash, + // no `/sd`) resolve into the same scopes as their `/sd/…` spellings. + assert_eq!( + resolve_path("/repo/n.md", Scope::Local), + ("/sd/repo/n.md".to_string(), Scope::Tracked) + ); + assert_eq!( + resolve_path("/local/j.md", Scope::Tracked), + ("/sd/local/j.md".to_string(), Scope::Local) + ); // A bare name lands in the current buffer's scope directory. assert_eq!( resolve_path("draft.md", Scope::Local), @@ -3841,6 +3970,134 @@ mod tests { assert!(e.take_effects().is_empty()); // clean buffer: no save on evict } + // ---- :enew / :delete (v0.5 slice 3) ---- + + #[test] + fn enew_creates_a_dirty_empty_buffer_and_asks_the_host_for_nothing() { + let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, "A".into()); + ex(&mut e, "enew draft.md"); + assert_eq!(e.path(), "/sd/repo/draft.md"); // bare name → current (Tracked) scope + assert_eq!(e.scope(), Scope::Tracked); + assert_eq!(e.text(), ""); + assert!(e.dirty()); // fresh + unsaved, so eviction/`:w` will persist it + assert_eq!(e.mode(), Mode::Normal); + // `:enew` allocates no card IO — it neither loads nor saves. + assert!(e.take_effects().is_empty()); + } + + #[test] + fn enew_derives_local_scope_from_the_path() { + let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, "A".into()); + ex(&mut e, "enew local/journal.md"); + assert_eq!(e.path(), "/sd/local/journal.md"); + assert_eq!(e.scope(), Scope::Local); + } + + #[test] + fn enew_adds_the_new_file_to_the_palette_list() { + let mut e = palette_editor(&["/sd/repo/notes.md", "/sd/repo/todo.md"]); + ex(&mut e, "enew draft.md"); + assert!(e.files.contains(&"/sd/repo/draft.md".to_string())); + // and it is findable in the palette without a disk re-enumeration + e.handle(Key::Palette); + for c in "draft".chars() { + e.handle(Key::Char(c)); + } + assert_eq!(palette_labels(&e), vec!["repo/draft.md"]); + } + + #[test] + fn enew_of_an_already_open_file_switches_without_clobbering() { + let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "AAA".into()); + e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "BBB".into()); // parks A + e.take_effects(); + ex(&mut e, "enew /sd/repo/a.md"); // A is parked (resident) — switch, don't empty it + assert_eq!(e.path(), "/sd/repo/a.md"); + assert_eq!(e.text(), "AAA"); // contents preserved, not clobbered to empty + assert!(e.take_effects().is_empty()); // resident: no Load + } + + #[test] + fn enew_without_a_name_is_a_usage_noop() { + let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, "A".into()); + ex(&mut e, "enew"); + assert_eq!(e.path(), "/sd/repo/notes.md"); // unchanged + assert!(e.take_effects().is_empty()); + assert_eq!(e.mode(), Mode::Normal); + } + + #[test] + fn delete_queues_a_delete_of_the_current_file() { + let (mut e, effs) = command("delete"); + assert_eq!( + effs, + vec![Effect::Delete { + path: "/sd/repo/notes.md".into(), + scope: Scope::Tracked, + }] + ); + // No file remains active (nothing else was resident): a scratch buffer. + assert_eq!(e.path(), ""); + assert_eq!(e.text(), ""); + assert_eq!(e.mode(), Mode::Normal); + assert!(e.take_effects().is_empty()); + } + + #[test] + fn delete_never_saves_the_discarded_buffer_even_when_dirty() { + let mut e = Editor::with_file("/sd/repo/notes.md".into(), Scope::Tracked, "A".into()); + e.handle(Key::Char('x')); // dirty it + assert!(e.dirty()); + ex(&mut e, "delete"); + // The buffer is being deleted, so it is discarded, not saved: Delete only. + assert_eq!(kinds(&e.take_effects()), vec![Kind::Delete]); + } + + #[test] + fn delete_switches_to_the_most_recently_parked_buffer() { + let mut e = Editor::with_file("/sd/repo/a.md".into(), Scope::Tracked, "AAA".into()); + e.install_loaded("/sd/repo/b.md".into(), Scope::Tracked, "BBB".into()); // active B, A parked + e.take_effects(); + ex(&mut e, "delete"); // deletes B, restores A + assert_eq!(e.path(), "/sd/repo/a.md"); + assert_eq!(e.text(), "AAA"); // A came back from RAM, caret/undo with it + match &e.take_effects()[..] { + [Effect::Delete { path, .. }] => assert_eq!(path, "/sd/repo/b.md"), + other => panic!("expected a single Delete of B, got {other:?}"), + } + } + + #[test] + fn delete_drops_the_file_from_the_palette_list() { + let mut e = palette_editor(&["/sd/repo/notes.md", "/sd/repo/todo.md"]); + ex(&mut e, "delete"); // notes.md is active + e.take_effects(); + assert!(!e.files.contains(&"/sd/repo/notes.md".to_string())); + e.handle(Key::Palette); + assert_eq!(palette_labels(&e), vec!["repo/todo.md"]); // only the survivor + } + + #[test] + fn delete_of_a_local_file_carries_local_scope() { + let mut e = Editor::with_file("/sd/local/j.md".into(), Scope::Local, "diary".into()); + ex(&mut e, "delete"); + match &e.take_effects()[..] { + [Effect::Delete { path, scope }] => { + assert_eq!(path, "/sd/local/j.md"); + assert_eq!(*scope, Scope::Local); + } + other => panic!("expected a Local Delete, got {other:?}"), + } + } + + #[test] + fn delete_on_an_unnamed_buffer_is_a_noop() { + let mut e = Editor::new(); // scratch, empty path — nothing on disk to delete + ex(&mut e, "delete"); + assert!(e.take_effects().is_empty()); + assert_eq!(e.mode(), Mode::Normal); + } + // ---- File palette (Ctrl-P) ---- /// A fresh editor over `/sd/repo/notes.md` with a palette file list. diff --git a/firmware/src/git_sync.rs b/firmware/src/git_sync.rs index 67dd183..5e8f115 100644 --- a/firmware/src/git_sync.rs +++ b/firmware/src/git_sync.rs @@ -254,13 +254,20 @@ fn publish_once() -> Result { /// (nothing to publish). Called on the first attempt and again to replay the note /// after a reconcile. /// -/// `add_all` runs a per-path filter that drops macOS AppleDouble sidecars -/// (`._name`) and `.DS_Store` that Finder/Spotlight sprinkle onto the FAT card -/// whenever it's mounted on a Mac — without it, a blind add --all sweeps them into -/// the commit (it did once: 07d87772 shipped `._.git`, `._README.md`, -/// `._notes.md`). Filtering here fixes it for *every* repo at the device level, so -/// no per-repo `.gitignore` is needed. (add --all also stages deletions, for a -/// future note-delete.) +/// Staging is `add --all` **plus** `add -u`, which together equal `git add -A`. +/// `add_all` stages new + modified files; `update_all` re-syncs already-tracked +/// entries to the working tree, which is what actually removes an index entry +/// whose file was deleted. Spike 14 found `add_all` alone did **not** stage a +/// `:delete`d file's removal on this libgit2 build (the tree came back unchanged, +/// so the publish was a silent no-op), so the `update_all` pass is load-bearing, +/// not belt-and-braces — do not drop it. +/// +/// Both run a per-path filter that drops macOS AppleDouble sidecars (`._name`) +/// and `.DS_Store` that Finder/Spotlight sprinkle onto the FAT card whenever it's +/// mounted on a Mac — without it, a blind add --all sweeps them into the commit +/// (it did once: 07d87772 shipped `._.git`, `._README.md`, `._notes.md`). +/// Filtering here fixes it for *every* repo at the device level, so no per-repo +/// `.gitignore` is needed. fn stage_and_commit(repo: &Repository) -> Result> { let mut index = repo.index().context("opening index")?; let mut skip_macos_cruft = |path: &Path, _matched: &[u8]| -> i32 { @@ -271,7 +278,12 @@ fn stage_and_commit(repo: &Repository) -> Result> { }; index .add_all(["*"], IndexAddOption::DEFAULT, Some(&mut skip_macos_cruft)) - .context("staging (add --all)")?; + .context("staging new/modified (add --all)")?; + // Stage deletions: update_all removes index entries whose working-tree file is + // gone. add_all does not do this reliably here (Spike 14), so this is required. + index + .update_all(["*"], Some(&mut skip_macos_cruft)) + .context("staging deletions (add -u)")?; index.write().context("writing index")?; let tree = repo.find_tree(index.write_tree().context("writing tree")?)?; diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 61479f7..600f5db 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -203,6 +203,7 @@ fn main() -> anyhow::Result<()> { // and no-op for now. ed.set_notice("pull: not wired yet (v0.7)"); } + Effect::Delete { path, scope } => delete_buffer(&storage, &mut ed, path, scope), } } } @@ -421,6 +422,33 @@ fn open_buffer(storage: &Storage, ed: &mut Editor, path: String, scope: Scope) { } } +/// Unlink a file from the card (`:delete`). The editor has already dropped it +/// from its model and switched away, so this is pure IO plus the snackbar. For a +/// Tracked file the removal is left in the git working copy — the next `:sync`'s +/// `add --all` stages the deletion — so nothing git-specific happens here. A +/// failure keeps the file on disk and says so; the buffer has still switched, so +/// the file is recoverable by re-opening it. +fn delete_buffer(storage: &Storage, ed: &mut Editor, path: String, scope: Scope) { + // Scope-qualified label (`repo/notes.md`), so the snackbar names exactly which + // file left the card — and, for a Tracked file, that the removal is only local + // until the next `:sync` publishes it (deleting from the card alone never + // touches the remote — that mirrors how a Save is local until Publish). + let label = path.strip_prefix("/sd/").unwrap_or(&path); + match storage.delete_path(&path) { + Ok(()) => { + log::info!("deleted {path} ({scope:?})"); + ed.set_notice(match scope { + Scope::Tracked => format!("deleted {label} - :sync to publish"), + Scope::Local => format!("deleted {label}"), + }); + } + Err(e) => { + log::error!("delete {path} FAILED ({e:#})"); + ed.set_notice(format!("delete FAILED: {label}")); + } + } +} + /// Enumerate the palette's openable files: the top-level regular files in /// `/sd/repo` and `/sd/local`, as absolute paths. Skips dotfiles (so `.git`, /// `.typoena.toml`, and the like never show) and anything that isn't a plain diff --git a/firmware/src/persistence.rs b/firmware/src/persistence.rs index 409b622..5e42bc2 100644 --- a/firmware/src/persistence.rs +++ b/firmware/src/persistence.rs @@ -308,6 +308,20 @@ impl Storage { Ok(()) } + /// Unlink a file under `/sd` (`:delete`). Tolerates a missing target — an + /// already-gone file is a success, so the call is idempotent. Also clears a + /// stray `{path}.tmp` best-effort, so a crash-interrupted save can't leave the + /// file half-present after a delete. For a Tracked file this leaves the + /// working copy short one file; the next publish's `add --all` stages it. + pub fn delete_path(&self, path: &str) -> Result<()> { + let _ = fs::remove_file(format!("{path}.tmp")); + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e).with_context(|| format!("unlink {path}")), + } + } + /// Reconcile a leftover `notes.md.tmp` at boot. The save sequence is /// write-tmp → fsync → unlink-target → rename, so a lingering tmp means the /// last save was interrupted. Which way to recover depends on whether the