Give card windows their own undo stacks and coarsen the close
Phase B of the two-level undo card: every card-window gesture — comment
post/delete/edit, body Edit sessions, style and details changes —
registers fine-grained on the window's own stack (window.undoManager
answers with it; board ⌘Z never sees mid-session card steps; an empty
window stack beeps, never falls through). Window close folds the stack
into one coarse values-based board step ("Edit card 'X'") — per-target
per-field later-wins merge, so foreign mid-session writes stay out by
construction, a no-net-change session registers nothing, and any stale
component skips the whole step. The comments/.trash purge defers with
the coarse step via a step-retirement seam on the providers: it runs
when the step leaves the board stack or the board session ends; the git
provider retires dropped steps on register, which keeps Pro's
purge-at-close-flush structural with no tier check. Interim on git
boards: gestures still auto-commit per debounce until phase C's
close-flush commit.
2432 tests in 418 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -74,22 +74,90 @@ extension BoardStore {
|
||||
func registerStep(
|
||||
_ name: String,
|
||||
subject: String? = nil,
|
||||
on window: CardWindowUndo? = nil,
|
||||
retiring: (@MainActor () -> Void)? = nil,
|
||||
undoExpects: [HistoryExpectation],
|
||||
redoExpects: [HistoryExpectation],
|
||||
undo: @escaping @MainActor (BoardStore) throws -> Void,
|
||||
redo: @escaping @MainActor (BoardStore) throws -> Void
|
||||
) {
|
||||
guard let history else { return }
|
||||
let retirement = retiring.map(HistoryStep.Retirement.init)
|
||||
// **The routing decision, and the whole of it** (13 ▸ Rules ▸ two levels): a gesture issued
|
||||
// in a card window lands on that window's stack, and everything else on the board's. It is a
|
||||
// parameter rather than ambient state on purpose — the issuing surface is knowledge only the
|
||||
// call site has, and a store-wide "current window" would be a second answer able to be wrong
|
||||
// for exactly one gesture (the board styling a card whose window happens to be open).
|
||||
guard let sink: any HistoryProviding = window?.stack ?? history else {
|
||||
// No substrate at all — a repo-nested board (06 ▸ Rules), or a store with no session.
|
||||
// Nothing records the step, so nothing can ever retire it: the work is owed now.
|
||||
retirement?.run()
|
||||
return
|
||||
}
|
||||
let named = subject ?? name
|
||||
history.register(HistoryStep(
|
||||
let step = HistoryStep(
|
||||
name: name,
|
||||
retirement: retirement,
|
||||
undo: { [weak self] direction in
|
||||
BoardStore.cross(self, direction, named, undoExpects, undo)
|
||||
},
|
||||
redo: { [weak self] direction in
|
||||
BoardStore.cross(self, direction, named, redoExpects, redo)
|
||||
}
|
||||
)
|
||||
// The raw halves, kept beside the window's stack for the close fold — before the register, so
|
||||
// a fold taken from inside a registration's own side effects can never see a step it has no
|
||||
// write for (`CardWindowUndo.netEffect`).
|
||||
window?.record(step.id, CardWindowUndo.Write(
|
||||
undoExpects: undoExpects,
|
||||
redoExpects: redoExpects,
|
||||
undo: undo,
|
||||
redo: redo
|
||||
))
|
||||
sink.register(step)
|
||||
}
|
||||
|
||||
// MARK: The window close's coarse step
|
||||
|
||||
/// **Registers one card-window session as one board step** — 13-native-undo.md ▸ Rules' window
|
||||
/// close ("the session's net effect registers on the board stack as one coarse step, 'Edit card
|
||||
/// ⟨title⟩', values-based, whose undo restores the card subtree to its session-start state —
|
||||
/// deleted comments included — and whose redo reapplies the net effect").
|
||||
///
|
||||
/// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about
|
||||
/// whether there is a board to register it on is this method's:
|
||||
///
|
||||
/// - **A vanished card registers nothing.** 05-card-window.md ▸ Deletion & lifecycle dismisses the
|
||||
/// window when its card leaves the board — into the trash, with its lane, to another board — and
|
||||
/// the card's own departure is already a board step of its own (`deleteCard`). A session step
|
||||
/// naming folders that have moved could only be a step that skips, so the honest answer is not
|
||||
/// to register one: the window's fine stack dies with the window, as 13's session-only rule has
|
||||
/// it. (A trashed card keeps its `comments/.trash/` too — "a trashed card carries its
|
||||
/// `comments/`", 01-storage-format.md — and the residue sweeps at the next open of that card.)
|
||||
/// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`.
|
||||
///
|
||||
/// - Parameter retiring: the deferred `comments/.trash/` purge (13 ▸ Interaction with the trash).
|
||||
/// - Returns: whether the purge now has an owner — a live step holding it until the step leaves
|
||||
/// the board stack, or a substrate that declined to keep the step and therefore ran it already
|
||||
/// (`GitHistoryProvider.register`). `false` means nothing was registered and the caller still
|
||||
/// owes the purge.
|
||||
@discardableResult
|
||||
func registerCardSession(
|
||||
_ window: CardWindowUndo,
|
||||
inCard cardID: ItemID,
|
||||
retiring: @escaping @MainActor () -> Void
|
||||
) -> Bool {
|
||||
guard let item = Self.boardItem(cardID, in: snapshot), item.cardID != nil else { return false }
|
||||
guard let net = window.netEffect() else { return false }
|
||||
registerStep(
|
||||
HistoryPhrase.cardSession,
|
||||
subject: item.title,
|
||||
retiring: retiring,
|
||||
undoExpects: net.undoExpects,
|
||||
redoExpects: net.redoExpects,
|
||||
undo: net.undo,
|
||||
redo: net.redo
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Validates one side of a step against disk, then runs it as an ordinary bracketed write.
|
||||
|
||||
Reference in New Issue
Block a user