The undo command surface re-rules — app-owned rows over FocusedValues, the window latch on record
NSWindow latches its own empty undo manager during SwiftUI window creation, before any delegate of ours can answer, so the nil-target route 13 assumed never reaches the session's manager. Ruled today: Edit ▸ Undo/Redo become the app's own replaced rows and the toolbar pair takes explicit targets, both reading the focused BoardUndoManager; the routing predicate relocates to the command layer unchanged; the delegate hook stays wired, unrelied-on. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -4,7 +4,7 @@ The undo/redo substrate for **every board** (amended 2026-08-08 — strategy/01-
|
||||
|
||||
## Rules
|
||||
|
||||
- **Two levels: one stack per board, one per open card window** (re-ruled 2026-07-31 — the session-coarsening model, superseding the pure one-stack rule): the **board stack** is owned by the board session and shared by board surfaces; a **card window owns its own stack** for the session it represents — every gesture issued in that window (comment post/delete/edit, body Edit sessions, style/details changes, attachment ops where undoable) registers there at fine grain, and `window.undoManager` answers with it (standard per-window AppKit scoping). Disk stays live throughout — files-first untouched; this is history granularity only. **Window close coarsens**: the session's net effect registers on the board stack as **one coarse step named "Changes to '⟨card⟩'"** (ruled 2026-07-31 — the board row reads "Undo Changes to 'Fix login'": plural and scope-flavored, distinct from every fine verb, honest about folding many kinds; the fine body-edit wording never leaks onto the board menu), values-based, whose undo restores the card subtree to its session-start state — deleted comments included — and whose redo reapplies the net effect; a session with no net change registers nothing. The coarse step is transactional at apply time: staleness validation runs per component (the field-level predicate below), and any stale component skips the whole step — never a partial session revert. **Session steps anchor by card identity, never by path** (ruled 2026-07-31): the coarse step — and the window's fine steps it folds — stores the card's UUID plus expected values, and apply-time validation resolves the card's *current* folder exactly the way the window itself always resolves its card (the per-snapshot UUID walk; `writeCardBody` already resolves trash locations on purpose). A tracked relocation — a lane move mid-session or after close, a trash move — therefore never stales the step; only genuine content changes do, which is what the validation exists to catch. A card that resolves nowhere (purged, or moved out of the board) is the honest skip. 06 ▸ Undo routing applies unchanged — text-editing surfaces get their session-scoped text undo above either stack. **Two stacks over one open card are the blessed shape** (2026-07-31): a board-issued gesture on a card whose window is open registers on the board stack while the window's own gestures register on the window stack — no ordering relation between the two, interleaving decided by ⌘Z focus (06 ▸ Undo routing); routing board gestures into the open window's stack was considered and rejected, since board ⌘Z must never see card-session steps.
|
||||
- **Two levels: one stack per board, one per open card window** (re-ruled 2026-07-31 — the session-coarsening model, superseding the pure one-stack rule): the **board stack** is owned by the board session and shared by board surfaces; a **card window owns its own stack** for the session it represents — every gesture issued in that window (comment post/delete/edit, body Edit sessions, style/details changes, attachment ops where undoable) registers there at fine grain, and the window's command surface answers with it (app-owned rows and toolbar targets — the command-surface rule below, re-ruled 2026-08-08; the delegate-vended `window.undoManager` premise this sentence used to state fell to the SwiftUI latch recorded there). Disk stays live throughout — files-first untouched; this is history granularity only. **Window close coarsens**: the session's net effect registers on the board stack as **one coarse step named "Changes to '⟨card⟩'"** (ruled 2026-07-31 — the board row reads "Undo Changes to 'Fix login'": plural and scope-flavored, distinct from every fine verb, honest about folding many kinds; the fine body-edit wording never leaks onto the board menu), values-based, whose undo restores the card subtree to its session-start state — deleted comments included — and whose redo reapplies the net effect; a session with no net change registers nothing. The coarse step is transactional at apply time: staleness validation runs per component (the field-level predicate below), and any stale component skips the whole step — never a partial session revert. **Session steps anchor by card identity, never by path** (ruled 2026-07-31): the coarse step — and the window's fine steps it folds — stores the card's UUID plus expected values, and apply-time validation resolves the card's *current* folder exactly the way the window itself always resolves its card (the per-snapshot UUID walk; `writeCardBody` already resolves trash locations on purpose). A tracked relocation — a lane move mid-session or after close, a trash move — therefore never stales the step; only genuine content changes do, which is what the validation exists to catch. A card that resolves nowhere (purged, or moved out of the board) is the honest skip. 06 ▸ Undo routing applies unchanged — text-editing surfaces get their session-scoped text undo above either stack. **Two stacks over one open card are the blessed shape** (2026-07-31): a board-issued gesture on a card whose window is open registers on the board stack while the window's own gestures register on the window stack — no ordering relation between the two, interleaving decided by ⌘Z focus (06 ▸ Undo routing); routing board gestures into the open window's stack was considered and rejected, since board ⌘Z must never see card-session steps.
|
||||
- **Registration at the Writer boundary.** Every app-mediated mutation already passes through the Writer as a `WriteOperation` (02-architecture.md) — that closed enum is the exact inventory of undoable operations. Each Writer call site registers the inverse operation, computed from the pre-write snapshot the store already holds: move → move back (original lane, original `order`); reorder → restore original `order`; rename → restore title; restyle → restore prior style; resize → restore prior width; Edit-session body save → restore prior body bytes; card or lane delete (⌫) → move back out of `.trash/` (lanes rejoined the trash 2026-07-29 — the recreate-from-capture inverse retires with the last destructive delete); restore-by-move → move back in; create → remove the created folder.
|
||||
- **What is not undoable** (settled): **Permanently delete** (the trash's Delete, Empty Trash) — `purgeIsUnrecoverable` stays true in base, and the existing confirmation rule (03-board-ui.md) already fires on all base boards, since none have git history: the confirm *is* the safety. **The duplicate-id remint** (01-storage-format.md — a silent scheduled heal since 2026-07-29, formerly the user-gated Repair) — heals aren't user gestures, so nothing enters the stack, and undoing one would recreate the duplicate id it exists to remove. Permanently delete matches its existing "destructive, confirmed, final" posture; the remint sits outside undo as all heals do. **Raw Source Apply** (blessed 2026-07-31): the hatch writes byte-for-byte outside every contract — no `modified` stamp, no attribution clear, and no history step at either level; an Apply-only session folds to no coarse step, and an Apply mixed into a session is invisible to the fold. The hatch's story is "you edited the file," and files-are-truth covers it (05-card-window.md's carve-outs are the same statement from the stamping side).
|
||||
- **Coalescing follows commit granularity** (settled; window scoping added 2026-07-31): one gesture, one undo step — a multi-card move is one step with a plural title; an Edit session is one step, registered at the Edit→Preview flip (the effective Save — 05-card-window.md) **on the card window's stack**, like every window gesture; the window close registers the one coarse session step on the board stack (Rules above); a styling batch is one step (03's one-gesture-one-commit rule, substrate swapped). The 06 vocabulary supplies menu titles ("Undo Move 3 Cards"), via NSUndoManager's dynamic retitling — the same naming machinery both editions use.
|
||||
@@ -12,12 +12,13 @@ The undo/redo substrate for **every board** (amended 2026-08-08 — strategy/01-
|
||||
- **Foreign writes never join the stack** (settled): NSUndoManager can only undo what the app mediated. An agent's or hand edit is not a step — the honest capability gap, whose designated successor is the deferred foreign-change journal (strategy/01-git-excision.md ▸ Successors). Foreign changes also do not clear the stack wholesale; collisions are handled lazily, per step, by validation:
|
||||
- **Staleness validation before every apply** (settled): an inverse operation re-checks its target against the disk — a fresh read of the target at ⌘Z time (blessed 2026-07-29: not the store snapshot, which is by construction one reload behind the app's own writes; a rapid ⌘Z run validated against the snapshot would compare pre-state and false-skip every step). **The predicate is field-level** (settled — ruled 2026-07-27): each step registers both sides of its write anyway (the before-value is the inverse; the after-value is what its write set), so validation compares the targeted field's current value against the expected after-value — nearly free, and truer to never-surprise-the-file than an existence-only check (an inverse rename must not clobber a foreign rename on a still-existing card; body steps compare bytes). Target folder gone, or the field no longer holding the step's after-value → the step is **skipped, not applied**: popped from the stack with an info-tone banner ("Undo skipped — 'Fix login' changed outside Lanework"), and ⌘Z falls through to the next step. Never apply a stale inverse on top of someone else's newer write. **Delete steps validate their undo by existence only** (blessed 2026-07-31): a delete's forward write sets nothing but the `modified` stamp (the arrival rank mint retired 2026-07-31), and a clock reading is not a choosable after-value — pinning it would false-skip the restore whenever an agent touched the trashed card; the undo therefore expects only that the trashed folder still exists, while the redo stays field-level via the restore's `order` write. **Invalidation is lazy** (settled — ruled 2026-07-27): staleness is discovered at ⌘Z time, never by background pruning — the EchoLedger's foreign diffs do not eagerly drop colliding steps. The stack always looks full; with the field-level predicate a skip fires only on a genuine per-field collision, and a skipped step's banner explains itself, where eager pruning would shrink the stack invisibly mid-session.
|
||||
- **Locks disable the stack** (settled): every read-only lock (vanished root, failed reload after wholesale ops, unwritable location — 02-architecture.md) disables Undo/Redo with the other mutating commands; the stack itself survives the lock and resumes when it clears. Steps landed before a lock validate like any other at apply time.
|
||||
- **The command surface is app-owned** (ruled 2026-08-08 — superseding the delegate-vended-manager mechanism): Edit ▸ Undo/Redo are the app's own rows (`CommandGroup(replacing: .undoRedo)`) and the toolbar pair carries explicit targets, both reading the focused session's `BoardUndoManager` through SwiftUI focus (`FocusedValues` — the ⌘I board-info precedent; board windows publish the session's manager, card windows their own): titles from `undoMenuItemTitle`/`redoMenuItemTitle` (the platform still composes and localizes the verb over the bare 06-vocabulary phrase), enablement from `canUndo`/`canRedo` (locks and empty stacks, unchanged). The original mechanism — the system's nil-target `undo:`/`redo:` resolving through `windowWillReturnUndoManager` — is unreachable on a SwiftUI window: NSWindow reads and **permanently latches** its own empty undo manager during window creation, before any app code can install the delegate, so the hook is never consulted (diagnosed 2026-08-07 by live probe, standalone-repro-verified; the green command-surface tests install the delegate before the first read and so never reproduce it). Alternatives rejected: registering steps into the latched manager re-opens the substrate problem the two-arrays decision answered (a `.failed` crossing must leave the step in place; NSUndoManager pops a group before running it and offers no way back); intercepting nil-target `undo:` from the responder chain is preempted by NSWindow handling the action itself, ahead of its delegate. The rows enact ▸ Undo routing themselves — the same predicate, relocated from the window delegate to the command layer: while a text surface holds focus, ⌘Z goes to the first responder's own `undoManager` (an editor's delegate-vended manager, or a field editor's window-latched one, which is exactly where its typing undo registered), and otherwise to the focused board or card stack. The `windowWillReturnUndoManager` hook stays wired — it is the correct answer wherever AppKit itself asks a delegate for a manager — but nothing of the command surface depends on it any more.
|
||||
|
||||
## Undo routing
|
||||
|
||||
(Migrated here 2026-08-08 from 06-history-undo.md at that doc's retirement — the rule was always tier- and substrate-independent, and this is its live home now; four docs cite it by this heading.)
|
||||
|
||||
**Routing is by focus** — the platform's first-responder rule. While a text-editing surface is focused (card title field, body Edit mode, raw source, board inline rename), ⌘Z/⇧⌘Z are that editor's own **text undo** — standard, transient, session-scoped: leaving the editor (mode flip, focus loss, close) ends the session, and from then on that content's undo story is board history. Text undo works on **every** board, and so does board-level undo: every board binds a provider (the native stack today; the seam admits future providers), so "no undo" is not a state any board is in. **Control-class text fields route the same way** (settled): the search field (04-interactions.md ▸ Search), the popover's rename field, and the popover's other configuration fields own ⌘Z/⇧⌘Z as field-local text undo while focused — "board menu commands stay enabled" never hands Edit ▸ Undo to board history while a text-bearing control has focus; a reflexive undo over a typo must never become a board-level restore. With focus outside every text-bearing surface — editor or control — Edit ▸ Undo/Redo are, **in a card window, that window's own session stack** (the two-level model in Rules above — fine-grained window gestures; the coarse close unit is one board step), and on board surfaces board history — the board's bound provider (disabling is locks and empty stacks). **No fall-through**: exhausting a focused editor's — or the window's — stack beeps; it never reaches board history.
|
||||
**Routing is by focus** — the platform's first-responder rule. While a text-editing surface is focused (card title field, body Edit mode, raw source, board inline rename), ⌘Z/⇧⌘Z are that editor's own **text undo** — standard, transient, session-scoped: leaving the editor (mode flip, focus loss, close) ends the session, and from then on that content's undo story is board history. Text undo works on **every** board, and so does board-level undo: every board binds a provider (the native stack today; the seam admits future providers), so "no undo" is not a state any board is in. **Control-class text fields route the same way** (settled): the search field (04-interactions.md ▸ Search), the popover's rename field, and the popover's other configuration fields own ⌘Z/⇧⌘Z as field-local text undo while focused — "board menu commands stay enabled" never hands Edit ▸ Undo to board history while a text-bearing control has focus; a reflexive undo over a typo must never become a board-level restore. With focus outside every text-bearing surface — editor or control — Edit ▸ Undo/Redo are, **in a card window, that window's own session stack** (the two-level model in Rules above — fine-grained window gestures; the coarse close unit is one board step), and on board surfaces board history — the board's bound provider (disabling is locks and empty stacks). **No fall-through**: exhausting a focused editor's — or the window's — stack beeps; it never reaches board history. **The routing decision is enacted at the command layer** (re-ruled 2026-08-08, mechanism only — the rule itself is unchanged): the app-owned Undo/Redo rows apply this section's predicate themselves (Rules ▸ the command-surface rule), because the window-delegate hook the platform route rode is never consulted on a SwiftUI window.
|
||||
|
||||
## Interaction with the trash
|
||||
|
||||
|
||||
Reference in New Issue
Block a user