Realign undo with the evening rulings — repo-nested and identity anchors

Repo-nested boards bind native undo in every tier (25d2513): the
no-undo case is gone, makeHistoryProvider answers git or native, and
the native path provably never touches the enclosing repository's
.git. Session undo steps anchor by card identity, never by path
(9119aa1): HistoryAnchor carries the card UUID (plus comment/draft
vocabulary) and apply-time validation resolves the current folder via
the same both-container walk writeCardBody uses — a board-side lane or
trash move no longer stales the coarse close step, while a genuine
field collision still skips it whole.

2448 tests in 423 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 21:19:42 -04:00
parent 54951e92ef
commit d076427ee0
15 changed files with 600 additions and 151 deletions
+47 -18
View File
@@ -1502,15 +1502,28 @@ public final class BoardStore: HealHost {
/// close step. The board popover, the Style popover and the quick-style rows pass nothing, which
/// is the board's stack where a board-issued gesture belongs even when it names a card whose
/// window is open.
///
/// **And so is the step's *anchoring***, by exactly the same split (13 Rules, ruled
/// 2026-07-31). A window's restyle is a session gesture: it anchors to the card's identity, so the
/// lane move that would once have staled it and with it the whole coarse step it folds into
/// now resolves through. A board-issued restyle keeps its path anchors, because a board gesture's
/// subjects are a *selection*, spanning lanes and the board root, and "board-stack steps keep
/// their existing path-anchored expectations" is the scope the ruling drew.
public func applyStyle(
to target: StyleTarget,
background: StyleChange = .keep,
icon: StyleChange = .keep,
on window: CardWindowUndo? = nil
) {
// A session gesture anchors to the card, everything else to the folder it resolved (above).
// The level is asked once, off the target, because a window's editor names exactly one card
// and a board gesture's selection may name lanes and the root neither of which a card walk
// could ever resolve.
let anchorsByIdentity = window != nil && styleLevel(of: target) == .card
let edits: [(
id: ItemID?,
folder: URL,
anchor: HistoryAnchor,
background: StyleChange,
icon: StyleChange,
priorBackground: FieldValue<String>,
@@ -1520,9 +1533,15 @@ public final class BoardStore: HealHost {
let background = Self.effective(background, against: subject.background)
let icon = Self.effective(icon, against: subject.icon)
guard background != .keep || icon != .keep else { return nil }
let anchor: HistoryAnchor = if anchorsByIdentity, let id = subject.id {
.card(id)
} else {
.path(subject.folder)
}
return (
id: subject.id,
folder: subject.folder,
anchor: anchor,
background: background,
icon: icon,
priorBackground: subject.background,
@@ -1569,20 +1588,20 @@ public final class BoardStore: HealHost {
subject: subject,
on: window,
undoExpects: edits.map {
.present($0.folder, fields: Self.styledFields(background: $0.background, icon: $0.icon))
.present($0.anchor, fields: Self.styledFields(background: $0.background, icon: $0.icon))
},
redoExpects: edits.map {
.present($0.folder, fields: Self.restoredStyleFields(
.present($0.anchor, fields: Self.restoredStyleFields(
background: $0.background,
priorBackground: $0.priorBackground,
icon: $0.icon,
priorIcon: $0.priorIcon
))
}
) { _ in
) { store in
for edit in edits {
try BoardWriter.updateIndex(
inItemFolder: edit.folder,
inItemFolder: try store.requiredFolder(for: edit.anchor, .style(title: nil)),
kind: edit.id == nil ? .board : nil,
operation: .style(title: nil)
) { document in
@@ -1590,10 +1609,10 @@ public final class BoardStore: HealHost {
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
}
}
} redo: { _ in
} redo: { store in
for edit in edits {
try BoardWriter.updateIndex(
inItemFolder: edit.folder,
inItemFolder: try store.requiredFolder(for: edit.anchor, .style(title: nil)),
kind: edit.id == nil ? .board : nil,
operation: .style(title: nil)
) { document in
@@ -1971,14 +1990,17 @@ public final class BoardStore: HealHost {
/// 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.
///
/// ### Its staleness predicate is the bytes, at the path the session wrote to
/// ### Its staleness predicate is the bytes, at the card the session wrote to
///
/// "Body steps compare bytes" (13 Rules), so the expectation is the whole body span as this
/// session left it a foreign editor that changed one character of it skips the step rather than
/// throwing that character away. The **container rides in the path** (`HistoryStaleness`): a
/// session that ended because its card was moved to the trash registers against the trash folder
/// it actually flushed into, and a later restore moves the card out from under the step, which
/// the ordinary existence check then reads as the collision it is.
/// throwing that character away.
///
/// **The step is anchored to the card, never to its folder** (13 Rules, ruled 2026-07-31): it
/// carries the card's UUID and the bytes, and the folder is resolved at apply time by the walk
/// `writeCardBody` itself uses. A lane move, mid-session or long after, therefore leaves the step
/// alone, and so does the trash move a dismissing window flushes into the two relocations this
/// step used to be staled by, though neither is a change to the bytes it is about.
///
/// ### Which stack it lands on is the caller's to say
///
@@ -1994,19 +2016,26 @@ public final class BoardStore: HealHost {
on window: CardWindowUndo? = nil
) {
guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return }
let folder = target.folder(under: rootURL)
let title = Self.cardTitle(at: target, in: snapshot)
let card = HistoryAnchor.card(cardID)
let operation = WriteOperation.editBody(title: title)
registerStep(
HistoryPhrase.name(.edit, kind: .card),
subject: title,
on: window,
undoExpects: [.present(folder, .body(newBody))],
redoExpects: [.present(folder, .body(priorBody))]
) { _ in
_ = try BoardWriter.writeBody(inItemFolder: folder, body: priorBody)
} redo: { _ in
_ = try BoardWriter.writeBody(inItemFolder: folder, body: newBody)
undoExpects: [.present(card, .body(newBody))],
redoExpects: [.present(card, .body(priorBody))]
) { store in
_ = try BoardWriter.writeBody(
inItemFolder: try store.requiredFolder(for: card, operation),
body: priorBody
)
} redo: { store in
_ = try BoardWriter.writeBody(
inItemFolder: try store.requiredFolder(for: card, operation),
body: newBody
)
}
}