Register inverse operations at the Writer boundary

The store is the Writer boundary, so it computes and registers
inverses: a weak history sink bound at session composition, one
HistoryStep per gesture at exactly the brackets that were already one
performWrite each — multi-card moves, style batches, width pairs, and
multi-row restores each undo as one plurally-titled step, and the Edit
session registers once at the flip from the bytes disk held before its
first landed write, debounce ticks registering nothing. Crossings run
through performWrite, so an undo brackets the watcher, echoes through
the reload, and reaches every window; every closure captures values,
never snapshots. The inventory follows 13 exactly: moves return to
origin lane and order, renames restore or remove the title key,
restyles and resizes restore field values or absence, tombstones and
restores swap with captured timestamps, and an undone create is a real
removal — no trace — with redo re-materializing the same UUID from
bytes captured at gesture time. Purge, attachments, repair,
bookkeeping, checkbox flips, raw Apply, and the whole arrival family
register nothing, each exclusion documented where it lives. Step names
speak 06's verb vocabulary through the new HistoryPhrase.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 14:24:31 -04:00
parent 93fad2ef1e
commit 2148ebb379
8 changed files with 1732 additions and 64 deletions
+489 -62
View File
@@ -355,6 +355,27 @@ public final class BoardStore {
@ObservationIgnored
public var displayStateDelegate: (@MainActor () -> Void)?
/// **Where this board's inverses go** the undo/redo substrate every write below registers into
/// (13-native-undo.md Rules: "Registration at the Writer boundary each Writer call site
/// registers the inverse operation, computed from the pre-write snapshot the store already
/// holds").
///
/// The store is the Writer boundary: every app-mediated mutation in the app goes through one of
/// the methods below and out through `performWrite`, which is precisely the set of call sites 13
/// names. So the sink belongs here, injected like `watcherBrackets` and for the same reason the
/// stack is **the session's**, "one stack per board, owned by the board session", and a store that
/// made its own would be a second answer to which stack a board has.
/// `AppModel.beginSession` wires it the moment the session's provider exists.
///
/// **Weak, deliberately.** The session owns both the store and the provider, and the provider's
/// steps hold closures over *this* store: a strong reference here would close that loop, leaving a
/// board that could only be freed by remembering to empty its undo stack first. `nil` no session
/// yet, a storeless test, a board whose stack has been cleared away keeps every method below
/// behaving exactly as it did before this milestone, registering nothing, which is `watcherBrackets`'
/// `nil` rule restated for a second seam.
@ObservationIgnored
public weak var history: (any HistoryProviding)?
// MARK: Reload machinery
/// Monotonic id of the most recently *started* reload and therefore also the number of tree
@@ -858,11 +879,11 @@ public final class BoardStore {
/// hand-written `width: 1` is legal and preserved until the app itself next edits width the
/// unchanged-units guard below skips it, so only a real change reaches the remove.
private func writeLaneWidths(_ changes: [(id: ItemID, units: Int)]) {
let writes: [(folder: URL, units: Int)] = changes.compactMap { change in
let writes: [(folder: URL, units: Int, prior: FieldValue<Int>)] = changes.compactMap { change in
guard let lane = snapshot.lanes.first(where: { $0.id == change.id }),
LaneLayoutMath.displayUnits(of: lane) != change.units
else { return nil }
return (rootURL.appendingPathComponent(change.id.rawValue), change.units)
return (rootURL.appendingPathComponent(change.id.rawValue), change.units, lane.width)
}
guard !writes.isEmpty else { return }
@@ -870,16 +891,37 @@ public final class BoardStore {
// call site Swift stops inferring the typed `throws(BoardWriteError)` and widens it to `any
// Error`, which `performWrite` will not take. Same wart as the value-returning call sites
// `performWrite`'s doc comment records, arriving from the other direction.
try? performWrite { () throws(BoardWriteError) -> Void in
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for write in writes {
try Self.setWidth(write.units, at: write.folder)
}
}
guard landed != nil else { return }
// resize prior width (13-native-undo.md Rules). One step whatever the batch's size the
// menu items step every selected lane in one gesture, and one gesture is one step.
registerStep(HistoryPhrase.name(.resize, kind: .lane, count: writes.count)) { _ in
for write in writes {
try BoardWriter.updateIndex(inItemFolder: write.folder, operation: .resize(title: nil)) { document in
if write.units == 1 {
document.remove(FrontmatterKeys.width)
} else {
document.set(FrontmatterKeys.width, to: .int(write.units))
}
Self.restoreWidth(write.prior, in: &document)
}
}
} redo: { _ in
for write in writes {
try Self.setWidth(write.units, at: write.folder)
}
}
}
/// The width write itself, spelled once so the gesture and its redo cannot drift apart on the
/// remove-at-default rule.
private static func setWidth(_ units: Int, at folder: URL) throws(BoardWriteError) {
try BoardWriter.updateIndex(inItemFolder: folder, operation: .resize(title: nil)) { document in
if units == 1 {
document.remove(FrontmatterKeys.width)
} else {
document.set(FrontmatterKeys.width, to: .int(units))
}
}
}
@@ -986,16 +1028,28 @@ public final class BoardStore {
/// targets written before it written the Writer is "atomic per filesystem operation, not per
/// gesture" and the reload shows the true state, which is the honest one.
public func applyStyle(to target: StyleTarget, background: StyleChange = .keep, icon: StyleChange = .keep) {
let edits: [(folder: URL, background: StyleChange, icon: StyleChange)] = styleSubjects(of: target)
let edits: [(
folder: URL,
background: StyleChange,
icon: StyleChange,
priorBackground: FieldValue<String>,
priorIcon: FieldValue<String>
)] = styleSubjects(of: target)
.compactMap { subject in
let background = Self.effective(background, against: subject.background)
let icon = Self.effective(icon, against: subject.icon)
guard background != .keep || icon != .keep else { return nil }
return (folder: subject.folder, background: background, icon: icon)
return (
folder: subject.folder,
background: background,
icon: icon,
priorBackground: subject.background,
priorIcon: subject.icon
)
}
guard !edits.isEmpty else { return }
try? performWrite { () throws(BoardWriteError) -> Void in
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for edit in edits {
// `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a
// failure names the item by the title it still has (see `WriteOperation.style`).
@@ -1005,6 +1059,31 @@ public final class BoardStore {
}
}
}
guard landed != nil else { return }
// restyle prior style (13-native-undo.md Rules). **One step for the batch**, which is the
// same sentence as this method's one bracket: "choosing a well applies to the whole selection
// one gesture, one commit", substrate swapped.
let kind: HistoryPhrase.Kind = switch styleLevel(of: target) {
case .board: .board
case .lane: .lane
case .card: .card
}
registerStep(HistoryPhrase.name(.restyle, kind: kind, count: edits.count)) { _ in
for edit in edits {
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
Self.restore(edit.priorBackground, to: FrontmatterKeys.background, in: &document)
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
}
}
} redo: { _ in
for edit in edits {
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
}
}
}
}
/// `change` narrowed against what is already on disk: `.keep` when it would write what is
@@ -1045,9 +1124,16 @@ public final class BoardStore {
/// and a menu item has no second thing to do about a failure.
public func createLane() {
let root = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.createLane(inBoard: root, title: nil)
let created = try? performWrite { () throws(BoardWriteError) -> ItemID in
try BoardWriter.createLane(inBoard: root, title: nil)
}
guard let created else { return }
// create remove the created folder (13-native-undo.md Rules). The bytes are read back
// here, while the folder still exists, because the inverse destroys it see `CreatedItem`.
let folder = root.appendingPathComponent(created.rawValue, isDirectory: true)
guard let item = createdItem(at: folder, kind: .lane) else { return }
registerCreation([item], kind: .lane)
}
// MARK: - The new-card placeholder's commit
@@ -1125,6 +1211,13 @@ public final class BoardStore {
return nil
}
transient.commitPlaceholder(expecting: created)
// create remove the created folder (13-native-undo.md Rules). The rank the pair above
// may have written is inside the captured bytes, so a redo puts the card back where the
// gesture put it, not merely at the bottom of the lane.
if let item = createdItem(at: laneFolder.appendingPathComponent(created.rawValue, isDirectory: true), kind: .card) {
registerCreation([item], kind: .card)
}
return created
}
@@ -1180,16 +1273,34 @@ public final class BoardStore {
folder.append(component: cardID.rawValue)
}
try? performWrite { () throws(BoardWriteError) -> Void in
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so the
// banner names the item by the title it still has rather than the one that failed to
// land (see `WriteOperation.rename`).
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
if let newTitle {
document.set(FrontmatterKeys.title, to: .string(newTitle))
} else {
document.remove(FrontmatterKeys.title)
}
try Self.setTitle(newTitle, at: folder)
}
guard landed != nil else { return }
// rename restore title (13-native-undo.md Rules). The prior title is the *typed* value,
// `nil` for an untitled item so undoing a rename that gave an untitled card a name takes
// the `title` key away again rather than writing `title: ""`.
let priorTitle = target.title
registerStep(HistoryPhrase.name(.rename, kind: target.cardID == nil ? .lane : .card)) { _ in
try Self.setTitle(priorTitle, at: folder)
} redo: { _ in
try Self.setTitle(newTitle, at: folder)
}
}
/// The title write every rename shares the item-level one and the board's spelled once so
/// the empty-title rule (a missing key, never `title: ""`) cannot differ between a gesture and
/// its own undo.
private static func setTitle(_ title: String?, at folder: URL) throws(BoardWriteError) {
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
if let title {
document.set(FrontmatterKeys.title, to: .string(title))
} else {
document.remove(FrontmatterKeys.title)
}
}
}
@@ -1231,6 +1342,12 @@ public final class BoardStore {
/// rather than write blind. Nothing here inspects the body: the store never re-parses to
/// second-guess the click, because its own snapshot is exactly as stale as the render was.
///
/// **It registers no undo step.** 13-native-undo.md Rules' inventory names the body write it
/// makes undoable precisely "Edit-session body save restore prior body bytes" and a Preview
/// checkbox is not one: it belongs to no session, has no flip to coalesce at, and 05 files it
/// under what is "undoable on git boards", which is the *other* substrate's answer. Registering it
/// here would be extending 13's inventory rather than implementing it.
///
/// **A checkbox in a card that has gone writes nothing** the vanished-target guard every
/// gesture in this file makes, ancestor-walked through `liveItem`: the card window would be
/// dismissing itself in the same breath, and the reload that removed the card is the authority.
@@ -1297,6 +1414,42 @@ public final class BoardStore {
}
}
/// Registers **one Edit session** as one undo step 13-native-undo.md Rules' coalescing
/// sentence, stated where the session ends rather than where the bytes land.
///
/// ### Why this is not registered in `writeCardBody`
///
/// Because a session is not a save. "An Edit session is one step, registered at the EditPreview
/// flip (the effective Save 05-card-window.md)", and a session contains any number of debounced
/// saves: registering per write would put a step on the stack every ~700 ms of typing, and Z
/// would walk backwards through the user's keystrokes in seven-hundred-millisecond slices rather
/// than undoing the edit they made. So `CardBodyEditSession` remembers the bytes disk held when
/// the session's first save landed, and calls this once at the flip with that pair the same
/// boundary pro-m1's auto-committer coalesces on, for the same reason.
///
/// ### The bytes are the whole state
///
/// `BoardWriter.writeBody` replaces the body span and nothing else, so a step built from two body
/// strings restores the prior body **byte for byte** unknown keys, comments and key order above
/// the delimiter were never this write's to change. That is the one inverse in the app whose
/// fidelity is byte-level rather than field-level.
///
/// Liveness is `writeCardBody`'s deliberately blind walk (`cardBodyTarget`), so a session that
/// ended because its card was tombstoned still registers the keystrokes survived into the
/// tombstoned folder, and their undo has to be able to reach the same place.
public func registerBodyEdit(inCard cardID: ItemID, priorBody: String, newBody: String) {
guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return }
let folder = rootURL
.appendingPathComponent(target.laneID.rawValue, isDirectory: true)
.appendingPathComponent(target.cardID.rawValue, isDirectory: true)
registerStep(HistoryPhrase.name(.edit, kind: .card)) { _ in
_ = try BoardWriter.writeBody(inItemFolder: folder, body: priorBody)
} redo: { _ in
_ = try BoardWriter.writeBody(inItemFolder: folder, body: newBody)
}
}
/// Which folder a card's body write lands in **the one card walk that ignores liveness**.
///
/// Every other resolution in this file goes through `liveItem`, whose ancestor-walked liveness is
@@ -1373,6 +1526,11 @@ public final class BoardStore {
///
/// A pull landing mid-session is not consulted at all: "Apply stays last-writer-wins" (05, citing
/// 07-sync-collab.md), the same posture the Edit buffer takes.
///
/// **It registers no undo step**, `toggleTaskMarker`'s reason: 13-native-undo.md Rules makes the
/// *Edit session's* body save undoable, and Apply is not one it is a whole-file replacement of
/// bytes the user typed themselves, with the raw buffer still on screen as its own record of what
/// they were.
public func applyCardSource(inCard cardID: ItemID, text: String) -> RawSourceApplyOutcome {
guard let target = Self.liveItem(cardID, in: snapshot), let card = target.cardID else { return .vanished }
let folder = rootURL
@@ -1433,16 +1591,19 @@ public final class BoardStore {
guard newTitle != snapshot.title.value else { return }
let folder = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
let priorTitle = snapshot.title.value
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so a
// refusal names the board by the title it still has (see `WriteOperation.rename`).
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
if let newTitle {
document.set(FrontmatterKeys.title, to: .string(newTitle))
} else {
document.remove(FrontmatterKeys.title)
}
}
try Self.setTitle(newTitle, at: folder)
}
guard landed != nil else { return }
// rename restore title, at the one level with no item to aim at.
registerStep(HistoryPhrase.name(.rename, kind: .board)) { _ in
try Self.setTitle(priorTitle, at: folder)
} redo: { _ in
try Self.setTitle(newTitle, at: folder)
}
}
@@ -1471,18 +1632,28 @@ public final class BoardStore {
let root = rootURL
let folder = root.appendingPathComponent(id.rawValue)
try? performWrite { () throws(BoardWriteError) -> Void in
// The rank the lane held before the write and the one it lands on both read out of the
// bracket below, because a renumber that fires inside it moves the *prior* value too: the
// dragged lane is among the renumbered children, so its pre-gesture `order` would no longer
// place it where it was. What an inverse must restore is the rank the file held immediately
// before its own rewrite, which is exactly what this captures either way.
var priorOrder = lanes[from].order
var newOrder: Double?
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target)
if rank == nil {
// Compact and place again. Unlike the card case the dragged lane *is* among the
// renumbered children it is a real folder on disk so its fresh rank is dropped
// from the ladder before the neighbours are consulted.
try BoardWriter.renumberVisibleChildren(of: root)
var compacted = Ranks.renumbered(count: lanes.count)
let renumbered = Ranks.renumbered(count: lanes.count)
priorOrder = renumbered[from]
var compacted = renumbered
compacted.remove(at: from)
rank = Ranks.insertionRank(amongVisible: compacted, at: target)
}
guard let rank else { return }
newOrder = rank
_ = try BoardWriter.moveItem(
at: folder,
@@ -1492,6 +1663,25 @@ public final class BoardStore {
order: rank
)
}
guard landed != nil, let newOrder else { return }
// reorder restore original `order` (13-native-undo.md Rules). A lane drag never changes
// parent the board root is the only one there is so 06's vocabulary word for it is
// Reorder, not Move.
let restored = priorOrder
registerStep(HistoryPhrase.name(.reorder, kind: .lane)) { _ in
try Self.setOrder(restored, at: folder)
} redo: { _ in
try Self.setOrder(newOrder, at: folder)
}
}
/// The bare rank rewrite an inverse reorder performs `moveItem`'s same-parent degenerate path
/// with the URL arithmetic taken out, since an inverse always names the folder directly.
private static func setOrder(_ order: Double, at folder: URL) throws(BoardWriteError) {
try BoardWriter.updateIndex(inItemFolder: folder, operation: .reorder(title: nil)) { document in
document.set(FrontmatterKeys.order, to: .double(order))
}
}
/// The within-board **lane drag**, multi-drag included: `ids` land contiguously at display
@@ -1519,23 +1709,29 @@ public final class BoardStore {
else { return }
let root = rootURL
try? performWrite { () throws(BoardWriteError) -> Void in
// `moveLane`'s capture, per member see its note on why the prior rank is read out of the
// bracket rather than off the snapshot.
var priorOrders = members.map(\.order)
var rewrites: [(folder: URL, order: Double)] = []
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
if ranks == nil {
// Compact and place again. The dragged lanes *are* among the renumbered children
// they are real folders on disk so their fresh rungs are dropped from the ladder
// before the neighbours are consulted, exactly as `moveLane` drops its one.
try BoardWriter.renumberVisibleChildren(of: root)
let compacted = zip(lanes, Ranks.renumbered(count: lanes.count))
.filter { !ids.contains($0.0.id) }
.map(\.1)
let renumbered = Array(zip(lanes, Ranks.renumbered(count: lanes.count)))
priorOrders = renumbered.filter { ids.contains($0.0.id) }.map(\.1)
let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1)
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
}
guard let ranks else { return }
for (member, rank) in zip(members, ranks) {
let folder = root.appendingPathComponent(member.id.rawValue, isDirectory: true)
rewrites.append((folder: folder, order: rank))
_ = try BoardWriter.moveItem(
at: root.appendingPathComponent(member.id.rawValue, isDirectory: true),
at: folder,
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
@@ -1543,6 +1739,21 @@ public final class BoardStore {
)
}
}
guard landed != nil, !rewrites.isEmpty else { return }
// reorder restore original `order`, one step for the whole run: "one `performWrite` bracket
// per gesture whatever the set's size" is the same sentence as one gesture, one undo step.
let inverse = Array(zip(rewrites.map(\.folder), priorOrders))
let forward = rewrites
registerStep(HistoryPhrase.name(.reorder, kind: .lane, count: forward.count)) { _ in
for (folder, order) in inverse {
try Self.setOrder(order, at: folder)
}
} redo: { _ in
for write in forward {
try Self.setOrder(write.order, at: write.folder)
}
}
}
// MARK: - Drag & drop commits
@@ -1576,6 +1787,7 @@ public final class BoardStore {
private struct DraggedCard {
let id: ItemID
let laneID: ItemID
let order: Double
}
/// `ids` narrowed to live cards under live lanes and sorted into **flatten order** "lane
@@ -1586,15 +1798,15 @@ public final class BoardStore {
/// membership is a UUID set that vanished items leave silently (02-architecture.md), and "partial
/// vanishing drops the survivors" is the design's own wording.
private func draggedCards(_ ids: Set<ItemID>) -> [DraggedCard] {
var lanes: [ItemID: ItemID] = [:]
var homes: [ItemID: (lane: ItemID, order: Double)] = [:]
for lane in snapshot.lanes where !lane.isDeleted {
for card in lane.cards where !card.isDeleted {
lanes[card.id] = lane.id
homes[card.id] = (lane.id, card.order)
}
}
return SelectionGrammar.liveCards(in: snapshot)
.filter { ids.contains($0) }
.compactMap { id in lanes[id].map { DraggedCard(id: id, laneID: $0) } }
.compactMap { id in homes[id].map { DraggedCard(id: id, laneID: $0.lane, order: $0.order) } }
}
/// The within-board card drop: `ids` land contiguously at logical position `index` among
@@ -1629,7 +1841,12 @@ public final class BoardStore {
let root = rootURL
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
// The pre-write home of every member, per 13's "move move back (original lane, original
// `order`)". A renumber inside the bracket rewrites the destination lane's own cards, so a
// member that was already there has its captured rank refreshed `moveLane`'s note.
var origins = Dictionary(uniqueKeysWithValues: members.map { ($0.id, (lane: $0.laneID, order: $0.order)) })
var arrivals: [(id: ItemID, order: Double)] = []
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
if ranks == nil {
// Compact and place again. The renumber assigns in display order over the lane's
@@ -1637,9 +1854,11 @@ public final class BoardStore {
// members already in this lane are dropped from it before the neighbours are
// consulted, exactly as `moveLane` drops the dragged lane's own rung.
try BoardWriter.renumberVisibleChildren(of: laneFolder)
let compacted = zip(rendered, Ranks.renumbered(count: rendered.count))
.filter { !ids.contains($0.0.id) }
.map(\.1)
let renumbered = Array(zip(rendered, Ranks.renumbered(count: rendered.count)))
for (card, rank) in renumbered where ids.contains(card.id) {
origins[card.id] = (lane: laneID, order: rank)
}
let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1)
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
}
guard let ranks else { return }
@@ -1648,6 +1867,7 @@ public final class BoardStore {
let folder = root
.appendingPathComponent(member.laneID.rawValue, isDirectory: true)
.appendingPathComponent(member.id.rawValue, isDirectory: true)
arrivals.append((id: member.id, order: rank))
_ = try BoardWriter.moveItem(
at: folder,
toParent: laneFolder,
@@ -1657,6 +1877,49 @@ public final class BoardStore {
)
}
}
guard landed != nil, !arrivals.isEmpty else { return }
// move move back (original lane, original `order`); a drop that never left its lane is
// 06's Reorder rather than Move, which is the same distinction the commit vocabulary draws.
let inverse: [(from: URL, toParent: URL, order: Double)] = arrivals.compactMap { arrival in
guard let origin = origins[arrival.id] else { return nil }
return (
from: laneFolder.appendingPathComponent(arrival.id.rawValue, isDirectory: true),
toParent: root.appendingPathComponent(origin.lane.rawValue, isDirectory: true),
order: origin.order
)
}
let forward: [(from: URL, order: Double)] = arrivals.compactMap { arrival in
guard let origin = origins[arrival.id] else { return nil }
return (
from: root
.appendingPathComponent(origin.lane.rawValue, isDirectory: true)
.appendingPathComponent(arrival.id.rawValue, isDirectory: true),
order: arrival.order
)
}
let crossedLanes = members.contains { $0.laneID != laneID }
registerStep(HistoryPhrase.name(crossedLanes ? .move : .reorder, kind: .card, count: arrivals.count)) { _ in
for step in inverse {
_ = try BoardWriter.moveItem(
at: step.from,
toParent: step.toParent,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: step.order
)
}
} redo: { _ in
for step in forward {
_ = try BoardWriter.moveItem(
at: step.from,
toParent: laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: step.order
)
}
}
}
/// The within-board -drag: fresh-GUID duplicates of `ids` land contiguously at `index` among
@@ -1716,6 +1979,15 @@ public final class BoardStore {
// board's own bracket, which is correct and needs no coordination: the source store's watcher
// sees a foreign change and reloads, which is exactly what a foreign change is.
//
// **None of these register an undo step, and the reason is 13's own two sentences.** Its inverse
// inventory names nine operations and an arrival is not among them; and "undo is board-local"
// one stack per board while a cross-board move's inverse would have to write into the *source*
// board, whose stack knows nothing about it and whose window may not even be open. The clipboard's
// half is the same shape one remove further: a paste's inverse needs the staged tree to still be
// there, which is exactly the staging lifecycle 13 defers with the attachment operations. Within a
// board, `copyCards`' -drag is left out with them: its Writer operation is `.copy`, not a create,
// and the three arrival paths are one gesture family that should gain undo together or not at all.
//
// `sources` are the items' folder URLs in the source board both boards are open in this app,
// so both roots are already security-scoped and the payload can carry plain URLs. The source
// board root is read back off the path rather than passed alongside: 01-storage-format.md's
@@ -2027,6 +2299,13 @@ public final class BoardStore {
// MARK: - Finder file drops
// **The attachment half registers no undo step** (13-native-undo.md Out of scope, ratified
// 2026-07-27): "attachment add/remove registers **no undo step** in v1", because remove
// re-add needs the removed file to survive somewhere and that staging area is a design pass of
// its own. Add remove would be a clean inverse on its own, but half a pair is worse than none:
// Z would undo attaching and refuse to undo detaching, which is not a rule anyone could learn.
// The *card-creating* half below is an ordinary create and does register one.
//
// The writes an external Finder file drag performs (04-interactions.md Drag and drop, "Files
// from Finder"): onto a card the files join its `attachments/`, onto lane empty space they become
// one card each. The gesture's half which card, which slot is `BoardDropContext`'s; these are
@@ -2107,6 +2386,12 @@ public final class BoardStore {
/// 01-storage-format.md's loose-file carve-out (§ Fractal layout Rules, settled 2026-07-28,
/// "Lanework-owns-the-board"; the loader's `looseCardFiles` is the notice half).
///
/// **It registers no undo step**, and unlike its neighbours that is not a deferral: nobody asked
/// for it. The relocation is the app tidying its own house on a reload, not a gesture there is
/// no Z that should follow it, and putting one on the stack would let the next Z undo something
/// the user never did. (It is `renumberVisibleChildren`'s posture: bookkeeping composes no event,
/// 06-history-undo.md Commit messages.)
///
/// **It is an ordinary app write and nothing more.** One `performWrite` bracket over the whole
/// board's worth of relocation, so the churn rounds back as a single app-mediated reload and (on
/// git boards) a single commit the style batch's rule, applied to a batch the app started
@@ -2236,6 +2521,7 @@ public final class BoardStore {
let root = rootURL
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
var created: [(folder: URL, source: URL)] = []
try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(
amongVisible: rendered.map(\.order), at: target, count: urls.count)
@@ -2265,8 +2551,17 @@ public final class BoardStore {
try? FileManager.default.removeItem(at: folder)
throw error
}
created.append((folder: folder, source: url))
}
}
// create remove the created folder (13-native-undo.md Rules), one step for the drop
// whatever its file count. The redo re-imports from the same source URLs the gesture used
// the one create in the app whose replay needs more than the card's own bytes. Collected
// from what actually landed rather than from `urls`, so a batch that failed halfway still
// hands Z exactly the cards it left behind.
let items = created.compactMap { createdItem(at: $0.folder, kind: .card, attachments: [$0.source]) }
registerCreation(items, kind: .card)
}
/// The title a dropped file's card takes: **the filename without its extension**
@@ -2334,7 +2629,12 @@ public final class BoardStore {
let orders = rendered.map(\.order)
let positions = Dictionary(uniqueKeysWithValues: rendered.enumerated().map { ($1.id, $0) })
try? performWrite { () throws(BoardWriteError) -> Void in
// Every rank this gesture rewrites, with the value it replaced the permutation's own
// inverse. It is read out of the bracket because the ladder may be the *renumbered* one:
// after a compaction the card at display position `origin` holds `ladder[origin]`, which is
// what its own rewrite overwrites and therefore what an undo has to put back.
var rewrites: [(folder: URL, from: Double, to: Double)] = []
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
var ladder = orders
if !Self.isStrictlyAscending(orders) {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
@@ -2345,8 +2645,10 @@ public final class BoardStore {
for (destination, id) in plan.ordering.enumerated() {
guard let origin = positions[id], origin != destination else { continue }
let rank = ladder[destination]
let folder = laneFolder.appendingPathComponent(id.rawValue, isDirectory: true)
rewrites.append((folder: folder, from: ladder[origin], to: rank))
try BoardWriter.updateIndex(
inItemFolder: laneFolder.appendingPathComponent(id.rawValue, isDirectory: true),
inItemFolder: folder,
// `.reorder(title: nil)`: `updateIndex` enriches it off the document it reads, so
// a failure names the card by its own title.
operation: .reorder(title: nil)
@@ -2355,6 +2657,21 @@ public final class BoardStore {
}
}
}
guard landed != nil, !rewrites.isEmpty else { return }
// reorder restore original `order` (13-native-undo.md Rules). The step is named for the
// *gesture's* subject the cards the user was moving not for every sibling the permutation
// displaced, which is the same rule 06 applies to a commit subject.
let steps = rewrites
registerStep(HistoryPhrase.name(.reorder, kind: .card, count: selection.ids.count)) { _ in
for step in steps {
try Self.setOrder(step.from, at: step.folder)
}
} redo: { _ in
for step in steps {
try Self.setOrder(step.to, at: step.folder)
}
}
}
/// Whether a lane's ranks separate its cards on their own the condition under which they can
@@ -2415,13 +2732,13 @@ public final class BoardStore {
/// reload-survival rule), and neither do `putBack`/`deleteImmediately` the item merely changed
/// sides, or nothing survives on either.
public func delete(_ ids: Set<ItemID>) {
let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
let paths = TrashModel.paths(of: ids, on: .live, in: snapshot)
guard !paths.isEmpty else { return }
// The successor is drawn from what the lane is *showing*, so a delete under an active search
// walks the filtered lane rather than selecting a card the query has hidden.
let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot, filter: searchFilter)
tombstone(folders)
tombstone(paths)
if let successor {
select([successor], liveness: .live, anchor: successor, head: successor)
@@ -2456,10 +2773,9 @@ public final class BoardStore {
/// Cards only, by the gesture's own gate (`TrashDrop.accepts`) but nothing here depends on
/// that: the paths resolve on the live side exactly as `delete(_:)`'s do.
public func deleteByDrag(cardIDs: [ItemID]) {
let folders = TrashModel.paths(of: Set(cardIDs), on: .live, in: snapshot)
.map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
tombstone(folders)
let paths = TrashModel.paths(of: Set(cardIDs), on: .live, in: snapshot)
guard !paths.isEmpty else { return }
tombstone(paths)
}
/// **The card window's Actions Delete** (05-card-window.md Actions: "Delete tombstones the
@@ -2485,9 +2801,9 @@ public final class BoardStore {
/// rule; liveness is ancestor-walked, so a card under a tombstoned lane is gone too and its
/// window is already dismissing.
public func deleteCard(_ id: ItemID) {
let folders = TrashModel.paths(of: [id], on: .live, in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
tombstone(folders)
let paths = TrashModel.paths(of: [id], on: .live, in: snapshot)
guard !paths.isEmpty else { return }
tombstone(paths)
}
/// The tombstone write itself **one `performWrite` bracket, whatever the set's size and
@@ -2495,8 +2811,27 @@ public final class BoardStore {
///
/// Spelled once so and drop-on-trash cannot drift apart on disk; everything that differs
/// between them is about the *selection*, and lives in the callers.
private func tombstone(_ folders: [URL]) {
try? performWrite { () throws(BoardWriteError) -> Void in
/// It is also where the tombstone's **undo step** is registered, for the identical reason: 13's
/// "tombstone () restore" has to mean the same thing whichever gesture asked, and a step
/// registered at each caller would be three chances to name it differently.
private func tombstone(_ paths: [TrashModel.ItemPath]) {
let folders = paths.map { $0.folder(under: rootURL) }
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
try BoardWriter.deleteItem(at: folder)
}
}
guard landed != nil else { return }
// tombstone restore. The undo is Put Back's own write, which is what makes 13's "the stack
// and the trash are two doors to the same tombstone state" true on disk rather than by
// agreement undoing a delete is *identical* in effect to Put Back.
let kind: HistoryPhrase.Kind = paths.contains(where: \.isLane) ? .lane : .card
registerStep(HistoryPhrase.name(.delete, kind: kind, count: folders.count)) { _ in
for folder in folders {
try BoardWriter.restoreItem(at: folder)
}
} redo: { _ in
for folder in folders {
try BoardWriter.deleteItem(at: folder)
}
@@ -2524,19 +2859,53 @@ public final class BoardStore {
/// resolve rule ejects them from a `.trashed` set as a vanish the same silent shrink an
/// external restore would produce.
public func putBack(_ ids: Set<ItemID>) {
let folders = TrashModel.paths(of: ids, on: .trashed, in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
let paths = TrashModel.paths(of: ids, on: .trashed, in: snapshot)
guard !paths.isEmpty else { return }
// Captured before the write: the timestamp each row is filed in the trash under, which is
// what an undo has to put back see `restoreTombstone`.
let restored = paths.map { (folder: $0.folder(under: rootURL), deleted: deletedField(at: $0)) }
try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
try BoardWriter.restoreItem(at: folder)
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
for item in restored {
try BoardWriter.restoreItem(at: item.folder)
}
}
guard landed != nil else { return }
// restore (Put Back) tombstone (13-native-undo.md Rules) the trash pair read the other
// way round from `tombstone(_:)`'s step.
let kind: HistoryPhrase.Kind = paths.contains(where: \.isLane) ? .lane : .card
registerStep(HistoryPhrase.name(.restore, kind: kind, count: restored.count)) { _ in
for item in restored {
try BoardWriter.updateIndex(inItemFolder: item.folder, operation: .delete(title: nil)) { document in
Self.restoreTombstone(item.deleted, in: &document)
}
}
} redo: { _ in
for item in restored {
try BoardWriter.restoreItem(at: item.folder)
}
}
}
/// The `deleted` value a trash row currently carries the one field a Put Back's inverse has to
/// carry forward, and one the `ItemPath` vocabulary deliberately does not (a path is a location,
/// not a reading of the file there).
private func deletedField(at path: TrashModel.ItemPath) -> FieldValue<Date> {
guard let lane = snapshot.lanes.first(where: { $0.id == path.laneID }) else { return .missing }
guard let cardID = path.cardID else { return lane.deleted }
return lane.cards.first(where: { $0.id == cardID })?.deleted ?? .missing
}
/// Delete Immediately : physically removes every tombstoned item in `ids` (03-board-ui.md §
/// Trash), in one bracket.
///
/// **It registers no undo step, and `purgeIsUnrecoverable` stays `true`** 13-native-undo.md
/// Rules settles this by name: "Permanently delete (Delete Immediately, Empty Trash)
/// `purgeIsUnrecoverable` stays true in base, and the existing confirmation rule already fires on
/// all base boards the confirm *is* the safety". A stack entry here would be a promise the
/// filesystem cannot keep.
///
/// **The confirmation is not here.** Whether the loss is real is `purgeIsUnrecoverable`'s
/// question and the alert is the window's; a store method that put up its own dialog could not
/// be driven from a test, and the same purge is reached by two surfaces (the menu item and the
@@ -2561,6 +2930,9 @@ public final class BoardStore {
/// Empty Trash : purges **every** tombstone on the board, in one bracket.
///
/// Not undoable, `deleteImmediately`'s ruling and its wording this is the other half of 13's
/// "Permanently delete".
///
/// **Whole-trash scope, search-independent** (03-board-ui.md § Trash, settled): the targets come
/// from the snapshot, never from the filtered view "a bulk command about the trash itself never
/// silently narrows to the visible subset". The filter does not reach this method at all, which
@@ -2641,7 +3013,12 @@ public final class BoardStore {
let rendered = destination.cards.filter { !$0.isDeleted }
let target = min(max(0, index), rendered.count)
try? performWrite { () throws(BoardWriteError) -> Void in
// What each row was before the gesture its lane, its recorded rank, and the timestamp it is
// filed in the trash under against where it lands. A tombstoned card is not among the
// destination's rendered cards, so a renumber inside the bracket cannot touch its own rank;
// only the neighbours' move, and their sequence is preserved.
var moves: [(cardID: ItemID, laneID: ItemID, priorOrder: Double, deleted: FieldValue<Date>, order: Double)] = []
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: rows.count)
if ranks == nil {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
@@ -2655,6 +3032,13 @@ public final class BoardStore {
for (row, rank) in zip(rows, ranks) {
let cardFolder = TrashModel.ItemPath(laneID: row.laneID, cardID: row.card.id).folder(under: root)
moves.append((
cardID: row.card.id,
laneID: row.laneID,
priorOrder: row.card.order,
deleted: row.card.deleted,
order: rank
))
guard row.laneID != laneID else {
try BoardWriter.updateIndex(
@@ -2680,6 +3064,49 @@ public final class BoardStore {
)
}
}
guard landed != nil, !moves.isEmpty else { return }
// restore tombstone (13-native-undo.md Rules), with the position half of the gesture
// walked back too: the row returns to the lane it was trashed in, at the rank it was trashed
// holding, under the timestamp it was trashed at which is exactly where its trash row was.
let steps = moves
registerStep(HistoryPhrase.name(.restore, kind: .card, count: steps.count)) { _ in
for step in steps {
let priorFolder = TrashModel.ItemPath(laneID: step.laneID, cardID: step.cardID).folder(under: root)
if step.laneID != laneID {
_ = try BoardWriter.moveItem(
at: laneFolder.appendingPathComponent(step.cardID.rawValue, isDirectory: true),
toParent: root.appendingPathComponent(step.laneID.rawValue, isDirectory: true),
sourceBoardRoot: root,
destinationBoardRoot: root,
order: step.priorOrder
)
}
try BoardWriter.updateIndex(inItemFolder: priorFolder, operation: .delete(title: nil)) { document in
Self.restoreTombstone(step.deleted, in: &document)
document.set(FrontmatterKeys.order, to: .double(step.priorOrder))
}
}
} redo: { _ in
for step in steps {
let priorFolder = TrashModel.ItemPath(laneID: step.laneID, cardID: step.cardID).folder(under: root)
guard step.laneID != laneID else {
try BoardWriter.updateIndex(inItemFolder: priorFolder, operation: .restore(title: nil)) { document in
document.remove(FrontmatterKeys.deleted)
document.set(FrontmatterKeys.order, to: .double(step.order))
}
continue
}
try BoardWriter.restoreItem(at: priorFolder)
_ = try BoardWriter.moveItem(
at: priorFolder,
toParent: laneFolder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: step.order
)
}
}
}
// MARK: - Selection (delegated)