Materialize the trash — faces, menus, and grammar

Phase 3 finishes the pivot at the surface. One card face serves two
containers: CardFaceView extracted with a role — board or trash — so
stripe, tint, chip, selection stroke, cut dim, marquee registration,
and drag are shared by construction, the trash side differing only in
its absences: no Open, no rename, no Style, no file-hover highlight,
and a Delete that goes through the confirmation host. The column
rewrote around the lanes' own single-column masonry so drag reflow
reads as positional slides; chrome stays the hatched header, symbol,
and count — 11 gives Empty Trash to the File menu alone. Two real
grammar bugs die here: plain Backspace on a trash selection purged
without the confirmation the menu raises, and the context menu's
Delete resolved against the standing selection, so right-clicking a
trash card under a board selection silently did nothing — it now
stages the clicked set explicitly. Open, Rename, Style, and Empty
Trash validation became testable store seams; the column is one named
accessibility container of ordinary card elements. The tombstone era
is swept: deleteItem, restoreItem, stripTombstonedChildren — dead
since lane copies stopped nesting trash — the restore verb, the
unreachable put-back banner row, and every quasi-lane doc comment.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 18:18:39 -04:00
parent 53bc71f7fb
commit 797d020d01
34 changed files with 1272 additions and 1422 deletions
+1 -5
View File
@@ -47,7 +47,6 @@ private let everyOperation: [WriteOperation] = [
.reorder(title: "Fix login"),
.copy(title: "Fix login"),
.delete(title: "Fix login"),
.restore(title: "Fix login"),
.purge(title: "Fix login"),
.style(title: "Fix login"),
.resize(title: "Fix login"),
@@ -63,7 +62,6 @@ private let titledOperations: [(with: WriteOperation, without: WriteOperation)]
(.reorder(title: "Fix login"), .reorder(title: nil)),
(.copy(title: "Fix login"), .copy(title: nil)),
(.delete(title: "Fix login"), .delete(title: nil)),
(.restore(title: "Fix login"), .restore(title: nil)),
(.purge(title: "Fix login"), .purge(title: nil)),
(.style(title: "Fix login"), .style(title: nil)),
(.resize(title: "Fix login"), .resize(title: nil)),
@@ -488,15 +486,13 @@ struct BannerCenterPhrasingTests {
!= BannerCenter.headline(for: error(.style(title: "Fix login"))))
}
@Test("The trash trio speaks the board's vocabulary, never the system Trash's")
@Test("The trash verbs speak the board's vocabulary, never the system Trash's")
func trashVerbsFollowTheNamingConstraint() {
// 03-board-ui.md § Trash's naming constraint, settled with the trash UI copy: two "Trash"
// concepts coexist, and "Finder's 'Move to Trash' phrasing is reserved for the system Trash;
// board deletion says 'Delete'". A banner is UI copy like any other.
#expect(BannerCenter.headline(for: error(.delete(title: "Fix login"), .io(message: "the disk is full")))
== "Couldn't delete 'Fix login' — the disk is full")
#expect(BannerCenter.headline(for: error(.restore(title: "Fix login"), .io(message: "the disk is full")))
== "Couldn't put 'Fix login' back — the disk is full")
#expect(BannerCenter.headline(for: error(.purge(title: "Fix login"), .io(message: "the disk is full")))
== "Couldn't permanently delete 'Fix login' — the disk is full")
+4 -324
View File
@@ -1571,332 +1571,12 @@ struct BoardWriterCopyTests {
}
}
// MARK: - Stripping a copied lane's tombstones
/// `BoardWriter.stripTombstonedChildren` the tail of a lane copy (04-interactions.md Drag and
/// drop: "A lane copy **strips tombstoned cards**"). `copyItem` copies the tree verbatim by
/// design, so the strip is the line after it rather than a filter inside it.
struct BoardWriterStripTombstonesTests {
/// A tombstoned card, as an agent or a delete leaves it.
private static func tombstone(order: String, title: String) -> String {
"---\nschema: 1\ntitle: \(title)\norder: \(order)\ndeleted: 2026-03-03T09:00:00Z\n---\n\(title) body.\n"
}
@Test func onlyTheTombstonedChildrenAreRemoved() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)",
Self.tombstone(order: "2048", title: "Trashed"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Also live"))
let live = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
let removed = try BoardWriter.stripTombstonedChildren(of: lane)
#expect(removed == [ItemID(rawValue: Ident.card2)])
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card2)"))
// Removed, never tombstoned, and the survivors are not rewritten on the way past.
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card3)"))
#expect(try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") == live)
#expect(try BoardLoader.load(boardRoot: fixture.url("A.kanban")).warnings.isEmpty)
}
@Test func aWholeTombstonedFolderGoesWithItsAttachments() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)",
Self.tombstone(order: "1024", title: "Trashed"))
try fixture.file("A.kanban/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x89, 0x50]))
_ = try BoardWriter.stripTombstonedChildren(of: lane)
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"])
}
@Test func nonUUIDStraysAndUnreadableChildrenAreLeftAlone() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
// A stray is not a level at all; a UUID-shaped folder with no `index.md` cannot be asked
// the liveness question, and the conservative direction is to keep it.
try fixture.file("A.kanban/\(Ident.lane1)/notes/scratch.txt", Data("hand-written\n".utf8))
try FileManager.default.createDirectory(at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.indexless)"),
withIntermediateDirectories: true)
let removed = try BoardWriter.stripTombstonedChildren(of: lane)
#expect(removed.isEmpty)
#expect(fixture.exists("A.kanban/\(Ident.lane1)/notes/scratch.txt"))
#expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.indexless)"))
}
@Test func aLaneWithNothingTombstonedIsUntouched() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
#expect(try BoardWriter.stripTombstonedChildren(of: lane).isEmpty)
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)").sorted() == [Ident.card1, "index.md"].sorted())
}
@Test func aMissingFolderIsALoudErrorNamingTheCopy() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let error = writeFailure { _ = try BoardWriter.stripTombstonedChildren(of: fixture.url("A.kanban/\(Ident.lane1)")) }
// The user pressed nothing called "delete": a failure here must say the copy failed.
#expect(error?.operation == .copy(title: nil))
}
}
// MARK: - Delete / Restore
/// `BoardWriter.deleteItem`/`restoreItem` the tombstone half of 01-storage-format.md §
/// Deletion: `deleted: <now>` written into the item's own `index.md` in place, and Put Back
/// (`remove(deleted)`) undoing exactly that. The folder never moves; hiding a tombstoned
/// subtree is the renderer's ancestor walk, not anything either call does a deleted lane's
/// cards are never touched.
struct BoardWriterDeleteRestoreTests {
@Test func deletingALaneWritesATombstoneInPlaceAndPreservesEverythingElse() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let laneFolder = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
let before = try fixture.indexText("A.kanban/\(Ident.lane1)")
try BoardWriter.deleteItem(at: laneFolder)
// Same path, same name a tombstone never moves or renames the folder.
#expect(fixture.exists("A.kanban/\(Ident.lane1)"))
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"])
let after = try fixture.indexText("A.kanban/\(Ident.lane1)")
let stamped = [FrontmatterKeys.modified, FrontmatterKeys.modifiedBy, FrontmatterKeys.deleted]
#expect(lines(of: after, excludingKeys: stamped) == lines(of: before, excludingKeys: stamped))
let document = try FrontmatterDocument.parse(after)
#expect(document.unknownFields.map(\.key) == ["project", "labels"])
#expect(document.body.hasSuffix("body — with *markdown*.\n"))
#expect(document.modifiedBy == .missing)
#expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60)
let deleted = try #require(document.deleted.value)
#expect(abs(deleted.timeIntervalSinceNow) < 60)
let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban"))
#expect(result.model.lanes.first?.isDeleted == true)
}
/// Hiding beneath is the renderer's walk, not a stored flag: deleting a lane writes only
/// the lane's own file.
@Test func deletingALaneLeavesItsNestedCardUntouched() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
let laneFolder = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One"))
let cardBefore = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)")
try BoardWriter.deleteItem(at: laneFolder)
#expect(try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") == cardBefore)
}
@Test func loaderRoundTripDeletingACardShowsItDeletedStillInTheSnapshotAtItsRecordedOrder() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
let cardFolder = try fixture.item(
"A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One")
)
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two"))
try BoardWriter.deleteItem(at: cardFolder)
let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban"))
#expect(result.warnings.isEmpty)
let cards = try #require(result.model.lanes.first?.cards)
#expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2])
#expect(cards[0].isDeleted == true)
#expect(cards[0].order == 1024)
#expect(cards[1].isDeleted == false)
}
/// After restore the file carries no residue of `deleted` at all, and the item reappears
/// among its current siblings at the `order` it had all along.
@Test func deleteThenRestoreLeavesNoResidueAndReappearsAtItsRecordedOrder() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("A.kanban", Item.board)
try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
let cardFolder = try fixture.item(
"A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1536", title: "Card One")
)
try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two"))
try BoardWriter.deleteItem(at: cardFolder)
#expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)"))
.deleted.value != nil)
try BoardWriter.restoreItem(at: cardFolder)
let after = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")
#expect(!after.contains("deleted"))
let document = try FrontmatterDocument.parse(after)
#expect(document.deleted == .missing)
#expect(document.order == .valid(1536))
let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban"))
let cards = try #require(result.model.lanes.first?.cards)
#expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2])
#expect(cards[0].isDeleted == false)
}
/// `remove` takes every occurrence, so a hand-duplicated `deleted` line cannot resurrect
/// the tombstone the instant the winning one is gone.
@Test func restoreRemovesAHandDuplicatedDeletedKeyEntirely() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let text = """
---
schema: 1
order: 1024
title: Twice Gone
deleted: 2026-01-01T00:00:00Z
deleted: 2026-06-01T00:00:00Z
---
body
"""
let folder = try fixture.item(Ident.lane1, text)
try BoardWriter.restoreItem(at: folder)
let after = try fixture.indexText(Ident.lane1)
#expect(after.components(separatedBy: "\n").filter { $0.hasPrefix("deleted:") }.isEmpty)
#expect(try FrontmatterDocument.parse(after).deleted == .missing)
}
/// Tombstones are inert to ordering: a sibling deleted via `deleteItem` must not factor into
/// a subsequent create's append target.
@Test func aTombstonedSiblingIsExcludedFromTheAppendRankAfterDeleteItem() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n")
let highOrder = try fixture.item(Child.b, "---\nschema: 1\norder: 9999\ntitle: B\n---\nbody\n")
try BoardWriter.deleteItem(at: highOrder)
let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New")
let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue))
#expect(document.order == .valid(2048))
}
@Test func renumberLeavesATombstonedSiblingByteIdentical() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("lane/\(Child.a)", "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n")
let toDelete = try fixture.item("lane/\(Child.b)", "---\nschema: 1\norder: 3000\ntitle: B\n---\nbody\n")
try BoardWriter.deleteItem(at: toDelete)
let tombstoneAfterDelete = try fixture.indexData("lane/\(Child.b)")
try BoardWriter.renumberVisibleChildren(of: fixture.url("lane"))
#expect(try fixture.indexData("lane/\(Child.b)") == tombstoneAfterDelete)
}
/// Board-root deletion is structurally unreachable at the writer level: a board root's
/// folder name is never UUID-shaped.
@Test func deleteRefusesANonUUIDShapedFolder() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let root = try fixture.item("A.kanban", Item.board)
let error = writeFailure { try BoardWriter.deleteItem(at: root) }
guard case let .unreadable(message) = error?.reason else {
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
return
}
#expect(message.contains("UUID-shaped"))
#expect(try fixture.indexText("A.kanban") == Item.board)
}
@Test func restoreRefusesANonUUIDShapedFolder() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let root = try fixture.item("A.kanban", Item.board)
let error = writeFailure { try BoardWriter.restoreItem(at: root) }
guard case let .unreadable(message) = error?.reason else {
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
return
}
#expect(message.contains("UUID-shaped"))
#expect(try fixture.indexText("A.kanban") == Item.board)
}
/// Comes free via `updateIndex`'s pre-flight: a readable-but-uneditable shape refuses every
/// app-mediated write, delete included.
@Test func deleteOnAnUneditableItemRefuses() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let folder = try fixture.item(Ident.lane1, Fixture.flowMapping)
let error = writeFailure { try BoardWriter.deleteItem(at: folder) }
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
#expect(try fixture.indexText(Ident.lane1) == Fixture.flowMapping)
}
// MARK: Title enrichment (02-architecture.md § Write-failure surfacing)
/// `updateIndex`'s pre-flight read succeeds the shape is readable, only uneditable so by
/// the time the refusal fires, `WriteOperation.withTitle` has already run: the title survives
/// into the thrown error. `Fixture.flowMapping` above has no `title` key at all, which is why
/// this test reaches for `Item.uneditable` instead the fixture that actually carries one.
@Test func deleteOnAnUneditableItemWithAKnownTitleCarriesItInTheOperation() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let folder = try fixture.item(Ident.lane1, Item.uneditable)
let error = writeFailure { try BoardWriter.deleteItem(at: folder) }
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
#expect(error?.operation == .delete(title: "Odd"))
}
/// The negative case: a file that cannot even be read (invalid UTF-8) never gets far enough
/// for `readDocument` to hand back a document, so there is no title to learn the operation
/// stays exactly as its call site constructed it, title `nil`.
@Test func deleteOnAnUnreadableIndexLeavesTheOperationsTitleNil() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let garbage = try #require("---\nschema: 1\ntitle: café\n---\nbody\n".data(using: .isoLatin1))
let folder = try fixture.item(Ident.lane1, bytes: garbage)
let error = writeFailure { try BoardWriter.deleteItem(at: folder) }
guard case .unreadable = error?.reason else {
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
return
}
#expect(error?.operation == .delete(title: nil))
}
}
// MARK: - Purge
/// `BoardWriter.purgeItem`: physical removal Delete Immediately / Empty Trash
/// (01-storage-format.md § Deletion) irreversible, and distinct from tombstoning. Does not
/// require the item to be tombstoned first: Delete Immediately skips that stage by design.
/// (01-storage-format.md § Deletion) irreversible, and distinct from the ordinary delete, which
/// is a *move* into `.trash/`. Does not require the item to be in the trash first: Delete
/// Immediately "skips the trash from anywhere" by design.
struct BoardWriterPurgeTests {
@Test func purgeRemovesTheFolderTreeIncludingNestedContentFromDisk() throws {
let fixture = try WriterFixture()
@@ -2129,7 +1809,7 @@ struct BoardWriterImportAttachmentsTests {
#expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["shot.png"])
}
/// Attachments belong to cards: the shape guard `deleteItem`/`restoreItem`/`purgeItem` share
/// Attachments belong to cards: the shape guard `moveItem`/`copyItem`/`purgeItem` share
/// refuses a board root (or any non-UUID-shaped folder) before `attachments/` is even
/// considered.
@Test func importIntoANonUUIDShapedFolderIsRefused() throws {
+13 -9
View File
@@ -263,22 +263,26 @@ struct StoreWriteCardBodyTests {
#expect(try fixture.indexData(cardPath) == before)
}
@Test("A tombstoned card is still written to, and stays tombstoned")
func aTombstonedCardStillTakesTheFlush() throws {
@Test("A trashed card is still written to, at its new .trash/ location")
func aTrashedCardStillTakesTheFlush() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try BoardWriter.deleteItem(at: fixture.url(cardPath))
try BoardWriter.deleteCardToTrash(at: fixture.url(cardPath), inBoard: fixture.root, order: 1024)
let store = try BoardStore(rootURL: fixture.root)
let trashedPath = ".trash/\(Ident.card1)"
// 05 Deletion & lifecycle: "a dirty Edit buffer flushes into the tombstoned card's folder
// before the window dismisses ... so the keystrokes survive Put Back".
// 05 Deletion & lifecycle, resettled 2026-07-28: "a dirty Edit buffer flushes into the
// card's folder at its new `.trash/` location before the window dismisses ... so the
// keystrokes survive a later restore". `BoardStore.cardBodyTarget` spans both containers for
// exactly this, which is why the store finds the card by id with no hint of where it went.
let outcome = store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "Typed as it went.\n")
#expect(outcome == .written)
#expect(try body(of: fixture, cardPath) == "Typed as it went.\n")
// Surgical: the write replaced the body span, so the tombstone is still standing and Put
// Back still has something to put back.
#expect(try FrontmatterDocument.parse(fixture.indexText(cardPath)).deleted.value != nil)
#expect(try body(of: fixture, trashedPath) == "Typed as it went.\n")
// Surgical: the write replaced the body span and nothing else, so the card is otherwise
// exactly as the delete left it a later restore brings the keystrokes back with it.
#expect(try FrontmatterDocument.parse(fixture.indexText(trashedPath)).title == .valid("Notes"))
#expect(try fixture.indexText(trashedPath).contains("project: lanework # agent overlay"))
}
@Test("A card that is not in the board at all reports vanished, and writes nowhere")
+13 -2
View File
@@ -176,8 +176,19 @@ struct BaseInertGitTests {
order: nil,
stamps: .fork
)
try BoardWriter.deleteItem(at: board.url("\(board.laneA)/\(board.card1)"))
try BoardWriter.restoreItem(at: board.url("\(board.laneA)/\(board.card1)"))
// The delete and its restore are both folder moves now (03-board-ui.md § Trash), which is
// the pair most likely to notice a `.git` at the root: the first walks into `<root>/.trash/`
// and the second walks back out of it.
try BoardWriter.deleteCardToTrash(
at: board.url("\(board.laneA)/\(board.card1)"), inBoard: board.root, order: 1024
)
_ = try BoardWriter.moveItem(
at: BoardWriter.trashFolder(inBoard: board.root).appendingPathComponent(board.card1),
toParent: board.url(board.laneA),
sourceBoardRoot: board.root,
destinationBoardRoot: board.root,
order: nil
)
// The renumbers are the pointed ones: both walk a parent's whole directory listing, which
// is where a `.git` entry actually gets looked at.
try BoardWriter.renumberVisibleChildren(of: board.root)
+22
View File
@@ -336,6 +336,28 @@ struct SearchFilterOrderTests {
#expect(SearchFilter.inactive.visibleIDs(in: model, container: .trash) == [card1, card2])
}
/// The trash *column* narrows through the same predicate, which is the whole of "shown, its cards
/// participate in the filter exactly like any other card the point of the pivot"
/// (03-board-ui.md § Trash). `LaneView.rendered`'s trash-side twin, pinned the same way: the
/// column, its count badge and its marquee registration all read this list, so one answer keeps
/// them in step.
@Test("The trash column renders exactly what navigation and Select All walk")
func trashColumnRendersTheFilteredCards() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(TrashLaneView.rendered(model.trash, filter: .inactive).map(\.id) == [card1, card2])
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "login")).map(\.id) == [card1])
// The column and the arrow grammar cannot disagree about what is on screen.
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "login")).map(\.id)
== SelectionGrammar.trashCards(in: model, filter: SearchFilter(query: "login")))
// A query nothing matches empties the column without emptying the container which is why
// Empty Trash's validation reads `.trash/` and not this list.
#expect(TrashLaneView.rendered(model.trash, filter: SearchFilter(query: "zzzz")).isEmpty)
#expect(!model.trash.isEmpty)
}
@Test("The delete successor is drawn from what the lane is showing")
func successorSkipsHiddenSiblings() throws {
let fixture = try makeBoard()
+216
View File
@@ -829,3 +829,219 @@ struct TrashConfirmationsTests {
#expect(confirmations.pending == nil)
}
}
// MARK: - The menu-validation seams
/// The trash's three File-menu rows, validated as predicates rather than as menu items 11-command
/// -nexus.md's inventory, and 03-board-ui.md § Trash's rulings about scope.
///
/// The rows themselves are `TrashCommands`, whose whole body is one `disabled()` per row over these
/// answers; what is worth pinning is the answers. `TrashModel.canDelete`/`canDeleteImmediately` are
/// pinned as pure functions in `TrashModelTests`; this suite covers the two seams that need a live
/// store Empty Trash's scope, and the staging a actually performs.
@MainActor
@Suite("The trash's menu validation")
struct TrashMenuValidationTests {
/// 11-command-nexus.md: "Board window, trash shown and non-empty (whole-trash scope,
/// search-independent)"; 03 § Trash: "menu validation's 'non-empty' reads `.trash/`, not the
/// filtered view".
@Test("Empty Trash needs the column shown and the container non-empty — never the filtered view")
func emptyTrashValidation() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Hidden, the trash "is invisible to every gesture" the command included.
#expect(!store.canEmptyTrash)
store.transient.isTrashVisible = true
#expect(store.canEmptyTrash)
// A query that hides every trash card leaves it enabled: the scope is the container, not
// what is on screen.
store.searchQuery = "zzzz-nothing-matches"
#expect(store.searchFilter.visibleIDs(in: store.snapshot, container: .trash).isEmpty)
#expect(store.canEmptyTrash)
// And the confirmation still names the true count, for the same reason.
let prompt = try #require(TrashModel.emptyTrashPrompt(in: store.snapshot, unrecoverable: true))
#expect(prompt.title == "Permanently delete 2 cards?")
}
@Test("An empty container disables it however visible the column is")
func emptyTrashNeedsCards() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
#expect(!store.canEmptyTrash)
}
/// **One Delete, staged by place** 04-interactions.md The map: "File Delete is the chord's
/// only owner no twin menu items, no shared-equivalent routing". Put Back's twin is retired,
/// so exactly one predicate enables the row and the selection's *container* decides which write
/// it performs.
@Test("Delete is one enabled row on both sides, and the container picks the write")
func deleteIsOneRowStagedByPlace() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
store.select([card1], in: .board)
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
confirmations.requestDelete(in: store)
// The board staging: a move, no alert, the card now in the trash.
#expect(confirmations.pending == nil)
#expect(fixture.exists(".trash/\(Ident.card1)"))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
store.select([trashed], in: .trash)
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
confirmations.requestDelete(in: store)
// The trash staging: permanent, and behind the alert.
let pending = try #require(confirmations.pending)
#expect(pending.action == .deleteTrashCards([trashed]))
}
/// A context menu names its target by where it was invoked, so the trash row's Delete must purge
/// the clicked card even while a *board* selection stands the case a selection-reading path
/// would silently no-op on (`TrashConfirmations.requestTrashDelete`).
@Test("The trash card's context-menu Delete acts on its own target, not on the selection")
func contextMenuDeleteIgnoresTheSelection() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
store.select([card1], in: .board)
confirmations.requestTrashDelete(of: [trashed], in: store)
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
#expect(pending.action == .deleteTrashCards([trashed]))
confirmations.confirm(in: store)
#expect(!fixture.exists(".trash/\(Ident.indexless)"))
// The board selection was never the subject and is untouched on disk.
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
}
}
// MARK: - Everything edit-shaped refuses a trash selection
/// 04-interactions.md The trash: "Everything edit-shaped is disabled on trash selections Open
/// Card, Rename, Style". Each of the three answers with one expression used for both its `disabled`
/// state and its action, which is what this suite drives (`BoardStore.openCardTarget`,
/// `.renameTarget`, `.boardStyleTarget`).
@MainActor
@Suite("Edit-shaped commands on a trash selection")
struct TrashGrammarExclusionTests {
@Test("Open Card takes a sole board card and nothing else")
func openRefusesTheTrash() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1], in: .board)
#expect(store.openCardTarget == card1)
// "Trash cards don't open double-click stops at selection; move it out first" (03 § Trash).
store.select([trashed], in: .trash)
#expect(store.openCardTarget == nil)
store.select([trashed, newer], in: .trash)
#expect(store.openCardTarget == nil)
// A lane and a multi-selection refuse too a card window is tied to one card.
store.select([lane1], in: .board)
#expect(store.openCardTarget == nil)
store.select([card1, card2], in: .board)
#expect(store.openCardTarget == nil)
}
@Test("Rename takes a sole board item, card or lane, and never a trash card")
func renameRefusesTheTrash() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1], in: .board)
#expect(store.renameTarget?.id == card1)
#expect(store.renameTarget?.title == "First")
store.select([lane1], in: .board)
#expect(store.renameTarget?.id == lane1)
store.select([trashed], in: .trash)
#expect(store.renameTarget == nil)
}
/// The one with a fall-through worth guarding: an empty selection styles *the board*, so a trash
/// selection has to disable rather than land there "quietly restyling the board because the
/// user had a trashed card selected would be the silent retarget 03 forbids".
@Test("Style… falls through to the board on an empty selection, but a trash selection disables it")
func styleRefusesTheTrashWithoutFallingThrough() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.clearSelection()
#expect(store.boardStyleTarget == .board)
store.select([card1], in: .board)
#expect(store.boardStyleTarget == .items([card1]))
store.select([trashed], in: .trash)
#expect(store.boardStyleTarget == nil)
}
/// The trash's Delete is not edit-shaped and neither is Reveal, so both stay available the two
/// rows 11-command-nexus.md gives a trash card, and no others.
@Test("Delete and Reveal are what a trash selection keeps")
func whatTheTrashKeeps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([trashed], in: .trash)
#expect(TrashModel.canDelete(selection: store.selection, in: store.snapshot))
#expect(TrashModel.canDeleteImmediately(selection: store.selection, in: store.snapshot))
#expect(ItemPath.resolve(store.selection.ids, in: .trash, snapshot: store.snapshot)
.map { $0.folder(under: store.rootURL).lastPathComponent } == [Ident.indexless])
}
}
// MARK: - Put Back is gone
/// 03-board-ui.md § Trash: "**No Put Back** (settled) Restoring is an ordinary move out". The
/// retirement is mostly a compile-time fact there is no `putBack` on the store, no restore write on
/// the Writer, and no second -titled menu row so what is left to state at runtime is that a
/// restore registers, phrases and writes as the ordinary move it now is.
@MainActor
@Suite("Put Back is retired")
struct PutBackRetirementTests {
@Test("The undo vocabulary has no restore verb — a restore is a Move")
func noRestoreVerb() {
#expect(!HistoryPhrase.Verb.allCases.map(\.rawValue).contains("Restore"))
#expect(!HistoryPhrase.Verb.allCases.map(\.rawValue).contains("Put Back"))
// What a drag-out or a X/V restore actually reads as in the Edit menu.
#expect(HistoryPhrase.name(.move, kind: .card) == "Move Card")
}
/// One predicate for both stagings, because there is only one item: the mirror-image pair that
/// existed to make two twins enable exactly one of themselves retired with Put Back.
@Test("One Delete predicate covers both containers")
func oneDeletePredicate() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try loaded(fixture)
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot))
#expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [trashed], container: .trash), in: snapshot))
}
}
+43 -71
View File
@@ -26,9 +26,7 @@ import Testing
/// What follows fills the remaining gaps: minimal-touch stated with **mtimes**, not just bytes
/// (existing sibling-byte assertions never look at the filesystem's own "did this file move"
/// signal); the renumber fallback stated as the *positive* exception across a whole board rather
/// than within one lane; deleterestore at full byte precision against the pre-delete original
/// (existing coverage checks the *result* is undeleted and reordered correctly, not that the
/// bytes differ from the original by exactly one line); unknown-key order through a writer op
/// than within one lane; unknown-key order through a writer op
/// with keys deliberately interleaved among schema-owned ones (existing coverage groups the
/// unknown keys together); and one end-to-end composite scenario tying every guarantee together.
@@ -110,8 +108,19 @@ struct WriteFidelityMinimalTouchTests {
snapshots[folder] = try snapshot(fixture, folder)
}
func step(_ label: String, targeting targets: Set<String>, _ operation: () throws -> Void) throws {
/// `departed` names folders the step moved *out of the enumerated tree* which, since
/// `allIndexFolders` skips hidden folders, is exactly what a delete now is: the card's folder
/// travels into `<root>/.trash/` (03-board-ui.md § Trash, resettled 2026-07-28) and stops
/// being visible here. They leave the tracked set rather than being asserted about, because
/// "untouched" is a claim about the files that stayed.
func step(
_ label: String,
targeting targets: Set<String>,
departed: Set<String> = [],
_ operation: () throws -> Void
) throws {
try operation()
for folder in departed { snapshots[folder] = nil }
for (folder, before) in snapshots where !targets.contains(folder) {
let after = try snapshot(fixture, folder)
@@ -144,11 +153,24 @@ struct WriteFidelityMinimalTouchTests {
inItemFolder: fixture.url("\(Ident.lane2)/\(Ident.card3)"), operation: .style(title: nil)
) { $0.set(FrontmatterKeys.title, to: .string("Renamed Three")) }
}
try step("delete", targeting: ["\(Ident.lane2)/\(Ident.card4)"]) {
try BoardWriter.deleteItem(at: fixture.url("\(Ident.lane2)/\(Ident.card4)"))
// The delete is a **move** into `.trash/` (03-board-ui.md § Trash): the card's folder leaves
// the visible tree entirely, and every index that stayed behind must be untouched mtime
// included, which is the point of this harness.
try step("delete", targeting: [], departed: ["\(Ident.lane2)/\(Ident.card4)"]) {
try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane2)/\(Ident.card4)"), inBoard: fixture.root, order: 1024
)
}
try step("restore", targeting: ["\(Ident.lane2)/\(Ident.card4)"]) {
try BoardWriter.restoreItem(at: fixture.url("\(Ident.lane2)/\(Ident.card4)"))
// The restore is an ordinary move out "there is no restore-specific machinery and no Put
// Back" (03 § Trash) so the folder simply comes back, and again nothing else moves.
try step("restore", targeting: []) {
_ = try BoardWriter.moveItem(
at: BoardWriter.trashFolder(inBoard: fixture.root).appendingPathComponent(Ident.card4),
toParent: fixture.url(Ident.lane2),
sourceBoardRoot: fixture.root,
destinationBoardRoot: fixture.root,
order: 2048
)
}
var createdCard = ""
@@ -219,65 +241,6 @@ struct WriteFidelityRenumberTests {
}
}
// MARK: - Delete restore, byte precision
/// 01-storage-format.md § Deletion: Put Back "undoes exactly what `deleteItem` wrote". Existing
/// coverage (`BoardWriterDeleteRestoreTests`) checks the *result* undeleted, reordered
/// correctly this test checks the *bytes*: after a full deleterestore round trip, the file
/// differs from the pre-delete original in exactly one place, the `modified:` line, with every
/// comment, inline comment, unknown key, and per-line ending untouched, and zero occurrences of
/// `deleted` anywhere in the text.
struct WriteFidelityTombstoneTests {
@Test func deleteThenRestoreDiffersFromTheOriginalOnlyInTheModifiedTimestamp() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
let original = "---\n"
+ "schema: 1\n"
+ "# a hand-written note\n"
+ "title: Original\n"
+ "order: 1536\n"
+ "project: lanework # agent overlay\n"
+ "sphere: work\r\n"
+ "labels: [a, b, c]\n"
+ "modified: 2026-01-01T00:00:00Z\n"
+ "---\n"
+ "Body text.\n\nMore body — with *markdown*.\n"
let cardPath = "\(Ident.lane1)/\(Ident.card1)"
let folder = try fixture.item(cardPath, original)
try fixture.item("\(Ident.lane1)/\(Ident.card2)", "---\nschema: 1\norder: 2048\ntitle: Sibling\n---\n")
try BoardWriter.deleteItem(at: folder)
#expect(try FrontmatterDocument.parse(fixture.indexText(cardPath)).deleted.value != nil)
try BoardWriter.restoreItem(at: folder)
let after = try fixture.indexText(cardPath)
#expect(lines(of: after, excludingKeys: [FrontmatterKeys.modified])
== lines(of: original, excludingKeys: [FrontmatterKeys.modified]))
// The untouched CRLF unknown-key line and the inline comment both survived verbatim.
#expect(after.contains("sphere: work\r\n"))
#expect(after.contains("project: lanework # agent overlay\n"))
// No residue of the key that made this item a tombstone, anywhere in the text.
#expect(!after.contains("deleted"))
let document = try FrontmatterDocument.parse(after)
#expect(document.deleted == .missing)
#expect(document.order == .valid(1536))
#expect(document.title == .valid("Original"))
// Position among siblings unchanged: the loader sees the card back at its recorded
// order, ahead of the sibling that was never touched.
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.warnings.isEmpty)
let cards = try #require(result.model.lanes.first?.cards)
#expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2])
#expect(cards[0].isDeleted == false)
#expect(cards[0].order == 1536)
}
}
// MARK: - Unknown-key order through a writer op
/// 01-storage-format.md § Fractal layout Rules, "unknown frontmatter keys and their order are
@@ -380,10 +343,19 @@ struct WriteFidelityCompositeTests {
)
#expect(copyID != card3)
// Delete, then restore, the hand-edited card its unknown keys and comment must come
// back with no residue of `deleted`.
try BoardWriter.deleteItem(at: lane1Folder.appendingPathComponent(card2.rawValue))
try BoardWriter.restoreItem(at: lane1Folder.appendingPathComponent(card2.rawValue))
// Delete, then restore, the hand-edited card two folder moves now (03-board-ui.md § Trash,
// resettled 2026-07-28), so its unknown keys, its comment and its body must ride along
// untouched through both legs.
try BoardWriter.deleteCardToTrash(
at: lane1Folder.appendingPathComponent(card2.rawValue), inBoard: root, order: 1024
)
_ = try BoardWriter.moveItem(
at: BoardWriter.trashFolder(inBoard: root).appendingPathComponent(card2.rawValue),
toParent: lane1Folder,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: nil
)
// Renumber both lanes the fallback that touches every visible sibling's `order`.
try BoardWriter.renumberVisibleChildren(of: lane1Folder)