Files
lanework/Kanban/UI/Board/TrashLaneView.swift
T
rzen 53bc71f7fb 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
2026-07-28 17:47:56 -04:00

539 lines
25 KiB
Swift

import AppKit
import Observation
import SwiftUI
// MARK: - TrashLaneView
/// The trash column: the trailing, visually distinct column the board's `.trash/` cards live in
/// (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized trash).
///
/// ### A rendering of `snapshot.trash`, and a quasi-lane
///
/// **Its contents are ordinary cards in a special place**, so there is nothing to derive: the column
/// renders `store.snapshot.trash`, which the loader parsed with the same card parse the lanes use
/// and sorted by `order` like any lane's children. 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
/// 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);
/// - 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
/// snapshot's lanes;
/// - it has **no new-card button**: nothing is created in the trash.
///
/// ### The drop it takes, and the drag it starts
///
/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md ▸ The trash) — the
/// drag becomes the pointer's delete gesture, and release moves the dragged card(s) into `.trash/`.
/// So the column declares an `onDrop` (`TrashDropDelegate`), and it is the narrowest one on the
/// board: a board card drag from **this** board, unmodified. It diverges from every other drop in one
/// way, and the ranks are what make the divergence honest: **the shadow always takes the topmost
/// row**, because every arrival mints a rank above the current top.
///
/// The drag *out* is the restore, and it is deliberately not special: a trash card's drag is an
/// ordinary `.cards` session in the `.trash` container, and `BoardDropContext.commitDrop` hands it
/// to the same `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an
/// ordinary move out … there is no restore-specific machinery and no Put Back" (03 § Trash).
///
/// **Finder file drops stay inert** — "Finder file drops on trash cards are inert" (▸ The trash) —
/// and say so directly: the delegate clears the file highlight over the column.
///
/// ### No editing in the trash
///
/// "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.
///
/// ### What is still phase 3's
///
/// The column renders the materialized trash correctly and its selection, drag, drop, filter and
/// context menu all speak the new container vocabulary — but its *visual* treatment is still the
/// tombstone era's compact dimmed plate rather than the card face 03 now implies ("a trashed card is
/// an ordinary card in a special place"). Reworking the plate into the ordinary face, and the
/// accessibility labelling 10-accessibility.md asks for, is the trash's own phase-3 card.
struct TrashLaneView: View {
let store: BoardStore
/// The window's purge-alert host, threaded down rather than read from the focus system: a
/// context menu's content is built in its own host, where a `@FocusedValue` is not reliably the
/// board window's, and the row's Delete Immediately must raise the *same* alert the menu bar's
/// does.
let confirmations: TrashConfirmations
/// The board window's drop machinery — a card's drag is an ordinary card session in the
/// **trash** container (`DragSession`, 04-interactions.md ▸ The trash).
let drops: BoardDropContext
/// The strip's rubber band. The column's empty space is its third surface, in the **trash**
/// container — "the rubber band stays on the side it started on" (04-interactions.md ▸ The
/// trash) — and every row registers its frame into the same registry.
let marquee: MarqueeControl
/// Reduce Motion, for the row transition below — 10-accessibility.md names the trash
/// specifically ("and trash animations all get reduced variants").
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// The lane plate's corner radius — matched to `LaneView`'s so the column reads as a sibling of
/// the lanes rather than as a different kind of object.
private let cornerRadius: CGFloat = 10
private let rowSpacing: CGFloat = 6
/// The height a shadow row holds open. A trash row's height is content-driven (one or two title
/// lines) and the cards being proposed have no row yet to be measured, so the shadow is drawn at
/// the nominal single-line plate — `LaneDropRegistry`'s own answer to the same question, in this
/// column's smaller idiom.
private let nominalRowHeight: CGFloat = 32
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
rows
}
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(.quaternary.opacity(0.35))
)
// **The delete gesture's drop target**, over the whole column — the header included, since
// the ruling is "dropping a live card on the shown trash", not on one of its rows. It
// declares every type the board's other targets do, because single-target dispatch hands the
// deepest region whatever session is in flight and a narrower target would strand the rest
// (`TrashDropDelegate`).
.onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops))
}
/// The cards the column shows.
///
/// **Shown, the trash's cards "participate in the filter exactly like any other card"**
/// (03-board-ui.md § Trash — "the point of the pivot"), so the search predicate narrows this
/// collection exactly as it narrows `LaneView.renderedCards`, and the count badge follows for
/// free because it reads this same value. Hidden, the column renders nothing and registers
/// nothing, so "hidden trash is invisible to search" needs no code at all.
private var cards: [Card] {
let filter = store.searchFilter
return store.snapshot.trash.filter { filter.matches($0) }
}
// MARK: - The delete gesture's landing
/// Where the delete gesture's shadow run opens in this column — always the topmost row — or `nil`
/// when no proposal names the trash, which is every other moment of the app's life.
private var proposal: Int? {
drops.session.trashProposal(onBoardRooted: store.rootURL)
}
/// The rows the `VStack` lays out: the entries, with the delete gesture's shadow run opened at
/// the top.
///
/// The run stands until the echo reload brings the real tombstones — the committed-overlay hold
/// keeps the arrangement the release proposed on screen for that round trip, exactly as every
/// other container's does (`CommittedHold`).
private var slots: [TrashSlot] {
var result = cards.map(TrashSlot.card)
guard let proposal else { return result }
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
return result
}
// MARK: - Header
/// Dimmed and hatched, with the trash symbol, the stable "Trash" title and a count badge
/// (03-board-ui.md § Trash ▸ Rendering).
///
/// The hatching is what makes the column read as *not a lane* at a glance — the design asks for
/// "visually distinct", and a lane's header is the surface this must not be mistaken for. It
/// carries no gesture at all: no selection (the quasi-lane "is never selectable as a lane"), no
/// reorder drag, no context menu.
private var header: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "trash")
.foregroundStyle(.secondary)
.imageScale(.medium)
Text("Trash")
.font(.headline)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
countBadge
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background {
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
.fill(.quaternary.opacity(0.5))
.overlay {
DiagonalHatch()
.stroke(.quaternary, lineWidth: 1)
.clipShape(UnevenRoundedRectangle(
topLeadingRadius: cornerRadius,
topTrailingRadius: cornerRadius
))
}
}
.accessibilityElement(children: .combine)
}
/// The card count — the same collection the body renders, so the badge cannot disagree with
/// what is on screen (`LaneView.countBadge`'s rule).
private var countBadge: some View {
Text("\(cards.count)")
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(Capsule().fill(.quaternary))
}
// MARK: - Rows
/// The rows, scrollable, with the navigation head kept in view.
///
/// **"Selection scrolls into view"** (04-interactions.md ▸ Grammar), watching the head rather
/// than the whole selection so exactly one column responds to any one arrow — `LaneView`'s rule,
/// on the trash side.
private var rows: some View {
ScrollViewReader { proxy in
scrollableRows
.onChange(of: store.transient.selectionHead) { _, head in
guard let head, cards.contains(where: { $0.id == head }) else { return }
proxy.scrollTo(TrashSlot.identity(of: head))
}
}
}
private var scrollableRows: some View {
ScrollView(.vertical) {
// **A plain `VStack`, deliberately not lazy.** Every row must keep its drawn frame
// registered in `MarqueeTargetRegistry` — the rubber band sweeps those frames and the
// arrows navigate by them (`NavigationMath`) — and a lazy stack only builds the rows it
// has scrolled to, so an unbuilt row is invisible to both. The constraint is affordable
// because a trash is small: it holds one board's tombstones, and Empty Trash… exists.
VStack(alignment: .leading, spacing: rowSpacing) {
ForEach(slots) { slot in
Group {
switch slot {
case let .card(card):
TrashCardRow(
store: store,
card: card,
confirmations: confirmations,
drops: drops,
registry: marquee.registry
)
case .shadow:
// The delete gesture's shadow, holding the topmost row open
// (04-interactions.md ▸ The trash).
DragShadow(cornerRadius: 6)
.frame(maxWidth: .infinity)
.frame(height: nominalRowHeight)
}
}
// A row is a card, so it arrives and leaves in the card's dialect — a delete
// files one in, a restore or a purge takes one out, and both halves of that pair
// should read alike from either side of the strip. The transaction is the
// reload's, like the lanes' (`Motion.reloadAnimates`).
.transition(Motion.cardTransition(reduced: reduceMotion))
// The scroll target — `LaneView`'s rule, and outermost for its reason.
.id(slot.id)
}
}
// The reflow that opens the topmost row for the shadow, keyed on **the drop proposal and
// nothing else** (03-board-ui.md § Motion's narrow keys) — the trash column's own copy of
// the rule `BoardView` applies to the strip and `LaneView` to its masonry.
.animation(Motion.dragReflow(reduced: reduceMotion), value: proposal)
// `maxHeight: .infinity` here, not just `maxWidth`, is what makes the gesture surface
// below reach the column's full height rather than stopping where the last row ends —
// the same fix `LaneView.scrollableCards` applies to its masonry, and for the identical
// reason: a `ScrollView` proposes its content only the height that content asks for, so a
// view sized to fit its rows leaves the blank space beneath them un-hit-testable. "The
// column's gesture surface is full height" (04-interactions.md ▸ The trash, settled)
// needs that blank space to actually belong to the view the gesture below is on.
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(6)
.contentShape(Rectangle())
// The band's trash-side surface. It arms from the column's empty space, full height
// (above) included, so a drag can start from the blank area below the last row exactly
// 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
// gesture priority (`MarqueeControl`).
.simultaneousGesture(marquee.gesture(in: .trash))
}
}
}
// MARK: - The plate
/// The trash row's *appearance*, with none of anybody's behaviour — the compact, dimmed plate a
/// tombstone wears.
///
/// **Two views draw it and neither may drift**: the row itself, and its own drag replica (a drag
/// image is a snapshot, and one built out of the live plate would re-register the row's frame from
/// inside the preview's geometry and then deregister it when the image went away — quietly stealing
/// the row from the rubber band and the arrow keys).
private struct TrashRowPlate: View {
let symbol: String
/// The title as written, or `nil` for an untitled card — "Untitled" is a rendering, never a value
/// (03-board-ui.md § Card face).
let title: String?
var isSelected: Bool = false
private let cornerRadius: CGFloat = 6
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: symbol)
.foregroundStyle(.secondary)
.imageScale(.small)
Text(title ?? "Untitled")
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary.opacity(0.6)))
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
)
}
}
// MARK: - What the column lays out
/// One row of the trash column — an entry, or one slot of the delete gesture's run.
///
/// `LaneSlot`'s smaller sibling, and keyed on the same principle: a slot's id decides whether the
/// echo reload reads as a *swap* or as a removal and an insertion.
private enum TrashSlot: Identifiable {
/// A card the snapshot's trash already holds.
case card(Card)
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash).
case shadow(index: Int)
var id: String {
switch self {
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
// than blinking (`LaneSlot`'s rule).
case let .shadow(index): "shadow:\(index)"
}
}
/// 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:)`).
static func identity(of item: ItemID) -> String { "entry:\(item.rawValue)" }
}
// MARK: - The hatch
/// Diagonal hatching for the trash header — the "dimmed/hatched" treatment 03-board-ui.md asks for,
/// drawn rather than imaged so it takes whatever width the division gives the column.
///
/// The lines start a full header-height to the left of the leading edge so the first stroke reaches
/// the top-left corner instead of beginning partway across.
private struct DiagonalHatch: Shape {
var spacing: CGFloat = 7
func path(in rect: CGRect) -> Path {
var path = Path()
guard spacing > 0, rect.height > 0 else { return path }
var x = rect.minX - rect.height
while x < rect.maxX {
path.move(to: CGPoint(x: x, y: rect.maxY))
path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY))
x += spacing
}
return path
}
}
// MARK: - Rows
/// One trash row: a compact, dimmed plate carrying the card's symbol and title.
///
/// **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
/// 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. (Making it the ordinary card face is phase 3's — see
/// `TrashLaneView`.)
private struct TrashCardRow: View {
let store: BoardStore
let card: Card
let confirmations: TrashConfirmations
let drops: BoardDropContext
/// Where the rubber band looks up what it is sweeping — the card face's rule, in the trash
/// container (`View.marqueeTarget`).
let registry: MarqueeTargetRegistry
var body: some View {
plate.onDrag(startRowDrag, preview: { dragReplica })
}
private var plate: some View {
rowFace
// 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.
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
// The deferred cut wears the same dim wherever it lands. It genuinely fires here now:
// "⌘X works — cut in the trash, paste into a lane is the keyboard-native restore"
// (04-interactions.md ▸ The trash, resettled 2026-07-28).
.cutTreatment(of: card.id, in: store)
.contentShape(Rectangle())
.onTapGesture { select() }
.marqueeTarget(card.id, kind: .card, container: .trash, in: registry)
.contextMenu { menu }
}
/// 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
/// (see `TrashRowPlate`).
private var rowFace: some View {
TrashRowPlate(
symbol: ItemSymbol.name(card.icon, fallback: ItemSymbol.card),
title: card.title.value,
isSelected: isSelected
)
}
// MARK: - Selection
private var isSelected: Bool {
store.selection.container == .trash && store.selection.ids.contains(card.id)
}
/// A click selects this card in the **trash** container, through the same grammar the board's
/// surfaces use — plain replaces, ⌘ toggles, ⇧ ranges (`SelectionGrammar`).
///
/// The container travels with the click, and that is what keeps the one remaining homogeneity
/// boundary true: a ⌘-click across it replaces rather than mixing (04-interactions.md ▸ The
/// trash). There is no kind axis inside the trash any more — lanes are never trashed. No
/// `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.
private func select() {
store.click(
SelectionTarget(id: card.id, kind: .card, container: .trash),
modifier: .current
)
}
// MARK: - Drag out
/// 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 (04-interactions.md ▸ The trash;
/// `DragLocality.operation`).
///
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
/// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic
/// every other card drop uses, committed by the same `moveCards`.
///
/// **Multi-drag carries the whole trash selection**, in the column's own order — the order the
/// rows are drawn in, which is `order` ascending like any lane's.
///
/// Refused under the read-only lock and while an inline editor is focused, like every other
/// mutating gesture.
private func startRowDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let selection = store.selection
let ids: Set<ItemID> = selection.container == .trash
&& selection.ids.contains(card.id)
&& selection.ids.count > 1
? selection.ids
: [card.id]
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
guard !rows.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .cards,
container: .trash,
items: rows.map {
DragPayload.Item(
id: $0.id.rawValue,
folder: ItemPath.trashCard($0.id).folder(under: root).path,
title: $0.title.value
)
}
)
drops.session.beginCards(
rows.map(\.id),
folders: payload.folders,
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
container: .trash,
source: store
)
return payload.itemProvider()
}
/// 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.
private var dragReplica: some View {
let count = store.selection.container == .trash && store.selection.ids.contains(card.id)
? max(1, store.selection.ids.count)
: 1
return ZStack {
if count > 2 { rowFace.offset(x: 10, y: 10).opacity(0.45) }
if count > 1 { rowFace.offset(x: 5, y: 5).opacity(0.7) }
rowFace
}
.frame(width: 200)
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
.padding(12)
}
// MARK: - The trash card's context menu
/// Delete and Reveal in Finder — the two rows 11-command-nexus.md gives a trash card, and no
/// 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
/// enabled on trash selections", read-only lock included — inspecting a folder before a purge is
/// exactly the errand it exists for.
@ViewBuilder
private var menu: some View {
Button("Delete") {
confirmations.requestPurge(of: targetIDs, in: store)
}
.disabled(!store.acceptsBoardMutations)
Divider()
Button("Reveal in Finder") {
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
}
}
/// 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
/// header apply to Style….
private var targetIDs: Set<ItemID> {
guard store.selection.container == .trash, store.selection.ids.contains(card.id) else {
return [card.id]
}
return store.selection.ids
}
private var targetFolders: [URL] {
ItemPath.resolve(targetIDs, in: .trash, snapshot: store.snapshot)
.map { $0.folder(under: store.rootURL) }
}
}