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:
@@ -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,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() }
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user