feat(editor): add :enew and :delete with real git-staging (v0.5 slice 3)
:enew <name> 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.
This commit is contained in:
@@ -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 <name>` 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 <path>` **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
|
||||
|
||||
@@ -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 <path>` — 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 <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() {
|
||||
"enew" => self.set_notice("usage: :enew <file>"),
|
||||
"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 <arg>` — 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 <file>");
|
||||
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<Kind> {
|
||||
@@ -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}<Enter>` from Normal.
|
||||
fn edit(e: &mut Editor, arg: &str) {
|
||||
ex(e, &format!("e {arg}"));
|
||||
}
|
||||
|
||||
/// Drive an arbitrary `:{cmd}<Enter>` 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.
|
||||
|
||||
@@ -254,13 +254,20 @@ fn publish_once() -> Result<PublishOutcome> {
|
||||
/// (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<Option<git2::Oid>> {
|
||||
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<Option<git2::Oid>> {
|
||||
};
|
||||
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")?)?;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user