Undo every mutation, and keep backups on disk - #4
Conversation
Undo was a single snapshot slot written by exactly one caller, the note
delete path. Everything else mutated the board with no record: pasting
over a note with ctrl+p replaced its title and body outright, `u`
answered "nothing to undo", and 400ms later the debounced save had
overwritten the only copy on disk. The text was simply gone.
Two changes, in-memory and on-disk.
History (history.go) is a bounded stack of whole-workspace snapshots.
Workspace rather than Board because `D` deletes an entire board of notes
and a board-level snapshot could not bring it back. A workspace is a
handful of flat structs, so a deep copy costs a few KB — far simpler than
a command/diff scheme, and it cannot drift out of sync with the mutation
sites the way the old single slot did.
Every mutation now goes through model.mutate(label), which snapshots then
touches the saver. 50 steps, `u` undoes and `ctrl+r` redoes, and the
label surfaces in the toast ("undo: paste into note"). Two details worth
naming: entering the editor snapshots once, so an editing session undoes
as one step rather than zero; and a drag snapshots at mouse-press, since
by release the note has already moved and the pre-move position is gone.
Same-label pushes within 700ms coalesce, so a held arrow key costs one
entry instead of forty.
View state — zoom, font, background — deliberately does not snapshot.
Burying a content change under six appearance steps makes undo worse.
Backups (storage.go) cover what undo cannot — a crash, or a bad paste
noticed tomorrow. Each save that actually changes content first copies
the previous notes.json to backups/notes-<timestamp>.json, keeping 20.
Saves that would write identical bytes do not rotate, so idling cannot
flush real history out of the ring. Rotation is best-effort: a failure to
back up never blocks the save, or a full disk would cost the user their
live work as well as their history.
ctrl+p over a note that already has text now asks first ("ctrl+p again to
replace <title>", 3s window). Undo covers it either way, but the
confirmation is what the user actually sees before the text disappears.
Tests: history semantics (deep copy, bounds, coalescing, redo
invalidation), the same paths driven through the model's key handlers,
and backup rotation including restoring one and loading it. Reverting
model.mutate to its pre-fix behavior fails six of them.
There was a problem hiding this comment.
Pull request overview
This PR addresses data-loss risk by introducing a real undo/redo stack that covers all workspace mutations (not just deletes) and by adding on-disk backup rotation for notes.json so prior states can be recovered across sessions.
Changes:
- Add a bounded workspace-snapshot history stack (undo/redo + coalescing) and integrate it via a centralized
m.mutate(label)seam. - Add best-effort backup rotation on save, keeping the newest 20 distinct prior versions.
- Add tests covering history semantics, integration via key handlers, and backup validity/rotation; update README to document undo/backups and redo.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents multi-step undo/redo, paste confirmation, and backup recovery workflow. |
| internal/app/undo_integration_test.go | Integration tests that drive undo/redo via model key handlers and cover paste confirmation/coalescing. |
| internal/app/storage.go | Adds backup rotation/pruning and backup directory helper around SaveWorkspace. |
| internal/app/model.go | Replaces single-slot delete undo with history-backed undo/redo; centralizes mutation snapshotting; adds paste confirmation. |
| internal/app/history.go | New bounded snapshot-based undo/redo implementation with coalescing + deep clone helpers. |
| internal/app/history_test.go | Unit tests for history cloning, bounds, coalescing, redo invalidation, and round-trips. |
| internal/app/backup_test.go | Tests for backup creation, bounded rotation, unchanged-save behavior, and restore validity. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| case "}": | ||
| // Move active board one slot to the right. | ||
| if m.workspace.MoveActive(+1) { | ||
| m.saver.Touch() | ||
| m.mutate("move board") | ||
| m.setToast("moved board →") |
There was a problem hiding this comment.
Confirmed and fixed in 1d9d0db.
MoveActive rearranges w.Boards in place, and the call sat inside the if condition, so the snapshot recorded the already-reordered slice — undo was a straight no-op, not merely inaccurate.
Snapshot now happens first. That introduced a second-order problem: mutate would then fire even when the move was impossible (single board, or a wrap onto itself), burning an undo slot on nothing. Added Workspace.CanMoveActive(delta) so the caller can ask before committing.
TestUndoRestoresBoardOrder covers the reorder, TestNoOpBoardMoveDoesNotPushHistory the no-op. Reverting the ordering fails the first.
| case "{": | ||
| // Move active board one slot to the left. | ||
| if m.workspace.MoveActive(-1) { | ||
| m.saver.Touch() | ||
| m.mutate("move board") | ||
| m.setToast("moved board ←") |
There was a problem hiding this comment.
Same fix as the } case above — both sites changed together in 1d9d0db.
| m2 := pressKey(t, m, "ctrl+p") | ||
| if got := m2.findNote(n.ID); got.Title != origTitle && !m2.pasteArmedUntil.IsZero() { | ||
| t.Errorf("first ctrl+p replaced the note title (%q); it should warn first", got.Title) | ||
| } |
There was a problem hiding this comment.
Correct, and worse than unreliable — that assertion could not fail. The wrong-behavior path clears pasteArmedUntil, so a ctrl+p that pasted immediately made !IsZero() false and the && short-circuited to a pass. I wrote a test that asserted nothing.
It also had no business depending on the system clipboard: absent in CI, and shared global state when present.
Rewritten in 1d9d0db. readClipboard is now a package var the tests stub, and the three paths are asserted separately:
TestPasteOverANonEmptyNoteAsksFirst— first press changes nothing, arms the window, and shows the promptTestSecondPasteReplacesAndIsUndoable— second press commits, andugets the note backTestPasteIntoAnEmptyNoteDoesNotAsk— nothing to lose, so no nag
Replacing the guard condition with if false now fails the first one.
| // Timestamped so the ring sorts chronologically and a same-second | ||
| // save does not collide. | ||
| stamp := time.Now().UTC().Format("20060102-150405.000") | ||
| name := filepath.Join(dir, "notes-"+stamp+".json") | ||
| if err := os.WriteFile(name, prev, 0o644); err != nil { | ||
| return | ||
| } |
There was a problem hiding this comment.
Reproduced before fixing: 50 rapid saves with distinct content left only 16 backups. The rest collided on the filename and were silently overwritten — so the ring was quietly holding fewer versions than it claimed.
Fixed in 1d9d0db with 20060102-150405.000000000. Fixed-width, so lexical order still equals chronological order, which pruneBackups relies on when sorting to decide what to drop.
TestRapidSavesDoNotCollide asserts all 20 survive; TestBackupNamesSortChronologically pins the ordering property the pruner depends on.
| return nil | ||
| } | ||
| out := *ws | ||
| if ws.Boards != nil { | ||
| out.Boards = make([]*Board, len(ws.Boards)) |
There was a problem hiding this comment.
The best of the five — it caught a contradiction between the PR description and the code. Verified: with a note created, then a zoom out, u rewound the zoom too.
I kept the whole-struct copy rather than making cloneBoard selective. The snapshot being a complete copy is what makes it hard to get wrong; a clone that omits fields is a clone that silently drops the next field someone adds. The right place to express "view state is not undoable" is the restore, so applyRestoredWorkspace now carries the live zoom / font / highlight / background across.
One wrinkle worth naming: I first keyed the carry by board name, which broke immediately — renames are themselves undoable, so the match fails in exactly the case where the board is being restored. Keyed by GrainSeed instead, which is assigned once at creation and never changes.
Three tests in 1d9d0db: TestUndoLeavesViewStateAlone, TestUndoRenamePreservesViewState, TestViewStateChangesDoNotPushHistory.
| the previous `notes.json` to: | ||
|
|
||
| ``` | ||
| ~/.local/share/redthread/backups/notes-YYYYMMDD-HHMMSS.mmm.json |
There was a problem hiding this comment.
This one was inverted at the time — rotateBackup used millisecond precision then, and the README matched it. But the sibling comment about collisions was right, so the stamp is now nanosecond and the README did need updating.
Both moved together in 1d9d0db: the pattern reads notes-YYYYMMDD-HHMMSS.nnnnnnnnn.json and the worked example alongside it matches. TestBackupFilenameMatchesDocumentedPattern asserts the real filenames against that regex, so the docs cannot drift from the code again.
Five findings from review on B33pBeeps#4. Four were real. Board reorder snapshotted too late. `{` / `}` called mutate inside the `if m.workspace.MoveActive(...)` condition, so the snapshot recorded the already-reordered slice and undo was a no-op. Snapshot first; ask CanMoveActive() up front so a no-op move (one board, or a wrap onto itself) does not burn an undo slot either. Undo reverted view state. The PR claimed zoom, font, and background were outside undo's remit, but a snapshot is a whole-struct copy, so those fields rode along inside it and an unrelated content undo rewound the zoom. applyRestoredWorkspace now carries the live values across a restore, keyed by GrainSeed rather than name — a rename is itself undoable, so a name-keyed match breaks exactly when it matters. Backup filenames collided. Millisecond stamps are not enough when the debounce can fire twice inside a millisecond: 50 rapid distinct saves left only 16 backups, the rest silently overwritten. Nanosecond resolution, fixed-width so lexical order still equals chronological order for pruning. README updated to match. The paste test could not fail. It asserted `title != orig && armed`, but the wrong-behavior path clears the arm window, so a paste that fired immediately satisfied neither half. It also depended on the system clipboard. readClipboard is now a package var the tests stub, and the three paste paths — first press warns, second press commits and is undoable, empty note applies immediately — are each asserted directly. Removing the guard now fails the test. The fifth finding (a claim that README documented a precision the code did not use) was backwards at the time it was written, but the doc needed the same edit anyway once the stamp changed.
|
@copilot the six review findings are addressed in 1d9d0db — four were real bugs (snapshot ordering on board reorder, view-state bleeding through undo, backup filename collisions, and a paste test whose assertion could not fail). Details are in the individual threads. Worth another look at the fixes if you're able. |
|
Same here — no rush. Worth flagging the motivation for this one, since it is a data-loss bug rather than a feature: So the two halves are: undo now covers every mutation rather than just deletes, and each content-changing save rotates the previous If the whole thing is more than you want to take at once, the backup rotation in One thing I did not fix, noted in the description: |
The suite raced under -race, and it was my tests that exposed it: a key press arms the debounced saver, whose timer marshals the workspace on its own goroutine ~400ms later — after the test has returned and while the next one is already mutating. Saver.Cancel drops a pending write, and newTestModel calls it at teardown. The underlying production race (marshal on a timer goroutine vs. the update loop) is untouched and still worth a separate fix; this just stops the tests from tripping over it and keeps -race meaningful as a signal.
|
Correction to something I said earlier in this PR: I reported the suite as They were in my tests, not the feature: Fixed in a3536ea with a The production race this was tripping over — |
The bug
Undo was a single snapshot slot, and exactly one caller wrote to it:
Every other mutation changed the board with no record. Pasting over a
note is the sharp edge:
So
ctrl+pon a focused note replaced its title and body outright,uanswered "nothing to undo", and the debounced save overwrote the only
copy on disk. There were no backups. The note was unrecoverable — which
is how this got reported.
In memory: a real undo stack
history.goholds a bounded stack of whole-workspace snapshots.Workspace rather than Board because
Ddeletes an entire board of notes,and a board-level snapshot could not bring that back. A workspace is a
handful of flat structs, so a deep copy costs a few KB — much simpler
than a command/diff scheme, and it can't drift out of sync with the
mutation sites the way the single slot did.
All 30 mutation sites already funnelled through
m.saver.Touch(), sothat was the natural seam. They now call
m.mutate(label), whichsnapshots and then touches. 50 steps,
uundoes,ctrl+rredoes, andthe label shows in the toast (
undo: paste into note).Two cases needed thought rather than a mechanical swap:
undoes as one step back to the pre-edit text — not zero steps, and not
one per keystroke.
already moved, so that is the only moment the pre-move position still
exists.
Same-label pushes within 700ms coalesce, so a held arrow key costs one
entry rather than forty and cannot evict the state you actually want.
Deliberately outside undo: zoom, font, highlight, and background. They're
view state, and burying a real content change under six appearance steps
makes undo worse. Note that a snapshot is a whole-struct copy, so those
fields do ride along inside it — the restore carries the live values
across rather than the clone omitting them, keyed by
GrainSeedsince arename is itself undoable. (Review caught that I had claimed this
without implementing it; fixed in 1d9d0db.)
On disk: backups
Undo dies with the session. Each save that actually changes content now
copies the previous file aside first:
Newest 20 kept, stamped to nanosecond resolution — the debounce can fire
twice inside a millisecond, and a colliding name silently overwrites a
distinct version (50 rapid saves were leaving only 16 backups before
review caught this). Saves that would write identical bytes don't rotate,
so idling can't flush real history out of the ring. Rotation is
best-effort — a failure to back up never blocks the save itself, since a
full disk shouldn't cost you your live work on top of your history.
And a confirmation
ctrl+pover a note that already has text now asks: the footer readsctrl+p again to replace <title>, and only a second press within 3scommits. Undo covers it regardless, but the confirmation is what you
actually see before the text disappears — which is the difference
between an inconvenience and the report that prompted this.
Testing
go test ./...,-race,go vet,gofmtall clean. Three new testfiles: history semantics (deep copy, bounds, coalescing, redo
invalidation), the same paths driven through the model's real key
handlers, and backup rotation — including writing a backup back over
notes.jsonand loading it.Reverting
model.mutateto its pre-fix behavior fails six of them, sothey're testing the fix rather than describing it.
One note: a pre-existing data race
Saver.TouchrunsSaveWorkspaceon atime.AfterFuncgoroutine, whichJSON-marshals the workspace while the UI thread mutates notes.
go test -raceflags it on currentmaintoo — I checked against unmodified codebefore concluding it wasn't mine, so it is not introduced here and
not fixed here.
It is worth a small follow-up, though, and backups raise the stakes
slightly: a torn marshal can now be rotated into the backup ring. The fix
is either marshalling under a mutex the update loop also holds, or
snapshotting before handing off to the timer —
cloneWorkspacefrom thisPR would do it. Happy to send that separately if you'd like.
This PR is independent of #3 (the light palette); they touch different
things and can merge in either order.