Repo-nested boards bind native undo in every tier (25d2513): the no-undo case is gone, makeHistoryProvider answers git or native, and the native path provably never touches the enclosing repository's .git. Session undo steps anchor by card identity, never by path (9119aa1): HistoryAnchor carries the card UUID (plus comment/draft vocabulary) and apply-time validation resolves the current folder via the same both-container walk writeCardBody uses — a board-side lane or trash move no longer stales the coarse close step, while a genuine field collision still skips it whole. 2448 tests in 423 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
403 lines
22 KiB
Swift
403 lines
22 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
// MARK: - Registration at the Writer boundary
|
|
|
|
/// The inverse-registration layer: how a `BoardStore` write turns into an undo step
|
|
/// (13-native-undo.md ▸ Rules).
|
|
///
|
|
/// ### Why it is an extension rather than lines inside each method
|
|
///
|
|
/// Every registration in `BoardStore` is the same four moves — name the gesture, hold the *values*
|
|
/// the write is about to overwrite, hold the values it is about to set, and hand the pair to the
|
|
/// session's stack — and only the middle two differ per operation. Spelling the frame once here
|
|
/// leaves each call site with the part that is actually about *that* operation: which fields it
|
|
/// touched and what they said before. It also keeps the one rule every step must obey in a single
|
|
/// place — **an inverse is performed as an ordinary app-mediated write**, through `performWrite`,
|
|
/// so an undo brackets the watcher, echoes back through the reload like any other change, refreshes
|
|
/// every window on the board, and (on git boards, pro-m1) commits. Undone changes are real writes,
|
|
/// never in-memory reverts.
|
|
///
|
|
/// ### Values, never live references
|
|
///
|
|
/// Nothing a step closes over is read from the snapshot at crossing time: every closure below
|
|
/// captures ids, folder URLs, orders, titles, style values and body bytes as **values**, computed
|
|
/// from the pre-write snapshot the store was holding when the gesture ran. That is what makes a step
|
|
/// meaningful minutes later, after any number of reloads, and it is what the **field-level staleness
|
|
/// predicate** compares against — each step carries both sides of its write (13 ▸ Rules ▸ staleness
|
|
/// validation).
|
|
///
|
|
/// ### Every step declares what its write left behind
|
|
///
|
|
/// Beside the two closures, each registration below hands the funnel two `[HistoryExpectation]`
|
|
/// lists: what the board must look like for the undo to be safe (the state the *forward* write left),
|
|
/// and what it must look like for the redo to be safe (the state the *undo* leaves). One entry per
|
|
/// item the gesture touched, naming that item, whether it should be there and live, and the fields
|
|
/// the write actually set — "extend the registration to carry whatever the predicate needs, no more".
|
|
/// A field this gesture never wrote is never listed, which is what makes a foreign edit elsewhere —
|
|
/// another card, another field of the same card — leave the step alone.
|
|
///
|
|
/// ### Board gestures name a folder; session steps name a card
|
|
///
|
|
/// The one split in that sentence (`HistoryAnchor`, ruled 2026-07-31): a board gesture's expectation
|
|
/// carries the path it wrote to, because where the item sits is what the gesture is *about*; a card
|
|
/// window session's carries the card's **identity**, and the folder is resolved at apply time by the
|
|
/// same snapshot walk the window resolves its own card with. So a lane move stales a move step (it
|
|
/// should) and no longer stales the body edit that happens to have been typed into the same card.
|
|
/// `folder(for:)` below is the one resolution both halves of a crossing go through.
|
|
@MainActor
|
|
extension BoardStore {
|
|
|
|
// MARK: The funnel
|
|
|
|
/// Registers one gesture's step on the board's stack — the one call every write site below makes,
|
|
/// and the only place `HistoryStep` is built.
|
|
///
|
|
/// `undo` walks the board back across the write; `redo` replays the write itself. Both are handed
|
|
/// the store rather than capturing it, so the only reference a step holds to the board is the
|
|
/// weak one this method installs: a stack outliving its session must not be the reason a board
|
|
/// stays in memory.
|
|
///
|
|
/// `subject` is what the **skip banner** quotes: the item's title where the step names exactly
|
|
/// one titled item, and otherwise `nil`, which falls back to the step's own 06 phrase. That is
|
|
/// two readings of one sentence rather than two sentences — 13's own example is an item ("Undo
|
|
/// skipped — 'Fix login' changed outside Lanework") and a batch has no single item to name, so it
|
|
/// says what it is instead ("Undo skipped — 'Move 3 Cards' changed outside Lanework"). An
|
|
/// untitled item takes the phrase too: "Untitled" is a rendering, never a value (03-board-ui.md
|
|
/// § Card face).
|
|
///
|
|
/// **A failed inverse answers `.failed`, a stale one `.skipped`** — the distinction the stack
|
|
/// depends on (`HistoryStepOutcome`): a stale step is dropped and ⌘Z falls through, a failed one
|
|
/// stays put with `performWrite`'s error banner already standing. The read-only lock reaches this
|
|
/// path only from a direct call — the adapter disables Undo and Redo under it
|
|
/// (`BoardUndoManager`, 13 ▸ Rules ▸ locks) — and is a refusal rather than a staleness, so it too
|
|
/// leaves the stack where it is: the lock clears, the stack resumes.
|
|
///
|
|
/// The seam is spelled with an **untyped** `throws`, deliberately and at a cost worth recording:
|
|
/// every inverse below is made of `BoardWriter` calls and throws nothing but `BoardWriteError`,
|
|
/// but a multi-statement closure literal cannot inherit a typed `throws` from its context —
|
|
/// Swift's inference stops at the brace — so a typed seam would make all twelve call sites spell
|
|
/// `{ (_: BoardStore) throws(BoardWriteError) -> Void in }` (the same wart `performWrite`'s doc
|
|
/// comment records, arriving from a third direction). `cross` narrows it back, and reports rather
|
|
/// than swallows the case that cannot happen.
|
|
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
|
|
) {
|
|
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 store with no session, or a test's substrate-less board. No
|
|
// board the app composes lands here any more (`AppModel.makeHistoryProvider`, re-ruled
|
|
// 2026-07-31: repo-nested boards bind the native stack like every other gitless board).
|
|
// Nothing records the step, so nothing can ever retire it: the work is owed now.
|
|
retirement?.run()
|
|
return
|
|
}
|
|
let named = subject ?? name
|
|
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 card that resolves nowhere registers nothing** — purged, or moved out of the board.
|
|
/// There is no folder for the step's components to be about, so a step registered here could
|
|
/// only be a step that skips, and 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 card in the trash still registers**, and that is the ruling of 2026-07-31 read at the
|
|
/// coarse step: the resolution below is `cardBodyTarget`'s, spanning both containers exactly as
|
|
/// `writeCardBody`'s does, so "a trash move" is one of the tracked relocations that "never
|
|
/// stales the step". 05-card-window.md ▸ Deletion & lifecycle dismisses the window when its card
|
|
/// is deleted, and the session it was in the middle of is still the user's to walk back — into
|
|
/// the trash folder the card now sits in, whose subtree the delete moved intact. (A trashed card
|
|
/// carries its `comments/` — 01-storage-format.md — which is what makes that true of the deleted
|
|
/// comments too.)
|
|
/// - **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 target = Self.cardBodyTarget(cardID, in: snapshot) else { return false }
|
|
guard let net = window.netEffect() else { return false }
|
|
registerStep(
|
|
HistoryPhrase.cardSession,
|
|
subject: Self.cardTitle(at: target, in: snapshot),
|
|
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.
|
|
///
|
|
/// The order is the whole rule: **nothing is written until every target still holds what this
|
|
/// step left there** (13 ▸ Rules ▸ staleness validation, "Never apply a stale inverse on top of
|
|
/// someone else's newer write"). A stale target posts the info-tone row that explains the skip and
|
|
/// answers `.skipped`, which pops the step and falls the crossing through to the next one down.
|
|
private static func cross(
|
|
_ store: BoardStore?,
|
|
_ direction: HistoryDirection,
|
|
_ subject: String,
|
|
_ expectations: [HistoryExpectation],
|
|
_ write: @MainActor (BoardStore) throws -> Void
|
|
) -> HistoryStepOutcome {
|
|
// The board went away under its own stack — a session torn down between the registration and
|
|
// the ⌘Z. There is nothing to write to and nothing to say it on, and a step whose board is
|
|
// gone is not one to pop: the stack is about to be cleared with the session anyway.
|
|
guard let store else { return .failed }
|
|
|
|
guard HistoryStaleness.isCurrent(expectations, resolvedBy: store.folder(for:)) else {
|
|
store.banners.postSkippedStep(direction, subject: subject)
|
|
return .skipped
|
|
}
|
|
|
|
var foreign: (any Error)?
|
|
let landed: Void? = try? store.performWrite { () throws(BoardWriteError) -> Void in
|
|
do {
|
|
try write(store)
|
|
} catch let error as BoardWriteError {
|
|
// The Writer's own failure: `performWrite` posts it to the banner and rethrows, which
|
|
// is the whole of how an inverse that could not land explains itself.
|
|
throw error
|
|
} catch {
|
|
foreign = error
|
|
}
|
|
}
|
|
if let foreign {
|
|
historyLogger.error("an inverse threw something that is not a write error: \(String(describing: foreign), privacy: .public)")
|
|
return .failed
|
|
}
|
|
// `nil` is either the Writer's `BoardWriteError` (bannered on the way past) or the read-only
|
|
// lock's refusal (the lock row is already standing). Both are failures, never staleness.
|
|
return landed == nil ? .failed : .applied
|
|
}
|
|
|
|
private static let historyLogger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "history")
|
|
|
|
// MARK: Resolving an anchor at apply time
|
|
|
|
/// **Where a step's anchor points on this board right now** — the one resolver, read by the
|
|
/// staleness predicate and by every card-anchored inverse below (13 ▸ Rules, ruled 2026-07-31:
|
|
/// "apply-time validation resolves the card's *current* folder exactly the way the window itself
|
|
/// always resolves its card").
|
|
///
|
|
/// One method rather than two so validation and the write it guards can never disagree about
|
|
/// where a step is aimed: `cross` checks with this and the inverse writes with it, in that order,
|
|
/// against the same snapshot and the same `rootURL` — which a mid-session folder rename may have
|
|
/// moved (`HistoryAnchor.folder(under:in:)`).
|
|
func folder(for anchor: HistoryAnchor) -> URL? {
|
|
anchor.folder(under: rootURL, in: snapshot)
|
|
}
|
|
|
|
/// The same, as a write's precondition rather than a question.
|
|
///
|
|
/// Unreachable in the ordinary crossing — `cross` has already validated every anchor, and an
|
|
/// unresolvable one skipped the whole step before any of this ran — so the throw exists to keep
|
|
/// the impossible case honest rather than to be caught: an inverse that could not find its card
|
|
/// must not silently write nothing and report success. It reads as an ordinary write failure,
|
|
/// because that is what it would be.
|
|
func requiredFolder(for anchor: HistoryAnchor, _ operation: WriteOperation) throws(BoardWriteError) -> URL {
|
|
guard let folder = folder(for: anchor) else {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: rootURL.path,
|
|
reason: .staleTarget(message: "the card this step was registered against is no longer on the board")
|
|
)
|
|
}
|
|
return folder
|
|
}
|
|
|
|
// MARK: Creates
|
|
|
|
/// One item a gesture brought into being: where it landed, the bytes it landed with, and any
|
|
/// files the same gesture imported into it.
|
|
///
|
|
/// The bytes are captured at create time because the *inverse of a create is a removal* — "create
|
|
/// → remove the created folder" (13 ▸ Rules) — and a removal leaves nothing for a redo to read.
|
|
/// Holding them is what lets ⇧⌘Z put the item back at its own path under its own UUID, which
|
|
/// every step registered above this one on the stack depends on.
|
|
struct CreatedItem {
|
|
let folder: URL
|
|
let indexText: String
|
|
/// The Finder files this create imported (`createCards(fromFiles:)`), replayed on redo from
|
|
/// the same source URLs the gesture used. Empty for every other create.
|
|
var attachments: [URL] = []
|
|
}
|
|
|
|
/// Registers a create's step: undo removes the folders, redo puts them back byte-for-byte.
|
|
///
|
|
/// **Removal, not a move into the trash**, exactly as 13 words it: an undone create leaves *no
|
|
/// trace*, because the item was born of the gesture being undone — filing it in the trash would
|
|
/// leave a card the user never really made for them to find. `purgeIsUnrecoverable` is untouched
|
|
/// by this: that flag is about the trash's own permanent delete, whose loss is the user's own
|
|
/// final gesture, while this loss is one ⇧⌘Z away.
|
|
///
|
|
/// The honest edge, recorded rather than papered over: anything that happened *inside* the
|
|
/// created folder through an operation that registers no step of its own — an attachment added by
|
|
/// hand, a foreign edit — goes with the folder and does not come back on redo. That follows from
|
|
/// 13's own two rulings (remove the folder; attachment operations register nothing in v1) rather
|
|
/// than from anything decided here.
|
|
///
|
|
/// **Its staleness predicate is existence, and deliberately nothing else** (13: "existence …
|
|
/// for create/delete/restore steps"). A create's after-value *is* the item's being there, so the
|
|
/// undo validates that the folders are still at their paths and the redo that they are still
|
|
/// gone. The same honest edge follows: a foreign *edit* inside a created card does not stop ⌘Z
|
|
/// from removing it, because the create never wrote that field — while a foreign *delete* does,
|
|
/// since the card the user is looking at in the trash is at a different path now.
|
|
func registerCreation(_ items: [CreatedItem], kind: HistoryPhrase.Kind, subject: String? = nil) {
|
|
guard !items.isEmpty else { return }
|
|
let operation: WriteOperation = kind == .lane ? .createLane : .createCard
|
|
registerStep(
|
|
HistoryPhrase.name(.add, kind: kind, count: items.count),
|
|
subject: subject,
|
|
undoExpects: items.map { .present($0.folder) },
|
|
redoExpects: items.map { .absent($0.folder) }
|
|
) { _ in
|
|
// Reversed, so a lane and a card created by one gesture unwind child-first — the same
|
|
// order they were made in, read backwards.
|
|
for item in items.reversed() {
|
|
try BoardWriter.purgeItem(at: item.folder)
|
|
}
|
|
} redo: { _ in
|
|
for item in items {
|
|
try BoardWriter.recreateItem(at: item.folder, indexText: item.indexText, operation: operation)
|
|
guard !item.attachments.isEmpty else { continue }
|
|
_ = try BoardWriter.importAttachments(item.attachments, intoCard: item.folder)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A just-created item, ready to register — or `nil` when its bytes could not be read back, in
|
|
/// which case the gesture simply registers nothing rather than arming an undo whose redo could
|
|
/// not restore anything.
|
|
func createdItem(at folder: URL, kind: HistoryPhrase.Kind, attachments: [URL] = []) -> CreatedItem? {
|
|
let operation: WriteOperation = kind == .lane ? .createLane : .createCard
|
|
guard let text = try? BoardWriter.readIndexText(ofItem: folder, operation: operation) else { return nil }
|
|
return CreatedItem(folder: folder, indexText: text, attachments: attachments)
|
|
}
|
|
|
|
// MARK: Restoring a field to what it said before
|
|
|
|
/// Puts a lenient string field (`title`, `background`, `icon`) back to the value it held before
|
|
/// the write — or removes the key, which is what "before" means for a field that was not there.
|
|
///
|
|
/// A **malformed** prior reads as a removal, and that is the one place an inverse is not
|
|
/// byte-exact: the app cannot re-emit `background: [a, b]` through a document edit that only
|
|
/// knows how to set scalars. It is also the case the forward write was designed to clear
|
|
/// ("choosing any well replaces it" — 03-board-ui.md § Styling ▸ Controls), so the undo lands the
|
|
/// item on the app's own reading of that field rather than resurrecting a value nothing could
|
|
/// read.
|
|
static func restore(_ prior: FieldValue<String>, to key: String, in document: inout FrontmatterDocument) {
|
|
if let value = prior.value {
|
|
document.set(key, to: .string(value))
|
|
} else {
|
|
document.remove(key)
|
|
}
|
|
}
|
|
|
|
/// The style fields a gesture actually set — **one entry per dimension it did not `.keep`**, so a
|
|
/// step that only chose a colour never validates the symbol beside it.
|
|
///
|
|
/// `.remove` is spelled as `nil`, which is the after-value the None well leaves: the key is gone,
|
|
/// and the item renders the level's default (03-board-ui.md § Styling ▸ Controls).
|
|
static func styledFields(background: StyleChange, icon: StyleChange) -> [ExpectedField] {
|
|
field(background, as: ExpectedField.background) + field(icon, as: ExpectedField.icon)
|
|
}
|
|
|
|
/// The same dimensions, holding the values the *inverse* restores — what the redo half validates
|
|
/// against once ⌘Z has run.
|
|
///
|
|
/// The prior is read as `FieldValue.value`, which is `nil` for a missing key **and** for a
|
|
/// malformed one, exactly matching `restore(_:to:in:)`: a malformed prior reads as a removal, so
|
|
/// the state the undo leaves is an absent key either way.
|
|
static func restoredStyleFields(
|
|
background: StyleChange,
|
|
priorBackground: FieldValue<String>,
|
|
icon: StyleChange,
|
|
priorIcon: FieldValue<String>
|
|
) -> [ExpectedField] {
|
|
var fields: [ExpectedField] = []
|
|
if background != .keep {
|
|
fields.append(.background(priorBackground.value))
|
|
}
|
|
if icon != .keep {
|
|
fields.append(.icon(priorIcon.value))
|
|
}
|
|
return fields
|
|
}
|
|
|
|
private static func field(
|
|
_ change: StyleChange,
|
|
as make: (String?) -> ExpectedField
|
|
) -> [ExpectedField] {
|
|
switch change {
|
|
case .keep: []
|
|
case let .set(value): [make(value)]
|
|
case .remove: [make(nil)]
|
|
}
|
|
}
|
|
|
|
/// Puts a lane's `width` back — the integer it held, or no key at all.
|
|
///
|
|
/// Note the asymmetry with the forward write, which removes the key at one unit (the
|
|
/// remove-at-default family): an inverse restores the *value*, so a hand-written `width: 1` comes
|
|
/// back as `width: 1` rather than as an absence that renders the same. Restoring what was there
|
|
/// outranks re-deriving what the app would have written.
|
|
static func restoreWidth(_ prior: FieldValue<Int>, in document: inout FrontmatterDocument) {
|
|
if let value = prior.value {
|
|
document.set(FrontmatterKeys.width, to: .int(value))
|
|
} else {
|
|
document.remove(FrontmatterKeys.width)
|
|
}
|
|
}
|
|
|
|
}
|