Realign code with the 2026-07-31 rulings
The trash sorts by modified descending — the arrival rank mint retires (Ranks.isOrderedForTrash one comparator, loader + merged order agree; the legacy deleted: migration stamps modified from the tombstone timestamp where parseable; delete undo steps validate existence-only; agent guide v8). Trash selection goes kind-blind — ranges, marquee, Select All, and the successor walk sweep both kinds; the guard moves to the exits (mixed-payload drop refusal, copy/cut validation). The copy stamping preflight widens back to comment depth (load-scoped posture — the board always loads, the gesture refuses whole). Fixes a latent no-op: trashed-lane drag restore never fired (DragSession.beginLanes hard-coded the board container). 2403 tests in 413 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -2343,8 +2343,10 @@ public final class BoardStore: HealHost {
|
||||
let rendered = snapshot.lanes
|
||||
let target = min(max(0, index), rendered.count)
|
||||
|
||||
// What an undo puts back: the rank the row held in the trash, captured before the write.
|
||||
var arrivals: [(id: ItemID, order: Double, trashRank: Double, title: String?)] = []
|
||||
// What an undo puts back: the `order` the row was **carrying** while trashed — its old strip
|
||||
// rank, which the trash move never rewrote (01-storage-format.md § Deletion, re-ruled
|
||||
// 2026-07-31) and which this restore is about to overwrite with a drop-position rank.
|
||||
var arrivals: [(id: ItemID, order: Double, carriedOrder: Double, title: String?)] = []
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
// The shared two-step; the arriving lanes are not among the renumbered children.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
@@ -2365,29 +2367,31 @@ public final class BoardStore: HealHost {
|
||||
arrivals.append((
|
||||
id: member.id,
|
||||
order: rank,
|
||||
trashRank: member.order,
|
||||
carriedOrder: member.order,
|
||||
title: member.title.value
|
||||
))
|
||||
}
|
||||
}
|
||||
guard landed != nil, !arrivals.isEmpty else { return }
|
||||
|
||||
// restore-by-move → **move back in** (13-native-undo.md ▸ Interaction with the trash), at the
|
||||
// trash rank the row was holding — the delete's step read from the other end.
|
||||
// restore-by-move → **move back in** (13-native-undo.md ▸ Interaction with the trash),
|
||||
// carrying back the `order` the row had while it sat there. The restore is what overwrote it
|
||||
// (a move writes a landing rank), so putting it back is what makes this a true inverse —
|
||||
// there is no *trash* rank involved either way, since the trash's own sequence is `modified`.
|
||||
let trashFolder = Self.parentFolder(of: .trashLane(arrivals[0].id), under: root)
|
||||
let steps = arrivals.map { arrival in
|
||||
(
|
||||
restored: ItemPath.lane(arrival.id).folder(under: root),
|
||||
trashed: ItemPath.trashLane(arrival.id).folder(under: root),
|
||||
order: arrival.order,
|
||||
trashRank: arrival.trashRank
|
||||
carriedOrder: arrival.carriedOrder
|
||||
)
|
||||
}
|
||||
registerStep(
|
||||
HistoryPhrase.name(.move, kind: .lane, count: steps.count),
|
||||
subject: arrivals.count == 1 ? arrivals[0].title : nil,
|
||||
undoExpects: steps.map { .present($0.restored, .order($0.order)) },
|
||||
redoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) }
|
||||
redoExpects: steps.map { .present($0.trashed, .order($0.carriedOrder)) }
|
||||
) { _ in
|
||||
for step in steps {
|
||||
_ = try BoardWriter.moveItem(
|
||||
@@ -2395,7 +2399,7 @@ public final class BoardStore: HealHost {
|
||||
toParent: trashFolder,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: step.trashRank
|
||||
order: step.carriedOrder
|
||||
)
|
||||
}
|
||||
} redo: { _ in
|
||||
@@ -3492,18 +3496,17 @@ public final class BoardStore: HealHost {
|
||||
return (lane, id)
|
||||
}
|
||||
|
||||
/// **The delete write itself: a physical move into `<root>/.trash/`, at a freshly minted top
|
||||
/// rank** — one `performWrite` bracket whatever the set's size and whichever gesture asked.
|
||||
/// **The delete write itself: a physical move into `<root>/.trash/`** — one `performWrite`
|
||||
/// bracket whatever the set's size and whichever gesture asked.
|
||||
///
|
||||
/// Spelled once so ⌫, drop-on-trash and the card window's button cannot drift apart on disk;
|
||||
/// everything that differs between them is about the *selection*, and lives in the callers.
|
||||
///
|
||||
/// **The rank is the store's to mint** (03-board-ui.md § Trash: "every arrival lands at the
|
||||
/// trash's topmost position, minting an `order` rank above the current top"). That is a question
|
||||
/// about the snapshot, which the stateless Writer does not have — so `Ranks.insertAtHead` runs
|
||||
/// here over `trashRanks`, and a multi-card delete threads the minted rank back through the
|
||||
/// running list so each card in the run lands above the one before it. Newest-first therefore
|
||||
/// falls out of ordinary ranks, with no timestamp sort anywhere.
|
||||
/// **There is no rank to mint** (03-board-ui.md § Trash, re-ruled 2026-07-31: "newest-first with
|
||||
/// no `order` rewrite, no rank minting, the item's `order` key riding along untouched for its
|
||||
/// eventual restore"). The trash sorts by `modified` descending and the move stamps it, so the
|
||||
/// position is the Writer's own doing and the store has no snapshot question left to answer —
|
||||
/// the head-of-the-trash ladder this method used to thread through the run retired with the rule.
|
||||
///
|
||||
/// - Returns: whether the write landed, so a caller can decide what to do with the selection.
|
||||
@discardableResult
|
||||
@@ -3517,23 +3520,11 @@ public final class BoardStore: HealHost {
|
||||
guard !moves.isEmpty else { return false }
|
||||
|
||||
let root = rootURL
|
||||
// The ranks, minted against the trash as it stands and threaded forward: each arrival is
|
||||
// above the previous one, so a three-card ⌫ reads newest-first in the column exactly as three
|
||||
// separate deletes would.
|
||||
var ladder = trashRanks
|
||||
var ranks: [Double] = []
|
||||
for _ in moves {
|
||||
let rank = Ranks.insertAtHead(ofVisible: ladder)
|
||||
ranks.append(rank)
|
||||
ladder.insert(rank, at: 0)
|
||||
}
|
||||
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for (move, rank) in zip(moves, ranks) {
|
||||
for move in moves {
|
||||
try BoardWriter.deleteCardToTrash(
|
||||
at: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root),
|
||||
inBoard: root,
|
||||
order: rank
|
||||
inBoard: root
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3543,28 +3534,29 @@ public final class BoardStore: HealHost {
|
||||
// trash: "a card delete is a move into `.trash/`, so its undo is the ordinary inverse move,
|
||||
// returning the card to its source lane and rank").
|
||||
//
|
||||
// The redo replays the *forward* write with its own captured rank, exactly as every other
|
||||
// redo in this file replays the values its gesture wrote — so a redone delete lands the card
|
||||
// back where the undo took it from, rather than at whatever the top of the trash has become
|
||||
// in the meantime.
|
||||
// The redo replays the forward write, which now takes no values at all — a delete is a folder
|
||||
// move plus a fresh stamp, and a redone delete lands where a first one would.
|
||||
//
|
||||
// **The expectations are one swap, and the container rides in the path** (`HistoryStaleness`):
|
||||
// the undo wants the card in the trash holding the rank the delete gave it; the redo wants it
|
||||
// back in its lane holding the rank it left. A foreign restore empties the trash path and the
|
||||
// undo skips; a foreign re-delete empties the lane path and the redo skips.
|
||||
let steps = zip(moves, ranks).map { move, rank in
|
||||
// the undo wants the card **in the trash**, and that is the whole of it — 13's field-level
|
||||
// predicate compares "what its write set", and this write sets no field an expectation can
|
||||
// name (the `modified` stamp is a clock reading, not a value the step chose). Existence is
|
||||
// the honest expectation, and it is the one that matters: a foreign restore empties the trash
|
||||
// path and the undo skips. The redo's side is unchanged and still field-level, because the
|
||||
// *undo* set it: the card back in its lane holding the rank it left. A foreign re-delete
|
||||
// empties the lane path and the redo skips.
|
||||
let steps = moves.map { move in
|
||||
(
|
||||
trashed: ItemPath.trashCard(move.id).folder(under: root),
|
||||
origin: ItemPath.card(lane: move.laneID, id: move.id).folder(under: root),
|
||||
laneFolder: ItemPath.lane(move.laneID).folder(under: root),
|
||||
priorOrder: move.order,
|
||||
trashRank: rank
|
||||
priorOrder: move.order
|
||||
)
|
||||
}
|
||||
registerStep(
|
||||
HistoryPhrase.name(.delete, kind: .card, count: steps.count),
|
||||
subject: moves.count == 1 ? moves[0].title : nil,
|
||||
undoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) },
|
||||
undoExpects: steps.map { .present($0.trashed) },
|
||||
redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) }
|
||||
) { _ in
|
||||
for step in steps {
|
||||
@@ -3578,7 +3570,7 @@ public final class BoardStore: HealHost {
|
||||
}
|
||||
} redo: { _ in
|
||||
for step in steps {
|
||||
try BoardWriter.deleteCardToTrash(at: step.origin, inBoard: root, order: step.trashRank)
|
||||
try BoardWriter.deleteCardToTrash(at: step.origin, inBoard: root)
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -3589,8 +3581,8 @@ public final class BoardStore: HealHost {
|
||||
/// exactly as a card moves … The no-dialog posture survives for a better reason: the move is
|
||||
/// recoverable, so nothing needs confirming").
|
||||
///
|
||||
/// **`moveToTrash`'s twin, and deliberately its mirror image**: the same head-of-the-trash rank
|
||||
/// mint threaded through the run, the same one bracket, the same one step — because on disk it is
|
||||
/// **`moveToTrash`'s twin, and deliberately its mirror image**: no rank, the same one bracket,
|
||||
/// the same one step — because on disk it is
|
||||
/// the same write one level up (`BoardWriter.deleteLaneToTrash`, which differs only in the guard
|
||||
/// it passes and the `kind` it stamps). What is *not* here any more is the whole capture layer:
|
||||
/// the lane's bytes never leave the disk, so nothing has to hold them (13-native-undo.md ▸
|
||||
@@ -3607,20 +3599,11 @@ public final class BoardStore: HealHost {
|
||||
guard !lanes.isEmpty else { return false }
|
||||
|
||||
let root = rootURL
|
||||
var ladder = trashRanks
|
||||
var ranks: [Double] = []
|
||||
for _ in lanes {
|
||||
let rank = Ranks.insertAtHead(ofVisible: ladder)
|
||||
ranks.append(rank)
|
||||
ladder.insert(rank, at: 0)
|
||||
}
|
||||
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for (lane, rank) in zip(lanes, ranks) {
|
||||
for lane in lanes {
|
||||
try BoardWriter.deleteLaneToTrash(
|
||||
at: ItemPath.lane(lane.id).folder(under: root),
|
||||
inBoard: root,
|
||||
order: rank
|
||||
inBoard: root
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3628,26 +3611,24 @@ public final class BoardStore: HealHost {
|
||||
|
||||
// lane delete → **the ordinary move back** (13-native-undo.md ▸ Interaction with the trash:
|
||||
// "a lane [returns] to its strip position (subtree intact — it never left the folder)"). The
|
||||
// redo replays the forward write with its own captured trash rank, exactly as the card
|
||||
// delete's does, so a redone delete lands the lane where the undo took it from rather than at
|
||||
// whatever the top of the trash has become in the meantime.
|
||||
// redo replays the forward write, which takes no values — the card delete's shape exactly.
|
||||
//
|
||||
// The expectations are one swap, and the container rides in the path (`HistoryStaleness`):
|
||||
// the undo wants the lane in the trash holding the rank the delete gave it, the redo wants it
|
||||
// back on the strip holding the rank it left. A foreign restore empties the trash path and
|
||||
// the undo skips; a foreign re-delete empties the strip path and the redo skips.
|
||||
let steps = zip(lanes, ranks).map { lane, rank in
|
||||
// the undo wants the lane **in the trash** (existence alone — the delete sets no field an
|
||||
// expectation can name, `moveToTrash`' note), the redo wants it back on the strip holding the
|
||||
// rank the undo put back. A foreign restore empties the trash path and the undo skips; a
|
||||
// foreign re-delete empties the strip path and the redo skips.
|
||||
let steps = lanes.map { lane in
|
||||
(
|
||||
trashed: ItemPath.trashLane(lane.id).folder(under: root),
|
||||
origin: ItemPath.lane(lane.id).folder(under: root),
|
||||
priorOrder: lane.order,
|
||||
trashRank: rank
|
||||
priorOrder: lane.order
|
||||
)
|
||||
}
|
||||
registerStep(
|
||||
HistoryPhrase.name(.delete, kind: .lane, count: steps.count),
|
||||
subject: lanes.count == 1 ? lanes[0].title.value : nil,
|
||||
undoExpects: steps.map { .present($0.trashed, .order($0.trashRank)) },
|
||||
undoExpects: steps.map { .present($0.trashed) },
|
||||
redoExpects: steps.map { .present($0.origin, .order($0.priorOrder)) }
|
||||
) { _ in
|
||||
for step in steps {
|
||||
@@ -3661,19 +3642,12 @@ public final class BoardStore: HealHost {
|
||||
}
|
||||
} redo: { _ in
|
||||
for step in steps {
|
||||
try BoardWriter.deleteLaneToTrash(at: step.origin, inBoard: root, order: step.trashRank)
|
||||
try BoardWriter.deleteLaneToTrash(at: step.origin, inBoard: root)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Every rank the trash currently holds, **both kinds** — what a fresh arrival mints above
|
||||
/// (03-board-ui.md § Trash: "Every arrival lands at the top … regardless of kind", so the ladder
|
||||
/// is the whole container and not one array of it).
|
||||
private var trashRanks: [Double] {
|
||||
snapshot.trash.map(\.order) + snapshot.trashedLanes.map(\.order)
|
||||
}
|
||||
|
||||
/// **The trash's own Delete — permanent** (03-board-ui.md § Trash: "on a trash selection, Delete
|
||||
/// (⌫/⌘⌫) is permanent … in the trash it removes the folder").
|
||||
///
|
||||
@@ -3763,15 +3737,17 @@ public final class BoardStore: HealHost {
|
||||
/// notice"). Old tombstoned lanes reappearing is the accepted cost, stated in the ruling; a
|
||||
/// tombstoned lane's own cards still migrate on their own account, as ordinary tombstoned cards.
|
||||
///
|
||||
/// ### The order among migrating cards is `deleted:`-ascending, deliberately
|
||||
/// ### The column order is the stamps', not the batch's
|
||||
///
|
||||
/// Every arrival mints a rank above the current top, so the *last* card migrated ends up topmost.
|
||||
/// Migrating oldest-first therefore reproduces the newest-first column the tombstone model's
|
||||
/// timestamp sort used to render — the same board, read the same way, with ordinary ranks doing
|
||||
/// the work. A card whose stamp is missing or unparseable sorts as **oldest** (the retired sort's
|
||||
/// own rule: "a corrupt stamp must not outrank fresh deletions"), and ties fall to the loader's
|
||||
/// walk order — lane `order`, then card `order` — which is the deterministic tie-break the whole
|
||||
/// corpus already uses.
|
||||
/// **Each migrated card takes its own `deleted:` timestamp as its `modified`** where it parses
|
||||
/// (01-storage-format.md § Deletion, re-ruled 2026-07-31; `BoardWriter.migrateTombstonedCard`),
|
||||
/// so the board's real deletion order survives into the trash's `modified`-descending sort no
|
||||
/// matter what order the batch runs in — the sequencing that used to *be* the ordering is now
|
||||
/// only a batch order. It is kept, `deleted:`-ascending, for determinism: the notice's card list
|
||||
/// and the commit's path order read the same way twice. A card whose stamp is missing or
|
||||
/// unparseable sorts as **oldest** here and takes migration time as its `modified`, landing it
|
||||
/// among the freshest — the honest reading, since a stamp that cannot be read is no evidence of
|
||||
/// when the card was deleted; ties fall to the loader's walk order.
|
||||
///
|
||||
/// ### The write half re-verifies against disk
|
||||
///
|
||||
@@ -3786,9 +3762,6 @@ public final class BoardStore: HealHost {
|
||||
let cards = Self.migrationOrder(of: work, in: snapshot)
|
||||
|
||||
var movedCards: [String?] = []
|
||||
// The ranks are minted exactly as a delete's are — head of the trash, threaded forward — so a
|
||||
// migrated card is indistinguishable on disk from one the user deletes today.
|
||||
var ladder = trashRanks
|
||||
heals.run(
|
||||
.legacyTombstone,
|
||||
signature: Self.signature(of: work.map(IntegrityRules.Defect.legacyTombstone)),
|
||||
@@ -3797,9 +3770,7 @@ public final class BoardStore: HealHost {
|
||||
for card in cards {
|
||||
let folder = ItemPath.card(lane: card.laneID, id: card.cardID).folder(under: root)
|
||||
guard Self.stillTombstoned(at: folder) else { continue }
|
||||
let rank = Ranks.insertAtHead(ofVisible: ladder)
|
||||
try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root, order: rank)
|
||||
ladder.insert(rank, at: 0)
|
||||
try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root)
|
||||
movedCards.append(card.title)
|
||||
}
|
||||
} posting: {
|
||||
@@ -3823,8 +3794,9 @@ public final class BoardStore: HealHost {
|
||||
return !document.deleted.isMissing
|
||||
}
|
||||
|
||||
/// The tombstoned cards in the order they should be filed into the trash — oldest `deleted:`
|
||||
/// first, so the newest ends up on top (see `migrateLegacyTombstones`).
|
||||
/// The tombstoned cards in the order the batch files them — oldest `deleted:` first, a
|
||||
/// deterministic batch order rather than the column's (see `migrateLegacyTombstones`: each card's
|
||||
/// own stamp decides where it lands).
|
||||
///
|
||||
/// `sorted(by:)` is not stable in the standard library, so the walk position is folded into the
|
||||
/// key rather than relied on: an unparseable or missing stamp takes `Date.distantPast` and ties
|
||||
@@ -4207,14 +4179,15 @@ public final class BoardStore: HealHost {
|
||||
/// trash's own reading of the same command when the trash side is the one in play.
|
||||
///
|
||||
/// Two branches, and the trash's is the narrow one: it fires only when the column is **shown**,
|
||||
/// the selection is in the trash, and it still names a card — the exact conditions under which
|
||||
/// "all" could mean anything but the board (04 ▸ The map, resettled 2026-07-28: "with the trash
|
||||
/// visible and a non-empty trash selection, Select All selects all visible trash cards; in every
|
||||
/// other state, all visible live cards — the container boundary decides which 'all' is meant").
|
||||
/// A trash selection naming nothing (a foreign restore, a purge) falls through to the board
|
||||
/// rather than selecting the trash wholesale on a guess. **Select All is card-scoped in both
|
||||
/// containers, never lane rows** (04 ▸ The trash, re-affirmed 2026-07-29 with lanes back in the
|
||||
/// trash), which is why the trash branch reads its cards and asks no kind question.
|
||||
/// the selection is in the trash, and it still names a row — the exact conditions under which
|
||||
/// "all" could mean anything but the board (04 ▸ The map, resettled 2026-07-28: "the container
|
||||
/// boundary decides which 'all' is meant"). A trash selection naming nothing (a foreign restore,
|
||||
/// a purge) falls through to the board rather than selecting the trash wholesale on a guess.
|
||||
///
|
||||
/// **In the trash "all" is all *rows*, both kinds** (04 ▸ The trash and 11-command-nexus.md ▸
|
||||
/// Select All, re-ruled 2026-07-31 with kind-blind trash selection: "Select All with a non-empty
|
||||
/// trash selection selects **all visible trash rows**"). The board's own Select All stays
|
||||
/// card-scoped, as everywhere.
|
||||
///
|
||||
/// The anchor — and the navigation head with it — **survives if it is still in the set** and is
|
||||
/// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor,
|
||||
@@ -4227,7 +4200,7 @@ public final class BoardStore: HealHost {
|
||||
let filter = searchFilter
|
||||
if transient.isTrashVisible, selection.container == .trash, !selection.isEmpty,
|
||||
SelectionGrammar.kind(of: selection, in: snapshot) != nil {
|
||||
apply(Set(SelectionGrammar.trashCards(in: snapshot, filter: filter)), in: .trash)
|
||||
apply(Set(SelectionGrammar.trashRows(in: snapshot, filter: filter)), in: .trash)
|
||||
return
|
||||
}
|
||||
apply(Set(SelectionGrammar.boardCards(in: snapshot, filter: filter)), in: .board)
|
||||
|
||||
Reference in New Issue
Block a user