Materialize the trash — store, undo, and the container universe
Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -347,7 +347,7 @@ struct RevealInFinderCommand: View {
|
|||||||
if let store {
|
if let store {
|
||||||
let ids = store.selection.ids
|
let ids = store.selection.ids
|
||||||
guard !ids.isEmpty else { return [store.rootURL] }
|
guard !ids.isEmpty else { return [store.rootURL] }
|
||||||
return TrashModel.paths(of: ids, on: store.selection.liveness, in: store.snapshot)
|
return ItemPath.resolve(ids, in: store.selection.container, snapshot: store.snapshot)
|
||||||
.map { $0.folder(under: store.rootURL) }
|
.map { $0.folder(under: store.rootURL) }
|
||||||
}
|
}
|
||||||
if let attachments {
|
if let attachments {
|
||||||
|
|||||||
@@ -645,11 +645,9 @@ public final class AppModel {
|
|||||||
public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) {
|
public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) {
|
||||||
var lanes = 0
|
var lanes = 0
|
||||||
var cards = 0
|
var cards = 0
|
||||||
for lane in snapshot.lanes where !lane.isDeleted {
|
for lane in snapshot.lanes {
|
||||||
lanes += 1
|
lanes += 1
|
||||||
for card in lane.cards where !card.isDeleted {
|
cards += lane.cards.count
|
||||||
cards += 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return (lanes, cards)
|
return (lanes, cards)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,9 +175,7 @@ struct CardWindowHost: View {
|
|||||||
let identity = ItemID(rawValue: cardID)
|
let identity = ItemID(rawValue: cardID)
|
||||||
for lane in snapshot.lanes {
|
for lane in snapshot.lanes {
|
||||||
guard let card = lane.cards.first(where: { $0.id == identity }) else { continue }
|
guard let card = lane.cards.first(where: { $0.id == identity }) else { continue }
|
||||||
return lane.isDeleted || card.isDeleted
|
return .shows(CardPlacement(card: card, lane: lane))
|
||||||
? .dismisses
|
|
||||||
: .shows(CardPlacement(card: card, lane: lane))
|
|
||||||
}
|
}
|
||||||
return .dismisses
|
return .dismisses
|
||||||
}
|
}
|
||||||
@@ -319,7 +317,7 @@ struct CardWindowHost: View {
|
|||||||
/// links resolve against (05-card-window.md ▸ Preview).
|
/// links resolve against (05-card-window.md ▸ Preview).
|
||||||
///
|
///
|
||||||
/// Built off the store's *current* `rootURL` rather than the ref's captured one, for
|
/// Built off the store's *current* `rootURL` rather than the ref's captured one, for
|
||||||
/// `BoardStore.liveItem`'s reason: a mid-session folder rename moves the board, and a preview
|
/// `BoardStore.boardItem`'s reason: a mid-session folder rename moves the board, and a preview
|
||||||
/// resolving images against where the board used to be would quietly stop showing them.
|
/// resolving images against where the board used to be would quietly stop showing them.
|
||||||
static func cardFolder(root: URL, placement: CardPlacement) -> URL {
|
static func cardFolder(root: URL, placement: CardPlacement) -> URL {
|
||||||
root
|
root
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ extension UTType {
|
|||||||
/// snapshot is missing or unreadable — "the staging-less fallback: content intact, attachments
|
/// snapshot is missing or unreadable — "the staging-less fallback: content intact, attachments
|
||||||
/// absent", announced by a banner rather than discovered later.
|
/// absent", announced by a banner rather than discovered later.
|
||||||
///
|
///
|
||||||
/// `kind` and `side` are the selection's own vocabulary (`SelectionKind`, `Liveness`) rather than
|
/// `kind` and `container` are the selection's own vocabulary (`SelectionKind`, `ItemContainer`) rather than
|
||||||
/// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and
|
/// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and
|
||||||
/// live-XOR-tombstoned invariants are exactly the ones those two types already carry. Their raw
|
/// live-XOR-tombstoned invariants are exactly the ones those two types already carry. Their raw
|
||||||
/// spellings are pasteboard API — a manifest written before a quit is decoded after the relaunch.
|
/// spellings are pasteboard API — a manifest written before a quit is decoded after the relaunch.
|
||||||
@@ -53,7 +53,7 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
|
|||||||
public var boardRoot: String
|
public var boardRoot: String
|
||||||
|
|
||||||
public var kind: SelectionKind
|
public var kind: SelectionKind
|
||||||
public var side: Liveness
|
public var container: ItemContainer
|
||||||
public var entries: [Entry]
|
public var entries: [Entry]
|
||||||
|
|
||||||
/// One copied item: where its snapshot is staged, what it is called, and its bytes.
|
/// One copied item: where its snapshot is staged, what it is called, and its bytes.
|
||||||
@@ -132,14 +132,14 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
|
|||||||
copyID: String,
|
copyID: String,
|
||||||
boardRoot: URL,
|
boardRoot: URL,
|
||||||
kind: SelectionKind,
|
kind: SelectionKind,
|
||||||
side: Liveness,
|
container: ItemContainer,
|
||||||
entries: [Entry]
|
entries: [Entry]
|
||||||
) {
|
) {
|
||||||
self.version = version
|
self.version = version
|
||||||
self.copyID = copyID
|
self.copyID = copyID
|
||||||
self.boardRoot = boardRoot.path
|
self.boardRoot = boardRoot.path
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.side = side
|
self.container = container
|
||||||
self.entries = entries
|
self.entries = entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ public final class ClipboardStore {
|
|||||||
copyID: copyID,
|
copyID: copyID,
|
||||||
boardRoot: store.rootURL,
|
boardRoot: store.rootURL,
|
||||||
kind: capture.kind,
|
kind: capture.kind,
|
||||||
side: capture.side,
|
container: capture.container,
|
||||||
entries: capture.subjects.map(\.entry)
|
entries: capture.subjects.map(\.entry)
|
||||||
)
|
)
|
||||||
guard let data = manifest.encoded() else { return }
|
guard let data = manifest.encoded() else { return }
|
||||||
@@ -209,7 +209,7 @@ public final class ClipboardStore {
|
|||||||
armedCut = ArmedCut(copyID: copyID, source: store)
|
armedCut = ArmedCut(copyID: copyID, source: store)
|
||||||
store.transient.pendingCut = ItemReferenceSet(
|
store.transient.pendingCut = ItemReferenceSet(
|
||||||
ids: Set(capture.subjects.map(\.id)),
|
ids: Set(capture.subjects.map(\.id)),
|
||||||
liveness: capture.side
|
container: capture.container
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// "A sweep at launch and on each copy purges entries the pasteboard no longer references."
|
// "A sweep at launch and on each copy purges entries the pasteboard no longer references."
|
||||||
@@ -219,8 +219,9 @@ public final class ClipboardStore {
|
|||||||
|
|
||||||
// MARK: - Availability
|
// MARK: - Availability
|
||||||
|
|
||||||
/// Whether Edit ▸ Copy applies — a non-empty selection that still names something the board
|
/// Whether Edit ▸ Copy applies — a non-empty selection that still names something, in either
|
||||||
/// renders, on either side of the live/tombstoned boundary.
|
/// container ("⌘C copies a trash card — a live copy lands wherever pasted, like copying out of
|
||||||
|
/// Finder's Trash" — 04-interactions.md ▸ The trash).
|
||||||
///
|
///
|
||||||
/// **The read-only lock deliberately does not close it**: "reading, selecting, searching and
|
/// **The read-only lock deliberately does not close it**: "reading, selecting, searching and
|
||||||
/// copying out all stay live" (02-architecture.md § The lock's scope) — a copy is a read. The
|
/// copying out all stay live" (02-architecture.md § The lock's scope) — a copy is a read. The
|
||||||
@@ -233,12 +234,15 @@ public final class ClipboardStore {
|
|||||||
return SelectionGrammar.kind(of: store.selection, in: store.snapshot) != nil
|
return SelectionGrammar.kind(of: store.selection, in: store.snapshot) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether Edit ▸ Cut applies. Copy's conditions, plus the two a *move* adds: the board must
|
/// Whether Edit ▸ Cut applies. Copy's conditions plus the one a *move* adds: the board must
|
||||||
/// accept writes (a cut mutates its source), and the selection must be **live** — "⌘X is
|
/// accept writes, since a cut mutates its source.
|
||||||
/// disabled: the move-out vocabulary is Put Back or drag-to-restore, nothing else" (04 ▸ The
|
///
|
||||||
/// trash).
|
/// **The trash no longer disqualifies it** (04-interactions.md ▸ The trash, resettled
|
||||||
|
/// 2026-07-28): "⌘X works — it was disabled under the tombstone model: cut in the trash, paste
|
||||||
|
/// into a lane is the keyboard-native restore, an ordinary folder move". So there is no
|
||||||
|
/// container clause here at all, which is the pivot showing up as a deleted line.
|
||||||
public func canCut(from store: BoardStore) -> Bool {
|
public func canCut(from store: BoardStore) -> Bool {
|
||||||
canCopy(from: store) && !store.isReadOnly && store.selection.liveness == .live
|
canCopy(from: store) && !store.isReadOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether Edit ▸ Paste applies to `store`.
|
/// Whether Edit ▸ Paste applies to `store`.
|
||||||
@@ -347,7 +351,6 @@ public final class ClipboardStore {
|
|||||||
operation: .move,
|
operation: .move,
|
||||||
toLane: target.laneID,
|
toLane: target.laneID,
|
||||||
at: target.index,
|
at: target.index,
|
||||||
clearingTombstones: false,
|
|
||||||
normalizingLooseFiles: true
|
normalizingLooseFiles: true
|
||||||
)
|
)
|
||||||
case let .lanes(index):
|
case let .lanes(index):
|
||||||
@@ -355,7 +358,6 @@ public final class ClipboardStore {
|
|||||||
sources,
|
sources,
|
||||||
operation: .move,
|
operation: .move,
|
||||||
at: index,
|
at: index,
|
||||||
clearingTombstones: false,
|
|
||||||
normalizingLooseFiles: true
|
normalizingLooseFiles: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -389,9 +391,9 @@ public final class ClipboardStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "⌘C strips `deleted:` at materialization" (04 ▸ The trash) — the trash's copy-out-only rule,
|
// A card copied out of the trash needs nothing done to it on arrival: it carries no
|
||||||
// and the one axis a paste varies that a within-board drop never does.
|
// `deleted:` key, because there is no such key any more (03-board-ui.md § Trash, resettled
|
||||||
let clearingTombstones = manifest.side == .trashed
|
// 2026-07-28). The tombstone era's strip-at-materialization axis is gone with it.
|
||||||
switch plan {
|
switch plan {
|
||||||
case let .cards(target):
|
case let .cards(target):
|
||||||
store.receiveCards(
|
store.receiveCards(
|
||||||
@@ -399,7 +401,6 @@ public final class ClipboardStore {
|
|||||||
operation: .copy,
|
operation: .copy,
|
||||||
toLane: target.laneID,
|
toLane: target.laneID,
|
||||||
at: target.index,
|
at: target.index,
|
||||||
clearingTombstones: clearingTombstones,
|
|
||||||
normalizingLooseFiles: true
|
normalizingLooseFiles: true
|
||||||
)
|
)
|
||||||
case let .lanes(index):
|
case let .lanes(index):
|
||||||
@@ -407,7 +408,6 @@ public final class ClipboardStore {
|
|||||||
sources,
|
sources,
|
||||||
operation: .copy,
|
operation: .copy,
|
||||||
at: index,
|
at: index,
|
||||||
clearingTombstones: clearingTombstones,
|
|
||||||
normalizingLooseFiles: true
|
normalizingLooseFiles: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -427,10 +427,11 @@ public final class ClipboardStore {
|
|||||||
let survivors = source.transient.pendingCut
|
let survivors = source.transient.pendingCut
|
||||||
guard !survivors.isEmpty else { return nil }
|
guard !survivors.isEmpty else { return nil }
|
||||||
|
|
||||||
// `TrashModel.paths` walks lanes in board order and each lane's cards in card order, which is
|
// `ItemPath.resolve` walks the container in display order, which is the flatten order the
|
||||||
// the flatten order the drop commits insert in — and the pending cut is homogeneous by kind,
|
// drop commits insert in — and the pending cut is homogeneous by container, so it is asked
|
||||||
// so only one of its two branches ever contributes.
|
// for exactly the side the cut was made on. A cut made in the trash therefore hands the
|
||||||
let folders = TrashModel.paths(of: survivors.ids, on: .live, in: source.snapshot)
|
// paste the trash folders it must move out, which is the keyboard restore (04 ▸ The trash).
|
||||||
|
let folders = ItemPath.resolve(survivors.ids, in: survivors.container, snapshot: source.snapshot)
|
||||||
.map { $0.folder(under: source.rootURL) }
|
.map { $0.folder(under: source.rootURL) }
|
||||||
guard !folders.isEmpty else { return nil }
|
guard !folders.isEmpty else { return nil }
|
||||||
return (source, folders)
|
return (source, folders)
|
||||||
@@ -541,17 +542,17 @@ public final class ClipboardStore {
|
|||||||
/// produces.
|
/// produces.
|
||||||
struct Subject {
|
struct Subject {
|
||||||
let id: ItemID
|
let id: ItemID
|
||||||
let path: TrashModel.ItemPath
|
let path: ItemPath
|
||||||
let entry: ClipboardManifest.Entry
|
let entry: ClipboardManifest.Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The selection, resolved into copy subjects in the order the clipboard records them — or `nil`
|
/// The selection, resolved into copy subjects in the order the clipboard records them — or `nil`
|
||||||
/// when it names nothing the board renders on its own side.
|
/// when it names nothing its container holds.
|
||||||
///
|
///
|
||||||
/// **The order is `SelectionGrammar.order`'s**, which is already the right answer for all four
|
/// **The order is `SelectionGrammar.order`'s**, which is already the right answer for every
|
||||||
/// (side, kind) pairs: flatten order for live cards, left-to-right for live lanes, and the trash's
|
/// (container, kind) pair: flatten order for board cards, left-to-right for lanes, and the
|
||||||
/// own deterministic sort for either kind of entry. Deriving it here would be a fifth definition
|
/// trash's own `order` for trash cards. Deriving it here would be a second definition of an order
|
||||||
/// of an order the app already states once.
|
/// the app already states once.
|
||||||
///
|
///
|
||||||
/// **The index text comes from the snapshot, not from disk.** `FrontmatterDocument` edits by line
|
/// **The index text comes from the snapshot, not from disk.** `FrontmatterDocument` edits by line
|
||||||
/// span, so `serialized()` on an untouched document returns the file's bytes exactly — which
|
/// span, so `serialized()` on an untouched document returns the file's bytes exactly — which
|
||||||
@@ -560,45 +561,18 @@ public final class ClipboardStore {
|
|||||||
static func capture(
|
static func capture(
|
||||||
selection: ItemReferenceSet,
|
selection: ItemReferenceSet,
|
||||||
snapshot: BoardModel
|
snapshot: BoardModel
|
||||||
) -> (kind: SelectionKind, side: Liveness, subjects: [Subject])? {
|
) -> (kind: SelectionKind, container: ItemContainer, subjects: [Subject])? {
|
||||||
guard let kind = SelectionGrammar.kind(of: selection, in: snapshot) else { return nil }
|
guard let kind = SelectionGrammar.kind(of: selection, in: snapshot) else { return nil }
|
||||||
let side = selection.liveness
|
let container = selection.container
|
||||||
let ordered = SelectionGrammar.order(of: kind, on: side, in: snapshot)
|
let ordered = SelectionGrammar.order(of: kind, in: container, snapshot: snapshot)
|
||||||
.filter { selection.ids.contains($0) }
|
.filter { selection.ids.contains($0) }
|
||||||
guard !ordered.isEmpty else { return nil }
|
guard !ordered.isEmpty else { return nil }
|
||||||
|
|
||||||
var subjects: [ItemID: Subject] = [:]
|
var subjects: [ItemID: Subject] = [:]
|
||||||
for lane in snapshot.lanes {
|
func addCard(_ card: Card, at path: ItemPath) {
|
||||||
if kind == .lane, Liveness(isDeleted: lane.isDeleted) == side {
|
|
||||||
subjects[lane.id] = Subject(
|
|
||||||
id: lane.id,
|
|
||||||
path: TrashModel.ItemPath(laneID: lane.id, cardID: nil),
|
|
||||||
entry: ClipboardManifest.Entry(
|
|
||||||
id: lane.id.rawValue,
|
|
||||||
folder: lane.id.rawValue,
|
|
||||||
title: lane.title.value,
|
|
||||||
index: lane.document.serialized(),
|
|
||||||
attachmentCount: 0,
|
|
||||||
// Live cards only — a lane copy strips tombstoned cards, and the fallback
|
|
||||||
// only ever materializes a copy (see `ClipboardManifest.Entry.cards`).
|
|
||||||
cards: lane.cards.filter { !$0.isDeleted }.map { card in
|
|
||||||
ClipboardManifest.Entry.Card(
|
|
||||||
id: card.id.rawValue,
|
|
||||||
title: card.title.value,
|
|
||||||
index: card.document.serialized(),
|
|
||||||
attachmentCount: card.attachments.count
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// A tombstoned lane subsumes its subtree on both sides: its cards render nowhere live and
|
|
||||||
// have no trash row of their own, so they are nobody's copy subject.
|
|
||||||
guard kind == .card, !lane.isDeleted else { continue }
|
|
||||||
for card in lane.cards where Liveness(isDeleted: card.isDeleted) == side {
|
|
||||||
subjects[card.id] = Subject(
|
subjects[card.id] = Subject(
|
||||||
id: card.id,
|
id: card.id,
|
||||||
path: TrashModel.ItemPath(laneID: lane.id, cardID: card.id),
|
path: path,
|
||||||
entry: ClipboardManifest.Entry(
|
entry: ClipboardManifest.Entry(
|
||||||
id: card.id.rawValue,
|
id: card.id.rawValue,
|
||||||
folder: card.id.rawValue,
|
folder: card.id.rawValue,
|
||||||
@@ -608,10 +582,47 @@ public final class ClipboardStore {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch container {
|
||||||
|
case .trash:
|
||||||
|
for card in snapshot.trash {
|
||||||
|
addCard(card, at: .trashCard(card.id))
|
||||||
|
}
|
||||||
|
case .board:
|
||||||
|
for lane in snapshot.lanes {
|
||||||
|
if kind == .lane {
|
||||||
|
subjects[lane.id] = Subject(
|
||||||
|
id: lane.id,
|
||||||
|
path: .lane(lane.id),
|
||||||
|
entry: ClipboardManifest.Entry(
|
||||||
|
id: lane.id.rawValue,
|
||||||
|
folder: lane.id.rawValue,
|
||||||
|
title: lane.title.value,
|
||||||
|
index: lane.document.serialized(),
|
||||||
|
attachmentCount: 0,
|
||||||
|
// Every card the lane has — "a lane carries exactly its cards", and the
|
||||||
|
// trash is board-level, so there is nothing nested to strip
|
||||||
|
// (04-interactions.md ▸ Drag and drop, resettled 2026-07-28).
|
||||||
|
cards: lane.cards.map { card in
|
||||||
|
ClipboardManifest.Entry.Card(
|
||||||
|
id: card.id.rawValue,
|
||||||
|
title: card.title.value,
|
||||||
|
index: card.document.serialized(),
|
||||||
|
attachmentCount: card.attachments.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for card in lane.cards {
|
||||||
|
addCard(card, at: .card(lane: lane.id, id: card.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolved = ordered.compactMap { subjects[$0] }
|
let resolved = ordered.compactMap { subjects[$0] }
|
||||||
guard !resolved.isEmpty else { return nil }
|
guard !resolved.isEmpty else { return nil }
|
||||||
return (kind, side, resolved)
|
return (kind, container, resolved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ extension BoardStore {
|
|||||||
// gone is not one to pop: the stack is about to be cleared with the session anyway.
|
// gone is not one to pop: the stack is about to be cleared with the session anyway.
|
||||||
guard let store else { return .failed }
|
guard let store else { return .failed }
|
||||||
|
|
||||||
guard HistoryStaleness.isCurrent(expectations, under: store.rootURL) else {
|
guard HistoryStaleness.isCurrent(expectations) else {
|
||||||
store.banners.postSkippedStep(direction, subject: subject)
|
store.banners.postSkippedStep(direction, subject: subject)
|
||||||
return .skipped
|
return .skipped
|
||||||
}
|
}
|
||||||
@@ -157,11 +157,11 @@ extension BoardStore {
|
|||||||
|
|
||||||
/// Registers a create's step: undo removes the folders, redo puts them back byte-for-byte.
|
/// Registers a create's step: undo removes the folders, redo puts them back byte-for-byte.
|
||||||
///
|
///
|
||||||
/// **Removal, not a tombstone**, exactly as 13 words it: an undone create leaves *no trace*,
|
/// **Removal, not a move into the trash**, exactly as 13 words it: an undone create leaves *no
|
||||||
/// because the item was born of the gesture being undone — a tombstone would leave a trash row
|
/// trace*, because the item was born of the gesture being undone — filing it in the trash would
|
||||||
/// for a card the user never really made. `purgeIsUnrecoverable` is untouched by this: that flag
|
/// leave a card the user never really made for them to find. `purgeIsUnrecoverable` is untouched
|
||||||
/// is about Delete Immediately, whose loss is the user's own final gesture, while this loss is
|
/// by this: that flag is about Delete Immediately, whose loss is the user's own final gesture,
|
||||||
/// one ⇧⌘Z away.
|
/// while this loss is one ⇧⌘Z away.
|
||||||
///
|
///
|
||||||
/// The honest edge, recorded rather than papered over: anything that happened *inside* the
|
/// The honest edge, recorded rather than papered over: anything that happened *inside* the
|
||||||
/// created folder through an operation that registers no step of its own — an attachment added by
|
/// created folder through an operation that registers no step of its own — an attachment added by
|
||||||
@@ -169,19 +169,19 @@ extension BoardStore {
|
|||||||
/// 13's own two rulings (remove the folder; attachment operations register nothing in v1) rather
|
/// 13's own two rulings (remove the folder; attachment operations register nothing in v1) rather
|
||||||
/// than from anything decided here.
|
/// than from anything decided here.
|
||||||
///
|
///
|
||||||
/// **Its staleness predicate is existence and liveness, and deliberately nothing else** (13:
|
/// **Its staleness predicate is existence, and deliberately nothing else** (13: "existence …
|
||||||
/// "existence/liveness for create/delete/restore steps"). A create's after-value *is* the item's
|
/// for create/delete/restore steps"). A create's after-value *is* the item's being there, so the
|
||||||
/// being there, so the undo validates that the folders are still there and still live and the
|
/// undo validates that the folders are still at their paths and the redo that they are still
|
||||||
/// redo that they are still gone. The same honest edge follows: a foreign *edit* inside a created
|
/// gone. The same honest edge follows: a foreign *edit* inside a created card does not stop ⌘Z
|
||||||
/// card does not stop ⌘Z from removing it, because the create never wrote that field — while a
|
/// from removing it, because the create never wrote that field — while a foreign *delete* does,
|
||||||
/// foreign *delete* does, since the trash row the user is looking at is not this step's to purge.
|
/// since the card the user is looking at in the trash is at a different path now.
|
||||||
func registerCreation(_ items: [CreatedItem], kind: HistoryPhrase.Kind, subject: String? = nil) {
|
func registerCreation(_ items: [CreatedItem], kind: HistoryPhrase.Kind, subject: String? = nil) {
|
||||||
guard !items.isEmpty else { return }
|
guard !items.isEmpty else { return }
|
||||||
let operation: WriteOperation = kind == .lane ? .createLane : .createCard
|
let operation: WriteOperation = kind == .lane ? .createLane : .createCard
|
||||||
registerStep(
|
registerStep(
|
||||||
HistoryPhrase.name(.add, kind: kind, count: items.count),
|
HistoryPhrase.name(.add, kind: kind, count: items.count),
|
||||||
subject: subject,
|
subject: subject,
|
||||||
undoExpects: items.map { .live($0.folder) },
|
undoExpects: items.map { .present($0.folder) },
|
||||||
redoExpects: items.map { .absent($0.folder) }
|
redoExpects: items.map { .absent($0.folder) }
|
||||||
) { _ in
|
) { _ in
|
||||||
// Reversed, so a lane and a card created by one gesture unwind child-first — the same
|
// Reversed, so a lane and a card created by one gesture unwind child-first — the same
|
||||||
@@ -282,14 +282,4 @@ extension BoardStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Puts an item's tombstone back — **with the timestamp it carried**, not with `now`.
|
|
||||||
///
|
|
||||||
/// The inverse of Put Back is the item returning to the trash exactly where it was, and the trash
|
|
||||||
/// sorts by `deleted` (03-board-ui.md § Trash ▸ Contents): re-stamping would file the row under
|
|
||||||
/// today and quietly reorder a list the user was reading. A prior that was malformed (or, by
|
|
||||||
/// construction impossibly, missing) falls back to `now` — the item has to be tombstoned, and an
|
|
||||||
/// unreadable timestamp is not a value to preserve.
|
|
||||||
static func restoreTombstone(_ prior: FieldValue<Date>, in document: inout FrontmatterDocument) {
|
|
||||||
document.set(FrontmatterKeys.deleted, to: .date(prior.value ?? Date()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,40 +43,42 @@ public enum ExpectedField: Sendable, Equatable {
|
|||||||
/// What one folder must currently hold for a step to be safe to cross — the state that step's write
|
/// What one folder must currently hold for a step to be safe to cross — the state that step's write
|
||||||
/// left it in.
|
/// left it in.
|
||||||
///
|
///
|
||||||
/// Two halves, both of them 13's: **existence and liveness** ("target folder gone ... → the step is
|
/// Two halves, both of them 13's: **existence** ("target folder gone ... → the step is skipped"),
|
||||||
/// skipped"; "existence/liveness for create/delete/restore steps"), and the **field-level**
|
/// and the **field-level** comparison above. A step carries one of these per item it touched, so a
|
||||||
/// comparison above. A step carries one of these per item it touched, so a multi-card move validates
|
/// multi-card move validates three targets and a single rename validates one — which is the whole of
|
||||||
/// three targets and a single rename validates one — which is the whole of "a foreign change to an
|
/// "a foreign change to an unrelated item must not skip anything": an item no step named is an item
|
||||||
/// unrelated item must not skip anything": an item no step named is an item no expectation mentions.
|
/// no expectation mentions.
|
||||||
///
|
///
|
||||||
/// **The folder's *path* is the parent check.** A move's step expects the card at its destination
|
/// ### The container side rides in the folder path
|
||||||
/// path; a card that a foreign writer moved elsewhere leaves nothing at that path, so the ordinary
|
///
|
||||||
/// existence half already answers "moved away" without a parent field of its own.
|
/// **The folder's *path* is the parent check**, and since the trash was materialized that check is
|
||||||
|
/// also the container check (03-board-ui.md § Trash, resettled 2026-07-28). A delete step's undo
|
||||||
|
/// expects its card at `<root>/.trash/<id>`; a foreign restore moves the folder out, so nothing is
|
||||||
|
/// at that path and the existence half already answers "the card is not in the trash any more".
|
||||||
|
/// The mirror holds: the redo expects it back at `<root>/<lane>/<id>`, where a foreign re-delete
|
||||||
|
/// leaves nothing. That is why `Presence` is a two-case answer rather than the tombstone era's
|
||||||
|
/// three-way live/tombstoned/absent reading of a `deleted:` key — there is no key to read, and no
|
||||||
|
/// ancestor to walk to find one.
|
||||||
public struct HistoryExpectation: Sendable, Equatable {
|
public struct HistoryExpectation: Sendable, Equatable {
|
||||||
|
|
||||||
/// Where the item this step wrote to should be — the destination for a move, the item's own
|
/// Where the item this step wrote to should be — the destination for a move, the item's own
|
||||||
/// folder for everything else, and the board root for the board's own rename and styling.
|
/// folder for everything else, and the board root for the board's own rename and styling.
|
||||||
public let folder: URL
|
public let folder: URL
|
||||||
|
|
||||||
/// Whether the item should be there, and if so on which side of the tombstone.
|
/// Whether the item should be there.
|
||||||
public let presence: Presence
|
public let presence: Presence
|
||||||
|
|
||||||
/// The fields the step's write set, with the values it set them to. Empty for a step whose
|
/// The fields the step's write set, with the values it set them to. Empty for a step whose
|
||||||
/// whole subject *is* existence — a create, a delete, a Put Back.
|
/// whole subject *is* existence — a create, a lane delete.
|
||||||
public let fields: [ExpectedField]
|
public let fields: [ExpectedField]
|
||||||
|
|
||||||
/// Where an item stands, as the trash's own three-way reading of it.
|
/// Whether anything is at this path.
|
||||||
public enum Presence: Sendable, Equatable {
|
public enum Presence: Sendable, Equatable {
|
||||||
/// There, and rendered: no `deleted:` on the item **or on any ancestor**. Liveness is
|
/// There, with a readable `index.md`. Which container that is, is the path's own answer.
|
||||||
/// effective, the same ancestor walk `BoardStore.liveItem` and the card windows' fate rule
|
case present
|
||||||
/// apply — a card under a tombstoned lane renders nowhere, so it is as gone as a deleted one.
|
|
||||||
case live
|
|
||||||
|
|
||||||
/// There, and tombstoned — a trash row, or a card hidden under a tombstoned lane.
|
/// Not there at all: the folder is gone. What an undone create and an undone lane delete
|
||||||
case tombstoned
|
/// leave, and what a redone one expects to find before putting it back.
|
||||||
|
|
||||||
/// Not there at all: the folder is gone. What an undone create leaves, and what a redone one
|
|
||||||
/// expects to find before putting it back.
|
|
||||||
case absent
|
case absent
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,37 +88,21 @@ public struct HistoryExpectation: Sendable, Equatable {
|
|||||||
self.fields = fields
|
self.fields = fields
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The item is live and its fields say what the step set them to.
|
/// The item is at this path and its fields say what the step set them to.
|
||||||
public static func live(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
|
public static func present(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
|
||||||
HistoryExpectation(folder: folder, presence: .live, fields: fields)
|
HistoryExpectation(folder: folder, presence: .present, fields: fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The same, for a caller whose field list is computed — the styling gesture's, which varies per
|
/// The same, for a caller whose field list is computed — the styling gesture's, which varies per
|
||||||
/// dimension. A label rather than a second variadic, so `.live(folder)` stays unambiguous.
|
/// dimension. A label rather than a second variadic, so `.present(folder)` stays unambiguous.
|
||||||
public static func live(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation {
|
public static func present(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation {
|
||||||
HistoryExpectation(folder: folder, presence: .live, fields: fields)
|
HistoryExpectation(folder: folder, presence: .present, fields: fields)
|
||||||
}
|
|
||||||
|
|
||||||
/// The item is tombstoned and its fields say what the step set them to.
|
|
||||||
public static func tombstoned(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
|
|
||||||
HistoryExpectation(folder: folder, presence: .tombstoned, fields: fields)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nothing is at this path.
|
/// Nothing is at this path.
|
||||||
public static func absent(_ folder: URL) -> HistoryExpectation {
|
public static func absent(_ folder: URL) -> HistoryExpectation {
|
||||||
HistoryExpectation(folder: folder, presence: .absent, fields: [])
|
HistoryExpectation(folder: folder, presence: .absent, fields: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The variant for a step whose target's liveness is not known until the gesture runs — the Edit
|
|
||||||
/// session's, which is registered against a card that may have been tombstoned out from under
|
|
||||||
/// the buffer (05-card-window.md ▸ Deletion & lifecycle).
|
|
||||||
public static func item(
|
|
||||||
_ folder: URL,
|
|
||||||
tombstoned: Bool,
|
|
||||||
_ fields: ExpectedField...
|
|
||||||
) -> HistoryExpectation {
|
|
||||||
HistoryExpectation(folder: folder, presence: tombstoned ? .tombstoned : .live, fields: fields)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - HistoryStaleness
|
// MARK: - HistoryStaleness
|
||||||
@@ -140,29 +126,29 @@ public struct HistoryExpectation: Sendable, Equatable {
|
|||||||
/// (settled — ruled 2026-07-27): staleness is discovered at ⌘Z time, never by background pruning ...
|
/// (settled — ruled 2026-07-27): staleness is discovered at ⌘Z time, never by background pruning ...
|
||||||
/// The stack always looks full". This type has exactly one caller, `BoardStore.cross`, one line
|
/// The stack always looks full". This type has exactly one caller, `BoardStore.cross`, one line
|
||||||
/// before the inverse would have been written.
|
/// before the inverse would have been written.
|
||||||
|
///
|
||||||
|
/// ### It needs no board root
|
||||||
|
///
|
||||||
|
/// The tombstone era's liveness half walked a folder's ancestors looking for a `deleted:` key, and
|
||||||
|
/// needed the root to know where to stop. Materializing the trash removed the walk: an item's
|
||||||
|
/// container is its path, and a path is checked by asking the filesystem whether anything is there.
|
||||||
public enum HistoryStaleness {
|
public enum HistoryStaleness {
|
||||||
|
|
||||||
/// Whether every target a step named still holds what that step left there.
|
/// Whether every target a step named still holds what that step left there.
|
||||||
///
|
public static func isCurrent(_ expectations: [HistoryExpectation]) -> Bool {
|
||||||
/// `root` is the board's current root, which the liveness walk stops at — a lane's parent.
|
expectations.allSatisfy(isCurrent)
|
||||||
public static func isCurrent(_ expectations: [HistoryExpectation], under root: URL) -> Bool {
|
|
||||||
expectations.allSatisfy { isCurrent($0, under: root) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One target's answer.
|
/// One target's answer.
|
||||||
///
|
///
|
||||||
/// A file that cannot be read or parsed fails every expectation but `.absent`: an `index.md`
|
/// A file that cannot be read or parsed fails a `.present` expectation: an `index.md` somebody
|
||||||
/// somebody has just broken is not one holding this step's after-value, and the honest reading of
|
/// has just broken is not one holding this step's after-value, and the honest reading of "the
|
||||||
/// "the field no longer holds it" covers a field that can no longer be read at all.
|
/// field no longer holds it" covers a field that can no longer be read at all.
|
||||||
public static func isCurrent(_ expectation: HistoryExpectation, under root: URL) -> Bool {
|
public static func isCurrent(_ expectation: HistoryExpectation) -> Bool {
|
||||||
guard expectation.presence != .absent else {
|
guard expectation.presence != .absent else {
|
||||||
return !FileManager.default.fileExists(atPath: expectation.folder.path)
|
return !FileManager.default.fileExists(atPath: expectation.folder.path)
|
||||||
}
|
}
|
||||||
guard let document = index(at: expectation.folder) else { return false }
|
guard let document = index(at: expectation.folder) else { return false }
|
||||||
|
|
||||||
let tombstoned = isEffectivelyTombstoned(expectation.folder, document: document, under: root)
|
|
||||||
guard tombstoned == (expectation.presence == .tombstoned) else { return false }
|
|
||||||
|
|
||||||
return expectation.fields.allSatisfy { matches($0, in: document) }
|
return expectation.fields.allSatisfy { matches($0, in: document) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,40 +178,6 @@ public enum HistoryStaleness {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Liveness
|
|
||||||
|
|
||||||
/// Whether the item at `folder` renders — **presence of `deleted:`, not its validity**
|
|
||||||
/// (`Lane.isDeleted`'s rule), walked up through the ancestors the way every other liveness
|
|
||||||
/// question in this app is.
|
|
||||||
///
|
|
||||||
/// The board root is never tombstoned however its own frontmatter reads: a board-level `deleted:`
|
|
||||||
/// is a tolerated load *warning* (01-storage-format.md § Deletion), not a state that hides the
|
|
||||||
/// board from itself.
|
|
||||||
private static func isEffectivelyTombstoned(
|
|
||||||
_ folder: URL,
|
|
||||||
document: FrontmatterDocument,
|
|
||||||
under root: URL
|
|
||||||
) -> Bool {
|
|
||||||
guard !isRoot(folder, root) else { return false }
|
|
||||||
guard document.deleted.isMissing else { return true }
|
|
||||||
|
|
||||||
// Lane and card are the only levels below the root, so this walks at most twice; the bound
|
|
||||||
// is there so a folder that is not under this root at all (a step registered before a
|
|
||||||
// mid-session root change) ends rather than climbing to `/`.
|
|
||||||
var parent = folder.deletingLastPathComponent()
|
|
||||||
for _ in 0 ..< 4 {
|
|
||||||
guard !isRoot(parent, root) else { return false }
|
|
||||||
guard let ancestor = index(at: parent) else { return false }
|
|
||||||
if !ancestor.deleted.isMissing { return true }
|
|
||||||
parent = parent.deletingLastPathComponent()
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isRoot(_ folder: URL, _ root: URL) -> Bool {
|
|
||||||
folder.standardizedFileURL.path == root.standardizedFileURL.path
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Reading
|
// MARK: Reading
|
||||||
|
|
||||||
/// The item's `index.md` as the app reads it, or `nil` when there is no readable, parseable one
|
/// The item's `index.md` as the app reads it, or `nil` when there is no readable, parseable one
|
||||||
|
|||||||
@@ -416,6 +416,27 @@ public final class BannerCenter {
|
|||||||
postLoss(message)
|
postLoss(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The legacy tombstone migration** (01-storage-format.md § Deletion, resettled 2026-07-28:
|
||||||
|
/// "Legacy `deleted:` keys migrate on load-and-write, never destroy … a graceful warning-tone
|
||||||
|
/// notice"): a board written by an older version carried `deleted:` keys, the app moved the
|
||||||
|
/// cards those keys named into `.trash/` and returned the lanes live, and this is the row that
|
||||||
|
/// says so.
|
||||||
|
///
|
||||||
|
/// **A loss row for `postRelocatedLooseFiles`' exact reason**, and it is the same shape of event:
|
||||||
|
/// the app moved the user's folders on its own initiative, on a board it opened rather than on a
|
||||||
|
/// gesture they made. That must be said out loud, must not evaporate unread, and must not rank
|
||||||
|
/// as an error, because no action failed. The one nuance worth naming: the *lane* half is a
|
||||||
|
/// resurrection rather than a removal — cards nobody asked to see again may reappear on the
|
||||||
|
/// board — which is exactly the kind of surprise this class exists to announce.
|
||||||
|
///
|
||||||
|
/// `cards` and `lanes` are the migrated items' titles, in the order they were written, `nil` for
|
||||||
|
/// an untitled one — "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so
|
||||||
|
/// the phrasing layer decides what to call it. A migration that migrated nothing posts nothing.
|
||||||
|
public func postMigratedTombstones(cards: [String?], lanes: [String?]) {
|
||||||
|
guard let message = Self.migratedTombstonesMessage(cards: cards, lanes: lanes) else { return }
|
||||||
|
postLoss(message)
|
||||||
|
}
|
||||||
|
|
||||||
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
|
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
|
||||||
/// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a
|
/// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a
|
||||||
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
|
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
|
||||||
@@ -822,6 +843,59 @@ public final class BannerCenter {
|
|||||||
return "Moved '\(name)' into attachments — \(subject)"
|
return "Moved '\(name)' into attachments — \(subject)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The legacy tombstone migration's line — **one folded sentence for both halves**, written in
|
||||||
|
/// `relocatedLooseFilesMessage`'s voice because it is the same kind of notice: the act first,
|
||||||
|
/// the subject after an em dash, plurals folded, a sole item named.
|
||||||
|
///
|
||||||
|
/// The two clauses are joined rather than posted as two rows, because it is **one migration**:
|
||||||
|
/// a board opened, its old deletion markers were resolved, and that is one thing that happened
|
||||||
|
/// to the user's files. Two rows would also mean two dismissals for one event, and would rank a
|
||||||
|
/// resurrection and a relocation against each other for no reason.
|
||||||
|
///
|
||||||
|
/// The shapes, in the relocation's own idiom:
|
||||||
|
///
|
||||||
|
/// - **One card**, no lanes: "Moved 'Fix login' to the trash — it carried an old deleted marker".
|
||||||
|
/// - **Several cards**: "Moved 3 cards to the trash — they carried old deleted markers".
|
||||||
|
/// - **One lane**, no cards: "Restored 'Doing' — it carried an old deleted marker".
|
||||||
|
/// - **Both**: "Moved 3 cards to the trash and restored 2 lanes — they carried old deleted markers".
|
||||||
|
///
|
||||||
|
/// **The tail names the cause once**, and it is the whole explanation the row owes: the user did
|
||||||
|
/// not delete anything just now, and without the clause the sentence would read as an action
|
||||||
|
/// they had somehow just taken. The singular/plural of the tail follows the *total*, so the
|
||||||
|
/// mixed case never has to spell a singular (two clauses carry at least two items).
|
||||||
|
///
|
||||||
|
/// `nil` when nothing migrated — a migration that migrated nothing is not news.
|
||||||
|
public nonisolated static func migratedTombstonesMessage(cards: [String?], lanes: [String?]) -> String? {
|
||||||
|
let total = cards.count + lanes.count
|
||||||
|
guard total > 0 else { return nil }
|
||||||
|
|
||||||
|
var clauses: [String] = []
|
||||||
|
if !cards.isEmpty {
|
||||||
|
let subject = cards.count == 1
|
||||||
|
? sole(cards[0])
|
||||||
|
: "\(cards.count) cards"
|
||||||
|
clauses.append("Moved \(subject) to the trash")
|
||||||
|
}
|
||||||
|
if !lanes.isEmpty {
|
||||||
|
let subject = lanes.count == 1
|
||||||
|
? sole(lanes[0])
|
||||||
|
: "\(lanes.count) lanes"
|
||||||
|
clauses.append(clauses.isEmpty ? "Restored \(subject)" : "restored \(subject)")
|
||||||
|
}
|
||||||
|
let tail = total == 1
|
||||||
|
? "it carried an old deleted marker"
|
||||||
|
: "they carried old deleted markers"
|
||||||
|
return "\(clauses.joined(separator: " and ")) — \(tail)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sole migrated item's name: its title in quotes, or the untitled rendering the relocation
|
||||||
|
/// line already uses ("an untitled card" / "an untitled lane" are one phrase here, because the
|
||||||
|
/// clause it sits in already says which level it is).
|
||||||
|
private nonisolated static func sole(_ title: String?) -> String {
|
||||||
|
guard let title else { return "an untitled item" }
|
||||||
|
return "'\(title)'"
|
||||||
|
}
|
||||||
|
|
||||||
/// The skipped-step line — 13-native-undo.md ▸ Rules' own example sentence, "Undo skipped — 'Fix
|
/// The skipped-step line — 13-native-undo.md ▸ Rules' own example sentence, "Undo skipped — 'Fix
|
||||||
/// login' changed outside Lanework", with ⇧⌘Z's mirror ("Redo skipped — …").
|
/// login' changed outside Lanework", with ⇧⌘Z's mirror ("Redo skipped — …").
|
||||||
///
|
///
|
||||||
|
|||||||
+691
-644
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - ItemContainer
|
||||||
|
|
||||||
|
/// Which of the board's two **card containers** something sits in — the board's lanes, or the
|
||||||
|
/// board's `.trash/`.
|
||||||
|
///
|
||||||
|
/// **This is the materialized trash's replacement for `Liveness`** (02-architecture.md § Changes
|
||||||
|
/// from Kanban, resettled 2026-07-28; 03-board-ui.md § Trash). Deletion is a *move* now, so there is
|
||||||
|
/// no flag to read, no ancestor to walk, and no "effective liveness" to compute: an item is in a
|
||||||
|
/// container or it is not, and which container is a fact about where its folder sits on disk. The
|
||||||
|
/// tombstone model's two-sided machinery — the absolute ancestor walk, the entry-vs-universe split,
|
||||||
|
/// kind-homogeneity inside the trash — is retired wholesale with it.
|
||||||
|
///
|
||||||
|
/// **An item-referencing set carries one of these** (`ItemReferenceSet`), because 04-interactions.md
|
||||||
|
/// ▸ The trash keeps exactly one boundary: "a selection never mixes trash cards with board cards — a
|
||||||
|
/// single container rule replacing the old liveness law, because Delete would otherwise mean two
|
||||||
|
/// different things in one gesture (move-to-trash vs permanent)".
|
||||||
|
///
|
||||||
|
/// **Lanes live only on the board side.** "Cards only. Lanes are never trashed" (03-board-ui.md §
|
||||||
|
/// Trash), so the trash's universe is cards and nothing else — which is why the trash needs no
|
||||||
|
/// kind axis of its own any more.
|
||||||
|
///
|
||||||
|
/// `String`-backed and `Codable` because the clipboard manifest carries one: a manifest written
|
||||||
|
/// before a quit is decoded after the relaunch, so these raw spellings are pasteboard API, and they
|
||||||
|
/// are the case names so nothing has to remember a second vocabulary.
|
||||||
|
public enum ItemContainer: String, Codable, Sendable, Equatable, CaseIterable {
|
||||||
|
|
||||||
|
/// The board proper — every lane, and every card inside a lane.
|
||||||
|
case board
|
||||||
|
|
||||||
|
/// `<root>/.trash/` — the reserved container deletion moves cards into.
|
||||||
|
case trash
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ItemContainer {
|
||||||
|
|
||||||
|
/// Every id `snapshot` holds in this container — **the universe** every item-referencing set is
|
||||||
|
/// held to (02-architecture.md § Live-reload resilience: "re-resolution matches UUID *and*
|
||||||
|
/// container side ... presence in the snapshot is the whole question").
|
||||||
|
///
|
||||||
|
/// One walk, no filtering: the board side is the lanes plus their cards, the trash side is
|
||||||
|
/// `snapshot.trash`. There is deliberately no liveness predicate anywhere in here — a legacy
|
||||||
|
/// `deleted:` key still riding in from an unmigrated board (`BoardLoader`'s migration window)
|
||||||
|
/// names an ordinary board card until its folder actually moves, which is the safe direction and
|
||||||
|
/// the one the migration then takes (01-storage-format.md § Deletion).
|
||||||
|
public func ids(in snapshot: BoardModel) -> Set<ItemID> {
|
||||||
|
var universe: Set<ItemID> = []
|
||||||
|
switch self {
|
||||||
|
case .board:
|
||||||
|
for lane in snapshot.lanes {
|
||||||
|
universe.insert(lane.id)
|
||||||
|
for card in lane.cards {
|
||||||
|
universe.insert(card.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .trash:
|
||||||
|
for card in snapshot.trash {
|
||||||
|
universe.insert(card.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return universe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ItemPath
|
||||||
|
|
||||||
|
/// Where an item's folder sits under a board root, as identity components rather than as a URL.
|
||||||
|
///
|
||||||
|
/// **Components, not a URL**, for the reason every path-shaped value in this app is: the caller
|
||||||
|
/// builds the URL off the store's *current* `rootURL`, so a board renamed or moved mid-session
|
||||||
|
/// writes at the new location (02-architecture.md § Write-failure surfacing).
|
||||||
|
///
|
||||||
|
/// **Three cases, because the board has exactly three places an identity-bearing folder can be** —
|
||||||
|
/// `<root>/<lane>`, `<root>/<lane>/<card>`, and `<root>/.trash/<card>`. The old two-optional-fields
|
||||||
|
/// shape could spell a fourth thing that does not exist; this cannot.
|
||||||
|
public enum ItemPath: Sendable, Equatable {
|
||||||
|
|
||||||
|
/// A lane: `<root>/<lane>/`.
|
||||||
|
case lane(ItemID)
|
||||||
|
|
||||||
|
/// A card in a lane: `<root>/<lane>/<card>/`.
|
||||||
|
case card(lane: ItemID, id: ItemID)
|
||||||
|
|
||||||
|
/// A card in the board's trash: `<root>/.trash/<card>/`.
|
||||||
|
case trashCard(ItemID)
|
||||||
|
|
||||||
|
/// The item this path names.
|
||||||
|
public var id: ItemID {
|
||||||
|
switch self {
|
||||||
|
case let .lane(id): id
|
||||||
|
case let .card(_, id): id
|
||||||
|
case let .trashCard(id): id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isLane: Bool {
|
||||||
|
if case .lane = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which container this path is in — the board for a lane or a lane's card, the trash for a
|
||||||
|
/// trash card. Derived rather than stored: the case *is* the answer.
|
||||||
|
public var container: ItemContainer {
|
||||||
|
if case .trashCard = self { return .trash }
|
||||||
|
return .board
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This path resolved under a board root.
|
||||||
|
public func folder(under root: URL) -> URL {
|
||||||
|
switch self {
|
||||||
|
case let .lane(id):
|
||||||
|
root.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||||
|
case let .card(lane, id):
|
||||||
|
root
|
||||||
|
.appendingPathComponent(lane.rawValue, isDirectory: true)
|
||||||
|
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||||
|
case let .trashCard(id):
|
||||||
|
BoardWriter.trashFolder(inBoard: root)
|
||||||
|
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ItemPath {
|
||||||
|
|
||||||
|
/// The folders `ids` names inside one container, **in display order**.
|
||||||
|
///
|
||||||
|
/// Display order — lanes left to right, each lane then its cards; the trash top to bottom —
|
||||||
|
/// rather than the caller's set iteration order, which is not an order at all: a batch that
|
||||||
|
/// fails partway must fail the same way twice (`BoardStore.styleSubjects` makes the same choice
|
||||||
|
/// for the same reason).
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
public static func resolve(
|
||||||
|
_ ids: Set<ItemID>,
|
||||||
|
in container: ItemContainer,
|
||||||
|
snapshot: BoardModel
|
||||||
|
) -> [ItemPath] {
|
||||||
|
guard !ids.isEmpty else { return [] }
|
||||||
|
var result: [ItemPath] = []
|
||||||
|
switch container {
|
||||||
|
case .board:
|
||||||
|
for lane in snapshot.lanes {
|
||||||
|
if ids.contains(lane.id) { result.append(.lane(lane.id)) }
|
||||||
|
for card in lane.cards where ids.contains(card.id) {
|
||||||
|
result.append(.card(lane: lane.id, id: card.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .trash:
|
||||||
|
for card in snapshot.trash where ids.contains(card.id) {
|
||||||
|
result.append(.trashCard(card.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where one id lives, searching both containers — `nil` when the snapshot does not hold it.
|
||||||
|
///
|
||||||
|
/// The board is searched first because that is where the overwhelming majority of lookups land;
|
||||||
|
/// an id can only be in one container anyway (board-wide uniqueness spans both —
|
||||||
|
/// 01-storage-format.md § Fractal layout ▸ Rules).
|
||||||
|
public static func of(_ id: ItemID, in snapshot: BoardModel) -> ItemPath? {
|
||||||
|
for lane in snapshot.lanes {
|
||||||
|
if lane.id == id { return .lane(id) }
|
||||||
|
if lane.cards.contains(where: { $0.id == id }) { return .card(lane: lane.id, id: id) }
|
||||||
|
}
|
||||||
|
return snapshot.trash.contains { $0.id == id } ? .trashCard(id) : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ import Foundation
|
|||||||
/// "The filter is the single source of truth for 'what's on the board': layout, drop zones, marquee,
|
/// "The filter is the single source of truth for 'what's on the board': layout, drop zones, marquee,
|
||||||
/// ranges, arrow nav, and lane count badges all read it." They read it *here* — the masonry through
|
/// ranges, arrow nav, and lane count badges all read it." They read it *here* — the masonry through
|
||||||
/// `LaneView.renderedCards`, the ranges and Select All through `SelectionGrammar`'s order lists, the
|
/// `LaneView.renderedCards`, the ranges and Select All through `SelectionGrammar`'s order lists, the
|
||||||
/// trash through `TrashLaneView.entries`, and the selection through
|
/// trash through `SelectionGrammar.trashCards`, and the selection through
|
||||||
/// `TransientBoardState.constrainToSearch(in:)`. There is deliberately no second spelling of "does
|
/// `TransientBoardState.constrainToSearch(in:)`. There is deliberately no second spelling of "does
|
||||||
/// this card match" anywhere, and no stored result set to go stale (`TransientBoardState`, kind 2).
|
/// this card match" anywhere, and no stored result set to go stale (`TransientBoardState`, kind 2).
|
||||||
///
|
///
|
||||||
@@ -29,9 +29,9 @@ import Foundation
|
|||||||
///
|
///
|
||||||
/// **Lanes are never hidden by a card query.** 04 filters *cards*; a lane whose cards all miss the
|
/// **Lanes are never hidden by a card query.** 04 filters *cards*; a lane whose cards all miss the
|
||||||
/// query stays on the board showing an empty body and a `0` badge, because the width division is
|
/// query stays on the board showing an empty body and a `0` badge, because the width division is
|
||||||
/// layout and the filter is content. `matches(_: Lane)` exists only for the trash, whose rows are
|
/// layout and the filter is content. There is no lane overload at all: the tombstone model's trash
|
||||||
/// tombstoned lanes as often as they are cards and which filter "like any lane" by their own
|
/// held lane *entries* that had to be filtered like rows, and lanes are never trashed now
|
||||||
/// title + body (03-board-ui.md § Trash).
|
/// (03-board-ui.md § Trash), so a card predicate is the whole of the filter.
|
||||||
public struct SearchFilter: Sendable, Equatable {
|
public struct SearchFilter: Sendable, Equatable {
|
||||||
|
|
||||||
/// The query exactly as typed — kept so a caller can key a transaction or a test on it.
|
/// The query exactly as typed — kept so a caller can key a transaction or a test on it.
|
||||||
@@ -78,54 +78,32 @@ public struct SearchFilter: Sendable, Equatable {
|
|||||||
matches(title: card.title.value, body: card.body)
|
matches(title: card.title.value, body: card.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A lane, by its own title and description — the trash's lane entries, and nothing on the board
|
|
||||||
/// itself (see the type's doc comment).
|
|
||||||
public func matches(_ lane: Lane) -> Bool {
|
|
||||||
matches(title: lane.title.value, body: lane.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A trash row, **by its own title and body**, whichever kind it is: "shown, it participates in
|
|
||||||
/// the filter like any lane" (03-board-ui.md § Trash), and a lane entry is a row like a card row.
|
|
||||||
///
|
|
||||||
/// A lane entry is deliberately *not* matched through its cards: the entry is one restorable
|
|
||||||
/// thing, and a lane surfacing because a card buried inside it matched would be a row the user
|
|
||||||
/// cannot act on the way the match suggests.
|
|
||||||
public func matches(_ entry: TrashEntry) -> Bool {
|
|
||||||
switch entry {
|
|
||||||
case let .card(card, _): matches(card)
|
|
||||||
case let .lane(lane, _): matches(lane)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - The visible universe
|
// MARK: - The visible universe
|
||||||
|
|
||||||
/// Every id the filter leaves visible on `side` — **the universe
|
/// Every id the filter leaves visible in `container` — **the universe
|
||||||
/// `ItemReferenceSet.constrained(to:)` is handed** for 04's "hidden cards leave the selection"
|
/// `ItemReferenceSet.constrained(to:)` is handed** for 04's "hidden cards leave the selection"
|
||||||
/// (`TransientBoardState.constrainToSearch(in:)`).
|
/// (`TransientBoardState.constrainToSearch(in:)`).
|
||||||
///
|
///
|
||||||
/// It is shaped exactly like `ItemReferenceSet.idUniverse(of:on:)` and means the same thing one
|
/// It is shaped exactly like `ItemContainer.ids(in:)` and means the same thing one step
|
||||||
/// step narrower: that one answers "what does the board *have*", this one "what does the board
|
/// narrower: that one answers "what does the board *have*", this one "what does the board
|
||||||
/// *show*". Two differences, both stated above and neither incidental:
|
/// *show*". The one difference is stated above and is not incidental: **every lane is in it**,
|
||||||
///
|
/// because the filter hides cards, so a lane selection survives a query that empties its body.
|
||||||
/// - **Live lanes are all in it.** The filter hides cards, so a lane is visible whatever its
|
public func visibleIDs(in snapshot: BoardModel, container: ItemContainer) -> Set<ItemID> {
|
||||||
/// cards do — a lane selection survives a query that empties its body.
|
|
||||||
/// - **The trashed side is the trash's rows**, filtered — `TrashModel.entries`' absolute
|
|
||||||
/// ancestor walk, which already excludes the cards a tombstoned lane subsumes. Those have no
|
|
||||||
/// row, so they are visible to nobody and belong in no universe a selection is held to.
|
|
||||||
public func visibleIDs(in snapshot: BoardModel, on side: Liveness) -> Set<ItemID> {
|
|
||||||
switch side {
|
|
||||||
case .live:
|
|
||||||
var ids: Set<ItemID> = []
|
var ids: Set<ItemID> = []
|
||||||
for lane in snapshot.lanes where !lane.isDeleted {
|
switch container {
|
||||||
|
case .board:
|
||||||
|
for lane in snapshot.lanes {
|
||||||
ids.insert(lane.id)
|
ids.insert(lane.id)
|
||||||
for card in lane.cards where !card.isDeleted && matches(card) {
|
for card in lane.cards where matches(card) {
|
||||||
|
ids.insert(card.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .trash:
|
||||||
|
for card in snapshot.trash where matches(card) {
|
||||||
ids.insert(card.id)
|
ids.insert(card.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
case .trashed:
|
|
||||||
return Set(TrashModel.entries(of: snapshot).lazy.filter { matches($0) }.map(\.id))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Folding
|
// MARK: - Folding
|
||||||
|
|||||||
@@ -21,22 +21,22 @@ public enum SelectionKind: String, Codable, Sendable, Equatable {
|
|||||||
case lane
|
case lane
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a pointer click names: an item, its level, and the side of the live/trash boundary the
|
/// What a pointer click names: an item, its level, and the container the surface it was clicked on
|
||||||
/// surface it was clicked on sits on.
|
/// belongs to.
|
||||||
///
|
///
|
||||||
/// The **side is the surface's, not the item's** — a card face is always `.live` and a trash row is
|
/// The **container is the surface's, not the item's** — a card face is always `.board` and a trash
|
||||||
/// always `.trashed`, because that is what the user clicked. A click on a surface whose item flipped
|
/// row is always `.trash`, because that is what the user clicked. A click on a surface whose item
|
||||||
/// liveness a moment ago simply selects nothing the next reload will keep, which is the ordinary
|
/// crossed containers a moment ago simply selects nothing the next reload will keep, which is the
|
||||||
/// vanish rule and not a case for this type to model.
|
/// ordinary vanish rule and not a case for this type to model.
|
||||||
public struct SelectionTarget: Sendable, Equatable {
|
public struct SelectionTarget: Sendable, Equatable {
|
||||||
public var id: ItemID
|
public var id: ItemID
|
||||||
public var kind: SelectionKind
|
public var kind: SelectionKind
|
||||||
public var side: Liveness
|
public var container: ItemContainer
|
||||||
|
|
||||||
public init(id: ItemID, kind: SelectionKind, side: Liveness) {
|
public init(id: ItemID, kind: SelectionKind, container: ItemContainer) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.side = side
|
self.container = container
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,15 +59,16 @@ public enum ClickModifier: Sendable, Equatable {
|
|||||||
/// (`SelectionGrammarTests`).
|
/// (`SelectionGrammarTests`).
|
||||||
///
|
///
|
||||||
/// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is
|
/// **Homogeneity is the invariant, and it is enforced here or nowhere.** The selection is
|
||||||
/// homogeneous on three axes at once — cards XOR lanes (§ Selection), live XOR tombstoned, and
|
/// homogeneous on **two** axes now — cards XOR lanes (§ Selection) and board XOR trash (§ The
|
||||||
/// within the trash card entries XOR lane entries (§ The trash) — and every one of them is a
|
/// trash's "single container rule replacing the old liveness law") — and the third, kind-inside-the
|
||||||
/// property of what a *click* is allowed to produce. So no outcome below is ever mixed: a modifier
|
/// -trash, retired with the lane entries it separated: "Cards only. Lanes are never trashed".
|
||||||
/// that would cross an axis degrades to a replace, which is the only answer that keeps the
|
/// Both surviving axes are a property of what a *click* is allowed to produce, so no outcome below
|
||||||
/// invariant true without silently dropping what the user asked for.
|
/// 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.
|
||||||
///
|
///
|
||||||
/// **Pure, for `NewCardTarget`'s reason**: the branches become lines of test rather than gestures to
|
/// **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,
|
/// drive, and the four surfaces that clicks arrive on (card face, lane header, lane empty space,
|
||||||
/// trash row) share one answer instead of four near-copies of it.
|
/// trash card) share one answer instead of four near-copies of it.
|
||||||
public enum SelectionGrammar {
|
public enum SelectionGrammar {
|
||||||
|
|
||||||
/// What a click leaves behind: the new selection, the anchor a subsequent ⇧-click would range
|
/// What a click leaves behind: the new selection, the anchor a subsequent ⇧-click would range
|
||||||
@@ -100,7 +101,7 @@ public enum SelectionGrammar {
|
|||||||
/// The grammar, one call.
|
/// The grammar, one call.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - target: what was clicked, with the surface's liveness side (see `SelectionTarget`).
|
/// - target: what was clicked, with the surface's container (see `SelectionTarget`).
|
||||||
/// - modifier: the effective modifier, already reduced to one of three (`ClickModifier`).
|
/// - modifier: the effective modifier, already reduced to one of three (`ClickModifier`).
|
||||||
/// - selection: the board's current selection.
|
/// - selection: the board's current selection.
|
||||||
/// - anchor: the range origin — `TransientBoardState.selectionAnchor`.
|
/// - anchor: the range origin — `TransientBoardState.selectionAnchor`.
|
||||||
@@ -145,18 +146,18 @@ public enum SelectionGrammar {
|
|||||||
selection: ItemReferenceSet,
|
selection: ItemReferenceSet,
|
||||||
togglesOnRepeat: Bool
|
togglesOnRepeat: Bool
|
||||||
) -> Outcome {
|
) -> Outcome {
|
||||||
if togglesOnRepeat, selection.liveness == target.side, selection.ids == [target.id] {
|
if togglesOnRepeat, selection.container == target.container, selection.ids == [target.id] {
|
||||||
return .cleared
|
return .cleared
|
||||||
}
|
}
|
||||||
return Outcome(
|
return Outcome(
|
||||||
selection: ItemReferenceSet(ids: [target.id], liveness: target.side),
|
selection: ItemReferenceSet(ids: [target.id], container: target.container),
|
||||||
anchor: target.id,
|
anchor: target.id,
|
||||||
head: target.id
|
head: target.id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **⌘-click toggles** — but only *within* a homogeneous set. Crossing either axis (a card
|
/// **⌘-click toggles** — but only *within* a homogeneous set. Crossing either axis (a card
|
||||||
/// clicked while lanes are selected, a trash row clicked while live cards are) is not a mixed
|
/// 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,
|
/// 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.
|
/// because the click unambiguously names a new set of one.
|
||||||
///
|
///
|
||||||
@@ -168,7 +169,7 @@ public enum SelectionGrammar {
|
|||||||
selection: ItemReferenceSet,
|
selection: ItemReferenceSet,
|
||||||
snapshot: BoardModel
|
snapshot: BoardModel
|
||||||
) -> Outcome {
|
) -> Outcome {
|
||||||
guard selection.liveness == target.side,
|
guard selection.container == target.container,
|
||||||
let current = kind(of: selection, in: snapshot),
|
let current = kind(of: selection, in: snapshot),
|
||||||
current == target.kind
|
current == target.kind
|
||||||
else {
|
else {
|
||||||
@@ -183,7 +184,7 @@ public enum SelectionGrammar {
|
|||||||
return .cleared
|
return .cleared
|
||||||
}
|
}
|
||||||
return Outcome(
|
return Outcome(
|
||||||
selection: ItemReferenceSet(ids: ids, liveness: target.side),
|
selection: ItemReferenceSet(ids: ids, container: target.container),
|
||||||
anchor: target.id,
|
anchor: target.id,
|
||||||
head: target.id
|
head: target.id
|
||||||
)
|
)
|
||||||
@@ -195,7 +196,7 @@ public enum SelectionGrammar {
|
|||||||
///
|
///
|
||||||
/// The anchor is valid **iff both it and the target sit in the same order list** — which folds
|
/// 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
|
/// the nil anchor, the vanished anchor, and every axis crossing into one test, since a list is
|
||||||
/// exactly one (side, kind) pair. An invalid anchor makes the click a plain one, never a no-op:
|
/// exactly one (container, kind) pair. 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
|
/// 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.
|
/// click names an unambiguous target and so always has something to do.
|
||||||
private static func shift(
|
private static func shift(
|
||||||
@@ -210,15 +211,15 @@ public enum SelectionGrammar {
|
|||||||
from: anchor,
|
from: anchor,
|
||||||
to: target.id,
|
to: target.id,
|
||||||
kind: target.kind,
|
kind: target.kind,
|
||||||
on: target.side,
|
in: target.container,
|
||||||
in: snapshot,
|
snapshot: snapshot,
|
||||||
filter: filter
|
filter: filter
|
||||||
)
|
)
|
||||||
else {
|
else {
|
||||||
return plain(target, selection: selection, togglesOnRepeat: false)
|
return plain(target, selection: selection, togglesOnRepeat: false)
|
||||||
}
|
}
|
||||||
return Outcome(
|
return Outcome(
|
||||||
selection: ItemReferenceSet(ids: span, liveness: target.side),
|
selection: ItemReferenceSet(ids: span, container: target.container),
|
||||||
anchor: anchor,
|
anchor: anchor,
|
||||||
head: target.id
|
head: target.id
|
||||||
)
|
)
|
||||||
@@ -234,8 +235,8 @@ public enum SelectionGrammar {
|
|||||||
///
|
///
|
||||||
/// **`nil` means the two do not share a list**, which folds the vanished endpoint, the nil
|
/// **`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
|
/// anchor's caller-side absence, and every axis crossing into one test — a list is exactly one
|
||||||
/// (side, kind) pair. The callers differ on what they do with that: a click degrades to a plain
|
/// (container, kind) pair. The callers differ on what they do with that: a click degrades to a
|
||||||
/// click (it names an unambiguous target), while a ⇧-arrow goes inert (its next step is
|
/// plain click (it names an unambiguous target), while a ⇧-arrow goes inert (its next step is
|
||||||
/// ambiguous).
|
/// ambiguous).
|
||||||
///
|
///
|
||||||
/// **A filtered endpoint is a missing one**, which needs no rule of its own: a card the search
|
/// **A filtered endpoint is a missing one**, which needs no rule of its own: a card the search
|
||||||
@@ -246,44 +247,47 @@ public enum SelectionGrammar {
|
|||||||
from: ItemID,
|
from: ItemID,
|
||||||
to: ItemID,
|
to: ItemID,
|
||||||
kind: SelectionKind,
|
kind: SelectionKind,
|
||||||
on side: Liveness,
|
in container: ItemContainer,
|
||||||
in snapshot: BoardModel,
|
snapshot: BoardModel,
|
||||||
filter: SearchFilter = .inactive
|
filter: SearchFilter = .inactive
|
||||||
) -> Set<ItemID>? {
|
) -> Set<ItemID>? {
|
||||||
let list = order(of: kind, on: side, in: snapshot, filter: filter)
|
let list = order(of: kind, in: container, snapshot: snapshot, filter: filter)
|
||||||
guard let start = list.firstIndex(of: from), let end = list.firstIndex(of: to) else { return nil }
|
guard let start = list.firstIndex(of: from), let end = list.firstIndex(of: to) else { return nil }
|
||||||
return Set(start <= end ? list[start...end] : list[end...start])
|
return Set(start <= end ? list[start...end] : list[end...start])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The order lists
|
// MARK: - The order lists
|
||||||
|
|
||||||
/// The list a ⇧-range walks for one (side, kind) pair — **the single place a "what's on the
|
/// The list a ⇧-range walks for one (container, kind) pair — **the single place a "what's on the
|
||||||
/// board, in what order" question is answered** for the pointer.
|
/// board, in what order" question is answered** for the pointer.
|
||||||
///
|
///
|
||||||
/// **The search filter threads in here and in `MarqueeTargetRegistry`'s membership, and nowhere
|
/// **The search filter threads in here and in `MarqueeTargetRegistry`'s membership, and nowhere
|
||||||
/// else** — the filter "is the single source of truth for what's on the board … ranges … all
|
/// else** — the filter "is the single source of truth for what's on the board … ranges … all
|
||||||
/// read it" (04-interactions.md § Search), and every range, every Select All and every arrow
|
/// read it" (04-interactions.md § Search), and every range, every Select All and every arrow
|
||||||
/// walk is stated in terms of these four lists, so one parameter narrows all of them together.
|
/// walk is stated in terms of these lists, so one parameter narrows all of them together.
|
||||||
///
|
///
|
||||||
/// It defaults to `.inactive` so the many callers with no query in hand (the drag's flatten
|
/// It defaults to `.inactive` so the many callers with no query in hand (the drag's flatten
|
||||||
/// order, a lane-index lookup, the successor's container) read exactly as they did before the
|
/// order, a lane-index lookup, the successor's container) read exactly as they did before the
|
||||||
/// filter existed; the callers that *are* the board's input grammar pass the store's query.
|
/// filter existed; the callers that *are* the board's input grammar pass the store's query.
|
||||||
///
|
///
|
||||||
/// **The lane list takes no filter**, because a card query hides no lane — see `SearchFilter`.
|
/// **The lane list takes no filter**, because a card query hides no lane — see `SearchFilter`.
|
||||||
|
/// **`(.trash, .lane)` is empty by construction**: "Cards only. Lanes are never trashed"
|
||||||
|
/// (03-board-ui.md § Trash), so there is no such list to walk rather than a rule saying not to.
|
||||||
public static func order(
|
public static func order(
|
||||||
of kind: SelectionKind,
|
of kind: SelectionKind,
|
||||||
on side: Liveness,
|
in container: ItemContainer,
|
||||||
in snapshot: BoardModel,
|
snapshot: BoardModel,
|
||||||
filter: SearchFilter = .inactive
|
filter: SearchFilter = .inactive
|
||||||
) -> [ItemID] {
|
) -> [ItemID] {
|
||||||
switch (side, kind) {
|
switch (container, kind) {
|
||||||
case (.live, .card): liveCards(in: snapshot, filter: filter)
|
case (.board, .card): boardCards(in: snapshot, filter: filter)
|
||||||
case (.live, .lane): liveLanes(in: snapshot)
|
case (.board, .lane): lanes(in: snapshot)
|
||||||
case (.trashed, _): trashEntries(of: kind, in: snapshot, filter: filter)
|
case (.trash, .card): trashCards(in: snapshot, filter: filter)
|
||||||
|
case (.trash, .lane): []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live cards in **flatten order** — "lane `order` first, then card `order` (a cross-lane
|
/// The board's cards in **flatten order** — "lane `order` first, then card `order` (a cross-lane
|
||||||
/// selection flattens left-to-right, top-to-bottom)", the multi-drag order the ⌘N target rule
|
/// selection flattens left-to-right, top-to-bottom)", the multi-drag order the ⌘N target rule
|
||||||
/// and paste anchoring already share (04-interactions.md ▸ Drag and drop, ▸ The map).
|
/// and paste anchoring already share (04-interactions.md ▸ Drag and drop, ▸ The map).
|
||||||
///
|
///
|
||||||
@@ -293,85 +297,65 @@ public enum SelectionGrammar {
|
|||||||
/// **The filter narrows the walk in place**, which is what makes a search-time ⇧-range and
|
/// **The filter narrows the walk in place**, which is what makes a search-time ⇧-range and
|
||||||
/// Select All read the same board the masonry drew: `LaneView.renderedCards` applies the same
|
/// Select All read the same board the masonry drew: `LaneView.renderedCards` applies the same
|
||||||
/// predicate to the same cards, one lane at a time, and this is that collection flattened.
|
/// predicate to the same cards, one lane at a time, and this is that collection flattened.
|
||||||
public static func liveCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
|
public static func boardCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
|
||||||
var ids: [ItemID] = []
|
var ids: [ItemID] = []
|
||||||
for lane in snapshot.lanes where !lane.isDeleted {
|
for lane in snapshot.lanes {
|
||||||
for card in lane.cards where !card.isDeleted && filter.matches(card) {
|
for card in lane.cards where filter.matches(card) {
|
||||||
ids.append(card.id)
|
ids.append(card.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live lanes, left to right. Tombstoned lanes render nowhere on the board (03-board-ui.md §
|
/// The board's lanes, left to right.
|
||||||
/// Trash collapses each into one entry), so they are absent from the live lane order entirely.
|
|
||||||
///
|
///
|
||||||
/// **No search filter, deliberately**: 04 § Search filters *cards*, and a lane whose body the
|
/// **No search filter, deliberately**: 04 § Search filters *cards*, and a lane whose body the
|
||||||
/// query empties is still a lane on the board — the width division is layout, and the badge
|
/// query empties is still a lane on the board — the width division is layout, and the badge
|
||||||
/// showing `0` is the honest report. So the lane domain's ranges, arrows and moves are the one
|
/// showing `0` is the honest report. So the lane domain's ranges, arrows and moves are the one
|
||||||
/// part of the board grammar a search does not narrow.
|
/// part of the board grammar a search does not narrow.
|
||||||
public static func liveLanes(in snapshot: BoardModel) -> [ItemID] {
|
public static func lanes(in snapshot: BoardModel) -> [ItemID] {
|
||||||
snapshot.lanes.filter { !$0.isDeleted }.map(\.id)
|
snapshot.lanes.map(\.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One kind of trash row, in the quasi-lane's own deterministic order (`TrashModel.entries`,
|
/// The trash's cards, top to bottom — `snapshot.trash` itself, which the loader already sorted
|
||||||
/// whose sort is "load-bearing for input — arrow walks, ⇧-ranges, and the rubber band all read
|
/// by `order` like any lane's children (03-board-ui.md § Trash: "the trash sorts by `order` like
|
||||||
/// it").
|
/// any lane", newest-first falling out of the ranks rather than a timestamp sort).
|
||||||
///
|
///
|
||||||
/// **Filtered to one kind, so a range skips what it cannot include.** Card and lane entries
|
/// **Filtered like any lane** (03-board-ui.md § Trash: "shown, its cards participate in the
|
||||||
/// interleave in one ordering, and "a selection never mixes card entries and lane entries"
|
/// filter exactly like any other card") — the same predicate `TrashLaneView` applies to the same
|
||||||
/// (04-interactions.md ▸ The trash), so a ⇧-range between two card rows spans the sorted order
|
/// cards, so a trash-side range walks exactly what the column is showing.
|
||||||
/// and collects only the card rows — stepping over any lane row that sits between them. That is
|
public static func trashCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
|
||||||
/// the deliberate pointer twin of the keyboard's rule: a ⇧-arrow onto a lane entry is *inert*
|
snapshot.trash.filter { filter.matches($0) }.map(\.id)
|
||||||
/// because its next step is ambiguous, while a click names an unambiguous same-kind target and
|
|
||||||
/// so the range simply skips.
|
|
||||||
///
|
|
||||||
/// **Filtered like any lane** (03-board-ui.md § Trash) — the same predicate `TrashLaneView`
|
|
||||||
/// applies to the same rows, so a trash-side range walks exactly what the column is showing.
|
|
||||||
public static func trashEntries(
|
|
||||||
of kind: SelectionKind,
|
|
||||||
in snapshot: BoardModel,
|
|
||||||
filter: SearchFilter = .inactive
|
|
||||||
) -> [ItemID] {
|
|
||||||
TrashModel.entries(of: snapshot)
|
|
||||||
.filter { $0.isLaneEntry == (kind == .lane) && filter.matches($0) }
|
|
||||||
.map(\.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The current selection's kind
|
// MARK: - The current selection's kind
|
||||||
|
|
||||||
/// Which level the selection holds, or `nil` when it holds nothing the board renders on its own
|
/// Which level the selection holds, or `nil` when it holds nothing its container renders.
|
||||||
/// side.
|
|
||||||
///
|
///
|
||||||
/// **Any member answers, because the set is homogeneous** — but the walk is the snapshot's order
|
/// **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
|
/// 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
|
/// 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.
|
/// as empty: a selection the next reload will drop must not decide what a click does now.
|
||||||
///
|
///
|
||||||
/// The membership rules are exactly the order lists': on the live side an item counts when its
|
/// **The trash answers `.card` or nothing**, because lanes are never trashed — which is why the
|
||||||
/// effective liveness is live, and on the trashed side only **rows** count — a card under a
|
/// trash's old kind axis (card entries XOR lane entries) has no code left anywhere.
|
||||||
/// tombstoned lane has no row of its own (`TrashModel.entries`' absolute ancestor walk), so it
|
|
||||||
/// is nobody's kind.
|
|
||||||
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
|
public static func kind(of selection: ItemReferenceSet, in snapshot: BoardModel) -> SelectionKind? {
|
||||||
guard !selection.isEmpty else { return nil }
|
guard !selection.isEmpty else { return nil }
|
||||||
|
switch selection.container {
|
||||||
|
case .trash:
|
||||||
|
return snapshot.trash.contains { selection.ids.contains($0.id) } ? .card : nil
|
||||||
|
case .board:
|
||||||
for lane in snapshot.lanes {
|
for lane in snapshot.lanes {
|
||||||
if Liveness(isDeleted: lane.isDeleted) == selection.liveness, selection.ids.contains(lane.id) {
|
if selection.ids.contains(lane.id) { return .lane }
|
||||||
return .lane
|
if lane.cards.contains(where: { selection.ids.contains($0.id) }) { return .card }
|
||||||
}
|
|
||||||
// A tombstoned lane subsumes its subtree on both sides: its cards render nowhere live
|
|
||||||
// and have no trash row of their own.
|
|
||||||
guard !lane.isDeleted else { continue }
|
|
||||||
for card in lane.cards
|
|
||||||
where Liveness(isDeleted: card.isDeleted) == selection.liveness && selection.ids.contains(card.id) {
|
|
||||||
return .card
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Successor on delete
|
// MARK: - Successor on delete
|
||||||
|
|
||||||
/// What ⌫ selects after tombstoning `ids` — 04-interactions.md ▸ The map's Finder-style
|
/// What ⌫ selects after deleting `ids` — 04-interactions.md ▸ The map's Finder-style
|
||||||
/// successor sibling, as a pure function of the **pre-write** snapshot.
|
/// successor sibling, as a pure function of the **pre-write** snapshot.
|
||||||
///
|
///
|
||||||
/// > Selection moves to the deleted item's successor sibling, Finder-style (next card in the
|
/// > Selection moves to the deleted item's successor sibling, Finder-style (next card in the
|
||||||
@@ -389,49 +373,56 @@ public enum SelectionGrammar {
|
|||||||
/// - **`nil` is a legitimate answer** — an emptied container selects nothing, and the caller
|
/// - **`nil` is a legitimate answer** — an emptied container selects nothing, and the caller
|
||||||
/// clears.
|
/// clears.
|
||||||
///
|
///
|
||||||
|
/// **Both stagings of Delete get one** (04, resettled 2026-07-28 — "one Delete vocabulary,
|
||||||
|
/// staged by place"): `container` says which side the gesture ran on, and the trash walks its own
|
||||||
|
/// ordered cards exactly as a lane walks its own. The permanent delete is as deliberate an act as
|
||||||
|
/// the move-to-trash, so it keeps the repeatable-keystroke property the rule exists for.
|
||||||
|
///
|
||||||
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
|
/// **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
|
/// reload-survival rule: "the selection just shrinks"), which is why this is called by
|
||||||
/// `BoardStore.delete` and by nothing on the reload path.
|
/// `BoardStore`'s delete paths and by nothing on the reload path.
|
||||||
///
|
///
|
||||||
/// **The container is what the lane is *showing*.** Under a search the successor must be a card
|
/// **The container is what the surface is *showing*.** Under a search the successor must be a
|
||||||
/// the user can see — "nothing invisible stays selected" is the trash's phrasing of a rule the
|
/// card the user can see — picking a hidden neighbour would hand the selection straight back to
|
||||||
/// filter obeys too — and picking a hidden neighbour would hand the selection straight back to
|
|
||||||
/// `constrainToSearch(in:)` to drop, which is a deselect wearing a successor's clothes. So the
|
/// `constrainToSearch(in:)` to drop, which is a deselect wearing a successor's clothes. So the
|
||||||
/// filter narrows the container, and repeated ⌫ walks down the *filtered* lane.
|
/// filter narrows the container, and repeated ⌫ walks down the *filtered* lane.
|
||||||
public static func successor(
|
public static func successor(
|
||||||
afterDeleting ids: Set<ItemID>,
|
afterDeleting ids: Set<ItemID>,
|
||||||
in snapshot: BoardModel,
|
in container: ItemContainer = .board,
|
||||||
|
snapshot: BoardModel,
|
||||||
filter: SearchFilter = .inactive
|
filter: SearchFilter = .inactive
|
||||||
) -> ItemID? {
|
) -> ItemID? {
|
||||||
guard !ids.isEmpty else { return nil }
|
guard !ids.isEmpty else { return nil }
|
||||||
let selection = ItemReferenceSet(ids: ids, liveness: .live)
|
let selection = ItemReferenceSet(ids: ids, container: container)
|
||||||
guard let kind = kind(of: selection, in: snapshot) else { return nil }
|
guard let kind = kind(of: selection, in: snapshot) else { return nil }
|
||||||
|
|
||||||
let container: [ItemID]
|
let siblings: [ItemID]
|
||||||
switch kind {
|
switch (container, kind) {
|
||||||
case .lane:
|
case (.trash, _):
|
||||||
container = liveLanes(in: snapshot)
|
siblings = trashCards(in: snapshot, filter: filter)
|
||||||
case .card:
|
case (.board, .lane):
|
||||||
|
siblings = lanes(in: snapshot)
|
||||||
|
case (.board, .card):
|
||||||
// The last selected card in flatten order names the lane; its lane's rendered cards are
|
// The last selected card in flatten order names the lane; its lane's rendered cards are
|
||||||
// the container the successor is drawn from.
|
// the container the successor is drawn from.
|
||||||
guard let last = liveCards(in: snapshot, filter: filter).last(where: { ids.contains($0) }),
|
guard let last = boardCards(in: snapshot, filter: filter).last(where: { ids.contains($0) }),
|
||||||
let lane = snapshot.lanes.first(where: { lane in
|
let lane = snapshot.lanes.first(where: { lane in
|
||||||
!lane.isDeleted && lane.cards.contains { $0.id == last && !$0.isDeleted }
|
lane.cards.contains { $0.id == last }
|
||||||
})
|
})
|
||||||
else { return nil }
|
else { return nil }
|
||||||
container = lane.cards.filter { !$0.isDeleted && filter.matches($0) }.map(\.id)
|
siblings = lane.cards.filter { filter.matches($0) }.map(\.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
let doomed = container.indices.filter { ids.contains(container[$0]) }
|
let doomed = siblings.indices.filter { ids.contains(siblings[$0]) }
|
||||||
guard let first = doomed.first, let last = doomed.last else { return nil }
|
guard let first = doomed.first, let last = doomed.last else { return nil }
|
||||||
if let after = container[(last + 1)...].first(where: { !ids.contains($0) }) { return after }
|
if let after = siblings[(last + 1)...].first(where: { !ids.contains($0) }) { return after }
|
||||||
return container[..<first].last { !ids.contains($0) }
|
return siblings[..<first].last { !ids.contains($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The rubber band
|
// MARK: - The rubber band
|
||||||
|
|
||||||
/// One item the marquee can sweep: its identity, its level, its side, and where it is drawn.
|
/// One item the marquee can sweep: its identity, its level, its container, and where it is drawn.
|
||||||
///
|
///
|
||||||
/// The frame is in the board strip's coordinate space (`BoardView.stripSpace`) and is **registered
|
/// The frame is in the board strip's coordinate space (`BoardView.stripSpace`) and is **registered
|
||||||
/// by the view that draws it** (`MarqueeTargetRegistry`) rather than computed here: the masonry's
|
/// by the view that draws it** (`MarqueeTargetRegistry`) rather than computed here: the masonry's
|
||||||
@@ -439,57 +430,54 @@ public enum SelectionGrammar {
|
|||||||
public struct MarqueeTarget: Sendable, Equatable {
|
public struct MarqueeTarget: Sendable, Equatable {
|
||||||
public var id: ItemID
|
public var id: ItemID
|
||||||
public var kind: SelectionKind
|
public var kind: SelectionKind
|
||||||
public var side: Liveness
|
public var container: ItemContainer
|
||||||
public var frame: CGRect
|
public var frame: CGRect
|
||||||
|
|
||||||
public init(id: ItemID, kind: SelectionKind, side: Liveness, frame: CGRect) {
|
public init(id: ItemID, kind: SelectionKind, container: ItemContainer, frame: CGRect) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.side = side
|
self.container = container
|
||||||
self.frame = frame
|
self.frame = frame
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a rubber band selects, as a pure function of the band, the drawn frames, and the side the
|
/// What a rubber band selects, as a pure function of the band, the drawn frames, and the container
|
||||||
/// band started on (`SelectionGrammarTests`).
|
/// the band started in (`SelectionGrammarTests`).
|
||||||
///
|
///
|
||||||
/// The two rules it exists to state, both 04-interactions.md's:
|
/// The two rules it exists to state, both 04-interactions.md's:
|
||||||
///
|
///
|
||||||
/// - **The band stays on the side of the boundary it started on** (▸ The trash), which is why `side`
|
/// - **The band stays on the side of the boundary it started on** (▸ The trash), which is why
|
||||||
/// is a parameter rather than something derived from what the rect happens to touch: a band begun
|
/// `container` is a parameter rather than something derived from what the rect happens to touch: a
|
||||||
/// on the board and dragged over the trash column selects live cards and nothing else.
|
/// 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
|
/// - **The band never selects lanes** (§ Selection gives it to cards: "click-drag rubber-bands
|
||||||
/// across lanes" — across them, not over them). Lanes are simply never registered as targets, and
|
/// across lanes" — across them, not over them). Lanes are simply never registered as targets, and
|
||||||
/// the live branch filters to cards anyway so the rule holds even if one were.
|
/// the filter below keeps the rule true even if one were.
|
||||||
|
///
|
||||||
|
/// **There is no kind rule any more.** Under the tombstone model the trash interleaved card rows and
|
||||||
|
/// lane rows in one column, so the band needed a topmost-wins tie-break to stay homogeneous by kind;
|
||||||
|
/// lanes are never trashed now, so both containers hold cards and the rule is one line for both.
|
||||||
public enum MarqueeMath {
|
public enum MarqueeMath {
|
||||||
|
|
||||||
/// The ids `rect` sweeps.
|
/// The ids `rect` sweeps.
|
||||||
///
|
public static func selection(
|
||||||
/// On the **trashed** side the band must additionally stay homogeneous by *kind*, because the
|
rect: CGRect,
|
||||||
/// trash's two row kinds interleave in one column. The rule is topmost-wins: the kind of the
|
targets: [MarqueeTarget],
|
||||||
/// highest intersecting row decides, and rows of the other kind are dropped — so a band pulled
|
in container: ItemContainer
|
||||||
/// down from a card row keeps collecting card rows and steps over the lane rows between them,
|
) -> Set<ItemID> {
|
||||||
/// exactly as a ⇧-range does.
|
Set(
|
||||||
public static func selection(rect: CGRect, targets: [MarqueeTarget], side: Liveness) -> Set<ItemID> {
|
targets.lazy
|
||||||
let hits = targets.filter { $0.side == side && rect.intersects($0.frame) }
|
.filter { $0.container == container && $0.kind == .card && rect.intersects($0.frame) }
|
||||||
guard !hits.isEmpty else { return [] }
|
.map(\.id)
|
||||||
|
)
|
||||||
switch side {
|
|
||||||
case .live:
|
|
||||||
return Set(hits.lazy.filter { $0.kind == .card }.map(\.id))
|
|
||||||
case .trashed:
|
|
||||||
guard let topmost = hits.min(by: isAbove) else { return [] }
|
|
||||||
return Set(hits.lazy.filter { $0.kind == topmost.kind }.map(\.id))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which of two drawn rows is "higher" — top edge, then leading edge, then identity.
|
/// Which of two drawn rows is "higher" — top edge, then leading edge, then identity.
|
||||||
///
|
///
|
||||||
/// Total rather than merely correct-for-a-column: two rows sharing a top edge must still order
|
/// Total rather than merely correct-for-a-column: two rows sharing a top edge must still order
|
||||||
/// the same way twice, or the topmost-kind rule would pick differently on identical input.
|
/// the same way twice.
|
||||||
///
|
///
|
||||||
/// Shared with `NavigationMath`, which breaks its score ties with it for the same reason: two
|
/// Used by `NavigationMath`, which breaks its score ties with it: two candidates that a metric
|
||||||
/// candidates that a metric cannot separate must still be separated the same way twice.
|
/// cannot separate must still be separated the same way twice.
|
||||||
static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool {
|
static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool {
|
||||||
if lhs.frame.minY != rhs.frame.minY { return lhs.frame.minY < rhs.frame.minY }
|
if lhs.frame.minY != rhs.frame.minY { return lhs.frame.minY < rhs.frame.minY }
|
||||||
if lhs.frame.minX != rhs.frame.minX { return lhs.frame.minX < rhs.frame.minX }
|
if lhs.frame.minX != rhs.frame.minX { return lhs.frame.minX < rhs.frame.minX }
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ public struct StyleEditorSession: Sendable, Equatable {
|
|||||||
case .board:
|
case .board:
|
||||||
return self
|
return self
|
||||||
case let .items(ids):
|
case let .items(ids):
|
||||||
let live = ItemReferenceSet(ids: ids, liveness: .live).resolved(against: snapshot).ids
|
let live = ItemReferenceSet(ids: ids, container: .board).resolved(against: snapshot).ids
|
||||||
guard !live.isEmpty else { return nil }
|
guard !live.isEmpty else { return nil }
|
||||||
return live == ids ? self : StyleEditorSession(target: .items(live))
|
return live == ids ? self : StyleEditorSession(target: .items(live))
|
||||||
}
|
}
|
||||||
@@ -170,9 +170,9 @@ public struct StyleEditorSession: Sendable, Equatable {
|
|||||||
/// the popover lands on the first thing the user's eye would find.
|
/// the popover lands on the first thing the user's eye would find.
|
||||||
public func presentationAnchor(in snapshot: BoardModel) -> ItemID? {
|
public func presentationAnchor(in snapshot: BoardModel) -> ItemID? {
|
||||||
guard case let .items(ids) = target else { return nil }
|
guard case let .items(ids) = target else { return nil }
|
||||||
for lane in snapshot.lanes where !lane.isDeleted {
|
for lane in snapshot.lanes {
|
||||||
if ids.contains(lane.id) { return lane.id }
|
if ids.contains(lane.id) { return lane.id }
|
||||||
if let card = lane.cards.first(where: { !$0.isDeleted && ids.contains($0.id) }) { return card.id }
|
if let card = lane.cards.first(where: { ids.contains($0.id) }) { return card.id }
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
// MARK: - Liveness
|
|
||||||
|
|
||||||
/// Which side of the live/tombstoned boundary something sits on.
|
|
||||||
///
|
|
||||||
/// An item-referencing set is **homogeneous by liveness** (04-interactions.md § The trash): it never
|
|
||||||
/// mixes live and tombstoned items, so the side is a property of the set as a whole rather than of
|
|
||||||
/// each member — which is exactly what makes re-resolution across a reload a matching rule rather
|
|
||||||
/// than a partition.
|
|
||||||
///
|
|
||||||
/// **`String`-backed and `Codable` because the clipboard manifest carries one** (04-interactions.md
|
|
||||||
/// ▸ Clipboard: a manifest records its entries' "source side (live/trashed)"). The raw spellings are
|
|
||||||
/// therefore pasteboard API — a manifest written before a quit is decoded after the relaunch — and
|
|
||||||
/// they are the case names so nothing has to remember a second vocabulary.
|
|
||||||
public enum Liveness: String, Codable, Sendable, Equatable {
|
|
||||||
case live
|
|
||||||
case trashed
|
|
||||||
|
|
||||||
/// The side an item's own tombstone flag puts it on. `Lane.isDeleted`/`Card.isDeleted` are
|
|
||||||
/// presence-of-the-key, not validity, so a malformed `deleted:` still reads as trashed — see
|
|
||||||
/// their doc comments in `BoardModel.swift`.
|
|
||||||
init(isDeleted: Bool) {
|
|
||||||
self = isDeleted ? .trashed : .live
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - The one definition of a side
|
|
||||||
|
|
||||||
extension Liveness {
|
|
||||||
|
|
||||||
/// Every item `snapshot` has on this side, visited in **board order** — lanes left to right,
|
|
||||||
/// each lane immediately before its own cards — as the lane it lives under and, for a card, the
|
|
||||||
/// card itself.
|
|
||||||
///
|
|
||||||
/// **This is the definition, and it is the only one** (02-architecture.md § Changes from Kanban,
|
|
||||||
/// settled: "universe and rows are one function, never a broader set with a pointer-side
|
|
||||||
/// subset"). Everything that asks what is on a side is this walk with a different accumulator:
|
|
||||||
/// `ItemReferenceSet.idUniverse` collects ids, `TrashModel.entries` builds the trash's rows,
|
|
||||||
/// `TrashModel.paths` and `emptyTrashTargets` build folders. Two spellings of the rule would be
|
|
||||||
/// two things to keep in step, and the one they would eventually disagree about is precisely the
|
|
||||||
/// item below.
|
|
||||||
///
|
|
||||||
/// **The whole rule is the `continue`: a tombstoned lane subsumes its subtree.** It contributes
|
|
||||||
/// one item to the trashed side — its own row — and its cards contribute nothing to *either*
|
|
||||||
/// side, whatever their own flags say. That is 03-board-ui.md § Trash's absolute ancestor walk
|
|
||||||
/// stated as code: "a card that carries its own `deleted:` under a tombstoned lane has **no row
|
|
||||||
/// of its own**".
|
|
||||||
///
|
|
||||||
/// **So the two sides do not partition the board, and that is the point.** A card beneath a
|
|
||||||
/// tombstoned lane is in *neither* universe, because it renders nowhere — no row, no membership.
|
|
||||||
/// A selection, a drag, a pending cut, a range anchor or a navigation head can therefore never
|
|
||||||
/// survive a reload sitting on something no surface would draw, and 04-interactions.md § Search's
|
|
||||||
/// hidden-cards-leave-the-selection rule and 02's reload-survival rule stay one rule rather than
|
|
||||||
/// two that happen to agree.
|
|
||||||
///
|
|
||||||
/// Non-escaping and accumulator-driven rather than array-returning: the callers below run on
|
|
||||||
/// every reload and on every menu validation, and none of them wants a board-sized copy of the
|
|
||||||
/// model to throw away.
|
|
||||||
func walk(_ snapshot: BoardModel, visiting visit: (Lane, Card?) -> Void) {
|
|
||||||
for lane in snapshot.lanes {
|
|
||||||
if lane.isDeleted {
|
|
||||||
// The subsumption, both halves of it: the lane is a trash row, and its cards are
|
|
||||||
// nobody's — so the loop below never runs for them.
|
|
||||||
if self == .trashed { visit(lane, nil) }
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if self == .live { visit(lane, nil) }
|
|
||||||
for card in lane.cards where Liveness(isDeleted: card.isDeleted) == self {
|
|
||||||
visit(lane, card)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - ItemReferenceSet
|
// MARK: - ItemReferenceSet
|
||||||
|
|
||||||
/// A set of UUIDs over the snapshot plus the liveness side it lives on — **the one shape every
|
/// A set of UUIDs over the snapshot plus the container it lives in — **the one shape every
|
||||||
/// piece of transient state that points at items wears**: the selection, drag membership, and the
|
/// piece of transient state that points at items wears**: the selection, drag membership, and the
|
||||||
/// pending cut are three values of this type, not three hand-rolled near-copies
|
/// pending cut are three values of this type, not three hand-rolled near-copies
|
||||||
/// (02-architecture.md § Live-reload resilience, "Selection — and every transient state that
|
/// (02-architecture.md § Live-reload resilience, "Selection — and every transient state that
|
||||||
@@ -92,46 +19,52 @@ extension Liveness {
|
|||||||
/// and everything else here is that primitive with a universe supplied. Only the universe differs
|
/// and everything else here is that primitive with a universe supplied. Only the universe differs
|
||||||
/// between the two callers:
|
/// between the two callers:
|
||||||
///
|
///
|
||||||
/// - **Reload survival**: the universe is the new snapshot's ids on this set's liveness side, which
|
/// - **Reload survival**: the universe is the new snapshot's ids in this set's container, which
|
||||||
/// is what `resolved(against:)` computes before delegating.
|
/// is what `resolved(against:)` computes before delegating.
|
||||||
/// - **The live search filter**: the universe is the visible ids the predicate produced, so
|
/// - **The live search filter**: the universe is the visible ids the predicate produced, so
|
||||||
/// 04-interactions.md § Search's "hidden cards leave the selection" needs no second rule — it is
|
/// 04-interactions.md § Search's "hidden cards leave the selection" needs no second rule — it is
|
||||||
/// this one, with a different universe (m5 wires that caller).
|
/// this one, with a different universe.
|
||||||
///
|
///
|
||||||
/// Both stay **pure value functions**. Deciding *when* to apply them belongs to the caller, and
|
/// Both stay **pure value functions**. Deciding *when* to apply them belongs to the caller, and
|
||||||
/// storing the result belongs to `TransientBoardState` — a set that filtered itself would need to
|
/// storing the result belongs to `TransientBoardState` — a set that filtered itself would need to
|
||||||
/// know about snapshots, and the whole point of the value-type snapshot is that nothing has to.
|
/// know about snapshots, and the whole point of the value-type snapshot is that nothing has to.
|
||||||
public struct ItemReferenceSet: Sendable, Equatable {
|
public struct ItemReferenceSet: Sendable, Equatable {
|
||||||
public var ids: Set<ItemID>
|
public var ids: Set<ItemID>
|
||||||
public var liveness: Liveness
|
|
||||||
|
|
||||||
public init(ids: Set<ItemID> = [], liveness: Liveness = .live) {
|
/// Which container the members live in — the board, or `.trash/` (`ItemContainer`).
|
||||||
|
///
|
||||||
|
/// A property of the set as a whole rather than of each member, because 04-interactions.md ▸ The
|
||||||
|
/// trash keeps exactly one boundary: "a selection never mixes trash cards with board cards".
|
||||||
|
/// That is what makes re-resolution across a reload a matching rule rather than a partition.
|
||||||
|
public var container: ItemContainer
|
||||||
|
|
||||||
|
public init(ids: Set<ItemID> = [], container: ItemContainer = .board) {
|
||||||
self.ids = ids
|
self.ids = ids
|
||||||
self.liveness = liveness
|
self.container = container
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nothing referenced, on the live side — the state a board opens in, the state a drag with
|
/// Nothing referenced, on the board side — the state a board opens in, the state a drag with
|
||||||
/// nothing in flight is in, and the state `TransientBoardState.clearSelection()` returns to.
|
/// nothing in flight is in, and the state `TransientBoardState.clearSelection()` returns to.
|
||||||
public static let empty = ItemReferenceSet()
|
public static let empty = ItemReferenceSet()
|
||||||
|
|
||||||
public var isEmpty: Bool { ids.isEmpty }
|
public var isEmpty: Bool { ids.isEmpty }
|
||||||
|
|
||||||
/// This set narrowed to `universe`: members that are in it, **side unchanged**.
|
/// This set narrowed to `universe`: members that are in it, **container unchanged**.
|
||||||
///
|
///
|
||||||
/// The primitive both directions are built from — intersection and nothing else. It is
|
/// The primitive both directions are built from — intersection and nothing else. It is
|
||||||
/// deliberately ignorant of what a universe *is*: a snapshot's ids on one liveness side
|
/// deliberately ignorant of what a universe *is*: a snapshot's ids in one container
|
||||||
/// (`resolved(against:)`) and a search predicate's visible ids are the same argument as far as
|
/// (`resolved(against:)`) and a search predicate's visible ids are the same argument as far as
|
||||||
/// the rule is concerned, which is what lets one rule be stated once and mean both.
|
/// the rule is concerned, which is what lets one rule be stated once and mean both.
|
||||||
///
|
///
|
||||||
/// The liveness side survives even when the membership does not: an emptied set is still a set
|
/// The container survives even when the membership does not: an emptied set is still a set in a
|
||||||
/// on a side, and re-populating it (a fresh click, a new drag) is the caller's business.
|
/// container, and re-populating it (a fresh click, a new drag) is the caller's business.
|
||||||
public func constrained(to universe: Set<ItemID>) -> ItemReferenceSet {
|
public func constrained(to universe: Set<ItemID>) -> ItemReferenceSet {
|
||||||
guard !ids.isEmpty else { return self }
|
guard !ids.isEmpty else { return self }
|
||||||
return ItemReferenceSet(ids: ids.intersection(universe), liveness: liveness)
|
return ItemReferenceSet(ids: ids.intersection(universe), container: container)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This set re-grounded on `snapshot`: the members that are still there, **on the same liveness
|
/// This set re-grounded on `snapshot`: the members that are still there, **in the same
|
||||||
/// side**, and nothing else.
|
/// container**, and nothing else.
|
||||||
///
|
///
|
||||||
/// Two rules, both settled in 02-architecture.md § Live-reload resilience:
|
/// Two rules, both settled in 02-architecture.md § Live-reload resilience:
|
||||||
///
|
///
|
||||||
@@ -139,35 +72,19 @@ public struct ItemReferenceSet: Sendable, Equatable {
|
|||||||
/// an empty result is a legitimate outcome. (App-mediated deletion is deliberately different:
|
/// an empty result is a legitimate outcome. (App-mediated deletion is deliberately different:
|
||||||
/// ⌫ selects the successor sibling, because that is an act rather than a surprise —
|
/// ⌫ selects the successor sibling, because that is an act rather than a surprise —
|
||||||
/// 04-interactions.md ▸ The map. That belongs to the delete command, not here.)
|
/// 04-interactions.md ▸ The map. That belongs to the delete command, not here.)
|
||||||
/// - **A liveness flip is a vanish.** A foreign edit that tombstones a selected live card — or
|
/// - **A container crossing is a vanish** (resettled 2026-07-28, the materialized trash): "a
|
||||||
/// restores a selected tombstoned one — ejects it, keeping 04-interactions.md's
|
/// foreign move that trashes a selected board card — or restores a selected trash card —
|
||||||
/// homogeneous-by-liveness invariant true across reloads so menu validation never sees a
|
/// ejects it from the selection (and from the pending cut)", keeping 04's container-boundary
|
||||||
/// mixed selection. The pending cut inherits the same rule for free (04 ▸ Clipboard: "a cut
|
/// invariant true across reloads so menu validation never sees a mixed selection. Drag
|
||||||
/// item that is tombstoned or vanishes externally before paste drops out of the pending
|
/// membership inherits it for free (04 ▸ Drag and drop's emptied-drag rule).
|
||||||
/// cut"), and so does drag membership (04 ▸ Drag and drop's emptied-drag rule).
|
|
||||||
///
|
///
|
||||||
/// The liveness that is matched is **effective — ancestor-walked** (settled), and the trashed
|
/// **Presence is the whole test.** There is no ancestor walk and no effective liveness left to
|
||||||
/// side is exactly the trash's rows: `Liveness.walk` is the one definition both sides read.
|
/// compute — "the old effective-liveness ancestor walk is retired with the tombstone model"
|
||||||
/// Tombstoning a lane therefore ejects its cards from a live set even though their own flags
|
/// (02-architecture.md) — because a deleted card's folder has actually moved, and a folder is
|
||||||
/// never changed — and does **not** hand them to a trashed set, because the lane's single entry
|
/// either in the container or it is not.
|
||||||
/// subsumes them (03-board-ui.md). A card under a tombstoned lane renders nowhere on either
|
|
||||||
/// side, and nothing invisible may stay selected, drag-included, or pending-cut.
|
|
||||||
public func resolved(against snapshot: BoardModel) -> ItemReferenceSet {
|
public func resolved(against snapshot: BoardModel) -> ItemReferenceSet {
|
||||||
guard !ids.isEmpty else { return self }
|
guard !ids.isEmpty else { return self }
|
||||||
return constrained(to: Self.idUniverse(of: snapshot, on: liveness))
|
return constrained(to: container.ids(in: snapshot))
|
||||||
}
|
|
||||||
|
|
||||||
/// Every id in `snapshot` on `side` — the reload direction's universe.
|
|
||||||
///
|
|
||||||
/// One line over `Liveness.walk`, which is where the rule itself lives and is stated: the live
|
|
||||||
/// side is the live lanes and their unflagged cards, and the trashed side is *exactly* the trash's
|
|
||||||
/// rows — tombstoned lanes, plus cards carrying their own `deleted:` under a live lane. Nothing
|
|
||||||
/// else is in either, so a card hidden beneath a tombstoned lane belongs to no universe and no
|
|
||||||
/// set may go on referencing it.
|
|
||||||
static func idUniverse(of snapshot: BoardModel, on side: Liveness) -> Set<ItemID> {
|
|
||||||
var universe: Set<ItemID> = []
|
|
||||||
side.walk(snapshot) { lane, card in universe.insert(card?.id ?? lane.id) }
|
|
||||||
return universe
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,8 +359,8 @@ public final class TransientBoardState {
|
|||||||
// MARK: Per-open values
|
// MARK: Per-open values
|
||||||
|
|
||||||
/// The lane that most recently held selection or a creation **in this window session** —
|
/// The lane that most recently held selection or a creation **in this window session** —
|
||||||
/// 04-interactions.md's ⌘N target rule's fallback when nothing (or a tombstoned something) is
|
/// 04-interactions.md's ⌘N target rule's fallback when nothing (or a trash selection, which
|
||||||
/// selected, before the last resort of the first lane.
|
/// never anchors creation) is selected, before the last resort of the first lane.
|
||||||
///
|
///
|
||||||
/// It is a *memory of a gesture*, not derived state: with an empty selection there is nothing
|
/// It is a *memory of a gesture*, not derived state: with an empty selection there is nothing
|
||||||
/// in the snapshot that could reconstruct which lane the user was last working in, which is
|
/// in the snapshot that could reconstruct which lane the user was last working in, which is
|
||||||
@@ -454,7 +371,7 @@ public final class TransientBoardState {
|
|||||||
/// is no target at all; `NewCardTarget` then falls through to the first lane.
|
/// is no target at all; `NewCardTarget` then falls through to the first lane.
|
||||||
public private(set) var lastActiveLaneID: ItemID?
|
public private(set) var lastActiveLaneID: ItemID?
|
||||||
|
|
||||||
/// Whether the trash quasi-lane is showing (03-board-ui.md ▸ Trash).
|
/// Whether the trash column is showing (03-board-ui.md ▸ Trash).
|
||||||
///
|
///
|
||||||
/// **Hidden on every open, never persisted**: visiting the trash is an errand, not a layout
|
/// **Hidden on every open, never persisted**: visiting the trash is an errand, not a layout
|
||||||
/// choice, so it does not belong in the board registry beside window frames
|
/// choice, so it does not belong in the board registry beside window frames
|
||||||
@@ -480,8 +397,8 @@ public final class TransientBoardState {
|
|||||||
///
|
///
|
||||||
/// Deliberately **not** filtered against the snapshot: a caller selects what it is rendering, and
|
/// Deliberately **not** filtered against the snapshot: a caller selects what it is rendering, and
|
||||||
/// `resolve(against:)` on the next reload is what keeps the set honest over time.
|
/// `resolve(against:)` on the next reload is what keeps the set honest over time.
|
||||||
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) {
|
public func select(_ ids: Set<ItemID>, in container: ItemContainer, anchor: ItemID? = nil, head: ItemID? = nil) {
|
||||||
selection = ItemReferenceSet(ids: ids, liveness: liveness)
|
selection = ItemReferenceSet(ids: ids, container: container)
|
||||||
let sole = ids.count == 1 ? ids.first : nil
|
let sole = ids.count == 1 ? ids.first : nil
|
||||||
selectionAnchor = anchor ?? sole
|
selectionAnchor = anchor ?? sole
|
||||||
selectionHead = head ?? sole
|
selectionHead = head ?? sole
|
||||||
@@ -666,16 +583,15 @@ public final class TransientBoardState {
|
|||||||
///
|
///
|
||||||
/// **Every item-referencing set is resolved independently.** They are re-grounded against the
|
/// **Every item-referencing set is resolved independently.** They are re-grounded against the
|
||||||
/// same snapshot but never against each other: a card leaving the selection must not disturb a
|
/// same snapshot but never against each other: a card leaving the selection must not disturb a
|
||||||
/// drag in flight or a pending cut that also held it, and each set carries its own liveness
|
/// drag in flight or a pending cut that also held it, and each set carries its own container.
|
||||||
/// side. Independence is what makes that a property of the code rather than of the order the
|
/// Independence is what makes that a property of the code rather than of the order the lines
|
||||||
/// lines happen to be in.
|
/// happen to be in.
|
||||||
///
|
///
|
||||||
/// **The placeholder has its own two rules**, because it references a lane rather than items:
|
/// **The placeholder has its own two rules**, because it references a lane rather than items:
|
||||||
///
|
///
|
||||||
/// - **Discarded when its anchor lane is gone** — absent from the snapshot, or effectively
|
/// - **Discarded when its anchor lane is gone** — absent from the snapshot. "If the
|
||||||
/// tombstoned. A tombstoned lane renders nowhere (03-board-ui.md collapses it to a single
|
/// placeholder's lane vanished in the reload, it is discarded" (02-architecture.md); a lane
|
||||||
/// trash entry), so its lane "vanished" in every sense 02-architecture.md means: "if the
|
/// delete is physical now, so gone is the only way a lane goes.
|
||||||
/// placeholder's lane vanished in the reload, it is discarded".
|
|
||||||
/// - **Discarded as a hand-off** when it is `.awaitingArrival(id)` and `id`'s card is in the
|
/// - **Discarded as a hand-off** when it is `.awaitingArrival(id)` and `id`'s card is in the
|
||||||
/// snapshot. The real card arrived; the overlay's whole job was covering the gap between the
|
/// snapshot. The real card arrived; the overlay's whole job was covering the gap between the
|
||||||
/// Writer's create and the watcher's round trip, and holding it a moment longer would draw the
|
/// Writer's create and the watcher's round trip, and holding it a moment longer would draw the
|
||||||
@@ -687,25 +603,25 @@ public final class TransientBoardState {
|
|||||||
///
|
///
|
||||||
/// **The rename editor has one rule, and it is the vanish rule** (04-interactions.md ▸
|
/// **The rename editor has one rule, and it is the vanish rule** (04-interactions.md ▸
|
||||||
/// Grammar, "Inline rename tracks its target by UUID, and vanishing discards it"): a target
|
/// Grammar, "Inline rename tracks its target by UUID, and vanishing discards it"): a target
|
||||||
/// that is tombstoned, deleted, or gone discards the editor and its keystrokes silently.
|
/// that is trashed, deleted, or gone discards the editor and its keystrokes silently —
|
||||||
/// A foreign *move* is deliberately not a vanish — the editor follows the UUID and the commit
|
/// "entering the trash is a vanish from the board; nothing is ever written into a vanished
|
||||||
/// writes wherever the item now lives — which falls out for free from matching on identity
|
/// folder". A foreign *move between lanes* is deliberately not a vanish — the editor follows
|
||||||
/// rather than on position. Liveness is **effective**, so a card under a lane an agent just
|
/// the UUID and the commit writes wherever the card now lives — which falls out for free from
|
||||||
/// tombstoned vanishes with it.
|
/// matching on identity within the board container.
|
||||||
///
|
///
|
||||||
/// **`lastActiveLaneID` is cleared when its lane goes**, for the reason 02-architecture.md
|
/// **`lastActiveLaneID` is cleared when its lane goes**, for the reason 02-architecture.md
|
||||||
/// gives every item-referencing piece of transient state: nothing may reference an item the
|
/// gives every item-referencing piece of transient state: nothing may reference an item the
|
||||||
/// current universe does not have. It is not an `ItemReferenceSet` only because it is one
|
/// current universe does not have. It is not an `ItemReferenceSet` only because it is one
|
||||||
/// optional rather than a set on a side — the rule it obeys is the same one.
|
/// optional rather than a set on a side — the rule it obeys is the same one.
|
||||||
///
|
///
|
||||||
/// **`selectionAnchor` obeys it too**, on the *selection's* side: a range origin that vanished
|
/// **`selectionAnchor` obeys it too**, in the *selection's* container: a range origin that
|
||||||
/// or flipped liveness is gone, and the next ⇧-click acts as a plain click rather than ranging
|
/// vanished or crossed containers is gone, and the next ⇧-click acts as a plain click rather
|
||||||
/// from somewhere that renders nowhere. It deliberately does **not** have to stay *in* the
|
/// than ranging from somewhere that renders nowhere. It deliberately does **not** have to stay
|
||||||
/// selection — a ⌘-click that toggles the anchor's neighbour out leaves the anchor selected and
|
/// *in* the selection — a ⌘-click that toggles the anchor's neighbour out leaves the anchor
|
||||||
/// a range from it is still exactly what the user asked for.
|
/// selected and a range from it is still exactly what the user asked for.
|
||||||
///
|
///
|
||||||
/// **The style editor tracks its target set live** (03-board-ui.md § Styling ▸ Controls,
|
/// **The style editor tracks its target set live** (03-board-ui.md § Styling ▸ Controls,
|
||||||
/// settled): a member that vanishes or flips liveness leaves the set — so the editor's
|
/// settled): a member that vanishes or crosses containers leaves the set — so the editor's
|
||||||
/// mixed-state display recomputes off the survivors — and a set emptied by a foreign reload
|
/// mixed-state display recomputes off the survivors — and a set emptied by a foreign reload
|
||||||
/// clears the session, which is how "the popover dismisses when it empties" reaches the screen.
|
/// clears the session, which is how "the popover dismisses when it empties" reaches the screen.
|
||||||
/// It never becomes a board session on the way; `StyleEditorSession.resolved(against:)` owns
|
/// It never becomes a board session on the way; `StyleEditorSession.resolved(against:)` owns
|
||||||
@@ -725,25 +641,22 @@ public final class TransientBoardState {
|
|||||||
newCardPlaceholder = resolvedPlaceholder(against: snapshot)
|
newCardPlaceholder = resolvedPlaceholder(against: snapshot)
|
||||||
styleEditor = styleEditor?.resolved(against: snapshot)
|
styleEditor = styleEditor?.resolved(against: snapshot)
|
||||||
|
|
||||||
// One universe computed once and asked three questions — the rename target's liveness, the
|
// One universe computed once and asked three questions — the rename target's container, the
|
||||||
// last-active lane's, and (via the placeholder above, which asks its own way) the anchor's.
|
// last-active lane's, and (via the placeholder above, which asks its own way) the anchor's.
|
||||||
let live = ItemReferenceSet.idUniverse(of: snapshot, on: .live)
|
let board = ItemContainer.board.ids(in: snapshot)
|
||||||
if let editor = renameEditor, !live.contains(editor.targetID) {
|
if let editor = renameEditor, !board.contains(editor.targetID) {
|
||||||
renameEditor = nil
|
renameEditor = nil
|
||||||
}
|
}
|
||||||
if let lane = lastActiveLaneID, !live.contains(lane) {
|
if let lane = lastActiveLaneID, !board.contains(lane) {
|
||||||
lastActiveLaneID = nil
|
lastActiveLaneID = nil
|
||||||
}
|
}
|
||||||
if selectionAnchor != nil || selectionHead != nil {
|
if selectionAnchor != nil || selectionHead != nil {
|
||||||
// The selection's side, because that is the side both cursors live on by construction —
|
// The selection's container, because that is where both cursors live by construction —
|
||||||
// every route that sets either sets the selection to the same side in the same call. A
|
// every route that sets either sets the selection to the same container in the same
|
||||||
// vanished or liveness-flipped cursor is gone, which is the rule every item reference
|
// call. A vanished or container-crossed cursor is gone, which is the rule every item
|
||||||
// here gets: "a flip is a vanish from its side of the boundary". The head then re-derives
|
// reference here gets. The head then re-derives from the selection's last member on the
|
||||||
// from the selection's last member on the next arrow, which is the same fallback an
|
// next arrow, which is the same fallback an anchorless ⇧-arrow already uses.
|
||||||
// anchorless ⇧-arrow already uses.
|
let universe = selection.container == .board ? board : selection.container.ids(in: snapshot)
|
||||||
let universe = selection.liveness == .live
|
|
||||||
? live
|
|
||||||
: ItemReferenceSet.idUniverse(of: snapshot, on: selection.liveness)
|
|
||||||
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
|
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
|
||||||
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
|
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
|
||||||
}
|
}
|
||||||
@@ -776,13 +689,14 @@ public final class TransientBoardState {
|
|||||||
/// under a live filter is a gesture in flight, not a set the filter has any claim on. The
|
/// under a live filter is a gesture in flight, not a set the filter has any claim on. The
|
||||||
/// editor's absence is the settled ruling in person: "an open inline rename survives the filter
|
/// editor's absence is the settled ruling in person: "an open inline rename survives the filter
|
||||||
/// hiding its card … the vanish-discard rule stays reserved for true liveness flips"
|
/// hiding its card … the vanish-discard rule stays reserved for true liveness flips"
|
||||||
/// (`RenameEditor`), so a foreign edit that stops the renaming card matching drops it from the
|
/// (`RenameEditor`) — read for the materialized trash, true container crossings — so a foreign
|
||||||
/// selection here and leaves the keystrokes exactly where the user left them.
|
/// edit that stops the renaming card matching drops it from the selection here and leaves the
|
||||||
|
/// keystrokes exactly where the user left them.
|
||||||
public func constrainToSearch(in snapshot: BoardModel) {
|
public func constrainToSearch(in snapshot: BoardModel) {
|
||||||
let filter = SearchFilter(query: searchQuery)
|
let filter = SearchFilter(query: searchQuery)
|
||||||
guard filter.isActive else { return }
|
guard filter.isActive else { return }
|
||||||
|
|
||||||
let universe = filter.visibleIDs(in: snapshot, on: selection.liveness)
|
let universe = filter.visibleIDs(in: snapshot, container: selection.container)
|
||||||
selection = selection.constrained(to: universe)
|
selection = selection.constrained(to: universe)
|
||||||
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
|
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
|
||||||
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
|
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
|
||||||
@@ -792,9 +706,7 @@ public final class TransientBoardState {
|
|||||||
private func resolvedPlaceholder(against snapshot: BoardModel) -> NewCardPlaceholder? {
|
private func resolvedPlaceholder(against snapshot: BoardModel) -> NewCardPlaceholder? {
|
||||||
guard let placeholder = newCardPlaceholder else { return nil }
|
guard let placeholder = newCardPlaceholder else { return nil }
|
||||||
|
|
||||||
guard let anchor = snapshot.lanes.first(where: { $0.id == placeholder.laneID }),
|
guard snapshot.lanes.contains(where: { $0.id == placeholder.laneID }) else { return nil }
|
||||||
!anchor.isDeleted
|
|
||||||
else { return nil }
|
|
||||||
|
|
||||||
if case let .awaitingArrival(id) = placeholder.phase,
|
if case let .awaitingArrival(id) = placeholder.phase,
|
||||||
snapshot.lanes.contains(where: { $0.cards.contains { $0.id == id } }) {
|
snapshot.lanes.contains(where: { $0.cards.contains { $0.id == id } }) {
|
||||||
|
|||||||
@@ -1,409 +1,147 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - TrashEntry
|
|
||||||
|
|
||||||
/// One row of the trash quasi-lane (03-board-ui.md § Trash).
|
|
||||||
///
|
|
||||||
/// **Two cases, not one**, because a tombstoned lane is not a tombstoned card wearing a different
|
|
||||||
/// symbol: it is "a single restorable entry" that *subsumes* everything beneath it, and Put Back on
|
|
||||||
/// it "returns [the lane] whole, cards and all" (04-interactions.md ▸ The trash). The card count it
|
|
||||||
/// carries is part of the entry rather than something the row re-derives, because the rule for what
|
|
||||||
/// that number means is subtle enough to want one home — see `TrashModel.entries(of:)`.
|
|
||||||
///
|
|
||||||
/// The card case carries its lane's id for the same reason `BoardStore.liveItem` returns two
|
|
||||||
/// components rather than a URL: the row's folder is `<root>/<lane>/<card>`, and the root is the
|
|
||||||
/// store's to supply — a mid-session folder rename may have moved it.
|
|
||||||
public enum TrashEntry: Identifiable, Sendable, Equatable {
|
|
||||||
|
|
||||||
/// A card carrying its own `deleted:` under a **live** lane. A card whose lane is tombstoned
|
|
||||||
/// never becomes one of these — see `TrashModel.entries(of:)`'s ancestor walk.
|
|
||||||
case card(Card, laneID: ItemID)
|
|
||||||
|
|
||||||
/// A tombstoned lane, and how many of its cards Put Back would return to the board.
|
|
||||||
case lane(Lane, returningCardCount: Int)
|
|
||||||
|
|
||||||
public var id: ItemID {
|
|
||||||
switch self {
|
|
||||||
case let .card(card, _): card.id
|
|
||||||
case let .lane(lane, _): lane.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The title as written, or `nil` for an untitled item — "Untitled" is a rendering, never a
|
|
||||||
/// value (03-board-ui.md § Card face).
|
|
||||||
public var title: String? {
|
|
||||||
switch self {
|
|
||||||
case let .card(card, _): card.title.value
|
|
||||||
case let .lane(lane, _): lane.title.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which kind of row this is — the axis 04-interactions.md ▸ The trash makes a selection
|
|
||||||
/// homogeneous over ("a selection never mixes card entries and lane entries").
|
|
||||||
public var isLaneEntry: Bool {
|
|
||||||
if case .lane = self { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `icon` field this row renders, so the row's symbol obeys the same lenient rule the board
|
|
||||||
/// face does (`ItemSymbol`).
|
|
||||||
public var icon: FieldValue<String> {
|
|
||||||
switch self {
|
|
||||||
case let .card(card, _): card.icon
|
|
||||||
case let .lane(lane, _): lane.icon
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where this row's folder sits under the board root.
|
|
||||||
public var path: TrashModel.ItemPath {
|
|
||||||
switch self {
|
|
||||||
case let .card(card, laneID): TrashModel.ItemPath(laneID: laneID, cardID: card.id)
|
|
||||||
case let .lane(lane, _): TrashModel.ItemPath(laneID: lane.id, cardID: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - TrashModel
|
// MARK: - TrashModel
|
||||||
|
|
||||||
/// The trash quasi-lane's contents, as a pure function of a snapshot (`TrashModelTests`) —
|
/// What is left of the trash as a *model* once the trash became a folder — 03-board-ui.md § Trash's
|
||||||
/// 03-board-ui.md § Trash's Contents rules with nothing else mixed in.
|
/// **materialized** container (resettled 2026-07-28).
|
||||||
///
|
///
|
||||||
/// **Pure because the trash is a pure view.** "Tombstoned cards keep their `deleted:` key and stay
|
/// ### Almost nothing, and that is the point of the pivot
|
||||||
/// exactly where they are on disk; nothing about the storage schema is trash-specific", so there is
|
|
||||||
/// no trash *state* anywhere — only this derivation, re-run against whatever snapshot is current.
|
|
||||||
/// A reload therefore rebuilds the rows for free, exactly as it rebuilds the lanes.
|
|
||||||
///
|
///
|
||||||
/// The three rules it owns, each of which the design settles explicitly:
|
/// The tombstone model needed a whole derivation layer: an entry type, an absolute ancestor walk, a
|
||||||
|
/// 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` — already parsed by the same card
|
||||||
|
/// parse the lanes use, 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.
|
||||||
///
|
///
|
||||||
/// 1. **The absolute ancestor walk.** A tombstoned lane's entry subsumes everything beneath it — "a
|
/// What genuinely remains is what the *commands* need and no view can answer: the two purge
|
||||||
/// card that carries its own `deleted:` under a tombstoned lane has **no row of its own**". There
|
/// confirmations' phrasing, and the menu validation that stages Delete by place. Both are pure
|
||||||
/// is no trash carve-out from 01-storage-format.md's consumer rule. This rule is not spelled
|
/// functions of a snapshot and a selection (`TrashModelTests`), so an alert's sentence is testable
|
||||||
/// here: it is `Liveness.walk`'s, because the trashed **universe** every item-referencing set is
|
/// without an alert on screen.
|
||||||
/// held to *is* this row set — "universe and rows are one function" (02-architecture.md § Changes
|
|
||||||
/// from Kanban, settled). Everything below that asks what is in the trash asks that walk, so the
|
|
||||||
/// rows a user sees and the ids a selection may hold cannot drift apart.
|
|
||||||
/// 2. **The returning count.** A lane entry's number "counts what Put Back returns to the board —
|
|
||||||
/// cards without their own flag; individually tombstoned descendants aren't in that number, since
|
|
||||||
/// they come back to the *trash*".
|
|
||||||
/// 3. **The deterministic sort.** Newest `deleted` first; ties by folder name ascending; an
|
|
||||||
/// unparseable stamp sorts as *oldest*, after every dated entry, folder-name-ordered among its
|
|
||||||
/// kind; lane entries interleave in the same single ordering by their own stamp. The order is
|
|
||||||
/// load-bearing for input — "arrow walks, ⇧-ranges, and the rubber band all read it" — so it is
|
|
||||||
/// total, not merely stable.
|
|
||||||
public enum TrashModel {
|
public enum TrashModel {
|
||||||
|
|
||||||
// MARK: - Where a row lives
|
|
||||||
|
|
||||||
/// An item's folder, as its identity components rather than as a URL.
|
|
||||||
///
|
|
||||||
/// Same shape and same reasoning as `BoardStore.liveItem`'s return: the caller builds the URL off
|
|
||||||
/// the store's *current* `rootURL`, so a board renamed or moved mid-session writes at the new
|
|
||||||
/// location (02-architecture.md § Write-failure surfacing).
|
|
||||||
public struct ItemPath: Sendable, Equatable {
|
|
||||||
public let laneID: ItemID
|
|
||||||
/// `nil` for a lane — the path is then the lane folder itself.
|
|
||||||
public let cardID: ItemID?
|
|
||||||
|
|
||||||
public init(laneID: ItemID, cardID: ItemID?) {
|
|
||||||
self.laneID = laneID
|
|
||||||
self.cardID = cardID
|
|
||||||
}
|
|
||||||
|
|
||||||
public var isLane: Bool { cardID == nil }
|
|
||||||
|
|
||||||
/// This path resolved under a board root.
|
|
||||||
public func folder(under root: URL) -> URL {
|
|
||||||
var url = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
|
||||||
if let cardID {
|
|
||||||
url.append(component: cardID.rawValue, directoryHint: .isDirectory)
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Entries
|
|
||||||
|
|
||||||
/// The trash's rows, in the order the quasi-lane shows them.
|
|
||||||
///
|
|
||||||
/// **Which items are rows is not decided here** — it is `Liveness.trashed.walk`, the same walk
|
|
||||||
/// `ItemReferenceSet.idUniverse(of:on:)` reads, because the trashed universe and this row set are
|
|
||||||
/// one function. Its `continue` on a tombstoned lane is the absolute ancestor walk: such a lane
|
|
||||||
/// contributes exactly one entry and its cards contribute none, whatever their own flags say.
|
|
||||||
///
|
|
||||||
/// What is left for this function is what a row *carries* — the returning count and the sort
|
|
||||||
/// inputs — and the order it shows in, neither of which any other caller of the walk wants.
|
|
||||||
public static func entries(of snapshot: BoardModel) -> [TrashEntry] {
|
|
||||||
var rows: [Row] = []
|
|
||||||
Liveness.trashed.walk(snapshot) { lane, card in
|
|
||||||
if let card {
|
|
||||||
rows.append(Row(
|
|
||||||
entry: .card(card, laneID: lane.id),
|
|
||||||
deleted: card.deleted.value,
|
|
||||||
name: card.id.rawValue
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
// Rule 2: only the cards *without* their own flag come back with the lane. The ones
|
|
||||||
// that carry a flag stay tombstoned and get their rows back in the trash — which is
|
|
||||||
// why Put Back on such a card is deliberately two steps.
|
|
||||||
let returning = lane.cards.filter { !$0.isDeleted }.count
|
|
||||||
rows.append(Row(
|
|
||||||
entry: .lane(lane, returningCardCount: returning),
|
|
||||||
deleted: lane.deleted.value,
|
|
||||||
name: lane.id.rawValue
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows.sorted(by: isOrdered).map(\.entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the board has anything in its trash at all — the "non-empty" half of Empty Trash…'s
|
|
||||||
/// menu validation, which "reads the board's tombstones, not the filtered view" (03 ▸ Trash).
|
|
||||||
///
|
|
||||||
/// The walk's non-emptiness in **short-circuit form**, which is the one place the rule is
|
|
||||||
/// restated and only because stopping early is the whole point: a tombstoned lane is a row
|
|
||||||
/// outright, and under a live lane any own-flagged card is one. There is deliberately no third
|
|
||||||
/// clause for a card beneath a tombstoned lane — the lane has already answered `true` for it.
|
|
||||||
/// `TrashModelTests` pins the equivalence to `entries(of:).isEmpty` so the shortcut cannot drift.
|
|
||||||
public static func isEmpty(_ snapshot: BoardModel) -> Bool {
|
|
||||||
!snapshot.lanes.contains { lane in
|
|
||||||
lane.isDeleted || lane.cards.contains(where: \.isDeleted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One entry's sort inputs, kept beside it so the comparator never re-reads the model.
|
|
||||||
private struct Row {
|
|
||||||
let entry: TrashEntry
|
|
||||||
/// The parsed `deleted` stamp, or `nil` when the value is present but unparseable —
|
|
||||||
/// 01-storage-format.md's unusable-timestamp rule, which still deletes (presence, not
|
|
||||||
/// validity) but supplies no position in time.
|
|
||||||
let deleted: Date?
|
|
||||||
/// The folder name, byte-for-byte — the tie-break the loader's display order already uses
|
|
||||||
/// (`Ranks.sortedForDisplay`, `name: { $0.id.rawValue }`), so the trash breaks ties the same
|
|
||||||
/// way the board does.
|
|
||||||
let name: String
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The sort, stated once: newest first among dated entries, then every undated entry.
|
|
||||||
///
|
|
||||||
/// **Undated sorts oldest, not first.** "A corrupt stamp must not outrank fresh deletions for the
|
|
||||||
/// trash's most prominent rows" — so an unparseable value loses to every real timestamp, however
|
|
||||||
/// old, and orders by folder name among its own kind.
|
|
||||||
private static func isOrdered(_ lhs: Row, _ rhs: Row) -> Bool {
|
|
||||||
switch (lhs.deleted, rhs.deleted) {
|
|
||||||
case let (left?, right?):
|
|
||||||
return left == right ? lhs.name < rhs.name : left > right
|
|
||||||
case (.some, nil):
|
|
||||||
return true
|
|
||||||
case (nil, .some):
|
|
||||||
return false
|
|
||||||
case (nil, nil):
|
|
||||||
return lhs.name < rhs.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Paths for the trash's writes
|
|
||||||
|
|
||||||
/// The folders `ids` names, in display order, restricted to one liveness side.
|
|
||||||
///
|
|
||||||
/// **The membership rule is not restated here** — it is `Liveness.walk`'s, the same one
|
|
||||||
/// `ItemReferenceSet.idUniverse` and `entries(of:)` read — spelled in *paths* rather than ids
|
|
||||||
/// because a write needs to know where the item is. So the trashed side is the trash's rows and
|
|
||||||
/// nothing besides: a card beneath a tombstoned lane is not individually addressable, which costs
|
|
||||||
/// nothing (it has no row for a user to act on, so no command can name it) and buys the guarantee
|
|
||||||
/// that every path this hands a writer names something the board would draw.
|
|
||||||
///
|
|
||||||
/// **A tombstoned lane still takes its subtree with it**, and that is subsumption rather than
|
|
||||||
/// omission: its path is the lane *folder*, and removing a folder removes what is inside it. Put
|
|
||||||
/// Back on it restores the lane and every card that rode along; Delete Immediately on it purges
|
|
||||||
/// the whole tree, own-flag cards included — the outcome the lane entry's confirmation sentence
|
|
||||||
/// exists to warn about (`message(lanes:unrecoverable:)`).
|
|
||||||
///
|
|
||||||
/// Display order — lanes left to right, each lane then its cards — rather than the caller's set
|
|
||||||
/// iteration order, which is not an order at all: a batch that fails partway must fail the same
|
|
||||||
/// way twice (`BoardStore.styleSubjects` makes the same choice for the same reason). The walk
|
|
||||||
/// visits in exactly that order, so this is a filter over it and never a sort.
|
|
||||||
public static func paths(of ids: Set<ItemID>, on side: Liveness, in snapshot: BoardModel) -> [ItemPath] {
|
|
||||||
guard !ids.isEmpty else { return [] }
|
|
||||||
var result: [ItemPath] = []
|
|
||||||
side.walk(snapshot) { lane, card in
|
|
||||||
guard ids.contains(card?.id ?? lane.id) else { return }
|
|
||||||
result.append(ItemPath(laneID: lane.id, cardID: card?.id))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every folder Empty Trash removes — "emptying purges every tombstone on the board, filter or
|
|
||||||
/// no filter" (03 ▸ Trash).
|
|
||||||
///
|
|
||||||
/// `paths(of:on:in:)` on the trashed side with **no id filter at all**, which is the strongest
|
|
||||||
/// form of that guarantee: the command's scope is the trashed universe itself, so it cannot
|
|
||||||
/// narrow to a selection any more than it can narrow to the search.
|
|
||||||
///
|
|
||||||
/// **A tombstoned lane contributes only itself**, and that is not an omission: removing the lane
|
|
||||||
/// folder removes the tree beneath it, own-flag cards included. Listing those cards as well would
|
|
||||||
/// be redundant purges of paths the first removal already took (harmless — `purgeItem` treats a
|
|
||||||
/// folder that is already gone as success — but noise) and would require a second, broader
|
|
||||||
/// definition of "in the trash" than the one every other caller reads.
|
|
||||||
public static func emptyTrashTargets(in snapshot: BoardModel) -> [ItemPath] {
|
|
||||||
var result: [ItemPath] = []
|
|
||||||
Liveness.trashed.walk(snapshot) { lane, card in
|
|
||||||
result.append(ItemPath(laneID: lane.id, cardID: card?.id))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Counts and phrasing
|
// MARK: - Counts and phrasing
|
||||||
|
|
||||||
/// How many lane entries and card entries a set of entries holds — the confirmation dialogs'
|
/// "41 cards", "1 card" — 06-history-undo.md's **plural folding**, which is all the folding a
|
||||||
/// only input beyond the item titles.
|
/// cards-only container can need ("Cards only. Lanes are never trashed" — 03-board-ui.md).
|
||||||
public struct EntryCounts: Sendable, Equatable {
|
public static func phrase(_ count: Int) -> String {
|
||||||
public var lanes: Int = 0
|
"\(count) card\(count == 1 ? "" : "s")"
|
||||||
public var cards: Int = 0
|
|
||||||
|
|
||||||
public var total: Int { lanes + cards }
|
|
||||||
public var isEmpty: Bool { total == 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func counts(of entries: [TrashEntry]) -> EntryCounts {
|
|
||||||
var counts = EntryCounts()
|
|
||||||
for entry in entries {
|
|
||||||
if entry.isLaneEntry { counts.lanes += 1 } else { counts.cards += 1 }
|
|
||||||
}
|
|
||||||
return counts
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The counts a set of `ItemPath`s describes — the same two numbers, from the shape the write
|
|
||||||
/// path actually carries.
|
|
||||||
public static func counts(of paths: [ItemPath]) -> EntryCounts {
|
|
||||||
var counts = EntryCounts()
|
|
||||||
for path in paths {
|
|
||||||
if path.isLane { counts.lanes += 1 } else { counts.cards += 1 }
|
|
||||||
}
|
|
||||||
return counts
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "2 lanes and 3 cards", "41 cards", "1 lane" — 06-history-undo.md's **plural folding** applied
|
|
||||||
/// to a mixed trash selection.
|
|
||||||
///
|
|
||||||
/// An empty count reads "nothing", which no caller renders: both confirmations refuse to open on
|
|
||||||
/// an empty scope. It is spelled anyway so the function is total.
|
|
||||||
public static func phrase(_ counts: EntryCounts) -> String {
|
|
||||||
switch (counts.lanes, counts.cards) {
|
|
||||||
case (0, 0): "nothing"
|
|
||||||
case let (0, cards): plural(cards, "card")
|
|
||||||
case let (lanes, 0): plural(lanes, "lane")
|
|
||||||
case let (lanes, cards): "\(plural(lanes, "lane")) and \(plural(cards, "card"))"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func plural(_ count: Int, _ noun: String) -> String {
|
|
||||||
"\(count) \(noun)\(count == 1 ? "" : "s")"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Confirmations
|
// MARK: - Confirmations
|
||||||
|
|
||||||
/// A purge confirmation's three strings, built once and rendered by the window's alert.
|
/// A purge confirmation's three strings, built once and rendered by the window's alert.
|
||||||
///
|
///
|
||||||
/// A value rather than a view so the phrasing rules — plural folding, naming a sole item, the
|
/// A value rather than a view so the phrasing rules — plural folding, naming a sole item, and
|
||||||
/// lane caveat, and whether the loss is actually irreversible — are testable without an alert on
|
/// whether the loss is actually irreversible — are testable without an alert on screen.
|
||||||
/// screen (`TrashModelTests`).
|
|
||||||
public struct PurgePrompt: Sendable, Equatable {
|
public struct PurgePrompt: Sendable, Equatable {
|
||||||
public let title: String
|
public let title: String
|
||||||
public let message: String
|
public let message: String
|
||||||
public let confirmTitle: String
|
public let confirmTitle: String
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete Immediately's alert — "the alert stands between one keystroke and unrecoverable
|
/// The alert in front of a **permanent** card delete — the trash's own ⌫/⌘⌫ and File ▸ Delete
|
||||||
/// deletion" (03-board-ui.md § Trash).
|
/// Immediately alike (03-board-ui.md § Trash: "Both confirm exactly where the loss is real ...
|
||||||
|
/// the alert stands between one keystroke and unrecoverable deletion").
|
||||||
///
|
///
|
||||||
/// A sole item is **named**; several fold into counts. `nil` when the ids name nothing
|
/// `container` is where the command found the cards: the trash for the trash's Delete, the board
|
||||||
/// tombstoned, which is also the command's own refusal — so the prompt and the action can never
|
/// for a Delete Immediately that skips the trash from a lane. The prompt reads the same either
|
||||||
|
/// way — what is being asked is whether to destroy these cards, and where they happen to be
|
||||||
|
/// sitting is not the question.
|
||||||
|
///
|
||||||
|
/// A sole card is **named**; several fold into a count. `nil` when the ids name nothing in that
|
||||||
|
/// container, which is also the command's own refusal — so the prompt and the action can never
|
||||||
/// disagree about whether there is anything to purge.
|
/// disagree about whether there is anything to purge.
|
||||||
public static func purgePrompt(
|
public static func purgePrompt(
|
||||||
for ids: Set<ItemID>,
|
for ids: Set<ItemID>,
|
||||||
in snapshot: BoardModel,
|
in container: ItemContainer,
|
||||||
|
snapshot: BoardModel,
|
||||||
unrecoverable: Bool
|
unrecoverable: Bool
|
||||||
) -> PurgePrompt? {
|
) -> PurgePrompt? {
|
||||||
let targets = paths(of: ids, on: .trashed, in: snapshot)
|
let targets = ItemPath.resolve(ids, in: container, snapshot: snapshot).filter { !$0.isLane }
|
||||||
guard !targets.isEmpty else { return nil }
|
guard !targets.isEmpty else { return nil }
|
||||||
|
|
||||||
let counts = counts(of: targets)
|
|
||||||
let subject: String
|
let subject: String
|
||||||
if targets.count == 1, let only = targets.first {
|
if targets.count == 1, let only = targets.first {
|
||||||
subject = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}"
|
subject = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}"
|
||||||
} else {
|
} else {
|
||||||
subject = phrase(counts)
|
subject = phrase(targets.count)
|
||||||
}
|
}
|
||||||
return PurgePrompt(
|
return PurgePrompt(
|
||||||
title: "Permanently delete \(subject)?",
|
title: "Permanently delete \(subject)?",
|
||||||
message: message(lanes: counts.lanes, unrecoverable: unrecoverable),
|
message: message(unrecoverable: unrecoverable),
|
||||||
confirmTitle: "Delete"
|
confirmTitle: "Delete"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Empty Trash…'s alert — **always shown** ("bulk scope, not per-item recoverability, is what it
|
/// Empty Trash…'s alert — **always shown** ("Empty Trash… confirms everywhere"), and always
|
||||||
/// guards"), and always naming the **true count**: every tombstone on the board, never the
|
/// naming the **true count**: every card in `.trash/`, never the filtered view (03-board-ui.md §
|
||||||
/// filtered view.
|
/// Trash: "search-independent, the confirmation naming the card count").
|
||||||
///
|
///
|
||||||
/// Counts rather than names even for a single entry, because the command is about the trash
|
/// Counts rather than names even for a single card, because the command is about the trash
|
||||||
/// rather than about an item: "Permanently delete 41 cards" is the design's own example phrasing
|
/// rather than about an item: "Permanently delete 41 cards" is the design's own example phrasing.
|
||||||
/// (06-history-undo.md's plural folding).
|
|
||||||
public static func emptyTrashPrompt(in snapshot: BoardModel, unrecoverable: Bool) -> PurgePrompt? {
|
public static func emptyTrashPrompt(in snapshot: BoardModel, unrecoverable: Bool) -> PurgePrompt? {
|
||||||
let counts = counts(of: emptyTrashTargets(in: snapshot))
|
guard !snapshot.trash.isEmpty else { return nil }
|
||||||
guard !counts.isEmpty else { return nil }
|
|
||||||
return PurgePrompt(
|
return PurgePrompt(
|
||||||
title: "Permanently delete \(phrase(counts))?",
|
title: "Permanently delete \(phrase(snapshot.trash.count))?",
|
||||||
message: message(lanes: counts.lanes, unrecoverable: unrecoverable),
|
message: message(unrecoverable: unrecoverable),
|
||||||
confirmTitle: "Delete"
|
confirmTitle: "Delete"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The alert's body: what a lane takes with it, and whether any of it comes back.
|
/// The alert's body: whether any of it comes back.
|
||||||
///
|
///
|
||||||
/// The lane sentence is not decoration — a lane entry's row says "3 cards" (what Put Back would
|
/// The tombstone era's second sentence — "Deleting a lane also deletes every card inside it" —
|
||||||
/// return), while purging the lane folder takes *every* card inside it, individually tombstoned
|
/// is gone with the lane entries it warned about: no purge path reaches a lane any more
|
||||||
/// ones included. That gap is exactly what a confirmation is for.
|
/// (`ItemPath.isLane` is filtered out above, and lane deletion is its own physical command with
|
||||||
private static func message(lanes: Int, unrecoverable: Bool) -> String {
|
/// undo as its net).
|
||||||
var parts: [String] = []
|
private static func message(unrecoverable: Bool) -> String {
|
||||||
if lanes > 0 {
|
|
||||||
parts.append("Deleting a lane also deletes every card inside it.")
|
|
||||||
}
|
|
||||||
// m7-git: on a git board the content stays reachable in history, so the second sentence is
|
// m7-git: on a git board the content stays reachable in history, so the second sentence is
|
||||||
// the honest one — and Delete Immediately does not confirm there at all
|
// the honest one — and Delete Immediately does not confirm there at all
|
||||||
// (`BoardStore.purgeIsUnrecoverable`).
|
// (`BoardStore.purgeIsUnrecoverable`).
|
||||||
parts.append(unrecoverable
|
unrecoverable
|
||||||
? "This can\u{2019}t be undone."
|
? "This can\u{2019}t be undone."
|
||||||
: "The board\u{2019}s history still has them.")
|
: "The board\u{2019}s history still has them."
|
||||||
return parts.joined(separator: " ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What to call an item in a prompt — its title, or the "Untitled" rendering.
|
/// What to call a card in a prompt — its title, or the "Untitled" rendering.
|
||||||
///
|
///
|
||||||
/// Total by construction: a path whose item has gone since the prompt was asked for reads
|
/// Total by construction: a path whose card has gone since the prompt was asked for reads
|
||||||
/// "Untitled" rather than failing, which is the same shrug every other vanished-target rule in
|
/// "Untitled" rather than failing, which is the same shrug every other vanished-target rule in
|
||||||
/// the app gives.
|
/// the app gives.
|
||||||
private static func displayName(of path: ItemPath, in snapshot: BoardModel) -> String {
|
private static func displayName(of path: ItemPath, in snapshot: BoardModel) -> String {
|
||||||
guard let lane = snapshot.lanes.first(where: { $0.id == path.laneID }) else { return "Untitled" }
|
switch path {
|
||||||
guard let cardID = path.cardID else { return lane.title.value ?? "Untitled" }
|
case let .lane(id):
|
||||||
return lane.cards.first { $0.id == cardID }?.title.value ?? "Untitled"
|
return snapshot.lanes.first { $0.id == id }?.title.value ?? "Untitled"
|
||||||
|
case let .card(lane, id):
|
||||||
|
return snapshot.lanes.first { $0.id == lane }?
|
||||||
|
.cards.first { $0.id == id }?.title.value ?? "Untitled"
|
||||||
|
case let .trashCard(id):
|
||||||
|
return snapshot.trash.first { $0.id == id }?.title.value ?? "Untitled"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Menu validation
|
// MARK: - Menu validation
|
||||||
|
|
||||||
/// Whether File ▸ Delete has something to tombstone — a **live**, non-empty selection that still
|
/// Whether File ▸ Delete has something to act on — **staged by place, but validated once**
|
||||||
/// names something the board renders.
|
/// (04-interactions.md ▸ The map, resettled 2026-07-28: "File ▸ Delete is the chord's only
|
||||||
|
/// owner — no twin menu items, no shared-equivalent routing").
|
||||||
///
|
///
|
||||||
/// The liveness side is the whole of the binary: "menu validation stays binary — Delete for live
|
/// One predicate for both stagings, because there is only one item now: a board selection moves
|
||||||
/// selections, Put Back / Delete Immediately for tombstoned ones" (04 ▸ The trash). The
|
/// into the trash, a trash selection deletes permanently, and the command is enabled whenever
|
||||||
/// resolution against the snapshot is what keeps a selection the next reload will drop from
|
/// either names something the board still holds. The old mirror-image pair
|
||||||
/// enabling an item that would write nothing.
|
/// (which existed to make two ⌘⌫ twins enable exactly one of themselves) retired with Put Back.
|
||||||
public static func canDelete(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
|
public static func canDelete(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
|
||||||
selection.liveness == .live && !paths(of: selection.ids, on: .live, in: snapshot).isEmpty
|
!ItemPath.resolve(selection.ids, in: selection.container, snapshot: snapshot).isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether File ▸ Put Back and File ▸ Delete Immediately have something to act on — the exact
|
/// Whether File ▸ Delete Immediately has something to purge — **a card selection, from anywhere**
|
||||||
/// mirror of `canDelete`, which is what makes the two ⌘⌫ twins enable exactly one of themselves.
|
/// (11-command-nexus.md: "Board window, card selection — skips the trash from anywhere").
|
||||||
public static func canActOnTrash(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
|
///
|
||||||
selection.liveness == .trashed && !paths(of: selection.ids, on: .trashed, in: snapshot).isEmpty
|
/// Cards only, in either container: a lane's delete is physical already and has undo as its net,
|
||||||
|
/// so there is nothing for "skip the trash" to mean on one.
|
||||||
|
public static func canDeleteImmediately(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
|
||||||
|
ItemPath.resolve(selection.ids, in: selection.container, snapshot: snapshot).contains { !$0.isLane }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -744,7 +744,7 @@ public struct LegacyTombstone: Sendable, Equatable {
|
|||||||
///
|
///
|
||||||
/// `title` is the card's as written, `nil` for an untitled one: "Untitled" is a rendering, never a
|
/// `title` is the card's as written, `nil` for an untitled one: "Untitled" is a rendering, never a
|
||||||
/// value (03-board-ui.md § Card face), so the phrasing layer decides what to call it. The path is
|
/// value (03-board-ui.md § Card face), so the phrasing layer decides what to call it. The path is
|
||||||
/// carried as its two identity components rather than as a URL, `BoardStore.liveItem`'s convention,
|
/// carried as its two identity components rather than as a URL, `ItemPath`'s convention,
|
||||||
/// so the write derives its path from the store's *current* root.
|
/// so the write derives its path from the store's *current* root.
|
||||||
public struct LooseCardFiles: Sendable, Equatable {
|
public struct LooseCardFiles: Sendable, Equatable {
|
||||||
public let laneID: ItemID
|
public let laneID: ItemID
|
||||||
|
|||||||
@@ -1391,12 +1391,18 @@ public enum BoardWriter: Sendable {
|
|||||||
try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path)
|
try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Tombstone (retiring)
|
// MARK: - Tombstone (retired, awaiting removal)
|
||||||
//
|
//
|
||||||
// The tombstone model is retired (01-storage-format.md § Deletion, resettled 2026-07-28): the
|
// The tombstone model is retired (01-storage-format.md § Deletion, resettled 2026-07-28): the
|
||||||
// app's delete is the physical move above, and no `deleted:` key is ever written again. The
|
// app's delete is the physical move above, and no `deleted:` key is ever written again.
|
||||||
// three calls below are kept only while their callers are still being moved across — the
|
//
|
||||||
// migration removes the last keys any of them could act on, and they go with the last consumer.
|
// **These have no app callers left.** Every consumer moved across with the store swap — the
|
||||||
|
// delete is `deleteCardToTrash`, the lane delete is `removeLane`, the restore is an ordinary
|
||||||
|
// `moveItem`, and the legacy keys are handled by the two `migrate…` calls above. They are kept
|
||||||
|
// here for exactly one more beat because `purgeItem` below is still live (Delete Immediately's
|
||||||
|
// board-side purge) and the three read as one family; the pair and
|
||||||
|
// `stripTombstonedChildren` go together in the trash's cleanup pass, with the suites that
|
||||||
|
// still pin their byte-level behaviour.
|
||||||
|
|
||||||
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md` —
|
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md` —
|
||||||
/// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never
|
/// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never
|
||||||
|
|||||||
@@ -141,14 +141,14 @@ struct OpenCardCommand: View {
|
|||||||
return store.isEditingInline || soleSelectedCard != nil
|
return store.isEditingInline || soleSelectedCard != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sole selected **live card**, or `nil`. A lane, a multi-selection and a tombstoned
|
/// The sole selected **board card**, or `nil`. A lane, a multi-selection and a trash
|
||||||
/// selection all answer `nil` — "everything edit-shaped is disabled on tombstoned selections"
|
/// selection all answer `nil` — "everything edit-shaped is disabled on trash selections … Open
|
||||||
/// (04 ▸ The trash), and a card window is tied to one card.
|
/// Card, Rename, Style…" (04 ▸ The trash), and a card window is tied to one card.
|
||||||
private var soleSelectedCard: ItemID? {
|
private var soleSelectedCard: ItemID? {
|
||||||
guard let store else { return nil }
|
guard let store else { return nil }
|
||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first,
|
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
||||||
BoardStore.liveItem(id, in: store.snapshot)?.cardID != nil
|
BoardStore.boardItem(id, in: store.snapshot)?.cardID != nil
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
@@ -161,8 +161,8 @@ struct OpenCardCommand: View {
|
|||||||
// holds it — and re-checked after, because one of those paths is *the lane vanished*.
|
// holds it — and re-checked after, because one of those paths is *the lane vanished*.
|
||||||
let lane = placeholder.laneID
|
let lane = placeholder.laneID
|
||||||
let created = store.commitPlaceholder()
|
let created = store.commitPlaceholder()
|
||||||
if store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) {
|
if store.snapshot.lanes.contains(where: { $0.id == lane }) {
|
||||||
store.select([lane], liveness: .live)
|
store.select([lane], in: .board)
|
||||||
}
|
}
|
||||||
if let created { open(created) }
|
if let created { open(created) }
|
||||||
return
|
return
|
||||||
@@ -170,7 +170,7 @@ struct OpenCardCommand: View {
|
|||||||
|
|
||||||
if let editor = store.transient.renameEditor {
|
if let editor = store.transient.renameEditor {
|
||||||
let target = editor.targetID
|
let target = editor.targetID
|
||||||
let isCard = BoardStore.liveItem(target, in: store.snapshot)?.cardID != nil
|
let isCard = BoardStore.boardItem(target, in: store.snapshot)?.cardID != nil
|
||||||
store.commitRename()
|
store.commitRename()
|
||||||
if isCard { open(target) }
|
if isCard { open(target) }
|
||||||
return
|
return
|
||||||
@@ -269,8 +269,8 @@ struct MoveLaneCommands: View {
|
|||||||
private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? {
|
private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? {
|
||||||
guard let store, store.acceptsBoardMutations else { return nil }
|
guard let store, store.acceptsBoardMutations else { return nil }
|
||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil }
|
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first else { return nil }
|
||||||
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
|
let lanes = SelectionGrammar.lanes(in: store.snapshot)
|
||||||
// A card id is in no lane order, so this is also the "not a lane" test.
|
// A card id is in no lane order, so this is also the "not a lane" test.
|
||||||
guard let from = lanes.firstIndex(of: id) else { return nil }
|
guard let from = lanes.firstIndex(of: id) else { return nil }
|
||||||
let to = from + delta
|
let to = from + delta
|
||||||
@@ -382,9 +382,9 @@ struct BoardInfoCommand: View {
|
|||||||
/// **lane's only rename path**: Return on a lane creates a card, so without this item a lane could
|
/// **lane's only rename path**: Return on a lane creates a card, so without this item a lane could
|
||||||
/// never be renamed at all (04-interactions.md ▸ Selection).
|
/// never be renamed at all (04-interactions.md ▸ Selection).
|
||||||
///
|
///
|
||||||
/// Validation is the sole-selected-live-item rule — card or lane, either kind, exactly one. A
|
/// Validation is the sole-selected-board-item rule — card or lane, either kind, exactly one. A
|
||||||
/// tombstoned selection never enables it: "everything edit-shaped is disabled on tombstoned
|
/// trash selection never enables it: "everything edit-shaped is disabled on trash selections"
|
||||||
/// selections" (04 ▸ The trash), which `ItemReferenceSet`'s liveness side answers directly.
|
/// (04 ▸ The trash), which `ItemReferenceSet`'s container answers directly.
|
||||||
struct BoardRenameCommand: View {
|
struct BoardRenameCommand: View {
|
||||||
|
|
||||||
@FocusedValue(\.boardStore) private var store
|
@FocusedValue(\.boardStore) private var store
|
||||||
@@ -400,8 +400,8 @@ struct BoardRenameCommand: View {
|
|||||||
private var renameTarget: (id: ItemID, title: String?)? {
|
private var renameTarget: (id: ItemID, title: String?)? {
|
||||||
guard let store, store.acceptsBoardMutations else { return nil }
|
guard let store, store.acceptsBoardMutations else { return nil }
|
||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first,
|
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
||||||
let item = BoardStore.liveItem(id, in: store.snapshot)
|
let item = BoardStore.boardItem(id, in: store.snapshot)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return (id: id, title: item.title)
|
return (id: id, title: item.title)
|
||||||
}
|
}
|
||||||
@@ -441,7 +441,7 @@ struct BoardStyleCommand: View {
|
|||||||
guard let store, store.acceptsBoardMutations else { return nil }
|
guard let store, store.acceptsBoardMutations else { return nil }
|
||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
guard !selection.isEmpty else { return .board }
|
guard !selection.isEmpty else { return .board }
|
||||||
guard selection.liveness == .live else { return nil }
|
guard selection.container == .board else { return nil }
|
||||||
// Re-resolved against the snapshot on the way in, so the session starts out holding only
|
// Re-resolved against the snapshot on the way in, so the session starts out holding only
|
||||||
// items that render — the same universe its own reload rule will hold it to.
|
// items that render — the same universe its own reload rule will hold it to.
|
||||||
let live = selection.resolved(against: store.snapshot).ids
|
let live = selection.resolved(against: store.snapshot).ids
|
||||||
@@ -498,8 +498,8 @@ struct LaneWidthCommands: View {
|
|||||||
private var selectedLanes: [Lane] {
|
private var selectedLanes: [Lane] {
|
||||||
guard let store, store.acceptsBoardMutations else { return [] }
|
guard let store, store.acceptsBoardMutations else { return [] }
|
||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
guard selection.liveness == .live, !selection.isEmpty else { return [] }
|
guard selection.container == .board, !selection.isEmpty else { return [] }
|
||||||
return store.snapshot.lanes.filter { selection.ids.contains($0.id) && !$0.isDeleted }
|
return store.snapshot.lanes.filter { selection.ids.contains($0.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `width` is ≥ 1 (03-board-ui.md § Lane), and an item whose only outcome is a no-op reads
|
/// `width` is ≥ 1 (03-board-ui.md § Lane), and an item whose only outcome is a no-op reads
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ struct BoardDropContext {
|
|||||||
DragLocality.isSameBoard(proposal.boardRoot, store.rootURL),
|
DragLocality.isSameBoard(proposal.boardRoot, store.rootURL),
|
||||||
let laneID = proposal.laneID
|
let laneID = proposal.laneID
|
||||||
else { return }
|
else { return }
|
||||||
guard !store.snapshot.lanes.contains(where: { $0.id == laneID && !$0.isDeleted }) else { return }
|
guard !store.snapshot.lanes.contains(where: { $0.id == laneID }) else { return }
|
||||||
session.propose(nil)
|
session.propose(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ struct BoardDropContext {
|
|||||||
func retargetLanes() {
|
func retargetLanes() {
|
||||||
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
||||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||||
let resting = store.snapshot.lanes.filter { !$0.isDeleted && !hidden.contains($0.id) }
|
let resting = store.snapshot.lanes.filter { !hidden.contains($0.id) }
|
||||||
let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) }
|
let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) }
|
||||||
let slot = DropSlotMath.laneSlot(
|
let slot = DropSlotMath.laneSlot(
|
||||||
cursorX: cursor.x,
|
cursorX: cursor.x,
|
||||||
@@ -181,14 +181,14 @@ struct BoardDropContext {
|
|||||||
/// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll).
|
/// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll).
|
||||||
func retargetCards(inLane laneID: ItemID) {
|
func retargetCards(inLane laneID: ItemID) {
|
||||||
guard session.isDraggingCards, let cursor = globalCursor() else { return }
|
guard session.isDraggingCards, let cursor = globalCursor() else { return }
|
||||||
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else {
|
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID }) else {
|
||||||
revalidateProposal()
|
revalidateProposal()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let grid = registry.grids[laneID] else { return }
|
guard let grid = registry.grids[laneID] else { return }
|
||||||
|
|
||||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||||
let rendered = lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) }
|
let rendered = lane.cards.filter { !hidden.contains($0.id) }
|
||||||
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
|
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
|
||||||
let placement = MasonryPlacement(
|
let placement = MasonryPlacement(
|
||||||
columnCount: grid.columns,
|
columnCount: grid.columns,
|
||||||
@@ -218,7 +218,7 @@ struct BoardDropContext {
|
|||||||
/// answers `nil` there — and the proposal simply **holds**, which is the hysteresis contract.
|
/// answers `nil` there — and the proposal simply **holds**, which is the hysteresis contract.
|
||||||
func retargetCardsFromStrip() {
|
func retargetCardsFromStrip() {
|
||||||
guard session.isDraggingCards, let cursor = stripCursor() else { return }
|
guard session.isDraggingCards, let cursor = stripCursor() else { return }
|
||||||
let lanes = store.snapshot.lanes.filter { !$0.isDeleted }
|
let lanes = store.snapshot.lanes
|
||||||
let index = LaneLayoutMath.laneIndex(
|
let index = LaneLayoutMath.laneIndex(
|
||||||
atX: cursor.x,
|
atX: cursor.x,
|
||||||
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
|
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||||
@@ -251,7 +251,7 @@ struct BoardDropContext {
|
|||||||
guard let sourceRoot = session.sourceRoot else { return false }
|
guard let sourceRoot = session.sourceRoot else { return false }
|
||||||
return TrashDrop.accepts(
|
return TrashDrop.accepts(
|
||||||
kind: session.kind,
|
kind: session.kind,
|
||||||
side: session.side,
|
container: session.container,
|
||||||
isWithinBoard: DragLocality.isSameBoard(sourceRoot, store.rootURL),
|
isWithinBoard: DragLocality.isSameBoard(sourceRoot, store.rootURL),
|
||||||
operation: session.resolveOperation(destinationRoot: store.rootURL),
|
operation: session.resolveOperation(destinationRoot: store.rootURL),
|
||||||
isTrashShown: store.transient.isTrashVisible,
|
isTrashShown: store.transient.isTrashVisible,
|
||||||
@@ -338,14 +338,14 @@ struct BoardDropContext {
|
|||||||
/// cursor and the snapshot, so it cannot oscillate — the drawn layout never feeds back into it.
|
/// cursor and the snapshot, so it cannot oscillate — the drawn layout never feeds back into it.
|
||||||
func retargetFile(inLane laneID: ItemID, info: DropInfo) {
|
func retargetFile(inLane laneID: ItemID, info: DropInfo) {
|
||||||
guard acceptsFileDrop(info), let cursor = globalCursor(),
|
guard acceptsFileDrop(info), let cursor = globalCursor(),
|
||||||
let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }),
|
let lane = store.snapshot.lanes.first(where: { $0.id == laneID }),
|
||||||
let grid = registry.grids[laneID]
|
let grid = registry.grids[laneID]
|
||||||
else {
|
else {
|
||||||
session.proposeFile(nil)
|
session.proposeFile(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let rendered = lane.cards.filter { !$0.isDeleted }
|
let rendered = lane.cards
|
||||||
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
|
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
|
||||||
let placement = MasonryPlacement(
|
let placement = MasonryPlacement(
|
||||||
columnCount: grid.columns,
|
columnCount: grid.columns,
|
||||||
@@ -399,7 +399,7 @@ struct BoardDropContext {
|
|||||||
session.proposeFile(nil)
|
session.proposeFile(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let lanes = store.snapshot.lanes.filter { !$0.isDeleted }
|
let lanes = store.snapshot.lanes
|
||||||
let index = LaneLayoutMath.laneIndex(
|
let index = LaneLayoutMath.laneIndex(
|
||||||
atX: cursor.x,
|
atX: cursor.x,
|
||||||
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
|
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||||
@@ -498,7 +498,7 @@ struct BoardDropContext {
|
|||||||
/// set's size (DRAG-REORDER.md § The drop commits).
|
/// set's size (DRAG-REORDER.md § The drop commits).
|
||||||
///
|
///
|
||||||
/// **One of the containers is not a destination but a verb.** A proposal naming the trash commits
|
/// **One of the containers is not a destination but a verb.** A proposal naming the trash commits
|
||||||
/// a tombstone — the same write ⌫ performs, through the same `BoardWriter.deleteItem` in the same
|
/// a delete — the same write ⌫ performs, through the same `BoardWriter.deleteCardToTrash` in the same
|
||||||
/// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk
|
/// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk
|
||||||
/// from one deleted by keystroke (04-interactions.md ▸ The trash, settled 2026-07-28).
|
/// from one deleted by keystroke (04-interactions.md ▸ The trash, settled 2026-07-28).
|
||||||
///
|
///
|
||||||
@@ -547,15 +547,16 @@ struct BoardDropContext {
|
|||||||
case .cards:
|
case .cards:
|
||||||
if target.isTrash {
|
if target.isTrash {
|
||||||
// **The pointer's delete gesture** (04-interactions.md ▸ The trash, settled
|
// **The pointer's delete gesture** (04-interactions.md ▸ The trash, settled
|
||||||
// 2026-07-28): "release tombstones the dragged card(s), exactly the ⌫ tombstone".
|
// 2026-07-28): "release moves the dragged card(s) into `.trash/`" — exactly the ⌫
|
||||||
|
// delete.
|
||||||
//
|
//
|
||||||
// The gate is re-asked here rather than trusted from the hover, because the one input
|
// The gate is re-asked here rather than trusted from the hover, because the one input
|
||||||
// that can change between them arrives through no callback at all: ⌥ pressed after
|
// that can change between them arrives through no callback at all: ⌥ pressed after
|
||||||
// the proposal stood would otherwise tombstone an original the copy grammar had just
|
// the proposal stood would otherwise delete an original the copy grammar had just
|
||||||
// promised to leave alone. A refusal cancels — items return, nothing is written.
|
// promised to leave alone. A refusal cancels — items return, nothing is written.
|
||||||
guard TrashDrop.accepts(
|
guard TrashDrop.accepts(
|
||||||
kind: kind,
|
kind: kind,
|
||||||
side: session.side,
|
container: session.container,
|
||||||
isWithinBoard: within,
|
isWithinBoard: within,
|
||||||
operation: operation,
|
operation: operation,
|
||||||
isTrashShown: store.transient.isTrashVisible,
|
isTrashShown: store.transient.isTrashVisible,
|
||||||
@@ -571,27 +572,20 @@ struct BoardDropContext {
|
|||||||
cancelDrop()
|
cancelDrop()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch (session.side, within) {
|
// **The trash side needs no branch of its own any more** (04-interactions.md ▸ The
|
||||||
case (.live, true):
|
// trash, resettled 2026-07-28: "Drag-to-restore follows the locality model: dropping a
|
||||||
|
// trash card into one of its own board's lanes is an ordinary move to the drop
|
||||||
|
// position"). `moveCards`/`copyCards` resolve their members in either container, so a
|
||||||
|
// restore *is* the within-board move and a cross-board restore *is* the ordinary
|
||||||
|
// arrival — which is exactly what retiring the restore-specific machinery bought.
|
||||||
|
if within {
|
||||||
if operation == .copy {
|
if operation == .copy {
|
||||||
store.copyCards(Set(ids), toLane: laneID, at: target.index)
|
store.copyCards(Set(ids), toLane: laneID, at: target.index)
|
||||||
} else {
|
} else {
|
||||||
store.moveCards(Set(ids), toLane: laneID, at: target.index)
|
store.moveCards(Set(ids), toLane: laneID, at: target.index)
|
||||||
}
|
}
|
||||||
case (.live, false):
|
|
||||||
store.receiveCards(folders, operation: operation, toLane: laneID, at: target.index)
|
|
||||||
case (.trashed, true):
|
|
||||||
// Drag-to-restore, and its ⌥ twin. "Dropping a tombstoned card into one of its own
|
|
||||||
// board's lanes restores it at the drop position"; ⌥ is the copy-out instead — a
|
|
||||||
// live copy lands and the tombstoned original stays (04-interactions.md ▸ The trash,
|
|
||||||
// "⌘C, ⌥-drag … always yield live copies").
|
|
||||||
if operation == .copy {
|
|
||||||
store.receiveRestoredCards(folders, operation: .copy, toLane: laneID, at: target.index)
|
|
||||||
} else {
|
} else {
|
||||||
store.restoreByDrag(cardIDs: ids, intoLane: laneID, at: target.index)
|
store.receiveCards(folders, operation: operation, toLane: laneID, at: target.index)
|
||||||
}
|
|
||||||
case (.trashed, false):
|
|
||||||
store.receiveRestoredCards(folders, operation: operation, toLane: laneID, at: target.index)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ struct BoardView: View {
|
|||||||
guard ClickModifier.current == .plain else { return }
|
guard ClickModifier.current == .plain else { return }
|
||||||
store.clearSelection()
|
store.clearSelection()
|
||||||
}
|
}
|
||||||
.simultaneousGesture(marqueeControl.gesture(side: .live))
|
.simultaneousGesture(marqueeControl.gesture(in: .board))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The lanes and the drag's shadows, plus the trash column when it is shown.
|
/// The lanes and the drag's shadows, plus the trash column when it is shown.
|
||||||
@@ -269,7 +269,7 @@ struct BoardView: View {
|
|||||||
if isTrashVisible {
|
if isTrashVisible {
|
||||||
// Trailing, always — the quasi-lane has no position of its own to lose, which is
|
// Trailing, always — the quasi-lane has no position of its own to lose, which is
|
||||||
// also why it never appears in the drop proposal's inputs (those are built from
|
// also why it never appears in the drop proposal's inputs (those are built from
|
||||||
// `liveLanes`) and why the terminal slot clamps in front of it.
|
// `boardLanes`) and why the terminal slot clamps in front of it.
|
||||||
TrashLaneView(
|
TrashLaneView(
|
||||||
store: store,
|
store: store,
|
||||||
confirmations: confirmations,
|
confirmations: confirmations,
|
||||||
@@ -427,11 +427,11 @@ struct BoardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The lanes the strip lays out, in snapshot order. **Tombstoned lanes render nowhere here** —
|
/// The lanes the strip lays out, in snapshot order — every lane the board has. Deleting a lane
|
||||||
/// 03-board-ui.md § Trash collapses each into a single restorable entry in the trash quasi-lane
|
/// is physical now (03-board-ui.md § Trash: "Cards only. Lanes are never trashed"), so a lane in
|
||||||
/// (a later card), and a lane that is not on the board consumes none of the window's width.
|
/// the snapshot is a lane on the board, with no hidden state to filter for.
|
||||||
private var liveLanes: [Lane] {
|
private var boardLanes: [Lane] {
|
||||||
store.snapshot.lanes.filter { !$0.isDeleted }
|
store.snapshot.lanes
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Trash
|
// MARK: - Trash
|
||||||
@@ -443,16 +443,15 @@ struct BoardView: View {
|
|||||||
store.transient.isTrashVisible
|
store.transient.isTrashVisible
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The trash's rows as the column is showing them — the shown trash "participates in the filter
|
/// The trash's cards as the column is showing them — the shown trash's cards "participate in
|
||||||
/// like any lane" (03-board-ui.md § Trash), and the arrows walk what is on screen
|
/// the filter exactly like any other card" (03-board-ui.md § Trash), and the arrows walk what is
|
||||||
/// (`TrashLaneView.entries` applies the identical predicate to the identical rows).
|
/// on screen (`TrashLaneView` applies the identical predicate to the identical cards).
|
||||||
///
|
///
|
||||||
/// Read by the three keyboard destinations that reach into the column — the arrow origin's
|
/// Read by the three keyboard destinations that reach into the column — the arrow origin's
|
||||||
/// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a row the
|
/// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a card the
|
||||||
/// filter took away.
|
/// filter took away.
|
||||||
private var trashEntries: [TrashEntry] {
|
private var trashCards: [ItemID] {
|
||||||
let filter = store.searchFilter
|
SelectionGrammar.trashCards(in: store.snapshot, filter: store.searchFilter)
|
||||||
return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The drag
|
// MARK: - The drag
|
||||||
@@ -506,7 +505,7 @@ struct BoardView: View {
|
|||||||
/// (`arrivingLaneUnits`).
|
/// (`arrivingLaneUnits`).
|
||||||
private func standardWidth(stripWidth: CGFloat) -> CGFloat {
|
private func standardWidth(stripWidth: CGFloat) -> CGFloat {
|
||||||
if resize.isActive { return resize.standard }
|
if resize.isActive { return resize.standard }
|
||||||
var units = LaneLayoutMath.totalUnits(of: liveLanes, trashUnits: isTrashVisible ? 1 : 0)
|
var units = LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0)
|
||||||
units += arrivingLaneUnits
|
units += arrivingLaneUnits
|
||||||
return LaneLayoutMath.standardWidth(
|
return LaneLayoutMath.standardWidth(
|
||||||
stripWidth: stripWidth,
|
stripWidth: stripWidth,
|
||||||
@@ -569,7 +568,7 @@ struct BoardView: View {
|
|||||||
private var stripSlots: [StripSlot] {
|
private var stripSlots: [StripSlot] {
|
||||||
let session = appModel.dragSession
|
let session = appModel.dragSession
|
||||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||||
var slots = liveLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
|
var slots = boardLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
|
||||||
guard let index = stripProposal else { return slots }
|
guard let index = stripProposal else { return slots }
|
||||||
let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) }
|
let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) }
|
||||||
slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count))
|
slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count))
|
||||||
@@ -596,10 +595,10 @@ struct BoardView: View {
|
|||||||
}
|
}
|
||||||
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
guard selection.liveness == .live,
|
guard selection.container == .board,
|
||||||
selection.ids.count == 1,
|
selection.ids.count == 1,
|
||||||
let id = selection.ids.first,
|
let id = selection.ids.first,
|
||||||
let target = BoardStore.liveItem(id, in: store.snapshot)
|
let target = BoardStore.boardItem(id, in: store.snapshot)
|
||||||
else { return .ignored }
|
else { return .ignored }
|
||||||
|
|
||||||
if target.cardID == nil {
|
if target.cardID == nil {
|
||||||
@@ -614,9 +613,11 @@ struct BoardView: View {
|
|||||||
/// grammar so no second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md ▸ The
|
/// grammar so no second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md ▸ The
|
||||||
/// map).
|
/// map).
|
||||||
///
|
///
|
||||||
/// Deliberately **live-only**: the nexus scopes this key to a live selection, and ⌫'s trash-side
|
/// **Both stagings**, unlike the tombstone era's live-only reading: "Plain ⌫ performs the same
|
||||||
/// role belongs to the ⌘⌫ twins, not to the bare key. A tombstoned selection is therefore inert
|
/// delete as fixed grammar" (04-interactions.md ▸ The map, resettled 2026-07-28), and the delete
|
||||||
/// here — Put Back is a chord.
|
/// is staged by place inside the store (`BoardStore.deleteSelection`) rather than by two menu
|
||||||
|
/// items sharing a chord. Put Back — the reason the bare key had to stay off the trash — is
|
||||||
|
/// retired with the tombstone model.
|
||||||
///
|
///
|
||||||
/// Inert while an inline editor is open, like every grammar key: the field owns ⌫ as backspace,
|
/// Inert while an inline editor is open, like every grammar key: the field owns ⌫ as backspace,
|
||||||
/// and a stray one reaching the board mid-edit would delete the item being renamed.
|
/// and a stray one reaching the board mid-edit would delete the item being renamed.
|
||||||
@@ -631,7 +632,7 @@ struct BoardView: View {
|
|||||||
}
|
}
|
||||||
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
guard selection.liveness == .live, !selection.isEmpty else { return .ignored }
|
guard !selection.isEmpty else { return .ignored }
|
||||||
store.deleteSelection()
|
store.deleteSelection()
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
@@ -716,7 +717,7 @@ struct BoardView: View {
|
|||||||
guard let origin = arrowOrigin() else { return seed(direction, mode) }
|
guard let origin = arrowOrigin() else { return seed(direction, mode) }
|
||||||
return origin.isLaneDomain
|
return origin.isLaneDomain
|
||||||
? laneArrow(direction, mode, from: origin.head)
|
? laneArrow(direction, mode, from: origin.head)
|
||||||
: cardArrow(direction, mode, from: origin.head, on: origin.side)
|
: cardArrow(direction, mode, from: origin.head, in: origin.container)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func direction(of key: KeyEquivalent) -> NavigationMath.Direction? {
|
private static func direction(of key: KeyEquivalent) -> NavigationMath.Direction? {
|
||||||
@@ -738,33 +739,32 @@ struct BoardView: View {
|
|||||||
/// Select All and a foreign reload leave the arrows somewhere sensible without any of them
|
/// Select All and a foreign reload leave the arrows somewhere sensible without any of them
|
||||||
/// having to name a cursor.
|
/// having to name a cursor.
|
||||||
///
|
///
|
||||||
/// The **trash's list is both kinds interleaved** (`TrashModel.entries`), because "arrows walk
|
/// The **trash's list is its cards**, top to bottom — there are no lane entries to interleave
|
||||||
/// every trash entry in its sorted order — card and lane entries alike" (04 ▸ The trash). The
|
/// any more (03-board-ui.md § Trash: "Cards only").
|
||||||
/// per-kind lists are the *range*'s business, not the walk's.
|
|
||||||
///
|
///
|
||||||
/// Both lists are the **filtered** board (04 § Search: "arrow nav … read[s] it"), so the
|
/// Both lists are the **filtered** board (04 § Search: "arrow nav … read[s] it"), so the
|
||||||
/// fallback lands on the last *visible* member rather than on a card the query hid.
|
/// fallback lands on the last *visible* member rather than on a card the query hid.
|
||||||
private func arrowOrigin() -> (head: ItemID, side: Liveness, isLaneDomain: Bool)? {
|
private func arrowOrigin() -> (head: ItemID, container: ItemContainer, isLaneDomain: Bool)? {
|
||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
guard !selection.isEmpty else { return nil }
|
guard !selection.isEmpty else { return nil }
|
||||||
|
|
||||||
let isLaneDomain: Bool
|
let isLaneDomain: Bool
|
||||||
let list: [ItemID]
|
let list: [ItemID]
|
||||||
switch selection.liveness {
|
switch selection.container {
|
||||||
case .live:
|
case .board:
|
||||||
guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
|
guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
|
||||||
isLaneDomain = kind == .lane
|
isLaneDomain = kind == .lane
|
||||||
list = SelectionGrammar.order(of: kind, on: .live, in: store.snapshot, filter: store.searchFilter)
|
list = SelectionGrammar.order(of: kind, in: .board, snapshot: store.snapshot, filter: store.searchFilter)
|
||||||
case .trashed:
|
case .trash:
|
||||||
isLaneDomain = false
|
isLaneDomain = false
|
||||||
list = trashEntries.map(\.id)
|
list = trashCards
|
||||||
}
|
}
|
||||||
|
|
||||||
if let head = store.transient.selectionHead, list.contains(head) {
|
if let head = store.transient.selectionHead, list.contains(head) {
|
||||||
return (head, selection.liveness, isLaneDomain)
|
return (head, selection.container, isLaneDomain)
|
||||||
}
|
}
|
||||||
guard let last = list.last(where: { selection.ids.contains($0) }) else { return nil }
|
guard let last = list.last(where: { selection.ids.contains($0) }) else { return nil }
|
||||||
return (last, selection.liveness, isLaneDomain)
|
return (last, selection.container, isLaneDomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **An empty selection seeds at the first lane's first card** (04-interactions.md ▸ Grammar) —
|
/// **An empty selection seeds at the first lane's first card** (04-interactions.md ▸ Grammar) —
|
||||||
@@ -779,8 +779,8 @@ struct BoardView: View {
|
|||||||
if mode == .jump, direction == .left || direction == .right {
|
if mode == .jump, direction == .left || direction == .right {
|
||||||
return jumpToEndLane(direction)
|
return jumpToEndLane(direction)
|
||||||
}
|
}
|
||||||
guard let first = Self.firstCard(scanning: liveLanes, filter: store.searchFilter) else { return .handled }
|
guard let first = Self.firstCard(scanning: boardLanes, filter: store.searchFilter) else { return .handled }
|
||||||
replaceSelection(with: first, on: .live)
|
replaceSelection(with: first, in: .board)
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,7 +790,7 @@ struct BoardView: View {
|
|||||||
_ direction: NavigationMath.Direction,
|
_ direction: NavigationMath.Direction,
|
||||||
_ mode: ArrowMode,
|
_ mode: ArrowMode,
|
||||||
from head: ItemID,
|
from head: ItemID,
|
||||||
on side: Liveness
|
in container: ItemContainer
|
||||||
) -> KeyPress.Result {
|
) -> KeyPress.Result {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .step: step(direction, from: head)
|
case .step: step(direction, from: head)
|
||||||
@@ -798,7 +798,7 @@ struct BoardView: View {
|
|||||||
case .jump:
|
case .jump:
|
||||||
switch direction {
|
switch direction {
|
||||||
case .left, .right: jumpToEndLane(direction)
|
case .left, .right: jumpToEndLane(direction)
|
||||||
case .up, .down: jumpWithinContainer(direction, from: head, on: side)
|
case .up, .down: jumpWithinContainer(direction, from: head, in: container)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -817,7 +817,7 @@ struct BoardView: View {
|
|||||||
),
|
),
|
||||||
let next = marqueeTargets.targets[nextID]
|
let next = marqueeTargets.targets[nextID]
|
||||||
else { return .handled }
|
else { return .handled }
|
||||||
replaceSelection(with: next.id, on: next.side)
|
replaceSelection(with: next.id, in: next.container)
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,7 +837,7 @@ struct BoardView: View {
|
|||||||
among: marqueeTargets.all
|
among: marqueeTargets.all
|
||||||
),
|
),
|
||||||
let next = marqueeTargets.targets[nextID],
|
let next = marqueeTargets.targets[nextID],
|
||||||
next.side == origin.side,
|
next.container == origin.container,
|
||||||
next.kind == origin.kind
|
next.kind == origin.kind
|
||||||
else { return .handled }
|
else { return .handled }
|
||||||
|
|
||||||
@@ -849,13 +849,13 @@ struct BoardView: View {
|
|||||||
from: anchor,
|
from: anchor,
|
||||||
to: next.id,
|
to: next.id,
|
||||||
kind: next.kind,
|
kind: next.kind,
|
||||||
on: next.side,
|
in: next.container,
|
||||||
in: store.snapshot,
|
snapshot: store.snapshot,
|
||||||
// The span is the *filtered* board's, so a range under a search collects exactly the
|
// The span is the *filtered* board's, so a range under a search collects exactly the
|
||||||
// rows between the two endpoints that are on screen (04 § Search: "ranges … read it").
|
// cards between the two endpoints that are on screen (04 § Search: "ranges … read it").
|
||||||
filter: store.searchFilter
|
filter: store.searchFilter
|
||||||
) else { return .handled }
|
) else { return .handled }
|
||||||
store.select(ids, liveness: next.side, anchor: anchor, head: next.id)
|
store.select(ids, in: next.container, anchor: anchor, head: next.id)
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,30 +869,30 @@ struct BoardView: View {
|
|||||||
private func jumpWithinContainer(
|
private func jumpWithinContainer(
|
||||||
_ direction: NavigationMath.Direction,
|
_ direction: NavigationMath.Direction,
|
||||||
from head: ItemID,
|
from head: ItemID,
|
||||||
on side: Liveness
|
in container: ItemContainer
|
||||||
) -> KeyPress.Result {
|
) -> KeyPress.Result {
|
||||||
let container: [ItemID]
|
let siblings: [ItemID]
|
||||||
var lane: ItemID?
|
var lane: ItemID?
|
||||||
switch side {
|
switch container {
|
||||||
case .trashed:
|
case .trash:
|
||||||
container = trashEntries.map(\.id)
|
siblings = trashCards
|
||||||
case .live:
|
case .board:
|
||||||
guard let home = store.snapshot.lanes.first(where: { lane in
|
guard let home = store.snapshot.lanes.first(where: { lane in
|
||||||
!lane.isDeleted && lane.cards.contains { $0.id == head && !$0.isDeleted }
|
lane.cards.contains { $0.id == head }
|
||||||
}) else { return .handled }
|
}) else { return .handled }
|
||||||
lane = home.id
|
lane = home.id
|
||||||
// The container is what the lane is *showing*: a jump to "the lane's first card" under
|
// The container is what the lane is *showing*: a jump to "the lane's first card" under
|
||||||
// a search means its first surviving card, not one the filter animated out.
|
// a search means its first surviving card, not one the filter animated out.
|
||||||
let filter = store.searchFilter
|
let filter = store.searchFilter
|
||||||
container = home.cards.filter { !$0.isDeleted && filter.matches($0) }.map(\.id)
|
siblings = home.cards.filter { filter.matches($0) }.map(\.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let target = direction == .up ? container.first : container.last else { return .handled }
|
guard let target = direction == .up ? siblings.first : siblings.last else { return .handled }
|
||||||
if direction == .up, target == head, let lane, store.selection.ids == [head] {
|
if direction == .up, target == head, let lane, store.selection.ids == [head] {
|
||||||
replaceSelection(with: lane, on: .live)
|
replaceSelection(with: lane, in: .board)
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
replaceSelection(with: target, on: side)
|
replaceSelection(with: target, in: container)
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,11 +904,11 @@ struct BoardView: View {
|
|||||||
/// the jump falls through to the last lane. Empty lanes are scanned past in both directions —
|
/// the jump falls through to the last lane. Empty lanes are scanned past in both directions —
|
||||||
/// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
|
/// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
|
||||||
private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
|
private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
|
||||||
if direction == .right, isTrashVisible, let first = trashEntries.first {
|
if direction == .right, isTrashVisible, let first = trashCards.first {
|
||||||
replaceSelection(with: first.id, on: .trashed)
|
replaceSelection(with: first, in: .trash)
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
let lanes = liveLanes
|
let lanes = boardLanes
|
||||||
let filter = store.searchFilter
|
let filter = store.searchFilter
|
||||||
// A lane the search emptied is scanned past exactly as an empty one is — the jump lands on
|
// A lane the search emptied is scanned past exactly as an empty one is — the jump lands on
|
||||||
// the first lane that is *showing* a card, which is what the user can see.
|
// the first lane that is *showing* a card, which is what the user can see.
|
||||||
@@ -916,7 +916,7 @@ struct BoardView: View {
|
|||||||
? Self.firstCard(scanning: lanes.reversed(), filter: filter)
|
? Self.firstCard(scanning: lanes.reversed(), filter: filter)
|
||||||
: Self.firstCard(scanning: lanes, filter: filter)
|
: Self.firstCard(scanning: lanes, filter: filter)
|
||||||
guard let target else { return .handled }
|
guard let target else { return .handled }
|
||||||
replaceSelection(with: target, on: .live)
|
replaceSelection(with: target, in: .board)
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,7 +937,7 @@ struct BoardView: View {
|
|||||||
_ mode: ArrowMode,
|
_ mode: ArrowMode,
|
||||||
from head: ItemID
|
from head: ItemID
|
||||||
) -> KeyPress.Result {
|
) -> KeyPress.Result {
|
||||||
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
|
let lanes = SelectionGrammar.lanes(in: store.snapshot)
|
||||||
guard let index = lanes.firstIndex(of: head) else { return .handled }
|
guard let index = lanes.firstIndex(of: head) else { return .handled }
|
||||||
|
|
||||||
switch (direction, mode) {
|
switch (direction, mode) {
|
||||||
@@ -945,30 +945,30 @@ struct BoardView: View {
|
|||||||
let next = index + (direction == .left ? -1 : 1)
|
let next = index + (direction == .left ? -1 : 1)
|
||||||
guard lanes.indices.contains(next) else { return .handled }
|
guard lanes.indices.contains(next) else { return .handled }
|
||||||
if mode == .step {
|
if mode == .step {
|
||||||
replaceSelection(with: lanes[next], on: .live)
|
replaceSelection(with: lanes[next], in: .board)
|
||||||
} else {
|
} else {
|
||||||
let anchor = store.transient.selectionAnchor ?? head
|
let anchor = store.transient.selectionAnchor ?? head
|
||||||
guard let ids = SelectionGrammar.range(
|
guard let ids = SelectionGrammar.range(
|
||||||
from: anchor,
|
from: anchor,
|
||||||
to: lanes[next],
|
to: lanes[next],
|
||||||
kind: .lane,
|
kind: .lane,
|
||||||
on: .live,
|
in: .board,
|
||||||
in: store.snapshot
|
snapshot: store.snapshot
|
||||||
) else { return .handled }
|
) else { return .handled }
|
||||||
store.select(ids, liveness: .live, anchor: anchor, head: lanes[next])
|
store.select(ids, in: .board, anchor: anchor, head: lanes[next])
|
||||||
}
|
}
|
||||||
|
|
||||||
case (.left, .jump), (.right, .jump):
|
case (.left, .jump), (.right, .jump):
|
||||||
guard let target = direction == .left ? lanes.first : lanes.last else { return .handled }
|
guard let target = direction == .left ? lanes.first : lanes.last else { return .handled }
|
||||||
replaceSelection(with: target, on: .live)
|
replaceSelection(with: target, in: .board)
|
||||||
|
|
||||||
case (.down, .step), (.down, .jump):
|
case (.down, .step), (.down, .jump):
|
||||||
guard let lane = store.snapshot.lanes.first(where: { $0.id == head && !$0.isDeleted }) else {
|
guard let lane = store.snapshot.lanes.first(where: { $0.id == head }) else {
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
let cards = lane.cards.filter { !$0.isDeleted }
|
let cards = lane.cards
|
||||||
guard let target = mode == .jump ? cards.last : cards.first else { return .handled }
|
guard let target = mode == .jump ? cards.last : cards.first else { return .handled }
|
||||||
replaceSelection(with: target.id, on: .live)
|
replaceSelection(with: target.id, in: .board)
|
||||||
|
|
||||||
case (.up, _), (.down, .extend):
|
case (.up, _), (.down, .extend):
|
||||||
// Nothing above the lane domain, and no vertical range within it.
|
// Nothing above the lane domain, and no vertical range within it.
|
||||||
@@ -980,8 +980,8 @@ struct BoardView: View {
|
|||||||
// MARK: Shared
|
// MARK: Shared
|
||||||
|
|
||||||
/// A jump's and a plain step's shared landing: one item, both cursors on it.
|
/// A jump's and a plain step's shared landing: one item, both cursors on it.
|
||||||
private func replaceSelection(with id: ItemID, on side: Liveness) {
|
private func replaceSelection(with id: ItemID, in container: ItemContainer) {
|
||||||
store.select([id], liveness: side, anchor: id, head: id)
|
store.select([id], in: container, anchor: id, head: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The first rendered card of the first lane that has one — the scan every "first/last lane"
|
/// The first rendered card of the first lane that has one — the scan every "first/last lane"
|
||||||
@@ -994,7 +994,7 @@ struct BoardView: View {
|
|||||||
filter: SearchFilter = .inactive
|
filter: SearchFilter = .inactive
|
||||||
) -> ItemID? {
|
) -> ItemID? {
|
||||||
for lane in lanes {
|
for lane in lanes {
|
||||||
if let card = lane.cards.first(where: { !$0.isDeleted && filter.matches($0) }) { return card.id }
|
if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id }
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,20 +46,6 @@ enum DragKind: String, Codable, Sendable, Equatable {
|
|||||||
/// stray drop into a text editor does something sane rather than nothing.
|
/// stray drop into a text editor does something sane rather than nothing.
|
||||||
struct DragPayload: Codable, Sendable, Equatable {
|
struct DragPayload: Codable, Sendable, Equatable {
|
||||||
|
|
||||||
/// Which side of the live/tombstoned boundary the drag started on — a trash row's drag is a card
|
|
||||||
/// drag from the trashed side, and 04-interactions.md ▸ The trash gives it its own rules
|
|
||||||
/// (restore within the board, copy-out across boards).
|
|
||||||
enum Side: String, Codable, Sendable, Equatable {
|
|
||||||
case live
|
|
||||||
case trashed
|
|
||||||
|
|
||||||
init(_ liveness: Liveness) {
|
|
||||||
self = liveness == .live ? .live : .trashed
|
|
||||||
}
|
|
||||||
|
|
||||||
var liveness: Liveness { self == .live ? .live : .trashed }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One dragged item: its UUID, its folder on disk, and its title for the text representation.
|
/// One dragged item: its UUID, its folder on disk, and its title for the text representation.
|
||||||
struct Item: Codable, Sendable, Equatable {
|
struct Item: Codable, Sendable, Equatable {
|
||||||
var id: String
|
var id: String
|
||||||
@@ -71,7 +57,11 @@ struct DragPayload: Codable, Sendable, Equatable {
|
|||||||
var boardRoot: String
|
var boardRoot: String
|
||||||
|
|
||||||
var kind: DragKind
|
var kind: DragKind
|
||||||
var side: Side
|
|
||||||
|
/// Which container the drag started in — a trash card's drag is a card drag from `.trash`, which
|
||||||
|
/// is the whole of what makes its within-board drop a restore (04-interactions.md ▸ The trash).
|
||||||
|
/// `ItemContainer` is `String`-backed and `Codable` precisely so it can ride a pasteboard.
|
||||||
|
var container: ItemContainer
|
||||||
|
|
||||||
/// The dragged items **in flatten order** — "lane `order` first, then card `order`"
|
/// The dragged items **in flatten order** — "lane `order` first, then card `order`"
|
||||||
/// (04-interactions.md ▸ Drag and drop). The drop commits trust this order rather than
|
/// (04-interactions.md ▸ Drag and drop). The drop commits trust this order rather than
|
||||||
@@ -97,10 +87,10 @@ struct DragPayload: Codable, Sendable, Equatable {
|
|||||||
try? JSONEncoder().encode(self)
|
try? JSONEncoder().encode(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(boardRoot: URL, kind: DragKind, side: Liveness, items: [Item]) {
|
init(boardRoot: URL, kind: DragKind, container: ItemContainer, items: [Item]) {
|
||||||
self.boardRoot = boardRoot.path
|
self.boardRoot = boardRoot.path
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.side = Side(side)
|
self.container = container
|
||||||
self.items = items
|
self.items = items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,17 @@ struct DropTarget: Equatable, Sendable {
|
|||||||
/// The three surfaces a drop can name, spelled as a sum so the impossible combinations cannot be
|
/// The three surfaces a drop can name, spelled as a sum so the impossible combinations cannot be
|
||||||
/// written down at all.
|
/// written down at all.
|
||||||
///
|
///
|
||||||
/// The trash is a case rather than an id because **it has no id**: the quasi-lane is not in the
|
/// The trash is a case rather than an id because **it has no id**: `.trash/` "holds card
|
||||||
/// snapshot — it is `TrashModel.entries` derived from it — so there is nothing to put in a
|
/// folders directly — same shape as a lane's children, no `index.md` of its own"
|
||||||
/// `lane`, and its index is not a position the pointer chose either (see `TrashDrop`).
|
/// (01-storage-format.md § Deletion), so there is nothing to put in a `lane`, and its index is
|
||||||
|
/// not a position the pointer chose either (see `TrashDrop`).
|
||||||
enum Container: Equatable, Sendable {
|
enum Container: Equatable, Sendable {
|
||||||
/// The **lane strip**: the index counts live lanes with the dragged run removed.
|
/// The **lane strip**: the index counts the board's lanes with the dragged run removed.
|
||||||
case strip
|
case strip
|
||||||
/// That lane's **masonry**: the index is a position in its logical card order
|
/// That lane's **masonry**: the index is a position in its logical card order
|
||||||
/// (DRAG-REORDER.md § The card masonry).
|
/// (DRAG-REORDER.md § The card masonry).
|
||||||
case lane(ItemID)
|
case lane(ItemID)
|
||||||
/// The **trash quasi-lane**, which a live card drag proposes into to delete it
|
/// The **trash column**, which a board card drag proposes into to delete it
|
||||||
/// (04-interactions.md ▸ The trash, settled 2026-07-28). The index is always the topmost row.
|
/// (04-interactions.md ▸ The trash, settled 2026-07-28). The index is always the topmost row.
|
||||||
case trash
|
case trash
|
||||||
}
|
}
|
||||||
@@ -57,8 +58,9 @@ enum TrashDrop {
|
|||||||
|
|
||||||
/// The row the shadow takes, always: **the topmost**.
|
/// The row the shadow takes, always: **the topmost**.
|
||||||
///
|
///
|
||||||
/// Not arbitrary, and the sort is what makes it honest: the trash orders by `deleted`
|
/// Not arbitrary, and the ranks are what make it honest: "every trash arrival mints a rank
|
||||||
/// newest-first (03-board-ui.md § Trash), so a fresh tombstone genuinely lands on top. The drop
|
/// 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
|
/// 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
|
/// drop in this app makes — while being the only proposal on the board the *pointer* does not
|
||||||
/// choose.
|
/// choose.
|
||||||
@@ -70,7 +72,7 @@ enum TrashDrop {
|
|||||||
/// - **Lanes are not deliverable this way** — "a lane drag proposes only lane slots". (The strip's
|
/// - **Lanes are not deliverable this way** — "a lane drag proposes only lane slots". (The strip's
|
||||||
/// slot list has never contained the quasi-lane, so this is belt over braces; it is written down
|
/// slot list has never contained the quasi-lane, so this is belt over braces; it is written down
|
||||||
/// because a guard that is only true by construction is one refactor from being false.)
|
/// because a guard that is only true by construction is one refactor from being false.)
|
||||||
/// - **A trash row is already there.** A `.trashed` session's vocabulary is restore and copy-out;
|
/// - **A trash card is already there.** A `.trash` session's vocabulary is restore and copy-out;
|
||||||
/// dropping it back where it came from writes nothing.
|
/// dropping it back where it came from writes nothing.
|
||||||
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign card
|
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign card
|
||||||
/// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the
|
/// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the
|
||||||
@@ -82,14 +84,14 @@ enum TrashDrop {
|
|||||||
/// - **The mutating-gesture rule**, like every other write the pointer can start.
|
/// - **The mutating-gesture rule**, like every other write the pointer can start.
|
||||||
static func accepts(
|
static func accepts(
|
||||||
kind: DragKind?,
|
kind: DragKind?,
|
||||||
side: Liveness,
|
container: ItemContainer,
|
||||||
isWithinBoard: Bool,
|
isWithinBoard: Bool,
|
||||||
operation: TransferOperation,
|
operation: TransferOperation,
|
||||||
isTrashShown: Bool,
|
isTrashShown: Bool,
|
||||||
acceptsMutations: Bool
|
acceptsMutations: Bool
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
guard isTrashShown, acceptsMutations else { return false }
|
guard isTrashShown, acceptsMutations else { return false }
|
||||||
guard kind == .cards, side == .live, isWithinBoard else { return false }
|
guard kind == .cards, container == .board, isWithinBoard else { return false }
|
||||||
return operation == .move
|
return operation == .move
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,14 +209,14 @@ enum DragLocality {
|
|||||||
/// - **Lane drags never copy within their board.** ⌥ is simply ignored there: the drag stays a
|
/// - **Lane drags never copy within their board.** ⌥ is simply ignored there: the drag stays a
|
||||||
/// clean reorder and the badge never shows copy. The within-board lane duplicate exists, but
|
/// clean reorder and the badge never shows copy. The within-board lane duplicate exists, but
|
||||||
/// its home is the clipboard (▸ Clipboard, Lane paste).
|
/// its home is the clipboard (▸ Clipboard, Lane paste).
|
||||||
/// - **A trash row's drag is copy-out grammar** (▸ The trash). Within its own board it is the
|
/// - **A trash card's drag is the restore** (▸ The trash). Within its own board it is "an
|
||||||
/// restore — a move, no badge; across boards the default is the live copy that leaves the
|
/// ordinary move to the drop position"; across boards the default is the copy that leaves the
|
||||||
/// tombstoned original in place, exactly as ⌘C out of the trash behaves. ⌘ forces the true
|
/// original in the source trash, and "⌘-drag forces the true cross-board restore-move". Both
|
||||||
/// restore-move either way, and ⌥ forces the live copy either way ("⌘C, ⌥-drag, and the
|
/// fall out of the ordinary locality rule with no trash clause at all, which is the pivot's
|
||||||
/// cross-board drag default always yield *live* copies").
|
/// whole point.
|
||||||
static func operation(
|
static func operation(
|
||||||
kind: DragKind,
|
kind: DragKind,
|
||||||
side: Liveness,
|
container: ItemContainer,
|
||||||
isWithinBoard: Bool,
|
isWithinBoard: Bool,
|
||||||
modifiers: NSEvent.ModifierFlags
|
modifiers: NSEvent.ModifierFlags
|
||||||
) -> TransferOperation {
|
) -> TransferOperation {
|
||||||
@@ -227,7 +229,7 @@ enum DragLocality {
|
|||||||
|
|
||||||
if forcesMove { return .move }
|
if forcesMove { return .move }
|
||||||
if forcesCopy { return .copy }
|
if forcesCopy { return .copy }
|
||||||
_ = side // the side changes which commit runs, never which operation the badge shows
|
_ = container // the container changes which commit runs, never which operation the badge shows
|
||||||
return isWithinBoard ? .move : .copy
|
return isWithinBoard ? .move : .copy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,9 +274,9 @@ final class DragSession {
|
|||||||
/// means here rather than a separate flag.
|
/// means here rather than a separate flag.
|
||||||
private(set) var kind: DragKind?
|
private(set) var kind: DragKind?
|
||||||
|
|
||||||
/// The side the drag started on. A trash row's drag is a `.cards` session on the `.trashed`
|
/// The container the drag started in. A trash card's drag is a `.cards` session in `.trash`,
|
||||||
/// side, and that is the whole of what makes it one (04-interactions.md ▸ The trash).
|
/// and that is the whole of what makes it a restore (04-interactions.md ▸ The trash).
|
||||||
private(set) var side: Liveness = .live
|
private(set) var container: ItemContainer = .board
|
||||||
|
|
||||||
/// The dragged items in **flatten order** — the order they will land in.
|
/// The dragged items in **flatten order** — the order they will land in.
|
||||||
private(set) var members: [ItemID] = []
|
private(set) var members: [ItemID] = []
|
||||||
@@ -358,8 +360,8 @@ final class DragSession {
|
|||||||
|
|
||||||
/// The items to **leave out of the resting layout** on the board rooted at `root`.
|
/// The items to **leave out of the resting layout** on the board rooted at `root`.
|
||||||
///
|
///
|
||||||
/// Only the source board hides anything, and only for a live-side session: a trash row's drag
|
/// Only the source board hides anything, and only for a board-side session: a trash card's drag
|
||||||
/// carries items that render in the quasi-lane, not in any lane's masonry, so no lane loses a
|
/// carries items that render in the trash column, not in any lane's masonry, so no lane loses a
|
||||||
/// card to it.
|
/// card to it.
|
||||||
///
|
///
|
||||||
/// **The dragged run is lifted out whatever the effective operation is** (DRAG-REORDER.md §
|
/// **The dragged run is lifted out whatever the effective operation is** (DRAG-REORDER.md §
|
||||||
@@ -368,7 +370,7 @@ final class DragSession {
|
|||||||
/// originals reappear when the write lands — the hold keeps them lifted for that round trip, so
|
/// originals reappear when the write lands — the hold keeps them lifted for that round trip, so
|
||||||
/// the arrangement on screen is the one the release proposed and stays still until the echo.
|
/// the arrangement on screen is the one the release proposed and stays still until the echo.
|
||||||
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
|
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
|
||||||
guard isActive, side == .live, let sourceRoot,
|
guard isActive, container == .board, let sourceRoot,
|
||||||
DragLocality.isSameBoard(root, sourceRoot)
|
DragLocality.isSameBoard(root, sourceRoot)
|
||||||
else { return [] }
|
else { return [] }
|
||||||
return memberSet
|
return memberSet
|
||||||
@@ -473,27 +475,27 @@ final class DragSession {
|
|||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: Lifecycle
|
||||||
|
|
||||||
/// Begins a card session — live faces or trash rows.
|
/// Begins a card session — board faces or trash cards.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - members: the dragged cards in flatten order (`SelectionGrammar.liveCards`, or the trash's
|
/// - members: the dragged cards in flatten order (`SelectionGrammar.boardCards`, or the
|
||||||
/// own sorted order for a trash-row drag).
|
/// trash's own order for a trash-card drag).
|
||||||
/// - heights: their measured heights, captured **before** the pickup transition starts.
|
/// - heights: their measured heights, captured **before** the pickup transition starts.
|
||||||
func beginCards(
|
func beginCards(
|
||||||
_ members: [ItemID],
|
_ members: [ItemID],
|
||||||
folders: [URL],
|
folders: [URL],
|
||||||
heights: [CGFloat],
|
heights: [CGFloat],
|
||||||
side: Liveness,
|
container: ItemContainer,
|
||||||
source: BoardStore
|
source: BoardStore
|
||||||
) {
|
) {
|
||||||
begin(kind: .cards, members: members, folders: folders, side: side, source: source)
|
begin(kind: .cards, members: members, folders: folders, container: container, source: source)
|
||||||
cardHeights = heights
|
cardHeights = heights
|
||||||
laneUnits = []
|
laneUnits = []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Begins a lane session.
|
/// Begins a lane session.
|
||||||
func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) {
|
func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) {
|
||||||
begin(kind: .lanes, members: members, folders: folders, side: .live, source: source)
|
begin(kind: .lanes, members: members, folders: folders, container: .board, source: source)
|
||||||
laneUnits = units
|
laneUnits = units
|
||||||
cardHeights = []
|
cardHeights = []
|
||||||
}
|
}
|
||||||
@@ -502,7 +504,7 @@ final class DragSession {
|
|||||||
kind: DragKind,
|
kind: DragKind,
|
||||||
members: [ItemID],
|
members: [ItemID],
|
||||||
folders: [URL],
|
folders: [URL],
|
||||||
side: Liveness,
|
container: ItemContainer,
|
||||||
source: BoardStore
|
source: BoardStore
|
||||||
) {
|
) {
|
||||||
endHold()
|
endHold()
|
||||||
@@ -510,14 +512,14 @@ final class DragSession {
|
|||||||
self.members = members
|
self.members = members
|
||||||
self.memberSet = Set(members)
|
self.memberSet = Set(members)
|
||||||
self.folders = folders
|
self.folders = folders
|
||||||
self.side = side
|
self.container = container
|
||||||
self.sourceStore = source
|
self.sourceStore = source
|
||||||
self.sourceRoot = source.rootURL
|
self.sourceRoot = source.rootURL
|
||||||
self.proposal = nil
|
self.proposal = nil
|
||||||
self.operation = .move
|
self.operation = .move
|
||||||
// The reload-resolved drag set: vanished members leave it silently, which is what
|
// The reload-resolved drag set: vanished members leave it silently, which is what
|
||||||
// `survivors` reads and what "an emptied drag cancels itself" is stated in terms of.
|
// `survivors` reads and what "an emptied drag cancels itself" is stated in terms of.
|
||||||
source.transient.dragMembers = ItemReferenceSet(ids: memberSet, liveness: side)
|
source.transient.dragMembers = ItemReferenceSet(ids: memberSet, container: container)
|
||||||
armWatchdog()
|
armWatchdog()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,7 +540,7 @@ final class DragSession {
|
|||||||
guard let kind, let sourceRoot else { return operation }
|
guard let kind, let sourceRoot else { return operation }
|
||||||
let resolved = DragLocality.operation(
|
let resolved = DragLocality.operation(
|
||||||
kind: kind,
|
kind: kind,
|
||||||
side: side,
|
container: container,
|
||||||
isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot),
|
isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot),
|
||||||
modifiers: NSEvent.modifierFlags
|
modifiers: NSEvent.modifierFlags
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ struct LaneView: View {
|
|||||||
// the drag off until the pointer actually moves, so a click is never a drag.
|
// the drag off until the pointer actually moves, so a click is never a drag.
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
store.click(
|
store.click(
|
||||||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
SelectionTarget(id: lane.id, kind: .lane, container: .board),
|
||||||
modifier: .current,
|
modifier: .current,
|
||||||
togglesOnRepeat: true
|
togglesOnRepeat: true
|
||||||
)
|
)
|
||||||
@@ -232,17 +232,17 @@ struct LaneView: View {
|
|||||||
/// alone — standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept).
|
/// alone — standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept).
|
||||||
/// Right-clicking something outside the selection acts on what was clicked.
|
/// Right-clicking something outside the selection acts on what was clicked.
|
||||||
private var styleTarget: StyleTarget {
|
private var styleTarget: StyleTarget {
|
||||||
guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else {
|
guard store.selection.container == .board, store.selection.ids.contains(lane.id) else {
|
||||||
return .items([lane.id])
|
return .items([lane.id])
|
||||||
}
|
}
|
||||||
return .items(store.selection.ids)
|
return .items(store.selection.ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
||||||
/// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here
|
/// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
|
||||||
/// on the live side).
|
/// on the live side).
|
||||||
private var targetIDs: Set<ItemID> {
|
private var targetIDs: Set<ItemID> {
|
||||||
guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else {
|
guard store.selection.container == .board, store.selection.ids.contains(lane.id) else {
|
||||||
return [lane.id]
|
return [lane.id]
|
||||||
}
|
}
|
||||||
return store.selection.ids
|
return store.selection.ids
|
||||||
@@ -352,20 +352,20 @@ struct LaneView: View {
|
|||||||
private func startLaneDrag() -> NSItemProvider {
|
private func startLaneDrag() -> NSItemProvider {
|
||||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
let ids: Set<ItemID> = selection.liveness == .live
|
let ids: Set<ItemID> = selection.container == .board
|
||||||
&& selection.ids.contains(lane.id)
|
&& selection.ids.contains(lane.id)
|
||||||
&& selection.ids.count > 1
|
&& selection.ids.count > 1
|
||||||
? selection.ids
|
? selection.ids
|
||||||
: [lane.id]
|
: [lane.id]
|
||||||
|
|
||||||
let members = store.snapshot.lanes.filter { !$0.isDeleted && ids.contains($0.id) }
|
let members = store.snapshot.lanes.filter { ids.contains($0.id) }
|
||||||
guard !members.isEmpty else { return NSItemProvider() }
|
guard !members.isEmpty else { return NSItemProvider() }
|
||||||
|
|
||||||
let root = store.rootURL
|
let root = store.rootURL
|
||||||
let payload = DragPayload(
|
let payload = DragPayload(
|
||||||
boardRoot: root,
|
boardRoot: root,
|
||||||
kind: .lanes,
|
kind: .lanes,
|
||||||
side: .live,
|
container: .board,
|
||||||
items: members.map {
|
items: members.map {
|
||||||
DragPayload.Item(
|
DragPayload.Item(
|
||||||
id: $0.id.rawValue,
|
id: $0.id.rawValue,
|
||||||
@@ -405,7 +405,7 @@ struct LaneView: View {
|
|||||||
|
|
||||||
private var draggedLaneCount: Int {
|
private var draggedLaneCount: Int {
|
||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
guard selection.liveness == .live, selection.ids.contains(lane.id) else { return 1 }
|
guard selection.container == .board, selection.ids.contains(lane.id) else { return 1 }
|
||||||
return selection.ids.count
|
return selection.ids.count
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,7 +544,7 @@ struct LaneView: View {
|
|||||||
// shares (04-interactions.md § Selection), and the modifier grammar on top of it.
|
// shares (04-interactions.md § Selection), and the modifier grammar on top of it.
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
store.click(
|
store.click(
|
||||||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
SelectionTarget(id: lane.id, kind: .lane, container: .board),
|
||||||
modifier: .current,
|
modifier: .current,
|
||||||
togglesOnRepeat: true
|
togglesOnRepeat: true
|
||||||
)
|
)
|
||||||
@@ -552,7 +552,7 @@ struct LaneView: View {
|
|||||||
// The rubber band's first surface — "click-drag rubber-bands across lanes". Simultaneous
|
// The rubber band's first surface — "click-drag rubber-bands across lanes". Simultaneous
|
||||||
// so the taps above stay instant; the band's own begin guard is what keeps a drag that
|
// so the taps above stay instant; the band's own begin guard is what keeps a drag that
|
||||||
// started on a card face out of it (`MarqueeControl`).
|
// started on a card face out of it (`MarqueeControl`).
|
||||||
.simultaneousGesture(marquee.gesture(side: .live))
|
.simultaneousGesture(marquee.gesture(in: .board))
|
||||||
// The same menu the header carries — "one menu, invoked on the header or lane empty
|
// The same menu the header carries — "one menu, invoked on the header or lane empty
|
||||||
// space alike" (03-board-ui.md § Lane, settled).
|
// space alike" (03-board-ui.md § Lane, settled).
|
||||||
.contextMenu { laneMenu }
|
.contextMenu { laneMenu }
|
||||||
@@ -688,7 +688,7 @@ struct LaneView: View {
|
|||||||
renaming: ItemID?
|
renaming: ItemID?
|
||||||
) -> [Card] {
|
) -> [Card] {
|
||||||
cards.filter { card in
|
cards.filter { card in
|
||||||
guard !card.isDeleted, !hidden.contains(card.id) else { return false }
|
guard !hidden.contains(card.id) else { return false }
|
||||||
return filter.matches(card) || card.id == renaming
|
return filter.matches(card) || card.id == renaming
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -696,7 +696,7 @@ struct LaneView: View {
|
|||||||
// MARK: - Selection
|
// MARK: - Selection
|
||||||
|
|
||||||
private var isSelected: Bool {
|
private var isSelected: Bool {
|
||||||
store.selection.liveness == .live && store.selection.ids.contains(lane.id)
|
store.selection.container == .board && store.selection.ids.contains(lane.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet —
|
/// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet —
|
||||||
@@ -922,7 +922,7 @@ private struct CardFaceView: View {
|
|||||||
// Board ▸ Rename. The modifier grammar — plain replaces, ⌘ toggles, ⇧ ranges — is
|
// Board ▸ Rename. The modifier grammar — plain replaces, ⌘ toggles, ⇧ ranges — is
|
||||||
// `SelectionGrammar`'s, reached through the store's one funnel.
|
// `SelectionGrammar`'s, reached through the store's one funnel.
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
store.click(SelectionTarget(id: card.id, kind: .card, side: .live), modifier: .current)
|
store.click(SelectionTarget(id: card.id, kind: .card, container: .board), modifier: .current)
|
||||||
}
|
}
|
||||||
// "A fast double-click opens the card window (⌘↩'s pointer twin)" (04 ▸ Selection).
|
// "A fast double-click opens the card window (⌘↩'s pointer twin)" (04 ▸ Selection).
|
||||||
//
|
//
|
||||||
@@ -948,7 +948,7 @@ private struct CardFaceView: View {
|
|||||||
drops.registry.update(height: height, for: card.id)
|
drops.registry.update(height: height, for: card.id)
|
||||||
}
|
}
|
||||||
.onDisappear { drops.registry.removeHeight(card.id) }
|
.onDisappear { drops.registry.removeHeight(card.id) }
|
||||||
.marqueeTarget(card.id, kind: .card, side: .live, in: marquee.registry)
|
.marqueeTarget(card.id, kind: .card, container: .board, in: marquee.registry)
|
||||||
.contextMenu { cardMenu }
|
.contextMenu { cardMenu }
|
||||||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||||
@@ -960,7 +960,7 @@ private struct CardFaceView: View {
|
|||||||
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||||
///
|
///
|
||||||
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order** —
|
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order** —
|
||||||
/// "lane `order` first, then card `order`", `SelectionGrammar.liveCards`' single definition of
|
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
|
||||||
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
|
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
|
||||||
///
|
///
|
||||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||||
@@ -969,7 +969,7 @@ private struct CardFaceView: View {
|
|||||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||||
let snapshot = store.snapshot
|
let snapshot = store.snapshot
|
||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
let ids: Set<ItemID> = selection.liveness == .live
|
let ids: Set<ItemID> = selection.container == .board
|
||||||
&& selection.ids.contains(card.id)
|
&& selection.ids.contains(card.id)
|
||||||
&& selection.ids.count > 1
|
&& selection.ids.count > 1
|
||||||
? selection.ids
|
? selection.ids
|
||||||
@@ -978,21 +978,21 @@ private struct CardFaceView: View {
|
|||||||
// Flatten order, and the lane each member currently lives in — the folder path's middle
|
// Flatten order, and the lane each member currently lives in — the folder path's middle
|
||||||
// component.
|
// component.
|
||||||
var lanesByCard: [ItemID: ItemID] = [:]
|
var lanesByCard: [ItemID: ItemID] = [:]
|
||||||
var titles: [ItemID: String?] = [:]
|
var titles: [ItemID: String] = [:]
|
||||||
for lane in snapshot.lanes where !lane.isDeleted {
|
for lane in snapshot.lanes {
|
||||||
for member in lane.cards where !member.isDeleted && ids.contains(member.id) {
|
for member in lane.cards where ids.contains(member.id) {
|
||||||
lanesByCard[member.id] = lane.id
|
lanesByCard[member.id] = lane.id
|
||||||
titles[member.id] = member.title.value
|
titles[member.id] = member.title.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let ordered = SelectionGrammar.liveCards(in: snapshot).filter { ids.contains($0) }
|
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
|
||||||
guard !ordered.isEmpty else { return NSItemProvider() }
|
guard !ordered.isEmpty else { return NSItemProvider() }
|
||||||
|
|
||||||
let root = store.rootURL
|
let root = store.rootURL
|
||||||
let payload = DragPayload(
|
let payload = DragPayload(
|
||||||
boardRoot: root,
|
boardRoot: root,
|
||||||
kind: .cards,
|
kind: .cards,
|
||||||
side: .live,
|
container: .board,
|
||||||
items: ordered.compactMap { id in
|
items: ordered.compactMap { id in
|
||||||
guard let laneID = lanesByCard[id] else { return nil }
|
guard let laneID = lanesByCard[id] else { return nil }
|
||||||
return DragPayload.Item(
|
return DragPayload.Item(
|
||||||
@@ -1001,7 +1001,7 @@ private struct CardFaceView: View {
|
|||||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||||
.path,
|
.path,
|
||||||
title: titles[id] ?? nil
|
title: titles[id]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1012,7 +1012,7 @@ private struct CardFaceView: View {
|
|||||||
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||||||
// span-cap (03-board-ui.md § Motion).
|
// span-cap (03-board-ui.md § Motion).
|
||||||
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
|
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
|
||||||
side: .live,
|
container: .board,
|
||||||
source: store
|
source: store
|
||||||
)
|
)
|
||||||
return payload.itemProvider()
|
return payload.itemProvider()
|
||||||
@@ -1021,7 +1021,7 @@ private struct CardFaceView: View {
|
|||||||
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
|
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
|
||||||
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
|
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
|
||||||
private var dragReplica: some View {
|
private var dragReplica: some View {
|
||||||
let count = store.selection.liveness == .live && store.selection.ids.contains(card.id)
|
let count = store.selection.container == .board && store.selection.ids.contains(card.id)
|
||||||
? max(1, store.selection.ids.count)
|
? max(1, store.selection.ids.count)
|
||||||
: 1
|
: 1
|
||||||
return ZStack {
|
return ZStack {
|
||||||
@@ -1098,17 +1098,17 @@ private struct CardFaceView: View {
|
|||||||
/// alone — standard macOS context-menu targeting, shared by Style… (`styleTarget`) and Delete
|
/// alone — standard macOS context-menu targeting, shared by Style… (`styleTarget`) and Delete
|
||||||
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
|
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
|
||||||
private var styleTarget: StyleTarget {
|
private var styleTarget: StyleTarget {
|
||||||
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
|
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
|
||||||
return .items([card.id])
|
return .items([card.id])
|
||||||
}
|
}
|
||||||
return .items(store.selection.ids)
|
return .items(store.selection.ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
||||||
/// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here
|
/// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
|
||||||
/// on the live side).
|
/// on the live side).
|
||||||
private var targetIDs: Set<ItemID> {
|
private var targetIDs: Set<ItemID> {
|
||||||
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
|
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
|
||||||
return [card.id]
|
return [card.id]
|
||||||
}
|
}
|
||||||
return store.selection.ids
|
return store.selection.ids
|
||||||
@@ -1205,7 +1205,7 @@ private struct CardFaceView: View {
|
|||||||
// MARK: - Selection and rename plumbing
|
// MARK: - Selection and rename plumbing
|
||||||
|
|
||||||
private var isSelected: Bool {
|
private var isSelected: Bool {
|
||||||
store.selection.liveness == .live && store.selection.ids.contains(card.id)
|
store.selection.container == .board && store.selection.ids.contains(card.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether an external Finder file drag is hovering **this** card — the attach highlight
|
/// Whether an external Finder file drag is hovering **this** card — the attach highlight
|
||||||
@@ -1330,14 +1330,14 @@ private struct NewCardStubView: View {
|
|||||||
///
|
///
|
||||||
/// The lane is read before the commit, because every discard path clears the overlay that holds
|
/// The lane is read before the commit, because every discard path clears the overlay that holds
|
||||||
/// it — and re-checked after, because one of those paths is *the lane vanished*, and selecting
|
/// it — and re-checked after, because one of those paths is *the lane vanished*, and selecting
|
||||||
/// something that renders nowhere would break the homogeneous-by-liveness invariant until the
|
/// something that renders nowhere would break the one-container invariant until the
|
||||||
/// next reload swept it away.
|
/// next reload swept it away.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func commit() -> ItemID? {
|
private func commit() -> ItemID? {
|
||||||
let lane = store.transient.newCardPlaceholder?.laneID
|
let lane = store.transient.newCardPlaceholder?.laneID
|
||||||
let id = store.commitPlaceholder()
|
let id = store.commitPlaceholder()
|
||||||
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) {
|
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane }) {
|
||||||
store.select([lane], liveness: .live)
|
store.select([lane], in: .board)
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ final class MarqueeSession {
|
|||||||
private(set) var current: CGPoint = .zero
|
private(set) var current: CGPoint = .zero
|
||||||
|
|
||||||
/// The side of the live/trash boundary this band selects on, frozen at `begin`.
|
/// The side of the live/trash boundary this band selects on, frozen at `begin`.
|
||||||
private(set) var side: Liveness = .live
|
private(set) var container: ItemContainer = .board
|
||||||
|
|
||||||
/// How far the pointer must travel before a drag on empty space becomes a band. Larger than the
|
/// How far the pointer must travel before a drag on empty space becomes a band. Larger than the
|
||||||
/// lane header's threshold because this gesture arms on *any* empty surface, and a click that
|
/// lane header's threshold because this gesture arms on *any* empty surface, and a click that
|
||||||
@@ -51,10 +51,10 @@ final class MarqueeSession {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func begin(at point: CGPoint, side: Liveness) {
|
func begin(at point: CGPoint, in container: ItemContainer) {
|
||||||
origin = point
|
origin = point
|
||||||
current = point
|
current = point
|
||||||
self.side = side
|
self.container = container
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(to point: CGPoint) {
|
func update(to point: CGPoint) {
|
||||||
@@ -81,9 +81,9 @@ final class MarqueeSession {
|
|||||||
/// is on screen. It is also what makes the band correct for free across a lane resize, a reorder in
|
/// is on screen. It is also what makes the band correct for free across a lane resize, a reorder in
|
||||||
/// flight, and a foreign reload — the frames simply re-register.
|
/// flight, and a foreign reload — the frames simply re-register.
|
||||||
///
|
///
|
||||||
/// **Lanes are never registered.** The band selects cards, and trash rows on the trash side; a lane
|
/// **Lanes are never registered.** The band selects cards — board cards or trash cards; a lane has
|
||||||
/// has no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands
|
/// no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands across
|
||||||
/// across lanes" made structural rather than filtered.
|
/// lanes" made structural rather than filtered.
|
||||||
///
|
///
|
||||||
/// It is also the **begin guard's** universe: a drag that starts inside a registered frame belongs
|
/// It is also the **begin guard's** universe: a drag that starts inside a registered frame belongs
|
||||||
/// to that item's own gesture (a card drag, a drag out of the trash), never to the band. Deciding
|
/// to that item's own gesture (a card drag, a drag out of the trash), never to the band. Deciding
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ enum NewCardTarget {
|
|||||||
/// the two can never disagree.
|
/// the two can never disagree.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - selection: the board's current selection, liveness side included. A `.trashed` selection
|
/// - selection: the board's current selection, container included. A `.trash` selection
|
||||||
/// "never anchors creation" and is treated exactly as an empty one — the settled precedent
|
/// "never anchors creation" and is treated exactly as an empty one — the settled precedent
|
||||||
/// 04 ▸ Clipboard cites for paste, applied here to its source rule ("a trashed card's live
|
/// 04 ▸ Clipboard cites for paste, applied here to its source rule ("a trashed card's live
|
||||||
/// disk-lane never leaks in as 'the selected card's lane'").
|
/// disk-lane never leaks in as 'the selected card's lane'").
|
||||||
@@ -53,7 +53,7 @@ enum NewCardTarget {
|
|||||||
lastActiveLaneID: ItemID?,
|
lastActiveLaneID: ItemID?,
|
||||||
snapshot: BoardModel
|
snapshot: BoardModel
|
||||||
) -> Resolution? {
|
) -> Resolution? {
|
||||||
let lanes = snapshot.lanes.filter { !$0.isDeleted }
|
let lanes = snapshot.lanes
|
||||||
guard !lanes.isEmpty else { return nil }
|
guard !lanes.isEmpty else { return nil }
|
||||||
|
|
||||||
if let anchor = flattenAnchor(selection: selection, snapshot: snapshot) {
|
if let anchor = flattenAnchor(selection: selection, snapshot: snapshot) {
|
||||||
@@ -86,21 +86,21 @@ enum NewCardTarget {
|
|||||||
/// which falls out of never looking at the trashed side at all), and a stale one whose ids name
|
/// which falls out of never looking at the trashed side at all), and a stale one whose ids name
|
||||||
/// nothing the board renders.
|
/// nothing the board renders.
|
||||||
static func flattenAnchor(selection: ItemReferenceSet, snapshot: BoardModel) -> Resolution? {
|
static func flattenAnchor(selection: ItemReferenceSet, snapshot: BoardModel) -> Resolution? {
|
||||||
guard selection.liveness == .live, !selection.ids.isEmpty else { return nil }
|
guard selection.container == .board, !selection.ids.isEmpty else { return nil }
|
||||||
|
|
||||||
// The snapshot's lanes and cards are already in display order, so the flatten order is one
|
// The snapshot's lanes and cards are already in display order, so the flatten order is one
|
||||||
// walk, and the *last* hit is the anchor. Selection is homogeneous (cards XOR lanes), so only
|
// walk, and the *last* hit is the anchor. Selection is homogeneous (cards XOR lanes), so only
|
||||||
// one of the two branches ever fires within a walk; a sole selection is simply the degenerate
|
// one of the two branches ever fires within a walk; a sole selection is simply the degenerate
|
||||||
// one-member case of the same rule.
|
// one-member case of the same rule.
|
||||||
var anchor: Resolution?
|
var anchor: Resolution?
|
||||||
for lane in snapshot.lanes where !lane.isDeleted {
|
for lane in snapshot.lanes {
|
||||||
// A selected lane: creation appends at its bottom, Return consistency; paste lands after
|
// A selected lane: creation appends at its bottom, Return consistency; paste lands after
|
||||||
// the lane itself.
|
// the lane itself.
|
||||||
if selection.ids.contains(lane.id) {
|
if selection.ids.contains(lane.id) {
|
||||||
anchor = Resolution(laneID: lane.id, anchorCardID: nil)
|
anchor = Resolution(laneID: lane.id, anchorCardID: nil)
|
||||||
}
|
}
|
||||||
// A selected card: its lane, immediately after it — paste-anchor consistency.
|
// A selected card: its lane, immediately after it — paste-anchor consistency.
|
||||||
for card in lane.cards where !card.isDeleted && selection.ids.contains(card.id) {
|
for card in lane.cards where selection.ids.contains(card.id) {
|
||||||
anchor = Resolution(laneID: lane.id, anchorCardID: card.id)
|
anchor = Resolution(laneID: lane.id, anchorCardID: card.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ enum PasteTarget {
|
|||||||
lastActiveLaneID: lastActiveLaneID,
|
lastActiveLaneID: lastActiveLaneID,
|
||||||
snapshot: snapshot
|
snapshot: snapshot
|
||||||
),
|
),
|
||||||
let lane = snapshot.lanes.first(where: { $0.id == resolution.laneID && !$0.isDeleted })
|
let lane = snapshot.lanes.first(where: { $0.id == resolution.laneID })
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
let rendered = lane.cards.filter { !$0.isDeleted }
|
let rendered = lane.cards
|
||||||
// `insertionIndex` answers `nil` for "append", which is `rendered.count` — the same position
|
// `insertionIndex` answers `nil` for "append", which is `rendered.count` — the same position
|
||||||
// said two ways, and the creation path's own degradation for an anchor that has since gone.
|
// said two ways, and the creation path's own degradation for an anchor that has since gone.
|
||||||
let index = BoardStore.insertionIndex(after: resolution.anchorCardID, among: rendered) ?? rendered.count
|
let index = BoardStore.insertionIndex(after: resolution.anchorCardID, among: rendered) ?? rendered.count
|
||||||
@@ -64,7 +64,7 @@ enum PasteTarget {
|
|||||||
/// included, because lane paste "stays enabled and lands at the board's right end" whatever the
|
/// included, because lane paste "stays enabled and lands at the board's right end" whatever the
|
||||||
/// board holds. That is what makes it the other way out of a board with no lanes.
|
/// board holds. That is what makes it the other way out of a board with no lanes.
|
||||||
static func lanes(selection: ItemReferenceSet, snapshot: BoardModel) -> Int {
|
static func lanes(selection: ItemReferenceSet, snapshot: BoardModel) -> Int {
|
||||||
let lanes = snapshot.lanes.filter { !$0.isDeleted }
|
let lanes = snapshot.lanes
|
||||||
guard let anchor = NewCardTarget.flattenAnchor(selection: selection, snapshot: snapshot),
|
guard let anchor = NewCardTarget.flattenAnchor(selection: selection, snapshot: snapshot),
|
||||||
let position = lanes.firstIndex(where: { $0.id == anchor.laneID })
|
let position = lanes.firstIndex(where: { $0.id == anchor.laneID })
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -47,24 +47,25 @@ struct MarqueeControl {
|
|||||||
/// loop simply keeps declining for the rest of that drag. Deciding this by frames rather than
|
/// loop simply keeps declining for the rest of that drag. Deciding this by frames rather than
|
||||||
/// by gesture priority is what keeps the two from fighting, and it stays correct as the
|
/// by gesture priority is what keeps the two from fighting, and it stays correct as the
|
||||||
/// masonry reflows.
|
/// masonry reflows.
|
||||||
/// - **The side is fixed at the origin** — 04-interactions.md ▸ The trash's rule, stored in the
|
/// - **The container is fixed at the origin** — 04-interactions.md ▸ The trash's rule ("the
|
||||||
/// session so a band dragged across the boundary keeps its meaning.
|
/// rubber band stays on the side it started on"), stored in the session so a band dragged
|
||||||
|
/// across the boundary keeps its meaning.
|
||||||
/// - **Live-updating, not commit-on-release**: each sample recomputes the whole set from the
|
/// - **Live-updating, not commit-on-release**: each sample recomputes the whole set from the
|
||||||
/// band, so the selection follows the cursor both ways. An empty band clears rather than
|
/// band, so the selection follows the cursor both ways. An empty band clears rather than
|
||||||
/// leaving the last non-empty one standing.
|
/// leaving the last non-empty one standing.
|
||||||
/// - **Alive under the read-only lock**: selection is not a mutation (02-architecture.md § The
|
/// - **Alive under the read-only lock**: selection is not a mutation (02-architecture.md § The
|
||||||
/// lock's scope), and no `isEditingInline` guard either — a click-away mid-rename already
|
/// lock's scope), and no `isEditingInline` guard either — a click-away mid-rename already
|
||||||
/// commits through the field's own focus loss.
|
/// commits through the field's own focus loss.
|
||||||
func gesture(side: Liveness) -> some Gesture {
|
func gesture(in container: ItemContainer) -> some Gesture {
|
||||||
DragGesture(minimumDistance: MarqueeSession.minimumDistance, coordinateSpace: .named(BoardView.stripSpace))
|
DragGesture(minimumDistance: MarqueeSession.minimumDistance, coordinateSpace: .named(BoardView.stripSpace))
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
if !session.isActive {
|
if !session.isActive {
|
||||||
guard !registry.contains(value.startLocation) else { return }
|
guard !registry.contains(value.startLocation) else { return }
|
||||||
session.begin(at: value.startLocation, side: side)
|
session.begin(at: value.startLocation, in: container)
|
||||||
}
|
}
|
||||||
session.update(to: value.location)
|
session.update(to: value.location)
|
||||||
guard let rect = session.rect else { return }
|
guard let rect = session.rect else { return }
|
||||||
let ids = MarqueeMath.selection(rect: rect, targets: registry.all, side: session.side)
|
let ids = MarqueeMath.selection(rect: rect, targets: registry.all, in: session.container)
|
||||||
if ids.isEmpty {
|
if ids.isEmpty {
|
||||||
store.clearSelection()
|
store.clearSelection()
|
||||||
} else {
|
} else {
|
||||||
@@ -74,7 +75,7 @@ struct MarqueeControl {
|
|||||||
// Both are spelled out rather than defaulted, because `select`'s sole-member
|
// Both are spelled out rather than defaulted, because `select`'s sole-member
|
||||||
// default would otherwise pick one up the moment a band happened to sweep
|
// default would otherwise pick one up the moment a band happened to sweep
|
||||||
// exactly one card.
|
// exactly one card.
|
||||||
store.select(ids, liveness: session.side, anchor: nil, head: nil)
|
store.select(ids, in: session.container, anchor: nil, head: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onEnded { _ in session.end() }
|
.onEnded { _ in session.end() }
|
||||||
@@ -111,7 +112,7 @@ extension View {
|
|||||||
func marqueeTarget(
|
func marqueeTarget(
|
||||||
_ id: ItemID,
|
_ id: ItemID,
|
||||||
kind: SelectionKind,
|
kind: SelectionKind,
|
||||||
side: Liveness,
|
container: ItemContainer,
|
||||||
in registry: MarqueeTargetRegistry
|
in registry: MarqueeTargetRegistry
|
||||||
) -> some View {
|
) -> some View {
|
||||||
// The space name is read here, on the main actor, rather than inside the measuring closure:
|
// The space name is read here, on the main actor, rather than inside the measuring closure:
|
||||||
@@ -120,7 +121,7 @@ extension View {
|
|||||||
return onGeometryChange(for: CGRect.self) { proxy in
|
return onGeometryChange(for: CGRect.self) { proxy in
|
||||||
proxy.frame(in: .named(space))
|
proxy.frame(in: .named(space))
|
||||||
} action: { frame in
|
} action: { frame in
|
||||||
registry.update(MarqueeTarget(id: id, kind: kind, side: side, frame: frame))
|
registry.update(MarqueeTarget(id: id, kind: kind, container: container, frame: frame))
|
||||||
}
|
}
|
||||||
.onDisappear { registry.remove(id) }
|
.onDisappear { registry.remove(id) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ final class TrashConfirmations {
|
|||||||
/// The **phrasing is captured when the request is made**, not recomputed at render time: the
|
/// The **phrasing is captured when the request is made**, not recomputed at render time: the
|
||||||
/// user is being asked about the trash as it was when they invoked the command, and a foreign
|
/// user is being asked about the trash as it was when they invoked the command, and a foreign
|
||||||
/// reload landing mid-alert must not silently change the sentence they are reading. The *action*
|
/// reload landing mid-alert must not silently change the sentence they are reading. The *action*
|
||||||
/// re-resolves against the current snapshot when it runs, so a confirmed purge never acts on an
|
/// re-resolves against the current snapshot when it runs, so a confirmed purge never acts on a
|
||||||
/// item that has since gone — `BoardWriter.purgeItem` treats an absent folder as success.
|
/// card that has since gone — the writer treats an absent folder as success.
|
||||||
private(set) var pending: Pending?
|
private(set) var pending: Pending?
|
||||||
|
|
||||||
struct Pending: Identifiable, Equatable {
|
struct Pending: Identifiable, Equatable {
|
||||||
@@ -30,20 +30,46 @@ final class TrashConfirmations {
|
|||||||
let prompt: TrashModel.PurgePrompt
|
let prompt: TrashModel.PurgePrompt
|
||||||
let action: Action
|
let action: Action
|
||||||
|
|
||||||
/// What the confirmation is standing in front of. Two cases, because the two commands have
|
/// What the confirmation is standing in front of. Three cases, because the three commands
|
||||||
/// genuinely different scopes: one names a selection, the other names the whole trash and
|
/// have genuinely different scopes and two different writes: the trash's own staged Delete,
|
||||||
/// re-derives its targets at the moment it runs.
|
/// Delete Immediately (which skips the trash from either container), and Empty Trash (which
|
||||||
|
/// names the whole container and re-derives its targets at the moment it runs).
|
||||||
enum Action: Equatable {
|
enum Action: Equatable {
|
||||||
|
case deleteTrashCards(Set<ItemID>)
|
||||||
case purge(Set<ItemID>)
|
case purge(Set<ItemID>)
|
||||||
case emptyTrash
|
case emptyTrash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **File ▸ Delete, staged by place** (04-interactions.md ▸ The map) — with the confirmation the
|
||||||
|
/// trash side owes and the board side does not.
|
||||||
|
///
|
||||||
|
/// A board selection goes straight through: moving a card into the trash and deleting a lane are
|
||||||
|
/// both recoverable (the trash itself, and native undo — 03-board-ui.md § Trash), so neither
|
||||||
|
/// stands an alert. A **trash** selection is the permanent one, and it "confirms exactly where
|
||||||
|
/// the loss is real": `purgeIsUnrecoverable` decides, exactly as it does for Delete Immediately.
|
||||||
|
///
|
||||||
|
/// The staging itself lives on the store (`BoardStore.deleteSelection`), so this is the alert and
|
||||||
|
/// nothing else — the two can never disagree about which write a ⌘⌫ performs.
|
||||||
|
func requestDelete(in store: BoardStore) {
|
||||||
|
guard store.selection.container == .trash, store.purgeIsUnrecoverable else {
|
||||||
|
store.deleteSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let prompt = TrashModel.purgePrompt(
|
||||||
|
for: store.selection.ids,
|
||||||
|
in: .trash,
|
||||||
|
snapshot: store.snapshot,
|
||||||
|
unrecoverable: true
|
||||||
|
) else { return }
|
||||||
|
pending = Pending(prompt: prompt, action: .deleteTrashCards(store.selection.ids))
|
||||||
|
}
|
||||||
|
|
||||||
/// Raises Delete Immediately's alert — **or purges outright** where the loss is not real.
|
/// Raises Delete Immediately's alert — **or purges outright** where the loss is not real.
|
||||||
///
|
///
|
||||||
/// The mode check is the one thing that decides between the two, and it lives on the store as a
|
/// The mode check is the one thing that decides between the two, and it lives on the store as a
|
||||||
/// named predicate (`BoardStore.purgeIsUnrecoverable`) so the git milestone changes one
|
/// named predicate (`BoardStore.purgeIsUnrecoverable`) so the git milestone changes one
|
||||||
/// expression rather than two call sites.
|
/// expression rather than three call sites.
|
||||||
func requestPurge(of ids: Set<ItemID>, in store: BoardStore) {
|
func requestPurge(of ids: Set<ItemID>, in store: BoardStore) {
|
||||||
guard store.purgeIsUnrecoverable else {
|
guard store.purgeIsUnrecoverable else {
|
||||||
store.deleteImmediately(ids)
|
store.deleteImmediately(ids)
|
||||||
@@ -51,7 +77,8 @@ final class TrashConfirmations {
|
|||||||
}
|
}
|
||||||
guard let prompt = TrashModel.purgePrompt(
|
guard let prompt = TrashModel.purgePrompt(
|
||||||
for: ids,
|
for: ids,
|
||||||
in: store.snapshot,
|
in: store.selection.container,
|
||||||
|
snapshot: store.snapshot,
|
||||||
unrecoverable: true
|
unrecoverable: true
|
||||||
) else { return }
|
) else { return }
|
||||||
pending = Pending(prompt: prompt, action: .purge(ids))
|
pending = Pending(prompt: prompt, action: .purge(ids))
|
||||||
@@ -73,6 +100,7 @@ final class TrashConfirmations {
|
|||||||
guard let pending else { return }
|
guard let pending else { return }
|
||||||
self.pending = nil
|
self.pending = nil
|
||||||
switch pending.action {
|
switch pending.action {
|
||||||
|
case let .deleteTrashCards(ids): store.deleteTrashCards(ids)
|
||||||
case let .purge(ids): store.deleteImmediately(ids)
|
case let .purge(ids): store.deleteImmediately(ids)
|
||||||
case .emptyTrash: store.emptyTrash()
|
case .emptyTrash: store.emptyTrash()
|
||||||
}
|
}
|
||||||
@@ -96,22 +124,17 @@ extension FocusedValues {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - File ▸ Delete / Put Back / Delete Immediately / Empty Trash…
|
// MARK: - File ▸ Delete / Delete Immediately / Empty Trash…
|
||||||
|
|
||||||
/// The File menu's trash rows (11-command-nexus.md).
|
/// The File menu's trash rows (11-command-nexus.md).
|
||||||
///
|
///
|
||||||
/// ### The ⌘⌫ chord twins
|
/// ### One Delete, staged by place
|
||||||
///
|
///
|
||||||
/// Delete and Put Back are **two items sharing one key equivalent**, and validation enables exactly
|
/// **Put Back is retired with the tombstone model** (04-interactions.md ▸ The map, resettled
|
||||||
/// one of them: "AppKit routes a shared key equivalent to the enabled item" (04-interactions.md ▸
|
/// 2026-07-28): "File ▸ Delete is the chord's only owner — no twin menu items, no shared-equivalent
|
||||||
/// The map, which names Finder's own Move to Trash/Put Back pair as the precedent). The two
|
/// routing". The ⌘⌫ chord therefore has exactly one owner, its validation is one predicate
|
||||||
/// predicates are mirror images over the selection's liveness side
|
/// (`TrashModel.canDelete`), and which write it performs is decided by the selection's *container*
|
||||||
/// (`TrashModel.canDelete`/`canActOnTrash`), so they can neither both enable nor both disable while
|
/// inside the store rather than by AppKit picking whichever of two items happened to be enabled.
|
||||||
/// something is selected — and a selection can never be mixed, because
|
|
||||||
/// `ItemReferenceSet.resolved(against:)` treats a liveness flip as a vanish.
|
|
||||||
///
|
|
||||||
/// **Both titles stay stable** (titles-are-API): each remaps independently through the system
|
|
||||||
/// mechanism, and remapping one never moves the other's role.
|
|
||||||
struct TrashCommands: View {
|
struct TrashCommands: View {
|
||||||
|
|
||||||
@FocusedValue(\.boardStore) private var store
|
@FocusedValue(\.boardStore) private var store
|
||||||
@@ -119,24 +142,18 @@ struct TrashCommands: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button("Delete") {
|
Button("Delete") {
|
||||||
store?.deleteSelection()
|
guard let store, let confirmations else { return }
|
||||||
|
confirmations.requestDelete(in: store)
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.delete, modifiers: .command)
|
.keyboardShortcut(.delete, modifiers: .command)
|
||||||
.disabled(!canDelete)
|
.disabled(!canDelete || confirmations == nil)
|
||||||
|
|
||||||
Button("Put Back") {
|
|
||||||
guard let store else { return }
|
|
||||||
store.putBack(store.selection.ids)
|
|
||||||
}
|
|
||||||
.keyboardShortcut(.delete, modifiers: .command)
|
|
||||||
.disabled(!canActOnTrash)
|
|
||||||
|
|
||||||
Button("Delete Immediately") {
|
Button("Delete Immediately") {
|
||||||
guard let store, let confirmations else { return }
|
guard let store, let confirmations else { return }
|
||||||
confirmations.requestPurge(of: store.selection.ids, in: store)
|
confirmations.requestPurge(of: store.selection.ids, in: store)
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.delete, modifiers: [.option, .command])
|
.keyboardShortcut(.delete, modifiers: [.option, .command])
|
||||||
.disabled(!canActOnTrash || confirmations == nil)
|
.disabled(!canDeleteImmediately || confirmations == nil)
|
||||||
|
|
||||||
Button("Empty Trash…") {
|
Button("Empty Trash…") {
|
||||||
guard let store, let confirmations else { return }
|
guard let store, let confirmations else { return }
|
||||||
@@ -146,29 +163,29 @@ struct TrashCommands: View {
|
|||||||
.disabled(!canEmptyTrash)
|
.disabled(!canEmptyTrash)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A live, non-empty selection on a board that accepts writes.
|
/// A non-empty selection that still names something, on a board that accepts writes — both
|
||||||
|
/// stagings at once, which is what having one item means.
|
||||||
private var canDelete: Bool {
|
private var canDelete: Bool {
|
||||||
guard let store, store.acceptsBoardMutations else { return false }
|
guard let store, store.acceptsBoardMutations else { return false }
|
||||||
return TrashModel.canDelete(selection: store.selection, in: store.snapshot)
|
return TrashModel.canDelete(selection: store.selection, in: store.snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tombstoned, non-empty selection — Put Back's condition and Delete Immediately's alike, the
|
/// A **card** selection, in either container — "skips the trash from anywhere"
|
||||||
/// two being the trash side's pair (04-interactions.md ▸ The trash: "menu validation stays
|
/// (11-command-nexus.md).
|
||||||
/// binary").
|
private var canDeleteImmediately: Bool {
|
||||||
private var canActOnTrash: Bool {
|
|
||||||
guard let store, store.acceptsBoardMutations else { return false }
|
guard let store, store.acceptsBoardMutations else { return false }
|
||||||
return TrashModel.canActOnTrash(selection: store.selection, in: store.snapshot)
|
return TrashModel.canDeleteImmediately(selection: store.selection, in: store.snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for this row) — where
|
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for this row) — where
|
||||||
/// "non-empty" reads the *board's* tombstones and never the filtered view (03-board-ui.md §
|
/// "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash: "menu
|
||||||
/// Trash: "a bulk command about the trash itself never silently narrows to the visible subset").
|
/// validation's non-empty reads `.trash/`, not the filtered view").
|
||||||
///
|
///
|
||||||
/// The visibility clause is 04-interactions.md's, stated for the whole quasi-lane: "hidden, it is
|
/// The visibility clause is 04-interactions.md's, stated for the whole column: "hidden, it is
|
||||||
/// invisible to every gesture".
|
/// invisible to every gesture".
|
||||||
private var canEmptyTrash: Bool {
|
private var canEmptyTrash: Bool {
|
||||||
guard let store, store.acceptsBoardMutations, store.transient.isTrashVisible else { return false }
|
guard let store, store.acceptsBoardMutations, store.transient.isTrashVisible else { return false }
|
||||||
return !TrashModel.isEmpty(store.snapshot)
|
return !store.snapshot.trash.isEmpty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,12 +214,10 @@ struct ShowTrashCommand: View {
|
|||||||
|
|
||||||
/// The toggle's binding — and the one place hiding the trash has a consequence beyond layout.
|
/// The toggle's binding — and the one place hiding the trash has a consequence beyond layout.
|
||||||
///
|
///
|
||||||
/// **Hiding drops a tombstoned selection.** The rows it pointed at are no longer on screen, and
|
/// **Hiding drops a trash selection** (04-interactions.md ▸ The trash: "hiding it clears a
|
||||||
/// "nothing invisible may stay selected" is the invariant every item-referencing set in this app
|
/// selection of trash cards — nothing invisible stays selected, so the toggle-off drops the
|
||||||
/// already obeys (`ItemReferenceSet`); leaving one behind would also leave Put Back and Delete
|
/// selection rather than leave commands enabled against rows nobody can see"). A *board*
|
||||||
/// Immediately enabled against a trash the user cannot see, which 04-interactions.md rules out
|
/// selection is untouched — the board it names is still right there.
|
||||||
/// outright ("hidden, it is invisible to every gesture"). A *live* selection is untouched — the
|
|
||||||
/// board it names is still right there.
|
|
||||||
///
|
///
|
||||||
/// The setter's body lives on the store (`BoardStore.setTrashVisible`) because the toolbar's
|
/// The setter's body lives on the store (`BoardStore.setTrashVisible`) because the toolbar's
|
||||||
/// Show Trash item is this same command with a different face (03-board-ui.md ▸ Toolbar: "toggle
|
/// Show Trash item is this same command with a different face (03-board-ui.md ▸ Toolbar: "toggle
|
||||||
@@ -237,7 +252,7 @@ extension BoardStore {
|
|||||||
// very animation, and a selection that cleared outside it would be the highlight easing
|
// very animation, and a selection that cleared outside it would be the highlight easing
|
||||||
// on its own — which 03 § Motion rules out ("the selection highlight rides whatever
|
// on its own — which 03 § Motion rules out ("the selection highlight rides whatever
|
||||||
// transaction is active").
|
// transaction is active").
|
||||||
if !shown, selection.liveness == .trashed {
|
if !shown, selection.container == .trash {
|
||||||
clearSelection()
|
clearSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-171
@@ -4,63 +4,55 @@ import SwiftUI
|
|||||||
|
|
||||||
// MARK: - TrashLaneView
|
// MARK: - TrashLaneView
|
||||||
|
|
||||||
/// The trash quasi-lane: the trailing, visually distinct column tombstoned items live in
|
/// The trash column: the trailing, visually distinct column the board's `.trash/` cards live in
|
||||||
/// (03-board-ui.md § Trash).
|
/// (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized trash).
|
||||||
///
|
///
|
||||||
/// ### A pure view, and a quasi-lane
|
/// ### A rendering of `snapshot.trash`, and a quasi-lane
|
||||||
///
|
///
|
||||||
/// **Nothing here moves anything on disk.** Tombstoned items keep their `deleted:` key and stay
|
/// **Its contents are ordinary cards in a special place**, so there is nothing to derive: the column
|
||||||
/// exactly where they are; this column is a rendering of `TrashModel.entries(of:)` and nothing more.
|
/// renders `store.snapshot.trash`, which the loader parsed with the same card parse the lanes use
|
||||||
/// It is a *quasi*-lane because it wears a lane's shape while sharing none of a lane's machinery:
|
/// and sorted by `order` like any lane's children. Newest-first falls out of the ranks (every
|
||||||
|
/// arrival mints one above the current top), so there is no timestamp sort and no entry type here at
|
||||||
|
/// all. It is a *quasi*-lane because it wears a lane's shape while sharing none of a lane's
|
||||||
|
/// machinery:
|
||||||
///
|
///
|
||||||
/// - it spans a **fixed one width unit** — no `width` frontmatter, no stepper, no resize handle, and
|
/// - it spans a **fixed one width unit** — no `width` frontmatter, no stepper, no resize handle, and
|
||||||
/// the edge drag never reaches it (`LaneLayoutMath.totalUnits(of:trashUnits:)` supplies the unit,
|
/// the edge drag never reaches it (`LaneLayoutMath.totalUnits(of:trashUnits:)` supplies the unit,
|
||||||
/// so there is no `Lane` value for any of those to act on);
|
/// so there is no `Lane` value for any of those to act on);
|
||||||
/// - it is **not draggable and not reorderable** — the header carries no gesture, and it is absent
|
/// - it is **not draggable and not reorderable** — the header carries no gesture, and it is absent
|
||||||
/// from the drop proposal's slot list by construction, since `BoardView` builds that from the
|
/// from the drop proposal's slot list by construction, since `BoardView` builds that from the
|
||||||
/// snapshot's live lanes;
|
/// snapshot's lanes;
|
||||||
/// - it is **no destination for a move** — "no move or paste ever targets the trash"
|
|
||||||
/// (04-interactions.md ▸ The trash). It is a destination for exactly one thing, below;
|
|
||||||
/// - it has **no new-card button**: nothing is created in the trash.
|
/// - it has **no new-card button**: nothing is created in the trash.
|
||||||
///
|
///
|
||||||
/// ### The one drop it takes: the delete gesture
|
/// ### The drop it takes, and the drag it starts
|
||||||
///
|
///
|
||||||
/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md ▸ The trash, settled
|
/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md ▸ The trash) — the
|
||||||
/// 2026-07-28) — "the drag becomes the pointer's delete gesture; release tombstones the dragged
|
/// drag becomes the pointer's delete gesture, and release moves the dragged card(s) into `.trash/`.
|
||||||
/// card(s), exactly the ⌫ tombstone". So the column does declare an `onDrop`
|
/// So the column declares an `onDrop` (`TrashDropDelegate`), and it is the narrowest one on the
|
||||||
/// (`TrashDropDelegate`), and it is the narrowest one on the board: a **live** card drag from **this**
|
/// board: a board card drag from **this** board, unmodified. It diverges from every other drop in one
|
||||||
/// board, unmodified. Lanes are not deliverable this way, a foreign board's card is not (that would
|
/// way, and the ranks are what make the divergence honest: **the shadow always takes the topmost
|
||||||
/// be a transfer-and-delete compound), ⌥ is not (copying into the trash is not a thing), and hidden
|
/// row**, because every arrival mints a rank above the current top.
|
||||||
/// the column is not rendered at all, so it has no region to enter. Every one of those refusals hands
|
|
||||||
/// the session back to the strip's own logic, which is exactly what happened here before this target
|
|
||||||
/// existed — so nothing about the column's behaviour changed except the gesture that is new
|
|
||||||
/// (`TrashDrop`).
|
|
||||||
///
|
///
|
||||||
/// It diverges from every other drop on the board in one way, and the sort is what makes the
|
/// The drag *out* is the restore, and it is deliberately not special: a trash card's drag is an
|
||||||
/// divergence honest: **the shadow always takes the topmost row**, because the trash orders by
|
/// ordinary `.cards` session in the `.trash` container, and `BoardDropContext.commitDrop` hands it
|
||||||
/// `deleted` newest-first and a fresh tombstone genuinely lands on top. The drop still lands exactly
|
/// to the same `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an
|
||||||
/// where the shadow shows.
|
/// ordinary move out … there is no restore-specific machinery and no Put Back" (03 § Trash).
|
||||||
///
|
///
|
||||||
/// **Finder file drops stay inert** — "attachment import on tombstoned cards is inert" (▸ The trash)
|
/// **Finder file drops stay inert** — "Finder file drops on trash cards are inert" (▸ The trash) —
|
||||||
/// — and now say so directly: the delegate clears the file highlight over the column rather than
|
/// and say so directly: the delegate clears the file highlight over the column.
|
||||||
/// relying on the strip resolving to no lane.
|
|
||||||
///
|
///
|
||||||
/// ### No editing in the trash
|
/// ### No editing in the trash
|
||||||
///
|
///
|
||||||
/// "Tombstoned cards don't open — double-click does nothing beyond selection; Put Back or drag out
|
/// "No editing in the trash: trash cards don't open — double-click stops at selection; move it out
|
||||||
/// first." There is deliberately no double-tap recogniser, no rename editor, and no Style… row: the
|
/// first." There is deliberately no double-tap recogniser, no rename editor, and no Style… row.
|
||||||
/// trash is for restoring or purging, not working.
|
|
||||||
///
|
///
|
||||||
/// ### What is still a later card's
|
/// ### What is still phase 3's
|
||||||
///
|
///
|
||||||
/// **⌘C copy-out** is still owed. The **search filter** ("shown, it participates in the filter like
|
/// The column renders the materialized trash correctly and its selection, drag, drop, filter and
|
||||||
/// any lane") arrived with m5 and is one line — see `entries`, which every other surface here reads
|
/// context menu all speak the new container vocabulary — but its *visual* treatment is still the
|
||||||
/// through. The *pointer* grammar is here: a row's click runs the same `SelectionGrammar` the
|
/// tombstone era's compact dimmed plate rather than the card face 03 now implies ("a trashed card is
|
||||||
/// board does, and the column's empty space rubber-bands on the trashed side. So is the drop, with
|
/// an ordinary card in a special place"). Reworking the plate into the ordinary face, and the
|
||||||
/// the target lane highlighted and the source row dimmed in place; the drag's replica is not. The
|
/// accessibility labelling 10-accessibility.md asks for, is the trash's own phase-3 card.
|
||||||
/// **keyboard** reaches the column entirely through the frames the rows register — arrow walks in
|
|
||||||
/// and out, ⇧-arrows inert at both the liveness and the kind boundary — so nothing in this file
|
|
||||||
/// implements it beyond keeping every row drawn and registered (see `rows`).
|
|
||||||
struct TrashLaneView: View {
|
struct TrashLaneView: View {
|
||||||
|
|
||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
@@ -71,13 +63,13 @@ struct TrashLaneView: View {
|
|||||||
/// does.
|
/// does.
|
||||||
let confirmations: TrashConfirmations
|
let confirmations: TrashConfirmations
|
||||||
|
|
||||||
/// The board window's drop machinery — a row's drag is an ordinary card session on the
|
/// The board window's drop machinery — a card's drag is an ordinary card session in the
|
||||||
/// **trashed** side (`DragSession`, 04-interactions.md ▸ The trash).
|
/// **trash** container (`DragSession`, 04-interactions.md ▸ The trash).
|
||||||
let drops: BoardDropContext
|
let drops: BoardDropContext
|
||||||
|
|
||||||
/// The strip's rubber band. The column's empty space is its third surface, on the **trashed**
|
/// The strip's rubber band. The column's empty space is its third surface, in the **trash**
|
||||||
/// side — "a rubber-band stays on the side of the boundary it started on" (04-interactions.md ▸
|
/// container — "the rubber band stays on the side it started on" (04-interactions.md ▸ The
|
||||||
/// The trash) — and every row registers its frame into the same registry.
|
/// trash) — and every row registers its frame into the same registry.
|
||||||
let marquee: MarqueeControl
|
let marquee: MarqueeControl
|
||||||
|
|
||||||
/// Reduce Motion, for the row transition below — 10-accessibility.md names the trash
|
/// Reduce Motion, for the row transition below — 10-accessibility.md names the trash
|
||||||
@@ -91,9 +83,9 @@ struct TrashLaneView: View {
|
|||||||
private let rowSpacing: CGFloat = 6
|
private let rowSpacing: CGFloat = 6
|
||||||
|
|
||||||
/// The height a shadow row holds open. A trash row's height is content-driven (one or two title
|
/// The height a shadow row holds open. A trash row's height is content-driven (one or two title
|
||||||
/// lines, plus a lane entry's count line) and the cards being proposed have no row yet to be
|
/// lines) and the cards being proposed have no row yet to be measured, so the shadow is drawn at
|
||||||
/// measured, so the shadow is drawn at the nominal single-line plate — `LaneDropRegistry`'s own
|
/// the nominal single-line plate — `LaneDropRegistry`'s own answer to the same question, in this
|
||||||
/// answer to the same question, in this column's smaller idiom.
|
/// column's smaller idiom.
|
||||||
private let nominalRowHeight: CGFloat = 32
|
private let nominalRowHeight: CGFloat = 32
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -113,16 +105,16 @@ struct TrashLaneView: View {
|
|||||||
.onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops))
|
.onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The rows the column shows.
|
/// The cards the column shows.
|
||||||
///
|
///
|
||||||
/// **The shown trash "participates in the filter like any lane"** (03-board-ui.md § Trash), so
|
/// **Shown, the trash's cards "participate in the filter exactly like any other card"**
|
||||||
/// the search predicate narrows this collection exactly as it narrows `LaneView.renderedCards`
|
/// (03-board-ui.md § Trash — "the point of the pivot"), so the search predicate narrows this
|
||||||
/// — card rows and lane rows alike, each by its own title and body (`SearchFilter`) — and the
|
/// collection exactly as it narrows `LaneView.renderedCards`, and the count badge follows for
|
||||||
/// count badge follows for free, because it reads this same value. Hidden, the column renders
|
/// free because it reads this same value. Hidden, the column renders nothing and registers
|
||||||
/// nothing and registers nothing, so "hidden trash is invisible to search" needs no code at all.
|
/// nothing, so "hidden trash is invisible to search" needs no code at all.
|
||||||
private var entries: [TrashEntry] {
|
private var cards: [Card] {
|
||||||
let filter = store.searchFilter
|
let filter = store.searchFilter
|
||||||
return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) }
|
return store.snapshot.trash.filter { filter.matches($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The delete gesture's landing
|
// MARK: - The delete gesture's landing
|
||||||
@@ -140,7 +132,7 @@ struct TrashLaneView: View {
|
|||||||
/// keeps the arrangement the release proposed on screen for that round trip, exactly as every
|
/// keeps the arrangement the release proposed on screen for that round trip, exactly as every
|
||||||
/// other container's does (`CommittedHold`).
|
/// other container's does (`CommittedHold`).
|
||||||
private var slots: [TrashSlot] {
|
private var slots: [TrashSlot] {
|
||||||
var result = entries.map(TrashSlot.entry)
|
var result = cards.map(TrashSlot.card)
|
||||||
guard let proposal else { return result }
|
guard let proposal else { return result }
|
||||||
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
|
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
|
||||||
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
|
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
|
||||||
@@ -186,11 +178,10 @@ struct TrashLaneView: View {
|
|||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The entry count — the same collection the body renders, so the badge cannot disagree with
|
/// The card count — the same collection the body renders, so the badge cannot disagree with
|
||||||
/// what is on screen (`LaneView.countBadge`'s rule, and it is why m5's filter needs no second
|
/// what is on screen (`LaneView.countBadge`'s rule).
|
||||||
/// change here).
|
|
||||||
private var countBadge: some View {
|
private var countBadge: some View {
|
||||||
Text("\(entries.count)")
|
Text("\(cards.count)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -210,7 +201,7 @@ struct TrashLaneView: View {
|
|||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
scrollableRows
|
scrollableRows
|
||||||
.onChange(of: store.transient.selectionHead) { _, head in
|
.onChange(of: store.transient.selectionHead) { _, head in
|
||||||
guard let head, entries.contains(where: { $0.id == head }) else { return }
|
guard let head, cards.contains(where: { $0.id == head }) else { return }
|
||||||
proxy.scrollTo(TrashSlot.identity(of: head))
|
proxy.scrollTo(TrashSlot.identity(of: head))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,10 +218,10 @@ struct TrashLaneView: View {
|
|||||||
ForEach(slots) { slot in
|
ForEach(slots) { slot in
|
||||||
Group {
|
Group {
|
||||||
switch slot {
|
switch slot {
|
||||||
case let .entry(entry):
|
case let .card(card):
|
||||||
TrashEntryRow(
|
TrashCardRow(
|
||||||
store: store,
|
store: store,
|
||||||
entry: entry,
|
card: card,
|
||||||
confirmations: confirmations,
|
confirmations: confirmations,
|
||||||
drops: drops,
|
drops: drops,
|
||||||
registry: marquee.registry
|
registry: marquee.registry
|
||||||
@@ -243,10 +234,10 @@ struct TrashLaneView: View {
|
|||||||
.frame(height: nominalRowHeight)
|
.frame(height: nominalRowHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A row is a tombstoned item, so it arrives and leaves in the card's dialect —
|
// A row is a card, so it arrives and leaves in the card's dialect — a delete
|
||||||
// a delete files one in, a Put Back or a purge takes one out, and both halves of
|
// files one in, a restore or a purge takes one out, and both halves of that pair
|
||||||
// that pair should read alike from either side of the strip. The transaction is
|
// should read alike from either side of the strip. The transaction is the
|
||||||
// the reload's, like the lanes' (`Motion.reloadAnimates`).
|
// reload's, like the lanes' (`Motion.reloadAnimates`).
|
||||||
.transition(Motion.cardTransition(reduced: reduceMotion))
|
.transition(Motion.cardTransition(reduced: reduceMotion))
|
||||||
// The scroll target — `LaneView`'s rule, and outermost for its reason.
|
// The scroll target — `LaneView`'s rule, and outermost for its reason.
|
||||||
.id(slot.id)
|
.id(slot.id)
|
||||||
@@ -271,7 +262,7 @@ struct TrashLaneView: View {
|
|||||||
// as the board background allows on the live side — a drag begun on a row instead is that
|
// as the board background allows on the live side — a drag begun on a row instead is that
|
||||||
// row's drag-out, and the begin guard makes that geometric rather than a matter of
|
// row's drag-out, and the begin guard makes that geometric rather than a matter of
|
||||||
// gesture priority (`MarqueeControl`).
|
// gesture priority (`MarqueeControl`).
|
||||||
.simultaneousGesture(marquee.gesture(side: .trashed))
|
.simultaneousGesture(marquee.gesture(in: .trash))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,13 +280,10 @@ private struct TrashRowPlate: View {
|
|||||||
|
|
||||||
let symbol: String
|
let symbol: String
|
||||||
|
|
||||||
/// The title as written, or `nil` for an untitled item — "Untitled" is a rendering, never a value
|
/// The title as written, or `nil` for an untitled card — "Untitled" is a rendering, never a value
|
||||||
/// (03-board-ui.md § Card face).
|
/// (03-board-ui.md § Card face).
|
||||||
let title: String?
|
let title: String?
|
||||||
|
|
||||||
/// A lane entry's returning-card count, and nothing else takes a second line.
|
|
||||||
let subtitle: String?
|
|
||||||
|
|
||||||
var isSelected: Bool = false
|
var isSelected: Bool = false
|
||||||
|
|
||||||
private let cornerRadius: CGFloat = 6
|
private let cornerRadius: CGFloat = 6
|
||||||
@@ -305,17 +293,10 @@ private struct TrashRowPlate: View {
|
|||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.imageScale(.small)
|
.imageScale(.small)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(title ?? "Untitled")
|
Text(title ?? "Untitled")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
if let subtitle {
|
|
||||||
Text(subtitle)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
@@ -336,22 +317,22 @@ private struct TrashRowPlate: View {
|
|||||||
/// echo reload reads as a *swap* or as a removal and an insertion.
|
/// echo reload reads as a *swap* or as a removal and an insertion.
|
||||||
private enum TrashSlot: Identifiable {
|
private enum TrashSlot: Identifiable {
|
||||||
|
|
||||||
/// A tombstone the snapshot already holds.
|
/// A card the snapshot's trash already holds.
|
||||||
case entry(TrashEntry)
|
case card(Card)
|
||||||
|
|
||||||
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash).
|
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash).
|
||||||
case shadow(index: Int)
|
case shadow(index: Int)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case let .entry(entry): Self.identity(of: entry.id)
|
case let .card(card): Self.identity(of: card.id)
|
||||||
// Constant per position in the run, so a run that grows or shrinks animates as slots rather
|
// Constant per position in the run, so a run that grows or shrinks animates as slots rather
|
||||||
// than blinking (`LaneSlot`'s rule).
|
// than blinking (`LaneSlot`'s rule).
|
||||||
case let .shadow(index): "shadow:\(index)"
|
case let .shadow(index): "shadow:\(index)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An entry slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
|
/// A card slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
|
||||||
/// scroll reader is looking for (`LaneSlot.identity(of:)`).
|
/// scroll reader is looking for (`LaneSlot.identity(of:)`).
|
||||||
static func identity(of item: ItemID) -> String { "entry:\(item.rawValue)" }
|
static func identity(of item: ItemID) -> String { "entry:\(item.rawValue)" }
|
||||||
}
|
}
|
||||||
@@ -381,145 +362,113 @@ private struct DiagonalHatch: Shape {
|
|||||||
|
|
||||||
// MARK: - Rows
|
// MARK: - Rows
|
||||||
|
|
||||||
/// One trash row: a compact, dimmed plate carrying the item's symbol and title — and, for a lane
|
/// One trash row: a compact, dimmed plate carrying the card's symbol and title.
|
||||||
/// entry, the count of cards Put Back would return with it.
|
|
||||||
///
|
///
|
||||||
/// **Face-like but not a card face.** It shares the plate, the symbol and the title, and it
|
/// **Face-like but not a card face.** It shares the plate, the symbol and the title, and it
|
||||||
/// deliberately shares none of the face's *editing* affordances: no rename editor, no Open, no
|
/// deliberately shares none of the face's *editing* affordances: no rename editor, no Open, no
|
||||||
/// Style…. That is 03-board-ui.md's no-editing-in-the-trash rule expressed as an absence rather
|
/// Style…. That is 03-board-ui.md's no-editing-in-the-trash rule expressed as an absence rather
|
||||||
/// than as a pile of `disabled` modifiers.
|
/// than as a pile of `disabled` modifiers. (Making it the ordinary card face is phase 3's — see
|
||||||
private struct TrashEntryRow: View {
|
/// `TrashLaneView`.)
|
||||||
|
private struct TrashCardRow: View {
|
||||||
|
|
||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
let entry: TrashEntry
|
let card: Card
|
||||||
let confirmations: TrashConfirmations
|
let confirmations: TrashConfirmations
|
||||||
let drops: BoardDropContext
|
let drops: BoardDropContext
|
||||||
|
|
||||||
/// Where the rubber band looks up what it is sweeping — the card face's rule, on the trashed
|
/// Where the rubber band looks up what it is sweeping — the card face's rule, in the trash
|
||||||
/// side (`View.marqueeTarget`).
|
/// container (`View.marqueeTarget`).
|
||||||
let registry: MarqueeTargetRegistry
|
let registry: MarqueeTargetRegistry
|
||||||
|
|
||||||
/// **Lane entries are not draggable** (03-board-ui.md § Trash: "a lane entry is not draggable —
|
|
||||||
/// its entry is a compact row, not the lane; its move-out is Put Back"), so the drag half is
|
|
||||||
/// simply *absent* for them rather than refused — no session, no image, no snap-back. A click
|
|
||||||
/// still selects either way.
|
|
||||||
@ViewBuilder
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if entry.isLaneEntry {
|
|
||||||
plate
|
|
||||||
} else {
|
|
||||||
plate.onDrag(startRowDrag, preview: { dragReplica })
|
plate.onDrag(startRowDrag, preview: { dragReplica })
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private var plate: some View {
|
private var plate: some View {
|
||||||
rowFace
|
rowFace
|
||||||
// The row being dragged out dims in place — the source stays visible in the trash,
|
// The row being dragged out dims in place — the source stays visible in the trash,
|
||||||
// because a restore is not a removal until the write lands.
|
// because a restore is not a removal until the write lands.
|
||||||
.opacity(drops.session.isDragging(entry.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
||||||
// The deferred cut wears the same dim wherever it lands, so the treatment is stated for
|
// The deferred cut wears the same dim wherever it lands. It genuinely fires here now:
|
||||||
// every surface a `pendingCut` could name rather than for two of the three. In practice
|
// "⌘X works — cut in the trash, paste into a lane is the keyboard-native restore"
|
||||||
// it never fires here: ⌘X is disabled on tombstoned selections (04-interactions.md ▸ The
|
// (04-interactions.md ▸ The trash, resettled 2026-07-28).
|
||||||
// trash), and a pending cut is homogeneous by liveness — a reload that tombstones a cut
|
.cutTreatment(of: card.id, in: store)
|
||||||
// card *ejects* it from the set rather than moving it to the other side.
|
|
||||||
.cutTreatment(of: entry.id, in: store)
|
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { select() }
|
.onTapGesture { select() }
|
||||||
.marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)
|
.marqueeTarget(card.id, kind: .card, container: .trash, in: registry)
|
||||||
.contextMenu { menu }
|
.contextMenu { menu }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This entry as the shared plate draws it — appearance only, no gesture, no context menu and
|
/// This card as the shared plate draws it — appearance only, no gesture, no context menu and
|
||||||
/// crucially no marquee registration, which is what makes it safe for the drag replica to render
|
/// crucially no marquee registration, which is what makes it safe for the drag replica to render
|
||||||
/// (see `TrashRowPlate`).
|
/// (see `TrashRowPlate`).
|
||||||
private var rowFace: some View {
|
private var rowFace: some View {
|
||||||
TrashRowPlate(
|
TrashRowPlate(
|
||||||
symbol: ItemSymbol.name(entry.icon, fallback: symbolFallback),
|
symbol: ItemSymbol.name(card.icon, fallback: ItemSymbol.card),
|
||||||
title: entry.title,
|
title: card.title.value,
|
||||||
subtitle: laneSubtitle,
|
|
||||||
isSelected: isSelected
|
isSelected: isSelected
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var symbolFallback: String {
|
|
||||||
entry.isLaneEntry ? ItemSymbol.lane : ItemSymbol.card
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "N cards" — what Put Back brings back with a tombstoned lane, not how many folders sit inside
|
|
||||||
/// it (`TrashModel.entries`' returning-count rule). Card entries have no second line.
|
|
||||||
private var laneSubtitle: String? {
|
|
||||||
guard case let .lane(_, returning) = entry else { return nil }
|
|
||||||
return "\(returning) card\(returning == 1 ? "" : "s")"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Selection
|
// MARK: - Selection
|
||||||
|
|
||||||
private var isSelected: Bool {
|
private var isSelected: Bool {
|
||||||
store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
|
store.selection.container == .trash && store.selection.ids.contains(card.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A click selects this row on the **trashed** side, through the same grammar the board's
|
/// A click selects this card in the **trash** container, through the same grammar the board's
|
||||||
/// surfaces use — plain replaces, ⌘ toggles, ⇧ ranges (`SelectionGrammar`).
|
/// surfaces use — plain replaces, ⌘ toggles, ⇧ ranges (`SelectionGrammar`).
|
||||||
///
|
///
|
||||||
/// The row's kind travels with the click, and that is what keeps the trash's second homogeneity
|
/// The container travels with the click, and that is what keeps the one remaining homogeneity
|
||||||
/// axis true: a ⌘-click across the card/lane-entry boundary replaces rather than mixing, and a
|
/// boundary true: a ⌘-click across it replaces rather than mixing (04-interactions.md ▸ The
|
||||||
/// ⇧-range walks only its own kind's rows (04-interactions.md ▸ The trash). No `togglesOnRepeat`
|
/// trash). There is no kind axis inside the trash any more — lanes are never trashed. No
|
||||||
/// — click-again-to-unselect is the lane's behaviour, not a row's.
|
/// `togglesOnRepeat` — click-again-to-unselect is the lane's behaviour, not a card's.
|
||||||
///
|
///
|
||||||
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
|
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
|
||||||
private func select() {
|
private func select() {
|
||||||
store.click(
|
store.click(
|
||||||
SelectionTarget(id: entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed),
|
SelectionTarget(id: card.id, kind: .card, container: .trash),
|
||||||
modifier: .current
|
modifier: .current
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Drag out
|
// MARK: - Drag out
|
||||||
|
|
||||||
/// Begins the row's drag out of the trash — an ordinary **card session on the trashed side**,
|
/// Begins the card's drag out of the trash — an ordinary **card session in the trash container**,
|
||||||
/// which is the whole of what makes it a restore rather than a move (04-interactions.md ▸ The
|
/// which is the whole of what makes it a restore (04-interactions.md ▸ The trash;
|
||||||
/// trash; `DragLocality.operation`).
|
/// `DragLocality.operation`).
|
||||||
///
|
///
|
||||||
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
|
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
|
||||||
/// destination lane's masonry, so "restores it at the drop position" is the same arithmetic every
|
/// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic
|
||||||
/// other card drop uses. What a release *means* differs by locality and modifier, and that lives
|
/// every other card drop uses, committed by the same `moveCards`.
|
||||||
/// in one place (`BoardDropContext.commitDrop`): within the board a restore, ⌥ a live copy-out,
|
|
||||||
/// across boards a live copy with ⌘ forcing the true restore-move.
|
|
||||||
///
|
///
|
||||||
/// **Multi-drag carries the whole trashed selection**, in the trash's own sorted order — the
|
/// **Multi-drag carries the whole trash selection**, in the column's own order — the order the
|
||||||
/// order the rows are drawn in, which is the only relative order a set of tombstones has.
|
/// rows are drawn in, which is `order` ascending like any lane's.
|
||||||
///
|
///
|
||||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||||
/// mutating gesture.
|
/// mutating gesture.
|
||||||
private func startRowDrag() -> NSItemProvider {
|
private func startRowDrag() -> NSItemProvider {
|
||||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
let ids: Set<ItemID> = selection.liveness == .trashed
|
let ids: Set<ItemID> = selection.container == .trash
|
||||||
&& selection.ids.contains(entry.id)
|
&& selection.ids.contains(card.id)
|
||||||
&& selection.ids.count > 1
|
&& selection.ids.count > 1
|
||||||
? selection.ids
|
? selection.ids
|
||||||
: [entry.id]
|
: [card.id]
|
||||||
|
|
||||||
// Card entries only: a lane entry cannot be dragged at all, so one caught up in a mixed
|
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
|
||||||
// selection is simply not carried. (The selection is homogeneous by kind anyway — this is
|
|
||||||
// belt over braces.)
|
|
||||||
let rows: [(id: ItemID, laneID: ItemID, title: String?)] = TrashModel.entries(of: store.snapshot)
|
|
||||||
.compactMap { candidate in
|
|
||||||
guard ids.contains(candidate.id), case let .card(card, laneID) = candidate else { return nil }
|
|
||||||
return (card.id, laneID, card.title.value)
|
|
||||||
}
|
|
||||||
guard !rows.isEmpty else { return NSItemProvider() }
|
guard !rows.isEmpty else { return NSItemProvider() }
|
||||||
|
|
||||||
let root = store.rootURL
|
let root = store.rootURL
|
||||||
let payload = DragPayload(
|
let payload = DragPayload(
|
||||||
boardRoot: root,
|
boardRoot: root,
|
||||||
kind: .cards,
|
kind: .cards,
|
||||||
side: .trashed,
|
container: .trash,
|
||||||
items: rows.map {
|
items: rows.map {
|
||||||
DragPayload.Item(
|
DragPayload.Item(
|
||||||
id: $0.id.rawValue,
|
id: $0.id.rawValue,
|
||||||
folder: TrashModel.ItemPath(laneID: $0.laneID, cardID: $0.id).folder(under: root).path,
|
folder: ItemPath.trashCard($0.id).folder(under: root).path,
|
||||||
title: $0.title
|
title: $0.title.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -527,7 +476,7 @@ private struct TrashEntryRow: View {
|
|||||||
rows.map(\.id),
|
rows.map(\.id),
|
||||||
folders: payload.folders,
|
folders: payload.folders,
|
||||||
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
|
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
|
||||||
side: .trashed,
|
container: .trash,
|
||||||
source: store
|
source: store
|
||||||
)
|
)
|
||||||
return payload.itemProvider()
|
return payload.itemProvider()
|
||||||
@@ -536,7 +485,7 @@ private struct TrashEntryRow: View {
|
|||||||
/// The image under the cursor: the row as it is drawn, fanned with a count badge for a
|
/// The image under the cursor: the row as it is drawn, fanned with a count badge for a
|
||||||
/// multi-drag — the card replica's treatment, at a trash row's size.
|
/// multi-drag — the card replica's treatment, at a trash row's size.
|
||||||
private var dragReplica: some View {
|
private var dragReplica: some View {
|
||||||
let count = store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
|
let count = store.selection.container == .trash && store.selection.ids.contains(card.id)
|
||||||
? max(1, store.selection.ids.count)
|
? max(1, store.selection.ids.count)
|
||||||
: 1
|
: 1
|
||||||
return ZStack {
|
return ZStack {
|
||||||
@@ -549,22 +498,18 @@ private struct TrashEntryRow: View {
|
|||||||
.padding(12)
|
.padding(12)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The trash entry's context menu
|
// MARK: - The trash card's context menu
|
||||||
|
|
||||||
/// Put Back, Delete Immediately, Reveal in Finder — the three rows 11-command-nexus.md gives a
|
/// Delete and Reveal in Finder — the two rows 11-command-nexus.md gives a trash card, and no
|
||||||
/// trash entry, and no others.
|
/// others ("Trash cards | Delete (permanent — 03's recoverability confirm), Reveal in Finder").
|
||||||
///
|
///
|
||||||
|
/// **Put Back is gone** with the tombstone model: restoring is drag-out or ⌘X/⌘V (03 § Trash).
|
||||||
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
|
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
|
||||||
/// enabled on tombstoned selections", read-only lock included — inspecting a folder before a
|
/// enabled on trash selections", read-only lock included — inspecting a folder before a purge is
|
||||||
/// purge is exactly the errand it exists for.
|
/// exactly the errand it exists for.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var menu: some View {
|
private var menu: some View {
|
||||||
Button("Put Back") {
|
Button("Delete") {
|
||||||
store.putBack(targetIDs)
|
|
||||||
}
|
|
||||||
.disabled(!store.acceptsBoardMutations)
|
|
||||||
|
|
||||||
Button("Delete Immediately") {
|
|
||||||
confirmations.requestPurge(of: targetIDs, in: store)
|
confirmations.requestPurge(of: targetIDs, in: store)
|
||||||
}
|
}
|
||||||
.disabled(!store.acceptsBoardMutations)
|
.disabled(!store.acceptsBoardMutations)
|
||||||
@@ -578,17 +523,16 @@ private struct TrashEntryRow: View {
|
|||||||
|
|
||||||
/// What this row's menu acts on: the whole selection when this row is part of it, else this row
|
/// What this row's menu acts on: the whole selection when this row is part of it, else this row
|
||||||
/// alone — standard macOS context-menu targeting, and the same rule the card face and the lane
|
/// alone — standard macOS context-menu targeting, and the same rule the card face and the lane
|
||||||
/// header apply to Style…. Right-clicking something outside the selection acts on what was
|
/// header apply to Style….
|
||||||
/// clicked, which is also what keeps a cross-kind menu from ever acting on a mixed set.
|
|
||||||
private var targetIDs: Set<ItemID> {
|
private var targetIDs: Set<ItemID> {
|
||||||
guard store.selection.liveness == .trashed, store.selection.ids.contains(entry.id) else {
|
guard store.selection.container == .trash, store.selection.ids.contains(card.id) else {
|
||||||
return [entry.id]
|
return [card.id]
|
||||||
}
|
}
|
||||||
return store.selection.ids
|
return store.selection.ids
|
||||||
}
|
}
|
||||||
|
|
||||||
private var targetFolders: [URL] {
|
private var targetFolders: [URL] {
|
||||||
TrashModel.paths(of: targetIDs, on: .trashed, in: store.snapshot)
|
ItemPath.resolve(targetIDs, in: .trash, snapshot: store.snapshot)
|
||||||
.map { $0.folder(under: store.rootURL) }
|
.map { $0.folder(under: store.rootURL) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,22 +24,12 @@ private func makeMixedBoard() throws -> WriterFixture {
|
|||||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||||
try fixture.item(
|
|
||||||
"\(Ident.lane1)/\(Ident.card3)",
|
|
||||||
"---\nschema: 1\norder: 3072\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
try fixture.item(
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Archive"))
|
||||||
Ident.lane2,
|
|
||||||
"---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
|
||||||
)
|
|
||||||
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Buried"))
|
|
||||||
try fixture.item(
|
|
||||||
"\(Ident.lane2)/\(Ident.indexless)",
|
|
||||||
"---\nschema: 1\norder: 2048\ntitle: Also buried\n---\nbody\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Doing"))
|
// The trash: cards in a sibling container, never lanes (03-board-ui.md § Trash).
|
||||||
|
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Gone"))
|
||||||
|
try fixture.item(".trash/\(Ident.card4)", Item.rich(order: "2048", title: "Also gone"))
|
||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,54 +66,35 @@ struct AppModelTests {
|
|||||||
|
|
||||||
// MARK: Live-only counts
|
// MARK: Live-only counts
|
||||||
|
|
||||||
@Test("The recents counts are live items only, at both levels")
|
@Test("The recents counts are working items only — the trash is an errand, not inventory")
|
||||||
func liveCountsIgnoreTombstonesAndWhatHidesBeneathThem() throws {
|
func liveCountsExcludeTheTrash() throws {
|
||||||
let fixture = try makeMixedBoard()
|
let fixture = try makeMixedBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
|
||||||
// The snapshot itself keeps everything — tombstones are what the trash renders — so this is a
|
// The snapshot itself keeps everything — the trash is a sibling container — so this is a
|
||||||
// genuine filter, not a property of the load.
|
// genuine exclusion, not a property of the load.
|
||||||
#expect(snapshot.lanes.count == 3)
|
#expect(snapshot.lanes.count == 2)
|
||||||
#expect(snapshot.lanes.flatMap(\.cards).count == 5)
|
#expect(snapshot.trash.count == 2)
|
||||||
|
|
||||||
let counts = AppModel.liveCounts(of: snapshot)
|
let counts = AppModel.liveCounts(of: snapshot)
|
||||||
#expect(counts.lanes == 2, "the tombstoned lane is not part of the board's working size")
|
#expect(counts.lanes == 2)
|
||||||
#expect(counts.cards == 2, "one tombstoned card, and two more hidden beneath a tombstoned lane")
|
// 02 § Per-board app state, re-grounded 2026-07-28: "cards in `.trash/` don't count; the row
|
||||||
|
// advertises the board's working size". The walk reads `snapshot.lanes` and the trash is
|
||||||
|
// `snapshot.trash`, so the exclusion is by construction and none could be forgotten.
|
||||||
|
#expect(counts.cards == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A board with nothing live counts zero rather than declining to answer")
|
@Test("A board with nothing on it counts zero rather than declining to answer")
|
||||||
func liveCountsOfAnEmptyBoard() throws {
|
func liveCountsOfAnEmptyBoard() throws {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
try fixture.item("", Item.board)
|
try fixture.item("", Item.board)
|
||||||
try fixture.item(
|
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "Gone"))
|
||||||
Ident.lane1,
|
|
||||||
"---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
|
||||||
)
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Buried"))
|
|
||||||
|
|
||||||
let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model)
|
let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model)
|
||||||
#expect(counts.lanes == 0)
|
#expect(counts.lanes == 0)
|
||||||
#expect(counts.cards == 0)
|
#expect(counts.cards == 0, "a board whose only content is trash advertises no working size")
|
||||||
}
|
|
||||||
|
|
||||||
@Test("A malformed deleted: still counts as deleted")
|
|
||||||
func liveCountsFollowPresenceNotValidity() throws {
|
|
||||||
let fixture = try WriterFixture()
|
|
||||||
defer { fixture.tearDown() }
|
|
||||||
try fixture.item("", Item.board)
|
|
||||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
||||||
try fixture.item(
|
|
||||||
"\(Ident.lane1)/\(Ident.card1)",
|
|
||||||
"---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: yesterday\n---\nbody\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The presence of the key is what encodes deletion intent (`Card.isDeleted`), so an
|
|
||||||
// unparseable timestamp hides the card here exactly as it hides it on the board.
|
|
||||||
let counts = AppModel.liveCounts(of: try BoardLoader.load(boardRoot: fixture.root).model)
|
|
||||||
#expect(counts.lanes == 1)
|
|
||||||
#expect(counts.cards == 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Display name
|
// MARK: Display name
|
||||||
@@ -241,7 +212,7 @@ struct AppModelTests {
|
|||||||
#expect(model.storeRegistry.liveStore(for: fixture.root) == nil, "the last reference went with the session")
|
#expect(model.storeRegistry.liveStore(for: fixture.root) == nil, "the last reference went with the session")
|
||||||
|
|
||||||
let record = try #require(model.boardRegistry.record(id: recordID))
|
let record = try #require(model.boardRegistry.record(id: recordID))
|
||||||
#expect(record.laneCount == 2, "the counts the welcome row will show are the live ones")
|
#expect(record.laneCount == 2, "the counts the welcome row will show are the working ones")
|
||||||
#expect(record.cardCount == 2)
|
#expect(record.cardCount == 2)
|
||||||
#expect(record.isOpenNow == false)
|
#expect(record.isOpenNow == false)
|
||||||
#expect(model.boardRegistry.restorables().isEmpty)
|
#expect(model.boardRegistry.restorables().isEmpty)
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ struct BoardStoreTests {
|
|||||||
#expect(!ran)
|
#expect(!ran)
|
||||||
|
|
||||||
// Reading stays live throughout — keeping the last-good snapshot is the point of the lock.
|
// Reading stays live throughout — keeping the last-good snapshot is the point of the lock.
|
||||||
store.select([ItemID(rawValue: Ident.card2)], liveness: .live)
|
store.select([ItemID(rawValue: Ident.card2)], in: .board)
|
||||||
#expect(store.selection.ids.count == 1)
|
#expect(store.selection.ids.count == 1)
|
||||||
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Repaired"))
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Repaired"))
|
||||||
@@ -421,50 +421,48 @@ struct BoardStoreTests {
|
|||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
|
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)
|
||||||
|
|
||||||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||||
await store.awaitQuiescence()
|
await store.awaitQuiescence()
|
||||||
|
|
||||||
#expect(store.selection.ids == [ItemID(rawValue: Ident.card2)])
|
#expect(store.selection.ids == [ItemID(rawValue: Ident.card2)])
|
||||||
#expect(store.selection.liveness == .live)
|
#expect(store.selection.container == .board)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A liveness flip is a vanish: a selected card tombstoned externally leaves the selection")
|
@Test("A container crossing is a vanish: a selected card trashed externally leaves the selection")
|
||||||
func selectionEjectsALivenessFlip() async throws {
|
func selectionEjectsAContainerCrossing() async throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
|
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)
|
||||||
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
|
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||||
await store.awaitQuiescence()
|
await store.awaitQuiescence()
|
||||||
|
|
||||||
// Still in the snapshot — the trash renders it — but no longer on the selection's side of
|
// Still in the snapshot — the trash renders it — but no longer in the selection's container,
|
||||||
// the boundary, so the homogeneous-by-liveness invariant survives a foreign edit.
|
// so 04's one-container invariant survives a foreign edit (02-architecture.md's reload rule,
|
||||||
let flipped = lane(Ident.lane1, in: store.snapshot)?.cards.first { $0.id.rawValue == Ident.card1 }
|
// resettled 2026-07-28: "re-resolution matches UUID *and* container side").
|
||||||
#expect(flipped?.isDeleted == true)
|
#expect(store.snapshot.trash.map(\.id.rawValue) == [Ident.card1])
|
||||||
#expect(store.selection.ids == [ItemID(rawValue: Ident.card2)])
|
#expect(store.selection.ids == [ItemID(rawValue: Ident.card2)])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Tombstoning a lane ejects its cards from a live selection — liveness is effective")
|
@Test("Deleting a lane externally takes its cards out of the selection with it")
|
||||||
func selectionEjectsCardsUnderATombstonedLane() async throws {
|
func selectionEjectsCardsUnderARemovedLane() async throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
|
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)
|
||||||
|
|
||||||
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Lane one"))
|
// A lane delete is physical now (03-board-ui.md § Trash), so the cards genuinely go with it
|
||||||
|
// — no ancestor walk needed, and none left to do: presence is the whole test.
|
||||||
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
|
||||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||||
await store.awaitQuiescence()
|
await store.awaitQuiescence()
|
||||||
|
|
||||||
// The cards' own flags never changed, but their lane's did — and liveness is
|
#expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 } == nil)
|
||||||
// ancestor-walked (02, settled): the cards render nowhere once 03 collapses the lane to
|
|
||||||
// a single trash entry, and nothing invisible may stay selected.
|
|
||||||
let survivor = lane(Ident.lane1, in: store.snapshot)?.cards.first { $0.id.rawValue == Ident.card1 }
|
|
||||||
#expect(survivor?.isDeleted == false, "the card's own flag is untouched")
|
|
||||||
#expect(store.selection.ids.isEmpty)
|
#expect(store.selection.ids.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,7 +471,7 @@ struct BoardStoreTests {
|
|||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
|
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], in: .board)
|
||||||
|
|
||||||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
|
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
|
||||||
@@ -481,9 +479,9 @@ struct BoardStoreTests {
|
|||||||
await store.awaitQuiescence()
|
await store.awaitQuiescence()
|
||||||
|
|
||||||
#expect(store.selection.ids.isEmpty)
|
#expect(store.selection.ids.isEmpty)
|
||||||
#expect(store.selection.liveness == .live, "the side survives even when the membership does not")
|
#expect(store.selection.container == .board, "the container survives even when the membership does not")
|
||||||
|
|
||||||
store.select([ItemID(rawValue: Ident.lane1)], liveness: .live)
|
store.select([ItemID(rawValue: Ident.lane1)], in: .board)
|
||||||
store.clearSelection()
|
store.clearSelection()
|
||||||
#expect(store.selection == .empty)
|
#expect(store.selection == .empty)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,13 +191,12 @@ struct RemoveAttachmentStoreTests {
|
|||||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x02]))
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x02]))
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.removeAttachment(named: "shot.png", fromCard: card2) // tombstoned
|
store.removeAttachment(named: "shot.png", fromCard: ItemID(rawValue: Ident.lane2)) // a lane
|
||||||
store.removeAttachment(named: "shot.png", fromCard: ItemID(rawValue: Ident.indexless)) // no such card
|
store.removeAttachment(named: "shot.png", fromCard: ItemID(rawValue: Ident.indexless)) // no such card
|
||||||
store.removeAttachment(named: "shot.png", fromCard: lane1) // a lane
|
store.removeAttachment(named: "shot.png", fromCard: lane1) // a lane
|
||||||
store.removeAttachment(named: "", fromCard: card1) // no name
|
store.removeAttachment(named: "", fromCard: card1) // no name
|
||||||
|
|
||||||
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
|
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
|
||||||
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card2)/attachments") == ["shot.png"])
|
|
||||||
#expect(store.banners.oneShots.isEmpty, "a vanished target is a silent no-op, not a failure")
|
#expect(store.banners.oneShots.isEmpty, "a vanished target is a silent no-op, not a failure")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ import Testing
|
|||||||
@testable import Kanban
|
@testable import Kanban
|
||||||
|
|
||||||
/// A card window's whole lifecycle is one decision re-taken on every snapshot: does this key still
|
/// A card window's whole lifecycle is one decision re-taken on every snapshot: does this key still
|
||||||
/// name a card? Four answers, three of which are "no" for different reasons, and the one that is
|
/// name a card **on the board**? The decision is a pure function and this is its suite; nothing here
|
||||||
/// easiest to get wrong — a live card under a tombstoned lane — is invisible in the card's own data.
|
/// needs a window.
|
||||||
/// So the decision is a pure function and this is its suite; nothing here needs a window.
|
///
|
||||||
|
/// **The walk got simpler with the materialized trash** (05-card-window.md ▸ Deletion & lifecycle,
|
||||||
|
/// resettled 2026-07-28): "entering the trash counts as deleted", and a trashed card's folder has
|
||||||
|
/// physically left its lane — so "is it under one of this board's lanes" is the whole question, and
|
||||||
|
/// the tombstone era's ancestor walk is gone.
|
||||||
|
|
||||||
// MARK: - Fixtures
|
// MARK: - Fixtures
|
||||||
|
|
||||||
/// - lane 1 (live): one live card, one tombstoned card
|
/// - lane 1: one card
|
||||||
/// - lane 2 (**tombstoned**): one live card, whose own flag is clear
|
/// - the trash: one card, moved there by a delete
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeBoard() throws -> WriterFixture {
|
private func makeBoard() throws -> WriterFixture {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
@@ -18,16 +22,7 @@ private func makeBoard() throws -> WriterFixture {
|
|||||||
|
|
||||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
|
||||||
try fixture.item(
|
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Gone"))
|
||||||
"\(Ident.lane1)/\(Ident.card2)",
|
|
||||||
"---\nschema: 1\norder: 2048\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
try fixture.item(
|
|
||||||
Ident.lane2,
|
|
||||||
"---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
|
||||||
)
|
|
||||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Buried alive"))
|
|
||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,30 +57,29 @@ struct CardWindowFateTests {
|
|||||||
#expect(laneTitle(fate) == "Todo")
|
#expect(laneTitle(fate) == "Todo")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A tombstoned card dismisses its window")
|
@Test("A card in the trash dismisses its window")
|
||||||
func aTombstonedCardDismisses() throws {
|
func aTrashedCardDismisses() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
|
||||||
// ⌫ on the board closes the card's open window — "a tombstone counts as deleted"
|
// ⌫ on the board closes the card's open window — "entering the trash counts as deleted"
|
||||||
// (05-card-window.md). The card is still in the snapshot; the trash renders it.
|
// (05-card-window.md). The card is still in the snapshot; the trash column renders it.
|
||||||
#expect(snapshot.lanes[0].cards.contains { $0.id.rawValue == Ident.card2 })
|
#expect(snapshot.trash.contains { $0.id.rawValue == Ident.card2 })
|
||||||
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card2, in: snapshot) == .dismisses)
|
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card2, in: snapshot) == .dismisses)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A live card under a tombstoned lane dismisses too — liveness is ancestor-walked")
|
@Test("Deleting a card's lane dismisses its window, because the card is gone with it")
|
||||||
func aLaneTombstoneDismissesItsCards() throws {
|
func aLaneDeleteDismissesItsCards() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
|
||||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
|
||||||
// The card's own flag says nothing is wrong. Its lane's does, and 03-board-ui.md collapses a
|
// "Deleting the card's *lane* deletes the card with it — the window dismisses because the
|
||||||
// tombstoned lane to one restorable trash entry — so the card renders nowhere, and a window
|
// card is gone" (05). A lane delete is physical, so this needs no ancestor walk: the card
|
||||||
// onto something that renders nowhere is the case this walk exists for.
|
// is simply not in the snapshot.
|
||||||
let buried = try #require(snapshot.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.first)
|
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card1, in: snapshot) == .dismisses)
|
||||||
#expect(!buried.isDeleted)
|
|
||||||
#expect(CardWindowHost.cardWindowFate(cardID: Ident.card3, in: snapshot) == .dismisses)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A card that is not in this board's snapshot dismisses — the cross-board move")
|
@Test("A card that is not in this board's snapshot dismisses — the cross-board move")
|
||||||
|
|||||||
@@ -52,21 +52,21 @@ let clipboardCard2 = ItemID(rawValue: Ident.card2)
|
|||||||
let clipboardCard3 = ItemID(rawValue: Ident.card3)
|
let clipboardCard3 = ItemID(rawValue: Ident.card3)
|
||||||
let clipboardCard4 = ItemID(rawValue: Ident.card4)
|
let clipboardCard4 = ItemID(rawValue: Ident.card4)
|
||||||
|
|
||||||
func tombstonedItem(order: String, title: String) -> String {
|
/// An ordinary card body, for the trash's resident.
|
||||||
|
func trashResidentItem(order: String, title: String) -> String {
|
||||||
"""
|
"""
|
||||||
---
|
---
|
||||||
schema: 1
|
schema: 1
|
||||||
title: \(title)
|
title: \(title)
|
||||||
order: \(order)
|
order: \(order)
|
||||||
created: 2026-01-01T09:00:00Z
|
created: 2026-01-01T09:00:00Z
|
||||||
deleted: 2026-03-03T09:00:00Z
|
|
||||||
---
|
---
|
||||||
\(title) body.
|
\(title) body.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Two lanes: `lane1` holds three live cards and one tombstoned one, `lane2` holds a single card.
|
/// Two lanes: `lane1` holds two cards, `lane2` holds a single card, and one card sits in the trash.
|
||||||
/// `card1` carries two attachments, which is what makes "the snapshot travels whole" and "the
|
/// `card1` carries two attachments, which is what makes "the snapshot travels whole" and "the
|
||||||
/// fallback lost exactly two files" both assertable.
|
/// fallback lost exactly two files" both assertable.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -78,9 +78,9 @@ func makeClipboardBoard() throws -> WriterFixture {
|
|||||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("png bytes".utf8))
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("png bytes".utf8))
|
||||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data("notes".utf8))
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data("notes".utf8))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstonedItem(order: "3072", title: "Trashed"))
|
|
||||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||||
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
|
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
|
||||||
|
try fixture.item(".trash/\(Ident.card3)", trashResidentItem(order: "1024", title: "Trashed"))
|
||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ struct ClipboardManifestTests {
|
|||||||
copyID: "abc",
|
copyID: "abc",
|
||||||
boardRoot: URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true),
|
boardRoot: URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true),
|
||||||
kind: .card,
|
kind: .card,
|
||||||
side: .live,
|
container: .board,
|
||||||
entries: [entry(Ident.card1)]
|
entries: [entry(Ident.card1)]
|
||||||
)
|
)
|
||||||
let data = try #require(manifest.encoded())
|
let data = try #require(manifest.encoded())
|
||||||
@@ -153,7 +153,7 @@ struct ClipboardManifestTests {
|
|||||||
copyID: "abc",
|
copyID: "abc",
|
||||||
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
|
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
|
||||||
kind: .card,
|
kind: .card,
|
||||||
side: .live,
|
container: .board,
|
||||||
entries: [entry(Ident.card1)]
|
entries: [entry(Ident.card1)]
|
||||||
)
|
)
|
||||||
manifest.version = ClipboardManifest.currentVersion + 1
|
manifest.version = ClipboardManifest.currentVersion + 1
|
||||||
@@ -167,7 +167,7 @@ struct ClipboardManifestTests {
|
|||||||
copyID: "abc",
|
copyID: "abc",
|
||||||
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
|
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
|
||||||
kind: .card,
|
kind: .card,
|
||||||
side: .live,
|
container: .board,
|
||||||
entries: []
|
entries: []
|
||||||
)
|
)
|
||||||
let data = try #require(manifest.encoded())
|
let data = try #require(manifest.encoded())
|
||||||
@@ -200,7 +200,7 @@ struct ClipboardManifestTests {
|
|||||||
copyID: "abc",
|
copyID: "abc",
|
||||||
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
|
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
|
||||||
kind: .card,
|
kind: .card,
|
||||||
side: .live,
|
container: .board,
|
||||||
entries: [titled, untitled]
|
entries: [titled, untitled]
|
||||||
)
|
)
|
||||||
#expect(manifest.plainText == "First\nUntitled")
|
#expect(manifest.plainText == "First\nUntitled")
|
||||||
@@ -218,7 +218,7 @@ struct ClipboardCopyTests {
|
|||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.stagingSettled()
|
await harness.clipboard.stagingSettled()
|
||||||
|
|
||||||
@@ -238,12 +238,12 @@ struct ClipboardCopyTests {
|
|||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
|
harness.store.select([clipboardCard1, clipboardCard2], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
|
|
||||||
let manifest = try #require(harness.clipboard.payload)
|
let manifest = try #require(harness.clipboard.payload)
|
||||||
#expect(manifest.kind == .card)
|
#expect(manifest.kind == .card)
|
||||||
#expect(manifest.side == .live)
|
#expect(manifest.container == .board)
|
||||||
#expect(manifest.rootURL.path == harness.fixture.root.path)
|
#expect(manifest.rootURL.path == harness.fixture.root.path)
|
||||||
// Flatten order — lane `order`, then card `order`.
|
// Flatten order — lane `order`, then card `order`.
|
||||||
#expect(manifest.entries.map(\.id) == [Ident.card1, Ident.card2])
|
#expect(manifest.entries.map(\.id) == [Ident.card1, Ident.card2])
|
||||||
@@ -258,7 +258,7 @@ struct ClipboardCopyTests {
|
|||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
|
|
||||||
let manifest = try #require(harness.clipboard.payload)
|
let manifest = try #require(harness.clipboard.payload)
|
||||||
@@ -266,12 +266,12 @@ struct ClipboardCopyTests {
|
|||||||
#expect(manifest.entries[0].index == onDisk)
|
#expect(manifest.entries[0].index == onDisk)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A lane copy embeds its live cards and leaves the tombstoned one out")
|
@Test("A lane copy embeds exactly its cards — the trash is board-level, so none is nested")
|
||||||
func laneEntryEmbedsLiveCards() async throws {
|
func laneEntryEmbedsItsCards() async throws {
|
||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardLane1], liveness: .live)
|
harness.store.select([clipboardLane1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
|
|
||||||
let manifest = try #require(harness.clipboard.payload)
|
let manifest = try #require(harness.clipboard.payload)
|
||||||
@@ -281,17 +281,17 @@ struct ClipboardCopyTests {
|
|||||||
#expect(manifest.entries[0].lostAttachmentCount == 2)
|
#expect(manifest.entries[0].lostAttachmentCount == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A trashed selection copies out, side recorded")
|
@Test("A trash selection copies out, container recorded")
|
||||||
func trashedSide() async throws {
|
func trashContainerRecorded() async throws {
|
||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.transient.isTrashVisible = true
|
harness.store.transient.isTrashVisible = true
|
||||||
harness.store.select([clipboardCard3], liveness: .trashed)
|
harness.store.select([clipboardCard3], in: .trash)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
|
|
||||||
let manifest = try #require(harness.clipboard.payload)
|
let manifest = try #require(harness.clipboard.payload)
|
||||||
#expect(manifest.side == .trashed)
|
#expect(manifest.container == .trash)
|
||||||
#expect(manifest.entries.map(\.id) == [Ident.card3])
|
#expect(manifest.entries.map(\.id) == [Ident.card3])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,12 +310,12 @@ struct ClipboardCopyTests {
|
|||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.stagingSettled()
|
await harness.clipboard.stagingSettled()
|
||||||
let first = try #require(harness.clipboard.payload?.copyID)
|
let first = try #require(harness.clipboard.payload?.copyID)
|
||||||
|
|
||||||
harness.store.select([clipboardCard2], liveness: .live)
|
harness.store.select([clipboardCard2], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.stagingSettled()
|
await harness.clipboard.stagingSettled()
|
||||||
let second = try #require(harness.clipboard.payload?.copyID)
|
let second = try #require(harness.clipboard.payload?.copyID)
|
||||||
@@ -365,7 +365,7 @@ struct ClipboardSweepTests {
|
|||||||
withIntermediateDirectories: true
|
withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.stagingSettled()
|
await harness.clipboard.stagingSettled()
|
||||||
|
|
||||||
@@ -378,7 +378,7 @@ struct ClipboardSweepTests {
|
|||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.stagingSettled()
|
await harness.clipboard.stagingSettled()
|
||||||
#expect(try harness.stagedCopyIDs().count == 1)
|
#expect(try harness.stagedCopyIDs().count == 1)
|
||||||
@@ -402,7 +402,7 @@ struct ClipboardTakeoverTests {
|
|||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
#expect(harness.clipboard.payload != nil)
|
#expect(harness.clipboard.payload != nil)
|
||||||
|
|
||||||
@@ -417,7 +417,7 @@ struct ClipboardTakeoverTests {
|
|||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
let before = harness.pasteboard.changeCount
|
let before = harness.pasteboard.changeCount
|
||||||
harness.clipboard.refresh()
|
harness.clipboard.refresh()
|
||||||
@@ -432,7 +432,7 @@ struct ClipboardTakeoverTests {
|
|||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.cut(from: harness.store)
|
harness.clipboard.cut(from: harness.store)
|
||||||
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1])
|
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1])
|
||||||
|
|
||||||
@@ -453,11 +453,11 @@ struct ClipboardCutTests {
|
|||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardCard1, clipboardCard4], liveness: .live)
|
harness.store.select([clipboardCard1, clipboardCard4], in: .board)
|
||||||
harness.clipboard.cut(from: harness.store)
|
harness.clipboard.cut(from: harness.store)
|
||||||
|
|
||||||
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1, clipboardCard4])
|
#expect(harness.store.transient.pendingCut.ids == [clipboardCard1, clipboardCard4])
|
||||||
#expect(harness.store.transient.pendingCut.liveness == .live)
|
#expect(harness.store.transient.pendingCut.container == .board)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A second copy voids the pending cut — its pasteboard entry has been overwritten")
|
@Test("A second copy voids the pending cut — its pasteboard entry has been overwritten")
|
||||||
@@ -465,21 +465,21 @@ struct ClipboardCutTests {
|
|||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.cut(from: harness.store)
|
harness.clipboard.cut(from: harness.store)
|
||||||
#expect(!harness.store.transient.pendingCut.isEmpty)
|
#expect(!harness.store.transient.pendingCut.isEmpty)
|
||||||
|
|
||||||
harness.store.select([clipboardCard2], liveness: .live)
|
harness.store.select([clipboardCard2], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
#expect(harness.store.transient.pendingCut.isEmpty)
|
#expect(harness.store.transient.pendingCut.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Deletion voids per item: a tombstoned cut member leaves the pending set on reload")
|
@Test("Deletion voids per item: a deleted cut member leaves the pending set on reload")
|
||||||
func deletionVoidsPerItem() async throws {
|
func deletionVoidsPerItem() async throws {
|
||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
|
harness.store.select([clipboardCard1, clipboardCard2], in: .board)
|
||||||
harness.clipboard.cut(from: harness.store)
|
harness.clipboard.cut(from: harness.store)
|
||||||
|
|
||||||
harness.store.delete([clipboardCard1])
|
harness.store.delete([clipboardCard1])
|
||||||
@@ -502,19 +502,22 @@ struct ClipboardAvailabilityTests {
|
|||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
#expect(harness.clipboard.canCopy(from: harness.store) == false)
|
#expect(harness.clipboard.canCopy(from: harness.store) == false)
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
#expect(harness.clipboard.canCopy(from: harness.store))
|
#expect(harness.clipboard.canCopy(from: harness.store))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Copy works on a trashed selection; cut does not")
|
/// 04-interactions.md ▸ The trash, resettled 2026-07-28: "⌘X **works** (it was disabled under
|
||||||
func trashIsCopyOutOnly() throws {
|
/// the tombstone model): cut in the trash, paste into a lane is the keyboard-native restore, an
|
||||||
|
/// ordinary folder move."
|
||||||
|
@Test("Both copy and cut work on a trash selection — cut is the keyboard restore")
|
||||||
|
func trashTakesCopyAndCut() throws {
|
||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.transient.isTrashVisible = true
|
harness.store.transient.isTrashVisible = true
|
||||||
harness.store.select([clipboardCard3], liveness: .trashed)
|
harness.store.select([clipboardCard3], in: .trash)
|
||||||
#expect(harness.clipboard.canCopy(from: harness.store))
|
#expect(harness.clipboard.canCopy(from: harness.store))
|
||||||
#expect(harness.clipboard.canCut(from: harness.store) == false)
|
#expect(harness.clipboard.canCut(from: harness.store))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("The read-only lock blocks cut but never copy")
|
@Test("The read-only lock blocks cut but never copy")
|
||||||
@@ -522,7 +525,7 @@ struct ClipboardAvailabilityTests {
|
|||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.store.enterVanishedRootLock()
|
harness.store.enterVanishedRootLock()
|
||||||
#expect(harness.clipboard.canCopy(from: harness.store))
|
#expect(harness.clipboard.canCopy(from: harness.store))
|
||||||
#expect(harness.clipboard.canCut(from: harness.store) == false)
|
#expect(harness.clipboard.canCut(from: harness.store) == false)
|
||||||
@@ -534,7 +537,7 @@ struct ClipboardAvailabilityTests {
|
|||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
harness.store.transient.beginRename(of: clipboardCard1, currentTitle: "First")
|
harness.store.transient.beginRename(of: clipboardCard1, currentTitle: "First")
|
||||||
|
|
||||||
@@ -549,7 +552,7 @@ struct ClipboardAvailabilityTests {
|
|||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
#expect(harness.clipboard.canPaste(into: harness.store) == false)
|
#expect(harness.clipboard.canPaste(into: harness.store) == false)
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
#expect(harness.clipboard.canPaste(into: harness.store))
|
#expect(harness.clipboard.canPaste(into: harness.store))
|
||||||
}
|
}
|
||||||
@@ -573,11 +576,11 @@ struct ClipboardAvailabilityTests {
|
|||||||
let sourceStore = try BoardStore(rootURL: source.root)
|
let sourceStore = try BoardStore(rootURL: source.root)
|
||||||
let emptyStore = try BoardStore(rootURL: empty.root)
|
let emptyStore = try BoardStore(rootURL: empty.root)
|
||||||
|
|
||||||
sourceStore.select([clipboardCard1], liveness: .live)
|
sourceStore.select([clipboardCard1], in: .board)
|
||||||
clipboard.copy(from: sourceStore)
|
clipboard.copy(from: sourceStore)
|
||||||
#expect(clipboard.canPaste(into: emptyStore) == false)
|
#expect(clipboard.canPaste(into: emptyStore) == false)
|
||||||
|
|
||||||
sourceStore.select([clipboardLane1], liveness: .live)
|
sourceStore.select([clipboardLane1], in: .board)
|
||||||
clipboard.copy(from: sourceStore)
|
clipboard.copy(from: sourceStore)
|
||||||
#expect(clipboard.canPaste(into: emptyStore))
|
#expect(clipboard.canPaste(into: emptyStore))
|
||||||
}
|
}
|
||||||
@@ -600,7 +603,7 @@ struct PasteTargetTests {
|
|||||||
let model = try snapshot(fixture)
|
let model = try snapshot(fixture)
|
||||||
|
|
||||||
let target = PasteTarget.cards(
|
let target = PasteTarget.cards(
|
||||||
selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live),
|
selection: ItemReferenceSet(ids: [clipboardCard1], container: .board),
|
||||||
lastActiveLaneID: nil,
|
lastActiveLaneID: nil,
|
||||||
snapshot: model
|
snapshot: model
|
||||||
)
|
)
|
||||||
@@ -614,11 +617,11 @@ struct PasteTargetTests {
|
|||||||
let model = try snapshot(fixture)
|
let model = try snapshot(fixture)
|
||||||
|
|
||||||
let target = PasteTarget.cards(
|
let target = PasteTarget.cards(
|
||||||
selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live),
|
selection: ItemReferenceSet(ids: [clipboardLane1], container: .board),
|
||||||
lastActiveLaneID: nil,
|
lastActiveLaneID: nil,
|
||||||
snapshot: model
|
snapshot: model
|
||||||
)
|
)
|
||||||
// Two rendered cards — the tombstoned third is not in the layout.
|
// Two rendered cards.
|
||||||
#expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2))
|
#expect(target == PasteTarget.Cards(laneID: clipboardLane1, index: 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,7 +632,7 @@ struct PasteTargetTests {
|
|||||||
let model = try snapshot(fixture)
|
let model = try snapshot(fixture)
|
||||||
|
|
||||||
let target = PasteTarget.cards(
|
let target = PasteTarget.cards(
|
||||||
selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], liveness: .live),
|
selection: ItemReferenceSet(ids: [clipboardCard4, clipboardCard1], container: .board),
|
||||||
lastActiveLaneID: nil,
|
lastActiveLaneID: nil,
|
||||||
snapshot: model
|
snapshot: model
|
||||||
)
|
)
|
||||||
@@ -637,18 +640,18 @@ struct PasteTargetTests {
|
|||||||
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
|
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A tombstoned selection never anchors: it behaves as nothing selected")
|
@Test("A trash selection never anchors: it behaves as nothing selected")
|
||||||
func tombstonedSelectionNeverAnchors() throws {
|
func trashSelectionNeverAnchors() throws {
|
||||||
let fixture = try makeClipboardBoard()
|
let fixture = try makeClipboardBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let model = try snapshot(fixture)
|
let model = try snapshot(fixture)
|
||||||
|
|
||||||
let target = PasteTarget.cards(
|
let target = PasteTarget.cards(
|
||||||
selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed),
|
selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash),
|
||||||
lastActiveLaneID: clipboardLane2,
|
lastActiveLaneID: clipboardLane2,
|
||||||
snapshot: model
|
snapshot: model
|
||||||
)
|
)
|
||||||
// The last-active lane, appended — never `card3`'s live disk-lane.
|
// The last-active lane, appended — the trash is never the destination.
|
||||||
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
|
#expect(target == PasteTarget.Cards(laneID: clipboardLane2, index: 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,7 +682,7 @@ struct PasteTargetTests {
|
|||||||
let model = try snapshot(fixture)
|
let model = try snapshot(fixture)
|
||||||
|
|
||||||
#expect(PasteTarget.lanes(
|
#expect(PasteTarget.lanes(
|
||||||
selection: ItemReferenceSet(ids: [clipboardLane1], liveness: .live),
|
selection: ItemReferenceSet(ids: [clipboardLane1], container: .board),
|
||||||
snapshot: model
|
snapshot: model
|
||||||
) == 1)
|
) == 1)
|
||||||
}
|
}
|
||||||
@@ -691,12 +694,12 @@ struct PasteTargetTests {
|
|||||||
let model = try snapshot(fixture)
|
let model = try snapshot(fixture)
|
||||||
|
|
||||||
#expect(PasteTarget.lanes(
|
#expect(PasteTarget.lanes(
|
||||||
selection: ItemReferenceSet(ids: [clipboardCard1], liveness: .live),
|
selection: ItemReferenceSet(ids: [clipboardCard1], container: .board),
|
||||||
snapshot: model
|
snapshot: model
|
||||||
) == 1)
|
) == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Nothing (or something tombstoned) selected lands a lane at the board's right end")
|
@Test("Nothing (or a trash selection) lands a lane at the board's right end")
|
||||||
func rightEnd() throws {
|
func rightEnd() throws {
|
||||||
let fixture = try makeClipboardBoard()
|
let fixture = try makeClipboardBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
@@ -704,7 +707,7 @@ struct PasteTargetTests {
|
|||||||
|
|
||||||
#expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 2)
|
#expect(PasteTarget.lanes(selection: .empty, snapshot: model) == 2)
|
||||||
#expect(PasteTarget.lanes(
|
#expect(PasteTarget.lanes(
|
||||||
selection: ItemReferenceSet(ids: [clipboardCard3], liveness: .trashed),
|
selection: ItemReferenceSet(ids: [clipboardCard3], container: .trash),
|
||||||
snapshot: model
|
snapshot: model
|
||||||
) == 2)
|
) == 2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ import Testing
|
|||||||
@Suite("DragPayload")
|
@Suite("DragPayload")
|
||||||
struct DragPayloadTests {
|
struct DragPayloadTests {
|
||||||
|
|
||||||
private static func payload(kind: DragKind = .cards, side: Liveness = .live) -> DragPayload {
|
private static func payload(kind: DragKind = .cards, container: ItemContainer = .board) -> DragPayload {
|
||||||
DragPayload(
|
DragPayload(
|
||||||
boardRoot: URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true),
|
boardRoot: URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true),
|
||||||
kind: kind,
|
kind: kind,
|
||||||
side: side,
|
container: container,
|
||||||
items: [
|
items: [
|
||||||
DragPayload.Item(id: "aaa", folder: "/Boards/Work.kanban/lane/aaa", title: "First"),
|
DragPayload.Item(id: "aaa", folder: "/Boards/Work.kanban/lane/aaa", title: "First"),
|
||||||
DragPayload.Item(id: "bbb", folder: "/Boards/Work.kanban/lane/bbb", title: nil)
|
DragPayload.Item(id: "bbb", folder: "/Boards/Work.kanban/lane/bbb", title: nil)
|
||||||
@@ -30,8 +30,8 @@ struct DragPayloadTests {
|
|||||||
@Test("A payload round-trips through its JSON representation unchanged")
|
@Test("A payload round-trips through its JSON representation unchanged")
|
||||||
func roundTrip() throws {
|
func roundTrip() throws {
|
||||||
for kind in [DragKind.cards, .lanes] {
|
for kind in [DragKind.cards, .lanes] {
|
||||||
for side in [Liveness.live, .trashed] {
|
for container in [ItemContainer.board, .trash] {
|
||||||
let original = Self.payload(kind: kind, side: side)
|
let original = Self.payload(kind: kind, container: container)
|
||||||
let data = try #require(original.encoded())
|
let data = try #require(original.encoded())
|
||||||
#expect(DragPayload(data: data) == original)
|
#expect(DragPayload(data: data) == original)
|
||||||
}
|
}
|
||||||
@@ -62,10 +62,10 @@ struct DragPayloadTests {
|
|||||||
#expect(Self.payload().plainText == "First\nUntitled")
|
#expect(Self.payload().plainText == "First\nUntitled")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("The side survives the round trip, because it is what makes a trash drag a trash drag")
|
@Test("The container survives the round trip, because it is what makes a trash drag a trash drag")
|
||||||
func sideSurvives() throws {
|
func containerSurvives() throws {
|
||||||
let data = try #require(Self.payload(side: .trashed).encoded())
|
let data = try #require(Self.payload(container: .trash).encoded())
|
||||||
#expect(DragPayload(data: data)?.side.liveness == .trashed)
|
#expect(DragPayload(data: data)?.container == .trash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,25 +95,25 @@ struct DragLocalityTests {
|
|||||||
/// The Finder volume model: within a board a drag rearranges, between boards it transfers.
|
/// The Finder volume model: within a board a drag rearranges, between boards it transfers.
|
||||||
@Test("Locality picks the default — within is a move, across is a copy")
|
@Test("Locality picks the default — within is a move, across is a copy")
|
||||||
func theDefault() {
|
func theDefault() {
|
||||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: none) == .move)
|
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: none) == .move)
|
||||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: none) == .copy)
|
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: none) == .copy)
|
||||||
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: none) == .copy)
|
#expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: none) == .copy)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("⌥ forces copy and ⌘ forces move, each a no-op where it is already the default")
|
@Test("⌥ forces copy and ⌘ forces move, each a no-op where it is already the default")
|
||||||
func modifiersOverride() {
|
func modifiersOverride() {
|
||||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: option) == .copy)
|
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: option) == .copy)
|
||||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: command) == .move)
|
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: command) == .move)
|
||||||
// The no-ops.
|
// The no-ops.
|
||||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: command) == .move)
|
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: true, modifiers: command) == .move)
|
||||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: option) == .copy)
|
#expect(DragLocality.operation(kind: .cards, container: .board, isWithinBoard: false, modifiers: option) == .copy)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("⌘ wins over ⌥ when both are held")
|
@Test("⌘ wins over ⌥ when both are held")
|
||||||
func commandWinsOverOption() {
|
func commandWinsOverOption() {
|
||||||
// Finder's own reduction, and the same precedence `ClickModifier.current` applies to clicks.
|
// Finder's own reduction, and the same precedence `ClickModifier.current` applies to clicks.
|
||||||
#expect(DragLocality.operation(
|
#expect(DragLocality.operation(
|
||||||
kind: .cards, side: .live, isWithinBoard: false, modifiers: [.option, .command]) == .move)
|
kind: .cards, container: .board, isWithinBoard: false, modifiers: [.option, .command]) == .move)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The first carve-out: "Lane drags never copy *within their board*. ⌥ is simply ignored there:
|
/// The first carve-out: "Lane drags never copy *within their board*. ⌥ is simply ignored there:
|
||||||
@@ -122,13 +122,13 @@ struct DragLocalityTests {
|
|||||||
func laneDragsNeverCopyWithinTheirBoard() {
|
func laneDragsNeverCopyWithinTheirBoard() {
|
||||||
for modifiers in [none, option, command, [.option, .command] as NSEvent.ModifierFlags] {
|
for modifiers in [none, option, command, [.option, .command] as NSEvent.ModifierFlags] {
|
||||||
#expect(
|
#expect(
|
||||||
DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: true, modifiers: modifiers) == .move,
|
DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: true, modifiers: modifiers) == .move,
|
||||||
"a within-board lane drag is a reorder whatever is held"
|
"a within-board lane drag is a reorder whatever is held"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Across boards the lane obeys the ordinary grammar again.
|
// Across boards the lane obeys the ordinary grammar again.
|
||||||
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: option) == .copy)
|
#expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: option) == .copy)
|
||||||
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: command) == .move)
|
#expect(DragLocality.operation(kind: .lanes, container: .board, isWithinBoard: false, modifiers: command) == .move)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The second: a trash row's drag is copy-out grammar (04-interactions.md ▸ The trash). Within its
|
/// The second: a trash row's drag is copy-out grammar (04-interactions.md ▸ The trash). Within its
|
||||||
@@ -137,11 +137,11 @@ struct DragLocalityTests {
|
|||||||
/// live copy either way.
|
/// live copy either way.
|
||||||
@Test("A trash row drags as a restore at home and as a copy-out abroad")
|
@Test("A trash row drags as a restore at home and as a copy-out abroad")
|
||||||
func trashDragDefaults() {
|
func trashDragDefaults() {
|
||||||
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: none) == .move)
|
#expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: true, modifiers: none) == .move)
|
||||||
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: false, modifiers: none) == .copy)
|
#expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: false, modifiers: none) == .copy)
|
||||||
#expect(DragLocality.operation(
|
#expect(DragLocality.operation(
|
||||||
kind: .cards, side: .trashed, isWithinBoard: false, modifiers: command) == .move)
|
kind: .cards, container: .trash, isWithinBoard: false, modifiers: command) == .move)
|
||||||
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: option) == .copy)
|
#expect(DragLocality.operation(kind: .cards, container: .trash, isWithinBoard: true, modifiers: option) == .copy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ struct TrashDropTests {
|
|||||||
/// The accepted session, with one clause at a time knocked out by the cases.
|
/// The accepted session, with one clause at a time knocked out by the cases.
|
||||||
private func accepts(
|
private func accepts(
|
||||||
kind: DragKind? = .cards,
|
kind: DragKind? = .cards,
|
||||||
side: Liveness = .live,
|
container: ItemContainer = .board,
|
||||||
isWithinBoard: Bool = true,
|
isWithinBoard: Bool = true,
|
||||||
operation: TransferOperation = .move,
|
operation: TransferOperation = .move,
|
||||||
isTrashShown: Bool = true,
|
isTrashShown: Bool = true,
|
||||||
@@ -166,7 +166,7 @@ struct TrashDropTests {
|
|||||||
) -> Bool {
|
) -> Bool {
|
||||||
TrashDrop.accepts(
|
TrashDrop.accepts(
|
||||||
kind: kind,
|
kind: kind,
|
||||||
side: side,
|
container: container,
|
||||||
isWithinBoard: isWithinBoard,
|
isWithinBoard: isWithinBoard,
|
||||||
operation: operation,
|
operation: operation,
|
||||||
isTrashShown: isTrashShown,
|
isTrashShown: isTrashShown,
|
||||||
@@ -196,12 +196,12 @@ struct TrashDropTests {
|
|||||||
#expect(!accepts(kind: nil))
|
#expect(!accepts(kind: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A trash row's drag is restore/copy-out grammar; dropped back where it came from it writes
|
/// A trash card's drag is the restore; dropped back where it came from it writes nothing, so it
|
||||||
/// nothing, so it never proposes.
|
/// never proposes.
|
||||||
@Test("A trash row dropped back on the trash is refused")
|
@Test("A trash card dropped back on the trash is refused")
|
||||||
func theTrashedSideIsRefused() {
|
func theTrashContainerIsRefused() {
|
||||||
#expect(!accepts(side: .trashed))
|
#expect(!accepts(container: .trash))
|
||||||
#expect(!accepts(side: .trashed, isWithinBoard: false))
|
#expect(!accepts(container: .trash, isWithinBoard: false))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "No move or paste ever targets the trash": a foreign card delivered into this board's trash
|
/// "No move or paste ever targets the trash": a foreign card delivered into this board's trash
|
||||||
@@ -340,7 +340,7 @@ struct DropSettleTests {
|
|||||||
.appendingPathComponent($0.id.rawValue, isDirectory: true)
|
.appendingPathComponent($0.id.rawValue, isDirectory: true)
|
||||||
},
|
},
|
||||||
heights: members.map { _ in 44 },
|
heights: members.map { _ in 44 },
|
||||||
side: .live,
|
container: .board,
|
||||||
source: store
|
source: store
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-107
@@ -16,20 +16,6 @@ import Testing
|
|||||||
|
|
||||||
// MARK: - Fixtures
|
// MARK: - Fixtures
|
||||||
|
|
||||||
private func tombstoned(order: String, title: String) -> String {
|
|
||||||
"""
|
|
||||||
---
|
|
||||||
schema: 1
|
|
||||||
title: \(title)
|
|
||||||
order: \(order)
|
|
||||||
created: 2026-01-01T09:00:00Z
|
|
||||||
deleted: 2026-03-03T09:00:00Z
|
|
||||||
---
|
|
||||||
\(title) body.
|
|
||||||
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Three cards in the first lane, one in the second — enough room for a run of two to insert
|
/// Three cards in the first lane, one in the second — enough room for a run of two to insert
|
||||||
/// between siblings without either end being the answer.
|
/// between siblings without either end being the answer.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -55,12 +41,12 @@ private enum Foreign {
|
|||||||
static let trashed = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
|
static let trashed = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The board every cross-board test drags *out of*: one lane holding a live card and a tombstoned
|
/// The board every cross-board test drags *out of*: one lane holding two cards, plus a card sitting
|
||||||
/// one, so the lane-copy rule has something to strip and the restore rules have a row to carry.
|
/// in the source board's own `.trash/` for the cross-board restore cases.
|
||||||
///
|
///
|
||||||
/// `colliding` puts the lane and its live card under identities the **destination** already holds,
|
/// `colliding` puts the lane and its first card under identities the **destination** already holds,
|
||||||
/// which is the import boundary's whole question; the tombstoned card keeps its foreign identity
|
/// which is the import boundary's whole question; the second card keeps its foreign identity either
|
||||||
/// either way, so a colliding arrival can prove the degradation is per folder.
|
/// way, so a colliding arrival can prove the degradation is per folder.
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeSourceBoard(colliding: Bool = false) throws -> WriterFixture {
|
private func makeSourceBoard(colliding: Bool = false) throws -> WriterFixture {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
@@ -69,7 +55,8 @@ private func makeSourceBoard(colliding: Bool = false) throws -> WriterFixture {
|
|||||||
let cardName = colliding ? Ident.card1 : Foreign.card
|
let cardName = colliding ? Ident.card1 : Foreign.card
|
||||||
try fixture.item(laneName, Item.rich(order: "1024", title: "Imported"))
|
try fixture.item(laneName, Item.rich(order: "1024", title: "Imported"))
|
||||||
try fixture.item("\(laneName)/\(cardName)", Item.rich(order: "1024", title: "Travelling"))
|
try fixture.item("\(laneName)/\(cardName)", Item.rich(order: "1024", title: "Travelling"))
|
||||||
try fixture.item("\(laneName)/\(Foreign.trashed)", tombstoned(order: "2048", title: "Trashed"))
|
try fixture.item("\(laneName)/\(Foreign.second)", Item.rich(order: "2048", title: "Second"))
|
||||||
|
try fixture.item(".trash/\(Foreign.trashed)", Item.rich(order: "1024", title: "Trashed"))
|
||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,14 +77,14 @@ private func loaded(_ fixture: WriterFixture) throws -> BoardModel {
|
|||||||
/// A lane's rendered card titles, in display order.
|
/// A lane's rendered card titles, in display order.
|
||||||
private func titles(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] {
|
private func titles(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] {
|
||||||
guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] }
|
guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] }
|
||||||
return lane.cards.filter { !$0.isDeleted }.compactMap(\.title.value)
|
return lane.cards.compactMap(\.title.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A lane's rendered card folder names, in display order — identity, where titles would not
|
/// A lane's rendered card folder names, in display order — identity, where titles would not
|
||||||
/// distinguish an original from its copy.
|
/// distinguish an original from its copy.
|
||||||
private func ids(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] {
|
private func ids(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] {
|
||||||
guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] }
|
guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] }
|
||||||
return lane.cards.filter { !$0.isDeleted }.map(\.id.rawValue)
|
return lane.cards.map(\.id.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func order(_ fixture: WriterFixture, _ relativePath: String) throws -> FieldValue<Double> {
|
private func order(_ fixture: WriterFixture, _ relativePath: String) throws -> FieldValue<Double> {
|
||||||
@@ -183,18 +170,16 @@ struct MoveCardsTests {
|
|||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A destination that is gone, tombstoned, or empty of members writes nothing")
|
@Test("A destination that is gone, or a set that names nothing, writes nothing")
|
||||||
func noOps() throws {
|
func noOps() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
|
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
|
||||||
store.moveCards([card1], toLane: lane3, at: 0) // tombstoned lane
|
|
||||||
store.moveCards([card1], toLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane
|
store.moveCards([card1], toLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane
|
||||||
store.moveCards([], toLane: lane2, at: 0) // nothing dragged
|
store.moveCards([], toLane: lane2, at: 0) // nothing dragged
|
||||||
store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0) // nothing live
|
store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0) // nothing there
|
||||||
|
|
||||||
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == stamp)
|
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == stamp)
|
||||||
#expect(try titles(lane2, in: fixture) == ["Fourth"])
|
#expect(try titles(lane2, in: fixture) == ["Fourth"])
|
||||||
@@ -315,14 +300,13 @@ struct CopyCardsTests {
|
|||||||
func noOps() throws {
|
func noOps() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
|
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.copyCards([card1], toLane: lane3, at: 0)
|
store.copyCards([card1], toLane: ItemID(rawValue: Ident.lane3), at: 0)
|
||||||
store.copyCards([], toLane: lane2, at: 0)
|
store.copyCards([], toLane: lane2, at: 0)
|
||||||
|
|
||||||
#expect(try titles(lane2, in: fixture) == ["Fourth"])
|
#expect(try titles(lane2, in: fixture) == ["Fourth"])
|
||||||
#expect(try fixture.entryNames(Ident.lane3) == ["index.md"])
|
#expect(!fixture.exists(Ident.lane3))
|
||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,14 +395,13 @@ struct ReceiveCardsTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let source = try makeSourceBoard()
|
let source = try makeSourceBoard()
|
||||||
defer { source.tearDown() }
|
defer { source.tearDown() }
|
||||||
try destination.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
|
|
||||||
let store = try BoardStore(rootURL: destination.root)
|
let store = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")],
|
store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")],
|
||||||
operation: .copy, toLane: lane3, at: 0)
|
operation: .copy, toLane: ItemID(rawValue: Ident.lane3), at: 0)
|
||||||
store.receiveCards([], operation: .copy, toLane: lane2, at: 0)
|
store.receiveCards([], operation: .copy, toLane: lane2, at: 0)
|
||||||
|
|
||||||
#expect(try destination.entryNames(Ident.lane3) == ["index.md"])
|
#expect(!destination.exists(Ident.lane3))
|
||||||
#expect(try titles(lane2, in: destination) == ["Fourth"])
|
#expect(try titles(lane2, in: destination) == ["Fourth"])
|
||||||
#expect(source.exists("\(Foreign.lane)/\(Foreign.card)"))
|
#expect(source.exists("\(Foreign.lane)/\(Foreign.card)"))
|
||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
@@ -524,8 +507,12 @@ struct MoveLanesTests {
|
|||||||
@Suite("BoardStore ▸ receiveLanes")
|
@Suite("BoardStore ▸ receiveLanes")
|
||||||
struct ReceiveLanesTests {
|
struct ReceiveLanesTests {
|
||||||
|
|
||||||
@Test("A lane copy transfers the content and strips the tombstoned cards")
|
/// 04-interactions.md ▸ Drag and drop, resettled 2026-07-28: "A lane carries exactly its cards —
|
||||||
func laneCopyStripsTombstones() throws {
|
/// the trash is board-level (`.trash/`), so there is nothing lane-nested to strip or carry: copy
|
||||||
|
/// and ⌘-drag move alike transfer the lane's folder as it is; the old tombstone-stripping rule is
|
||||||
|
/// retired with the tombstone model."
|
||||||
|
@Test("A lane copy transfers the whole lane, minting fresh identities at every level")
|
||||||
|
func laneCopyRemintsThroughout() throws {
|
||||||
let destination = try makeBoard()
|
let destination = try makeBoard()
|
||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let source = try makeSourceBoard()
|
let source = try makeSourceBoard()
|
||||||
@@ -538,19 +525,15 @@ struct ReceiveLanesTests {
|
|||||||
#expect(model.lanes.map(\.title.value) == ["Imported", "Todo", "Doing"])
|
#expect(model.lanes.map(\.title.value) == ["Imported", "Todo", "Doing"])
|
||||||
let arrived = try #require(model.lanes.first)
|
let arrived = try #require(model.lanes.first)
|
||||||
#expect(arrived.id.rawValue != Foreign.lane, "a copy mints fresh UUIDs at every level")
|
#expect(arrived.id.rawValue != Foreign.lane, "a copy mints fresh UUIDs at every level")
|
||||||
#expect(arrived.cards.map(\.title.value) == ["Travelling"],
|
#expect(arrived.cards.map(\.title.value) == ["Travelling", "Second"], "nothing to strip")
|
||||||
"trash isn't content — the tombstoned card did not come")
|
#expect(arrived.cards.allSatisfy { $0.id.rawValue != Foreign.card },
|
||||||
#expect(arrived.cards.allSatisfy { !$0.isDeleted })
|
"a copied lane's cards are new cards")
|
||||||
#expect(arrived.cards[0].id.rawValue != Foreign.card, "a copied lane's cards are new cards")
|
#expect(model.trash.isEmpty, "the source's trash is board-level and never travels with a lane")
|
||||||
|
|
||||||
// The tombstoned original stays recoverable in the source board.
|
|
||||||
#expect(source.exists("\(Foreign.lane)/\(Foreign.trashed)"))
|
|
||||||
#expect(try source.indexText("\(Foreign.lane)/\(Foreign.trashed)").contains("deleted:"))
|
|
||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A lane move carries its tombstoned cards whole, into the destination's trash")
|
@Test("A lane move carries its cards whole, identity and all")
|
||||||
func laneMoveCarriesTombstones() throws {
|
func laneMoveCarriesItsCards() throws {
|
||||||
let destination = try makeBoard()
|
let destination = try makeBoard()
|
||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let source = try makeSourceBoard()
|
let source = try makeSourceBoard()
|
||||||
@@ -563,10 +546,9 @@ struct ReceiveLanesTests {
|
|||||||
#expect(model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Foreign.lane],
|
#expect(model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Foreign.lane],
|
||||||
"identity travels, and the drop position is honoured")
|
"identity travels, and the drop position is honoured")
|
||||||
let arrived = try #require(model.lanes.last)
|
let arrived = try #require(model.lanes.last)
|
||||||
#expect(arrived.cards.map(\.id.rawValue) == [Foreign.card, Foreign.trashed])
|
#expect(arrived.cards.map(\.id.rawValue) == [Foreign.card, Foreign.second],
|
||||||
#expect(arrived.cards[1].isDeleted, "the tombstone came along as-is")
|
"a lane carries exactly its cards: nothing lane-nested to strip or carry")
|
||||||
#expect(TrashModel.entries(of: model).map(\.id) == [ItemID(rawValue: Foreign.trashed)],
|
#expect(model.trash.isEmpty, "and nothing arrived in the destination's own trash")
|
||||||
"and it renders in the destination's trash")
|
|
||||||
#expect(!source.exists(Foreign.lane))
|
#expect(!source.exists(Foreign.lane))
|
||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
@@ -591,7 +573,7 @@ struct ReceiveLanesTests {
|
|||||||
let arrivedCards = arrived.cards.map(\.id.rawValue)
|
let arrivedCards = arrived.cards.map(\.id.rawValue)
|
||||||
#expect(arrivedCards.count == 2)
|
#expect(arrivedCards.count == 2)
|
||||||
#expect(arrivedCards[0] != Ident.card1, "the colliding card was repaired too")
|
#expect(arrivedCards[0] != Ident.card1, "the colliding card was repaired too")
|
||||||
#expect(arrivedCards[1] == Foreign.trashed, "and nothing else was — degradation is per folder")
|
#expect(arrivedCards[1] == Foreign.second, "and nothing else was — degradation is per folder")
|
||||||
#expect(try titles(lane1, in: destination) == ["First", "Second", "Third"],
|
#expect(try titles(lane1, in: destination) == ["First", "Second", "Third"],
|
||||||
"the residents kept their identities and their ranks")
|
"the residents kept their identities and their ranks")
|
||||||
}
|
}
|
||||||
@@ -609,26 +591,37 @@ struct ReceiveLanesTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Drag to restore, positionally
|
// MARK: - Restore is an ordinary move out
|
||||||
|
|
||||||
/// A lane holding a live card, a tombstoned one, and another live one — so a restore has somewhere
|
/// A lane holding two cards, plus one card in the board's `.trash/` — so a restore has somewhere to
|
||||||
/// to land that is neither the head nor the tail.
|
/// land that is neither the head nor the tail.
|
||||||
|
///
|
||||||
|
/// **There is no tombstone anywhere in it.** "Restoring is an ordinary move out: drag a trash card
|
||||||
|
/// into any lane at any position … there is no restore-specific machinery and no Put Back"
|
||||||
|
/// (03-board-ui.md § Trash, resettled 2026-07-28), so the fixture is an ordinary board plus a
|
||||||
|
/// reserved folder.
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeTrashBoard() throws -> WriterFixture {
|
private func makeTrashBoard() throws -> WriterFixture {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
try fixture.item("", Item.board)
|
try fixture.item("", Item.board)
|
||||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Trashed"))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
|
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
|
||||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||||
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
|
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
|
||||||
|
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Trashed"))
|
||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restoring out of the trash — **through `moveCards` and `copyCards`, with no method of its own**.
|
||||||
|
///
|
||||||
|
/// That is the claim these tests exist to pin. The tombstone model needed `restoreByDrag` and
|
||||||
|
/// `receiveRestoredCards` because a restore had to clear a key as well as place a rank; a
|
||||||
|
/// materialized trash makes it "an ordinary move" (03-board-ui.md § Trash), so the ordinary drop
|
||||||
|
/// commits take a trash-side source and the retired pair is gone.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Suite("BoardStore ▸ positional drag-to-restore")
|
@Suite("BoardStore ▸ restore by move-out")
|
||||||
struct RestoreByDragPositionTests {
|
struct RestoreByMoveOutTests {
|
||||||
|
|
||||||
@Test("The drop position sets the restored card's order")
|
@Test("The drop position sets the restored card's order")
|
||||||
func dropPositionSetsTheOrder() throws {
|
func dropPositionSetsTheOrder() throws {
|
||||||
@@ -636,76 +629,89 @@ struct RestoreByDragPositionTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
// Index 0 among the lane's two live cards: ahead of both, not back at its recorded 2048.
|
// Index 0 among the lane's two cards: ahead of both.
|
||||||
store.restoreByDrag(cardID: card2, intoLane: lane1, at: 0)
|
store.moveCards([card2], toLane: lane1, at: 0)
|
||||||
|
|
||||||
#expect(try titles(lane1, in: fixture) == ["Trashed", "First", "Third"])
|
#expect(try titles(lane1, in: fixture) == ["Trashed", "First", "Third"])
|
||||||
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(0))
|
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(0))
|
||||||
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "same lane never moves a folder")
|
#expect(!fixture.exists(".trash/\(Ident.card2)"), "the folder physically left the trash")
|
||||||
#expect(TrashModel.isEmpty(try loaded(fixture)))
|
#expect(try loaded(fixture).trash.isEmpty)
|
||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A cross-lane restore lands at the drop position, not at the bottom")
|
|
||||||
func crossLanePositional() throws {
|
|
||||||
let fixture = try makeTrashBoard()
|
|
||||||
defer { fixture.tearDown() }
|
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
|
||||||
|
|
||||||
store.restoreByDrag(cardID: card2, intoLane: lane2, at: 0)
|
|
||||||
|
|
||||||
#expect(try titles(lane2, in: fixture) == ["Trashed", "Fourth"])
|
|
||||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
|
||||||
#expect(try order(fixture, "\(Ident.lane2)/\(Ident.card2)") == .valid(0))
|
|
||||||
let text = try fixture.indexText("\(Ident.lane2)/\(Ident.card2)")
|
|
||||||
#expect(!text.contains("deleted:"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("An out-of-range index clamps to the lane's bottom")
|
@Test("An out-of-range index clamps to the lane's bottom")
|
||||||
func indexClamps() throws {
|
func indexClamps() throws {
|
||||||
let fixture = try makeTrashBoard()
|
let fixture = try makeTrashBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.restoreByDrag(cardID: card2, intoLane: lane1, at: 99)
|
store.moveCards([card2], toLane: lane1, at: 99)
|
||||||
|
|
||||||
#expect(try titles(lane1, in: fixture) == ["First", "Third", "Trashed"])
|
#expect(try titles(lane1, in: fixture) == ["First", "Third", "Trashed"])
|
||||||
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(4096))
|
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(4096))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A multi-row restore lands the run contiguously, in drop order, in one bracket")
|
@Test("The identity travels, exactly as it does for any move")
|
||||||
|
func identityTravels() throws {
|
||||||
|
let fixture = try makeTrashBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.moveCards([card2], toLane: lane2, at: 0)
|
||||||
|
|
||||||
|
#expect(try ids(lane2, in: fixture) == [Ident.card2, Ident.card4])
|
||||||
|
#expect(!fixture.exists(".trash/\(Ident.card2)"))
|
||||||
|
// Nothing about the restored card says it was ever deleted: there is no key to remove,
|
||||||
|
// because there is no key.
|
||||||
|
#expect(!(try fixture.indexText("\(Ident.lane2)/\(Ident.card2)").contains("deleted:")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A multi-card restore lands the run contiguously, in one bracket")
|
||||||
func multiRestore() throws {
|
func multiRestore() throws {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
try fixture.item("", Item.board)
|
try fixture.item("", Item.board)
|
||||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "TrashedA"))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstoned(order: "3072", title: "TrashedB"))
|
|
||||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||||
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
|
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
|
||||||
|
// The trash's own order is its display order — `card3` above `card2`, newest-first.
|
||||||
|
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "TrashedA"))
|
||||||
|
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "TrashedB"))
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.restoreByDrag(cardIDs: [card3, card2], intoLane: lane2, at: 0)
|
store.moveCards([card3, card2], toLane: lane2, at: 0)
|
||||||
|
|
||||||
#expect(try titles(lane2, in: fixture) == ["TrashedB", "TrashedA", "Fourth"],
|
#expect(try titles(lane2, in: fixture) == ["TrashedB", "TrashedA", "Fourth"],
|
||||||
"the payload's order is the landing order")
|
"the trash's own order is the landing order")
|
||||||
#expect(TrashModel.isEmpty(try loaded(fixture)))
|
#expect(try loaded(fixture).trash.isEmpty)
|
||||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
|
||||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card3)"))
|
|
||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A row that is not a trash row is skipped, and an empty list writes nothing")
|
@Test("⌥ copies out and leaves the original in the trash")
|
||||||
func skipsWhatIsNotARow() throws {
|
func copyOutLeavesTheOriginal() throws {
|
||||||
|
let fixture = try makeTrashBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.copyCards([card2], toLane: lane2, at: 0)
|
||||||
|
|
||||||
|
#expect(try titles(lane2, in: fixture) == ["Trashed", "Fourth"])
|
||||||
|
let landed = try #require(try ids(lane2, in: fixture).first)
|
||||||
|
#expect(landed != Ident.card2, "a copy out of the trash is still a copy")
|
||||||
|
#expect(fixture.exists(".trash/\(Ident.card2)"), "the original stays in the trash")
|
||||||
|
#expect(try loaded(fixture).trash.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A card that is not in the trash and an empty list both write nothing")
|
||||||
|
func skipsWhatIsNotThere() throws {
|
||||||
let fixture = try makeTrashBoard()
|
let fixture = try makeTrashBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let untouched = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
let untouched = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
|
||||||
// `card1` is live — it has no trash row to drag — and `indexless` names nothing.
|
store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0)
|
||||||
store.restoreByDrag(cardIDs: [card1, ItemID(rawValue: Ident.indexless)], intoLane: lane2, at: 0)
|
store.moveCards([], toLane: lane2, at: 0)
|
||||||
store.restoreByDrag(cardIDs: [], intoLane: lane2, at: 0)
|
|
||||||
|
|
||||||
#expect(try titles(lane2, in: fixture) == ["Fourth"])
|
#expect(try titles(lane2, in: fixture) == ["Fourth"])
|
||||||
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == untouched)
|
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == untouched)
|
||||||
@@ -713,52 +719,49 @@ struct RestoreByDragPositionTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The cross-board half — **`receiveCards`, with no restore variant** (04-interactions.md ▸ The
|
||||||
|
/// trash: "Dropped on *another* board it follows the copy default — a live copy lands there, the
|
||||||
|
/// original stays in the source trash; ⌘-drag forces the true cross-board restore-move").
|
||||||
@MainActor
|
@MainActor
|
||||||
@Suite("BoardStore ▸ receiveRestoredCards")
|
@Suite("BoardStore ▸ cross-board restore")
|
||||||
struct ReceiveRestoredCardsTests {
|
struct CrossBoardRestoreTests {
|
||||||
|
|
||||||
@Test("A cross-board restore-copy lands live and leaves the source tombstone standing")
|
@Test("A cross-board copy out of the trash lands a fresh identity and leaves the original")
|
||||||
func restoreCopyStripsDeletedAndKeepsTheOriginal() throws {
|
func copyLeavesTheOriginal() throws {
|
||||||
let destination = try makeBoard()
|
let destination = try makeBoard()
|
||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let source = try makeSourceBoard()
|
let source = try makeSourceBoard()
|
||||||
defer { source.tearDown() }
|
defer { source.tearDown() }
|
||||||
let store = try BoardStore(rootURL: destination.root)
|
let store = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
store.receiveRestoredCards([source.url("\(Foreign.lane)/\(Foreign.trashed)")],
|
store.receiveCards([source.url(".trash/\(Foreign.trashed)")],
|
||||||
operation: .copy, toLane: lane2, at: 0)
|
operation: .copy, toLane: lane2, at: 0)
|
||||||
|
|
||||||
#expect(try titles(lane2, in: destination) == ["Trashed", "Fourth"])
|
#expect(try titles(lane2, in: destination) == ["Trashed", "Fourth"])
|
||||||
let landedIDs = try ids(lane2, in: destination)
|
let landed = try #require(try ids(lane2, in: destination).first)
|
||||||
let landed = try #require(landedIDs.first)
|
|
||||||
#expect(landed != Foreign.trashed, "a copy out of the trash is still a copy")
|
#expect(landed != Foreign.trashed, "a copy out of the trash is still a copy")
|
||||||
let text = try destination.indexText("\(Ident.lane2)/\(landed)")
|
let text = try destination.indexText("\(Ident.lane2)/\(landed)")
|
||||||
#expect(!text.contains("deleted:"), "`deleted:` is stripped on paste/duplicate/drop")
|
|
||||||
#expect(text.contains("created: 2026-01-01T09:00:00Z"), "a copy is a fork")
|
#expect(text.contains("created: 2026-01-01T09:00:00Z"), "a copy is a fork")
|
||||||
|
|
||||||
// The tombstoned original stays recoverable in the source board's trash.
|
#expect(source.exists(".trash/\(Foreign.trashed)"), "the original stays in the source trash")
|
||||||
#expect(source.exists("\(Foreign.lane)/\(Foreign.trashed)"))
|
|
||||||
#expect(try source.indexText("\(Foreign.lane)/\(Foreign.trashed)").contains("deleted:"))
|
|
||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A cross-board restore-move clears the tombstone and the source loses the folder")
|
@Test("A ⌘-drag move out of another board's trash carries the identity and empties it")
|
||||||
func restoreMoveClearsTheTombstone() throws {
|
func moveCarriesTheIdentity() throws {
|
||||||
let destination = try makeBoard()
|
let destination = try makeBoard()
|
||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let source = try makeSourceBoard()
|
let source = try makeSourceBoard()
|
||||||
defer { source.tearDown() }
|
defer { source.tearDown() }
|
||||||
let store = try BoardStore(rootURL: destination.root)
|
let store = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
store.receiveRestoredCards([source.url("\(Foreign.lane)/\(Foreign.trashed)")],
|
store.receiveCards([source.url(".trash/\(Foreign.trashed)")],
|
||||||
operation: .move, toLane: lane2, at: 1)
|
operation: .move, toLane: lane2, at: 1)
|
||||||
|
|
||||||
#expect(try ids(lane2, in: destination) == [Ident.card4, Foreign.trashed], "identity travels")
|
#expect(try ids(lane2, in: destination) == [Ident.card4, Foreign.trashed], "identity travels")
|
||||||
let text = try destination.indexText("\(Ident.lane2)/\(Foreign.trashed)")
|
#expect(!source.exists(".trash/\(Foreign.trashed)"), "the card left the source trash")
|
||||||
#expect(!text.contains("deleted:"))
|
#expect(try loaded(source).trash.isEmpty)
|
||||||
#expect(!source.exists("\(Foreign.lane)/\(Foreign.trashed)"), "the tombstone left the source")
|
#expect(try loaded(destination).trash.isEmpty)
|
||||||
#expect(TrashModel.isEmpty(try loaded(source)))
|
|
||||||
#expect(TrashModel.isEmpty(try loaded(destination)))
|
|
||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,27 +184,25 @@ struct ImportAttachmentsToCardTests {
|
|||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A tombstoned, ancestor-tombstoned, or vanished target writes nothing")
|
@Test("A trashed or vanished target writes nothing")
|
||||||
func inertTargets() throws {
|
func inertTargets() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let sources = try DropSources()
|
let sources = try DropSources()
|
||||||
defer { sources.tearDown() }
|
defer { sources.tearDown() }
|
||||||
// A tombstoned card, and a live card under a tombstoned lane — effective liveness is
|
// "Finder file drops on trash cards are inert" (04 ▸ The trash), and a card whose lane was
|
||||||
// ancestor-walked, so both render nowhere and both are inert.
|
// deleted is simply not there.
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second"))
|
try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2)
|
||||||
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
|
|
||||||
try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Buried"))
|
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let shot = try sources.file("shot.png")
|
let shot = try sources.file("shot.png")
|
||||||
|
|
||||||
store.importAttachments([shot], toCard: card2) // tombstoned card
|
store.importAttachments([shot], toCard: card2) // a trash card
|
||||||
store.importAttachments([shot], toCard: ItemID(rawValue: Ident.card4)) // under a tombstoned lane
|
store.importAttachments([shot], toCard: ItemID(rawValue: Ident.card4)) // its lane is gone
|
||||||
store.importAttachments([shot], toCard: ItemID(rawValue: Ident.indexless)) // no such card
|
store.importAttachments([shot], toCard: ItemID(rawValue: Ident.indexless)) // no such card
|
||||||
store.importAttachments([shot], toCard: lane1) // a lane, not a card
|
store.importAttachments([shot], toCard: lane1) // a lane, not a card
|
||||||
store.importAttachments([], toCard: card1) // nothing dropped
|
store.importAttachments([], toCard: card1) // nothing dropped
|
||||||
|
|
||||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)/attachments"))
|
#expect(!fixture.exists(".trash/\(Ident.card2)/attachments"))
|
||||||
#expect(!fixture.exists("\(Ident.lane3)/\(Ident.card4)/attachments"))
|
#expect(!fixture.exists("\(Ident.lane3)/\(Ident.card4)/attachments"))
|
||||||
#expect(!fixture.exists("\(Ident.lane1)/attachments"))
|
#expect(!fixture.exists("\(Ident.lane1)/attachments"))
|
||||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments"))
|
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments"))
|
||||||
@@ -387,21 +385,20 @@ struct CreateCardsFromFilesTests {
|
|||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A tombstoned, vanished, or empty destination writes nothing")
|
@Test("A deleted, vanished, or empty destination writes nothing")
|
||||||
func inertDestinations() throws {
|
func inertDestinations() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let sources = try DropSources()
|
let sources = try DropSources()
|
||||||
defer { sources.tearDown() }
|
defer { sources.tearDown() }
|
||||||
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
|
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let shot = try sources.file("shot.png")
|
let shot = try sources.file("shot.png")
|
||||||
|
|
||||||
store.createCards(fromFiles: [shot], inLane: lane3, at: 0) // tombstoned
|
store.createCards(fromFiles: [shot], inLane: lane3, at: 0) // no such lane
|
||||||
store.createCards(fromFiles: [shot], inLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane
|
store.createCards(fromFiles: [shot], inLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane
|
||||||
store.createCards(fromFiles: [], inLane: lane1, at: 0) // nothing dropped
|
store.createCards(fromFiles: [], inLane: lane1, at: 0) // nothing dropped
|
||||||
|
|
||||||
#expect(try fixture.entryNames(Ident.lane3) == ["index.md"])
|
#expect(!fixture.exists(Ident.lane3))
|
||||||
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"])
|
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"])
|
||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,21 +220,21 @@ struct InlineRenameWriteTests {
|
|||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A commit at a card under a tombstoned lane writes nothing — liveness is effective")
|
@Test("A commit at a card that entered the trash writes nothing")
|
||||||
func targetUnderATombstonedLaneWritesNothing() throws {
|
func targetInTheTrashWritesNothing() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
// The lane carries the tombstone; the card's own flag is untouched, and it renders nowhere
|
// "Entering the trash is a vanish from the board; nothing is ever written into a vanished
|
||||||
// regardless (03-board-ui.md collapses the lane to one trash entry).
|
// folder" (04 ▸ Grammar).
|
||||||
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
|
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
let before = try fixture.indexData(".trash/\(Ident.card1)")
|
||||||
|
|
||||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||||
store.transient.updateRenameDraft("Never lands")
|
store.transient.updateRenameDraft("Never lands")
|
||||||
store.commitRename()
|
store.commitRename()
|
||||||
|
|
||||||
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
|
#expect(try fixture.indexData(".trash/\(Ident.card1)") == before)
|
||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,9 +75,9 @@ private func target(
|
|||||||
width: CGFloat = 100,
|
width: CGFloat = 100,
|
||||||
height: CGFloat = 100,
|
height: CGFloat = 100,
|
||||||
kind: SelectionKind = .card,
|
kind: SelectionKind = .card,
|
||||||
side: Liveness = .live
|
container: ItemContainer = .board
|
||||||
) -> MarqueeTarget {
|
) -> MarqueeTarget {
|
||||||
MarqueeTarget(id: id, kind: kind, side: side, frame: CGRect(x: x, y: y, width: width, height: height))
|
MarqueeTarget(id: id, kind: kind, container: container, frame: CGRect(x: x, y: y, width: width, height: height))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A two-by-two grid: `card1` `card3` on the top row, `card2` `card4` beneath them — the smallest
|
/// A two-by-two grid: `card1` `card3` on the top row, `card2` `card4` beneath them — the smallest
|
||||||
@@ -145,7 +145,7 @@ struct NavigationMathTests {
|
|||||||
|
|
||||||
@Test("The predicate is the ⇧-arrow's restriction — the trash side is simply not a candidate")
|
@Test("The predicate is the ⇧-arrow's restriction — the trash side is simply not a candidate")
|
||||||
func predicateRestrictsCandidates() {
|
func predicateRestrictsCandidates() {
|
||||||
let trashed = target(card2, x: 0, y: 100, side: .trashed)
|
let trashed = target(card2, x: 0, y: 100, container: .trash)
|
||||||
let live = target(card3, x: 0, y: 400)
|
let live = target(card3, x: 0, y: 400)
|
||||||
let all = [trashed, live]
|
let all = [trashed, live]
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ struct NavigationMathTests {
|
|||||||
"a plain arrow walks across the boundary"
|
"a plain arrow walks across the boundary"
|
||||||
)
|
)
|
||||||
#expect(
|
#expect(
|
||||||
NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.side == .live }) == card3
|
NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.container == .board }) == card3
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,10 +216,10 @@ struct SuccessorTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let snapshot = try load(fixture)
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
#expect(SelectionGrammar.successor(afterDeleting: [card2], in: snapshot) == card3)
|
#expect(SelectionGrammar.successor(afterDeleting: [card2], snapshot: snapshot) == card3)
|
||||||
#expect(SelectionGrammar.successor(afterDeleting: [card1], in: snapshot) == card2)
|
#expect(SelectionGrammar.successor(afterDeleting: [card1], snapshot: snapshot) == card2)
|
||||||
#expect(
|
#expect(
|
||||||
SelectionGrammar.successor(afterDeleting: [card1, card2], in: snapshot) == card3,
|
SelectionGrammar.successor(afterDeleting: [card1, card2], snapshot: snapshot) == card3,
|
||||||
"a block's successor is the first survivor after its last member"
|
"a block's successor is the first survivor after its last member"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -230,8 +230,8 @@ struct SuccessorTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let snapshot = try load(fixture)
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
#expect(SelectionGrammar.successor(afterDeleting: [card4], in: snapshot) == card3)
|
#expect(SelectionGrammar.successor(afterDeleting: [card4], snapshot: snapshot) == card3)
|
||||||
#expect(SelectionGrammar.successor(afterDeleting: [card3, card4], in: snapshot) == card2)
|
#expect(SelectionGrammar.successor(afterDeleting: [card3, card4], snapshot: snapshot) == card2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A survivor between the members is found forwards first")
|
@Test("A survivor between the members is found forwards first")
|
||||||
@@ -242,7 +242,7 @@ struct SuccessorTests {
|
|||||||
|
|
||||||
// Doomed at positions 0 and 2: forward from the last one finds card4, which is what makes
|
// Doomed at positions 0 and 2: forward from the last one finds card4, which is what makes
|
||||||
// repeated ⌫ keep moving down rather than bouncing back up the lane.
|
// repeated ⌫ keep moving down rather than bouncing back up the lane.
|
||||||
#expect(SelectionGrammar.successor(afterDeleting: [card1, card3], in: snapshot) == card4)
|
#expect(SelectionGrammar.successor(afterDeleting: [card1, card3], snapshot: snapshot) == card4)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("An emptied container selects nothing")
|
@Test("An emptied container selects nothing")
|
||||||
@@ -251,10 +251,10 @@ struct SuccessorTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let snapshot = try load(fixture)
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
#expect(SelectionGrammar.successor(afterDeleting: [card1, card2, card3, card4], in: snapshot) == nil)
|
#expect(SelectionGrammar.successor(afterDeleting: [card1, card2, card3, card4], snapshot: snapshot) == nil)
|
||||||
#expect(SelectionGrammar.successor(afterDeleting: [], in: snapshot) == nil)
|
#expect(SelectionGrammar.successor(afterDeleting: [], snapshot: snapshot) == nil)
|
||||||
#expect(
|
#expect(
|
||||||
SelectionGrammar.successor(afterDeleting: [ItemID(rawValue: "nobody")], in: snapshot) == nil,
|
SelectionGrammar.successor(afterDeleting: [ItemID(rawValue: "nobody")], snapshot: snapshot) == nil,
|
||||||
"ids naming nothing name no container either"
|
"ids naming nothing name no container either"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -267,7 +267,7 @@ struct SuccessorTests {
|
|||||||
|
|
||||||
// card5 is later than card2 in flatten order (lane `order`, then card `order`), so the
|
// card5 is later than card2 in flatten order (lane `order`, then card `order`), so the
|
||||||
// container is lane2 — the same "last member" anchor ⌘N and paste already share.
|
// container is lane2 — the same "last member" anchor ⌘N and paste already share.
|
||||||
#expect(SelectionGrammar.successor(afterDeleting: [card2, card5], in: snapshot) == card6)
|
#expect(SelectionGrammar.successor(afterDeleting: [card2, card5], snapshot: snapshot) == card6)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Lanes follow the same rule in the live lane order")
|
@Test("Lanes follow the same rule in the live lane order")
|
||||||
@@ -276,9 +276,9 @@ struct SuccessorTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let snapshot = try load(fixture)
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
#expect(SelectionGrammar.successor(afterDeleting: [lane1], in: snapshot) == lane2)
|
#expect(SelectionGrammar.successor(afterDeleting: [lane1], snapshot: snapshot) == lane2)
|
||||||
#expect(SelectionGrammar.successor(afterDeleting: [lane3], in: snapshot) == lane2, "the last lane's predecessor")
|
#expect(SelectionGrammar.successor(afterDeleting: [lane3], snapshot: snapshot) == lane2, "the last lane's predecessor")
|
||||||
#expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], in: snapshot) == nil)
|
#expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], snapshot: snapshot) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("⌫ selects the successor immediately, before the reload echoes the tombstone back")
|
@Test("⌫ selects the successor immediately, before the reload echoes the tombstone back")
|
||||||
@@ -287,7 +287,7 @@ struct SuccessorTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.select([card2], liveness: .live)
|
store.select([card2], in: .board)
|
||||||
store.deleteSelection()
|
store.deleteSelection()
|
||||||
|
|
||||||
#expect(store.selection.ids == [card3])
|
#expect(store.selection.ids == [card3])
|
||||||
@@ -306,7 +306,7 @@ struct SuccessorTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.select([card5, card6], liveness: .live)
|
store.select([card5, card6], in: .board)
|
||||||
store.deleteSelection()
|
store.deleteSelection()
|
||||||
|
|
||||||
#expect(store.selection.isEmpty)
|
#expect(store.selection.isEmpty)
|
||||||
@@ -324,13 +324,13 @@ struct SelectionHeadTests {
|
|||||||
func headDefaults() throws {
|
func headDefaults() throws {
|
||||||
let state = TransientBoardState()
|
let state = TransientBoardState()
|
||||||
|
|
||||||
state.select([card1], liveness: .live)
|
state.select([card1], in: .board)
|
||||||
#expect(state.selectionHead == card1)
|
#expect(state.selectionHead == card1)
|
||||||
|
|
||||||
state.select([card1, card2], liveness: .live)
|
state.select([card1, card2], in: .board)
|
||||||
#expect(state.selectionHead == nil, "a set with no gesture behind it names no cursor")
|
#expect(state.selectionHead == nil, "a set with no gesture behind it names no cursor")
|
||||||
|
|
||||||
state.select([card1, card2], liveness: .live, anchor: card1, head: card2)
|
state.select([card1, card2], in: .board, anchor: card1, head: card2)
|
||||||
#expect(state.selectionAnchor == card1)
|
#expect(state.selectionAnchor == card1)
|
||||||
#expect(state.selectionHead == card2, "an explicit head is kept whatever the count")
|
#expect(state.selectionHead == card2, "an explicit head is kept whatever the count")
|
||||||
|
|
||||||
@@ -346,9 +346,9 @@ struct SelectionHeadTests {
|
|||||||
let snapshot = try load(fixture)
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
let outcome = SelectionGrammar.click(
|
let outcome = SelectionGrammar.click(
|
||||||
SelectionTarget(id: card3, kind: .card, side: .live),
|
SelectionTarget(id: card3, kind: .card, container: .board),
|
||||||
modifier: .shift,
|
modifier: .shift,
|
||||||
selection: ItemReferenceSet(ids: [card1], liveness: .live),
|
selection: ItemReferenceSet(ids: [card1], container: .board),
|
||||||
anchor: card1,
|
anchor: card1,
|
||||||
snapshot: snapshot
|
snapshot: snapshot
|
||||||
)
|
)
|
||||||
@@ -364,7 +364,7 @@ struct SelectionHeadTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.select([card1, card2], liveness: .live, anchor: card1, head: card2)
|
store.select([card1, card2], in: .board, anchor: card1, head: card2)
|
||||||
|
|
||||||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
|
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
|
||||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||||
@@ -375,25 +375,16 @@ struct SelectionHeadTests {
|
|||||||
#expect(store.transient.selectionAnchor == card1, "the anchor survived — it is still in the tree")
|
#expect(store.transient.selectionAnchor == card1, "the anchor survived — it is still in the tree")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A liveness flip is a vanish for the head too")
|
@Test("A container crossing is a vanish for the head too")
|
||||||
func resolveDropsALivenessFlippedHead() async throws {
|
func resolveDropsAContainerCrossedHead() async throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.select([card1], liveness: .live)
|
store.select([card1], in: .board)
|
||||||
#expect(store.transient.selectionHead == card1)
|
#expect(store.transient.selectionHead == card1)
|
||||||
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", """
|
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||||||
---
|
|
||||||
schema: 1
|
|
||||||
title: First
|
|
||||||
order: 1024
|
|
||||||
deleted: 2026-03-03T09:00:00Z
|
|
||||||
---
|
|
||||||
First body.
|
|
||||||
|
|
||||||
""")
|
|
||||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||||
await store.awaitQuiescence()
|
await store.awaitQuiescence()
|
||||||
|
|
||||||
@@ -416,7 +407,7 @@ struct SortWriteTests {
|
|||||||
let untouchedFirst = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
let untouchedFirst = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||||
let untouchedFourth = try fixture.indexText("\(Ident.lane1)/\(Ident.card4)")
|
let untouchedFourth = try fixture.indexText("\(Ident.lane1)/\(Ident.card4)")
|
||||||
|
|
||||||
store.select([card3], liveness: .live)
|
store.select([card3], in: .board)
|
||||||
store.sortSelection(.up)
|
store.sortSelection(.up)
|
||||||
|
|
||||||
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card3, card2, card4])
|
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card3, card2, card4])
|
||||||
@@ -435,7 +426,7 @@ struct SortWriteTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.select([card2, card4], liveness: .live)
|
store.select([card2, card4], in: .board)
|
||||||
store.sortSelection(.up)
|
store.sortSelection(.up)
|
||||||
|
|
||||||
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2, card4, card3])
|
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2, card4, card3])
|
||||||
@@ -447,7 +438,7 @@ struct SortWriteTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.select([card1, card2], liveness: .live)
|
store.select([card1, card2], in: .board)
|
||||||
store.sortSelection(.down)
|
store.sortSelection(.down)
|
||||||
|
|
||||||
#expect(try cardOrder(Ident.lane1, in: fixture) == [card3, card1, card2, card4])
|
#expect(try cardOrder(Ident.lane1, in: fixture) == [card3, card1, card2, card4])
|
||||||
@@ -467,7 +458,7 @@ struct SortWriteTests {
|
|||||||
|
|
||||||
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2])
|
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2])
|
||||||
|
|
||||||
store.select([card2], liveness: .live)
|
store.select([card2], in: .board)
|
||||||
store.sortSelection(.up)
|
store.sortSelection(.up)
|
||||||
|
|
||||||
#expect(try cardOrder(Ident.lane1, in: fixture) == [card2, card1])
|
#expect(try cardOrder(Ident.lane1, in: fixture) == [card2, card1])
|
||||||
@@ -482,16 +473,16 @@ struct SortWriteTests {
|
|||||||
|
|
||||||
#expect(store.sortPlan(.up) == nil, "nothing selected")
|
#expect(store.sortPlan(.up) == nil, "nothing selected")
|
||||||
|
|
||||||
store.select([lane1], liveness: .live)
|
store.select([lane1], in: .board)
|
||||||
#expect(store.sortPlan(.up) == nil, "a lane selection — ⌥⌘↑/⌥⌘↓ are inert on lanes")
|
#expect(store.sortPlan(.up) == nil, "a lane selection — ⌥⌘↑/⌥⌘↓ are inert on lanes")
|
||||||
|
|
||||||
store.select([card2, card5], liveness: .live)
|
store.select([card2, card5], in: .board)
|
||||||
#expect(store.sortPlan(.up) == nil, "a card selection spanning lanes — cards never change lanes by ⌘-arrow")
|
#expect(store.sortPlan(.up) == nil, "a card selection spanning lanes — cards never change lanes by ⌘-arrow")
|
||||||
|
|
||||||
store.select([card1], liveness: .trashed)
|
store.select([card1], in: .trash)
|
||||||
#expect(store.sortPlan(.up) == nil, "a tombstoned selection")
|
#expect(store.sortPlan(.up) == nil, "a tombstoned selection")
|
||||||
|
|
||||||
store.select([card1], liveness: .live)
|
store.select([card1], in: .board)
|
||||||
#expect(store.sortPlan(.up) == nil, "already at the top")
|
#expect(store.sortPlan(.up) == nil, "already at the top")
|
||||||
#expect(store.sortPlan(.down) != nil, "but the other direction is live")
|
#expect(store.sortPlan(.down) != nil, "but the other direction is live")
|
||||||
}
|
}
|
||||||
@@ -513,7 +504,7 @@ struct MoveLaneConventionTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
|
let lanes = SelectionGrammar.lanes(in: store.snapshot)
|
||||||
#expect(lanes == [lane1, lane2, lane3])
|
#expect(lanes == [lane1, lane2, lane3])
|
||||||
let from = try #require(lanes.firstIndex(of: lane2))
|
let from = try #require(lanes.firstIndex(of: lane2))
|
||||||
|
|
||||||
@@ -527,7 +518,7 @@ struct MoveLaneConventionTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
|
let lanes = SelectionGrammar.lanes(in: store.snapshot)
|
||||||
let from = try #require(lanes.firstIndex(of: lane1))
|
let from = try #require(lanes.firstIndex(of: lane1))
|
||||||
|
|
||||||
store.moveLane(lane1, toIndex: from + 1)
|
store.moveLane(lane1, toIndex: from + 1)
|
||||||
|
|||||||
@@ -665,9 +665,9 @@ struct LooseFilePasteTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live)
|
harness.store.select([ItemID(rawValue: Ident.card1)], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
target.select([ItemID(rawValue: Ident.lane4)], liveness: .live)
|
target.select([ItemID(rawValue: Ident.lane4)], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
let arrived = try arrivedCard(in: destination)
|
let arrived = try arrivedCard(in: destination)
|
||||||
@@ -693,9 +693,9 @@ struct LooseFilePasteTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live)
|
harness.store.select([ItemID(rawValue: Ident.card1)], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
target.select([ItemID(rawValue: Ident.lane4)], liveness: .live)
|
target.select([ItemID(rawValue: Ident.lane4)], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
let arrived = try arrivedCard(in: destination)
|
let arrived = try arrivedCard(in: destination)
|
||||||
@@ -718,9 +718,9 @@ struct LooseFilePasteTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live)
|
harness.store.select([ItemID(rawValue: Ident.card1)], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
target.select([ItemID(rawValue: Ident.lane4)], liveness: .live)
|
target.select([ItemID(rawValue: Ident.lane4)], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
#expect(harness.fixture.exists("\(cardPath)/notes.txt"))
|
#expect(harness.fixture.exists("\(cardPath)/notes.txt"))
|
||||||
@@ -735,7 +735,7 @@ struct LooseFilePasteTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([ItemID(rawValue: Ident.lane1)], liveness: .live)
|
harness.store.select([ItemID(rawValue: Ident.lane1)], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
@@ -760,7 +760,7 @@ struct LooseFilePasteTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live)
|
harness.store.select([ItemID(rawValue: Ident.card1)], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.stagingSettled()
|
await harness.clipboard.stagingSettled()
|
||||||
// The snapshot goes missing between the copy and the paste — 04's degraded paste.
|
// The snapshot goes missing between the copy and the paste — 04's degraded paste.
|
||||||
@@ -768,7 +768,7 @@ struct LooseFilePasteTests {
|
|||||||
try FileManager.default.removeItem(at: harness.staging.appendingPathComponent(staged))
|
try FileManager.default.removeItem(at: harness.staging.appendingPathComponent(staged))
|
||||||
}
|
}
|
||||||
|
|
||||||
target.select([ItemID(rawValue: Ident.lane4)], liveness: .live)
|
target.select([ItemID(rawValue: Ident.lane4)], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
let arrived = try arrivedCard(in: destination)
|
let arrived = try arrivedCard(in: destination)
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ private func makeBoard() throws -> WriterFixture {
|
|||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
||||||
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Archive"))
|
|
||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,14 +70,14 @@ struct NewCardTargetTests {
|
|||||||
|
|
||||||
// "With a card selected, the new card is created in that card's lane, immediately after it
|
// "With a card selected, the new card is created in that card's lane, immediately after it
|
||||||
// (paste-anchor consistency)."
|
// (paste-anchor consistency)."
|
||||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], liveness: .live))
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], container: .board))
|
||||||
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card1))
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card1))
|
||||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], liveness: .live))
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], container: .board))
|
||||||
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
|
||||||
|
|
||||||
// The last card in a lane is still an anchor here — "after the last card" and "at the
|
// The last card in a lane is still an anchor here — "after the last card" and "at the
|
||||||
// bottom" coincide, and it is the commit that notices (`BoardStore.insertionIndex`).
|
// bottom" coincide, and it is the commit that notices (`BoardStore.insertionIndex`).
|
||||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card2], liveness: .live))
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card2], container: .board))
|
||||||
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +88,7 @@ struct NewCardTargetTests {
|
|||||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
|
||||||
// "With a lane selected, appended at its bottom (Return consistency)."
|
// "With a lane selected, appended at its bottom (Return consistency)."
|
||||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane2], liveness: .live))
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane2], container: .board))
|
||||||
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,25 +106,25 @@ struct NewCardTargetTests {
|
|||||||
#expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
#expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
||||||
#expect(resolve(snapshot, lastActive: ItemID(rawValue: Ident.indexless))
|
#expect(resolve(snapshot, lastActive: ItemID(rawValue: Ident.indexless))
|
||||||
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
||||||
// A *tombstoned* lane is not a target either — it renders nowhere, and the trash is never a
|
// A lane that has been deleted is not a target either — a lane delete is physical, so the
|
||||||
// creation destination.
|
// memory simply names nothing.
|
||||||
#expect(resolve(snapshot, lastActive: lane3)
|
#expect(resolve(snapshot, lastActive: lane3)
|
||||||
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A tombstoned selection never anchors creation — it behaves as nothing selected")
|
@Test("A trash selection never anchors creation — it behaves as nothing selected")
|
||||||
func aTombstonedSelectionNeverAnchors() throws {
|
func aTrashSelectionNeverAnchors() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
|
||||||
// Settled in 04 ▸ The map, on the ⌘N rule's own wording: "a **tombstoned** selection, which
|
// 04 ▸ The map, on the ⌘N rule's own wording: "with nothing selected — or a **trash**
|
||||||
// never anchors creation". The trash-side lane is a real lane on disk with a live sibling
|
// selection, which never anchors creation". A trashed card's board-side lane must not leak
|
||||||
// list — the rule must not let its identity leak in as a target.
|
// in as a target.
|
||||||
let trashed = ItemReferenceSet(ids: [lane3], liveness: .trashed)
|
let inTrash = ItemReferenceSet(ids: [card1], container: .trash)
|
||||||
#expect(resolve(snapshot, selection: trashed, lastActive: lane2)
|
#expect(resolve(snapshot, selection: inTrash, lastActive: lane2)
|
||||||
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
||||||
#expect(resolve(snapshot, selection: trashed)
|
#expect(resolve(snapshot, selection: inTrash)
|
||||||
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,21 +139,22 @@ struct NewCardTargetTests {
|
|||||||
// target — New Card, Return-creation, and Paste with a card payload disable via menu
|
// target — New Card, Return-creation, and Paste with a card payload disable via menu
|
||||||
// validation until a lane exists."
|
// validation until a lane exists."
|
||||||
#expect(resolve(empty) == nil)
|
#expect(resolve(empty) == nil)
|
||||||
#expect(resolve(empty, selection: ItemReferenceSet(ids: [card1], liveness: .live)) == nil)
|
#expect(resolve(empty, selection: ItemReferenceSet(ids: [card1], container: .board)) == nil)
|
||||||
#expect(resolve(empty, lastActive: lane1) == nil)
|
#expect(resolve(empty, lastActive: lane1) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A board whose every lane is tombstoned is a zero-lane board")
|
@Test("A board whose every lane has been deleted is a zero-lane board")
|
||||||
func everyLaneTombstonedIsAlsoZeroLane() throws {
|
func everyLaneDeletedIsAlsoZeroLane() throws {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
try fixture.item("", Item.board)
|
for lane in [Ident.lane1, Ident.lane2, Ident.lane3] where fixture.exists(lane) {
|
||||||
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
|
try FileManager.default.removeItem(at: fixture.url(lane))
|
||||||
|
}
|
||||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
|
||||||
// The lanes are still in the snapshot — the trash renders them — but none is on the board,
|
// "Every lane deleted" is the design's own second reading of the zero-lane case — and a lane
|
||||||
// and "every lane deleted" is the design's own second reading of the zero-lane case.
|
// delete is physical now, so it is literally the same board as the hand-made one above.
|
||||||
#expect(snapshot.lanes.count == 1)
|
#expect(snapshot.lanes.isEmpty)
|
||||||
#expect(resolve(snapshot) == nil)
|
#expect(resolve(snapshot) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,15 +167,15 @@ struct NewCardTargetTests {
|
|||||||
// "A multi-selection anchors at its last member in flatten order (lane `order`, then card
|
// "A multi-selection anchors at its last member in flatten order (lane `order`, then card
|
||||||
// `order`, the multi-drag order)": card3 lives in lane2, which sorts after lane1's pair, so
|
// `order`, the multi-drag order)": card3 lives in lane2, which sorts after lane1's pair, so
|
||||||
// creation follows card3 — the last-active lane never enters into it.
|
// creation follows card3 — the last-active lane never enters into it.
|
||||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card3], liveness: .live), lastActive: lane1)
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card3], container: .board), lastActive: lane1)
|
||||||
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
|
||||||
|
|
||||||
// Within one lane the flatten order is card order: card2 sorts after card1.
|
// Within one lane the flatten order is card order: card2 sorts after card1.
|
||||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], liveness: .live), lastActive: lane2)
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], container: .board), lastActive: lane2)
|
||||||
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
|
||||||
|
|
||||||
// A multi-LANE selection appends to the last selected lane's bottom.
|
// A multi-LANE selection appends to the last selected lane's bottom.
|
||||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1, lane2], liveness: .live))
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1, lane2], container: .board))
|
||||||
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ struct NewCardTargetTests {
|
|||||||
|
|
||||||
// A selection naming something the board does not render — the reload that drops it has not
|
// A selection naming something the board does not render — the reload that drops it has not
|
||||||
// landed yet — must not refuse the creation the user just asked for.
|
// landed yet — must not refuse the creation the user just asked for.
|
||||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], liveness: .live))
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], container: .board))
|
||||||
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ struct PasteFromStagingTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
|
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
|
||||||
@@ -85,9 +85,9 @@ struct PasteFromStagingTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
|
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
|
||||||
@@ -107,9 +107,9 @@ struct PasteFromStagingTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
#expect(try pastedTitles(clipboardLane1, in: harness.fixture) == ["First", "Second"])
|
#expect(try pastedTitles(clipboardLane1, in: harness.fixture) == ["First", "Second"])
|
||||||
@@ -123,13 +123,13 @@ struct PasteFromStagingTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
target.handleWatcherEvent(.treeChanged(.appMediated))
|
target.handleWatcherEvent(.treeChanged(.appMediated))
|
||||||
await target.awaitQuiescence()
|
await target.awaitQuiescence()
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
let ids = try pastedIDs(destinationLane, in: destination)
|
let ids = try pastedIDs(destinationLane, in: destination)
|
||||||
@@ -142,7 +142,7 @@ struct PasteFromStagingTests {
|
|||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.paste(into: harness.store)?.value
|
await harness.clipboard.paste(into: harness.store)?.value
|
||||||
|
|
||||||
@@ -159,15 +159,15 @@ struct PasteFromStagingTests {
|
|||||||
@Suite("Paste ▸ lanes")
|
@Suite("Paste ▸ lanes")
|
||||||
struct PasteLaneTests {
|
struct PasteLaneTests {
|
||||||
|
|
||||||
@Test("A pasted lane copy takes fresh GUIDs throughout and strips tombstoned cards")
|
@Test("A pasted lane copy takes fresh GUIDs throughout and carries exactly its cards")
|
||||||
func laneCopyStripsTombstones() async throws {
|
func laneCopyRemintsThroughout() async throws {
|
||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
let destination = try makeDestination()
|
let destination = try makeDestination()
|
||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardLane1], liveness: .live)
|
harness.store.select([clipboardLane1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
@@ -176,12 +176,11 @@ struct PasteLaneTests {
|
|||||||
let arrived = try #require(model.lanes.last)
|
let arrived = try #require(model.lanes.last)
|
||||||
#expect(arrived.id.rawValue != Ident.lane1)
|
#expect(arrived.id.rawValue != Ident.lane1)
|
||||||
#expect(arrived.title.value == "Todo")
|
#expect(arrived.title.value == "Todo")
|
||||||
// The tombstoned card is gone — "the copy transfers content, and trash isn't content".
|
// "A lane carries exactly its cards — the trash is board-level, so there is nothing
|
||||||
|
// lane-nested to strip or carry" (04 ▸ Drag and drop, resettled 2026-07-28).
|
||||||
#expect(arrived.cards.count == 2)
|
#expect(arrived.cards.count == 2)
|
||||||
#expect(arrived.cards.allSatisfy { !$0.isDeleted })
|
|
||||||
#expect(arrived.cards.map(\.id.rawValue).allSatisfy { $0 != Ident.card1 && $0 != Ident.card2 })
|
#expect(arrived.cards.map(\.id.rawValue).allSatisfy { $0 != Ident.card1 && $0 != Ident.card2 })
|
||||||
// The tombstoned original is still recoverable where it always was.
|
#expect(try pasted(harness.fixture).trash.count == 1, "and the source's trash is untouched")
|
||||||
#expect(try lane(clipboardLane1, in: harness.fixture)?.cards.count == 3)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A lane paste with nothing selected lands at the board's right end")
|
@Test("A lane paste with nothing selected lands at the board's right end")
|
||||||
@@ -192,7 +191,7 @@ struct PasteLaneTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardLane1], liveness: .live)
|
harness.store.select([clipboardLane1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
@@ -208,30 +207,30 @@ struct PasteLaneTests {
|
|||||||
try empty.item("", Item.board)
|
try empty.item("", Item.board)
|
||||||
let target = try BoardStore(rootURL: empty.root)
|
let target = try BoardStore(rootURL: empty.root)
|
||||||
|
|
||||||
harness.store.select([clipboardLane1], liveness: .live)
|
harness.store.select([clipboardLane1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
#expect(try pasted(empty).lanes.compactMap(\.title.value) == ["Todo"])
|
#expect(try pasted(empty).lanes.compactMap(\.title.value) == ["Todo"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A lane cut-move carries its tombstoned cards whole")
|
@Test("A lane cut-move carries its cards whole, identity and all")
|
||||||
func laneCutMoveCarriesTombstones() async throws {
|
func laneCutMoveCarriesItsCards() async throws {
|
||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
let destination = try makeDestination()
|
let destination = try makeDestination()
|
||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardLane1], liveness: .live)
|
harness.store.select([clipboardLane1], in: .board)
|
||||||
harness.clipboard.cut(from: harness.store)
|
harness.clipboard.cut(from: harness.store)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
let model = try pasted(destination)
|
let model = try pasted(destination)
|
||||||
let arrived = try #require(model.lanes.first { $0.id == clipboardLane1 })
|
let arrived = try #require(model.lanes.first { $0.id == clipboardLane1 })
|
||||||
// Identity travelled, and the tombstone landed in the destination's trash.
|
// Identity travelled, and every card came with it — nothing to strip.
|
||||||
#expect(arrived.cards.count == 3)
|
#expect(arrived.cards.count == 2)
|
||||||
#expect(arrived.cards.contains { $0.isDeleted })
|
#expect(model.trash.isEmpty, "the source board's trash is board-level and stays there")
|
||||||
// The lane left the source board entirely.
|
// The lane left the source board entirely.
|
||||||
#expect(try pasted(harness.fixture).lanes.map(\.id) == [clipboardLane2])
|
#expect(try pasted(harness.fixture).lanes.map(\.id) == [clipboardLane2])
|
||||||
}
|
}
|
||||||
@@ -241,7 +240,7 @@ struct PasteLaneTests {
|
|||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
|
|
||||||
harness.store.select([clipboardLane1], liveness: .live)
|
harness.store.select([clipboardLane1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.paste(into: harness.store)?.value
|
await harness.clipboard.paste(into: harness.store)?.value
|
||||||
|
|
||||||
@@ -259,8 +258,8 @@ struct PasteLaneTests {
|
|||||||
@Suite("Paste ▸ from the trash")
|
@Suite("Paste ▸ from the trash")
|
||||||
struct PasteFromTrashTests {
|
struct PasteFromTrashTests {
|
||||||
|
|
||||||
@Test("A card copied out of the trash arrives live")
|
@Test("A card copied out of the trash is an ordinary copy of an ordinary card")
|
||||||
func cardArrivesLive() async throws {
|
func cardCopiesOutOrdinarily() async throws {
|
||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
let destination = try makeDestination()
|
let destination = try makeDestination()
|
||||||
@@ -268,46 +267,39 @@ struct PasteFromTrashTests {
|
|||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.transient.isTrashVisible = true
|
harness.store.transient.isTrashVisible = true
|
||||||
harness.store.select([clipboardCard3], liveness: .trashed)
|
harness.store.select([clipboardCard3], in: .trash)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
|
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
|
||||||
// And the tombstoned original stays in the source trash — copy-out, never a move.
|
// The original stays in the source trash — a copy is a copy (04 ▸ The trash: "⌘C copies a
|
||||||
#expect(try lane(clipboardLane1, in: harness.fixture)?
|
// trash card; a live copy lands wherever pasted, like copying out of Finder's Trash").
|
||||||
.cards.first { $0.id == clipboardCard3 }?.isDeleted == true)
|
#expect(try pasted(harness.fixture).trash.map(\.id) == [clipboardCard3])
|
||||||
|
// And nothing had to be stripped on arrival: a trashed card carries no key at all.
|
||||||
|
let landed = try #require(try lane(destinationLane, in: destination)?.cards.last)
|
||||||
|
#expect(landed.deleted.isMissing)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A lane entry copied out of the trash arrives live, its tombstoned interior cards stripped")
|
/// **Cut in the trash, paste into a lane, is the keyboard-native restore** (04-interactions.md
|
||||||
func laneEntryStripsBothWays() async throws {
|
/// ▸ The trash, resettled 2026-07-28) — "an ordinary folder move", which is exactly what the
|
||||||
let fixture = try WriterFixture()
|
/// armed cut already does.
|
||||||
defer { fixture.tearDown() }
|
@Test("Cut in the trash and paste into a lane is the keyboard restore — the folder moves")
|
||||||
try fixture.item("", Item.board)
|
func cutFromTheTrashIsTheKeyboardRestore() async throws {
|
||||||
// A tombstoned lane holding one plain card and one that carries its own tombstone.
|
let harness = try makeClipboardHarness()
|
||||||
try fixture.item(Ident.lane1, tombstonedItem(order: "1024", title: "Archive"))
|
defer { harness.tearDown() }
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Kept"))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstonedItem(order: "2048", title: "Gone"))
|
|
||||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
|
||||||
|
|
||||||
let harness = try ClipboardHarness(fixture: fixture)
|
|
||||||
defer { try? FileManager.default.removeItem(at: harness.staging) }
|
|
||||||
let destination = try makeDestination()
|
|
||||||
defer { destination.tearDown() }
|
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
|
||||||
|
|
||||||
harness.store.transient.isTrashVisible = true
|
harness.store.transient.isTrashVisible = true
|
||||||
harness.store.select([clipboardLane1], liveness: .trashed)
|
harness.store.select([clipboardCard3], in: .trash)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.cut(from: harness.store)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
harness.store.select([clipboardLane2], in: .board)
|
||||||
|
await harness.clipboard.paste(into: harness.store)?.value
|
||||||
|
|
||||||
let model = try pasted(destination)
|
let model = try pasted(harness.fixture)
|
||||||
let arrived = try #require(model.lanes.last)
|
#expect(model.trash.isEmpty, "the folder left the trash")
|
||||||
#expect(arrived.isDeleted == false)
|
let lane = try #require(model.lanes.first { $0.id == clipboardLane2 })
|
||||||
#expect(arrived.title.value == "Archive")
|
#expect(lane.cards.map(\.id).contains(clipboardCard3), "identity travels — it is a move")
|
||||||
#expect(arrived.cards.count == 1)
|
#expect(harness.store.transient.pendingCut.isEmpty, "the cut was consumed")
|
||||||
#expect(arrived.cards.first?.title.value == "Kept")
|
|
||||||
#expect(arrived.cards.first?.isDeleted == false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,9 +329,9 @@ struct PasteSearchAndStalenessTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
// "First" would be hidden by this query — exactly the card that must not arrive invisible.
|
// "First" would be hidden by this query — exactly the card that must not arrive invisible.
|
||||||
target.searchQuery = "resident"
|
target.searchQuery = "resident"
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
@@ -356,7 +348,7 @@ struct PasteSearchAndStalenessTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardLane2], liveness: .live)
|
harness.store.select([clipboardLane2], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
target.searchQuery = "resident"
|
target.searchQuery = "resident"
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
@@ -373,10 +365,10 @@ struct PasteSearchAndStalenessTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
harness.pasteboard.takeOver()
|
harness.pasteboard.takeOver()
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
target.searchQuery = "resident"
|
target.searchQuery = "resident"
|
||||||
|
|
||||||
// `refresh()` at the front of the paste sees the moved changeCount, so there is no payload
|
// `refresh()` at the front of the paste sees the moved changeCount, so there is no payload
|
||||||
@@ -395,9 +387,9 @@ struct PasteSearchAndStalenessTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
target.searchQuery = "resident"
|
target.searchQuery = "resident"
|
||||||
|
|
||||||
// The gesture passed validation; the takeover lands while the task is still waiting on the
|
// The gesture passed validation; the takeover lands while the task is still waiting on the
|
||||||
@@ -427,9 +419,9 @@ struct PasteCutTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.cut(from: harness.store)
|
harness.clipboard.cut(from: harness.store)
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
// Identity travelled.
|
// Identity travelled.
|
||||||
@@ -450,13 +442,13 @@ struct PasteCutTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.cut(from: harness.store)
|
harness.clipboard.cut(from: harness.store)
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
target.handleWatcherEvent(.treeChanged(.appMediated))
|
target.handleWatcherEvent(.treeChanged(.appMediated))
|
||||||
await target.awaitQuiescence()
|
await target.awaitQuiescence()
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
let ids = try pastedIDs(destinationLane, in: destination)
|
let ids = try pastedIDs(destinationLane, in: destination)
|
||||||
@@ -474,12 +466,12 @@ struct PasteCutTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.cut(from: harness.store)
|
harness.clipboard.cut(from: harness.store)
|
||||||
// The source board closes — its store goes, and with it the cut's arming.
|
// The source board closes — its store goes, and with it the cut's arming.
|
||||||
harness.store.transient.pendingCut = .empty
|
harness.store.transient.pendingCut = .empty
|
||||||
|
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
// A copy: a fresh identity at the destination, and the original still at home.
|
// A copy: a fresh identity at the destination, and the original still at home.
|
||||||
@@ -496,22 +488,21 @@ struct PasteCutTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1, clipboardCard2], liveness: .live)
|
harness.store.select([clipboardCard1, clipboardCard2], in: .board)
|
||||||
harness.clipboard.cut(from: harness.store)
|
harness.clipboard.cut(from: harness.store)
|
||||||
|
|
||||||
// One of the two is tombstoned before the paste: the reload ejects it from the pending cut.
|
// One of the two is deleted before the paste: the reload ejects it from the pending cut.
|
||||||
harness.store.delete([clipboardCard1])
|
harness.store.delete([clipboardCard1])
|
||||||
harness.store.handleWatcherEvent(.treeChanged(.appMediated))
|
harness.store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||||
await harness.store.awaitQuiescence()
|
await harness.store.awaitQuiescence()
|
||||||
#expect(harness.store.transient.pendingCut.ids == [clipboardCard2])
|
#expect(harness.store.transient.pendingCut.ids == [clipboardCard2])
|
||||||
|
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
#expect(try pastedIDs(destinationLane, in: destination) == [Ident.indexless, Ident.card2])
|
#expect(try pastedIDs(destinationLane, in: destination) == [Ident.indexless, Ident.card2])
|
||||||
// The tombstoned one stayed behind, in the source board's trash.
|
// The deleted one stayed behind, in the source board's trash.
|
||||||
#expect(try lane(clipboardLane1, in: harness.fixture)?
|
#expect(try pasted(harness.fixture).trash.map(\.id).contains(clipboardCard1))
|
||||||
.cards.first { $0.id == clipboardCard1 }?.isDeleted == true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A cut emptied down to nothing is simply void — a paste copies instead")
|
@Test("A cut emptied down to nothing is simply void — a paste copies instead")
|
||||||
@@ -522,14 +513,14 @@ struct PasteCutTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.cut(from: harness.store)
|
harness.clipboard.cut(from: harness.store)
|
||||||
harness.store.delete([clipboardCard1])
|
harness.store.delete([clipboardCard1])
|
||||||
harness.store.handleWatcherEvent(.treeChanged(.appMediated))
|
harness.store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||||
await harness.store.awaitQuiescence()
|
await harness.store.awaitQuiescence()
|
||||||
#expect(harness.store.transient.pendingCut.isEmpty)
|
#expect(harness.store.transient.pendingCut.isEmpty)
|
||||||
|
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
// The staged snapshot is still there, so the paste is a copy — content intact.
|
// The staged snapshot is still there, so the paste is a copy — content intact.
|
||||||
@@ -551,7 +542,7 @@ struct PasteFallbackTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.stagingSettled()
|
await harness.clipboard.stagingSettled()
|
||||||
// The snapshot goes — a swept tree, a full disk, an unreadable container.
|
// The snapshot goes — a swept tree, a full disk, an unreadable container.
|
||||||
@@ -560,7 +551,7 @@ struct PasteFallbackTests {
|
|||||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||||
)
|
)
|
||||||
|
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
|
let arrived = try #require(try pastedIDs(destinationLane, in: destination).last)
|
||||||
@@ -585,7 +576,7 @@ struct PasteFallbackTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardCard1], liveness: .live)
|
harness.store.select([clipboardCard1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.stagingSettled()
|
await harness.clipboard.stagingSettled()
|
||||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||||
@@ -593,7 +584,7 @@ struct PasteFallbackTests {
|
|||||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||||
)
|
)
|
||||||
|
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
#expect(target.banners.losses.map(\.message) == ["Pasted 'First' without its 2 attachments"])
|
#expect(target.banners.losses.map(\.message) == ["Pasted 'First' without its 2 attachments"])
|
||||||
@@ -608,7 +599,7 @@ struct PasteFallbackTests {
|
|||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
// `card2` has no attachments, so a fallback loses nothing at all.
|
// `card2` has no attachments, so a fallback loses nothing at all.
|
||||||
harness.store.select([clipboardCard2], liveness: .live)
|
harness.store.select([clipboardCard2], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.stagingSettled()
|
await harness.clipboard.stagingSettled()
|
||||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||||
@@ -616,7 +607,7 @@ struct PasteFallbackTests {
|
|||||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||||
)
|
)
|
||||||
|
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Second"])
|
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Second"])
|
||||||
@@ -631,7 +622,7 @@ struct PasteFallbackTests {
|
|||||||
defer { destination.tearDown() }
|
defer { destination.tearDown() }
|
||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.select([clipboardLane1], liveness: .live)
|
harness.store.select([clipboardLane1], in: .board)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.stagingSettled()
|
await harness.clipboard.stagingSettled()
|
||||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||||
@@ -643,14 +634,14 @@ struct PasteFallbackTests {
|
|||||||
|
|
||||||
let arrived = try #require(try pasted(destination).lanes.last)
|
let arrived = try #require(try pasted(destination).lanes.last)
|
||||||
#expect(arrived.title.value == "Todo")
|
#expect(arrived.title.value == "Todo")
|
||||||
// The two live cards, and not the tombstoned third.
|
// Both of the lane's cards.
|
||||||
#expect(arrived.cards.count == 2)
|
#expect(arrived.cards.count == 2)
|
||||||
#expect(Set(arrived.cards.compactMap(\.title.value)) == ["First", "Second"])
|
#expect(Set(arrived.cards.compactMap(\.title.value)) == ["First", "Second"])
|
||||||
#expect(target.banners.losses.map(\.message) == ["Pasted 'Todo' without its 2 attachments"])
|
#expect(target.banners.losses.map(\.message) == ["Pasted 'Todo' without its 2 attachments"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A trash-sourced fallback still strips `deleted:` at materialization")
|
@Test("A trash-sourced fallback materializes an ordinary card — there is no key to strip")
|
||||||
func trashedFallbackStripsDeleted() async throws {
|
func trashedFallbackIsOrdinary() async throws {
|
||||||
let harness = try makeClipboardHarness()
|
let harness = try makeClipboardHarness()
|
||||||
defer { harness.tearDown() }
|
defer { harness.tearDown() }
|
||||||
let destination = try makeDestination()
|
let destination = try makeDestination()
|
||||||
@@ -658,7 +649,7 @@ struct PasteFallbackTests {
|
|||||||
let target = try BoardStore(rootURL: destination.root)
|
let target = try BoardStore(rootURL: destination.root)
|
||||||
|
|
||||||
harness.store.transient.isTrashVisible = true
|
harness.store.transient.isTrashVisible = true
|
||||||
harness.store.select([clipboardCard3], liveness: .trashed)
|
harness.store.select([clipboardCard3], in: .trash)
|
||||||
harness.clipboard.copy(from: harness.store)
|
harness.clipboard.copy(from: harness.store)
|
||||||
await harness.clipboard.stagingSettled()
|
await harness.clipboard.stagingSettled()
|
||||||
let copyID = try #require(harness.clipboard.payload?.copyID)
|
let copyID = try #require(harness.clipboard.payload?.copyID)
|
||||||
@@ -666,7 +657,7 @@ struct PasteFallbackTests {
|
|||||||
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
at: harness.staging.appendingPathComponent(copyID, isDirectory: true)
|
||||||
)
|
)
|
||||||
|
|
||||||
target.select([destinationLane], liveness: .live)
|
target.select([destinationLane], in: .board)
|
||||||
await harness.clipboard.paste(into: target)?.value
|
await harness.clipboard.paste(into: target)?.value
|
||||||
|
|
||||||
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
|
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Trashed"])
|
||||||
|
|||||||
@@ -423,18 +423,18 @@ struct RawSourceStoreTests {
|
|||||||
func aTombstonedCardIsRefused() throws {
|
func aTombstonedCardIsRefused() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
try BoardWriter.deleteItem(at: fixture.url(cardPath))
|
try fixture.move(cardPath, toTrash: Ident.card1)
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let before = try fixture.indexData(cardPath)
|
let before = try fixture.indexData(".trash/\(Ident.card1)")
|
||||||
let card = ItemID(rawValue: Ident.card1)
|
let card = ItemID(rawValue: Ident.card1)
|
||||||
|
|
||||||
// 05 ▸ Deletion & lifecycle: "An open raw-source buffer discards instead: its Apply writes
|
// 05 ▸ Deletion & lifecycle: "An open raw-source buffer discards instead: its Apply would
|
||||||
// the *whole* pre-tombstone `index.md` and would silently undelete the card — a foreign
|
// write a whole stale `index.md` over the trashed card — a delete is never fought by a stale
|
||||||
// delete is never reverted by a stale buffer." The Edit buffer's flush is the opposite rule,
|
// buffer." The Edit buffer's flush is the opposite rule, deliberately, which is why the two
|
||||||
// deliberately, which is why the two walks differ (`liveItem` vs `cardBodyTarget`).
|
// walks differ (`boardItem` vs `cardBodyTarget`).
|
||||||
#expect(store.readCardSource(inCard: card) == .vanished)
|
#expect(store.readCardSource(inCard: card) == .vanished)
|
||||||
#expect(store.applyCardSource(inCard: card, text: handEditedSource) == .vanished)
|
#expect(store.applyCardSource(inCard: card, text: handEditedSource) == .vanished)
|
||||||
#expect(try fixture.indexData(cardPath) == before)
|
#expect(try fixture.indexData(".trash/\(Ident.card1)") == before)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A card that is not in the board at all is vanished too")
|
@Test("A card that is not in the board at all is vanished too")
|
||||||
|
|||||||
@@ -105,18 +105,8 @@ private func makeTrashBoard() throws -> WriterFixture {
|
|||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
try fixture.item("", Item.board)
|
try fixture.item("", Item.board)
|
||||||
try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane."))
|
try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane."))
|
||||||
try fixture.item(
|
try fixture.item(".trash/\(Ident.card1)", item(order: "1024", title: "Fix login", body: "Auth."))
|
||||||
"\(Ident.lane1)/\(Ident.card1)",
|
try fixture.item(".trash/\(Ident.card2)", item(order: "2048", title: "Polish", body: "Wording."))
|
||||||
tombstoned(order: "1024", title: "Fix login", body: "Auth.", deleted: "2026-03-05T10:00:00Z")
|
|
||||||
)
|
|
||||||
try fixture.item(
|
|
||||||
"\(Ident.lane1)/\(Ident.card2)",
|
|
||||||
tombstoned(order: "2048", title: "Polish", body: "Wording.", deleted: "2026-03-05T06:00:00Z")
|
|
||||||
)
|
|
||||||
try fixture.item(
|
|
||||||
More.laneX,
|
|
||||||
tombstoned(order: "2048", title: "Old login lane", body: "Retired.", deleted: "2026-03-05T08:00:00Z")
|
|
||||||
)
|
|
||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,17 +228,17 @@ struct SearchFilterPredicateTests {
|
|||||||
#expect(!SearchFilter(query: "csv").matches(unrelated))
|
#expect(!SearchFilter(query: "csv").matches(unrelated))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A lane matches by its own title and body — the trash's rows, not the board's lanes")
|
@Test("There is no lane predicate: the filter is a card predicate, end to end")
|
||||||
func lanesMatchByTheirOwnText() throws {
|
func lanesAreNeverFiltered() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let model = try load(fixture)
|
let model = try load(fixture)
|
||||||
let todo = try #require(model.lanes.first { $0.id == lane1 })
|
|
||||||
|
|
||||||
#expect(SearchFilter(query: "todo").matches(todo))
|
// 04 § Search filters *cards*. Under the tombstone model the trash held lane *entries* that
|
||||||
#expect(SearchFilter(query: "inbox").matches(todo))
|
// had to be matched like rows, which is why `matches(_: Lane)` existed; lanes are never
|
||||||
// A lane is not matched through its cards: `card1` says "login", the lane does not.
|
// trashed now, so the overload went with them and every lane is always visible.
|
||||||
#expect(!SearchFilter(query: "login").matches(todo))
|
let visible = SearchFilter(query: "nothing-matches-this").visibleIDs(in: model, container: .board)
|
||||||
|
#expect(visible == Set(model.lanes.map(\.id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("The visible universe keeps every live lane and only the matching cards")
|
@Test("The visible universe keeps every live lane and only the matching cards")
|
||||||
@@ -257,7 +247,7 @@ struct SearchFilterPredicateTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let model = try load(fixture)
|
let model = try load(fixture)
|
||||||
|
|
||||||
let visible = SearchFilter(query: "login").visibleIDs(in: model, on: .live)
|
let visible = SearchFilter(query: "login").visibleIDs(in: model, container: .board)
|
||||||
// Lanes are never hidden by a card query — a lane the filter empties is still a lane on the
|
// Lanes are never hidden by a card query — a lane the filter empties is still a lane on the
|
||||||
// board, so a lane selection survives a query that empties its body.
|
// board, so a lane selection survives a query that empties its body.
|
||||||
#expect(visible.isSuperset(of: [lane1, lane2, lane3]))
|
#expect(visible.isSuperset(of: [lane1, lane2, lane3]))
|
||||||
@@ -277,12 +267,9 @@ struct SearchFilterOrderTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let model = try load(fixture)
|
let model = try load(fixture)
|
||||||
|
|
||||||
#expect(SelectionGrammar.liveCards(in: model) == [card1, card2, card3, card4, card5])
|
#expect(SelectionGrammar.boardCards(in: model) == [card1, card2, card3, card4, card5])
|
||||||
#expect(SelectionGrammar.liveCards(in: model, filter: SearchFilter(query: "login")) == [card1, card3])
|
#expect(SelectionGrammar.boardCards(in: model, filter: SearchFilter(query: "login")) == [card1, card3])
|
||||||
#expect(SelectionGrammar.order(
|
#expect(SelectionGrammar.order(of: .card, in: .board, snapshot: model,
|
||||||
of: .card,
|
|
||||||
on: .live,
|
|
||||||
in: model,
|
|
||||||
filter: SearchFilter(query: "login")
|
filter: SearchFilter(query: "login")
|
||||||
) == [card1, card3])
|
) == [card1, card3])
|
||||||
}
|
}
|
||||||
@@ -295,10 +282,7 @@ struct SearchFilterOrderTests {
|
|||||||
|
|
||||||
// `lane3`'s only card misses the query, and the lane is still in the order: the width
|
// `lane3`'s only card misses the query, and the lane is still in the order: the width
|
||||||
// division is layout, and a `0` badge is the honest report.
|
// division is layout, and a `0` badge is the honest report.
|
||||||
#expect(SelectionGrammar.order(
|
#expect(SelectionGrammar.order(of: .lane, in: .board, snapshot: model,
|
||||||
of: .lane,
|
|
||||||
on: .live,
|
|
||||||
in: model,
|
|
||||||
filter: SearchFilter(query: "login")
|
filter: SearchFilter(query: "login")
|
||||||
) == [lane1, lane2, lane3])
|
) == [lane1, lane2, lane3])
|
||||||
}
|
}
|
||||||
@@ -310,16 +294,11 @@ struct SearchFilterOrderTests {
|
|||||||
let model = try load(fixture)
|
let model = try load(fixture)
|
||||||
|
|
||||||
// Unfiltered, card1 → card3 sweeps card2 up with it.
|
// Unfiltered, card1 → card3 sweeps card2 up with it.
|
||||||
#expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, on: .live, in: model)
|
#expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, in: .board, snapshot: model)
|
||||||
== [card1, card2, card3])
|
== [card1, card2, card3])
|
||||||
|
|
||||||
// Filtered, the span between the same two endpoints is the two that are on screen.
|
// Filtered, the span between the same two endpoints is the two that are on screen.
|
||||||
#expect(SelectionGrammar.range(
|
#expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, in: .board, snapshot: model,
|
||||||
from: card1,
|
|
||||||
to: card3,
|
|
||||||
kind: .card,
|
|
||||||
on: .live,
|
|
||||||
in: model,
|
|
||||||
filter: SearchFilter(query: "login")
|
filter: SearchFilter(query: "login")
|
||||||
) == [card1, card3])
|
) == [card1, card3])
|
||||||
}
|
}
|
||||||
@@ -330,34 +309,31 @@ struct SearchFilterOrderTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let model = try load(fixture)
|
let model = try load(fixture)
|
||||||
|
|
||||||
#expect(SelectionGrammar.range(
|
#expect(SelectionGrammar.range(from: card1, to: card2, kind: .card, in: .board, snapshot: model,
|
||||||
from: card1,
|
|
||||||
to: card2,
|
|
||||||
kind: .card,
|
|
||||||
on: .live,
|
|
||||||
in: model,
|
|
||||||
filter: SearchFilter(query: "login")
|
filter: SearchFilter(query: "login")
|
||||||
) == nil)
|
) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Trash entries filter like any lane — card rows and lane rows alike, by their own text")
|
@Test("Trash cards participate in the filter exactly like any other card")
|
||||||
func trashEntriesNarrow() throws {
|
func trashCardsNarrow() throws {
|
||||||
let fixture = try makeTrashBoard()
|
let fixture = try makeTrashBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let model = try load(fixture)
|
let model = try load(fixture)
|
||||||
|
|
||||||
#expect(SelectionGrammar.trashEntries(of: .card, in: model) == [card1, card2])
|
#expect(SelectionGrammar.trashCards(in: model) == [card1, card2])
|
||||||
#expect(SelectionGrammar.trashEntries(of: .lane, in: model) == [laneX])
|
#expect(SelectionGrammar.trashCards(in: model, filter: SearchFilter(query: "login")) == [card1])
|
||||||
|
// And there is no lane list in the trash at all — "Cards only. Lanes are never trashed".
|
||||||
|
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: model).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
let filter = SearchFilter(query: "login")
|
@Test("The trash's visible universe is its matching cards")
|
||||||
#expect(SelectionGrammar.trashEntries(of: .card, in: model, filter: filter) == [card1])
|
func trashUniverseNarrows() throws {
|
||||||
// The lane row matches on its *own* title, not on the card buried inside it.
|
let fixture = try makeTrashBoard()
|
||||||
#expect(SelectionGrammar.trashEntries(of: .lane, in: model, filter: filter) == [laneX])
|
defer { fixture.tearDown() }
|
||||||
#expect(SelectionGrammar.trashEntries(
|
let model = try load(fixture)
|
||||||
of: .lane,
|
|
||||||
in: model,
|
#expect(SearchFilter(query: "login").visibleIDs(in: model, container: .trash) == [card1])
|
||||||
filter: SearchFilter(query: "polish")
|
#expect(SearchFilter.inactive.visibleIDs(in: model, container: .trash) == [card1, card2])
|
||||||
).isEmpty)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("The delete successor is drawn from what the lane is showing")
|
@Test("The delete successor is drawn from what the lane is showing")
|
||||||
@@ -367,12 +343,12 @@ struct SearchFilterOrderTests {
|
|||||||
let model = try load(fixture)
|
let model = try load(fixture)
|
||||||
|
|
||||||
// Unfiltered, deleting the untitled card lands on its lane's next card.
|
// Unfiltered, deleting the untitled card lands on its lane's next card.
|
||||||
#expect(SelectionGrammar.successor(afterDeleting: [card3], in: model) == card4)
|
#expect(SelectionGrammar.successor(afterDeleting: [card3], snapshot: model) == card4)
|
||||||
// Under `login`, card4 is hidden — and there is nothing else visible in that lane, so the
|
// Under `login`, card4 is hidden — and there is nothing else visible in that lane, so the
|
||||||
// honest answer is nothing rather than a card the query animated out.
|
// honest answer is nothing rather than a card the query animated out.
|
||||||
#expect(SelectionGrammar.successor(
|
#expect(SelectionGrammar.successor(
|
||||||
afterDeleting: [card3],
|
afterDeleting: [card3],
|
||||||
in: model,
|
snapshot: model,
|
||||||
filter: SearchFilter(query: "login")
|
filter: SearchFilter(query: "login")
|
||||||
) == nil)
|
) == nil)
|
||||||
}
|
}
|
||||||
@@ -405,11 +381,11 @@ struct SearchFilterStoreTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.transient.isTrashVisible = true
|
store.transient.isTrashVisible = true
|
||||||
store.select([card2], liveness: .trashed)
|
store.select([card2], in: .trash)
|
||||||
store.selectAll()
|
store.selectAll()
|
||||||
#expect(store.selection.ids == [card1, card2])
|
#expect(store.selection.ids == [card1, card2])
|
||||||
|
|
||||||
store.select([card1], liveness: .trashed)
|
store.select([card1], in: .trash)
|
||||||
store.searchQuery = "login"
|
store.searchQuery = "login"
|
||||||
store.selectAll()
|
store.selectAll()
|
||||||
#expect(store.selection.ids == [card1])
|
#expect(store.selection.ids == [card1])
|
||||||
@@ -421,7 +397,7 @@ struct SearchFilterStoreTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.select([card1, card2], liveness: .live, anchor: card1, head: card2)
|
store.select([card1, card2], in: .board, anchor: card1, head: card2)
|
||||||
store.searchQuery = "login"
|
store.searchQuery = "login"
|
||||||
|
|
||||||
#expect(store.selection.ids == [card1])
|
#expect(store.selection.ids == [card1])
|
||||||
@@ -437,7 +413,7 @@ struct SearchFilterStoreTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.select([lane3], liveness: .live)
|
store.select([lane3], in: .board)
|
||||||
store.searchQuery = "login"
|
store.searchQuery = "login"
|
||||||
#expect(store.selection.ids == [lane3])
|
#expect(store.selection.ids == [lane3])
|
||||||
}
|
}
|
||||||
@@ -449,12 +425,12 @@ struct SearchFilterStoreTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.searchQuery = "login"
|
store.searchQuery = "login"
|
||||||
store.select([card1], liveness: .live)
|
store.select([card1], in: .board)
|
||||||
store.clearSearch()
|
store.clearSearch()
|
||||||
|
|
||||||
#expect(store.searchQuery.isEmpty)
|
#expect(store.searchQuery.isEmpty)
|
||||||
#expect(store.selection.ids == [card1])
|
#expect(store.selection.ids == [card1])
|
||||||
#expect(SelectionGrammar.liveCards(in: store.snapshot, filter: store.searchFilter).count == 5)
|
#expect(SelectionGrammar.boardCards(in: store.snapshot, filter: store.searchFilter).count == 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A reload landing under an active query re-applies the filter to the selection")
|
@Test("A reload landing under an active query re-applies the filter to the selection")
|
||||||
@@ -464,7 +440,7 @@ struct SearchFilterStoreTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.searchQuery = "login"
|
store.searchQuery = "login"
|
||||||
store.select([card1, card3], liveness: .live, anchor: card1, head: card3)
|
store.select([card1, card3], in: .board, anchor: card1, head: card3)
|
||||||
|
|
||||||
// An agent edits the title out of the match. The card is still there — this is not a vanish,
|
// An agent edits the title out of the match. The card is still there — this is not a vanish,
|
||||||
// so only the *filter's* universe can eject it.
|
// so only the *filter's* universe can eject it.
|
||||||
@@ -502,7 +478,7 @@ struct SearchFilterStoreTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.searchQuery = "login"
|
store.searchQuery = "login"
|
||||||
store.select([card1], liveness: .live)
|
store.select([card1], in: .board)
|
||||||
|
|
||||||
store.transient.beginRename(of: card1, currentTitle: "Fix login")
|
store.transient.beginRename(of: card1, currentTitle: "Fix login")
|
||||||
#expect(store.searchQuery == "login")
|
#expect(store.searchQuery == "login")
|
||||||
@@ -526,7 +502,7 @@ struct SearchFilterStoreTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.searchQuery = "login"
|
store.searchQuery = "login"
|
||||||
store.select([card1], liveness: .live)
|
store.select([card1], in: .board)
|
||||||
|
|
||||||
store.transient.beginRename(of: card1, currentTitle: "Fix login")
|
store.transient.beginRename(of: card1, currentTitle: "Fix login")
|
||||||
store.transient.updateRenameDraft("Fix login again")
|
store.transient.updateRenameDraft("Fix login again")
|
||||||
@@ -544,7 +520,7 @@ struct SearchFilterStoreTests {
|
|||||||
|
|
||||||
// `Todo` shows both its cards under this query, so the successor is the visible neighbour.
|
// `Todo` shows both its cards under this query, so the successor is the visible neighbour.
|
||||||
store.searchQuery = "the"
|
store.searchQuery = "the"
|
||||||
store.select([card1], liveness: .live)
|
store.select([card1], in: .board)
|
||||||
store.delete([card1])
|
store.delete([card1])
|
||||||
#expect(store.selection.ids == [card2])
|
#expect(store.selection.ids == [card2])
|
||||||
}
|
}
|
||||||
@@ -572,7 +548,7 @@ struct SearchFilterExemptionTests {
|
|||||||
let filter = SearchFilter(query: "login")
|
let filter = SearchFilter(query: "login")
|
||||||
|
|
||||||
// Every live lane is in the visible universe, `Done` — which holds only a miss — included.
|
// Every live lane is in the visible universe, `Done` — which holds only a miss — included.
|
||||||
let visible = filter.visibleIDs(in: model, on: .live)
|
let visible = filter.visibleIDs(in: model, container: .board)
|
||||||
#expect(visible.isSuperset(of: [lane1, lane2, lane3]))
|
#expect(visible.isSuperset(of: [lane1, lane2, lane3]))
|
||||||
#expect(!visible.contains(card5))
|
#expect(!visible.contains(card5))
|
||||||
|
|
||||||
@@ -623,7 +599,7 @@ struct SearchFilterExemptionTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.searchQuery = "login"
|
store.searchQuery = "login"
|
||||||
store.select([card1], liveness: .live)
|
store.select([card1], in: .board)
|
||||||
store.transient.beginRename(of: card1, currentTitle: "Fix login")
|
store.transient.beginRename(of: card1, currentTitle: "Fix login")
|
||||||
store.transient.updateRenameDraft("Fix login thoroughly")
|
store.transient.updateRenameDraft("Fix login thoroughly")
|
||||||
|
|
||||||
@@ -644,7 +620,7 @@ struct SearchFilterExemptionTests {
|
|||||||
#expect(try card(card1, in: store.snapshot).title.value == "Fix login thoroughly")
|
#expect(try card(card1, in: store.snapshot).title.value == "Fix login thoroughly")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A vanish still discards the editor — the carve-out is the filter's, not liveness's")
|
@Test("A vanish still discards the editor — the carve-out is the filter's, not the container's")
|
||||||
func aVanishStillDiscardsTheEditor() async throws {
|
func aVanishStillDiscardsTheEditor() async throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
@@ -652,10 +628,7 @@ struct SearchFilterExemptionTests {
|
|||||||
|
|
||||||
store.searchQuery = "login"
|
store.searchQuery = "login"
|
||||||
store.transient.beginRename(of: card1, currentTitle: "Fix login")
|
store.transient.beginRename(of: card1, currentTitle: "Fix login")
|
||||||
try fixture.item(
|
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||||||
"\(Ident.lane1)/\(Ident.card1)",
|
|
||||||
tombstoned(order: "1024", title: "Fix login", body: "The auth flow breaks on retry.")
|
|
||||||
)
|
|
||||||
await reload(store)
|
await reload(store)
|
||||||
|
|
||||||
#expect(store.transient.renameEditor == nil)
|
#expect(store.transient.renameEditor == nil)
|
||||||
|
|||||||
@@ -8,40 +8,27 @@ import Testing
|
|||||||
///
|
///
|
||||||
/// The grammar is written as a pure function precisely so it can be tested like one: a click, a
|
/// The grammar is written as a pure function precisely so it can be tested like one: a click, a
|
||||||
/// selection, an anchor and a snapshot in, a selection and an anchor out — no window, no gesture, no
|
/// selection, an anchor and a snapshot in, a selection and an anchor out — no window, no gesture, no
|
||||||
/// modifier flags. The boards underneath are **real loads off real temp trees**, because every rule
|
/// modifier flags. The boards underneath are **real loads off real temp trees**, because the rules
|
||||||
/// here reads `isDeleted`, card ordering, or `TrashModel`'s sort, and a hand-built `BoardModel`
|
/// read card ordering and the trash container, and a hand-built `BoardModel` would let both drift
|
||||||
/// would let all three drift from what the loader actually produces.
|
/// from what the loader actually produces.
|
||||||
|
///
|
||||||
|
/// **Two homogeneity axes, not three** (resettled 2026-07-28): cards XOR lanes, and board XOR trash.
|
||||||
|
/// The third — card entries XOR lane entries *inside* the trash — retired with the lane entries it
|
||||||
|
/// separated, because lanes are never trashed.
|
||||||
///
|
///
|
||||||
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
|
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
|
||||||
|
|
||||||
// MARK: - Fixtures
|
// MARK: - Fixtures
|
||||||
|
|
||||||
/// More literal identities than `Ident` offers: a three-lane range needs five cards, and the trash's
|
/// More literal identities than `Ident` offers: a three-lane range needs five cards.
|
||||||
/// interleaving needs entries whose folder names are distinguishable in the sort's tie-break.
|
|
||||||
private enum More {
|
private enum More {
|
||||||
static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
static let laneX = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
static let card6 = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||||
static let laneY = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String {
|
/// Three lanes and five cards — enough that a flatten-order range crosses two lane boundaries.
|
||||||
"""
|
|
||||||
---
|
|
||||||
schema: 1
|
|
||||||
title: \(title)
|
|
||||||
order: \(order)
|
|
||||||
deleted: \(deleted)
|
|
||||||
---
|
|
||||||
\(title) body.
|
|
||||||
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Three live lanes, five live cards and one tombstoned card — enough that a flatten-order range
|
|
||||||
/// crosses two lane boundaries and has something to *skip* on the way.
|
|
||||||
///
|
///
|
||||||
/// Flatten order of the live cards is `[card1, card2, card3, card5]`; `card4` carries its own
|
/// Flatten order is `[card1, card2, card3, card4, card5]`.
|
||||||
/// `deleted:` and is in none of it.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeLiveBoard() throws -> WriterFixture {
|
private func makeLiveBoard() throws -> WriterFixture {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
@@ -51,27 +38,23 @@ private func makeLiveBoard() throws -> WriterFixture {
|
|||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
||||||
try fixture.item("\(Ident.lane2)/\(Ident.card4)", tombstoned(order: "2048", title: "Fourth"))
|
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fourth"))
|
||||||
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
|
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
|
||||||
try fixture.item("\(Ident.lane3)/\(More.card5)", Item.rich(order: "1024", title: "Fifth"))
|
try fixture.item("\(Ident.lane3)/\(More.card5)", Item.rich(order: "1024", title: "Fifth"))
|
||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A trash whose two row kinds **interleave**, which is the only shape that can prove a range skips.
|
/// A board with one lane and three cards in its `.trash/` — the container the trash-side grammar
|
||||||
///
|
/// walks, in `order` display order (`[card1, card2, card3]`, newest first by ordinary ranks).
|
||||||
/// `TrashModel`'s sort is newest `deleted` first, so the entry order is
|
|
||||||
/// `[card1, laneX, card2, laneY, card3]` — a card range from `card1` to `card3` has two lane rows
|
|
||||||
/// sitting inside its span, and a lane range from `laneX` to `laneY` has a card row inside its own.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeTrashBoard() throws -> WriterFixture {
|
private func makeTrashBoard() throws -> WriterFixture {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
try fixture.item("", Item.board)
|
try fixture.item("", Item.board)
|
||||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First", deleted: "2026-03-05T10:00:00Z"))
|
try fixture.item("\(Ident.lane1)/\(More.card6)", Item.rich(order: "1024", title: "Live"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second", deleted: "2026-03-05T08:00:00Z"))
|
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "256", title: "First"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstoned(order: "3072", title: "Third", deleted: "2026-03-05T06:00:00Z"))
|
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "512", title: "Second"))
|
||||||
try fixture.item(More.laneX, tombstoned(order: "2048", title: "Archive", deleted: "2026-03-05T09:00:00Z"))
|
try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
||||||
try fixture.item(More.laneY, tombstoned(order: "3072", title: "Old", deleted: "2026-03-05T07:00:00Z"))
|
|
||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,19 +66,18 @@ private let card2 = ItemID(rawValue: Ident.card2)
|
|||||||
private let card3 = ItemID(rawValue: Ident.card3)
|
private let card3 = ItemID(rawValue: Ident.card3)
|
||||||
private let card4 = ItemID(rawValue: Ident.card4)
|
private let card4 = ItemID(rawValue: Ident.card4)
|
||||||
private let card5 = ItemID(rawValue: More.card5)
|
private let card5 = ItemID(rawValue: More.card5)
|
||||||
private let laneX = ItemID(rawValue: More.laneX)
|
private let card6 = ItemID(rawValue: More.card6)
|
||||||
private let laneY = ItemID(rawValue: More.laneY)
|
|
||||||
|
|
||||||
private func load(_ fixture: WriterFixture) throws -> BoardModel {
|
private func load(_ fixture: WriterFixture) throws -> BoardModel {
|
||||||
try BoardLoader.load(boardRoot: fixture.root).model
|
try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
}
|
}
|
||||||
|
|
||||||
private func target(_ id: ItemID, _ kind: SelectionKind, _ side: Liveness = .live) -> SelectionTarget {
|
private func target(_ id: ItemID, _ kind: SelectionKind, _ container: ItemContainer = .board) -> SelectionTarget {
|
||||||
SelectionTarget(id: id, kind: kind, side: side)
|
SelectionTarget(id: id, kind: kind, container: container)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func set(_ ids: Set<ItemID>, _ side: Liveness = .live) -> ItemReferenceSet {
|
private func set(_ ids: Set<ItemID>, _ container: ItemContainer = .board) -> ItemReferenceSet {
|
||||||
ItemReferenceSet(ids: ids, liveness: side)
|
ItemReferenceSet(ids: ids, container: container)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One click, with the grammar's own defaults filled in.
|
/// One click, with the grammar's own defaults filled in.
|
||||||
@@ -123,7 +105,7 @@ private func click(
|
|||||||
@Suite("SelectionGrammar ▸ order")
|
@Suite("SelectionGrammar ▸ order")
|
||||||
struct SelectionOrderTests {
|
struct SelectionOrderTests {
|
||||||
|
|
||||||
@Test("Live cards flatten lane order first, then card order — tombstones excluded")
|
@Test("Board cards flatten lane order first, then card order")
|
||||||
func flattenOrder() throws {
|
func flattenOrder() throws {
|
||||||
let fixture = try makeLiveBoard()
|
let fixture = try makeLiveBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
@@ -131,22 +113,21 @@ struct SelectionOrderTests {
|
|||||||
|
|
||||||
// "Lane `order` first, then card `order` (a cross-lane selection flattens left-to-right,
|
// "Lane `order` first, then card `order` (a cross-lane selection flattens left-to-right,
|
||||||
// top-to-bottom)" — the multi-drag order (04-interactions.md ▸ Drag and drop).
|
// top-to-bottom)" — the multi-drag order (04-interactions.md ▸ Drag and drop).
|
||||||
#expect(SelectionGrammar.liveCards(in: snapshot) == [card1, card2, card3, card5])
|
#expect(SelectionGrammar.boardCards(in: snapshot) == [card1, card2, card3, card4, card5])
|
||||||
#expect(SelectionGrammar.liveLanes(in: snapshot) == [lane1, lane2, lane3])
|
#expect(SelectionGrammar.lanes(in: snapshot) == [lane1, lane2, lane3])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Trash order lists are one sort, filtered to one kind")
|
@Test("The trash's list is its cards, in `order`; its lane list is empty by construction")
|
||||||
func trashOrderPerKind() throws {
|
func trashOrder() throws {
|
||||||
let fixture = try makeTrashBoard()
|
let fixture = try makeTrashBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let snapshot = try load(fixture)
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
// The single ordering the quasi-lane shows, newest first.
|
#expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3])
|
||||||
#expect(TrashModel.entries(of: snapshot).map(\.id) == [card1, laneX, card2, laneY, card3])
|
#expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3])
|
||||||
// Each kind's list is that ordering with the other kind's rows dropped — which is exactly
|
// "Cards only. Lanes are never trashed" — so there is no list to walk rather than a rule
|
||||||
// what makes a ⇧-range step over them (04-interactions.md ▸ The trash).
|
// saying not to (03-board-ui.md § Trash).
|
||||||
#expect(SelectionGrammar.trashEntries(of: .card, in: snapshot) == [card1, card2, card3])
|
#expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot).isEmpty)
|
||||||
#expect(SelectionGrammar.trashEntries(of: .lane, in: snapshot) == [laneX, laneY])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A selection's kind is derived from the snapshot, and a ghost selection has none")
|
@Test("A selection's kind is derived from the snapshot, and a ghost selection has none")
|
||||||
@@ -158,10 +139,10 @@ struct SelectionOrderTests {
|
|||||||
#expect(SelectionGrammar.kind(of: set([card1, card3]), in: snapshot) == .card)
|
#expect(SelectionGrammar.kind(of: set([card1, card3]), in: snapshot) == .card)
|
||||||
#expect(SelectionGrammar.kind(of: set([lane2]), in: snapshot) == .lane)
|
#expect(SelectionGrammar.kind(of: set([lane2]), in: snapshot) == .lane)
|
||||||
#expect(SelectionGrammar.kind(of: .empty, in: snapshot) == nil)
|
#expect(SelectionGrammar.kind(of: .empty, in: snapshot) == nil)
|
||||||
// A tombstoned card is on neither side's live list, and the live side is what this set says.
|
// A set claiming a container that does not hold its members answers nothing.
|
||||||
#expect(SelectionGrammar.kind(of: set([card4]), in: snapshot) == nil)
|
#expect(SelectionGrammar.kind(of: set([card1], .trash), in: snapshot) == nil)
|
||||||
// Members that name nothing are ignored; one that names something still answers.
|
// Members that name nothing are ignored; one that names something still answers.
|
||||||
#expect(SelectionGrammar.kind(of: set([card4, card1]), in: snapshot) == .card)
|
#expect(SelectionGrammar.kind(of: set([ItemID(rawValue: Ident.indexless), card1]), in: snapshot) == .card)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,9 +186,9 @@ struct PlainClickTests {
|
|||||||
#expect(card.selection == set([card1]))
|
#expect(card.selection == set([card1]))
|
||||||
#expect(card.anchor == card1)
|
#expect(card.anchor == card1)
|
||||||
|
|
||||||
// Nor does the toggle reach across the boundary: a live click on a trashed sole selection
|
// Nor does the toggle reach across the boundary: a board click on a trash-side sole
|
||||||
// of the same id is a replace.
|
// selection of the same id is a replace.
|
||||||
let crossed = click(target(lane1, .lane), .plain, selection: set([lane1], .trashed), anchor: lane1, in: snapshot, togglesOnRepeat: true)
|
let crossed = click(target(lane1, .lane), .plain, selection: set([lane1], .trash), anchor: lane1, in: snapshot, togglesOnRepeat: true)
|
||||||
#expect(crossed.selection == set([lane1]))
|
#expect(crossed.selection == set([lane1]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,24 +241,19 @@ struct CommandClickTests {
|
|||||||
#expect(ontoCard.anchor == card1)
|
#expect(ontoCard.anchor == card1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("⌘-click across the liveness boundary replaces too")
|
@Test("⌘-click across the container boundary replaces too")
|
||||||
func acrossSideReplaces() throws {
|
func acrossContainerReplaces() throws {
|
||||||
let fixture = try makeTrashBoard()
|
let fixture = try makeTrashBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let snapshot = try load(fixture)
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
// "Selection is homogeneous by liveness … a selection never mixes live and tombstoned"
|
// "A selection never mixes trash cards with board cards — a single container rule replacing
|
||||||
// (04 ▸ The trash). The live lane1 is the only live thing on this board.
|
// the old liveness law" (04 ▸ The trash, resettled 2026-07-28).
|
||||||
let intoTrash = click(target(card1, .card, .trashed), .command, selection: set([lane1]), anchor: lane1, in: snapshot)
|
let intoTrash = click(target(card1, .card, .trash), .command, selection: set([card6]), anchor: card6, in: snapshot)
|
||||||
#expect(intoTrash.selection == set([card1], .trashed))
|
#expect(intoTrash.selection == set([card1], .trash))
|
||||||
|
|
||||||
let backOut = click(target(lane1, .lane), .command, selection: set([card1, card2], .trashed), anchor: card2, in: snapshot)
|
let backOut = click(target(card6, .card), .command, selection: set([card1, card2], .trash), anchor: card2, in: snapshot)
|
||||||
#expect(backOut.selection == set([lane1]))
|
#expect(backOut.selection == set([card6]))
|
||||||
|
|
||||||
// And within the trash, the second axis: card entries XOR lane entries.
|
|
||||||
let ontoLaneEntry = click(target(laneX, .lane, .trashed), .command, selection: set([card1, card2], .trashed), anchor: card2, in: snapshot)
|
|
||||||
#expect(ontoLaneEntry.selection == set([laneX], .trashed))
|
|
||||||
#expect(ontoLaneEntry.anchor == laneX)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("⌘-click with nothing — or nothing real — selected replaces")
|
@Test("⌘-click with nothing — or nothing real — selected replaces")
|
||||||
@@ -292,7 +268,8 @@ struct CommandClickTests {
|
|||||||
|
|
||||||
// A selection whose members all name nothing the board renders counts as empty: a ⌘-click
|
// A selection whose members all name nothing the board renders counts as empty: 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.
|
||||||
let fromGhost = click(target(card1, .card), .command, selection: set([card4]), anchor: card4, in: snapshot)
|
let ghost = ItemID(rawValue: Ident.indexless)
|
||||||
|
let fromGhost = click(target(card1, .card), .command, selection: set([ghost]), anchor: ghost, in: snapshot)
|
||||||
#expect(fromGhost.selection == set([card1]))
|
#expect(fromGhost.selection == set([card1]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,18 +280,16 @@ struct CommandClickTests {
|
|||||||
@Suite("SelectionGrammar ▸ ⇧-click")
|
@Suite("SelectionGrammar ▸ ⇧-click")
|
||||||
struct ShiftClickTests {
|
struct ShiftClickTests {
|
||||||
|
|
||||||
@Test("A ⇧-range spans the flatten order across lanes, skipping tombstones, and leaves the anchor put")
|
@Test("A ⇧-range spans the flatten order across lanes and leaves the anchor put")
|
||||||
func rangeAcrossLanes() throws {
|
func rangeAcrossLanes() throws {
|
||||||
let fixture = try makeLiveBoard()
|
let fixture = try makeLiveBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let snapshot = try load(fixture)
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
let outcome = click(target(card5, .card), .shift, selection: set([card1]), anchor: card1, in: snapshot)
|
let outcome = click(target(card5, .card), .shift, selection: set([card1]), anchor: card1, in: snapshot)
|
||||||
#expect(outcome.selection == set([card1, card2, card3, card5]))
|
#expect(outcome.selection == set([card1, card2, card3, card4, card5]))
|
||||||
// Finder-list style: the anchor is unchanged, so successive ⇧-clicks sweep from one origin.
|
// Finder-list style: the anchor is unchanged, so successive ⇧-clicks sweep from one origin.
|
||||||
#expect(outcome.anchor == card1)
|
#expect(outcome.anchor == card1)
|
||||||
// card4 is tombstoned and in no order list, so no range can pick it up.
|
|
||||||
#expect(!outcome.selection.ids.contains(card4))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Direction does not matter — the range is the span between anchor and target")
|
@Test("Direction does not matter — the range is the span between anchor and target")
|
||||||
@@ -324,7 +299,7 @@ struct ShiftClickTests {
|
|||||||
let snapshot = try load(fixture)
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
let backwards = click(target(card1, .card), .shift, selection: set([card5]), anchor: card5, in: snapshot)
|
let backwards = click(target(card1, .card), .shift, selection: set([card5]), anchor: card5, in: snapshot)
|
||||||
#expect(backwards.selection == set([card1, card2, card3, card5]))
|
#expect(backwards.selection == set([card1, card2, card3, card4, card5]))
|
||||||
#expect(backwards.anchor == card5)
|
#expect(backwards.anchor == card5)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,8 +325,9 @@ struct ShiftClickTests {
|
|||||||
#expect(noAnchor.selection == set([card3]))
|
#expect(noAnchor.selection == set([card3]))
|
||||||
#expect(noAnchor.anchor == card3)
|
#expect(noAnchor.anchor == card3)
|
||||||
|
|
||||||
// An anchor that names nothing the board renders (card4 is tombstoned).
|
// An anchor that names nothing the board renders.
|
||||||
let vanished = click(target(card3, .card), .shift, selection: set([card1]), anchor: card4, in: snapshot)
|
let ghost = ItemID(rawValue: Ident.indexless)
|
||||||
|
let vanished = click(target(card3, .card), .shift, selection: set([card1]), anchor: ghost, in: snapshot)
|
||||||
#expect(vanished.selection == set([card3]))
|
#expect(vanished.selection == set([card3]))
|
||||||
#expect(vanished.anchor == card3)
|
#expect(vanished.anchor == card3)
|
||||||
|
|
||||||
@@ -361,32 +337,35 @@ struct ShiftClickTests {
|
|||||||
#expect(acrossKind.selection == set([card3]))
|
#expect(acrossKind.selection == set([card3]))
|
||||||
#expect(acrossKind.anchor == card3)
|
#expect(acrossKind.anchor == card3)
|
||||||
|
|
||||||
// Same for a side crossing: the live card list holds no trash row.
|
// Same for a container crossing: the board's card list holds no trash card.
|
||||||
let acrossSide = click(target(card3, .card, .trashed), .shift, selection: set([card1]), anchor: card1, in: snapshot)
|
let acrossContainer = click(target(card3, .card, .trash), .shift, selection: set([card1]), anchor: card1, in: snapshot)
|
||||||
#expect(acrossSide.selection == set([card3], .trashed))
|
#expect(acrossContainer.selection == set([card3], .trash))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A trash card range steps over the lane entries inside its span")
|
@Test("A trash range walks the column's own order")
|
||||||
func trashCardRangeSkipsLaneEntries() throws {
|
func trashRangeWalksTheColumn() throws {
|
||||||
let fixture = try makeTrashBoard()
|
let fixture = try makeTrashBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let snapshot = try load(fixture)
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
// Sorted order is [card1, laneX, card2, laneY, card3]; a card range collects card rows only.
|
// The column's order is `[card1, card2, card3]`; a range between the ends takes all three,
|
||||||
let outcome = click(target(card3, .card, .trashed), .shift, selection: set([card1], .trashed), anchor: card1, in: snapshot)
|
// with no kind to step over — lanes are never trashed.
|
||||||
#expect(outcome.selection == set([card1, card2, card3], .trashed))
|
let outcome = click(target(card3, .card, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
|
||||||
|
#expect(outcome.selection == set([card1, card2, card3], .trash))
|
||||||
#expect(outcome.anchor == card1)
|
#expect(outcome.anchor == card1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A trash lane range steps over the card entries inside its span")
|
@Test("A range never crosses the container boundary")
|
||||||
func trashLaneRangeSkipsCardEntries() throws {
|
func trashRangeStopsAtTheBoundary() throws {
|
||||||
let fixture = try makeTrashBoard()
|
let fixture = try makeTrashBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let snapshot = try load(fixture)
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
let outcome = click(target(laneY, .lane, .trashed), .shift, selection: set([laneX], .trashed), anchor: laneX, in: snapshot)
|
// `card6` is on the board and `card1` is in the trash: no single order list holds both, so
|
||||||
#expect(outcome.selection == set([laneX, laneY], .trashed))
|
// the anchor is not findable and the ⇧-click degrades to a plain one.
|
||||||
#expect(outcome.anchor == laneX)
|
let outcome = click(target(card1, .card, .trash), .shift, selection: set([card6]), anchor: card6, in: snapshot)
|
||||||
|
#expect(outcome.selection == set([card1], .trash))
|
||||||
|
#expect(outcome.anchor == card1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,53 +374,46 @@ struct ShiftClickTests {
|
|||||||
@Suite("MarqueeMath")
|
@Suite("MarqueeMath")
|
||||||
struct MarqueeMathTests {
|
struct MarqueeMathTests {
|
||||||
|
|
||||||
private static func card(_ id: ItemID, _ y: CGFloat, side: Liveness = .live) -> MarqueeTarget {
|
private static func card(_ id: ItemID, _ y: CGFloat, container: ItemContainer = .board) -> MarqueeTarget {
|
||||||
MarqueeTarget(id: id, kind: .card, side: side, frame: CGRect(x: 0, y: y, width: 100, height: 40))
|
MarqueeTarget(id: id, kind: .card, container: container, frame: CGRect(x: 0, y: y, width: 100, height: 40))
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func laneEntry(_ id: ItemID, _ y: CGFloat) -> MarqueeTarget {
|
@Test("On the board side the band takes intersecting cards, and only cards")
|
||||||
MarqueeTarget(id: id, kind: .lane, side: .trashed, frame: CGRect(x: 0, y: y, width: 100, height: 40))
|
func boardSideTakesCards() {
|
||||||
}
|
|
||||||
|
|
||||||
@Test("On the live side the band takes intersecting cards, and only cards")
|
|
||||||
func liveSideTakesCards() {
|
|
||||||
let targets = [
|
let targets = [
|
||||||
Self.card(card1, 0),
|
Self.card(card1, 0),
|
||||||
Self.card(card2, 100),
|
Self.card(card2, 100),
|
||||||
// A lane registered by mistake is still never swept: "click-drag rubber-bands across
|
// A lane registered by mistake is still never swept: "click-drag rubber-bands across
|
||||||
// lanes" (04-interactions.md § Selection) — across, not over.
|
// lanes" (04-interactions.md § Selection) — across, not over.
|
||||||
MarqueeTarget(id: lane1, kind: .lane, side: .live, frame: CGRect(x: 0, y: 0, width: 200, height: 400)),
|
MarqueeTarget(id: lane1, kind: .lane, container: .board, frame: CGRect(x: 0, y: 0, width: 200, height: 400)),
|
||||||
// A trash row cannot be reached by a band that began on the board.
|
// A trash row cannot be reached by a band that began on the board.
|
||||||
Self.card(card3, 10, side: .trashed)
|
Self.card(card3, 10, container: .trash)
|
||||||
]
|
]
|
||||||
let ids = MarqueeMath.selection(rect: CGRect(x: 0, y: 0, width: 50, height: 120), targets: targets, side: .live)
|
let ids = MarqueeMath.selection(rect: CGRect(x: 0, y: 0, width: 50, height: 120), targets: targets, in: .board)
|
||||||
#expect(ids == [card1, card2])
|
#expect(ids == [card1, card2])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("On the trash side the topmost intersecting row's kind wins")
|
/// **There is no kind rule any more.** The tombstone model interleaved card rows and lane rows
|
||||||
func trashSideIsHomogeneousByKind() {
|
/// in one column, so the band needed a topmost-wins tie-break to stay homogeneous by kind; lanes
|
||||||
// Interleaved rows, the trash's own shape: card, lane, card.
|
/// are never trashed now, so both containers hold cards and one line serves both.
|
||||||
|
@Test("On the trash side the band takes the trash's cards, and stays on its own side")
|
||||||
|
func trashSideTakesItsOwnCards() {
|
||||||
let targets = [
|
let targets = [
|
||||||
Self.card(card1, 0, side: .trashed),
|
Self.card(card1, 0, container: .trash),
|
||||||
Self.laneEntry(laneX, 50),
|
Self.card(card2, 100, container: .trash),
|
||||||
Self.card(card2, 100, side: .trashed)
|
Self.card(card3, 50)
|
||||||
]
|
]
|
||||||
let all = CGRect(x: 0, y: 0, width: 50, height: 200)
|
let all = CGRect(x: 0, y: 0, width: 50, height: 200)
|
||||||
|
|
||||||
// Begun on a card row: the lane row between the two cards is stepped over, exactly as a
|
#expect(MarqueeMath.selection(rect: all, targets: targets, in: .trash) == [card1, card2])
|
||||||
// ⇧-range does (04-interactions.md ▸ The trash).
|
#expect(MarqueeMath.selection(rect: all, targets: targets, in: .board) == [card3])
|
||||||
#expect(MarqueeMath.selection(rect: all, targets: targets, side: .trashed) == [card1, card2])
|
|
||||||
|
|
||||||
// Begun below it, so the lane row is topmost: only lane entries come back.
|
|
||||||
let lower = CGRect(x: 0, y: 60, width: 50, height: 200)
|
|
||||||
#expect(MarqueeMath.selection(rect: lower, targets: targets, side: .trashed) == [laneX])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A band touching nothing selects nothing")
|
@Test("A band touching nothing selects nothing")
|
||||||
func emptyBand() {
|
func emptyBand() {
|
||||||
let targets = [Self.card(card1, 0), Self.card(card2, 100)]
|
let targets = [Self.card(card1, 0), Self.card(card2, 100)]
|
||||||
#expect(MarqueeMath.selection(rect: CGRect(x: 500, y: 500, width: 10, height: 10), targets: targets, side: .live).isEmpty)
|
#expect(MarqueeMath.selection(rect: CGRect(x: 500, y: 500, width: 10, height: 10), targets: targets, in: .board).isEmpty)
|
||||||
#expect(MarqueeMath.selection(rect: .zero, targets: [], side: .trashed).isEmpty)
|
#expect(MarqueeMath.selection(rect: .zero, targets: [], in: .trash).isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,15 +435,15 @@ struct SelectionAnchorTests {
|
|||||||
func anchorDefaults() {
|
func anchorDefaults() {
|
||||||
let state = TransientBoardState()
|
let state = TransientBoardState()
|
||||||
|
|
||||||
state.select([card1], liveness: .live)
|
state.select([card1], in: .board)
|
||||||
#expect(state.selectionAnchor == card1)
|
#expect(state.selectionAnchor == card1)
|
||||||
|
|
||||||
// "A marquee and wholesale selections pass no anchor deliberately."
|
// "A marquee and wholesale selections pass no anchor deliberately."
|
||||||
state.select([card1, card2], liveness: .live)
|
state.select([card1, card2], in: .board)
|
||||||
#expect(state.selectionAnchor == nil)
|
#expect(state.selectionAnchor == nil)
|
||||||
|
|
||||||
// An explicit anchor wins over the default in both directions.
|
// An explicit anchor wins over the default in both directions.
|
||||||
state.select([card1, card2, card3], liveness: .live, anchor: card2)
|
state.select([card1, card2, card3], in: .board, anchor: card2)
|
||||||
#expect(state.selectionAnchor == card2)
|
#expect(state.selectionAnchor == card2)
|
||||||
|
|
||||||
state.clearSelection()
|
state.clearSelection()
|
||||||
@@ -484,7 +456,7 @@ struct SelectionAnchorTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.select([card1, card2], liveness: .live, anchor: card1)
|
store.select([card1, card2], in: .board, anchor: card1)
|
||||||
// A survivor of the same reload proves the rule is about the anchor, not about reloading.
|
// A survivor of the same reload proves the rule is about the anchor, not about reloading.
|
||||||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
|
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
|
||||||
await reload(store)
|
await reload(store)
|
||||||
@@ -497,17 +469,18 @@ struct SelectionAnchorTests {
|
|||||||
#expect(store.transient.selectionAnchor == nil)
|
#expect(store.transient.selectionAnchor == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A liveness flip is a vanish for the anchor too")
|
@Test("A container crossing is a vanish for the anchor too")
|
||||||
func flippedAnchorIsDropped() async throws {
|
func crossedAnchorIsDropped() async throws {
|
||||||
let fixture = try makeLiveBoard()
|
let fixture = try makeLiveBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.select([card1, card2], liveness: .live, anchor: card1)
|
store.select([card1, card2], in: .board, anchor: card1)
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
|
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||||||
await reload(store)
|
await reload(store)
|
||||||
|
|
||||||
// "A flip is a vanish from its side of the boundary" (02-architecture.md's reload rule).
|
// "A container crossing is a vanish for this purpose" (02-architecture.md's reload rule,
|
||||||
|
// resettled 2026-07-28).
|
||||||
#expect(store.selection.ids == [card2])
|
#expect(store.selection.ids == [card2])
|
||||||
#expect(store.transient.selectionAnchor == nil)
|
#expect(store.transient.selectionAnchor == nil)
|
||||||
}
|
}
|
||||||
@@ -520,7 +493,7 @@ struct SelectionAnchorTests {
|
|||||||
let state = TransientBoardState()
|
let state = TransientBoardState()
|
||||||
|
|
||||||
// A ⌘-click that toggled the anchor's own row out leaves the anchor standing.
|
// A ⌘-click that toggled the anchor's own row out leaves the anchor standing.
|
||||||
state.select([card2, card3], liveness: .live, anchor: card1)
|
state.select([card2, card3], in: .board, anchor: card1)
|
||||||
#expect(state.selectionAnchor == card1)
|
#expect(state.selectionAnchor == card1)
|
||||||
let outcome = click(target(card3, .card), .shift, selection: state.selection, anchor: state.selectionAnchor, in: snapshot)
|
let outcome = click(target(card3, .card), .shift, selection: state.selection, anchor: state.selectionAnchor, in: snapshot)
|
||||||
#expect(outcome.selection == set([card1, card2, card3]))
|
#expect(outcome.selection == set([card1, card2, card3]))
|
||||||
@@ -533,17 +506,17 @@ struct SelectionAnchorTests {
|
|||||||
@Suite("BoardStore ▸ Select All")
|
@Suite("BoardStore ▸ Select All")
|
||||||
struct SelectAllTests {
|
struct SelectAllTests {
|
||||||
|
|
||||||
@Test("Select All takes every rendered card — tombstones excluded, lanes never")
|
@Test("Select All takes every rendered card, and never a lane")
|
||||||
func liveBranch() throws {
|
func liveBranch() throws {
|
||||||
let fixture = try makeLiveBoard()
|
let fixture = try makeLiveBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.select([lane2], liveness: .live)
|
store.select([lane2], in: .board)
|
||||||
store.selectAll()
|
store.selectAll()
|
||||||
|
|
||||||
// "All visible cards on the board" (04-interactions.md ▸ The map).
|
// "All visible cards on the board" (04-interactions.md ▸ The map).
|
||||||
#expect(store.selection == set([card1, card2, card3, card5]))
|
#expect(store.selection == set([card1, card2, card3, card4, card5]))
|
||||||
// The lane the anchor named is not in the new set, so the anchor goes with it.
|
// The lane the anchor named is not in the new set, so the anchor goes with it.
|
||||||
#expect(store.transient.selectionAnchor == nil)
|
#expect(store.transient.selectionAnchor == nil)
|
||||||
}
|
}
|
||||||
@@ -554,25 +527,23 @@ struct SelectAllTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.select([card3], liveness: .live)
|
store.select([card3], in: .board)
|
||||||
store.selectAll()
|
store.selectAll()
|
||||||
#expect(store.transient.selectionAnchor == card3)
|
#expect(store.transient.selectionAnchor == card3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("On the trash side Select All stays within the selection's kind")
|
@Test("On the trash side Select All takes every visible trash card")
|
||||||
func trashBranchIsHomogeneousByKind() throws {
|
func trashBranchTakesTheColumn() throws {
|
||||||
let fixture = try makeTrashBoard()
|
let fixture = try makeTrashBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
store.transient.isTrashVisible = true
|
store.transient.isTrashVisible = true
|
||||||
|
|
||||||
store.select([card2], liveness: .trashed)
|
// "With the trash visible and a non-empty trash selection, Select All selects all visible
|
||||||
|
// trash cards" (04 ▸ The map, resettled 2026-07-28). There is no kind clause left to honour.
|
||||||
|
store.select([card2], in: .trash)
|
||||||
store.selectAll()
|
store.selectAll()
|
||||||
#expect(store.selection == set([card1, card2, card3], .trashed))
|
#expect(store.selection == set([card1, card2, card3], .trash))
|
||||||
|
|
||||||
store.select([laneX], liveness: .trashed)
|
|
||||||
store.selectAll()
|
|
||||||
#expect(store.selection == set([laneX, laneY], .trashed))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board")
|
@Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board")
|
||||||
@@ -582,20 +553,20 @@ struct SelectAllTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
// Hidden — the column is invisible to every gesture (04 ▸ The trash).
|
// Hidden — the column is invisible to every gesture (04 ▸ The trash).
|
||||||
store.select([card1], liveness: .trashed)
|
store.select([card1], in: .trash)
|
||||||
store.selectAll()
|
store.selectAll()
|
||||||
#expect(store.selection.liveness == .live)
|
#expect(store.selection.container == .board)
|
||||||
|
|
||||||
// Shown, but nothing tombstoned is selected.
|
// Shown, but nothing in the trash is selected.
|
||||||
store.transient.isTrashVisible = true
|
store.transient.isTrashVisible = true
|
||||||
store.clearSelection()
|
store.clearSelection()
|
||||||
store.selectAll()
|
store.selectAll()
|
||||||
#expect(store.selection.liveness == .live)
|
#expect(store.selection.container == .board)
|
||||||
|
|
||||||
// Shown, trashed side, but the ids name no row: a guess would be worse than the board.
|
// Shown, trash side, but the ids name no card there: a guess would be worse than the board.
|
||||||
store.select([card5], liveness: .trashed)
|
store.select([card5], in: .trash)
|
||||||
store.selectAll()
|
store.selectAll()
|
||||||
#expect(store.selection.liveness == .live)
|
#expect(store.selection.container == .board)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Select All on a board with no rendered cards clears rather than selecting an empty set")
|
@Test("Select All on a board with no rendered cards clears rather than selecting an empty set")
|
||||||
@@ -606,7 +577,7 @@ struct SelectAllTests {
|
|||||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.select([lane1], liveness: .live)
|
store.select([lane1], in: .board)
|
||||||
store.selectAll()
|
store.selectAll()
|
||||||
#expect(store.selection.isEmpty)
|
#expect(store.selection.isEmpty)
|
||||||
#expect(store.transient.selectionAnchor == nil)
|
#expect(store.transient.selectionAnchor == nil)
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ struct StyleEditorSessionTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
store.transient.beginStyleEditor(for: .items([card1, card2]))
|
store.transient.beginStyleEditor(for: .items([card1, card2]))
|
||||||
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second"))
|
try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2)
|
||||||
await reload(store)
|
await reload(store)
|
||||||
|
|
||||||
let session = try #require(store.transient.styleEditor)
|
let session = try #require(store.transient.styleEditor)
|
||||||
@@ -126,7 +126,7 @@ struct StyleEditorSessionTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
store.transient.beginStyleEditor(for: .items([card3]))
|
store.transient.beginStyleEditor(for: .items([card3]))
|
||||||
|
|
||||||
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
|
||||||
await reload(store)
|
await reload(store)
|
||||||
|
|
||||||
// The card's own flag never changed — its lane's did. Effective liveness is ancestor-walked,
|
// The card's own flag never changed — its lane's did. Effective liveness is ancestor-walked,
|
||||||
@@ -141,7 +141,7 @@ struct StyleEditorSessionTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
store.transient.beginStyleEditor(for: .items([card1]))
|
store.transient.beginStyleEditor(for: .items([card1]))
|
||||||
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
|
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||||||
await reload(store)
|
await reload(store)
|
||||||
|
|
||||||
#expect(store.transient.styleEditor?.target != .board)
|
#expect(store.transient.styleEditor?.target != .board)
|
||||||
@@ -155,8 +155,8 @@ struct StyleEditorSessionTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
store.transient.beginStyleEditor(for: .board)
|
store.transient.beginStyleEditor(for: .board)
|
||||||
|
|
||||||
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
|
||||||
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
|
||||||
await reload(store)
|
await reload(store)
|
||||||
|
|
||||||
#expect(store.transient.styleEditor?.target == .board)
|
#expect(store.transient.styleEditor?.target == .board)
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ private func makeBoard() throws -> WriterFixture {
|
|||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", styled(order: "1024", title: "First", keys: ["background: fern", "iconColor: chalk"]))
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", styled(order: "1024", title: "First", keys: ["background: fern", "iconColor: chalk"]))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second"))
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second"))
|
||||||
try fixture.item(Ident.lane2, styled(order: "2048", title: "Doing", keys: ["background: chalk", "icon: tray"]))
|
try fixture.item(Ident.lane2, styled(order: "2048", title: "Doing", keys: ["background: chalk", "icon: tray"]))
|
||||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third", keys: ["deleted: 2026-03-03T09:00:00Z"]))
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third"))
|
||||||
try fixture.item(Ident.lane3, Item.uneditable)
|
try fixture.item(Ident.lane3, Item.uneditable)
|
||||||
try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone", keys: ["deleted: 2026-03-03T09:00:00Z"]))
|
try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone"))
|
||||||
try fixture.item("\(Ident.lane4)/\(Ident.card4)", styled(order: "1024", title: "Hidden"))
|
try fixture.item("\(Ident.lane4)/\(Ident.card4)", styled(order: "1024", title: "Hidden"))
|
||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
@@ -256,26 +256,27 @@ struct StyleWriteTests {
|
|||||||
#expect(lane(lane1, in: model)?.background.isMissing == true)
|
#expect(lane(lane1, in: model)?.background.isMissing == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Vanished, tombstoned and hidden targets are skipped silently")
|
@Test("Vanished and trashed targets are skipped silently")
|
||||||
func skipsTargetsThatRenderNowhere() throws {
|
func skipsTargetsThatRenderNowhere() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.move("\(Ident.lane2)/\(Ident.card3)", toTrash: Ident.card3)
|
||||||
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane4))
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let log = BracketLog()
|
let log = BracketLog()
|
||||||
log.attach(to: store)
|
log.attach(to: store)
|
||||||
let tombstoned = try fixture.indexData("\(Ident.lane2)/\(Ident.card3)")
|
let trashedBytes = try fixture.indexData(".trash/\(Ident.card3)")
|
||||||
let hidden = try fixture.indexData("\(Ident.lane4)/\(Ident.card4)")
|
|
||||||
|
|
||||||
// An id that names nothing, a tombstoned card, and a live card under a tombstoned lane —
|
// An id that names nothing, and a card that has been moved to the trash — "everything
|
||||||
// "nothing is ever written into a vanished folder", ancestor walk included.
|
// edit-shaped is disabled on trash selections" (04 ▸ The trash), and "nothing is ever
|
||||||
|
// written into a vanished folder".
|
||||||
store.applyStyle(
|
store.applyStyle(
|
||||||
to: .items([ItemID(rawValue: Ident.indexless), card3, card4]),
|
to: .items([ItemID(rawValue: Ident.indexless), card3, card4]),
|
||||||
background: .set("obsidian")
|
background: .set("obsidian")
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(log.begins == 0)
|
#expect(log.begins == 0)
|
||||||
#expect(try fixture.indexData("\(Ident.lane2)/\(Ident.card3)") == tombstoned)
|
#expect(try fixture.indexData(".trash/\(Ident.card3)") == trashedBytes)
|
||||||
#expect(try fixture.indexData("\(Ident.lane4)/\(Ident.card4)") == hidden)
|
|
||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,13 +315,13 @@ struct StyleWriteTests {
|
|||||||
|
|
||||||
// MARK: Subjects and levels
|
// MARK: Subjects and levels
|
||||||
|
|
||||||
@Test("Subjects are the live targets in display order, with their current values")
|
@Test("Subjects are the board's targets in display order, with their current values")
|
||||||
func subjectsAreLiveTargetsInDisplayOrder() throws {
|
func subjectsAreBoardTargetsInDisplayOrder() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
let subjects = store.styleSubjects(of: .items([card2, card1, card3, lane1]))
|
let subjects = store.styleSubjects(of: .items([card2, card1, lane1]))
|
||||||
#expect(subjects.map(\.id) == [lane1, card1, card2], "lane first, then its cards top to bottom")
|
#expect(subjects.map(\.id) == [lane1, card1, card2], "lane first, then its cards top to bottom")
|
||||||
#expect(subjects.map(\.background) == [.missing, .valid("fern"), .missing])
|
#expect(subjects.map(\.background) == [.missing, .valid("fern"), .missing])
|
||||||
#expect(subjects.last?.folder.lastPathComponent == Ident.card2)
|
#expect(subjects.last?.folder.lastPathComponent == Ident.card2)
|
||||||
|
|||||||
@@ -328,19 +328,19 @@ struct StoreToggleTaskMarkerTests {
|
|||||||
#expect(try fixture.indexData(cardPath) == before)
|
#expect(try fixture.indexData(cardPath) == before)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A tombstoned card's checkbox does not write")
|
@Test("A trashed card's checkbox does not write")
|
||||||
func aTombstonedCardWritesNothing() throws {
|
func aTrashedCardWritesNothing() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
try BoardWriter.deleteItem(at: fixture.url(cardPath))
|
try fixture.move(cardPath, toTrash: Ident.card1)
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let before = try body(of: fixture, cardPath)
|
let before = try body(of: fixture, ".trash/\(Ident.card1)")
|
||||||
let offsets = markerOffsets(in: checklistBody)
|
let offsets = markerOffsets(in: checklistBody)
|
||||||
|
|
||||||
// Effective liveness, ancestor-walked (`BoardStore.liveItem`): a card in the trash renders
|
// `BoardStore.boardItem` is the board container and only it: a trashed card does not open,
|
||||||
// nowhere, so nothing may write through a preview of it.
|
// so nothing may write through a preview of one (03 ▸ Trash's no-editing rule).
|
||||||
store.toggleTaskMarker(inCard: ItemID(rawValue: Ident.card1), bodyOffset: offsets[0], checked: false)
|
store.toggleTaskMarker(inCard: ItemID(rawValue: Ident.card1), bodyOffset: offsets[0], checked: false)
|
||||||
|
|
||||||
#expect(try body(of: fixture, cardPath) == before)
|
#expect(try body(of: fixture, ".trash/\(Ident.card1)") == before)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,9 +76,9 @@ struct TransientBoardStateTests {
|
|||||||
// Overlapping but different: card1 is selected and cut, card2 is selected and dragged,
|
// Overlapping but different: card1 is selected and cut, card2 is selected and dragged,
|
||||||
// card3 is dragged and cut. Whatever happens to one member, two of the three sets are
|
// card3 is dragged and cut. Whatever happens to one member, two of the three sets are
|
||||||
// always the control.
|
// always the control.
|
||||||
store.transient.select([card1, card2], liveness: .live)
|
store.transient.select([card1, card2], in: .board)
|
||||||
store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], liveness: .live)
|
store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], container: .board)
|
||||||
store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], liveness: .live)
|
store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], container: .board)
|
||||||
|
|
||||||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
await reload(store)
|
await reload(store)
|
||||||
@@ -98,7 +98,7 @@ struct TransientBoardStateTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
let seeded = ItemReferenceSet(ids: [card1, card2], liveness: .live)
|
let seeded = ItemReferenceSet(ids: [card1, card2], container: .board)
|
||||||
|
|
||||||
// Direction one — the live search filter (04-interactions.md § Search): card1's title and
|
// Direction one — the live search filter (04-interactions.md § Search): card1's title and
|
||||||
// body both miss the query, so it is not in the visible set the predicate produced, and
|
// body both miss the query, so it is not in the visible set the predicate produced, and
|
||||||
@@ -116,74 +116,70 @@ struct TransientBoardStateTests {
|
|||||||
#expect(filtered == resolved, "one primitive, two universes — the two rules are one rule")
|
#expect(filtered == resolved, "one primitive, two universes — the two rules are one rule")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Tombstoning a lane ejects its cards from every referencing set — liveness is effective")
|
@Test("A container crossing is a vanish for every item-referencing set")
|
||||||
func effectiveLivenessEjectsFromEverySet() async throws {
|
func aContainerCrossingEjectsFromEverySet() async throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.transient.select([card1, card2], liveness: .live)
|
store.transient.select([card1, card2], in: .board)
|
||||||
store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], liveness: .live)
|
store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], container: .board)
|
||||||
|
|
||||||
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
|
// A foreign writer moves card1 and card2 into the board's trash — the move a delete is.
|
||||||
|
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||||||
|
try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2)
|
||||||
await reload(store)
|
await reload(store)
|
||||||
|
|
||||||
// The cards' own flags never changed, but their lane's did — and liveness is ancestor-walked
|
// 02-architecture.md § Live-reload resilience, resettled 2026-07-28: "re-resolution matches
|
||||||
// (02, settled): they render nowhere once 03 collapses the lane to a single trash entry, and
|
// UUID *and* container side … a foreign move that trashes a selected board card ejects it
|
||||||
// nothing invisible may stay selected or pending-cut.
|
// from the selection (and from the pending cut)".
|
||||||
let survivor = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }
|
let trashedIDs = Set(store.snapshot.trash.map(\.id))
|
||||||
#expect(survivor?.isDeleted == false, "the card's own flag is untouched")
|
#expect(trashedIDs == Set([card1, card2]))
|
||||||
#expect(store.transient.selection.ids.isEmpty)
|
#expect(store.transient.selection.ids.isEmpty)
|
||||||
#expect(store.transient.pendingCut.ids == [card3], "card3's lane is untouched, so card3 stays cut")
|
#expect(store.transient.pendingCut.ids == [card3], "card3 never moved, so card3 stays cut")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A card with its own deleted: under a tombstoned lane is in neither universe")
|
@Test("A restore ejects a trash-side set the same way, in the other direction")
|
||||||
func ownFlaggedCardUnderATombstonedLaneIsInNeitherUniverse() async throws {
|
func restoringEjectsATrashSideSet() async throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.move("\(Ident.lane2)/\(Ident.card3)", toTrash: Ident.card3)
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
// Everything that can point at an item, all aimed at card3 — and on the *trashed* side, as
|
// Everything that can point at an item, all aimed at the trashed card.
|
||||||
// if the user had clicked its trash row a moment before its lane went too.
|
store.transient.select([card3], in: .trash, anchor: card3, head: card3)
|
||||||
store.transient.select([card3], liveness: .trashed, anchor: card3, head: card3)
|
store.transient.dragMembers = ItemReferenceSet(ids: [card3], container: .trash)
|
||||||
store.transient.dragMembers = ItemReferenceSet(ids: [card3], liveness: .trashed)
|
store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .trash)
|
||||||
store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .trashed)
|
|
||||||
store.transient.beginRename(of: card3, currentTitle: "Third")
|
store.transient.beginRename(of: card3, currentTitle: "Third")
|
||||||
|
#expect(store.snapshot.trash.map(\.id) == [card3])
|
||||||
|
|
||||||
// Both flags at once: the card carries its own `deleted:` *and* an agent tombstones its lane.
|
// An agent moves it back out — a container crossing the other way.
|
||||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", tombstoned(order: "1024", title: "Third"))
|
try fixture.move(".trash/\(Ident.card3)", toLane: Ident.lane2, card: Ident.card3)
|
||||||
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
|
|
||||||
await reload(store)
|
await reload(store)
|
||||||
|
|
||||||
let lane = try #require(store.snapshot.lanes.first { $0.id == lane2 })
|
#expect(store.snapshot.trash.isEmpty)
|
||||||
#expect(lane.isDeleted)
|
#expect(!ItemContainer.trash.ids(in: store.snapshot).contains(card3))
|
||||||
#expect(lane.cards.first { $0.id == card3 }?.isDeleted == true, "the card's own flag is on disk")
|
#expect(ItemContainer.board.ids(in: store.snapshot).contains(card3),
|
||||||
|
"presence is the whole test — the card is simply on the other side now")
|
||||||
|
|
||||||
// The lane's single entry subsumes it (03-board-ui.md's absolute ancestor walk), so it has
|
|
||||||
// no row — and "the trashed side has exactly one definition: the set with trash rows"
|
|
||||||
// (02-architecture.md, settled). No row, no membership, on either side.
|
|
||||||
#expect(!TrashModel.entries(of: store.snapshot).map(\.id).contains(card3))
|
|
||||||
#expect(!ItemReferenceSet.idUniverse(of: store.snapshot, on: .trashed).contains(card3))
|
|
||||||
#expect(!ItemReferenceSet.idUniverse(of: store.snapshot, on: .live).contains(card3))
|
|
||||||
|
|
||||||
// So every set holding it is ejected — from the trashed side here, and from the live side
|
|
||||||
// for the same reason, which the value function says directly since a set has one side.
|
|
||||||
#expect(store.transient.selection.ids.isEmpty)
|
#expect(store.transient.selection.ids.isEmpty)
|
||||||
#expect(store.transient.dragMembers.ids.isEmpty)
|
#expect(store.transient.dragMembers.ids.isEmpty)
|
||||||
#expect(store.transient.pendingCut.ids.isEmpty)
|
#expect(store.transient.pendingCut.ids.isEmpty)
|
||||||
#expect(ItemReferenceSet(ids: [card3], liveness: .live).resolved(against: store.snapshot).isEmpty)
|
|
||||||
|
|
||||||
// And no cursor or editor survives on it: an anchor that ranges from somewhere the board
|
// And no cursor survives on it: an anchor that ranges from a container the selection has left
|
||||||
// draws nowhere would be a range the user cannot see the origin of.
|
// would be a range the user cannot see the origin of.
|
||||||
#expect(store.transient.selectionAnchor == nil)
|
#expect(store.transient.selectionAnchor == nil)
|
||||||
#expect(store.transient.selectionHead == nil)
|
#expect(store.transient.selectionHead == nil)
|
||||||
#expect(store.transient.renameEditor == nil)
|
|
||||||
|
|
||||||
// Menu validation agrees, which is the point of the sets and the commands reading one rule:
|
// The rename editor tracks the *board* container, so a card arriving back on the board keeps
|
||||||
// neither ⌘⌫ twin offers to act on it.
|
// its editor — "a foreign move mid-rename is invisible" (04 ▸ Grammar). It is the departure
|
||||||
let stale = ItemReferenceSet(ids: [card3], liveness: .trashed)
|
// into the trash that discards it, which the delete tests cover.
|
||||||
#expect(!TrashModel.canActOnTrash(selection: stale, in: store.snapshot))
|
#expect(store.transient.renameEditor?.targetID == card3)
|
||||||
#expect(!TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], liveness: .live),
|
|
||||||
|
// Menu validation agrees with the sets, which is the point of both reading one rule.
|
||||||
|
let stale = ItemReferenceSet(ids: [card3], container: .trash)
|
||||||
|
#expect(!TrashModel.canDelete(selection: stale, in: store.snapshot))
|
||||||
|
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], container: .board),
|
||||||
in: store.snapshot))
|
in: store.snapshot))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,9 +189,9 @@ struct TransientBoardStateTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.transient.select([card1, lane1], liveness: .live)
|
store.transient.select([card1, lane1], in: .board)
|
||||||
store.transient.dragMembers = ItemReferenceSet(ids: [card2], liveness: .live)
|
store.transient.dragMembers = ItemReferenceSet(ids: [card2], container: .board)
|
||||||
store.transient.pendingCut = ItemReferenceSet(ids: [card3], liveness: .live)
|
store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .board)
|
||||||
store.transient.beginPlaceholder(inLane: lane2)
|
store.transient.beginPlaceholder(inLane: lane2)
|
||||||
|
|
||||||
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
|
||||||
@@ -238,13 +234,12 @@ struct TransientBoardStateTests {
|
|||||||
store.transient.beginPlaceholder(inLane: lane2)
|
store.transient.beginPlaceholder(inLane: lane2)
|
||||||
store.transient.updateDraft("Half a title")
|
store.transient.updateDraft("Half a title")
|
||||||
|
|
||||||
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
|
||||||
await reload(store)
|
await reload(store)
|
||||||
|
|
||||||
// Still in the snapshot — the trash renders its single entry — but the lane the editor was
|
// A lane delete is physical (03-board-ui.md § Trash), so "the placeholder's lane vanished in
|
||||||
// sitting in is not on the board any more, which is the same vanish as far as an overlay
|
// the reload" is literally the whole test.
|
||||||
// anchored to it is concerned.
|
#expect(store.snapshot.lanes.first { $0.id == lane2 } == nil)
|
||||||
#expect(store.snapshot.lanes.first { $0.id == lane2 }?.isDeleted == true)
|
|
||||||
#expect(store.transient.newCardPlaceholder == nil)
|
#expect(store.transient.newCardPlaceholder == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,37 +371,36 @@ struct TransientBoardStateTests {
|
|||||||
#expect(store.transient.renameEditor == nil)
|
#expect(store.transient.renameEditor == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A rename whose target is tombstoned is discarded — a liveness flip is a vanish")
|
@Test("A rename whose target enters the trash is discarded — a container crossing is a vanish")
|
||||||
func renameDiscardedWhenItsTargetIsTombstoned() async throws {
|
func renameDiscardedWhenItsTargetIsTrashed() async throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||||
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
|
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||||||
await reload(store)
|
await reload(store)
|
||||||
|
|
||||||
#expect(store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }?.isDeleted == true)
|
// 04 ▸ Grammar: "a target that is trashed, deleted, or gone at commit time discards the
|
||||||
|
// editor and its keystrokes silently — entering the trash is a vanish from the board".
|
||||||
|
#expect(store.snapshot.trash.map(\.id) == [card1])
|
||||||
#expect(store.transient.renameEditor == nil)
|
#expect(store.transient.renameEditor == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A rename under a tombstoned lane is discarded too — liveness is effective")
|
@Test("A rename whose lane is deleted is discarded too — the card went with it")
|
||||||
func renameDiscardedWhenItsLaneIsTombstoned() async throws {
|
func renameDiscardedWhenItsLaneIsDeleted() async throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||||
|
|
||||||
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
|
||||||
await reload(store)
|
await reload(store)
|
||||||
|
|
||||||
// The card's own flag never changed; its lane's did. The ancestor walk is absolute — the
|
// A lane delete is physical, so the card is simply not in the snapshot — no ancestor walk.
|
||||||
// card renders nowhere, so the editor sitting on it has no target
|
#expect(store.snapshot.lanes.first { $0.id == lane1 } == nil)
|
||||||
// (`CardWindowHost.cardWindowFate`'s rule, applied to the third inline editor).
|
|
||||||
let card = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }
|
|
||||||
#expect(card?.isDeleted == false)
|
|
||||||
#expect(store.transient.renameEditor == nil)
|
#expect(store.transient.renameEditor == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,17 +451,17 @@ struct TransientBoardStateTests {
|
|||||||
|
|
||||||
#expect(store.transient.lastActiveLaneID == nil, "a fresh board has no history to remember")
|
#expect(store.transient.lastActiveLaneID == nil, "a fresh board has no history to remember")
|
||||||
|
|
||||||
store.select([lane2], liveness: .live)
|
store.select([lane2], in: .board)
|
||||||
#expect(store.transient.lastActiveLaneID == lane2)
|
#expect(store.transient.lastActiveLaneID == lane2)
|
||||||
|
|
||||||
// A *card* selection is its lane holding selection too — 04's "the lane that most recently
|
// A *card* selection is its lane holding selection too — 04's "the lane that most recently
|
||||||
// held selection or a creation".
|
// held selection or a creation".
|
||||||
store.select([card1], liveness: .live)
|
store.select([card1], in: .board)
|
||||||
#expect(store.transient.lastActiveLaneID == lane1)
|
#expect(store.transient.lastActiveLaneID == lane1)
|
||||||
|
|
||||||
// A cross-lane selection names no single lane, so it leaves the memory alone rather than
|
// A cross-lane selection names no single lane, so it leaves the memory alone rather than
|
||||||
// guessing at one of the two.
|
// guessing at one of the two.
|
||||||
store.select([card1, card3], liveness: .live)
|
store.select([card1, card3], in: .board)
|
||||||
#expect(store.transient.lastActiveLaneID == lane1)
|
#expect(store.transient.lastActiveLaneID == lane1)
|
||||||
|
|
||||||
// Deselecting does not un-happen where the user was working: ⌘N with nothing selected is
|
// Deselecting does not un-happen where the user was working: ⌘N with nothing selected is
|
||||||
@@ -485,11 +479,11 @@ struct TransientBoardStateTests {
|
|||||||
store.transient.beginPlaceholder(inLane: lane2)
|
store.transient.beginPlaceholder(inLane: lane2)
|
||||||
#expect(store.transient.lastActiveLaneID == lane2)
|
#expect(store.transient.lastActiveLaneID == lane2)
|
||||||
|
|
||||||
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
|
try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
|
||||||
await reload(store)
|
await reload(store)
|
||||||
|
|
||||||
// A lane that renders nowhere is no target at all; `NewCardTarget` then falls through to
|
// A lane that is gone is no target at all; `NewCardTarget` then falls through to the first
|
||||||
// the first lane rather than proposing the trash.
|
// lane rather than proposing the trash.
|
||||||
#expect(store.transient.lastActiveLaneID == nil)
|
#expect(store.transient.lastActiveLaneID == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+273
-385
@@ -2,476 +2,364 @@ import Foundation
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Kanban
|
@testable import Kanban
|
||||||
|
|
||||||
/// `TrashModel` is the trash quasi-lane's whole heart, and it is a pure function — so this suite is
|
/// What is left of the trash as a *model* once the trash became a folder — 03-board-ui.md § Trash,
|
||||||
/// the executable form of 03-board-ui.md § Trash ▸ Contents: the deterministic sort, the absolute
|
/// resettled 2026-07-28 (the materialized trash).
|
||||||
/// ancestor walk, and what a lane entry's card count actually counts.
|
///
|
||||||
|
/// **The suite is much smaller than the tombstone model's was, and that is the finding.** The rows
|
||||||
|
/// no longer need deriving — the trash's contents *are* `snapshot.trash`, parsed by the same card
|
||||||
|
/// parse the lanes use and already in `order` display order — so the deterministic timestamp sort,
|
||||||
|
/// the absolute ancestor walk, and the returning-card count all went with the entries they described.
|
||||||
|
/// What remains is what the *commands* need: the two purge confirmations' phrasing, and the menu
|
||||||
|
/// validation that stages Delete by place. Plus `ItemPath`, the location vocabulary that replaced
|
||||||
|
/// `TrashModel.paths`.
|
||||||
///
|
///
|
||||||
/// The snapshots are **loaded from real temp boards** rather than hand-built, for the reason every
|
/// The snapshots are **loaded from real temp boards** rather than hand-built, for the reason every
|
||||||
/// other model suite here does it: the interesting inputs are `FieldValue<Date>` shapes — a valid
|
/// other model suite here does it: only the loader produces `.trash` the way production does.
|
||||||
/// stamp, an unparseable one, an absent key — and only the loader produces those the way production
|
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||||||
/// does. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
|
||||||
|
|
||||||
// MARK: - Fixtures
|
// MARK: - Fixtures
|
||||||
|
|
||||||
/// A few more literal identities than `Ident` offers: the sort tests need enough rows to prove an
|
/// A few more literal identities than `Ident` offers — folder name is the display tie-break, so the
|
||||||
/// *ordering* rather than a comparison, and folder name is the tie-break, so the names matter.
|
/// names matter.
|
||||||
private enum More {
|
private enum More {
|
||||||
static let laneA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
static let laneA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
static let laneB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
|
||||||
static let cardD = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
|
static let cardD = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
|
||||||
static let cardE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"
|
static let cardE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"
|
||||||
static let cardF = "ffffffff-ffff-4fff-8fff-ffffffffffff"
|
static let cardF = "ffffffff-ffff-4fff-8fff-ffffffffffff"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An item with a `deleted:` key — `stamp` goes in verbatim, so a test can write an unparseable one.
|
private func card(order: String, title: String) -> String {
|
||||||
private func tombstoned(order: String, title: String, deleted: String) -> String {
|
"---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n"
|
||||||
"""
|
|
||||||
---
|
|
||||||
schema: 1
|
|
||||||
title: \(title)
|
|
||||||
order: \(order)
|
|
||||||
deleted: \(deleted)
|
|
||||||
---
|
|
||||||
\(title) body.
|
|
||||||
|
|
||||||
"""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func live(order: String, title: String) -> String {
|
private func untitled(order: String) -> String {
|
||||||
"---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n"
|
"---\nschema: 1\norder: \(order)\n---\nNo title.\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func load(_ fixture: WriterFixture) throws -> BoardModel {
|
private func load(_ fixture: WriterFixture) throws -> BoardModel {
|
||||||
try BoardLoader.load(boardRoot: fixture.root).model
|
try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ids(_ entries: [TrashEntry]) -> [String] {
|
/// One lane with two cards, and three cards in the board's trash.
|
||||||
entries.map(\.id.rawValue)
|
@MainActor
|
||||||
}
|
private func makeBoard() throws -> WriterFixture {
|
||||||
|
|
||||||
// MARK: - The sort
|
|
||||||
|
|
||||||
@Suite("TrashModel ▸ sort")
|
|
||||||
struct TrashModelSortTests {
|
|
||||||
|
|
||||||
@Test("Dated entries sort newest first, and lane entries interleave by their own stamp")
|
|
||||||
func newestFirstWithLanesInterleaved() throws {
|
|
||||||
let fixture = try WriterFixture()
|
|
||||||
defer { fixture.tearDown() }
|
|
||||||
try fixture.item("", Item.board)
|
|
||||||
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
|
|
||||||
tombstoned(order: "1024", title: "Oldest card", deleted: "2026-03-01T09:00:00Z"))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
|
|
||||||
tombstoned(order: "2048", title: "Newest card", deleted: "2026-03-03T09:00:00Z"))
|
|
||||||
// A tombstoned lane whose own stamp falls between the two cards': the sort is one ordering
|
|
||||||
// over both kinds, not cards-then-lanes.
|
|
||||||
try fixture.item(Ident.lane2,
|
|
||||||
tombstoned(order: "2048", title: "Doing", deleted: "2026-03-02T09:00:00Z"))
|
|
||||||
|
|
||||||
let entries = TrashModel.entries(of: try load(fixture))
|
|
||||||
#expect(ids(entries) == [Ident.card2, Ident.lane2, Ident.card1])
|
|
||||||
#expect(entries[1].isLaneEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Ties on the second break by folder name, ascending")
|
|
||||||
func tiesBreakByFolderName() throws {
|
|
||||||
let fixture = try WriterFixture()
|
|
||||||
defer { fixture.tearDown() }
|
|
||||||
try fixture.item("", Item.board)
|
|
||||||
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
|
||||||
// One multi-card ⌫ stamps one second onto N cards. The three are written in an order that
|
|
||||||
// is neither their folder-name order nor their `order` order, so only the rule can produce
|
|
||||||
// the expectation below.
|
|
||||||
let stamp = "2026-03-03T09:00:00Z"
|
|
||||||
try fixture.item("\(Ident.lane1)/\(More.cardF)", tombstoned(order: "1024", title: "F", deleted: stamp))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "2048", title: "5", deleted: stamp))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(More.cardD)", tombstoned(order: "3072", title: "D", deleted: stamp))
|
|
||||||
|
|
||||||
let entries = TrashModel.entries(of: try load(fixture))
|
|
||||||
#expect(ids(entries) == [Ident.card1, More.cardD, More.cardF])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("An unparseable stamp sorts oldest — after every dated entry, however old")
|
|
||||||
func unparseableSortsOldest() throws {
|
|
||||||
let fixture = try WriterFixture()
|
|
||||||
defer { fixture.tearDown() }
|
|
||||||
try fixture.item("", Item.board)
|
|
||||||
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
|
|
||||||
tombstoned(order: "1024", title: "Corrupt", deleted: "whenever"))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
|
|
||||||
tombstoned(order: "2048", title: "Ancient", deleted: "1999-01-01T00:00:00Z"))
|
|
||||||
|
|
||||||
let model = try load(fixture)
|
|
||||||
// The premise: the key is present but has no date reading — still a tombstone (presence,
|
|
||||||
// not validity), and still with no position in time.
|
|
||||||
let corrupt = try #require(model.lanes.first?.cards.first { $0.id.rawValue == Ident.card1 })
|
|
||||||
#expect(corrupt.isDeleted)
|
|
||||||
#expect(corrupt.deleted.value == nil)
|
|
||||||
|
|
||||||
// A corrupt stamp must not outrank a fresh deletion for the trash's most prominent rows —
|
|
||||||
// and here it does not even outrank a 1999 one.
|
|
||||||
#expect(ids(TrashModel.entries(of: model)) == [Ident.card2, Ident.card1])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Undated entries order by folder name among themselves, lanes included")
|
|
||||||
func undatedOrderByFolderName() throws {
|
|
||||||
let fixture = try WriterFixture()
|
|
||||||
defer { fixture.tearDown() }
|
|
||||||
try fixture.item("", Item.board)
|
|
||||||
try fixture.item(More.laneB, tombstoned(order: "3072", title: "B lane", deleted: "not-a-date"))
|
|
||||||
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(More.cardE)", tombstoned(order: "1024", title: "E", deleted: "corrupt"))
|
|
||||||
try fixture.item(More.laneA, tombstoned(order: "2048", title: "A lane", deleted: "later"))
|
|
||||||
|
|
||||||
// `a… < b… < e…`: one folder-name ordering across both kinds, exactly as the dated half has
|
|
||||||
// one date ordering across both kinds.
|
|
||||||
#expect(ids(TrashModel.entries(of: try load(fixture))) == [More.laneA, More.laneB, More.cardE])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - The ancestor walk and the returning count
|
|
||||||
|
|
||||||
@Suite("TrashModel ▸ contents")
|
|
||||||
struct TrashModelContentsTests {
|
|
||||||
|
|
||||||
/// The board every test below reads: one live lane holding a tombstoned card and a live one, and
|
|
||||||
/// one tombstoned lane holding a live card, a card with its own flag, and another live card.
|
|
||||||
private func makeBoard() throws -> WriterFixture {
|
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
try fixture.item("", Item.board)
|
try fixture.item("", Item.board)
|
||||||
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
|
try fixture.item("\(More.laneA)/\(Ident.card1)", card(order: "1024", title: "First"))
|
||||||
tombstoned(order: "1024", title: "Trashed card", deleted: "2026-03-03T09:00:00Z"))
|
try fixture.item("\(More.laneA)/\(Ident.card2)", card(order: "2048", title: "Second"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", live(order: "2048", title: "Live card"))
|
// Newest-first by ordinary ranks: every arrival mints above the current top.
|
||||||
try fixture.item(Ident.lane2,
|
try fixture.item(".trash/\(More.cardD)", card(order: "1024", title: "Oldest"))
|
||||||
tombstoned(order: "2048", title: "Doing", deleted: "2026-03-02T09:00:00Z"))
|
try fixture.item(".trash/\(More.cardE)", card(order: "512", title: "Middle"))
|
||||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", live(order: "1024", title: "Rides along"))
|
try fixture.item(".trash/\(More.cardF)", card(order: "256", title: "Newest"))
|
||||||
try fixture.item("\(Ident.lane2)/\(More.cardD)",
|
|
||||||
tombstoned(order: "2048", title: "Own flag", deleted: "2026-03-04T09:00:00Z"))
|
|
||||||
try fixture.item("\(Ident.lane2)/\(More.cardE)", live(order: "3072", title: "Rides along too"))
|
|
||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A card with its own deleted: under a tombstoned lane has no row — the walk is absolute")
|
private let laneA = ItemID(rawValue: More.laneA)
|
||||||
func ownFlagUnderTombstonedLaneHasNoRow() throws {
|
private let card1 = ItemID(rawValue: Ident.card1)
|
||||||
|
private let card2 = ItemID(rawValue: Ident.card2)
|
||||||
|
private let cardD = ItemID(rawValue: More.cardD)
|
||||||
|
private let cardE = ItemID(rawValue: More.cardE)
|
||||||
|
private let cardF = ItemID(rawValue: More.cardF)
|
||||||
|
|
||||||
|
// MARK: - The contents
|
||||||
|
|
||||||
|
/// **There is no derivation left to test** — so what this suite pins instead is that the container
|
||||||
|
/// *is* the list, in the order the column shows it, which is the pivot's whole claim.
|
||||||
|
@MainActor
|
||||||
|
@Suite("The trash's contents are the container")
|
||||||
|
struct TrashContentsTests {
|
||||||
|
|
||||||
|
@Test("The trash is `snapshot.trash`, newest first by ordinary ranks")
|
||||||
|
func theContainerIsTheList() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
let entries = TrashModel.entries(of: try load(fixture))
|
#expect(snapshot.trash.compactMap(\.title.value) == ["Newest", "Middle", "Oldest"],
|
||||||
// `cardD`'s stamp is the newest on the board, so if it had a row at all it would be the
|
"03 ▸ Trash: the trash sorts by `order` like any lane, and entry is at the top")
|
||||||
// first one. The lane's single entry subsumes it instead.
|
#expect(snapshot.trash.allSatisfy { $0.deleted.isMissing },
|
||||||
#expect(ids(entries) == [Ident.card1, Ident.lane2])
|
"there is no `deleted:` key and no timestamp sort")
|
||||||
#expect(!ids(entries).contains(More.cardD))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A lane entry counts what Put Back returns — cards without their own flag")
|
@Test("Lanes are never in it, whatever a hand-editor nests in there")
|
||||||
func returningCountExcludesOwnFlaggedCards() throws {
|
func lanesAreNeverTrashed() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
|
// A lane-shaped nesting inside `.trash/` is a stray by construction: the container holds
|
||||||
|
// card folders directly, and the walk does not descend.
|
||||||
|
try fixture.item(".trash/\(Ident.lane3)/\(Ident.card4)", card(order: "1024", title: "Nested"))
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
let entries = TrashModel.entries(of: try load(fixture))
|
#expect(!snapshot.trash.map(\.id).contains(ItemID(rawValue: Ident.card4)))
|
||||||
let lane = try #require(entries.first { $0.id.rawValue == Ident.lane2 })
|
#expect(snapshot.lanes.map(\.id) == [laneA], "and nothing in there is a lane")
|
||||||
guard case let .lane(_, returning) = lane else {
|
|
||||||
Issue.record("expected a lane entry")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Three cards sit in the folder; two come back with the lane. The third comes back to the
|
|
||||||
// *trash*, which is why it is not in this number.
|
|
||||||
#expect(returning == 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A lane entry with nothing to return counts zero rather than being suppressed")
|
@Test("An absent container is an empty trash")
|
||||||
func emptyLaneEntryCountsZero() throws {
|
func absentIsEmpty() throws {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
try fixture.item("", Item.board)
|
try fixture.item("", Item.board)
|
||||||
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Empty", deleted: "2026-03-03T09:00:00Z"))
|
try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
|
||||||
|
|
||||||
let entries = TrashModel.entries(of: try load(fixture))
|
#expect(try load(fixture).trash.isEmpty)
|
||||||
#expect(entries.count == 1)
|
|
||||||
guard case let .lane(_, returning) = entries[0] else {
|
|
||||||
Issue.record("expected a lane entry")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#expect(returning == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("An empty trash is empty, and a board with any tombstone is not")
|
|
||||||
func emptiness() throws {
|
|
||||||
let clean = try WriterFixture()
|
|
||||||
defer { clean.tearDown() }
|
|
||||||
try clean.item("", Item.board)
|
|
||||||
try clean.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
|
||||||
try clean.item("\(Ident.lane1)/\(Ident.card1)", live(order: "1024", title: "Card"))
|
|
||||||
#expect(TrashModel.isEmpty(try load(clean)))
|
|
||||||
#expect(TrashModel.entries(of: try load(clean)).isEmpty)
|
|
||||||
|
|
||||||
let dirty = try makeBoard()
|
|
||||||
defer { dirty.tearDown() }
|
|
||||||
#expect(!TrashModel.isEmpty(try load(dirty)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Paths resolve on the row set's liveness side, in display order")
|
|
||||||
func pathsResolveByEffectiveLiveness() throws {
|
|
||||||
let fixture = try makeBoard()
|
|
||||||
defer { fixture.tearDown() }
|
|
||||||
let model = try load(fixture)
|
|
||||||
let everything: Set<ItemID> = [
|
|
||||||
ItemID(rawValue: Ident.lane1), ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2),
|
|
||||||
ItemID(rawValue: Ident.lane2), ItemID(rawValue: Ident.card3), ItemID(rawValue: More.cardD),
|
|
||||||
]
|
|
||||||
|
|
||||||
// Live: the live lane and its live card. `card3` is live by its own flag but its lane is
|
|
||||||
// tombstoned, so the ancestor walk takes it off this side.
|
|
||||||
let liveSide = TrashModel.paths(of: everything, on: .live, in: model)
|
|
||||||
#expect(liveSide.map { ($0.laneID.rawValue, $0.cardID?.rawValue) }.map { "\($0.0)/\($0.1 ?? "-")" }
|
|
||||||
== ["\(Ident.lane1)/-", "\(Ident.lane1)/\(Ident.card2)"])
|
|
||||||
|
|
||||||
// Trashed: the tombstoned card and the tombstoned lane — the trash's two rows, in display
|
|
||||||
// order, lanes left to right and each lane before its cards. Nothing under `lane2` is here:
|
|
||||||
// its cards have no rows, and the lane's *folder* is what takes them (Put Back returns them
|
|
||||||
// with it; Delete Immediately purges them with it).
|
|
||||||
let trashedSide = TrashModel.paths(of: everything, on: .trashed, in: model)
|
|
||||||
#expect(trashedSide.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "-")" }
|
|
||||||
== ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-"])
|
|
||||||
|
|
||||||
#expect(TrashModel.paths(of: [], on: .live, in: model).isEmpty)
|
|
||||||
#expect(TrashModel.paths(of: [ItemID(rawValue: Ident.indexless)], on: .trashed, in: model).isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("The trashed universe is exactly the trash's rows — one function, not two")
|
|
||||||
func trashedUniverseIsTheRowSet() throws {
|
|
||||||
let fixture = try makeBoard()
|
|
||||||
defer { fixture.tearDown() }
|
|
||||||
let model = try load(fixture)
|
|
||||||
|
|
||||||
// The guarantee 02-architecture.md settles: "universe and rows are one function, never a
|
|
||||||
// broader set with a pointer-side subset". Anything a set may reference on the trashed side
|
|
||||||
// is something the quasi-lane draws.
|
|
||||||
let rows = Set(TrashModel.entries(of: model).map(\.id))
|
|
||||||
let trashed = ItemReferenceSet.idUniverse(of: model, on: .trashed)
|
|
||||||
#expect(trashed == rows)
|
|
||||||
|
|
||||||
// And the two universes therefore do **not** partition the board. `cardD` carries its own
|
|
||||||
// `deleted:` under a tombstoned lane and `card3` rides along unflagged under the same one;
|
|
||||||
// both render nowhere, so neither is on either side.
|
|
||||||
let liveUniverse = ItemReferenceSet.idUniverse(of: model, on: .live)
|
|
||||||
for hidden in [ItemID(rawValue: More.cardD), ItemID(rawValue: Ident.card3)] {
|
|
||||||
#expect(!rows.contains(hidden))
|
|
||||||
#expect(!trashed.contains(hidden))
|
|
||||||
#expect(!liveUniverse.contains(hidden))
|
|
||||||
#expect(ItemReferenceSet(ids: [hidden], liveness: .trashed).resolved(against: model).isEmpty)
|
|
||||||
#expect(ItemReferenceSet(ids: [hidden], liveness: .live).resolved(against: model).isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paths are the same walk in folder form, so they name the same things — one per row.
|
|
||||||
#expect(TrashModel.paths(of: rows, on: .trashed, in: model).count == rows.count)
|
|
||||||
#expect(TrashModel.emptyTrashTargets(in: model).count == rows.count)
|
|
||||||
// And `isEmpty`'s short-circuit is that walk's non-emptiness, on both a dirty board and a
|
|
||||||
// clean one.
|
|
||||||
#expect(TrashModel.isEmpty(model) == rows.isEmpty)
|
|
||||||
|
|
||||||
let clean = try WriterFixture()
|
|
||||||
defer { clean.tearDown() }
|
|
||||||
try clean.item("", Item.board)
|
|
||||||
try clean.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
|
||||||
let cleanModel = try load(clean)
|
|
||||||
#expect(TrashModel.isEmpty(cleanModel) == TrashModel.entries(of: cleanModel).isEmpty)
|
|
||||||
#expect(ItemReferenceSet.idUniverse(of: cleanModel, on: .trashed).isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Empty Trash targets every tombstone, a tombstoned lane contributing only its own folder")
|
|
||||||
func emptyTrashTargets() throws {
|
|
||||||
let fixture = try makeBoard()
|
|
||||||
defer { fixture.tearDown() }
|
|
||||||
|
|
||||||
let targets = TrashModel.emptyTrashTargets(in: try load(fixture))
|
|
||||||
// The own-flag card inside the tombstoned lane is not listed — removing the lane folder
|
|
||||||
// takes it, and a second purge of a path that is already gone would be noise.
|
|
||||||
#expect(targets.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "-")" }
|
|
||||||
== ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-"])
|
|
||||||
#expect(TrashModel.counts(of: targets) == TrashModel.EntryCounts(lanes: 1, cards: 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("A path resolves under whichever root it is given")
|
|
||||||
func pathsResolveUnderTheGivenRoot() {
|
|
||||||
let root = URL(fileURLWithPath: "/tmp/Board.kanban")
|
|
||||||
let card = TrashModel.ItemPath(laneID: ItemID(rawValue: Ident.lane1), cardID: ItemID(rawValue: Ident.card1))
|
|
||||||
#expect(card.folder(under: root).path == "/tmp/Board.kanban/\(Ident.lane1)/\(Ident.card1)")
|
|
||||||
#expect(!card.isLane)
|
|
||||||
|
|
||||||
let lane = TrashModel.ItemPath(laneID: ItemID(rawValue: Ident.lane1), cardID: nil)
|
|
||||||
#expect(lane.folder(under: root).path == "/tmp/Board.kanban/\(Ident.lane1)")
|
|
||||||
#expect(lane.isLane)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Counts, phrasing, and the confirmations
|
// MARK: - ItemPath
|
||||||
|
|
||||||
|
/// The location vocabulary that replaced `TrashModel.paths` — three cases because the board has
|
||||||
|
/// exactly three places an identity-bearing folder can be.
|
||||||
|
@MainActor
|
||||||
|
@Suite("ItemPath")
|
||||||
|
struct ItemPathTests {
|
||||||
|
|
||||||
|
@Test("Each case resolves to the folder it names")
|
||||||
|
func foldersResolve() throws {
|
||||||
|
let root = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
|
||||||
|
|
||||||
|
#expect(ItemPath.lane(laneA).folder(under: root).path == "/Boards/Work.kanban/\(More.laneA)")
|
||||||
|
#expect(ItemPath.card(lane: laneA, id: card1).folder(under: root).path
|
||||||
|
== "/Boards/Work.kanban/\(More.laneA)/\(Ident.card1)")
|
||||||
|
#expect(ItemPath.trashCard(cardD).folder(under: root).path
|
||||||
|
== "/Boards/Work.kanban/.trash/\(More.cardD)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The container is the case, and only a lane is a lane")
|
||||||
|
func containerAndKind() {
|
||||||
|
#expect(ItemPath.lane(laneA).container == .board)
|
||||||
|
#expect(ItemPath.card(lane: laneA, id: card1).container == .board)
|
||||||
|
#expect(ItemPath.trashCard(cardD).container == .trash)
|
||||||
|
|
||||||
|
#expect(ItemPath.lane(laneA).isLane)
|
||||||
|
#expect(!ItemPath.card(lane: laneA, id: card1).isLane)
|
||||||
|
#expect(!ItemPath.trashCard(cardD).isLane)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Resolution is per container, in display order, skipping what is not there")
|
||||||
|
func resolutionIsPerContainer() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
let everything: Set<ItemID> = [laneA, card1, card2, cardD, cardE, cardF]
|
||||||
|
|
||||||
|
#expect(ItemPath.resolve(everything, in: .board, snapshot: snapshot)
|
||||||
|
== [.lane(laneA), .card(lane: laneA, id: card1), .card(lane: laneA, id: card2)],
|
||||||
|
"lanes left to right, each lane then its cards — never the caller's set order")
|
||||||
|
#expect(ItemPath.resolve(everything, in: .trash, snapshot: snapshot)
|
||||||
|
== [.trashCard(cardF), .trashCard(cardE), .trashCard(cardD)],
|
||||||
|
"and the trash top to bottom")
|
||||||
|
|
||||||
|
#expect(ItemPath.resolve([], in: .board, snapshot: snapshot).isEmpty)
|
||||||
|
#expect(ItemPath.resolve([ItemID(rawValue: Ident.indexless)], in: .trash, snapshot: snapshot).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A lookup that spans containers finds an item wherever it is, and nothing where it is not")
|
||||||
|
func lookupSpansContainers() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
#expect(ItemPath.of(laneA, in: snapshot) == .lane(laneA))
|
||||||
|
#expect(ItemPath.of(card1, in: snapshot) == .card(lane: laneA, id: card1))
|
||||||
|
#expect(ItemPath.of(cardD, in: snapshot) == .trashCard(cardD))
|
||||||
|
#expect(ItemPath.of(ItemID(rawValue: Ident.indexless), in: snapshot) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The universes
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("ItemContainer ▸ the universe")
|
||||||
|
struct ItemContainerUniverseTests {
|
||||||
|
|
||||||
|
@Test("The board's universe is its lanes and their cards; the trash's is its cards")
|
||||||
|
func universesArePresenceOnly() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
#expect(ItemContainer.board.ids(in: snapshot) == [laneA, card1, card2])
|
||||||
|
#expect(ItemContainer.trash.ids(in: snapshot) == [cardD, cardE, cardF])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The two universes partition the board — nothing is in both, nothing is in neither")
|
||||||
|
func theyPartition() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
let board = ItemContainer.board.ids(in: snapshot)
|
||||||
|
let trash = ItemContainer.trash.ids(in: snapshot)
|
||||||
|
|
||||||
|
#expect(board.isDisjoint(with: trash))
|
||||||
|
// The tombstone model's two sides deliberately did *not* partition — a card under a
|
||||||
|
// tombstoned lane was in neither. Presence being the whole test is what closed that gap.
|
||||||
|
var everything: Set<ItemID> = []
|
||||||
|
for lane in snapshot.lanes {
|
||||||
|
everything.insert(lane.id)
|
||||||
|
for card in lane.cards { everything.insert(card.id) }
|
||||||
|
}
|
||||||
|
for card in snapshot.trash { everything.insert(card.id) }
|
||||||
|
#expect(board.union(trash) == everything)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Phrasing
|
||||||
|
|
||||||
@Suite("TrashModel ▸ phrasing")
|
@Suite("TrashModel ▸ phrasing")
|
||||||
struct TrashModelPhrasingTests {
|
struct TrashPhrasingTests {
|
||||||
|
|
||||||
@Test("Plural folding reads counts, singular and plural, cards and lanes and both")
|
@Test("Plurals fold, and there is only one noun left to fold")
|
||||||
func pluralFolding() {
|
func pluralFolding() {
|
||||||
#expect(TrashModel.phrase(.init(lanes: 0, cards: 41)) == "41 cards")
|
#expect(TrashModel.phrase(1) == "1 card")
|
||||||
#expect(TrashModel.phrase(.init(lanes: 0, cards: 1)) == "1 card")
|
#expect(TrashModel.phrase(41) == "41 cards")
|
||||||
#expect(TrashModel.phrase(.init(lanes: 1, cards: 0)) == "1 lane")
|
#expect(TrashModel.phrase(0) == "0 cards")
|
||||||
#expect(TrashModel.phrase(.init(lanes: 3, cards: 0)) == "3 lanes")
|
|
||||||
#expect(TrashModel.phrase(.init(lanes: 2, cards: 3)) == "2 lanes and 3 cards")
|
|
||||||
#expect(TrashModel.phrase(.init(lanes: 1, cards: 1)) == "1 lane and 1 card")
|
|
||||||
#expect(TrashModel.phrase(.init()) == "nothing")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Entry counts split lane entries from card entries")
|
@MainActor
|
||||||
func entryCounts() throws {
|
@Test("A sole card is named; several fold into a count")
|
||||||
let fixture = try WriterFixture()
|
func purgePromptNamesOrCounts() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
try fixture.item("", Item.board)
|
let snapshot = try load(fixture)
|
||||||
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
|
|
||||||
tombstoned(order: "1024", title: "One", deleted: "2026-03-01T09:00:00Z"))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
|
|
||||||
tombstoned(order: "2048", title: "Two", deleted: "2026-03-02T09:00:00Z"))
|
|
||||||
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Gone", deleted: "2026-03-03T09:00:00Z"))
|
|
||||||
|
|
||||||
let counts = TrashModel.counts(of: TrashModel.entries(of: try load(fixture)))
|
|
||||||
#expect(counts == TrashModel.EntryCounts(lanes: 1, cards: 2))
|
|
||||||
#expect(counts.total == 3)
|
|
||||||
#expect(!counts.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Delete Immediately names a sole item and folds several into counts")
|
|
||||||
func purgePrompt() throws {
|
|
||||||
let fixture = try WriterFixture()
|
|
||||||
defer { fixture.tearDown() }
|
|
||||||
try fixture.item("", Item.board)
|
|
||||||
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
|
|
||||||
tombstoned(order: "1024", title: "Fix login", deleted: "2026-03-01T09:00:00Z"))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
|
|
||||||
tombstoned(order: "2048", title: "Ship it", deleted: "2026-03-02T09:00:00Z"))
|
|
||||||
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing", deleted: "2026-03-03T09:00:00Z"))
|
|
||||||
let model = try load(fixture)
|
|
||||||
|
|
||||||
let sole = try #require(TrashModel.purgePrompt(
|
let sole = try #require(TrashModel.purgePrompt(
|
||||||
for: [ItemID(rawValue: Ident.card1)], in: model, unrecoverable: true))
|
for: [cardF], in: .trash, snapshot: snapshot, unrecoverable: true
|
||||||
#expect(sole.title == "Permanently delete \u{201C}Fix login\u{201D}?")
|
))
|
||||||
|
#expect(sole.title == "Permanently delete \u{201C}Newest\u{201D}?")
|
||||||
#expect(sole.message == "This can\u{2019}t be undone.")
|
#expect(sole.message == "This can\u{2019}t be undone.")
|
||||||
#expect(sole.confirmTitle == "Delete")
|
#expect(sole.confirmTitle == "Delete")
|
||||||
|
|
||||||
let several = try #require(TrashModel.purgePrompt(
|
let several = try #require(TrashModel.purgePrompt(
|
||||||
for: [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)],
|
for: [cardE, cardF], in: .trash, snapshot: snapshot, unrecoverable: true
|
||||||
in: model, unrecoverable: true))
|
))
|
||||||
#expect(several.title == "Permanently delete 2 cards?")
|
#expect(several.title == "Permanently delete 2 cards?")
|
||||||
|
|
||||||
// A lane in the set earns the sentence that matters most: the row says how many cards come
|
|
||||||
// *back*, while a purge takes every card in the folder.
|
|
||||||
let withLane = try #require(TrashModel.purgePrompt(
|
|
||||||
for: [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.lane2)],
|
|
||||||
in: model, unrecoverable: true))
|
|
||||||
#expect(withLane.title == "Permanently delete 1 lane and 1 card?")
|
|
||||||
#expect(withLane.message.hasPrefix("Deleting a lane also deletes every card inside it."))
|
|
||||||
|
|
||||||
// m7-git: a board whose history keeps the content says so instead.
|
|
||||||
let recoverable = try #require(TrashModel.purgePrompt(
|
|
||||||
for: [ItemID(rawValue: Ident.card1)], in: model, unrecoverable: false))
|
|
||||||
#expect(recoverable.message == "The board\u{2019}s history still has them.")
|
|
||||||
|
|
||||||
// Nothing tombstoned in the set: no prompt, which is also the command's own refusal.
|
|
||||||
#expect(TrashModel.purgePrompt(for: [ItemID(rawValue: Ident.lane1)], in: model, unrecoverable: true) == nil)
|
|
||||||
#expect(TrashModel.purgePrompt(for: [], in: model, unrecoverable: true) == nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("An untitled item is named by its rendering, never by an empty string")
|
@MainActor
|
||||||
func untitledPrompt() throws {
|
@Test("Delete Immediately reads the same either side of the boundary")
|
||||||
|
func purgePromptSpansContainers() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
let fromBoard = try #require(TrashModel.purgePrompt(
|
||||||
|
for: [card1], in: .board, snapshot: snapshot, unrecoverable: true
|
||||||
|
))
|
||||||
|
#expect(fromBoard.title == "Permanently delete \u{201C}First\u{201D}?",
|
||||||
|
"11 ▸ Delete Immediately skips the trash from anywhere")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test("A lane in the set contributes nothing — no purge path reaches one")
|
||||||
|
func lanesAreNeverPurged() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
#expect(TrashModel.purgePrompt(for: [laneA], in: .board, snapshot: snapshot, unrecoverable: true) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test("An untitled card reads as the untitled rendering, never as an empty pair of quotes")
|
||||||
|
func untitledReadsAsARendering() throws {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
try fixture.item("", Item.board)
|
try fixture.item("", Item.board)
|
||||||
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
|
try fixture.item(".trash/\(More.cardD)", untitled(order: "1024"))
|
||||||
"---\nschema: 1\norder: 1024\ndeleted: 2026-03-01T09:00:00Z\n---\nbody\n")
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
let prompt = try #require(TrashModel.purgePrompt(
|
let prompt = try #require(TrashModel.purgePrompt(
|
||||||
for: [ItemID(rawValue: Ident.card1)], in: try load(fixture), unrecoverable: true))
|
for: [cardD], in: .trash, snapshot: snapshot, unrecoverable: true
|
||||||
|
))
|
||||||
#expect(prompt.title == "Permanently delete \u{201C}Untitled\u{201D}?")
|
#expect(prompt.title == "Permanently delete \u{201C}Untitled\u{201D}?")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Empty Trash names the whole trash's count and refuses on an empty one")
|
@MainActor
|
||||||
|
@Test("A set naming nothing raises no prompt — the refusal and the action agree")
|
||||||
|
func nothingToPurgeRaisesNothing() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
#expect(TrashModel.purgePrompt(
|
||||||
|
for: [ItemID(rawValue: Ident.indexless)], in: .trash, snapshot: snapshot, unrecoverable: true
|
||||||
|
) == nil)
|
||||||
|
#expect(TrashModel.purgePrompt(for: [], in: .board, snapshot: snapshot, unrecoverable: true) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test("Empty Trash names the true count, and the message follows recoverability")
|
||||||
func emptyTrashPrompt() throws {
|
func emptyTrashPrompt() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
|
let unrecoverable = try #require(TrashModel.emptyTrashPrompt(in: snapshot, unrecoverable: true))
|
||||||
|
#expect(unrecoverable.title == "Permanently delete 3 cards?")
|
||||||
|
#expect(unrecoverable.message == "This can\u{2019}t be undone.")
|
||||||
|
|
||||||
|
let recoverable = try #require(TrashModel.emptyTrashPrompt(in: snapshot, unrecoverable: false))
|
||||||
|
#expect(recoverable.message == "The board\u{2019}s history still has them.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test("An empty trash raises no Empty Trash prompt")
|
||||||
|
func emptyTrashOnAnEmptyTrash() throws {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
try fixture.item("", Item.board)
|
try fixture.item("", Item.board)
|
||||||
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
|
||||||
#expect(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true) == nil)
|
|
||||||
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
|
#expect(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true) == nil)
|
||||||
tombstoned(order: "1024", title: "One", deleted: "2026-03-01T09:00:00Z"))
|
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
|
|
||||||
tombstoned(order: "2048", title: "Two", deleted: "2026-03-02T09:00:00Z"))
|
|
||||||
let prompt = try #require(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true))
|
|
||||||
// Counts even for a small trash: the command is about the trash, not about an item.
|
|
||||||
#expect(prompt.title == "Permanently delete 2 cards?")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Menu validation
|
// MARK: - Menu validation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
@Suite("TrashModel ▸ validation")
|
@Suite("TrashModel ▸ validation")
|
||||||
struct TrashModelValidationTests {
|
struct TrashValidationTests {
|
||||||
|
|
||||||
private func makeBoard() throws -> WriterFixture {
|
/// **One Delete, one predicate.** The tombstone model needed a mirror-image pair so two ⌘⌫ twins
|
||||||
let fixture = try WriterFixture()
|
/// could enable exactly one of themselves; Put Back's retirement left one item, so the predicate
|
||||||
try fixture.item("", Item.board)
|
/// is "does this selection name anything", asked in the selection's own container.
|
||||||
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
@Test("Delete enables for either container, and for nothing that names nothing")
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", live(order: "1024", title: "Live"))
|
func canDeleteIsStagedNotSplit() throws {
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
|
|
||||||
tombstoned(order: "2048", title: "Trashed", deleted: "2026-03-01T09:00:00Z"))
|
|
||||||
return fixture
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("The ⌘⌫ twins enable exactly one of themselves, by the selection's liveness side")
|
|
||||||
func chordTwinsAreBinary() throws {
|
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let model = try load(fixture)
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
let liveSelection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card1)], liveness: .live)
|
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot))
|
||||||
#expect(TrashModel.canDelete(selection: liveSelection, in: model))
|
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [laneA], container: .board), in: snapshot))
|
||||||
#expect(!TrashModel.canActOnTrash(selection: liveSelection, in: model))
|
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [cardD], container: .trash), in: snapshot))
|
||||||
|
|
||||||
let trashedSelection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card2)], liveness: .trashed)
|
#expect(!TrashModel.canDelete(selection: .empty, in: snapshot))
|
||||||
#expect(!TrashModel.canDelete(selection: trashedSelection, in: model))
|
// A selection the next reload will drop: the id is real, but not in the container it claims.
|
||||||
#expect(TrashModel.canActOnTrash(selection: trashedSelection, in: model))
|
#expect(!TrashModel.canDelete(
|
||||||
|
selection: ItemReferenceSet(ids: [card1], container: .trash), in: snapshot
|
||||||
|
))
|
||||||
|
#expect(!TrashModel.canDelete(
|
||||||
|
selection: ItemReferenceSet(ids: [cardD], container: .board), in: snapshot
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Neither enables on an empty selection, or on one whose members have gone")
|
@Test("Delete Immediately is cards only, in either container")
|
||||||
func nothingToActOn() throws {
|
func canDeleteImmediatelyIsCardsOnly() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let model = try load(fixture)
|
let snapshot = try load(fixture)
|
||||||
|
|
||||||
#expect(!TrashModel.canDelete(selection: .empty, in: model))
|
#expect(TrashModel.canDeleteImmediately(
|
||||||
#expect(!TrashModel.canActOnTrash(selection: ItemReferenceSet(ids: [], liveness: .trashed), in: model))
|
selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot
|
||||||
|
))
|
||||||
// A selection the next reload will drop: the id names nothing, so the item that would write
|
#expect(TrashModel.canDeleteImmediately(
|
||||||
// nothing does not look available.
|
selection: ItemReferenceSet(ids: [cardD], container: .trash), in: snapshot
|
||||||
let stale = ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], liveness: .live)
|
))
|
||||||
#expect(!TrashModel.canDelete(selection: stale, in: model))
|
#expect(!TrashModel.canDeleteImmediately(
|
||||||
}
|
selection: ItemReferenceSet(ids: [laneA], container: .board), in: snapshot
|
||||||
|
), "a lane's delete is physical already — there is nothing for 'skip the trash' to mean")
|
||||||
@Test("A selection whose side disagrees with the item's own flag enables neither")
|
#expect(!TrashModel.canDeleteImmediately(selection: .empty, in: snapshot))
|
||||||
func sideMustMatch() throws {
|
|
||||||
let fixture = try makeBoard()
|
|
||||||
defer { fixture.tearDown() }
|
|
||||||
let model = try load(fixture)
|
|
||||||
|
|
||||||
// The homogeneous-by-liveness invariant makes this unreachable through the UI, and the
|
|
||||||
// reload rule ejects it if a foreign edit ever produces it — but the predicates must not
|
|
||||||
// trust that, since they are what stands between a stale set and a write.
|
|
||||||
let wrongSide = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card1)], liveness: .trashed)
|
|
||||||
#expect(!TrashModel.canActOnTrash(selection: wrongSide, in: model))
|
|
||||||
#expect(!TrashModel.canDelete(selection: wrongSide, in: model))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+542
-387
File diff suppressed because it is too large
Load Diff
+151
-108
@@ -18,7 +18,10 @@ import Testing
|
|||||||
|
|
||||||
// MARK: - Fixtures
|
// MARK: - Fixtures
|
||||||
|
|
||||||
private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String {
|
/// A card sitting in `<root>/.trash/` — an ordinary card in a special place (03-board-ui.md §
|
||||||
|
/// Trash), with the unknown-key overlay every other fixture card carries so an inverse's
|
||||||
|
/// verbatim-preservation claim has something to preserve.
|
||||||
|
private func trashResident(order: String, title: String) -> String {
|
||||||
"""
|
"""
|
||||||
---
|
---
|
||||||
schema: 1
|
schema: 1
|
||||||
@@ -26,7 +29,6 @@ private func tombstoned(order: String, title: String, deleted: String = "2026-03
|
|||||||
order: \(order)
|
order: \(order)
|
||||||
project: lanework # agent overlay
|
project: lanework # agent overlay
|
||||||
created: 2026-01-01T09:00:00Z
|
created: 2026-01-01T09:00:00Z
|
||||||
deleted: \(deleted)
|
|
||||||
---
|
---
|
||||||
\(title) body.
|
\(title) body.
|
||||||
|
|
||||||
@@ -47,8 +49,8 @@ Styled body.
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
/// Two live lanes — the first with two live cards, a styled one and a tombstoned one; the second
|
/// Two lanes — the first with two plain cards and a styled one, the second with one card — plus one
|
||||||
/// with one card. Enough for every inverse in this file.
|
/// card already sitting in the board's `.trash/`. Enough for every inverse in this file.
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeBoard() throws -> WriterFixture {
|
private func makeBoard() throws -> WriterFixture {
|
||||||
let fixture = try WriterFixture()
|
let fixture = try WriterFixture()
|
||||||
@@ -57,9 +59,9 @@ private func makeBoard() throws -> WriterFixture {
|
|||||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card3)", styledCard)
|
try fixture.item("\(Ident.lane1)/\(Ident.card3)", styledCard)
|
||||||
try fixture.item("\(Ident.lane1)/\(Ident.card4)", tombstoned(order: "4096", title: "Trashed"))
|
|
||||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||||
try fixture.item("\(Ident.lane2)/\(Ident.indexless)", Item.rich(order: "1024", title: "Elsewhere"))
|
try fixture.item("\(Ident.lane2)/\(Ident.indexless)", Item.rich(order: "1024", title: "Elsewhere"))
|
||||||
|
try fixture.item(".trash/\(Ident.card4)", trashResident(order: "1024", title: "Trashed"))
|
||||||
return fixture
|
return fixture
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +76,7 @@ private let elsewhere = ItemID(rawValue: Ident.indexless)
|
|||||||
private let card1Path = "\(Ident.lane1)/\(Ident.card1)"
|
private let card1Path = "\(Ident.lane1)/\(Ident.card1)"
|
||||||
private let card2Path = "\(Ident.lane1)/\(Ident.card2)"
|
private let card2Path = "\(Ident.lane1)/\(Ident.card2)"
|
||||||
private let card3Path = "\(Ident.lane1)/\(Ident.card3)"
|
private let card3Path = "\(Ident.lane1)/\(Ident.card3)"
|
||||||
private let trashedPath = "\(Ident.lane1)/\(Ident.card4)"
|
private let trashedPath = ".trash/\(Ident.card4)"
|
||||||
|
|
||||||
/// A store with a stack behind it. The provider is returned because `BoardStore.history` is **weak**
|
/// A store with a stack behind it. The provider is returned because `BoardStore.history` is **weak**
|
||||||
/// — the session owns the stack in the app, and a test that dropped it would watch its own steps
|
/// — the session owns the stack in the app, and a test that dropped it would watch its own steps
|
||||||
@@ -488,7 +490,7 @@ struct MoveUndoTests {
|
|||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let (store, history) = try makeStore(fixture)
|
let (store, history) = try makeStore(fixture)
|
||||||
store.select([card1], liveness: .live)
|
store.select([card1], in: .board)
|
||||||
|
|
||||||
store.sortSelection(.down)
|
store.sortSelection(.down)
|
||||||
|
|
||||||
@@ -506,13 +508,13 @@ struct MoveUndoTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The trash pair
|
// MARK: - Delete
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Suite("Undo ▸ the trash pair")
|
@Suite("Undo ▸ delete")
|
||||||
struct TrashUndoTests {
|
struct TrashUndoTests {
|
||||||
|
|
||||||
@Test("Undoing a delete is Put Back — the key goes, and nothing else moves")
|
@Test("Undoing a card delete moves it back out of the trash, to its lane and its rank")
|
||||||
func deleteRoundTrip() throws {
|
func deleteRoundTrip() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
@@ -520,20 +522,42 @@ struct TrashUndoTests {
|
|||||||
let before = try fixture.indexText(card1Path)
|
let before = try fixture.indexText(card1Path)
|
||||||
|
|
||||||
store.delete([card1])
|
store.delete([card1])
|
||||||
#expect(try document(fixture, card1Path).deleted.value != nil)
|
#expect(!fixture.exists(card1Path), "the folder physically left its lane")
|
||||||
|
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||||
#expect(history.undoActionName == "Delete Card")
|
#expect(history.undoActionName == "Delete Card")
|
||||||
|
|
||||||
history.undo()
|
history.undo()
|
||||||
|
#expect(fixture.exists(card1Path), "13 ▸ Interaction with the trash: the undo is the move back")
|
||||||
|
#expect(!fixture.exists(".trash/\(Ident.card1)"))
|
||||||
let undone = try fixture.indexText(card1Path)
|
let undone = try fixture.indexText(card1Path)
|
||||||
#expect(try FrontmatterDocument.parse(undone).deleted.isMissing)
|
#expect(try FrontmatterDocument.parse(undone).order.value == 1024, "at its original rank")
|
||||||
// Byte-identical but for the stamp: the tombstone and its inverse are one key each.
|
// Byte-identical but for the stamps every app write owns — no `deleted:` key was ever
|
||||||
#expect(untouchedLines(undone).filter { !$0.hasPrefix("deleted:") } == untouchedLines(before))
|
// written, so there is none to come back and none to remove.
|
||||||
|
#expect(untouchedLines(undone) == untouchedLines(before))
|
||||||
|
|
||||||
history.redo()
|
history.redo()
|
||||||
#expect(try document(fixture, card1Path).deleted.value != nil)
|
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||||
|
#expect(!fixture.exists(card1Path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A multi-item delete is one step with a plural title")
|
@Test("The redo files the card under the rank the delete minted, not a fresh one")
|
||||||
|
func redoUsesTheCapturedTrashRank() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let (store, history) = try makeStore(fixture)
|
||||||
|
|
||||||
|
store.delete([card1])
|
||||||
|
let minted = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value)
|
||||||
|
#expect(minted < 1024, "entry is at the top: a rank above the current topmost (order 1024)")
|
||||||
|
|
||||||
|
history.undo()
|
||||||
|
history.redo()
|
||||||
|
|
||||||
|
#expect(try document(fixture, ".trash/\(Ident.card1)").order.value == minted,
|
||||||
|
"the redo replays the write's own captured rank")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A multi-card delete is one step with a plural title, and lands newest-last on top")
|
||||||
func batchDeleteIsOneStep() throws {
|
func batchDeleteIsOneStep() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
@@ -542,69 +566,74 @@ struct TrashUndoTests {
|
|||||||
store.delete([card1, card2])
|
store.delete([card1, card2])
|
||||||
|
|
||||||
#expect(history.undoActionName == "Delete 2 Cards")
|
#expect(history.undoActionName == "Delete 2 Cards")
|
||||||
|
let first = try #require(try document(fixture, ".trash/\(Ident.card1)").order.value)
|
||||||
|
let second = try #require(try document(fixture, ".trash/\(Ident.card2)").order.value)
|
||||||
|
#expect(second < first, "each arrival in the run mints a rank above the one before it")
|
||||||
|
|
||||||
history.undo()
|
history.undo()
|
||||||
#expect(try document(fixture, card1Path).deleted.isMissing)
|
#expect(fixture.exists(card1Path))
|
||||||
#expect(try document(fixture, card2Path).deleted.isMissing)
|
#expect(fixture.exists(card2Path))
|
||||||
|
#expect(try loadedTrash(fixture).map(\.id) == [trashed], "only the board's own resident is left")
|
||||||
#expect(history.canUndo == false)
|
#expect(history.canUndo == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A lane delete is named for the lane")
|
@Test("A lane delete is physical, and its undo recreates the folder byte for byte")
|
||||||
func laneDeleteIsNamedForTheLane() throws {
|
func laneDeleteRecreatesTheSubtree() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let (store, history) = try makeStore(fixture)
|
let (store, history) = try makeStore(fixture)
|
||||||
|
let laneText = try fixture.indexText(Ident.lane2)
|
||||||
|
let cardText = try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)")
|
||||||
|
|
||||||
store.delete([lane2])
|
store.delete([lane2])
|
||||||
|
|
||||||
|
#expect(!fixture.exists(Ident.lane2), "03 ▸ Trash: deleting a lane deletes it, physically")
|
||||||
|
#expect(try loadedTrash(fixture).map(\.id) == [trashed], "lanes are never trashed")
|
||||||
#expect(history.undoActionName == "Delete Lane")
|
#expect(history.undoActionName == "Delete Lane")
|
||||||
|
|
||||||
history.undo()
|
history.undo()
|
||||||
#expect(try document(fixture, Ident.lane2).deleted.isMissing)
|
|
||||||
|
#expect(fixture.exists(Ident.lane2))
|
||||||
|
#expect(try fixture.indexText(Ident.lane2) == laneText, "the capture replays bytes, it does not edit")
|
||||||
|
#expect(try fixture.indexText("\(Ident.lane2)/\(Ident.indexless)") == cardText,
|
||||||
|
"and the whole subtree comes back with it — nested cards included")
|
||||||
|
|
||||||
|
history.redo()
|
||||||
|
#expect(!fixture.exists(Ident.lane2))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Undoing a Put Back re-tombstones with the timestamp the row was filed under")
|
@Test("A restore-by-move-out registers as an ordinary Move, with the ordinary move inverse")
|
||||||
func putBackRoundTrip() throws {
|
func restoreIsAnOrdinaryMove() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let (store, history) = try makeStore(fixture)
|
let (store, history) = try makeStore(fixture)
|
||||||
let filedUnder = try #require(try document(fixture, trashedPath).deleted.value)
|
let trashRank = try #require(try document(fixture, trashedPath).order.value)
|
||||||
|
|
||||||
store.putBack([trashed])
|
store.moveCards([trashed], toLane: lane2, at: 0)
|
||||||
#expect(try document(fixture, trashedPath).deleted.isMissing)
|
|
||||||
#expect(history.undoActionName == "Restore Card")
|
|
||||||
|
|
||||||
history.undo()
|
|
||||||
#expect(try document(fixture, trashedPath).deleted.value == filedUnder,
|
|
||||||
"the trash sorts by this — a fresh stamp would reorder a list the user was reading")
|
|
||||||
|
|
||||||
history.redo()
|
|
||||||
#expect(try document(fixture, trashedPath).deleted.isMissing)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Drag-to-restore undoes the position half too")
|
|
||||||
func restoreByDragRoundTrip() throws {
|
|
||||||
let fixture = try makeBoard()
|
|
||||||
defer { fixture.tearDown() }
|
|
||||||
let (store, history) = try makeStore(fixture)
|
|
||||||
let filedUnder = try #require(try document(fixture, trashedPath).deleted.value)
|
|
||||||
|
|
||||||
store.restoreByDrag(cardID: trashed, intoLane: lane2, at: 0)
|
|
||||||
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
|
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
|
||||||
#expect(try document(fixture, "\(Ident.lane2)/\(Ident.card4)").deleted.isMissing)
|
#expect(!fixture.exists(trashedPath))
|
||||||
#expect(history.undoActionName == "Restore Card")
|
#expect(history.undoActionName == "Move Card",
|
||||||
|
"13: a restore is an ordinary move between containers, named as one")
|
||||||
|
|
||||||
history.undo()
|
history.undo()
|
||||||
#expect(fixture.exists(trashedPath), "back in the lane it was trashed in")
|
#expect(fixture.exists(trashedPath), "back in the trash it came out of")
|
||||||
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)") == false)
|
#expect(!fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
|
||||||
let undone = try document(fixture, trashedPath)
|
#expect(try document(fixture, trashedPath).order.value == trashRank,
|
||||||
#expect(undone.deleted.value == filedUnder)
|
"at the rank it was filed under")
|
||||||
#expect(undone.order.value == 4096, "at the rank it was trashed holding")
|
|
||||||
|
|
||||||
history.redo()
|
history.redo()
|
||||||
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
|
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)"))
|
||||||
#expect(try document(fixture, "\(Ident.lane2)/\(Ident.card4)").deleted.isMissing)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The board's trash as the loader reads it — never the store's snapshot, which a write deliberately
|
||||||
|
/// does not touch.
|
||||||
|
@MainActor
|
||||||
|
private func loadedTrash(_ fixture: WriterFixture) throws -> [Card] {
|
||||||
|
try BoardLoader.load(boardRoot: fixture.root).model.trash
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - The Edit session
|
// MARK: - The Edit session
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -703,6 +732,7 @@ struct NotUndoableTests {
|
|||||||
store.delete([card1])
|
store.delete([card1])
|
||||||
let armed = try #require(history.undoActionName)
|
let armed = try #require(history.undoActionName)
|
||||||
|
|
||||||
|
store.select([trashed], in: .trash)
|
||||||
store.deleteImmediately([trashed])
|
store.deleteImmediately([trashed])
|
||||||
|
|
||||||
#expect(fixture.exists(trashedPath) == false)
|
#expect(fixture.exists(trashedPath) == false)
|
||||||
@@ -758,7 +788,7 @@ struct NotUndoableTests {
|
|||||||
store.delete([card1])
|
store.delete([card1])
|
||||||
store.setLaneWidth(lane1, units: 2)
|
store.setLaneWidth(lane1, units: 2)
|
||||||
|
|
||||||
#expect(try document(fixture, card1Path).deleted.value != nil)
|
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||||
#expect(try document(fixture, Ident.lane1).width.value == 2)
|
#expect(try document(fixture, Ident.lane1).width.value == 2)
|
||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
@@ -803,9 +833,9 @@ struct CrossingIsAWriteTests {
|
|||||||
store.delete([card1])
|
store.delete([card1])
|
||||||
for _ in 0 ..< 3 {
|
for _ in 0 ..< 3 {
|
||||||
history.undo()
|
history.undo()
|
||||||
#expect(try document(fixture, card1Path).deleted.isMissing)
|
#expect(fixture.exists(card1Path))
|
||||||
history.redo()
|
history.redo()
|
||||||
#expect(try document(fixture, card1Path).deleted.value != nil)
|
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -840,8 +870,15 @@ private enum Foreign {
|
|||||||
_ = try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: body)
|
_ = try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func delete(_ fixture: WriterFixture, _ path: String) throws {
|
/// A foreign delete of a **card** — the shape a delete has on disk now: the folder moves into
|
||||||
try BoardWriter.deleteItem(at: fixture.url(path))
|
/// `.trash/`. `FileManager` and nothing else, so no rank is minted and no stamp is written.
|
||||||
|
static func trash(_ fixture: WriterFixture, _ path: String, id: String) throws {
|
||||||
|
try fixture.move(path, toTrash: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A foreign delete of a **lane** — physical, since lanes are never trashed.
|
||||||
|
static func removeLane(_ fixture: WriterFixture, _ path: String) throws {
|
||||||
|
try FileManager.default.removeItem(at: fixture.url(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func purge(_ fixture: WriterFixture, _ path: String) throws {
|
static func purge(_ fixture: WriterFixture, _ path: String) throws {
|
||||||
@@ -886,12 +923,13 @@ struct StaleStepTests {
|
|||||||
store.transient.updateRenameDraft("Second!")
|
store.transient.updateRenameDraft("Second!")
|
||||||
store.commitRename()
|
store.commitRename()
|
||||||
|
|
||||||
try Foreign.delete(fixture, card2Path)
|
try Foreign.trash(fixture, card2Path, id: Ident.card2)
|
||||||
history.undo()
|
history.undo()
|
||||||
|
|
||||||
// The top step's card is in the trash now, so its rename is not ours to walk back; the one
|
// The top step's card is in the trash now, so its rename is not ours to walk back; the one
|
||||||
// below it is untouched and applies in the same ⌘Z.
|
// below it is untouched and applies in the same ⌘Z.
|
||||||
#expect(try document(fixture, card2Path).title.value == "Second!", "the foreign writer's board, left alone")
|
#expect(try document(fixture, ".trash/\(Ident.card2)").title.value == "Second!",
|
||||||
|
"the foreign writer's board, left alone")
|
||||||
#expect(try document(fixture, card1Path).title.value == "First", "⌘Z fell through and did something")
|
#expect(try document(fixture, card1Path).title.value == "First", "⌘Z fell through and did something")
|
||||||
#expect(store.banners.signposts.map(\.message)
|
#expect(store.banners.signposts.map(\.message)
|
||||||
== ["Undo skipped — 'Second!' changed outside Lanework"])
|
== ["Undo skipped — 'Second!' changed outside Lanework"])
|
||||||
@@ -906,29 +944,33 @@ struct StaleStepTests {
|
|||||||
let (store, history) = try makeStore(fixture)
|
let (store, history) = try makeStore(fixture)
|
||||||
|
|
||||||
store.delete([card1])
|
store.delete([card1])
|
||||||
try Foreign.purge(fixture, card1Path)
|
try Foreign.purge(fixture, ".trash/\(Ident.card1)")
|
||||||
|
|
||||||
history.undo()
|
history.undo()
|
||||||
|
|
||||||
#expect(fixture.exists(card1Path) == false)
|
#expect(fixture.exists(card1Path) == false)
|
||||||
|
#expect(!fixture.exists(".trash/\(Ident.card1)"))
|
||||||
#expect(store.banners.signposts.count == 1)
|
#expect(store.banners.signposts.count == 1)
|
||||||
#expect(store.banners.oneShots.isEmpty, "a skip is not a write failure — no error row")
|
#expect(store.banners.oneShots.isEmpty, "a skip is not a write failure — no error row")
|
||||||
#expect(history.canUndo == false)
|
#expect(history.canUndo == false)
|
||||||
#expect(history.canRedo == false, "a skipped step leaves nothing behind")
|
#expect(history.canRedo == false, "a skipped step leaves nothing behind")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A foreign Put Back skips the delete's undo — the item is not on the side we left it")
|
/// The container check, and it needs no field of its own: a delete step's undo expects its card
|
||||||
|
/// at `<root>/.trash/<id>`, and a foreign restore leaves that path empty (`HistoryStaleness`).
|
||||||
|
@Test("A foreign restore skips the delete's undo — the card is not in the container we left it")
|
||||||
func aForeignRestoreSkipsTheDeleteStep() throws {
|
func aForeignRestoreSkipsTheDeleteStep() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let (store, history) = try makeStore(fixture)
|
let (store, history) = try makeStore(fixture)
|
||||||
|
|
||||||
store.delete([card1])
|
store.delete([card1])
|
||||||
try BoardWriter.restoreItem(at: fixture.url(card1Path))
|
try fixture.move(".trash/\(Ident.card1)", toLane: Ident.lane2, card: Ident.card1)
|
||||||
|
|
||||||
history.undo()
|
history.undo()
|
||||||
|
|
||||||
#expect(try document(fixture, card1Path).deleted.isMissing, "still live, as they left it")
|
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)"), "where the foreign writer put it")
|
||||||
|
#expect(!fixture.exists(card1Path), "and not moved back on top of them")
|
||||||
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'First' changed outside Lanework"])
|
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — 'First' changed outside Lanework"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,23 +991,25 @@ struct StaleStepTests {
|
|||||||
#expect(history.canUndo == false)
|
#expect(history.canUndo == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A tombstoned card is stale for a field edit, even with the field itself untouched")
|
@Test("A foreign lane delete is stale for a field edit — there is nothing at the path")
|
||||||
func aTombstonedTargetIsStaleForAFieldEdit() throws {
|
func aRemovedLaneIsStaleForAFieldEdit() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let (store, history) = try makeStore(fixture)
|
let (store, history) = try makeStore(fixture)
|
||||||
|
|
||||||
store.setLaneWidth(lane1, units: 3)
|
store.setLaneWidth(lane1, units: 3)
|
||||||
try Foreign.delete(fixture, Ident.lane1)
|
try Foreign.removeLane(fixture, Ident.lane1)
|
||||||
|
|
||||||
history.undo()
|
history.undo()
|
||||||
|
|
||||||
#expect(try document(fixture, Ident.lane1).width.value == 3, "not resized inside the trash")
|
#expect(!fixture.exists(Ident.lane1), "not conjured back to be resized")
|
||||||
#expect(store.banners.signposts.count == 1)
|
#expect(store.banners.signposts.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A card under a foreign-tombstoned lane is stale too — liveness is ancestor-walked")
|
/// The card's own path is the check: a lane delete is physical, so a card under it is simply not
|
||||||
func anAncestorTombstoneIsStale() throws {
|
/// there any more — no ancestor walk, which is what materializing the trash bought.
|
||||||
|
@Test("A card whose lane a foreign writer deleted is stale too")
|
||||||
|
func aCardUnderARemovedLaneIsStale() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let (store, history) = try makeStore(fixture)
|
let (store, history) = try makeStore(fixture)
|
||||||
@@ -973,11 +1017,11 @@ struct StaleStepTests {
|
|||||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||||
store.transient.updateRenameDraft("Renamed")
|
store.transient.updateRenameDraft("Renamed")
|
||||||
store.commitRename()
|
store.commitRename()
|
||||||
try Foreign.delete(fixture, Ident.lane1)
|
try Foreign.removeLane(fixture, Ident.lane1)
|
||||||
|
|
||||||
history.undo()
|
history.undo()
|
||||||
|
|
||||||
#expect(try document(fixture, card1Path).title.value == "Renamed", "the card renders nowhere; nothing was written")
|
#expect(!fixture.exists(card1Path), "the card is gone; nothing was written")
|
||||||
#expect(store.banners.signposts.count == 1)
|
#expect(store.banners.signposts.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1014,7 +1058,7 @@ struct StaleStepTests {
|
|||||||
store.commitRename()
|
store.commitRename()
|
||||||
|
|
||||||
try Foreign.rename(fixture, card2Path, to: "Theirs")
|
try Foreign.rename(fixture, card2Path, to: "Theirs")
|
||||||
try Foreign.delete(fixture, card3Path)
|
try Foreign.trash(fixture, card3Path, id: Ident.card3)
|
||||||
history.undo()
|
history.undo()
|
||||||
|
|
||||||
#expect(try document(fixture, card1Path).title.value == "First", "the step applied")
|
#expect(try document(fixture, card1Path).title.value == "First", "the step applied")
|
||||||
@@ -1108,7 +1152,7 @@ struct StaleStepTests {
|
|||||||
let (store, history) = try makeStore(fixture)
|
let (store, history) = try makeStore(fixture)
|
||||||
|
|
||||||
store.delete([card1])
|
store.delete([card1])
|
||||||
try Foreign.purge(fixture, card1Path)
|
try Foreign.purge(fixture, ".trash/\(Ident.card1)")
|
||||||
history.undo()
|
history.undo()
|
||||||
|
|
||||||
let row = try #require(store.bannerRows.last)
|
let row = try #require(store.bannerRows.last)
|
||||||
@@ -1219,7 +1263,7 @@ struct FailedCrossingTests {
|
|||||||
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.url(card1Path).path)
|
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.url(card1Path).path)
|
||||||
history.undo()
|
history.undo()
|
||||||
|
|
||||||
#expect(try document(fixture, card2Path).deleted.value != nil,
|
#expect(fixture.exists(".trash/\(Ident.card2)"),
|
||||||
"the step below was never reached — a refused disk is not a reason to attempt more")
|
"the step below was never reached — a refused disk is not a reason to attempt more")
|
||||||
#expect(history.undoActionName == "Rename Card")
|
#expect(history.undoActionName == "Rename Card")
|
||||||
}
|
}
|
||||||
@@ -1322,34 +1366,36 @@ struct HistoryPhraseTests {
|
|||||||
/// that the two doors reach one state rather than two similar ones, and that alternating between
|
/// that the two doors reach one state rather than two similar ones, and that alternating between
|
||||||
/// them leaves a stack that crosses cleanly in both directions.
|
/// them leaves a stack that crosses cleanly in both directions.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Suite("Undo ▸ the trash's two doors")
|
@Suite("Undo ▸ the trash and the stack are the same folder moves")
|
||||||
struct TrashInterplayTests {
|
struct TrashInterplayTests {
|
||||||
|
|
||||||
@Test("Undoing a delete lands exactly where Put Back would have — the same bytes, not a near miss")
|
/// 13-native-undo.md ▸ Interaction with the trash: "The stack and the trash never conflict — they
|
||||||
|
/// are the same folder moves addressed by recency instead of by selection."
|
||||||
|
@Test("Undoing a delete lands exactly where a manual move-out would — the same bytes")
|
||||||
func theTwoDoorsReachOneState() async throws {
|
func theTwoDoorsReachOneState() async throws {
|
||||||
// Two identical boards, one per door: the claim is about a *state*, so the honest comparison
|
// Two identical boards, one per door: the claim is about a *state*, so the honest comparison
|
||||||
// is the whole board read off disk, not the one field each path happens to write.
|
// is the whole board read off disk, not the one field each path happens to write.
|
||||||
let byUndo = try makeBoard()
|
let byUndo = try makeBoard()
|
||||||
defer { byUndo.tearDown() }
|
defer { byUndo.tearDown() }
|
||||||
let byPutBack = try makeBoard()
|
let byMove = try makeBoard()
|
||||||
defer { byPutBack.tearDown() }
|
defer { byMove.tearDown() }
|
||||||
let origin = try boardTexts(byUndo)
|
let origin = try boardTexts(byUndo)
|
||||||
|
|
||||||
let (undoStore, history) = try makeStore(byUndo)
|
let (undoStore, history) = try makeStore(byUndo)
|
||||||
undoStore.delete([card1])
|
undoStore.delete([card1])
|
||||||
history.undo()
|
history.undo()
|
||||||
|
|
||||||
let (putBackStore, _) = try makeStore(byPutBack)
|
let (moveStore, _) = try makeStore(byMove)
|
||||||
putBackStore.delete([card1])
|
moveStore.delete([card1])
|
||||||
// Put Back reads the trashed side of the snapshot, so it has to see the tombstone first —
|
// The move-out reads the trash side of the snapshot, so it has to see the card arrive there
|
||||||
// which is the one-way flow, not a test artefact.
|
// first — which is the one-way flow, not a test artefact.
|
||||||
await reload(putBackStore)
|
await reload(moveStore)
|
||||||
putBackStore.putBack([card1])
|
moveStore.moveCards([card1], toLane: lane1, at: 0)
|
||||||
|
|
||||||
expectSameBoard(try boardTexts(byUndo), try boardTexts(byPutBack), "the two doors")
|
expectSameBoard(try boardTexts(byUndo), try boardTexts(byMove), "the two doors")
|
||||||
expectSameBoard(try boardTexts(byUndo), origin, "undo against the board it started from")
|
expectSameBoard(try boardTexts(byUndo), origin, "undo against the board it started from")
|
||||||
#expect(try document(byUndo, card1Path).deleted.isMissing)
|
#expect(byUndo.exists(card1Path))
|
||||||
#expect(try document(byPutBack, card1Path).deleted.isMissing)
|
#expect(byMove.exists(card1Path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Undoing a delete is position-preserving — the card comes back where it was, not at an end")
|
@Test("Undoing a delete is position-preserving — the card comes back where it was, not at an end")
|
||||||
@@ -1358,25 +1404,25 @@ struct TrashInterplayTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let (store, history) = try makeStore(fixture)
|
let (store, history) = try makeStore(fixture)
|
||||||
|
|
||||||
/// The lane's live cards in display order, read through the loader — the order the board
|
/// The lane's cards in display order, read through the loader — the order the board actually
|
||||||
/// actually renders, rather than the ranks it is derived from.
|
/// renders, rather than the ranks it is derived from.
|
||||||
func liveCards() throws -> [String] {
|
func laneCards() throws -> [String] {
|
||||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
let lane = try #require(result.model.lanes.first { $0.id == lane1 })
|
let lane = try #require(result.model.lanes.first { $0.id == lane1 })
|
||||||
return lane.cards.filter { !$0.isDeleted }.map(\.id.rawValue)
|
return lane.cards.map(\.id.rawValue)
|
||||||
}
|
}
|
||||||
let before = try liveCards()
|
let before = try laneCards()
|
||||||
#expect(before == [Ident.card1, Ident.card2, Ident.card3], "the middle card is genuinely in the middle")
|
#expect(before == [Ident.card1, Ident.card2, Ident.card3], "the middle card is genuinely in the middle")
|
||||||
|
|
||||||
// The middle of three: an implementation that restored by appending would pass on a first or
|
// The middle of three: an implementation that restored by appending would pass on a first or
|
||||||
// last card and fail here.
|
// last card and fail here.
|
||||||
store.delete([card2])
|
store.delete([card2])
|
||||||
await reload(store)
|
await reload(store)
|
||||||
#expect(try liveCards() == [Ident.card1, Ident.card3])
|
#expect(try laneCards() == [Ident.card1, Ident.card3])
|
||||||
|
|
||||||
history.undo()
|
history.undo()
|
||||||
|
|
||||||
#expect(try liveCards() == before, "01's deletion bullet: restore is position-perfect")
|
#expect(try laneCards() == before, "13: the undo returns the card to its source lane and rank")
|
||||||
#expect(try document(fixture, card2Path).order.value == 2048, "the rank it held all along")
|
#expect(try document(fixture, card2Path).order.value == 2048, "the rank it held all along")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1387,30 +1433,27 @@ struct TrashInterplayTests {
|
|||||||
let (store, history) = try makeStore(fixture)
|
let (store, history) = try makeStore(fixture)
|
||||||
let live = try boardTexts(fixture)
|
let live = try boardTexts(fixture)
|
||||||
|
|
||||||
// Three gestures over one card, alternating the doors: ⌫, Put Back, ⌫.
|
// Three gestures over one card, alternating the doors: ⌫, move back out, ⌫.
|
||||||
store.delete([card1])
|
store.delete([card1])
|
||||||
await reload(store)
|
await reload(store)
|
||||||
let filedUnder = try #require(try document(fixture, card1Path).deleted.value)
|
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||||
|
|
||||||
store.putBack([card1])
|
store.moveCards([card1], toLane: lane1, at: 0)
|
||||||
await reload(store)
|
await reload(store)
|
||||||
#expect(try document(fixture, card1Path).deleted.isMissing)
|
#expect(fixture.exists(card1Path))
|
||||||
|
|
||||||
store.delete([card1])
|
store.delete([card1])
|
||||||
await reload(store)
|
await reload(store)
|
||||||
#expect(history.undoActionName == "Delete Card")
|
#expect(history.undoActionName == "Delete Card")
|
||||||
let trashed = try boardTexts(fixture)
|
let trashedBoard = try boardTexts(fixture)
|
||||||
|
|
||||||
// Back up the stack: delete → restore → tombstone → restore. The middle step is Put Back's
|
// Back up the stack: delete → move → delete, each crossed in reverse.
|
||||||
// inverse, which has to re-file the row under the stamp it was filed under rather than under
|
|
||||||
// now — the trash sorts by it.
|
|
||||||
history.undo()
|
history.undo()
|
||||||
#expect(try document(fixture, card1Path).deleted.isMissing)
|
#expect(fixture.exists(card1Path))
|
||||||
#expect(history.undoActionName == "Restore Card")
|
#expect(history.undoActionName == "Move Card")
|
||||||
|
|
||||||
history.undo()
|
history.undo()
|
||||||
#expect(try document(fixture, card1Path).deleted.value == filedUnder,
|
#expect(fixture.exists(".trash/\(Ident.card1)"), "back in the trash the move took it out of")
|
||||||
"re-tombstoned where it was filed, not where a fresh stamp would put it")
|
|
||||||
#expect(history.undoActionName == "Delete Card")
|
#expect(history.undoActionName == "Delete Card")
|
||||||
|
|
||||||
history.undo()
|
history.undo()
|
||||||
@@ -1419,12 +1462,12 @@ struct TrashInterplayTests {
|
|||||||
|
|
||||||
// And forward again, the same three steps in the other direction.
|
// And forward again, the same three steps in the other direction.
|
||||||
history.redo()
|
history.redo()
|
||||||
#expect(try document(fixture, card1Path).deleted.value != nil)
|
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||||
history.redo()
|
history.redo()
|
||||||
#expect(try document(fixture, card1Path).deleted.isMissing)
|
#expect(fixture.exists(card1Path))
|
||||||
history.redo()
|
history.redo()
|
||||||
#expect(history.canRedo == false)
|
#expect(history.canRedo == false)
|
||||||
expectSameBoard(try boardTexts(fixture), trashed, "three doors forward")
|
expectSameBoard(try boardTexts(fixture), trashedBoard, "three doors forward")
|
||||||
#expect(store.banners.signposts.isEmpty, "nothing was stale: the doors never collided")
|
#expect(store.banners.signposts.isEmpty, "nothing was stale: the doors never collided")
|
||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,29 @@ struct WriterFixture {
|
|||||||
func entryNames(_ relativePath: String) throws -> [String] {
|
func entryNames(_ relativePath: String) throws -> [String] {
|
||||||
try FileManager.default.contentsOfDirectory(atPath: url(relativePath).path).sorted()
|
try FileManager.default.contentsOfDirectory(atPath: url(relativePath).path).sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Moves a folder from one place in the board to another — a **foreign** move, the way an agent
|
||||||
|
/// or a hand-editor makes one: `FileManager` and nothing else, no `index.md` rewritten, no rank
|
||||||
|
/// touched. What the container-crossing rules are stated in terms of (02-architecture.md §
|
||||||
|
/// Live-reload resilience, resettled 2026-07-28).
|
||||||
|
func moveFolder(_ relativePath: String, to destinationPath: String) throws {
|
||||||
|
let destination = url(destinationPath)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: destination.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try FileManager.default.moveItem(at: url(relativePath), to: destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A card folder moved into `<root>/.trash/` — the shape of a delete on disk, made foreignly.
|
||||||
|
func move(_ relativePath: String, toTrash cardName: String) throws {
|
||||||
|
try moveFolder(relativePath, to: ".trash/\(cardName)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A card folder moved out of the trash into a lane — the shape of a restore, made foreignly.
|
||||||
|
func move(_ relativePath: String, toLane laneName: String, card cardName: String) throws {
|
||||||
|
try moveFolder(relativePath, to: "\(laneName)/\(cardName)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Move/copy identities
|
// MARK: - Move/copy identities
|
||||||
|
|||||||
Reference in New Issue
Block a user