Problem
The block editor has no undo/redo. Every destructive action — backspace merging
blocks, Ctrl+K cutting a block, accidentally deleting a heading — is permanent
until the user manually reconstructs it. For a developer audience with Ctrl+Z
muscle memory, this suppresses confident editing and makes the tool feel fragile.
Context
The editor (internal/editor/editor.go) uses a block-based content model where
m.blocks []block.Block holds the document and m.textareas []textarea.Model
provides one textarea per block. The Block struct is simple (4 fields: Type,
Content, Language, Checked), making deep copies cheap.
There are ~10 structural mutation points that need undo tracking:
insertBlockBefore / insertBlockAfter (line ~300-337)
deleteBlock (line ~342)
mergeBlockUp (line ~369)
swapBlocks (line ~420)
handleEnter (line ~444) — splits blocks
handleBackspace (line ~583) — merges/deletes/converts blocks
cutBlock (line ~652)
applyPaletteSelection (line ~688) — block type change
- Checklist toggle via Ctrl+X (line ~944)
Content is synced from textareas to blocks in focusBlock() (line ~250-262)
before changing focus, which provides a natural batching boundary for
character-level edits.
Ctrl+Z and Ctrl+Y are both unbound. Key events are matched via msg.String()
(e.g., "ctrl+z"), not enum constants.
The forked textarea (fork/bubbles/textarea/textarea.go) has SetCursor(col)
and Line() but no SetRow() method. Cursor restoration on undo needs a new
SetRow method added to the fork.
newTextareaForBlock() (line ~144-163) already exists and rebuilds a textarea
from a block — this is the restore path for undo/redo.
Approach: full-snapshot undo
Deep copy []block.Block + active index + cursor position on each structural
mutation. Restore by replacing blocks, rebuilding textareas via
newTextareaForBlock, and refocusing. Block struct is small (~4 fields), so
memory is negligible even at 100-depth cap (~1MB worst case for a large doc).
Character-level edits are batched by focus-change boundaries: focusBlock()
pushes an undo snapshot only when the active block's content changed since the
last snapshot. This means typing "hello world" then moving to another block
creates one undo entry for all the typing — matching VS Code / Notion behavior.
Tasks
Test plan
Scope
Type: feature
Size: medium
Problem
The block editor has no undo/redo. Every destructive action — backspace merging
blocks, Ctrl+K cutting a block, accidentally deleting a heading — is permanent
until the user manually reconstructs it. For a developer audience with Ctrl+Z
muscle memory, this suppresses confident editing and makes the tool feel fragile.
Context
The editor (
internal/editor/editor.go) uses a block-based content model wherem.blocks []block.Blockholds the document andm.textareas []textarea.Modelprovides one textarea per block. The
Blockstruct is simple (4 fields: Type,Content, Language, Checked), making deep copies cheap.
There are ~10 structural mutation points that need undo tracking:
insertBlockBefore/insertBlockAfter(line ~300-337)deleteBlock(line ~342)mergeBlockUp(line ~369)swapBlocks(line ~420)handleEnter(line ~444) — splits blockshandleBackspace(line ~583) — merges/deletes/converts blockscutBlock(line ~652)applyPaletteSelection(line ~688) — block type changeContent is synced from textareas to blocks in
focusBlock()(line ~250-262)before changing focus, which provides a natural batching boundary for
character-level edits.
Ctrl+Z and Ctrl+Y are both unbound. Key events are matched via
msg.String()(e.g.,
"ctrl+z"), not enum constants.The forked textarea (
fork/bubbles/textarea/textarea.go) hasSetCursor(col)and
Line()but noSetRow()method. Cursor restoration on undo needs a newSetRowmethod added to the fork.newTextareaForBlock()(line ~144-163) already exists and rebuilds a textareafrom a block — this is the restore path for undo/redo.
Approach: full-snapshot undo
Deep copy
[]block.Block+ active index + cursor position on each structuralmutation. Restore by replacing blocks, rebuilding textareas via
newTextareaForBlock, and refocusing. Block struct is small (~4 fields), somemory is negligible even at 100-depth cap (~1MB worst case for a large doc).
Character-level edits are batched by focus-change boundaries:
focusBlock()pushes an undo snapshot only when the active block's content changed since the
last snapshot. This means typing "hello world" then moving to another block
creates one undo entry for all the typing — matching VS Code / Notion behavior.
Tasks
editorStatesnapshot struct andundoStacktype with push/pop/clearand max-depth cap (100) in
internal/editor/undo.godeepCopyBlocks()helper for[]block.BlockSetRow(row int)to forked textarea for cursor row restorationundoStack,redoStack, andundoDirtyfields to editorModelpushUndo()— captures state, pushes to undo stack, clears redo stackrestoreState()— sets blocks, rebuilds textareas, focuses active, restores cursorperformUndo()/performRedo()— swap between stacks and restorectrl+z→performUndo()andctrl+y→performRedo()in UpdatepushUndo()call before each structural mutation (10+ sites)pushUndo()infocusBlock()whenundoDirtyis trueundoDirty = trueon textarea keystroke forwarding (line ~1030)internal/editor/undo_test.goTest plan
Scope
Type: feature
Size: medium