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
)
}
}
+56 -26
View File
@@ -141,22 +141,29 @@ extension BoardStore {
return nil
}
let folder = card.folder
let title = card.title
let draftFolder = CommentThread.draftFolder(inCard: folder)
let postedFolder = CommentThread.commentFolder(posted.id, inCard: folder)
// **Anchored to the card, not to its folder** (13 Rules, ruled 2026-07-31): both halves of
// the move name the card's identity plus the thread position, and the card's folder is
// resolved at apply time so a lane move under an open window leaves this step, and the
// coarse step folding it, exactly as sound as they were.
let cardAnchor = HistoryAnchor.card(id)
let operation = WriteOperation.postComment(title: title)
registerStep(
HistoryPhrase.comment,
subject: title,
on: window,
undoExpects: [.present(postedFolder), .absent(draftFolder)],
redoExpects: [.present(draftFolder), .absent(postedFolder)]
) { _ in
try BoardWriter.unpostComment(posted.id, inCard: folder, cardTitle: title)
} redo: { _ in
undoExpects: [.present(.comment(posted.id, inCard: id)), .absent(.commentDraft(inCard: id))],
redoExpects: [.present(.commentDraft(inCard: id)), .absent(.comment(posted.id, inCard: id))]
) { store in
try BoardWriter.unpostComment(
posted.id,
inCard: try store.requiredFolder(for: cardAnchor, operation),
cardTitle: title
)
} redo: { store in
try BoardWriter.repostComment(
as: posted.id,
inCard: folder,
inCard: try store.requiredFolder(for: cardAnchor, operation),
stamping: posted.posted,
cardTitle: title
)
@@ -205,19 +212,28 @@ extension BoardStore {
on window: CardWindowUndo? = nil
) {
guard priorBody != newBody, let card = commentSubject(id) else { return }
let folder = CommentThread.commentFolder(commentID, inCard: card.folder)
let title = card.title
let comment = HistoryAnchor.comment(commentID, inCard: id)
let operation = WriteOperation.editComment(title: title)
registerStep(
HistoryPhrase.name(.edit, kind: .comment),
subject: title,
on: window,
undoExpects: [.present(folder, .body(newBody))],
redoExpects: [.present(folder, .body(priorBody))]
) { _ in
_ = try BoardWriter.editComment(at: folder, body: priorBody, cardTitle: title)
} redo: { _ in
_ = try BoardWriter.editComment(at: folder, body: newBody, cardTitle: title)
undoExpects: [.present(comment, .body(newBody))],
redoExpects: [.present(comment, .body(priorBody))]
) { store in
_ = try BoardWriter.editComment(
at: try store.requiredFolder(for: comment, operation),
body: priorBody,
cardTitle: title
)
} redo: { store in
_ = try BoardWriter.editComment(
at: try store.requiredFolder(for: comment, operation),
body: newBody,
cardTitle: title
)
}
}
@@ -228,9 +244,10 @@ extension BoardStore {
///
/// The step is the move read backwards, `moveToTrash`'s registration one level down and without
/// its rank half: a comment's trash has no order, so there is no `.order` after-value to compare
/// and existence is the whole predicate. The container rides in the path exactly as it does at
/// board level the undo expects the comment in `comments/.trash/`, the redo expects it back in
/// the thread so a foreign restore or a foreign re-delete skips the right half by itself.
/// and existence is the whole predicate. The container rides in the *anchor* exactly as it rides
/// in the path at board level the undo expects the comment in `comments/.trash/`, the redo
/// expects it back in the thread so a foreign restore or a foreign re-delete skips the right
/// half by itself, while the card's own lane is nowhere in either claim.
///
/// **Both sides of the move are declared**, `postComment`'s shape (added 2026-07-31 with the
/// window stack): the undo needs the trashed folder there *and the live path free*, because the
@@ -256,17 +273,30 @@ extension BoardStore {
}
guard landed != nil else { return false }
let trashed = CommentThread.trashedCommentFolder(commentID, inCard: folder)
// `postComment`'s anchoring, for its reason: the pair of anchors is the move, and the card's
// folder is resolved at apply time rather than baked into the step (13 Rules, ruled
// 2026-07-31).
let cardAnchor = HistoryAnchor.card(id)
let comment = HistoryAnchor.comment(commentID, inCard: id)
let trashed = HistoryAnchor.trashedComment(commentID, inCard: id)
let operation = WriteOperation.deleteComment(title: title)
registerStep(
HistoryPhrase.name(.delete, kind: .comment),
subject: title,
on: window,
undoExpects: [.present(trashed), .absent(live)],
redoExpects: [.present(live), .absent(trashed)]
) { _ in
try BoardWriter.restoreComment(commentID, inCard: folder, cardTitle: title)
} redo: { _ in
_ = try BoardWriter.deleteComment(at: live, cardTitle: title)
undoExpects: [.present(trashed), .absent(comment)],
redoExpects: [.present(comment), .absent(trashed)]
) { store in
try BoardWriter.restoreComment(
commentID,
inCard: try store.requiredFolder(for: cardAnchor, operation),
cardTitle: title
)
} redo: { store in
_ = try BoardWriter.deleteComment(
at: try store.requiredFolder(for: comment, operation),
cardTitle: title
)
}
return true
}