Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
195 lines
9.4 KiB
Swift
195 lines
9.4 KiB
Swift
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** ("target folder gone ... → the step is skipped"),
|
|
/// 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 container side rides in the folder path
|
|
///
|
|
/// **The folder's *path* is the parent check**, and since the trash was materialized that check is
|
|
/// also the container check (03-board-ui.md § Trash, resettled 2026-07-28). A delete step's undo
|
|
/// expects its card at `<root>/.trash/<id>`; a foreign restore moves the folder out, so nothing is
|
|
/// at that path and the existence half already answers "the card is not in the trash any more".
|
|
/// The mirror holds: the redo expects it back at `<root>/<lane>/<id>`, where a foreign re-delete
|
|
/// leaves nothing. That is why `Presence` is a two-case answer rather than the tombstone era's
|
|
/// three-way live/tombstoned/absent reading of a `deleted:` key — there is no key to read, and no
|
|
/// ancestor to walk to find one.
|
|
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.
|
|
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 lane delete.
|
|
public let fields: [ExpectedField]
|
|
|
|
/// Whether anything is at this path.
|
|
public enum Presence: Sendable, Equatable {
|
|
/// There, with a readable `index.md`. Which container that is, is the path's own answer.
|
|
case present
|
|
|
|
/// Not there at all: the folder is gone. What an undone create and an undone lane delete
|
|
/// leave, 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 at this path and its fields say what the step set them to.
|
|
public static func present(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
|
|
HistoryExpectation(folder: folder, presence: .present, 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 `.present(folder)` stays unambiguous.
|
|
public static func present(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation {
|
|
HistoryExpectation(folder: folder, presence: .present, fields: fields)
|
|
}
|
|
|
|
/// Nothing is at this path.
|
|
public static func absent(_ folder: URL) -> HistoryExpectation {
|
|
HistoryExpectation(folder: folder, presence: .absent, 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.
|
|
///
|
|
/// ### It needs no board root
|
|
///
|
|
/// The tombstone era's liveness half walked a folder's ancestors looking for a `deleted:` key, and
|
|
/// needed the root to know where to stop. Materializing the trash removed the walk: an item's
|
|
/// container is its path, and a path is checked by asking the filesystem whether anything is there.
|
|
public enum HistoryStaleness {
|
|
|
|
/// Whether every target a step named still holds what that step left there.
|
|
public static func isCurrent(_ expectations: [HistoryExpectation]) -> Bool {
|
|
expectations.allSatisfy(isCurrent)
|
|
}
|
|
|
|
/// One target's answer.
|
|
///
|
|
/// A file that cannot be read or parsed fails a `.present` expectation: 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) -> Bool {
|
|
guard expectation.presence != .absent else {
|
|
return !FileManager.default.fileExists(atPath: expectation.folder.path)
|
|
}
|
|
guard let document = index(at: expectation.folder) 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: 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
|
|
}
|
|
}
|