Build the trash quasi-lane

Deletion becomes a two-stage, Finder-style story. File > Delete and
plain Backspace tombstone the live selection; View > Show Trash (no
chord — Shift-Cmd-T stays with the system's tab bar) reveals the
quasi-lane: trailing, one fixed width unit consumed only while shown,
hatched dimmed header, count badge, no new-card button, exempt from
resize and reorder alike. Its contents are a pure view over the
snapshot — the deterministic sort (deleted newest first, folder-name
ties, unparseable stamps oldest) interleaves card rows with a
tombstoned lane's single entry, whose count names what Put Back
returns; the ancestor walk is absolute, so an own-flag card beneath a
tombstoned lane has no row and recovery is deliberately two steps.
Put Back twins Delete on Cmd-Backspace with validation enabling
exactly one; restore fidelity is byte-perfect because nothing ever
moved. Delete Immediately confirms exactly where loss is real (every
board is mode-none today; the predicate names the git carve-out for
m7), Empty Trash always confirms with the true whole-board count,
and dragging a tombstoned card onto a live lane restores it there —
positional drops and cross-board locality arrive with m5's machinery.
The banner's delete phrasing drops "move to the trash" per the naming
constraint: board deletion says Delete, "Move to Trash" stays
reserved for the system Trash. 47 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 15:38:34 -04:00
parent aa6aaf2a11
commit 2090d742b9
14 changed files with 2428 additions and 18 deletions
+518
View File
@@ -0,0 +1,518 @@
import Foundation
import Testing
@testable import Kanban
/// `BoardStore`'s trash operations Delete, Put Back, Delete Immediately, Empty Trash, and
/// drag-to-restore (03-board-ui.md § Trash).
///
/// These drive a **real store over a real temp board** and then read the **raw bytes** back, never
/// the app's own read path, like every other write suite here: the interesting claims are about the
/// files which key lands, which stamps follow it, which folders survive, and what comes through
/// byte-for-byte. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
// MARK: - Fixtures
private func tombstoned(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
project: lanework # agent overlay
created: 2026-01-01T09:00:00Z
deleted: 2026-03-03T09:00:00Z
---
\(title) body.
"""
}
/// Two live lanes with two cards each, plus one already-tombstoned card and one already-tombstoned
/// lane holding a live card and an own-flagged one enough for every rule in this file.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
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.card2)", tombstoned(order: "2048", title: "Trashed"))
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.lane3, tombstoned(order: "3072", title: "Gone"))
try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Rides along"))
try fixture.item("\(Ident.lane3)/\(Ident.indexless)", tombstoned(order: "2048", title: "Own flag"))
return fixture
}
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let lane3 = ItemID(rawValue: Ident.lane3)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
private let card4 = ItemID(rawValue: Ident.card4)
private let ownFlag = ItemID(rawValue: Ident.indexless)
/// The file's lines minus the ones an app-mediated write is *supposed* to change what must come
/// through a tombstone or a Put Back byte-for-byte, in order.
private func untouchedLines(_ text: String) -> [Substring] {
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
!$0.hasPrefix("modified") && !$0.hasPrefix("deleted:")
}
}
/// A file's bytes and mtime "minimal touch" stated the way `WriteFidelityTests` states it.
private func stat(_ fixture: WriterFixture, _ relativePath: String) throws -> (data: Data, modified: Date) {
let indexURL = fixture.url(relativePath).appendingPathComponent("index.md")
let data = try Data(contentsOf: indexURL)
let attributes = try FileManager.default.attributesOfItem(atPath: indexURL.path)
guard let modified = attributes[.modificationDate] as? Date else {
Issue.record("no modification date for \(relativePath)")
return (data, .distantPast)
}
return (data, modified)
}
@MainActor
private func reload(_ store: BoardStore) async {
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
}
// MARK: - Delete
@MainActor
@Suite("BoardStore ▸ delete")
struct TrashDeleteTests {
@Test("Delete stamps deleted and modified, clears modified-by, and touches nothing else")
func tombstonesAndStamps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
store.delete([card1])
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
let document = try FrontmatterDocument.parse(after)
#expect(document.deleted.value != nil)
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
// Everything the write does not own survives exactly, in order the unknown key with its
// inline comment, the reserved `labels`, the original `created`, and the body.
#expect(untouchedLines(after) == untouchedLines(before))
#expect(store.banners.oneShots.isEmpty)
}
@Test("A multi-item delete is one bracket, and a lane's tombstone rewrites only the lane")
func batchAndLaneMinimalTouch() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let card = try stat(fixture, "\(Ident.lane2)/\(Ident.card3)")
store.delete([lane2, card1])
// Both landed.
#expect(try FrontmatterDocument.parse(fixture.indexText(Ident.lane2)).deleted.value != nil)
#expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)"))
.deleted.value != nil)
// Hiding the subtree is the renderer's ancestor walk, not a stored flag: the lane's card is
// untouched, bytes *and* mtime.
let cardAfter = try stat(fixture, "\(Ident.lane2)/\(Ident.card3)")
#expect(cardAfter.data == card.data)
#expect(cardAfter.modified == card.modified)
}
@Test("Delete clears the selection")
func clearsSelection() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1], liveness: .live)
store.deleteSelection()
// m5's successor-selection grammar replaces this; until then, what was selected renders
// nowhere and the selection says so.
#expect(store.selection.isEmpty)
}
@Test("Already-tombstoned ids are skipped rather than re-stamped, and an empty set writes nothing")
func liveOnly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let trashed = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)")
let underTombstonedLane = try stat(fixture, "\(Ident.lane3)/\(Ident.card4)")
// `card2` carries its own flag; `card4` is live by its own flag but its lane is tombstoned,
// so effective liveness puts it on the trashed side too.
store.delete([card2, card4])
store.delete([])
store.delete([ItemID(rawValue: "00000000-0000-4000-8000-000000000000")])
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashed.modified)
#expect(try stat(fixture, "\(Ident.lane3)/\(Ident.card4)").modified == underTombstonedLane.modified)
#expect(store.banners.oneShots.isEmpty)
}
@Test("A read-only board refuses the delete without a second banner")
func readOnlyRefusesQuietly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.enterVanishedRootLock()
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
store.delete([card1])
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
#expect(store.banners.oneShots.isEmpty, "the lock row is already standing")
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
}
@Test("A readable-but-uneditable item refuses the write, banners it, and keeps its bytes")
func uneditableBanners() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.uneditable)
let store = try BoardStore(rootURL: fixture.root)
store.delete([card1])
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == Item.uneditable)
let posted = try #require(store.banners.oneShots.first)
#expect(posted.error.operation == .delete(title: "Odd"))
#expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't delete 'Odd' — "))
}
}
// MARK: - Put Back
@MainActor
@Suite("BoardStore ▸ put back")
struct TrashPutBackTests {
@Test("A delete→Put Back round trip differs from the original only in the modified timestamp")
func roundTripFidelity() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let path = "\(Ident.lane1)/\(Ident.card1)"
let original = try fixture.indexText(path)
store.delete([card1])
await reload(store)
store.putBack([card1])
let after = try fixture.indexText(path)
// Restore fidelity is perfect because nothing ever moved: no residue of the key that made
// this a tombstone, and every other line the inline comment included as written.
#expect(!after.contains("deleted"))
#expect(untouchedLines(after) == untouchedLines(original))
#expect(try FrontmatterDocument.parse(after).order == .valid(1024))
// Position among siblings survives byte-for-byte, which is what "at its old order" means.
let cards = try #require(BoardLoader.load(boardRoot: fixture.root).model.lanes.first?.cards)
#expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2])
#expect(cards[0].isDeleted == false)
}
@Test("Putting back a lane splits its contents by flag — own-flag cards stay tombstoned")
func laneSplitsByFlag() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let ridesAlong = try stat(fixture, "\(Ident.lane3)/\(Ident.card4)")
let ownFlagged = try stat(fixture, "\(Ident.lane3)/\(Ident.indexless)")
store.putBack([lane3])
// Only the lane's own file was rewritten. The card that rides along was never flagged, so
// it simply reappears; the own-flagged one keeps its key and its row moves back to the
// trash recovering it is deliberately a second Put Back.
#expect(!(try fixture.indexText(Ident.lane3).contains("deleted")))
#expect(try stat(fixture, "\(Ident.lane3)/\(Ident.card4)").modified == ridesAlong.modified)
#expect(try stat(fixture, "\(Ident.lane3)/\(Ident.indexless)").modified == ownFlagged.modified)
let model = try BoardLoader.load(boardRoot: fixture.root).model
let lane = try #require(model.lanes.first { $0.id == lane3 })
#expect(!lane.isDeleted)
#expect(lane.cards.first { $0.id == card4 }?.isDeleted == false)
#expect(lane.cards.first { $0.id == ownFlag }?.isDeleted == true)
// And the own-flagged card now has a row of its own, which it did not while the lane was
// tombstoned.
#expect(TrashModel.entries(of: model).map(\.id).contains(ownFlag))
}
@Test("Put Back on a live item, an empty set, or an unknown id writes nothing")
func noOps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let live = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
store.putBack([card1])
store.putBack([])
store.putBack([ItemID(rawValue: Ident.indexless.uppercased())])
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == live.modified)
}
}
// MARK: - Delete Immediately and Empty Trash
@MainActor
@Suite("BoardStore ▸ purge")
struct TrashPurgeTests {
@Test("Delete Immediately removes the folder and leaves everything else alone")
func purgeRemovesTheFolder() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let sibling = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
store.deleteImmediately([card2])
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == sibling.modified)
#expect(store.selection.isEmpty)
#expect(store.banners.oneShots.isEmpty)
}
@Test("Purging a lane takes its whole folder, tombstoned cards and all")
func purgingALaneTakesItsSubtree() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.deleteImmediately([lane3])
#expect(!fixture.exists(Ident.lane3))
#expect(!fixture.exists("\(Ident.lane3)/\(Ident.card4)"))
#expect(!fixture.exists("\(Ident.lane3)/\(Ident.indexless)"))
#expect(fixture.exists(Ident.lane1), "the live lanes are untouched")
}
@Test("Delete Immediately never reaches a live item")
func purgeIsTrashedOnly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.deleteImmediately([card1, lane1])
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(fixture.exists(Ident.lane1))
}
@Test("Empty Trash purges every tombstone on the board, own-flag cards under a lane included")
func emptyTrashPurgesEverything() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.emptyTrash()
// The tombstoned card under a live lane, the tombstoned lane, and through the lane's own
// folder the card that carried its own flag inside it.
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(!fixture.exists(Ident.lane3))
#expect(!fixture.exists("\(Ident.lane3)/\(Ident.indexless)"))
// Everything live survives, and the board now has an empty trash.
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card3)"))
let model = try BoardLoader.load(boardRoot: fixture.root).model
#expect(TrashModel.isEmpty(model))
#expect(store.selection.isEmpty)
}
@Test("Empty Trash on a board with no tombstones writes nothing")
func emptyTrashOnACleanBoard() 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)", Item.rich(order: "1024", title: "First"))
let store = try BoardStore(rootURL: fixture.root)
let card = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
store.emptyTrash()
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == card.modified)
#expect(store.banners.oneShots.isEmpty)
}
@Test("Every board is unrecoverable today, so Delete Immediately always confirms")
func purgeIsUnrecoverableEverywhere() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// m7-git: the git milestone is what makes this answer `false` for some boards.
#expect(store.purgeIsUnrecoverable)
}
}
// MARK: - Drag to restore
@MainActor
@Suite("BoardStore ▸ drag to restore")
struct TrashDragRestoreTests {
@Test("A drop back on the card's own lane removes the key only — the folder never moves")
func sameLaneIsAPutBack() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let original = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
store.restoreByDrag(cardID: card2, intoLane: lane1)
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(!after.contains("deleted"))
// The `order` is untouched, so the card returns where it was rather than at the bottom
// the position-perfect restore a pure-view trash makes possible.
#expect(try FrontmatterDocument.parse(after).order == .valid(2048))
#expect(untouchedLines(after) == untouchedLines(original))
}
@Test("A drop on another lane restores and appends at that lane's bottom, in one bracket")
func crossLaneMovesAndAppends() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.restoreByDrag(cardID: card2, intoLane: lane2)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card2)"))
let after = try fixture.indexText("\(Ident.lane2)/\(Ident.card2)")
#expect(!after.contains("deleted"))
// m5-drag: the positional drop replaces this append. Lane two's one visible card is at
// 1024, so the arrival lands at 2048 the Writer's own append over visible siblings.
#expect(try FrontmatterDocument.parse(after).order == .valid(2048))
let model = try BoardLoader.load(boardRoot: fixture.root).model
let lane = try #require(model.lanes.first { $0.id == lane2 })
#expect(lane.cards.map(\.id.rawValue) == [Ident.card3, Ident.card2])
#expect(lane.cards.allSatisfy { !$0.isDeleted })
#expect(TrashModel.isEmpty(model) == false, "the tombstoned lane is still in the trash")
}
@Test("A drop that names nothing droppable writes nothing")
func noOps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let trashedCard = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)")
let liveCard = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
// A tombstoned destination lane is never a drop target (04 Drag and drop: "a card is
// never filed under a `deleted:` parent").
store.restoreByDrag(cardID: card2, intoLane: lane3)
// A lane that is not on the board at all.
store.restoreByDrag(cardID: card2, intoLane: ItemID(rawValue: Ident.indexless))
// A card that is not a trash row: live, and for `card4` hidden by its lane rather than
// by its own flag, so it has no row to drag in the first place.
store.restoreByDrag(cardID: card1, intoLane: lane2)
store.restoreByDrag(cardID: card4, intoLane: lane1)
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashedCard.modified)
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == liveCard.modified)
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card4)"))
#expect(store.banners.oneShots.isEmpty)
}
@Test("A read-only board refuses the drop")
func readOnlyRefuses() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.enterVanishedRootLock()
store.restoreByDrag(cardID: card2, intoLane: lane2)
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("deleted:"))
}
}
// MARK: - The confirmations
@MainActor
@Suite("TrashConfirmations")
struct TrashConfirmationsTests {
@Test("Delete Immediately raises the alert where the loss is real, and purges on confirm")
func purgeConfirmsThenActs() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
confirmations.requestPurge(of: [card2], in: store)
let pending = try #require(confirmations.pending)
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
#expect(pending.action == .purge([card2]))
// Nothing has happened yet the alert is what stands between the keystroke and the loss.
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
confirmations.confirm(in: store)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
#expect(confirmations.pending == nil)
// Idempotent: the binding's own dismissal fires an instant after the button.
confirmations.confirm(in: store)
}
@Test("Cancelling dismisses and writes nothing")
func cancelWritesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
confirmations.requestPurge(of: [card2], in: store)
confirmations.cancel()
#expect(confirmations.pending == nil)
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
}
@Test("Empty Trash always confirms, and its scope is the whole trash")
func emptyTrashConfirms() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let confirmations = TrashConfirmations()
confirmations.requestEmptyTrash(in: store)
let pending = try #require(confirmations.pending)
#expect(pending.action == .emptyTrash)
#expect(pending.prompt.title == "Permanently delete 1 lane and 1 card?")
confirmations.confirm(in: store)
#expect(TrashModel.isEmpty(try BoardLoader.load(boardRoot: fixture.root).model))
}
@Test("Neither command raises an alert with nothing to act on")
func nothingToConfirm() 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)
let confirmations = TrashConfirmations()
confirmations.requestEmptyTrash(in: store)
#expect(confirmations.pending == nil)
confirmations.requestPurge(of: [lane1], in: store)
#expect(confirmations.pending == nil)
}
}