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:
@@ -227,8 +227,16 @@ public final class ClipboardStore {
|
||||
/// the text (04 ▸ Grammar). The field consumes the selector natively, so this guard is belt over
|
||||
/// braces — but a board command that stayed armed under an editor would be exactly the kind of
|
||||
/// fall-through 04 is careful about.
|
||||
///
|
||||
/// **A mixed trash selection closes it — this is one of the two exits the kind guard moved to**
|
||||
/// (04-interactions.md ▸ The trash, ruled 2026-07-31 with kind-blind trash selection): "the
|
||||
/// pasteboard's payload types are per-kind, so Cut and Copy grey out via ordinary menu validation
|
||||
/// while a trash selection mixes kinds — no failed gesture, no beep". It is also what keeps
|
||||
/// `capture`'s single `kind` honest: the manifest names one payload type, and a set spanning both
|
||||
/// never reaches it.
|
||||
public func canCopy(from store: BoardStore) -> Bool {
|
||||
guard !store.isEditingInline else { return false }
|
||||
guard !SelectionGrammar.mixesKinds(store.selection, in: store.snapshot) else { return false }
|
||||
return SelectionGrammar.kind(of: store.selection, in: store.snapshot) != nil
|
||||
}
|
||||
|
||||
|
||||
@@ -498,13 +498,10 @@ enum UITestLaunch {
|
||||
|
||||
// The delete goes last so the trashed card's identity is one the lanes above have already
|
||||
// finished with — and through the ordinary delete door, so `.trash/` ends up holding exactly
|
||||
// what a user's ⌘⌫ would have put there, stamps and `order` included.
|
||||
// what a user's ⌘⌫ would have put there: the `modified` stamp that positions it, its `order`
|
||||
// riding along untouched.
|
||||
let doomed = cardURLs[trashedCardIndex.lane][trashedCardIndex.card]
|
||||
try BoardWriter.deleteCardToTrash(
|
||||
at: doomed,
|
||||
inBoard: root,
|
||||
order: Ranks.append(toVisible: [] as [Double])
|
||||
)
|
||||
try BoardWriter.deleteCardToTrash(at: doomed, inBoard: root)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -554,6 +554,27 @@ public final class BannerCenter {
|
||||
postLoss(Self.skippedFoldersMessage(count: count))
|
||||
}
|
||||
|
||||
/// **The mixed-kind drag that tried to leave the trash** (04-interactions.md ▸ The trash, ruled
|
||||
/// 2026-07-31 with kind-blind trash selection): "pickup is allowed — the selection is legal — but
|
||||
/// every out-of-trash drop target refuses the mixed payload, and the release surfaces a notice
|
||||
/// explaining the rule … the refused drag ends like any refusal, rows staying put".
|
||||
///
|
||||
/// **A loss row**, with the relocation family and `postSkippedFolders`: nothing failed — no write
|
||||
/// was attempted — and nothing is wrong with the board, but the gesture the user made did not
|
||||
/// happen, which is exactly the warning-tone "didn't arrive" register. The wording is 04's own,
|
||||
/// verbatim, and lives here because BannerCenter owns the phrasing (▸ Clipboard).
|
||||
///
|
||||
/// It is the *teaching* half of the guard: ⌘C/⌘X grey out silently (menu validation says no
|
||||
/// before the gesture starts), while the drag has no such surface to say it in advance — so the
|
||||
/// drop-time explanation "is where the rule teaches itself".
|
||||
public func postMixedTrashDrag() {
|
||||
postLoss(Self.mixedTrashDragMessage)
|
||||
}
|
||||
|
||||
/// 04-interactions.md ▸ The trash's own sentence.
|
||||
nonisolated static let mixedTrashDragMessage =
|
||||
"Cards and lanes leave the trash separately \u{2014} restore one kind at a time"
|
||||
|
||||
/// Removes a dismissable row: a one-shot failure, a loss row, or a signpost. **An id that names
|
||||
/// an in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel"
|
||||
/// are different promises and a row that offers one must never quietly do the other.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -76,12 +76,12 @@ extension ItemContainer {
|
||||
// MARK: - The trash's two kinds, in one order
|
||||
|
||||
/// One row of the trash column — a card, or a trashed lane's opaque unit (03-board-ui.md § Trash,
|
||||
/// re-ruled 2026-07-29: "Lane rows and cards interleave in the one trash column purely by trash
|
||||
/// rank").
|
||||
/// re-ruled 2026-07-31: "Lane rows and cards interleave in the one trash column by `modified`
|
||||
/// descending").
|
||||
///
|
||||
/// **It exists so the interleave is written once.** The container's two kinds are two arrays on the
|
||||
/// snapshot, for the reason `BoardModel.trash` states — a trashed card is an ordinary card every
|
||||
/// card-shaped surface already reads, a trashed lane is an opaque row none of them may — but *rank
|
||||
/// card-shaped surface already reads, a trashed lane is an opaque row none of them may — but *column
|
||||
/// order* is a question about the container as a whole, and it is asked by the column that draws the
|
||||
/// rows, by the grammar that ranges and navigates over them, and by the path resolver that batches
|
||||
/// them. Three merges would be three chances to disagree about what "the row below this one" is.
|
||||
@@ -100,8 +100,28 @@ public enum TrashEntry: Identifiable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The rank that decides where this row sits among the others — the one field both kinds carry
|
||||
/// for the same purpose.
|
||||
/// **The stamp that decides where this row sits among the others** — the trash sorts by
|
||||
/// `modified` descending (01-storage-format.md § Deletion, re-ruled 2026-07-31), and the trash
|
||||
/// move is what writes it. `nil` for an entry whose mover skipped the restamp, which sorts below
|
||||
/// every dated sibling (`Ranks.isOrderedForTrash`).
|
||||
public var modified: Date? {
|
||||
switch self {
|
||||
case let .card(card): card.modified.value
|
||||
case let .lane(lane): lane.modified.value
|
||||
}
|
||||
}
|
||||
|
||||
/// The first tie-break's key — the title as the row draws it, `nil` when the entry has none.
|
||||
public var title: String? {
|
||||
switch self {
|
||||
case let .card(card): card.title.value
|
||||
case let .lane(lane): lane.title.value
|
||||
}
|
||||
}
|
||||
|
||||
/// The rank the entry is *carrying*, untouched by the trash move — its position among the lane
|
||||
/// (or board) siblings it left, which a restore returns it to. Deliberately **not** what orders
|
||||
/// this row: the trash is sorted by `modified` (above).
|
||||
public var order: Double {
|
||||
switch self {
|
||||
case let .card(card): card.order
|
||||
@@ -131,15 +151,17 @@ public enum TrashEntry: Identifiable, Sendable, Equatable {
|
||||
extension BoardModel {
|
||||
|
||||
/// The trash's rows, top to bottom — **the container's one order**, both kinds interleaved by
|
||||
/// rank (03-board-ui.md § Trash).
|
||||
/// `modified` descending (03-board-ui.md § Trash, re-ruled 2026-07-31: "The merged order is one
|
||||
/// derivation … a second implementation of 'the row below this one' is a bug by definition").
|
||||
///
|
||||
/// The tie-break is the folder name's, `Ranks.sortedForDisplay`'s own, which is what the loader
|
||||
/// already applied within each kind: two rows minted the same rank by two writers order the same
|
||||
/// way twice.
|
||||
/// The tail is `Ranks.isOrderedForTrash`'s — title case-insensitively, then folder name — which
|
||||
/// is what the loader already applied within each kind, so the merge of two sorted arrays and
|
||||
/// each array alone agree everywhere they overlap.
|
||||
public var trashEntries: [TrashEntry] {
|
||||
Ranks.sortedForDisplay(
|
||||
Ranks.sortedForTrash(
|
||||
trash.map(TrashEntry.card) + trashedLanes.map(TrashEntry.lane),
|
||||
order: \.order,
|
||||
modified: \.modified,
|
||||
title: \.title,
|
||||
name: { $0.id.rawValue }
|
||||
)
|
||||
}
|
||||
@@ -230,10 +252,10 @@ extension ItemPath {
|
||||
/// fails partway must fail the same way twice (`BoardStore.styleSubjects` makes the same choice
|
||||
/// for the same reason).
|
||||
///
|
||||
/// **The trash's order interleaves its two kinds by rank** (03-board-ui.md § Trash: "lane rows
|
||||
/// and cards interleave in the one trash column purely by trash rank"), which is why the walk is
|
||||
/// `trashEntries` rather than the two arrays concatenated: the column's order is the batch's
|
||||
/// order, and it is stated in exactly one place.
|
||||
/// **The trash's order interleaves its two kinds by `modified`** (03-board-ui.md § Trash: "lane
|
||||
/// rows and cards interleave in the one trash column by `modified` descending"), which is why the
|
||||
/// walk is `trashEntries` rather than the two arrays concatenated: the column's order is the
|
||||
/// batch's order, and it is stated in exactly one place.
|
||||
///
|
||||
/// Ids the container does not hold are simply absent, which is every caller's standing posture:
|
||||
/// a selection the next reload will drop writes nothing rather than being refused.
|
||||
|
||||
@@ -58,14 +58,20 @@ public enum ClickModifier: Sendable, Equatable {
|
||||
/// function of the click, the current selection, the anchor, and the snapshot
|
||||
/// (`SelectionGrammarTests`).
|
||||
///
|
||||
/// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is
|
||||
/// homogeneous on **two** axes — cards XOR lanes (§ Selection) and board XOR trash (§ The trash's
|
||||
/// "single container rule replacing the old liveness law") — and since lanes rejoined the trash
|
||||
/// (2026-07-29) the kind axis simply reaches into the second container too: "a trash selection is
|
||||
/// either cards or lane rows, kind-homogeneous like the live board's own grammar". Both axes are a
|
||||
/// property of what a *click* is allowed to produce, so no outcome below is ever mixed: a modifier
|
||||
/// that would cross an axis degrades to a replace, which is the only answer that keeps the invariant
|
||||
/// true without silently dropping what the user asked for.
|
||||
/// **The container is the invariant, and it is enforced here or nowhere.** A selection never mixes
|
||||
/// trash rows with board items (§ The trash's "single container rule replacing the old liveness
|
||||
/// law"), and *on the board* it is also cards XOR lanes (§ Selection). **Inside the trash it is
|
||||
/// kind-blind** (re-ruled 2026-07-31, superseding the lanes-rejoin pass's kind-homogeneous trash
|
||||
/// grammar): "within the trash cards and lane rows select together — clicks, ⇧-click ranges,
|
||||
/// ⇧-arrow extension, and the rubber band all sweep every row". The kind axis therefore stops at the
|
||||
/// container boundary rather than reaching through it, and the guard the trash used to need moved to
|
||||
/// the exits — the mixed-payload drop refusal and ⌘C/⌘X validation (§ The trash), since "inside the
|
||||
/// trash the only verbs are Delete and the restore paths, so upstream homogeneity bought nothing the
|
||||
/// exits don't".
|
||||
///
|
||||
/// What has not changed is what a modifier does when it *would* cross an axis that still stands: it
|
||||
/// degrades to a replace, which is the only answer that keeps the invariant true without silently
|
||||
/// dropping what the user asked for.
|
||||
///
|
||||
/// **Pure, for `NewCardTarget`'s reason**: the branches become lines of test rather than gestures to
|
||||
/// drive, and the four surfaces that clicks arrive on (card face, lane header, lane empty space,
|
||||
@@ -157,14 +163,20 @@ public enum SelectionGrammar {
|
||||
)
|
||||
}
|
||||
|
||||
/// **⌘-click toggles** — but only *within* a homogeneous set. Crossing either axis (a card
|
||||
/// clicked while lanes are selected, a trash card clicked while board cards are) is not a mixed
|
||||
/// selection and not a refusal: it is a **replace**, the same outcome a plain click would give,
|
||||
/// because the click unambiguously names a new set of one.
|
||||
/// **⌘-click toggles** — but only *within* a set it can legally join. Crossing the container (a
|
||||
/// trash row clicked while board cards are selected) or, **on the board**, the kind (a card
|
||||
/// clicked while lanes are selected) is not a mixed selection and not a refusal: it is a
|
||||
/// **replace**, the same outcome a plain click would give, because the click unambiguously names
|
||||
/// a new set of one.
|
||||
///
|
||||
/// **Inside the trash the kind clause simply does not apply** (04-interactions.md ▸ The trash,
|
||||
/// re-ruled 2026-07-31): a ⌘-click on a lane row extends a set of trash cards, because there
|
||||
/// "cards and lane rows select together". The container clause is untouched.
|
||||
///
|
||||
/// The current kind is derived from the snapshot rather than remembered (`kind(of:in:)`); a
|
||||
/// selection whose members all name nothing the board renders counts as empty, so a ⌘-click
|
||||
/// after a foreign delete starts a fresh set rather than extending a ghost.
|
||||
/// after a foreign delete starts a fresh set rather than extending a ghost — which is why the
|
||||
/// call stands even in the trash, where its *answer* no longer gates anything.
|
||||
private static func command(
|
||||
_ target: SelectionTarget,
|
||||
selection: ItemReferenceSet,
|
||||
@@ -172,7 +184,7 @@ public enum SelectionGrammar {
|
||||
) -> Outcome {
|
||||
guard selection.container == target.container,
|
||||
let current = kind(of: selection, in: snapshot),
|
||||
current == target.kind
|
||||
target.container == .trash || current == target.kind
|
||||
else {
|
||||
return plain(target, selection: selection, togglesOnRepeat: false)
|
||||
}
|
||||
@@ -196,8 +208,10 @@ public enum SelectionGrammar {
|
||||
/// origin rather than walking it along.
|
||||
///
|
||||
/// The anchor is valid **iff both it and the target sit in the same order list** — which folds
|
||||
/// the nil anchor, the vanished anchor, and every axis crossing into one test, since a list is
|
||||
/// exactly one (container, kind) pair. An invalid anchor makes the click a plain one, never a no-op:
|
||||
/// the nil anchor, the vanished anchor, and every standing axis crossing into one test, since a
|
||||
/// list is one container, and on the board one kind of it (`order(of:in:)`; inside the trash the
|
||||
/// list is the whole column, so a range from a card to a lane row is an ordinary range).
|
||||
/// An invalid anchor makes the click a plain one, never a no-op:
|
||||
/// the keyboard's ⇧-arrow goes inert at a boundary because its next step is ambiguous, while a
|
||||
/// click names an unambiguous target and so always has something to do.
|
||||
private static func shift(
|
||||
@@ -235,8 +249,8 @@ public enum SelectionGrammar {
|
||||
/// pointer and the keyboard to disagree about what a range is.
|
||||
///
|
||||
/// **`nil` means the two do not share a list**, which folds the vanished endpoint, the nil
|
||||
/// anchor's caller-side absence, and every axis crossing into one test — a list is exactly one
|
||||
/// (container, kind) pair. The callers differ on what they do with that: a click degrades to a
|
||||
/// anchor's caller-side absence, and every standing axis crossing into one test. The callers
|
||||
/// differ on what they do with that: a click degrades to a
|
||||
/// plain click (it names an unambiguous target), while a ⇧-arrow goes inert (its next step is
|
||||
/// ambiguous).
|
||||
///
|
||||
@@ -276,21 +290,27 @@ public enum SelectionGrammar {
|
||||
/// the opaque unit's own (03-board-ui.md § Trash: "The row matches the search filter by lane
|
||||
/// title only") — a trashed lane is a row in a column, not a container a query can empty.
|
||||
///
|
||||
/// **Both trash lists are kind-narrowed slices of one order** (`BoardModel.trashEntries`,
|
||||
/// 04-interactions.md ▸ The trash: "⇧-click ranges skip rows of the other kind"), which is what
|
||||
/// makes a range skip the other kind rather than needing a rule that says so: a list is exactly
|
||||
/// one (container, kind) pair, and the rows in between simply are not in it.
|
||||
/// **The trash has one list, whatever the kind** (04-interactions.md ▸ The trash, re-ruled
|
||||
/// 2026-07-31 — superseding the kind-narrowed slices this returned while the trash's grammar was
|
||||
/// kind-homogeneous): "⇧-click ranges … sweep every row". So a trash range walks
|
||||
/// `BoardModel.trashEntries` — the column as drawn — and picks up rows of both kinds between its
|
||||
/// endpoints, which is the ruling implemented as an absence rather than as a clause. The `kind`
|
||||
/// argument is simply not consulted there; on the board it still names one of two lists, because
|
||||
/// the live board is still cards XOR lanes.
|
||||
public static func order(
|
||||
of kind: SelectionKind,
|
||||
in container: ItemContainer,
|
||||
snapshot: BoardModel,
|
||||
filter: SearchFilter = .inactive
|
||||
) -> [ItemID] {
|
||||
switch (container, kind) {
|
||||
case (.board, .card): boardCards(in: snapshot, filter: filter)
|
||||
case (.board, .lane): lanes(in: snapshot)
|
||||
case (.trash, .card): trashCards(in: snapshot, filter: filter)
|
||||
case (.trash, .lane): trashLanes(in: snapshot, filter: filter)
|
||||
switch container {
|
||||
case .trash:
|
||||
return trashRows(in: snapshot, filter: filter)
|
||||
case .board:
|
||||
switch kind {
|
||||
case .card: return boardCards(in: snapshot, filter: filter)
|
||||
case .lane: return lanes(in: snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,12 +345,15 @@ public enum SelectionGrammar {
|
||||
}
|
||||
|
||||
/// The trash's cards, top to bottom — `snapshot.trash` itself, which the loader already sorted
|
||||
/// by `order` like any lane's children (03-board-ui.md § Trash: "the trash sorts by `order` like
|
||||
/// any lane", newest-first falling out of the ranks rather than a timestamp sort).
|
||||
/// by `modified` descending (03-board-ui.md § Trash, re-ruled 2026-07-31: "the trash sorts by
|
||||
/// `modified` descending", newest-first falling out of the stamp rather than a minted rank).
|
||||
///
|
||||
/// **Filtered like any lane** (03-board-ui.md § Trash: "shown, its cards participate in the
|
||||
/// filter exactly like any other card") — the same predicate `TrashLaneView` applies to the same
|
||||
/// cards, so a trash-side range walks exactly what the column is showing.
|
||||
/// filter exactly like any other card").
|
||||
///
|
||||
/// **Not the ranging grammar's list any more** (kind-blind trash selection, 2026-07-31 — see
|
||||
/// `order(of:in:)`): this is the kind-narrowed slice, kept for the consumers that genuinely mean
|
||||
/// "the trash's *cards*" and for the search suite that pins the predicate.
|
||||
public static func trashCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
|
||||
snapshot.trash.filter { filter.matches($0) }.map(\.id)
|
||||
}
|
||||
@@ -338,20 +361,21 @@ public enum SelectionGrammar {
|
||||
/// The trash's **lane rows**, top to bottom — the opaque units (03-board-ui.md § Trash, lanes
|
||||
/// rejoined 2026-07-29), filtered by title alone (`SearchFilter.matches(_ lane:)`).
|
||||
///
|
||||
/// Its own list rather than a kind flag on `trashCards` because that is what an order list *is*
|
||||
/// here: one (container, kind) pair, and the pair is what a ⇧-range walks. The rows' positions
|
||||
/// among the cards are `trashRows`' business.
|
||||
/// Its own list rather than a kind flag on `trashCards`, and for the same reason that one
|
||||
/// survives: some consumers mean the rows of one kind. The rows' positions among the cards are
|
||||
/// `trashRows`' business.
|
||||
public static func trashLanes(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
|
||||
snapshot.trashedLanes.filter { filter.matches($0) }.map(\.id)
|
||||
}
|
||||
|
||||
/// **Every row the trash column shows, both kinds, in rank order** — what *navigation* walks
|
||||
/// (04-interactions.md ▸ The trash: "inside, plain arrows walk every row, card and lane row
|
||||
/// alike (navigation crosses kinds)").
|
||||
/// **Every row the trash column shows, both kinds, in the column's own order** — what
|
||||
/// *everything* in the trash walks (04-interactions.md ▸ The trash: "inside, plain arrows walk
|
||||
/// every row, card and lane row alike", and since 2026-07-31 the ranging grammar too: "clicks,
|
||||
/// ⇧-click ranges, ⇧-arrow extension, and the rubber band all sweep every row").
|
||||
///
|
||||
/// The deliberate counterpart to the two lists above: extension and ranging are per-kind, so
|
||||
/// they stop at a kind boundary, while navigation is over the column as drawn and crosses it.
|
||||
/// One merge for both — `BoardModel.trashEntries`.
|
||||
/// Navigation and ranging read the same sequence, which is what the kind-blind ruling bought:
|
||||
/// there is no longer a per-kind list that could disagree with the column about "the row below
|
||||
/// this one". One merge for all of it — `BoardModel.trashEntries`.
|
||||
public static func trashRows(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
|
||||
snapshot.trashEntries.filter { filter.matches($0) }.map(\.id)
|
||||
}
|
||||
@@ -360,15 +384,19 @@ public enum SelectionGrammar {
|
||||
|
||||
/// Which level the selection holds, or `nil` when it holds nothing its container renders.
|
||||
///
|
||||
/// **Any member answers, because the set is homogeneous** — but the walk is the snapshot's order
|
||||
/// rather than the set's iteration order, so the answer is deterministic even for a set that
|
||||
/// somehow was not. Members that name nothing are ignored, and a set of only such members reads
|
||||
/// as empty: a selection the next reload will drop must not decide what a click does now.
|
||||
/// **On the board any member answers, because the set is homogeneous there** — but the walk is
|
||||
/// the snapshot's order rather than the set's iteration order, so the answer is deterministic
|
||||
/// even for a set that somehow was not. Members that name nothing are ignored, and a set of only
|
||||
/// such members reads as empty: a selection the next reload will drop must not decide what a
|
||||
/// click does now.
|
||||
///
|
||||
/// **The trash answers for both kinds** (04-interactions.md ▸ The trash, lanes rejoined
|
||||
/// 2026-07-29: "a trash selection is either cards or lane rows, kind-homogeneous like the live
|
||||
/// board's own grammar"), walked in the column's own rank order so a set that somehow held both
|
||||
/// answers by what is topmost rather than by array iteration order.
|
||||
/// **In the trash it answers for the topmost row, and a mixed set is legal** (04-interactions.md
|
||||
/// ▸ The trash, re-ruled 2026-07-31 — trash selection is kind-blind). Two callers still want it
|
||||
/// there and neither is asking about homogeneity: the ⌘-click branch uses it as a liveness test
|
||||
/// ("does this selection still name anything"), and the clipboard's capture wants the payload's
|
||||
/// kind — which is sound precisely because Cut and Copy are validated against
|
||||
/// `mixesKinds(_:in:)` first. Anything that needs to know whether the set is of one kind asks
|
||||
/// that, never this.
|
||||
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
|
||||
guard !selection.isEmpty else { return nil }
|
||||
switch selection.container {
|
||||
@@ -383,6 +411,45 @@ public enum SelectionGrammar {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Whether the selection names rows of both kinds** — the predicate the *exits* are validated
|
||||
/// against now that the trash's selection grammar is kind-blind (04-interactions.md ▸ The trash,
|
||||
/// ruled 2026-07-31: "The guard moves to the exits (the mixed-payload drop refusal and ⌘C/⌘X
|
||||
/// validation)").
|
||||
///
|
||||
/// Only the trash can answer `true`: the live board's grammar is still cards XOR lanes, and its
|
||||
/// branch is a walk rather than a `false` so a caller cannot be misled by a set some future
|
||||
/// gesture built wrongly.
|
||||
///
|
||||
/// Rows the container no longer holds are ignored, like everywhere else here — a selection whose
|
||||
/// lane row a foreign purge took is a single-kind selection now, and greying out ⌘C for a ghost
|
||||
/// would be a refusal the user cannot see the reason for.
|
||||
public static func mixesKinds(_ selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
|
||||
guard !selection.isEmpty else { return false }
|
||||
var seen: SelectionKind?
|
||||
switch selection.container {
|
||||
case .trash:
|
||||
for entry in snapshot.trashEntries where selection.ids.contains(entry.id) {
|
||||
guard let seen else {
|
||||
seen = entry.kind
|
||||
continue
|
||||
}
|
||||
if seen != entry.kind { return true }
|
||||
}
|
||||
case .board:
|
||||
for lane in snapshot.lanes {
|
||||
if selection.ids.contains(lane.id) {
|
||||
if seen == .card { return true }
|
||||
seen = .lane
|
||||
}
|
||||
if lane.cards.contains(where: { selection.ids.contains($0.id) }) {
|
||||
if seen == .lane { return true }
|
||||
seen = .card
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Successor on delete
|
||||
|
||||
/// What ⌫ selects after deleting `ids` — 04-interactions.md ▸ The map's Finder-style
|
||||
@@ -407,7 +474,7 @@ public enum SelectionGrammar {
|
||||
/// staged by place"): `container` says which side the gesture ran on, and the trash walks its own
|
||||
/// ordered rows exactly as a lane walks its own cards. The permanent delete is as deliberate an
|
||||
/// act as the move-to-trash, so it keeps the repeatable-keystroke property the rule exists for.
|
||||
/// **The trash's own successor crosses kinds** — an interim, and the branch below says why.
|
||||
/// **The trash's own successor is kind-blind** — ruled, and the branch below says so.
|
||||
///
|
||||
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
|
||||
/// reload-survival rule: "the selection just shrinks"), which is why this is called by
|
||||
@@ -430,15 +497,13 @@ public enum SelectionGrammar {
|
||||
let siblings: [ItemID]
|
||||
switch (container, kind) {
|
||||
case (.trash, _):
|
||||
// **The siblings are every row, both kinds** — the interim answer to an open gap. 04
|
||||
// ▸ The trash settles navigation (plain arrows cross kinds) and extension (⇧-arrows stop
|
||||
// at the kind boundary) for the trash's two kinds, but says nothing about which row the
|
||||
// *successor* lands on after a lane row is purged. Until that is ruled, this follows
|
||||
// navigation rather than extension: the successor is the next row down the column
|
||||
// whatever its kind, so a purge never strands the selection with nothing selected while
|
||||
// rows the user can see sit right below it. The conservative direction — the alternative
|
||||
// (kind-scoped siblings) clears the selection whenever the purged row was its kind's
|
||||
// last, which is a deselect wearing a successor's clothes.
|
||||
// **The siblings are every row, both kinds** (04-interactions.md ▸ The map, ruled
|
||||
// 2026-07-31 — ratifying what stood here as an interim): "In the trash the successor walk
|
||||
// is kind-blind: the next row of either kind, in the same all-rows order plain arrows
|
||||
// walk — a successor is a fresh singleton selection, so the landing violates no grammar,
|
||||
// and repeated ⌘⌫ empties a mixed trash without dead-ends". The alternative — kind-scoped
|
||||
// siblings — clears the selection whenever the purged row was its kind's last, which is a
|
||||
// deselect wearing a successor's clothes.
|
||||
siblings = trashRows(in: snapshot, filter: filter)
|
||||
case (.board, .lane):
|
||||
siblings = lanes(in: snapshot)
|
||||
@@ -489,16 +554,15 @@ public struct MarqueeTarget: Sendable, Equatable {
|
||||
/// - **The band stays on the side of the boundary it started on** (▸ The trash), which is why
|
||||
/// `container` is a parameter rather than something derived from what the rect happens to touch: a
|
||||
/// band begun on the board and dragged over the trash column selects board cards and nothing else.
|
||||
/// - **The band never selects lanes** (§ Selection gives it to cards: "click-drag rubber-bands
|
||||
/// across lanes" — across them, not over them). A live lane is never registered as a target at
|
||||
/// all; a **trashed lane row is**, because the arrows navigate by the same frames
|
||||
/// (`NavigationMath`) and the band's begin guard reads them too — so the kind filter below is
|
||||
/// load-bearing rather than belt over braces.
|
||||
///
|
||||
/// **The kind filter is the whole of the rule, in both containers**: "the rubber band stays on the
|
||||
/// side it started on and selects cards only (as the board marquee does); lane rows join by click
|
||||
/// grammar" (04-interactions.md ▸ The trash, re-affirmed 2026-07-29). A band never has to break a
|
||||
/// tie between a card and a lane row, because a lane row is not in the answer whatever it sweeps.
|
||||
/// - **On the board the band never selects lanes** (§ Selection gives it to cards: "click-drag
|
||||
/// rubber-bands across lanes" — across them, not over them). A live lane is never registered as a
|
||||
/// target at all, so that half is true twice over.
|
||||
/// - **In the trash it sweeps every row** (04-interactions.md ▸ The trash, re-ruled 2026-07-31 —
|
||||
/// superseding the card-only band this enforced while the trash's grammar was kind-homogeneous):
|
||||
/// "the rubber band [sweeps] every row (the band's full-height backdrop covers both kinds)". A
|
||||
/// trashed lane row is registered as a target — the arrows navigate by the same frames
|
||||
/// (`NavigationMath`) and the band's begin guard reads them — so dropping the kind filter on that
|
||||
/// side is the whole of the change.
|
||||
public enum MarqueeMath {
|
||||
|
||||
/// The ids `rect` sweeps.
|
||||
@@ -509,7 +573,13 @@ public enum MarqueeMath {
|
||||
) -> Set<ItemID> {
|
||||
Set(
|
||||
targets.lazy
|
||||
.filter { $0.container == container && $0.kind == .card && rect.intersects($0.frame) }
|
||||
.filter { target in
|
||||
guard target.container == container, rect.intersects(target.frame) else {
|
||||
return false
|
||||
}
|
||||
// Kind-blind in the trash, card-only on the board.
|
||||
return container == .trash || target.kind == .card
|
||||
}
|
||||
.map(\.id)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ import Foundation
|
||||
/// returning-card count, a deterministic `deleted`-timestamp sort, and a paths function that had to
|
||||
/// restate which items were addressable. All of it is gone. A trashed card is "an ordinary card in a
|
||||
/// special place", so the trash's contents *are* `snapshot.trash` and `snapshot.trashedLanes` —
|
||||
/// already parsed by the loader, already in `order` display order, already newest-first because
|
||||
/// every arrival mints a rank above the current top. There is nothing to derive, and no second
|
||||
/// definition to keep in step with the loader's.
|
||||
/// already parsed by the loader, already in the column's own order, already newest-first because
|
||||
/// every arrival stamps `modified` and the container sorts by it descending (re-ruled 2026-07-31).
|
||||
/// There is nothing to derive, and no second definition to keep in step with the loader's.
|
||||
///
|
||||
/// What genuinely remains is what the *commands* need and no view can answer: the two purge
|
||||
/// confirmations' phrasing — which since lanes rejoined the trash (2026-07-29) has to **count the
|
||||
|
||||
@@ -63,8 +63,13 @@ enum AgentGuide {
|
||||
/// `modified-by` like any content edit, the trash move included, since it's the same rule and
|
||||
/// not a special case — and the card-level `attachments` claimed name
|
||||
/// (01-storage-format.md § Fractal layout ▸ Rules, "level-uniform"): that name belongs to the
|
||||
/// app's own folder, so a *file* by that name is a defect the app displaces on sight.
|
||||
static let version = 7
|
||||
/// app's own folder, so a *file* by that name is a defect the app displaces on sight. **v8
|
||||
/// retires the trash's arrival rank** (01-storage-format.md § Deletion and 03-board-ui.md ▸
|
||||
/// Trash, re-ruled 2026-07-31; 08-agent-integration.md's own line): the trash sorts by `modified`
|
||||
/// descending, so there is no rank to mint on the way in — the guide's smallest-`order`-minus-1024
|
||||
/// formula is replaced by "restamp `modified`, leave `order` alone", which is the same stamp
|
||||
/// discipline v7 already taught, now doing the ordering as well.
|
||||
static let version = 8
|
||||
|
||||
// MARK: - The version marker
|
||||
|
||||
@@ -450,12 +455,14 @@ enum AgentGuide {
|
||||
- **Delete a card or a lane = move its folder into `<board>/.trash/`**:
|
||||
`mv <lane>/<card-uuid> <board>/.trash/`, or `mv <lane-uuid>
|
||||
<board>/.trash/` for a whole lane (create `.trash/` if missing). A lane
|
||||
travels with its cards inside it. Arrivals go on top: set the moved
|
||||
item's `order` to the smallest `order` already in `.trash/` minus 1024
|
||||
(empty trash: any number). It's a container change like any other move
|
||||
(Moving and reordering above): stamp `modified` and re-stamp
|
||||
`modified-by`. Restore is the same move in reverse — a card into a lane,
|
||||
a lane back to board root, with a fresh `order`, stamped the same way.
|
||||
travels with its cards inside it. It's a container change like any other
|
||||
move (Moving and reordering above): stamp `modified` and re-stamp
|
||||
`modified-by` — and here the stamp is also the position. **The trash
|
||||
sorts by `modified`, newest first**, so a restamped arrival lands on top;
|
||||
there is no rank to mint, and you should leave `order` exactly as it is —
|
||||
it rides along for the restore. Restore is the same move in reverse — a
|
||||
card into a lane, a lane back to board root, with a fresh `order`,
|
||||
stamped the same way.
|
||||
- **Stamp `kind: lane` when you trash a lane that lacks it.** `.trash/` is
|
||||
flat, so an empty lane folder looks exactly like a card folder; the `kind`
|
||||
value is what tells them apart in there.
|
||||
|
||||
@@ -317,8 +317,10 @@ public enum BoardLoader: Sendable {
|
||||
var trash: [Card] = []
|
||||
var trashedLanes: [TrashedLane] = []
|
||||
/// Every trash entry as the dedupe needs it, kind-blind — the container is one flat list to
|
||||
/// the identity rule, whatever the snapshot splits it into.
|
||||
var trashEntries: [(id: ItemID, title: String?, order: Double)] = []
|
||||
/// the identity rule, whatever the snapshot splits it into. Carried with the two keys the
|
||||
/// container's order is stated in (`Ranks.isOrderedForTrash`), never `order`: the trash is
|
||||
/// sorted by `modified` descending since 2026-07-31.
|
||||
var trashEntries: [(id: ItemID, title: String?, modified: Date?)] = []
|
||||
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
||||
for entryURL in trashCandidates(in: boardRoot) {
|
||||
let entryName = entryURL.lastPathComponent
|
||||
@@ -360,7 +362,7 @@ public enum BoardLoader: Sendable {
|
||||
)
|
||||
let id = ItemID(rawValue: entryName)
|
||||
trashKinds[id] = kind
|
||||
trashEntries.append((id: id, title: document.title.value, order: order))
|
||||
trashEntries.append((id: id, title: document.title.value, modified: document.modified.value))
|
||||
|
||||
switch kind {
|
||||
case .lane:
|
||||
@@ -372,6 +374,7 @@ public enum BoardLoader: Sendable {
|
||||
id: id,
|
||||
schema: schema,
|
||||
title: document.title,
|
||||
modified: document.modified,
|
||||
order: order,
|
||||
heldCards: children().count,
|
||||
document: document
|
||||
@@ -407,12 +410,19 @@ public enum BoardLoader: Sendable {
|
||||
// Display order is settled here rather than in the `BoardModel` call below, because the
|
||||
// dedupe's last tie-break *is* traversal order and the rule needs the occurrences in it.
|
||||
let orderedLanes = Ranks.sortedForDisplay(walkedLanes, order: \.order, name: \.name)
|
||||
let orderedTrash = Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue })
|
||||
let orderedTrashedLanes = Ranks.sortedForDisplay(trashedLanes, order: \.order, name: { $0.id.rawValue })
|
||||
// The trash's own display order, **both kinds at once** — the column interleaves them by rank
|
||||
// **The trash's arrays are sorted by `modified` descending**, not by `order` (01-storage-format.md
|
||||
// § Deletion, re-ruled 2026-07-31): the trash move rewrites no rank, so the stamp *is* the
|
||||
// position. Each kind is sorted by the same comparator the merged `BoardModel.trashEntries`
|
||||
// applies, which is what makes a kind-narrowed slice of the column agree with the column.
|
||||
let orderedTrash = Ranks.sortedForTrash(
|
||||
trash, modified: { $0.modified.value }, title: { $0.title.value }, name: { $0.id.rawValue })
|
||||
let orderedTrashedLanes = Ranks.sortedForTrash(
|
||||
trashedLanes, modified: { $0.modified.value }, title: { $0.title.value }, name: { $0.id.rawValue })
|
||||
// The trash's own display order, **both kinds at once** — the column interleaves them
|
||||
// (03-board-ui.md § Trash), and the dedupe's last tie-break is stated in traversal order, so
|
||||
// the two kinds are merged before the rule sees them rather than after.
|
||||
let orderedTrashEntries = Ranks.sortedForDisplay(trashEntries, order: \.order, name: { $0.id.rawValue })
|
||||
let orderedTrashEntries = Ranks.sortedForTrash(
|
||||
trashEntries, modified: \.modified, title: \.title, name: { $0.id.rawValue })
|
||||
let verdict = dedupeIdentities(
|
||||
inBoardAt: boardRoot,
|
||||
lanes: orderedLanes,
|
||||
@@ -496,7 +506,7 @@ public enum BoardLoader: Sendable {
|
||||
private static func dedupeIdentities(
|
||||
inBoardAt root: URL,
|
||||
lanes: [WalkedLane],
|
||||
trash: [(id: ItemID, title: String?, order: Double)],
|
||||
trash: [(id: ItemID, title: String?, modified: Date?)],
|
||||
historyRanker: IdentityHistoryRanker?
|
||||
) -> IntegrityRules.DedupeVerdict {
|
||||
typealias Container = IntegrityRules.IdentityOccurrence.Container
|
||||
|
||||
@@ -118,16 +118,17 @@ public struct BoardModel: Sendable, Equatable {
|
||||
/// **The container's other kind is `trashedLanes`** (re-ruled 2026-07-29 — lanes trash too).
|
||||
/// The two are separate arrays rather than one list of a sum type because they are separate
|
||||
/// *things*: a trashed card is an ordinary card that every card-shaped surface already reads,
|
||||
/// and a trashed lane is an opaque row that none of them may. Interleaving the two by trash
|
||||
/// rank is a rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the
|
||||
/// one trash column purely by trash rank"), and both arrays carry the `order` that answers it.
|
||||
/// and a trashed lane is an opaque row that none of them may. Interleaving the two is a
|
||||
/// rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the one trash
|
||||
/// column by `modified` descending"), and both arrays carry the stamp that answers it —
|
||||
/// `BoardModel.trashEntries` is the one merge.
|
||||
///
|
||||
/// **Display order is `order` ascending, like any lane's cards** — `Ranks.sortedForDisplay`,
|
||||
/// same folder-name tie-break. Newest-first falls out of ordinary ranks rather than a
|
||||
/// timestamp sort: every arrival mints a rank *above* the current topmost
|
||||
/// (`Ranks.insertAtHead`), so the trash needs no sort rule of its own. There is deliberately
|
||||
/// no `deleted:` key on anything in here — a trashed card is an ordinary card in a special
|
||||
/// place.
|
||||
/// **Display order is `modified` descending** — `Ranks.sortedForTrash`, tie-broken by title then
|
||||
/// folder name (re-ruled 2026-07-31, retiring the arrival rank mint). The trash is the one
|
||||
/// container in the app whose sequence is not a rank sequence: the delete move stamps, and that
|
||||
/// stamp *is* the position, with each entry's `order` riding along untouched for its restore.
|
||||
/// There is deliberately no `deleted:` key on anything in here — a trashed card is an ordinary
|
||||
/// card in a special place.
|
||||
///
|
||||
/// Empty when `.trash/` is absent (the overwhelmingly common case — the folder is minted by
|
||||
/// the first delete), and empty when it holds nothing the loader recognizes as a card.
|
||||
@@ -281,9 +282,17 @@ public struct TrashedLane: Identifiable, Sendable, Equatable {
|
||||
public let schema: Int
|
||||
public let title: FieldValue<String>
|
||||
|
||||
/// Rank within the trash, ascending = top to bottom — the same required, strictly validated
|
||||
/// field a live lane carries (`Lane.order`), and what interleaves this row among the trash's
|
||||
/// cards. Newest-first falls out of it: every arrival mints a rank above the current topmost.
|
||||
/// **When the row entered the trash** — and therefore *where it sits*: the trash sorts by
|
||||
/// `modified` descending (01-storage-format.md § Deletion, re-ruled 2026-07-31), and the trash
|
||||
/// move is the container-changing write that stamps it. Missing on a foreign mover that skipped
|
||||
/// the restamp, which sorts it below every dated sibling (`Ranks.isOrderedForTrash`).
|
||||
public let modified: FieldValue<Date>
|
||||
|
||||
/// The lane's rank **among the board's lanes**, riding along untouched — the trash move rewrites
|
||||
/// no `order` at all, so this is still the strip position a restore would want and the value the
|
||||
/// undo of a restore puts back. It is deliberately *not* what orders this row in the column
|
||||
/// (`modified` is), and the same required, strictly validated field a live lane carries
|
||||
/// (`Lane.order`).
|
||||
public let order: Double
|
||||
|
||||
/// **How many cards the lane is holding** — the row's whole other half ("Doing — 5 cards").
|
||||
|
||||
@@ -94,9 +94,16 @@ public enum BoardWriter: Sendable {
|
||||
/// `nil`, the default, derives it from position (`IntegrityRules.placement`), and stamps
|
||||
/// nothing when position has no answer: a guessed kind on disk would be worse than an absent
|
||||
/// one, because the trash's discriminator trusts what it finds.
|
||||
/// - Parameter stampedAt: the value `modified` takes, for the **one** write whose stamp is not
|
||||
/// "now": the legacy tombstone migration, which stamps from the `deleted:` timestamp it is
|
||||
/// retiring so a board's real deletion order survives into the trash's `modified`-descending
|
||||
/// sort (01-storage-format.md § Deletion). `nil`, the default, is `Date()` — every other
|
||||
/// caller. It is a parameter rather than something the `edits` closure could do because the
|
||||
/// stamps deliberately run *after* `edits` and outrank it.
|
||||
public static func updateIndex(
|
||||
inItemFolder folder: URL,
|
||||
kind: IntegrityRules.ObjectKind? = nil,
|
||||
stampedAt: Date? = nil,
|
||||
operation: WriteOperation,
|
||||
edits: (inout FrontmatterDocument) -> Void
|
||||
) throws(BoardWriteError) {
|
||||
@@ -117,7 +124,7 @@ public enum BoardWriter: Sendable {
|
||||
// the container's own arrangement and leaves both provenance keys exactly as it found them —
|
||||
// a standing `modified-by` survives a reorder, which is the pairing 01 spells out.
|
||||
if !operation.rewritesOrderOnly {
|
||||
document.set(FrontmatterKeys.modified, to: .date(Date()))
|
||||
document.set(FrontmatterKeys.modified, to: .date(stampedAt ?? Date()))
|
||||
document.remove(FrontmatterKeys.modifiedBy)
|
||||
}
|
||||
|
||||
@@ -1036,15 +1043,20 @@ public enum BoardWriter: Sendable {
|
||||
/// **A folder with no `index.md` is not an offense** and is skipped: it is interrupted-create
|
||||
/// residue, the loader skips it too (`.missingIndex`), and there is no contract work to fail.
|
||||
///
|
||||
/// **Comment folders are deliberately outside this preflight** (added 2026-07-30 with the comment
|
||||
/// storage, and worth stating because it is a *narrowing* of a 2026-07-29 ruling): 01's copy
|
||||
/// transaction says "refuses whole, loudly, naming the offending item", and its enhanced-schema
|
||||
/// section says "**comment defects never refuse the board** — worst case is the stray posture …
|
||||
/// a broken leaf annotation must not brick a load; deliberate, proportionate divergence from card
|
||||
/// fail-fast". A ⌘V that refuses because one comment on one card inside a pasted lane has
|
||||
/// hand-broken frontmatter is that divergence read the other way round. So the walk stays
|
||||
/// `identityDescendants`' — cards and lanes — and a comment the contract cannot be applied to is
|
||||
/// copied verbatim with a log line instead (`stampCopiedComment`).
|
||||
/// **The preflight reaches comment depth** (01-storage-format.md § Frontmatter and § Enhanced
|
||||
/// schema, ruled 2026-07-31 — reversing the 2026-07-30 carve-out that kept comments out of it):
|
||||
/// "a comment whose frontmatter cannot take the stamp refuses the copy exactly like a card or
|
||||
/// lane — never-refuse … is a *load* posture, and a user-initiated copy is a transaction, not a
|
||||
/// load". The board still always loads with a broken annotation on it; the *gesture* may refuse,
|
||||
/// and refusing is what keeps the sever rule true — no copy carries a live `remote` claim or a
|
||||
/// stale `modified-by` because one comment could not be rewritten.
|
||||
///
|
||||
/// The walk is therefore `copyContractDescendants`' — every folder `remintDescendants` will
|
||||
/// materialize and `stampCopiedDescendant` will then rewrite, comments included — and the
|
||||
/// refusal names the comment's *card*, which is what `operation.withTitle` produces here: the
|
||||
/// enrichment reads the offending file's own title, and a comment has none, so the operation
|
||||
/// keeps the copy root's. (The error always carries the path, which is what points at the
|
||||
/// annotation itself.)
|
||||
///
|
||||
/// **Internal rather than `private`**: template instantiation preflights its own tree with this,
|
||||
/// for `remintDescendants`' reason — one definition of what a copy owes its descendants.
|
||||
@@ -1052,7 +1064,7 @@ public enum BoardWriter: Sendable {
|
||||
of folder: URL,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) {
|
||||
for descendant in identityDescendants(of: folder) {
|
||||
for descendant in copyContractDescendants(of: folder) {
|
||||
let indexURL = descendant.appendingPathComponent(BoardLoader.indexFileName)
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }
|
||||
let document = try readDocument(at: indexURL, operation: operation)
|
||||
@@ -1062,9 +1074,9 @@ public enum BoardWriter: Sendable {
|
||||
|
||||
/// Every **card or lane** beneath `folder`, depth first — `remintDescendants`' recursion with the
|
||||
/// renaming taken out, so the preflight and the remint cannot disagree about which folders a copy
|
||||
/// materializes as *items*. Comment folders are not here, by the carve-out
|
||||
/// `checkCopiedDescendantsAreStampable` states; it is also exactly the right reach for
|
||||
/// `stripCommentTrash`, since a thread lives under a card and nowhere else.
|
||||
/// materializes as *items*. Comment folders are not here (they hang off `comments/`, which is not
|
||||
/// identity-shaped), which is exactly the right reach for `stripCommentTrash`, since a thread
|
||||
/// lives under a card and nowhere else.
|
||||
private static func identityDescendants(of folder: URL) -> [URL] {
|
||||
var found: [URL] = []
|
||||
for child in childCandidates(of: folder) {
|
||||
@@ -1074,6 +1086,26 @@ public enum BoardWriter: Sendable {
|
||||
return found
|
||||
}
|
||||
|
||||
/// **Every folder below `folder` whose `index.md` the copy contract rewrites** — the items *and*
|
||||
/// their comments, which is `remintDescendants`' own reach read as a list.
|
||||
///
|
||||
/// The root's own thread is in it: `checkIndexIsRewritable` preflights the copy root itself, but
|
||||
/// nothing preflights the comments hanging off it, and the copy stamps those too. Two walks that
|
||||
/// disagreed about which folders a copy touches is exactly the drift a mid-flight failure after a
|
||||
/// clean preflight would be.
|
||||
private static func copyContractDescendants(of folder: URL) -> [URL] {
|
||||
func comments(of item: URL) -> [URL] {
|
||||
childCandidates(of: item.appendingPathComponent(
|
||||
IntegrityRules.commentsFolderName, isDirectory: true))
|
||||
}
|
||||
var found = comments(of: folder)
|
||||
for item in identityDescendants(of: folder) {
|
||||
found.append(item)
|
||||
found.append(contentsOf: comments(of: item))
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/// Stamps one copied folder below the root — **strictly**, since the preflight has already cleared
|
||||
/// the whole subtree (`checkCopiedDescendantsAreStampable`): an `index.md` that cannot be read or
|
||||
/// edited here is a disk failure between the two reads, not a shape to tolerate, and it fails the
|
||||
@@ -1087,6 +1119,12 @@ public enum BoardWriter: Sendable {
|
||||
/// A folder with **no `index.md`** is still skipped, and for a different reason entirely: there is
|
||||
/// nothing there to stamp (interrupted-create residue, which the loader skips too).
|
||||
///
|
||||
/// **A comment takes the same strict path as a card** (ruled 2026-07-31): the former lenient
|
||||
/// branch — stamp best-effort, log, copy verbatim on failure — retired with the preflight's
|
||||
/// comment carve-out, because the preflight now clears comments too and a failure here is a disk
|
||||
/// failure at either depth. `kind` is left to position (`IntegrityRules.placement`), which
|
||||
/// answers `.comment` for a folder under a card's `comments/`.
|
||||
///
|
||||
/// **Internal rather than `private`**, with `remintDescendants` and for its reason: an
|
||||
/// instantiated board's lanes and cards are stamped by this exact rule.
|
||||
static func stampCopiedDescendant(
|
||||
@@ -1098,35 +1136,11 @@ public enum BoardWriter: Sendable {
|
||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path) else { return }
|
||||
|
||||
// The one lenient branch, and the preflight's own carve-out read from the write side: a
|
||||
// comment was never checked, so a comment that cannot be stamped is a *tolerated* defect
|
||||
// rather than a disk failure — it copies verbatim, keeping whatever `remote` and
|
||||
// `modified-by` it carried, and says so in the log (01-storage-format.md § Enhanced schema,
|
||||
// "comment defects never refuse … tolerated, logged").
|
||||
guard !isCommentFolder(folder) else {
|
||||
do {
|
||||
try updateIndex(inItemFolder: folder, kind: .comment, operation: operation) { document in
|
||||
applyCopyContract(to: &document, stamps: stamps, now: now)
|
||||
}
|
||||
} catch {
|
||||
logger.warning(
|
||||
"\(folder.path, privacy: .public): copied comment left unstamped — \(error.description, privacy: .public)"
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try updateIndex(inItemFolder: folder, operation: operation) { document in
|
||||
applyCopyContract(to: &document, stamps: stamps, now: now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `folder` is a comment — its parent is a card's `comments/`. Position, like every other
|
||||
/// kind question here (`IntegrityRules.placement`).
|
||||
static func isCommentFolder(_ folder: URL) -> Bool {
|
||||
IntegrityRules.placement(ofFolder: folder) == .comment
|
||||
}
|
||||
|
||||
static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "writer")
|
||||
|
||||
// MARK: - The materialized trash
|
||||
@@ -1157,22 +1171,23 @@ public enum BoardWriter: Sendable {
|
||||
/// meet it here.
|
||||
/// 4. **Move the folder.** Nothing beneath it is read or rewritten, so `attachments/`, strays
|
||||
/// and every byte arrive unchanged, exactly as in an ordinary move.
|
||||
/// 5. **Rewrite `order` to `order`, and stamp.**
|
||||
/// 5. **Stamp** — and nothing else.
|
||||
///
|
||||
/// **`order` is the caller's, always** — deliberately not defaulted and deliberately not
|
||||
/// computed here. Entry is at the *top* ("every arrival … lands at the trash's topmost
|
||||
/// position, minting an `order` rank above the current top"), which is
|
||||
/// `Ranks.insertAtHead(ofVisible:)` over the trash's current ranks — a question about the
|
||||
/// *snapshot*, which the store holds and this stateless layer does not. Value-passing keeps
|
||||
/// the seam: the Writer takes a rank, the store computes it.
|
||||
/// **No rank is minted, and `order` is not rewritten at all** (01-storage-format.md § Deletion,
|
||||
/// re-ruled 2026-07-31, retiring the arrival rank mint; 03-board-ui.md § Trash): the trash sorts
|
||||
/// by `modified` descending, "and that stamp *is* the position — newest-first falls out with no
|
||||
/// `order` rewrite at all; a delete is a pure folder move plus the stamp, and the item's `order`
|
||||
/// key rides along untouched (which is also what undo's move-back restores for free)". The
|
||||
/// parameter this call used to take is gone with the rule, and so is the store-side ladder that
|
||||
/// computed it: there is no snapshot question left for the caller to answer.
|
||||
///
|
||||
/// **The `modified` stamp is the point, not a side effect** — and it needs no exception to earn
|
||||
/// it. The trash move changes the card's *container*, which is the whole predicate
|
||||
/// **The `modified` stamp is therefore the whole write** — and it needs no exception to earn it.
|
||||
/// The trash move changes the card's *container*, which is the whole predicate
|
||||
/// (`WriteOperation.rewritesOrderOnly`, refined 2026-07-30): "deletion is an edit to the item's
|
||||
/// story", so it stamps exactly as a cross-lane move does, and the stamp is what a future
|
||||
/// age-based auto-purge reads. It falls out of `updateIndex` here rather than being asked for,
|
||||
/// which is why there is nothing extra in step 5 — and why the reorders-don't-stamp rule needs no
|
||||
/// trash carve-out to coexist with this call.
|
||||
/// story", so it stamps exactly as a cross-lane move does. It falls out of `updateIndex` rather
|
||||
/// than being asked for, which is why step 5 carries no argument — and why the
|
||||
/// reorders-don't-stamp rule needs no trash carve-out to coexist with this call. Its second job
|
||||
/// is the `kind` the flat container needs, backfilled on the same touch.
|
||||
///
|
||||
/// **Collision inside `.trash/` is impossible by construction**, and it is checked anyway. The
|
||||
/// card is a resident of this very board, and board-wide uniqueness now spans lanes *and* the
|
||||
@@ -1186,14 +1201,12 @@ public enum BoardWriter: Sendable {
|
||||
@discardableResult
|
||||
public static func deleteCardToTrash(
|
||||
at cardFolder: URL,
|
||||
inBoard boardRoot: URL,
|
||||
order: Double
|
||||
inBoard boardRoot: URL
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
try moveIntoTrash(
|
||||
at: cardFolder,
|
||||
inBoard: boardRoot,
|
||||
kind: .card,
|
||||
order: order,
|
||||
operation: .delete(title: nil),
|
||||
removingLegacyKey: false
|
||||
)
|
||||
@@ -1208,12 +1221,12 @@ public enum BoardWriter: Sendable {
|
||||
///
|
||||
/// - **The guard is `checkIsLaneFolder`** — UUID-shaped directly under a board root, which
|
||||
/// refuses a card, a board root, a stray, and notably a folder already in `.trash/`.
|
||||
/// - **`kind: lane` is stamped**, not derived. The rank rewrite is a `updateIndex` on a folder
|
||||
/// that is by then *inside* `.trash/`, where position cannot answer and shape would answer
|
||||
/// *wrongly* for the one lane that most needs the key: an **empty** lane is shape-identical to
|
||||
/// a card (01's own "honest limit"). The caller knows what it moved, so it says so — which is
|
||||
/// also the backfill the ruling asks of this write ("`kind: lane` … backfilled on touch when
|
||||
/// absent … the trash move's rank mint included").
|
||||
/// - **`kind: lane` is stamped**, not derived. The stamping rewrite is an `updateIndex` on a
|
||||
/// folder that is by then *inside* `.trash/`, where position cannot answer and shape would
|
||||
/// answer *wrongly* for the one lane that most needs the key: an **empty** lane is
|
||||
/// shape-identical to a card (01's own "honest limit"). The caller knows what it moved, so it
|
||||
/// says so — which is also the backfill the ruling asks of this write ("`kind: lane` …
|
||||
/// backfilled on touch when absent … the trash move's `modified` stamp included").
|
||||
///
|
||||
/// **The subtree rides along untouched**: nothing beneath the lane is read or rewritten, so its
|
||||
/// cards, their `attachments/` and every stray arrive byte-identical and come back with it on
|
||||
@@ -1224,14 +1237,12 @@ public enum BoardWriter: Sendable {
|
||||
@discardableResult
|
||||
public static func deleteLaneToTrash(
|
||||
at laneFolder: URL,
|
||||
inBoard boardRoot: URL,
|
||||
order: Double
|
||||
inBoard boardRoot: URL
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
try moveIntoTrash(
|
||||
at: laneFolder,
|
||||
inBoard: boardRoot,
|
||||
kind: .lane,
|
||||
order: order,
|
||||
operation: .delete(title: nil),
|
||||
removingLegacyKey: false
|
||||
)
|
||||
@@ -1249,19 +1260,25 @@ public enum BoardWriter: Sendable {
|
||||
/// The removal is `FrontmatterDocument.remove`, so it takes **every** occurrence of the key:
|
||||
/// a hand-duplicated `deleted:` line cannot leave a twin behind that would re-migrate the card
|
||||
/// on the next load. Nothing else in the file is touched — unknown keys, comments, blank lines,
|
||||
/// line endings and the body are the same bytes they were, and `order` and the stamps are the
|
||||
/// only writes, exactly as for an ordinary delete.
|
||||
/// line endings and the body are the same bytes they were, and the stamps are the only other
|
||||
/// writes, exactly as for an ordinary delete.
|
||||
///
|
||||
/// **The stamp is the retiring key's own timestamp where it parses** (01-storage-format.md
|
||||
/// § Deletion, re-ruled 2026-07-31: "the migration move stamps `modified` **from the legacy
|
||||
/// `deleted:` timestamp** where parseable — the deletion time is when the card entered the
|
||||
/// trash, so real deletion order survives into the `modified`-descending sort — and from
|
||||
/// migration time otherwise"). It is the one write in the app whose `modified` is not "now",
|
||||
/// and it is why the legacy value is read *before* the move rather than left to the closure:
|
||||
/// `updateIndex`'s stamps deliberately outrank anything `edits` does.
|
||||
@discardableResult
|
||||
public static func migrateTombstonedCard(
|
||||
at cardFolder: URL,
|
||||
inBoard boardRoot: URL,
|
||||
order: Double
|
||||
inBoard boardRoot: URL
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
try moveIntoTrash(
|
||||
at: cardFolder,
|
||||
inBoard: boardRoot,
|
||||
kind: .card,
|
||||
order: order,
|
||||
operation: .migrateTombstone(title: nil),
|
||||
removingLegacyKey: true
|
||||
)
|
||||
@@ -1278,7 +1295,6 @@ public enum BoardWriter: Sendable {
|
||||
at itemFolder: URL,
|
||||
inBoard boardRoot: URL,
|
||||
kind: IntegrityRules.ObjectKind,
|
||||
order: Double,
|
||||
operation initialOperation: WriteOperation,
|
||||
removingLegacyKey: Bool
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
@@ -1293,7 +1309,17 @@ public enum BoardWriter: Sendable {
|
||||
case .lane: try checkIsLaneFolder(itemFolder, operation: operation)
|
||||
case .card, .board, .comment: try checkIsCardFolder(itemFolder, operation: operation)
|
||||
}
|
||||
operation = try checkIndexIsRewritable(inItemFolder: itemFolder, operation: operation)
|
||||
// `checkIndexIsRewritable`'s two checks, spelled out rather than called, for one reason: the
|
||||
// migration needs the *document* this read produces — the legacy `deleted:` timestamp it is
|
||||
// about to remove becomes the `modified` stamp (`migrateTombstonedCard`), and it has to be
|
||||
// read while the file is still at its old path.
|
||||
let sourceIndex = itemFolder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
let source = try readDocument(at: sourceIndex, operation: operation)
|
||||
operation = operation.withTitle(source.title.value)
|
||||
try checkEditable(source, at: sourceIndex, operation: operation)
|
||||
// Parseable legacy stamp → the deletion time it recorded; malformed, absent, or an ordinary
|
||||
// delete → `nil`, which is `updateIndex`'s "now".
|
||||
let stamp = removingLegacyKey ? source.deleted.value : nil
|
||||
|
||||
let trash = trashFolder(inBoard: boardRoot)
|
||||
do {
|
||||
@@ -1322,8 +1348,10 @@ public enum BoardWriter: Sendable {
|
||||
// absence where the item was, the shown-trash side sees an arrival where it went.
|
||||
EchoLedger.current?.recordMove(from: itemFolder, to: arrived)
|
||||
|
||||
try updateIndex(inItemFolder: arrived, kind: kind, operation: operation) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(order))
|
||||
// The whole of the arrival write: the stamp (which is the row's position), the `kind` the
|
||||
// flat container discriminates on, and — for the migration alone — the retiring key. `order`
|
||||
// is deliberately absent; it rides along as the item left it.
|
||||
try updateIndex(inItemFolder: arrived, kind: kind, stampedAt: stamp, operation: operation) { document in
|
||||
if removingLegacyKey {
|
||||
document.remove(FrontmatterKeys.deleted)
|
||||
}
|
||||
|
||||
@@ -143,6 +143,66 @@ enum Ranks: Sendable {
|
||||
items.sorted { isOrderedForDisplay($0, before: $1, order: order, name: name) }
|
||||
}
|
||||
|
||||
// MARK: - The trash's own order
|
||||
|
||||
/// The trash's order: **`modified` descending**, ties broken by title
|
||||
/// (case-insensitive), then folder name (01-storage-format.md § Deletion,
|
||||
/// re-ruled 2026-07-31, retiring the arrival rank mint; 03-board-ui.md §
|
||||
/// Trash: "the trash sorts by `modified` descending … Ties break by title
|
||||
/// (case-insensitive), then folder name — the deterministic tail").
|
||||
///
|
||||
/// **`order` is not consulted at all in here**, which is the whole of the
|
||||
/// ruling: a delete is a pure folder move plus the stamp, and the item's
|
||||
/// `order` key rides along untouched for its eventual restore. The trash is
|
||||
/// the one container in the app whose sequence is not a rank sequence.
|
||||
///
|
||||
/// **An undated entry sorts after every dated one** — the comment thread's
|
||||
/// own rule for a missing `created` ("ties and missing/malformed … sort
|
||||
/// after dated siblings"), read one container over: a stamp the app always
|
||||
/// writes is missing only on a hand-made or foreign entry, and a missing
|
||||
/// value must never outrank a real deletion. The tail then separates them
|
||||
/// exactly as it separates two equal stamps.
|
||||
///
|
||||
/// Titles compare with `localizedCaseInsensitiveCompare`, the same
|
||||
/// comparison the rest of the app's user-facing sorting uses, and an
|
||||
/// untitled entry compares as the empty string — it sorts first among its
|
||||
/// stamp-mates, which is deterministic and is all the tail owes.
|
||||
static func isOrderedForTrash<T>(
|
||||
_ lhs: T, before rhs: T,
|
||||
modified: (T) -> Date?, title: (T) -> String?, name: (T) -> String
|
||||
) -> Bool {
|
||||
let lhsModified = modified(lhs)
|
||||
let rhsModified = modified(rhs)
|
||||
if lhsModified != rhsModified {
|
||||
// Newest first; an absent stamp is older than every present one.
|
||||
switch (lhsModified, rhsModified) {
|
||||
case let (.some(left), .some(right)): return left > right
|
||||
case (.some, .none): return true
|
||||
case (.none, .some): return false
|
||||
case (.none, .none): break
|
||||
}
|
||||
}
|
||||
let lhsTitle = title(lhs) ?? ""
|
||||
let rhsTitle = title(rhs) ?? ""
|
||||
let comparison = lhsTitle.localizedCaseInsensitiveCompare(rhsTitle)
|
||||
guard comparison == .orderedSame else { return comparison == .orderedAscending }
|
||||
return name(lhs) < name(rhs)
|
||||
}
|
||||
|
||||
/// Sorts the trash's entries into column order — `isOrderedForTrash`'s rule,
|
||||
/// applied by the loader to each kind's array and by `BoardModel.trashEntries`
|
||||
/// to the merged sequence, so the two can never disagree about what "the row
|
||||
/// below this one" is (03-board-ui.md § Trash: "The merged order is one
|
||||
/// derivation").
|
||||
static func sortedForTrash<T>(
|
||||
_ items: [T],
|
||||
modified: (T) -> Date?,
|
||||
title: (T) -> String?,
|
||||
name: (T) -> String
|
||||
) -> [T] {
|
||||
items.sorted { isOrderedForTrash($0, before: $1, modified: modified, title: title, name: name) }
|
||||
}
|
||||
|
||||
// MARK: - Tombstone exclusion
|
||||
|
||||
/// `append(toVisible:)`, ignoring tombstoned siblings. Tombstones are
|
||||
|
||||
@@ -535,6 +535,23 @@ struct BoardDropContext {
|
||||
cancelDrop()
|
||||
return false
|
||||
}
|
||||
// **A mixed-kind drag never leaves the trash** (04-interactions.md ▸ The trash, ruled
|
||||
// 2026-07-31 with kind-blind trash selection): "pickup is allowed — the selection is legal —
|
||||
// but every out-of-trash drop target refuses the mixed payload, and the release surfaces a
|
||||
// notice explaining the rule … the refused drag ends like any refusal, rows staying put".
|
||||
//
|
||||
// One guard for every out-of-trash target, because every one of them funnels through this
|
||||
// commit — the lane masonries, the strip, the cross-board arrivals, and the window fallback
|
||||
// alike. A *within*-trash release cannot reach it: the column declines `.trash`-container
|
||||
// sessions outright (`TrashDrop.accepts`), so a trash-origin drag never holds a `.trash`
|
||||
// proposal, and "within-trash drops stay inert" needs no clause here.
|
||||
//
|
||||
// The notice is the destination board's strip, which is where the user is looking.
|
||||
guard !(session.container == .trash && session.mixesKinds) else {
|
||||
store.banners.postMixedTrashDrag()
|
||||
cancelDrop()
|
||||
return false
|
||||
}
|
||||
let ids = survivors.map { session.members[$0] }
|
||||
let folders = survivors.map { session.folders[$0] }
|
||||
let within = DragLocality.isSameBoard(sourceRoot, store.rootURL)
|
||||
|
||||
@@ -893,14 +893,20 @@ struct BoardView: View {
|
||||
return .handled
|
||||
}
|
||||
|
||||
/// **⇧-arrow extends, and stops at both boundaries** (04 ▸ The trash, settled): "a ⇧-arrow whose
|
||||
/// next step would cross from live cards into the trash (or back), or from card entries onto a
|
||||
/// lane entry within it, is simply inert".
|
||||
/// **⇧-arrow extends, and stops at the container boundary** (04 ▸ The trash: "⇧-arrow extension
|
||||
/// still stops at the container boundary") — a ⇧-arrow whose next step would cross from live
|
||||
/// cards into the trash, or back, is simply inert.
|
||||
///
|
||||
/// **The kind boundary is no longer one of the stops inside the trash** (re-ruled 2026-07-31):
|
||||
/// "⇧-click ranges, ⇧-arrow extension … all sweep every row" there, so a ⇧-arrow from a trash
|
||||
/// card onto a lane row extends across it. On the board the kind test stands — the live grammar
|
||||
/// is still cards XOR lanes.
|
||||
///
|
||||
/// The *step* that would cross is what goes inert — the crossing item is never stepped over in
|
||||
/// search of a legal one, because that would silently drop the held range for a longer reach
|
||||
/// than the user asked for. So the nearest neighbour is computed **unrestricted** and then
|
||||
/// tested: a different side or a different kind means this press does nothing at all.
|
||||
/// tested: a different side (or, on the board, a different kind) means this press does nothing
|
||||
/// at all.
|
||||
private func extend(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
|
||||
guard let origin = marqueeTargets.targets[head],
|
||||
let nextID = NavigationMath.nearest(
|
||||
@@ -910,7 +916,7 @@ struct BoardView: View {
|
||||
),
|
||||
let next = marqueeTargets.targets[nextID],
|
||||
next.container == origin.container,
|
||||
next.kind == origin.kind
|
||||
next.container == .trash || next.kind == origin.kind
|
||||
else { return .handled }
|
||||
|
||||
// An extension with no anchor makes one of where it started — the keyboard's equivalent of
|
||||
|
||||
@@ -356,12 +356,19 @@ struct CardFaceView: View {
|
||||
/// every other card drop uses, committed by the same `moveCards`.
|
||||
///
|
||||
/// **Multi-drag carries the whole trash selection**, in the column's own order — the order the
|
||||
/// rows are drawn in, which is `order` ascending like any lane's.
|
||||
/// rows are drawn in, which since 2026-07-31 is `modified` descending (03 § Trash).
|
||||
///
|
||||
/// **A kind-blind selection can span both kinds, and the session says so** (04-interactions.md ▸
|
||||
/// The trash, ruled 2026-07-31): the lane rows in it cannot ride a `.cards` session, so rather
|
||||
/// than let them fall silently out of the payload the flag travels and the *drop* refuses with
|
||||
/// the notice (`DragSession.mixesKinds`). Pickup stays allowed — the selection is legal.
|
||||
private func startTrashCardDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let ids = draggedIDs
|
||||
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
|
||||
guard !rows.isEmpty else { return NSItemProvider() }
|
||||
let mixesKinds = SelectionGrammar.mixesKinds(
|
||||
ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot)
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
@@ -381,7 +388,8 @@ struct CardFaceView: View {
|
||||
folders: payload.folders,
|
||||
heights: rows.map { drops.registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight },
|
||||
container: .trash,
|
||||
source: store
|
||||
source: store,
|
||||
mixesKinds: mixesKinds
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
@@ -60,12 +60,11 @@ enum TrashDrop {
|
||||
|
||||
/// The row the shadow takes, always: **the topmost**.
|
||||
///
|
||||
/// Not arbitrary, and the ranks are what make it honest: "every trash arrival mints a rank
|
||||
/// above the current top" (04-interactions.md ▸ The trash), so a fresh delete genuinely lands on
|
||||
/// top. The drop
|
||||
/// therefore still lands exactly where the shadow shows — the one positional promise every other
|
||||
/// drop in this app makes — while being the only proposal on the board the *pointer* does not
|
||||
/// choose.
|
||||
/// Not arbitrary, and the stamp is what makes it honest: "every trash arrival stamps `modified`
|
||||
/// and the trash sorts newest-first by that stamp" (04-interactions.md ▸ The trash, re-ruled
|
||||
/// 2026-07-31), so a fresh delete genuinely lands on top. The drop therefore still lands exactly
|
||||
/// where the shadow shows — the one positional promise every other drop in this app makes —
|
||||
/// while being the only proposal on the board the *pointer* does not choose.
|
||||
static let landingIndex = 0
|
||||
|
||||
/// Whether the shown trash takes this session — the whole of the gate, and every clause is a
|
||||
@@ -284,6 +283,18 @@ final class DragSession {
|
||||
/// and that is the whole of what makes it a restore (04-interactions.md ▸ The trash).
|
||||
private(set) var container: ItemContainer = .board
|
||||
|
||||
/// **Whether the selection this drag was picked up from spanned both kinds** — only ever true in
|
||||
/// the trash, whose selection went kind-blind (04-interactions.md ▸ The trash, ruled 2026-07-31).
|
||||
///
|
||||
/// A session carries items of exactly one `kind` — the pasteboard type is per-kind and a
|
||||
/// `DragPayload` names one — so a mixed selection dragged from a trash row starts a session over
|
||||
/// *its* kind and silently leaves the other behind. That silence is what this flag exists to
|
||||
/// prevent: "pickup is allowed — the selection is legal — but every out-of-trash drop target
|
||||
/// refuses the mixed payload, and the release surfaces a notice explaining the rule". The refusal
|
||||
/// is at the commit (`BoardDropContext.commitDrop`), where every drop out of the trash funnels,
|
||||
/// rather than at hover — 04 puts the explanation at the release.
|
||||
private(set) var mixesKinds = false
|
||||
|
||||
/// The dragged items in **flatten order** — the order they will land in.
|
||||
private(set) var members: [ItemID] = []
|
||||
|
||||
@@ -496,16 +507,33 @@ final class DragSession {
|
||||
folders: [URL],
|
||||
heights: [CGFloat],
|
||||
container: ItemContainer,
|
||||
source: BoardStore
|
||||
source: BoardStore,
|
||||
mixesKinds: Bool = false
|
||||
) {
|
||||
begin(kind: .cards, members: members, folders: folders, container: container, source: source)
|
||||
begin(
|
||||
kind: .cards, members: members, folders: folders,
|
||||
container: container, source: source, mixesKinds: mixesKinds
|
||||
)
|
||||
cardHeights = heights
|
||||
laneUnits = []
|
||||
}
|
||||
|
||||
/// Begins a lane session.
|
||||
func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) {
|
||||
begin(kind: .lanes, members: members, folders: folders, container: .board, source: source)
|
||||
///
|
||||
/// `container` is a parameter because a **trashed lane row**'s drag is a `.lanes` session in
|
||||
/// `.trash` — the restore at the lane level (04-interactions.md ▸ The trash ▸ Drag-to-restore).
|
||||
func beginLanes(
|
||||
_ members: [ItemID],
|
||||
folders: [URL],
|
||||
units: [Int],
|
||||
container: ItemContainer = .board,
|
||||
source: BoardStore,
|
||||
mixesKinds: Bool = false
|
||||
) {
|
||||
begin(
|
||||
kind: .lanes, members: members, folders: folders,
|
||||
container: container, source: source, mixesKinds: mixesKinds
|
||||
)
|
||||
laneUnits = units
|
||||
cardHeights = []
|
||||
}
|
||||
@@ -515,7 +543,8 @@ final class DragSession {
|
||||
members: [ItemID],
|
||||
folders: [URL],
|
||||
container: ItemContainer,
|
||||
source: BoardStore
|
||||
source: BoardStore,
|
||||
mixesKinds: Bool
|
||||
) {
|
||||
endHold()
|
||||
self.kind = kind
|
||||
@@ -523,6 +552,7 @@ final class DragSession {
|
||||
self.memberSet = Set(members)
|
||||
self.folders = folders
|
||||
self.container = container
|
||||
self.mixesKinds = mixesKinds
|
||||
self.sourceStore = source
|
||||
self.sourceRoot = source.rootURL
|
||||
self.proposal = nil
|
||||
|
||||
@@ -183,6 +183,11 @@ struct TrashLaneRowView: View {
|
||||
/// **Multi-drag carries the whole trash-side lane selection**, in the column's own order, and a
|
||||
/// row outside the selection drags alone — the card face's targeting rule at the other level.
|
||||
///
|
||||
/// **A kind-blind selection can span both kinds, and the session says so** (04-interactions.md ▸
|
||||
/// The trash, ruled 2026-07-31): a `.lanes` session cannot carry the cards in it, so rather than
|
||||
/// drop them silently the flag travels and the release refuses with the notice
|
||||
/// (`DragSession.mixesKinds`) — "pickup is allowed — the selection is legal".
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
|
||||
private func startDrag() -> NSItemProvider {
|
||||
@@ -190,6 +195,8 @@ struct TrashLaneRowView: View {
|
||||
let ids = draggedIDs
|
||||
let rows = store.snapshot.trashedLanes.filter { ids.contains($0.id) }
|
||||
guard !rows.isEmpty else { return NSItemProvider() }
|
||||
let mixesKinds = SelectionGrammar.mixesKinds(
|
||||
ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot)
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
@@ -212,7 +219,12 @@ struct TrashLaneRowView: View {
|
||||
// standard width; the restored lane then draws at whatever `width` its untouched
|
||||
// frontmatter still says, on the next reload.
|
||||
units: rows.map { _ in 1 },
|
||||
source: store
|
||||
// **The session's container is this row's**, matching the payload's — which is what
|
||||
// routes the release to `BoardStore.restoreLanes` (the move *out* of `.trash/`) rather
|
||||
// than to `moveLanes`' strip permutation, and what the mixed-payload exit keys on.
|
||||
container: .trash,
|
||||
source: store,
|
||||
mixesKinds: mixesKinds
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import SwiftUI
|
||||
///
|
||||
/// **Its cards are ordinary cards in a special place**, so there is nothing to derive and nothing
|
||||
/// to draw differently: the column renders `store.snapshot.trash` — which the loader parsed with the
|
||||
/// same card parse the lanes use and sorted by `order` like any lane's children — through the very
|
||||
/// same card parse the lanes use and sorted by `modified` descending, the trash's own order — through the very
|
||||
/// same `CardFaceView` a lane renders. "A trashed card is an ordinary card in a special place —
|
||||
/// search, selection, rendering, styling, and clipboard all treat it exactly like any other card"
|
||||
/// (03 § Trash), and one view is the only way to make *rendering* literally true rather than
|
||||
@@ -24,11 +24,11 @@ import SwiftUI
|
||||
/// **A trashed lane is an opaque unit** — "one distinct dimmed row showing its title and held-card
|
||||
/// count … no styling accents, never expandable" (03 § Trash) — so it gets its own small view
|
||||
/// (`TrashLaneRowView`) rather than a third card-face role: what a card face draws is exactly what
|
||||
/// the design says this row must not. The two kinds **interleave purely by trash rank**, which is
|
||||
/// the design says this row must not. The two kinds **interleave by `modified` descending**, which is
|
||||
/// `BoardModel.trashEntries`' merge and not a second one written here.
|
||||
///
|
||||
/// Newest-first falls out of the ranks (every arrival of either kind mints one above the current
|
||||
/// top), so there is no timestamp sort and no entry type here at all.
|
||||
/// Newest-first falls out of the stamp (every arrival of either kind restamps `modified` on the way
|
||||
/// in — 03 § Trash, re-ruled 2026-07-31), so no rank is minted anywhere in this container.
|
||||
///
|
||||
/// ### What makes it a column and not a lane
|
||||
///
|
||||
@@ -52,9 +52,9 @@ import SwiftUI
|
||||
/// trash, lanes extended 2026-07-29) — the drag becomes the pointer's delete gesture, and release
|
||||
/// moves the dragged item(s) into `.trash/`. So the column declares an `onDrop`
|
||||
/// (`TrashDropDelegate`), and it is the narrowest one on the board: a board drag from **this** board,
|
||||
/// unmodified. It diverges from every other drop in one way, and the ranks are what make the
|
||||
/// divergence honest: **the shadow always takes the topmost row**, because every arrival mints a rank
|
||||
/// above the current top.
|
||||
/// unmodified. It diverges from every other drop in one way, and the stamp is what makes the
|
||||
/// divergence honest: **the shadow always takes the topmost row**, because every arrival restamps
|
||||
/// `modified` and the column is sorted by it, newest first.
|
||||
///
|
||||
/// The drag *out* is the restore, and it is deliberately not special at either level: a trash card's
|
||||
/// drag is an ordinary `.cards` session in the `.trash` container
|
||||
@@ -155,8 +155,9 @@ struct TrashLaneView: View {
|
||||
))
|
||||
}
|
||||
|
||||
/// The rows the column shows — **both kinds, interleaved by rank** (03-board-ui.md § Trash:
|
||||
/// "Lane rows and cards interleave in the one trash column purely by trash rank").
|
||||
/// The rows the column shows — **both kinds, interleaved by `modified` descending**
|
||||
/// (03-board-ui.md § Trash: "Lane rows and cards interleave in the one trash column by `modified`
|
||||
/// descending").
|
||||
///
|
||||
/// **Shown, the trash's contents "participate in the filter exactly like any other card"**
|
||||
/// (03 § Trash — "the point of the pivot"), so the search predicate narrows this collection
|
||||
|
||||
Reference in New Issue
Block a user