ADR 0018: Undo/redo — diff-based groups on top of the buffer's own primitives¶
Status¶
Accepted
Context¶
Undo/redo has been a known gap since ADR 0005, which chose a piece
table specifically because it made undo "simpler... on top of this:
each edit already produces a small, cheap-to-record diff" — but the
diff-recording layer itself was never built. It's a real standalone
gap in a "complete normal editor," not only a Vim prerequisite (Vim's
u/Ctrl-r is deferred further out — see docs/ROADMAP.md).
The one design wrinkle specific to this editor: a single keystroke with multi-cursor active (ADR 0012) already issues N buffer operations, one per cursor, processed highest-offset-first so no cursor's recorded offset is invalidated by another cursor's edit in the same batch. Undo has to treat that whole batch as one user-visible action, and has to unwind it in a way that respects the same ordering constraint the forward pass relies on.
Decision¶
New core module, core/include/ase/undo.h + core/src/undo.c,
AseUndoStack (opaque, alongside AseBuffer):
- Records edit intent, not snapshots: each entry is
{offset, inserted bytes XOR removed bytes}.EditorViewport's edit primitives (insertTextAt,deleteBackwardAt,deleteForwardAt) callase_undo_record_insert/_deleteright alongside the matchingase_buffer_insert/_deletecall — for deletes, the removed bytes are read out ofm_cachebefore the buffer call, sincease_buffer_deletedoesn't hand back what it removed andm_cacheis already known-correct at that position (unaffected by any higher-offset cursor processed earlier in the same batch, by the same invariant that already lets multi-cursor deletes skip cross-cursor bookkeeping). - Groups, not individual entries, are the undo/redo unit.
ase_undo_begin_group/_end_groupbracket each ofinsertText/deleteBackward/deleteForward's per-cursor loops inEditorViewport, each call snapshotting the caller's current cursor offsets (copied in, not referenced) so undo/redo can restore them exactly rather than re-deriving a position. A group with zero recorded entries (every cursor's op was a no-op, e.g. Backspace at offset 0) is discarded rather than pushed — nothing to undo, and a no-op keystroke shouldn't clear redo history. - Undo unwinds in the exact reverse of application order (not
regrouped by offset): entries were recorded highest-offset-first, and
each entry's offset is only guaranteed valid in the buffer state that
existed right before that entry was originally applied. Redo
reapplies in the original forward order — the order already proven
safe. Both use
AseBuffer's two existing mutators (ase_buffer_insert/_delete) as the only way they touch the buffer — no new buffer-internal access. - A new edit clears redo history (standard semantics), but only once a group actually has entries — an all-no-op group leaves redo alone.
- GUI wiring: one
AseUndoStack *m_undoonEditorViewport, created/ destroyed alongside the buffer.Ctrl+Z/Ctrl+Shift+Zcall newundo()/redo()methods, which apply the returned cursor snapshot,refreshCache(), andsnapAnimationToTarget()— an undo/redo renders instantly, the same reasoning ADR 0017 already established for typing: gliding can't keep pace with rapid actions and just reads as lag. - Deliberately no coalescing of consecutive keystrokes into one
group — each
keyPressEventcall is its own group, so typing three characters is three separate undo steps, not one. Simpler, and matches how several real editors behave without a "typing session" heuristic; revisit only if it's reported as annoying in practice.
Consequences¶
Tested in core/tests/test_undo.c (single insert/delete round-trips,
grouped multi-cursor undo/redo, redo cleared by a new edit, a no-op
group not polluting the stack, undo past the beginning as a no-op not
a crash) — all passing under ASan/UBSan alongside the rest of the
suite. Verified live: typing "123" then one Ctrl+Z removes only the
"3" (per-keystroke granularity, as designed); two simultaneous cursors
typing "Z" at once, then one Ctrl+Z, revert both together. Every
later phase in the "complete normal editor" plan (selection-delete,
cut, paste, replace-all) routes through the same begin_group/
record_*/end_group calls this phase established, rather than
needing its own undo plumbing.