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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user