Files
lanework/Kanban/UI/Board/TrashLaneView.swift
T
rzen 2090d742b9 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
2026-07-27 15:38:34 -04:00

395 lines
16 KiB
Swift

import AppKit
import Observation
import SwiftUI
// MARK: - The strip's half of a trash row's drag
/// What the strip lends the trash so a row can be dragged back onto the board (03-board-ui.md §
/// Trash ▸ Drag-to-restore).
///
/// `LaneHeaderDrag`'s sibling, and for its reason: the gesture lives on the row, but the one thing it
/// needs — *which lane is under the cursor* — is the strip's geometry, and it must be read at gesture
/// time rather than at body-evaluation time.
@MainActor
struct TrashRowDrag {
/// The live lane under an x in strip coordinates, or `nil` when the point is not over one — a
/// gap, the outer margin, or the trash itself (`LaneLayoutMath.laneIndex`).
let laneUnder: (CGFloat) -> ItemID?
}
// MARK: - TrashDragSession
/// Window-local state for an in-flight drag out of the trash — `LaneReorderSession`'s sibling, and
/// deliberately as small.
///
/// It holds only what the pointer contributes: which card, and which lane is currently under it.
/// Everything else — the strip's geometry, the lanes themselves — is read fresh at render time, so a
/// foreign reload mid-drag cannot leave this holding a stale board (04-interactions.md ▸ Drag and
/// drop's re-grounding rule).
@MainActor
@Observable
final class TrashDragSession {
/// The card being dragged out of the trash; `nil` when idle.
private(set) var cardID: ItemID?
/// The live lane the pointer is over, or `nil` when it is over nothing droppable. Observed: the
/// lanes read it to draw the drop highlight, which is this milestone's whole visual feedback.
private(set) var targetLaneID: ItemID?
/// How far the pointer must travel before a click on a row becomes a drag — the same threshold
/// the lane header uses, so the two gestures feel alike.
static let threshold: CGFloat = 4
var isActive: Bool { cardID != nil }
func isDragging(_ id: ItemID) -> Bool { cardID == id }
func isTarget(_ id: ItemID) -> Bool { targetLaneID == id }
func begin(cardID: ItemID) {
self.cardID = cardID
targetLaneID = nil
}
func update(targetLaneID: ItemID?) {
guard isActive else { return }
self.targetLaneID = targetLaneID
}
/// Ends the drag, handing the caller nothing — the *commit* needs the current snapshot, which
/// the view has and this session deliberately does not. Idempotent, because a gesture can end
/// after the card it was carrying has already vanished.
func end() {
cardID = nil
targetLaneID = nil
}
}
// MARK: - TrashLaneView
/// The trash quasi-lane: the trailing, visually distinct column tombstoned items live in
/// (03-board-ui.md § Trash).
///
/// ### A pure view, and a quasi-lane
///
/// **Nothing here moves anything on disk.** Tombstoned items keep their `deleted:` key and stay
/// exactly where they are; this column is a rendering of `TrashModel.entries(of:)` and nothing more.
/// 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 reorder proposal's `unitCounts` by construction, since `BoardView` builds that from
/// the snapshot's live lanes;
/// - it has **no new-card button**: nothing is created in the trash.
///
/// ### No editing in the trash
///
/// "Tombstoned cards don't open — double-click does nothing beyond selection; Put Back or drag out
/// first." There is deliberately no double-tap recogniser, no rename editor, and no Style… row: the
/// trash is for restoring or purging, not working.
///
/// ### What is still a later card's
///
/// The **search filter** ("shown, it participates in the filter like any lane") and the full
/// **keyboard grammar** — arrow walks into and out of the column, ⇧-ranges that stop at both the
/// liveness and the kind boundary, the rubber band, ⌘C copy-out — are m5's. So is the drag's replica:
/// what ships here is the drop, with the target lane highlighted and the source row dimmed in place.
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 strip's drop resolution — see `TrashRowDrag`.
let drag: TrashRowDrag
/// The window's one drag-out session, owned by `BoardView` for the same lifetime the reorder
/// session has.
let dragSession: TrashDragSession
/// 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
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
rows
}
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(.quaternary.opacity(0.35))
)
}
/// The rows the column shows.
///
// m5-search: the shown trash "participates in the filter like any lane", 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.
private var entries: [TrashEntry] {
TrashModel.entries(of: store.snapshot)
}
// 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 entry count — the same collection the body renders, so the badge cannot disagree with
/// what is on screen (`LaneView.countBadge`'s rule, and it is why m5's filter needs no second
/// change here).
private var countBadge: some View {
Text("\(entries.count)")
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(Capsule().fill(.quaternary))
}
// MARK: - Rows
private var rows: some View {
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: rowSpacing) {
ForEach(entries) { entry in
TrashEntryRow(
store: store,
entry: entry,
confirmations: confirmations,
drag: drag,
dragSession: dragSession
)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(6)
}
}
}
// 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 item's symbol and title — and, for a lane
/// entry, the count of cards Put Back would return with it.
///
/// **Face-like but not a card face.** It shares the plate, the symbol and the title, and it
/// deliberately shares none of the face's *editing* affordances: no rename editor, no Open, no
/// Style…, no attachment carousel. 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.
private struct TrashEntryRow: View {
let store: BoardStore
let entry: TrashEntry
let confirmations: TrashConfirmations
let drag: TrashRowDrag
let dragSession: TrashDragSession
private let cornerRadius: CGFloat = 6
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: ItemSymbol.name(entry.icon, fallback: symbolFallback))
.foregroundStyle(.secondary)
.imageScale(.small)
VStack(alignment: .leading, spacing: 2) {
Text(entry.title ?? "Untitled")
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
if case let .lane(_, returning) = entry {
// "N cards" — what Put Back brings back with the lane, not how many folders sit
// inside it (`TrashModel.entries`' returning-count rule).
Text("\(returning) card\(returning == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.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)
)
// The row being dragged out dims further, so the gesture reads even without a replica.
.opacity(dragSession.isDragging(entry.id) ? 0.45 : 1)
.contentShape(Rectangle())
.gesture(rowGesture)
.contextMenu { menu }
}
private var symbolFallback: String {
entry.isLaneEntry ? ItemSymbol.lane : ItemSymbol.card
}
// MARK: - Selection
private var isSelected: Bool {
store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
}
/// A click replaces the selection with this one row, on the **trashed** side.
///
/// Replace-only is what enforces both invariants at once here: a selection that is always exactly
/// one row can never mix live with tombstoned, nor card entries with lane entries
/// (04-interactions.md ▸ The trash). The extension grammar — ⌘-click, ⇧-ranges that go inert at
/// both boundaries, the rubber band that stays on the side it started on — is **m5's
/// selection-model card**, and nothing here should pre-empt it.
///
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
private func select() {
store.select([entry.id], liveness: .trashed)
}
// MARK: - Drag out
/// One gesture recognising the same click-versus-drag split the lane header uses: a plain click
/// selects, and only movement past the threshold begins a drag out of the trash.
///
/// **Lane entries are not draggable** (03-board-ui.md § Trash: "a lane entry is not draggable —
/// its entry is a compact row, not the lane; its move-out is Put Back"), so the drag half is
/// simply absent for them and the release still selects.
///
/// The coordinate space is the strip's, because what the release needs is a *position* over the
/// board, not a translation.
private var rowGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .named(BoardView.stripSpace))
.onChanged { value in
guard isDraggable, !store.isReadOnly, !store.isEditingInline else { return }
if !dragSession.isDragging(entry.id) {
let travelled = max(abs(value.translation.width), abs(value.translation.height))
guard travelled > TrashDragSession.threshold else { return }
dragSession.begin(cardID: entry.id)
}
dragSession.update(targetLaneID: drag.laneUnder(value.location.x))
}
.onEnded { value in
guard dragSession.isDragging(entry.id) else {
select()
return
}
dragSession.end()
// A drop over anything but a live lane — the trash itself, a gap, the outer margin —
// writes nothing. There is no replica to snap back; the row never left.
guard let lane = drag.laneUnder(value.location.x) else { return }
store.restoreByDrag(cardID: entry.id, intoLane: lane)
}
}
private var isDraggable: Bool { !entry.isLaneEntry }
// MARK: - The trash entry's context menu
/// Put Back, Delete Immediately, Reveal in Finder — the three rows 11-command-nexus.md gives a
/// trash entry, and no others.
///
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
/// enabled on tombstoned selections", read-only lock included — inspecting a folder before a
/// purge is exactly the errand it exists for.
@ViewBuilder
private var menu: some View {
Button("Put Back") {
store.putBack(targetIDs)
}
.disabled(!store.acceptsBoardMutations)
Button("Delete Immediately") {
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…. Right-clicking something outside the selection acts on what was
/// clicked, which is also what keeps a cross-kind menu from ever acting on a mixed set.
private var targetIDs: Set<ItemID> {
guard store.selection.liveness == .trashed, store.selection.ids.contains(entry.id) else {
return [entry.id]
}
return store.selection.ids
}
private var targetFolders: [URL] {
TrashModel.paths(of: targetIDs, on: .trashed, in: store.snapshot)
.map { $0.folder(under: store.rootURL) }
}
}