Implement staleness validation and skip-with-banner

Every crossing validates its expectations before writing: each step
carries per-item HistoryExpectations — folder, effective ancestor-walked
liveness, and exactly the fields the gesture set — and a mismatch pops
the step, posts the signpost ('Undo skipped — Fix login changed outside
Lanework'), and falls through to the next. Validation reads disk, not
the in-memory snapshot: the snapshot is by construction one reload
behind every app write, so a rapid second undo would false-skip against
the pre-state — disk is what current can honestly mean at press time.
Stale and failed part ways: a stale step is one the board moved past,
so dropping it loses nothing; a failed one is refused by a usually
momentary condition, so it stays put and the crossing stops with only
performWrite's own error row — which forced the provider off
NSUndoManager onto two plain arrays, since a popped group cannot be put
back. The read-only lock disables Undo/Redo through the adapter while
the stack survives to resume on clear. Delete and restore validate
presence alone — a machine timestamp is not a decision — and a
malformed field matches nothing, since it is a shape the app never
writes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 14:54:42 -04:00
parent 2148ebb379
commit 50669489cb
10 changed files with 1333 additions and 149 deletions
+115 -17
View File
@@ -23,9 +23,19 @@ import os
/// 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 next milestone's
/// field-level staleness predicate compares against each step already carries both sides of its
/// write (13 Rules staleness validation).
/// 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's folder, 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.
@MainActor
extension BoardStore {
@@ -39,11 +49,20 @@ extension BoardStore {
/// weak one this method installs: a stack outliving its session must not be the reason a board
/// stays in memory.
///
/// **A step that could not write answers `.skipped`.** `HistoryStepOutcome` has two cases and the
/// honest reading of a failed inverse is the second one nothing landed, so nothing goes on the
/// opposite stack and `performWrite` has already posted the banner that says why. The read-only
/// lock, the other way a write is refused here, cannot reach this path in practice: a lock
/// disables Undo and Redo with the rest of the mutating commands (13 Rules locks).
/// `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`,
@@ -54,25 +73,48 @@ extension BoardStore {
/// than swallows the case that cannot happen.
func registerStep(
_ name: String,
subject: String? = nil,
undoExpects: [HistoryExpectation],
redoExpects: [HistoryExpectation],
undo: @escaping @MainActor (BoardStore) throws -> Void,
redo: @escaping @MainActor (BoardStore) throws -> Void
) {
guard let history else { return }
let named = subject ?? name
history.register(HistoryStep(
name: name,
undo: { [weak self] in BoardStore.cross(self, undo) },
redo: { [weak self] in BoardStore.cross(self, redo) }
undo: { [weak self] direction in
BoardStore.cross(self, direction, named, undoExpects, undo)
},
redo: { [weak self] direction in
BoardStore.cross(self, direction, named, redoExpects, redo)
}
))
}
/// Runs one side of a step as an ordinary bracketed write.
/// 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. Nothing to write to, so nothing ran.
guard let store else { return .skipped }
// 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, under: store.rootURL) else {
store.banners.postSkippedStep(direction, subject: subject)
return .skipped
}
var foreign: (any Error)?
let landed: Void? = try? store.performWrite { () throws(BoardWriteError) -> Void in
do {
@@ -87,9 +129,11 @@ extension BoardStore {
}
if let foreign {
historyLogger.error("an inverse threw something that is not a write error: \(String(describing: foreign), privacy: .public)")
return .skipped
return .failed
}
return landed == nil ? .skipped : .applied
// `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")
@@ -124,10 +168,22 @@ extension BoardStore {
/// 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.
func registerCreation(_ items: [CreatedItem], kind: HistoryPhrase.Kind) {
///
/// **Its staleness predicate is existence and liveness, and deliberately nothing else** (13:
/// "existence/liveness 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 there and still live 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 trash row the user is looking at is not this step's to purge.
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)) { _ in
registerStep(
HistoryPhrase.name(.add, kind: kind, count: items.count),
subject: subject,
undoExpects: items.map { .live($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() {
@@ -170,6 +226,48 @@ extension BoardStore {
}
}
/// 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
+32 -3
View File
@@ -32,25 +32,54 @@ import AppKit
/// clears a window's undo manager on paths this app does not control, and a card window closing must
/// not empty the board's stack (13-native-undo.md Rules the stack belongs to the *session*, and
/// its one clearing point is that session's teardown).
///
/// ### The read-only lock disables Undo and Redo here
///
/// "Every read-only lock (vanished root, failed reload after wholesale ops, unwritable location)
/// disables Undo/Redo with the other mutating commands; **the stack itself survives the lock and
/// resumes when it clears**" (13 Rules). This is the right place for it and the only one: every
/// surface that offers Z the Edit menu's nil-target row, the toolbar pair, a card window's
/// responder chain validates through this object, so answering `false` here disables all of them
/// at once, exactly as the lock's other victims disable through menu validation (02-architecture.md
/// § "The lock's scope"). Putting it in the *provider* would have been the same answer in the wrong
/// place: the stack is not the thing that is locked, the board is, and a Pro session binding the git
/// provider must inherit the rule without reimplementing it.
public final class BoardUndoManager: UndoManager {
/// The substrate this manager is a face for. Strong: the session owns both, and the manager is
/// only ever reachable while the session that made it is alive.
private let history: any HistoryProviding
public init(history: any HistoryProviding) {
/// Whether the board is refusing writes `BoardStore.isReadOnly`, read through a closure rather
/// than by holding the store. The adapter is deliberately store-free (it is a face for a *seam*,
/// and Pro binds a different substrate behind the same one), and a closure is what lets the
/// composition root wire the board's own truth in without this file learning what a `BoardStore`
/// is. The default answers "writable", which is what a manager built without a board a test of
/// the adapter's own grammar should have.
private let isReadOnly: @MainActor () -> Bool
public init(history: any HistoryProviding, isReadOnly: @escaping @MainActor () -> Bool = { false }) {
self.history = history
self.isReadOnly = isReadOnly
super.init()
}
// MARK: Enablement
public override var canUndo: Bool { history.canUndo }
/// **False under the lock, whatever the stack holds.** The steps are still there this is an
/// enablement answer, not a clearing so the first Z after the lock clears crosses the step it
/// would have crossed before it landed.
public override var canUndo: Bool { !isReadOnly() && history.canUndo }
public override var canRedo: Bool { history.canRedo }
public override var canRedo: Bool { !isReadOnly() && history.canRedo }
// MARK: Crossing
/// Not gated on the lock, deliberately: `undo:` reaches a manager only through a menu item or
/// toolbar button that has already validated against `canUndo`, and a crossing that somehow
/// started anyway is refused one layer down by `performWrite` which leaves the step on the
/// stack (`HistoryStepOutcome.failed`), the same place this enablement rule keeps it. A second
/// guard here would be a second answer to one question.
public override func undo() { history.undo() }
public override func redo() { history.redo() }
+67 -15
View File
@@ -1,15 +1,43 @@
import Foundation
// MARK: - HistoryDirection
/// Which command the user pressed Z or Z.
///
/// **Not which half of a step is running.** A step that has already been undone goes onto the redo
/// stack *reversed* (`HistoryStep.reversed`), so the closure Z crosses is the one registered as
/// `redo` and the closure a second Z crosses is the one registered as `undo` the halves swap, and
/// a step cannot tell which it is by looking at itself. What it has to be told is the **command**,
/// because the one sentence a step ever says out loud names it: "Undo skipped 'Fix login' changed
/// outside Lanework" (13-native-undo.md Rules), with "Redo skipped" as its mirror.
public enum HistoryDirection: Sendable, Equatable {
case undo
case redo
/// The stack a step lands on once it has been crossed this way.
public var opposite: HistoryDirection {
self == .undo ? .redo : .undo
}
}
// MARK: - HistoryStepOutcome
/// What happened when a step was asked to walk its write back (or forward again).
///
/// The second case is 13-native-undo.md Rules **staleness validation**, in the vocabulary the
/// provider needs it in: "target folder gone, or the field no longer holding the step's after-value
/// the step is **skipped, not applied**: popped from the stack ... and Z falls through to the
/// next step". The provider reads this answer and nothing else the *predicate* (field-level,
/// settled) and the info-tone banner that explains a skip both belong to the step, which is the only
/// side that knows what it wrote and which board to say it on.
/// The three cases are three different fates for the *stack*, which is the only thing the provider
/// asks about:
///
/// - `.applied` the write landed; the step's mirror image joins the opposite stack.
/// - `.skipped` **stale** (13-native-undo.md Rules staleness validation): "target folder gone,
/// or the field no longer holding the step's after-value the step is **skipped, not applied**:
/// popped from the stack ... and Z falls through to the next step". The step is dropped and the
/// crossing continues.
/// - `.failed` the inverse was attempted and could not be written. The step **stays**, and the
/// crossing stops (see the case's own note).
///
/// The provider reads this answer and nothing else the *predicate* (field-level, settled) and the
/// info-tone banner that explains a skip both belong to the step, which is the only side that knows
/// what it wrote and which board to say it on.
public enum HistoryStepOutcome: Equatable, Sendable {
/// The step ran. Its mirror image joins the opposite stack.
@@ -19,6 +47,25 @@ public enum HistoryStepOutcome: Equatable, Sendable {
/// its inverse would clobber somebody else's newer edit. Nothing ran, the step is dropped, and
/// the crossing continues with the next one down.
case skipped
/// The step tried and could not: the Writer refused the inverse (a disk error, a permission
/// problem, a board that has gone read-only under the stack). Nothing landed.
///
/// **The step stays put and the crossing stops** the one thing that distinguishes this from
/// `.skipped`, and the reason the case exists. 13 is silent here, so the posture is the honest
/// reading of its two rules: a *stale* step is one the board has moved past, so dropping it
/// loses nothing; a *failed* one is a step the user still means to cross, refused by a condition
/// that is usually momentary (a full disk, an unplugged volume), so popping it would spend their
/// only route back on a transient error. The failure has already banners itself as an ordinary
/// write failure (`BoardStore.performWrite` posts before it rethrows), which is exactly the
/// vocabulary 02-architecture.md § Write-failure surfacing gives it and which is why a failure
/// must *not* also raise the info-tone skip row: two rows for one event would say the step is
/// both gone and retryable.
///
/// Stopping rather than falling through follows from the same reading: fall-through exists to
/// walk past steps the board no longer has a use for, and a disk that just refused one write is
/// not a reason to attempt N more.
case failed
}
// MARK: - HistoryStep
@@ -47,11 +94,15 @@ public enum HistoryStepOutcome: Equatable, Sendable {
/// never part of it**: the platform composes and localizes that (`BoardUndoManager`), and a step
/// that spelled it would read "Undo Undo Move Card" in the Edit menu.
///
/// ### Both closures are `@MainActor`
/// ### Both closures are `@MainActor`, and both are handed the direction
///
/// Everything they touch the store, the snapshot, the banners is, and a step exists to be run
/// from a menu command. Marking them says so at the seam instead of leaving each provider to
/// rediscover it.
/// Everything they touch the store, the snapshot, the banners is `@MainActor`, and a step exists
/// to be run from a menu command. Marking them says so at the seam instead of leaving each provider
/// to rediscover it.
///
/// The `HistoryDirection` argument is **which command the user pressed**, not which half is running:
/// `reversed` swaps the two closures, so the half a Z crosses is the `redo` one as often as not,
/// and the skip sentence has to name the keystroke rather than the half (see `HistoryDirection`).
public struct HistoryStep {
/// The menu phrase, unprefixed see the type's note.
@@ -59,16 +110,16 @@ public struct HistoryStep {
/// Walks the board back across this step. Registered at the Writer boundary as the *inverse* of
/// the write that just landed.
public let undo: @MainActor () -> HistoryStepOutcome
public let undo: @MainActor (HistoryDirection) -> HistoryStepOutcome
/// Walks it forward again the original write, replayed. Reached only after `undo` applied,
/// because that is the only way a step reaches the redo stack.
public let redo: @MainActor () -> HistoryStepOutcome
public let redo: @MainActor (HistoryDirection) -> HistoryStepOutcome
public init(
name: String,
undo: @escaping @MainActor () -> HistoryStepOutcome,
redo: @escaping @MainActor () -> HistoryStepOutcome
undo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome,
redo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome
) {
self.name = name
self.undo = undo
@@ -131,7 +182,8 @@ public protocol HistoryProviding: AnyObject {
var redoActionName: String? { get }
/// Crosses one step backwards. A stale step is skipped rather than applied, and the crossing
/// falls through to the next one (13 Rules staleness validation); an empty stack does
/// falls through to the next one (13 Rules staleness validation); a step whose write *failed*
/// stays where it is and stops the crossing (`HistoryStepOutcome.failed`); an empty stack does
/// nothing.
func undo()
+242
View File
@@ -0,0 +1,242 @@
import Foundation
// MARK: - ExpectedField
/// One field a step's write left holding a known value the unit 13-native-undo.md Rules'
/// **field-level** predicate compares.
///
/// "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" (settled, ruled 2026-07-27). These cases are therefore exactly
/// the fields the app's own inverses write and no others: a step declares what *it* set, never what
/// it merely read, so a foreign edit to a field this gesture never touched cannot skip anything.
///
/// **`nil` means the key is absent**, which is a real after-value throughout this app rather than a
/// missing one: an emptied rename removes `title`, a width of one unit removes `width`, and the None
/// well removes `background` (the remove-at-default family). A **malformed** value on disk matches
/// neither an absence nor a value it is not something this app writes, so finding one is finding
/// somebody else's edit.
public enum ExpectedField: Sendable, Equatable {
/// `title` renames at every level.
case title(String?)
/// `order` every reorder, every move's landing rank, and the rank half of a drag-restore.
case order(Double)
/// `width` the lane resize. `nil` is the one-unit default, whose key the write removes.
case width(Int?)
/// `background` the styling gesture's colour dimension.
case background(String?)
/// `icon` the styling gesture's symbol dimension.
case icon(String?)
/// The body span, **byte for byte** the Edit session's step, and the one inverse in the app
/// whose fidelity is not field-level (13: "body steps compare bytes").
case body(String)
}
// MARK: - HistoryExpectation
/// What one folder must currently hold for a step to be safe to cross the state that step's write
/// left it in.
///
/// Two halves, both of them 13's: **existence and liveness** ("target folder gone ... the step is
/// skipped"; "existence/liveness for create/delete/restore steps"), and the **field-level**
/// comparison above. A step carries one of these per item it touched, so a multi-card move validates
/// three targets and a single rename validates one which is the whole of "a foreign change to an
/// unrelated item must not skip anything": an item no step named is an item no expectation mentions.
///
/// **The folder's *path* is the parent check.** A move's step expects the card at its destination
/// path; a card that a foreign writer moved elsewhere leaves nothing at that path, so the ordinary
/// existence half already answers "moved away" without a parent field of its own.
public struct HistoryExpectation: Sendable, Equatable {
/// Where the item this step wrote to should be the destination for a move, the item's own
/// folder for everything else, and the board root for the board's own rename and styling.
public let folder: URL
/// Whether the item should be there, and if so on which side of the tombstone.
public let presence: Presence
/// The fields the step's write set, with the values it set them to. Empty for a step whose
/// whole subject *is* existence a create, a delete, a Put Back.
public let fields: [ExpectedField]
/// Where an item stands, as the trash's own three-way reading of it.
public enum Presence: Sendable, Equatable {
/// There, and rendered: no `deleted:` on the item **or on any ancestor**. Liveness is
/// effective, the same ancestor walk `BoardStore.liveItem` and the card windows' fate rule
/// apply a card under a tombstoned lane renders nowhere, so it is as gone as a deleted one.
case live
/// There, and tombstoned a trash row, or a card hidden under a tombstoned lane.
case tombstoned
/// Not there at all: the folder is gone. What an undone create leaves, and what a redone one
/// expects to find before putting it back.
case absent
}
public init(folder: URL, presence: Presence, fields: [ExpectedField]) {
self.folder = folder
self.presence = presence
self.fields = fields
}
/// The item is live and its fields say what the step set them to.
public static func live(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .live, fields: fields)
}
/// The same, for a caller whose field list is computed the styling gesture's, which varies per
/// dimension. A label rather than a second variadic, so `.live(folder)` stays unambiguous.
public static func live(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .live, fields: fields)
}
/// The item is tombstoned and its fields say what the step set them to.
public static func tombstoned(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .tombstoned, fields: fields)
}
/// Nothing is at this path.
public static func absent(_ folder: URL) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: .absent, fields: [])
}
/// The variant for a step whose target's liveness is not known until the gesture runs the Edit
/// session's, which is registered against a card that may have been tombstoned out from under
/// the buffer (05-card-window.md Deletion & lifecycle).
public static func item(
_ folder: URL,
tombstoned: Bool,
_ fields: ExpectedField...
) -> HistoryExpectation {
HistoryExpectation(folder: folder, presence: tombstoned ? .tombstoned : .live, fields: fields)
}
}
// MARK: - HistoryStaleness
/// The staleness predicate: does the board still look the way this step's write left it?
/// (13-native-undo.md Rules staleness validation.)
///
/// ### It reads disk, not the snapshot
///
/// 13 says "re-checks its target against the current snapshot at Z time", and *current* is the
/// load-bearing word: the store's `snapshot` is by construction one reload behind every write the
/// app makes (the one-way flow means a write is only visible once the watcher round-trips it), so
/// validating against it would make a second Z pressed inside the debounce window compare against a
/// board that still shows the first one's *pre*-state every rapid undo run would false-skip. Disk
/// is what "current" can honestly mean at the instant a step is crossed, and it is also what an
/// inverse is about to write to.
///
/// ### Lazily, never eagerly
///
/// Nothing here is called by the watcher, the ledger, or any background sweep: "invalidation is lazy
/// (settled ruled 2026-07-27): staleness is discovered at Z time, never by background pruning ...
/// The stack always looks full". This type has exactly one caller, `BoardStore.cross`, one line
/// before the inverse would have been written.
public enum HistoryStaleness {
/// Whether every target a step named still holds what that step left there.
///
/// `root` is the board's current root, which the liveness walk stops at a lane's parent.
public static func isCurrent(_ expectations: [HistoryExpectation], under root: URL) -> Bool {
expectations.allSatisfy { isCurrent($0, under: root) }
}
/// One target's answer.
///
/// A file that cannot be read or parsed fails every expectation but `.absent`: an `index.md`
/// somebody has just broken is not one holding this step's after-value, and the honest reading of
/// "the field no longer holds it" covers a field that can no longer be read at all.
public static func isCurrent(_ expectation: HistoryExpectation, under root: URL) -> Bool {
guard expectation.presence != .absent else {
return !FileManager.default.fileExists(atPath: expectation.folder.path)
}
guard let document = index(at: expectation.folder) else { return false }
let tombstoned = isEffectivelyTombstoned(expectation.folder, document: document, under: root)
guard tombstoned == (expectation.presence == .tombstoned) else { return false }
return expectation.fields.allSatisfy { matches($0, in: document) }
}
// MARK: Fields
static func matches(_ field: ExpectedField, in document: FrontmatterDocument) -> Bool {
switch field {
case let .title(expected): equal(document.title, expected)
case let .order(expected): document.order.value == expected
case let .width(expected): equal(document.width, expected)
case let .background(expected): equal(document.background, expected)
case let .icon(expected): equal(document.icon, expected)
case let .body(expected): document.body == expected
}
}
/// A present value matches a present expectation by value; an absence matches an absence.
///
/// **A malformed field matches nothing**, deliberately: `FieldValue.malformed` is a shape this
/// app never writes (`background: [a, b]` is somebody's hand edit), so a step that expected its
/// own removed key must not read one as "gone" and clobber it.
private static func equal<Value: Sendable & Equatable>(_ actual: FieldValue<Value>, _ expected: Value?) -> Bool {
switch (actual, expected) {
case (.missing, nil): true
case let (.valid(value), .some(expected)): value == expected
default: false
}
}
// MARK: Liveness
/// Whether the item at `folder` renders **presence of `deleted:`, not its validity**
/// (`Lane.isDeleted`'s rule), walked up through the ancestors the way every other liveness
/// question in this app is.
///
/// The board root is never tombstoned however its own frontmatter reads: a board-level `deleted:`
/// is a tolerated load *warning* (01-storage-format.md § Deletion), not a state that hides the
/// board from itself.
private static func isEffectivelyTombstoned(
_ folder: URL,
document: FrontmatterDocument,
under root: URL
) -> Bool {
guard !isRoot(folder, root) else { return false }
guard document.deleted.isMissing else { return true }
// Lane and card are the only levels below the root, so this walks at most twice; the bound
// is there so a folder that is not under this root at all (a step registered before a
// mid-session root change) ends rather than climbing to `/`.
var parent = folder.deletingLastPathComponent()
for _ in 0 ..< 4 {
guard !isRoot(parent, root) else { return false }
guard let ancestor = index(at: parent) else { return false }
if !ancestor.deleted.isMissing { return true }
parent = parent.deletingLastPathComponent()
}
return false
}
private static func isRoot(_ folder: URL, _ root: URL) -> Bool {
folder.standardizedFileURL.path == root.standardizedFileURL.path
}
// MARK: Reading
/// The item's `index.md` as the app reads it, or `nil` when there is no readable, parseable one
/// there a folder that is gone, an indexless folder, bytes that are not UTF-8, frontmatter
/// somebody has just broken.
private static func index(at folder: URL) -> FrontmatterDocument? {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
guard let data = try? Data(contentsOf: indexURL),
let text = String(validating: data, as: UTF8.self),
let document = try? FrontmatterDocument.parse(text)
else { return nil }
return document
}
}
+81 -90
View File
@@ -2,126 +2,117 @@ import Foundation
// MARK: - NativeHistoryProvider
/// The base edition's undo substrate: one `NSUndoManager`-backed stack per board session
/// (13-native-undo.md).
/// The base edition's undo substrate: one stack per board session (13-native-undo.md).
///
/// ### Why `NSUndoManager` at all, when the steps are ours
/// ### Two arrays, and why not `NSUndoManager`
///
/// Because the *command surface* is the platform's. Edit Undo and Edit Redo are the system's own
/// nil-target `undo:`/`redo:` rows (11-command-nexus.md), the toolbar pair carries the same actions
/// This provider was an `NSUndoManager` for exactly one milestone, on the argument that the *command
/// surface* is the platform's Edit Undo and Edit Redo are the system's own nil-target
/// `undo:`/`redo:` rows (11-command-nexus.md), the toolbar pair carries the same actions
/// (`BoardToolbar`), and both light up, disable and **retitle** from whatever `UndoManager` the
/// focused window hands back "Undo Move 3 Cards" is `NSUndoManager`'s dynamic retitling, which 13
/// names as the mechanism and 12 records as the one both editions share. Reimplementing the stack
/// over two arrays would mean reimplementing that, badly.
/// focused window hands back. All of that is still true, and none of it lives here: the retitling is
/// `BoardUndoManager.undoMenuItemTitle` composing `undoMenuTitle(forUndoActionName:)` over the bare
/// phrase this seam vends as a `String?`. The adapter is the `UndoManager`; the substrate never
/// needed to be one.
///
/// It stays *behind* `HistoryProviding` regardless: nothing outside this file learns that base's
/// stack is an `NSUndoManager`, which is what lets pro-m1 bind git to the same seam
/// (12 The provider seam).
/// What forced the change is the staleness milestone's third outcome. `HistoryStepOutcome.failed`
/// means **the step stays put** a disk error is retryable, so Z must still be able to reach the
/// step it just could not write. `NSUndoManager` pops a group before running it and offers no way to
/// put it back: a registration made while undoing lands on the *redo* stack by its own documented
/// rule, and one made after the crossing returns clears the redo stack outright. Either way a failed
/// undo would have quietly destroyed something. Two arrays express all three outcomes exactly, and
/// the grammar they have to implement is four lines long.
///
/// ### One `register` call is exactly one step
///
/// `groupsByEvent` is turned **off** and every registration is wrapped in its own group. The default
/// (on) closes a group at the end of the run-loop turn, which would silently fold two gestures that
/// happened to land in one event into a single Z the opposite of 13's "one gesture, one undo
/// step", and the coalescing decision belongs to the Writer call site (a multi-card move registers
/// *one* step with a plural title), never to the run loop's timing.
/// Nothing here groups, coalesces, or waits for the end of a run-loop turn 13's "one gesture, one
/// undo step" is a property of the Writer call sites (a multi-card move registers *one* step with a
/// plural title), and the substrate's job is to not have opinions about it. This is what
/// `NSUndoManager`'s `groupsByEvent = false` was buying, as an absence rather than a setting.
///
/// ### Undo flips to redo by re-registering
/// ### Undo flips to redo by reversing
///
/// A step that applies pushes its own mirror image back onto the manager from inside the undo and
/// `NSUndoManager` routes a registration made while it is undoing onto the **redo** stack, and vice
/// versa. That single rule gives the whole classic dance (undo redo undo ) with no second stack
/// of our own, and it is why a *skipped* step leaves nothing behind: it registers nothing, so the
/// empty group is discarded and the step is simply gone (13: "popped from the stack ... and Z falls
/// through to the next step").
/// A step that applies is pushed onto the opposite stack **reversed** its two halves swapped
/// (`HistoryStep.reversed`) which gives the whole classic dance (undo redo undo ) with one
/// rule. Both stacks therefore hold steps oriented so that *crossing them means calling `undo`*, and
/// a skipped step leaves nothing behind at all: it is popped and never re-pushed, which is 13's
/// "popped from the stack ... and Z falls through to the next step".
@MainActor
public final class NativeHistoryProvider: HistoryProviding {
/// The stack. `private` and never vended: see the type's note.
private let manager = UndoManager()
/// The two stacks, top last. Both hold steps oriented for crossing see the type's note.
private var undoSteps: [HistoryStep] = []
private var redoSteps: [HistoryStep] = []
/// Set by the step just crossed when it declined to apply, read by the crossing loop below.
/// A flag rather than a return value because the manager, not this object, calls the step.
private var lastCrossingSkipped = false
public init() {
// See "One `register` call is exactly one step", above.
manager.groupsByEvent = false
}
public init() {}
// MARK: - HistoryProviding
public var canUndo: Bool { manager.canUndo }
public var canUndo: Bool { !undoSteps.isEmpty }
public var canRedo: Bool { manager.canRedo }
public var canRedo: Bool { !redoSteps.isEmpty }
/// `NSUndoManager` answers `""` not `nil` for a stack with nothing on it *and* for a step
/// registered without a name, so the emptiness check is the honest one.
public var undoActionName: String? {
guard canUndo, !manager.undoActionName.isEmpty else { return nil }
return manager.undoActionName
}
/// The phrase the menu title is composed from, or `nil` when there is nothing to cross and
/// also `nil` for a step registered without a name, which is the emptiness the adapter's `""`
/// contract is written against.
public var undoActionName: String? { name(of: undoSteps.last) }
public var redoActionName: String? {
guard canRedo, !manager.redoActionName.isEmpty else { return nil }
return manager.redoActionName
}
public var redoActionName: String? { name(of: redoSteps.last) }
/// Records one undoable step and clears the redo stack the classic rule, and the one every
/// substrate shares.
public func register(_ step: HistoryStep) {
push(step)
undoSteps.append(step)
redoSteps.removeAll()
}
public func undo() {
cross(manager.undo, while: { self.manager.canUndo })
}
public func undo() { cross(.undo) }
public func redo() {
cross(manager.redo, while: { self.manager.canRedo })
}
public func redo() { cross(.redo) }
public func clear() {
manager.removeAllActions()
undoSteps.removeAll()
redoSteps.removeAll()
}
// MARK: - The stack
// MARK: - The crossing
/// Registers `step` as one group of its own, named for the menu.
///
/// The target is `self` and the payload rides in the closure, which is `NSUndoManager`'s
/// block-based form: the manager references its target **unowned** (its own documented rule), so
/// the provider owning the manager that references the provider is not a cycle.
private func push(_ step: HistoryStep) {
manager.beginUndoGrouping()
manager.registerUndo(withTarget: self) { provider in
provider.apply(step)
}
// Inside the group, deliberately: the name belongs to the group being closed, and on the
// way back it is what retitles Redo.
manager.setActionName(step.name)
manager.endUndoGrouping()
}
/// Runs one step's action and records what happened.
private func apply(_ step: HistoryStep) {
switch step.undo() {
case .applied:
// Lands on the opposite stack see "Undo flips to redo by re-registering".
push(step.reversed)
case .skipped:
lastCrossingSkipped = true
}
}
/// Crosses one step, and keeps going while the steps it crosses decline to apply 13's
/// Crosses one step, and keeps going while the steps it crosses decline as **stale** 13's
/// fall-through: "the step is skipped, not applied ... and Z falls through to the next step".
///
/// The `while` guard is the manager's own emptiness, so a stack of nothing but stale steps
/// empties itself and stops rather than spinning.
private func cross(_ step: () -> Void, while more: () -> Bool) {
repeat {
guard more() else { return }
lastCrossingSkipped = false
step()
} while lastCrossingSkipped
/// The loop's own exit is an empty stack, so a stack of nothing but stale steps empties itself
/// and stops rather than spinning. The other two outcomes each end the crossing after one step:
/// an applied step is the Z the user asked for, and a failed one leaves the stack exactly as it
/// found it (`HistoryStepOutcome.failed`).
private func cross(_ direction: HistoryDirection) {
while let step = pop(direction) {
switch step.undo(direction) {
case .applied:
push(step.reversed, onto: direction.opposite)
return
case .skipped:
continue
case .failed:
push(step, onto: direction)
return
}
}
}
private func pop(_ direction: HistoryDirection) -> HistoryStep? {
direction == .undo ? undoSteps.popLast() : redoSteps.popLast()
}
private func push(_ step: HistoryStep, onto direction: HistoryDirection) {
if direction == .undo {
undoSteps.append(step)
} else {
redoSteps.append(step)
}
}
private func name(of step: HistoryStep?) -> String? {
guard let name = step?.name, !name.isEmpty else { return nil }
return name
}
}