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:
@@ -36,6 +36,14 @@ import Foundation
|
||||
/// prior style fields) — and the coarse step is their composition. That also makes the coarse step's
|
||||
/// *after*-values the values the app itself wrote, so a foreign edit landing between a gesture and
|
||||
/// the close makes the step stale (13's field-level predicate) instead of being quietly reverted.
|
||||
///
|
||||
/// ### The values are anchored to the card, not to its folder
|
||||
///
|
||||
/// "Session steps anchor by card identity, never by path" (13 ▸ Rules, ruled 2026-07-31): every write
|
||||
/// folded here names the card's UUID and the thread position inside it (`HistoryAnchor`), never the
|
||||
/// lane the card happened to be in when the gesture ran. The fold is therefore over identities the
|
||||
/// whole way down, and one board-side lane move — which used to stale every component of the coarse
|
||||
/// step at once — changes nothing about it.
|
||||
@MainActor
|
||||
public final class CardWindowUndo {
|
||||
|
||||
@@ -154,6 +162,11 @@ public final class CardWindowUndo {
|
||||
/// card fold to one target carrying both, while two that set the same dimension fold to one value.
|
||||
/// Presence is whole-target and takes the later answer, which is what makes a post-then-delete of
|
||||
/// one comment fold to "in the trash" rather than to two contradictory claims.
|
||||
///
|
||||
/// **A target is an anchor, not a path** (13 ▸ Rules, ruled 2026-07-31): every write folded here
|
||||
/// is a card window's, so every anchor is a card identity, and the fold is over identities the
|
||||
/// whole way down — which is what carries "session steps anchor by card identity" into the coarse
|
||||
/// step without the fold knowing anything about it.
|
||||
static func fold(_ lists: [[HistoryExpectation]]) -> Fold {
|
||||
var fold = Fold()
|
||||
for list in lists {
|
||||
@@ -170,7 +183,7 @@ public final class CardWindowUndo {
|
||||
var fields: [ExpectedField.Kind: ExpectedField]
|
||||
}
|
||||
|
||||
private var targets: [URL: Target] = [:]
|
||||
private var targets: [HistoryAnchor: Target] = [:]
|
||||
/// First-seen order, so the folded list a step carries is stable rather than hash-ordered —
|
||||
/// a skip banner and a test both read better when the card comes before its comments.
|
||||
///
|
||||
@@ -178,17 +191,17 @@ public final class CardWindowUndo {
|
||||
/// built by walking the same writes in opposite directions, so their orders differ by
|
||||
/// construction while the question being asked — did anything actually change — is about the
|
||||
/// values alone.
|
||||
private var order: [URL] = []
|
||||
private var order: [HistoryAnchor] = []
|
||||
|
||||
static func == (lhs: Fold, rhs: Fold) -> Bool { lhs.targets == rhs.targets }
|
||||
|
||||
/// **A later `.absent` clears what earlier writes said about the path**, which is how a move
|
||||
/// **A later `.absent` clears what earlier writes said about the target**, which is how a move
|
||||
/// inside one session folds correctly: a comment edited and then deleted leaves nothing at its
|
||||
/// live path, and carrying the edit's body expectation there would make the session's own step
|
||||
/// stale the moment it was registered. Every move-shaped step declares both of its paths
|
||||
/// precisely so this is expressible (`BoardStore.deleteComment`, `postComment`).
|
||||
/// live location, and carrying the edit's body expectation there would make the session's own
|
||||
/// step stale the moment it was registered. Every move-shaped step declares both of its
|
||||
/// anchors precisely so this is expressible (`BoardStore.deleteComment`, `postComment`).
|
||||
mutating func merge(_ expectation: HistoryExpectation) {
|
||||
let key = expectation.folder.standardizedFileURL
|
||||
let key = Self.key(expectation.anchor)
|
||||
if targets[key] == nil {
|
||||
order.append(key)
|
||||
targets[key] = Target(presence: expectation.presence, fields: [:])
|
||||
@@ -209,10 +222,18 @@ public final class CardWindowUndo {
|
||||
order.compactMap { key in
|
||||
guard let target = targets[key] else { return nil }
|
||||
let fields = Self.fieldOrder.compactMap { target.fields[$0] }
|
||||
return HistoryExpectation(folder: key, presence: target.presence, fields: fields)
|
||||
return HistoryExpectation(anchor: key, presence: target.presence, fields: fields)
|
||||
}
|
||||
}
|
||||
|
||||
/// Two anchors are one target when they name the same thing. Identity anchors already do that
|
||||
/// by construction — a UUID is its own normal form — so the only normalization left is the
|
||||
/// path anchor's, which a board gesture may spell with a trailing slash or a `.` component.
|
||||
private static func key(_ anchor: HistoryAnchor) -> HistoryAnchor {
|
||||
guard case let .path(url) = anchor else { return anchor }
|
||||
return .path(url.standardizedFileURL)
|
||||
}
|
||||
|
||||
private static let fieldOrder: [ExpectedField.Kind] = [.title, .order, .width, .background, .icon, .body]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user