Files
lanework/Kanban/UI/Board/TrashLaneView.swift
T
rzen 33bf425f25 Drop a card on the shown trash to delete it
04's ruling makes the drag the pointer's delete gesture: the shown
trash column accepts live same-board card drags, the shadow pinned
topmost — honest, since the trash sorts by deleted newest-first — and
release tombstones through the same write path as Backspace, extracted
so the two gestures cannot drift. DropTarget grew a container case for
the quasi-lane (it has no lane id by construction); lane drags,
cross-board arrivals, option-copies (re-checked at release, the one
input that can flip without a callback), trashed-side payloads, hidden
trash, and the read-only lock all refuse — and a refusal falls through
to the strip retarget, never cancelling the drag. The settle draws the
tombstoned rows in the trash under the cards' own GUIDs, so the echo is
an invisible content swap and nothing winks out for a round trip.
Selection needs no surgery: the reload's resolve rule ejects tombstoned
members as the vanish it is, pinned by a test contrasting both gestures.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 08:12:09 -04:00

652 lines
32 KiB
Swift

import AppKit
import Observation
import SwiftUI
// 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 drop proposal's slot list by construction, since `BoardView` builds that from the
/// snapshot's live lanes;
/// - it is **no destination for a move** — "no move or paste ever targets the trash"
/// (04-interactions.md ▸ The trash). It is a destination for exactly one thing, below;
/// - it has **no new-card button**: nothing is created in the trash.
///
/// ### The one drop it takes: the delete gesture
///
/// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md ▸ The trash, settled
/// 2026-07-28) — "the drag becomes the pointer's delete gesture; release tombstones the dragged
/// card(s), exactly the ⌫ tombstone". So the column does declare an `onDrop`
/// (`TrashDropDelegate`), and it is the narrowest one on the board: a **live** card drag from **this**
/// board, unmodified. Lanes are not deliverable this way, a foreign board's card is not (that would
/// be a transfer-and-delete compound), ⌥ is not (copying into the trash is not a thing), and hidden
/// the column is not rendered at all, so it has no region to enter. Every one of those refusals hands
/// the session back to the strip's own logic, which is exactly what happened here before this target
/// existed — so nothing about the column's behaviour changed except the gesture that is new
/// (`TrashDrop`).
///
/// It diverges from every other drop on the board in one way, and the sort is what makes the
/// divergence honest: **the shadow always takes the topmost row**, because the trash orders by
/// `deleted` newest-first and a fresh tombstone genuinely lands on top. The drop still lands exactly
/// where the shadow shows.
///
/// **Finder file drops stay inert** — "attachment import on tombstoned cards is inert" (▸ The trash)
/// — and now say so directly: the delegate clears the file highlight over the column rather than
/// relying on the strip resolving to no lane.
///
/// ### 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
///
/// **⌘C copy-out** is still owed. The **search filter** ("shown, it participates in the filter like
/// any lane") arrived with m5 and is one line — see `entries`, which every other surface here reads
/// through. The *pointer* grammar is here: a row's click runs the same `SelectionGrammar` the
/// board does, and the column's empty space rubber-bands on the trashed side. So is the drop, with
/// the target lane highlighted and the source row dimmed in place; the drag's replica is not. The
/// **keyboard** reaches the column entirely through the frames the rows register — arrow walks in
/// and out, ⇧-arrows inert at both the liveness and the kind boundary — so nothing in this file
/// implements it beyond keeping every row drawn and registered (see `rows`).
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 row's drag is an ordinary card session on the
/// **trashed** side (`DragSession`, 04-interactions.md ▸ The trash).
let drops: BoardDropContext
/// The strip's rubber band. The column's empty space is its third surface, on the **trashed**
/// side — "a rubber-band stays on the side of the boundary 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, plus a lane entry's count line) 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 rows the column shows.
///
/// **The shown trash "participates in the filter like any lane"** (03-board-ui.md § Trash), so
/// the search predicate narrows this collection exactly as it narrows `LaneView.renderedCards`
/// — card rows and lane rows alike, each by its own title and body (`SearchFilter`) — 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 entries: [TrashEntry] {
let filter = store.searchFilter
return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) }
}
// MARK: - The delete gesture's landing
/// What the column draws at the drop proposal — the shadow run while the drag is in flight, the
/// tombstoned rows themselves once the release has settled (`DragSession.trashLanding`). `nil`
/// when no proposal names the trash, which is every other moment of the app's life.
private var landing: DropLanding? {
drops.session.trashLanding(onBoardRooted: store.rootURL)
}
/// The rows the `VStack` lays out: the entries, with the delete gesture's run opened at the top.
///
/// **The settled run displaces the entries it is standing in for.** For the one render pass where
/// the echo has landed but the hold has not yet been retired (`BoardView` hands off on the *next*
/// snapshot, an `onChange` later), the arriving card is in both collections at once — so the real
/// entry steps aside and the overlay's row keeps the slot. That is `LaneView`'s hidden-members
/// rule at the other end of the same gesture: never draw the arrangement twice.
///
/// The two then swap **inside one element**, because both are keyed by the card's own identity: a
/// tombstone is not a remint — the card keeps its GUID, its folder and its bytes — so this
/// landing, alone among the board's, can always promise the key. No insert, no remove, no
/// transition to fire; "the handoff must read as one arrival" (02-architecture.md ▸
/// TransientBoardState ▸ overlays).
private var slots: [TrashSlot] {
guard let landing else { return entries.map(TrashSlot.entry) }
let arriving: [ItemID]
let run: [TrashSlot]
switch landing.run {
case .shadows:
arriving = []
run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
case let .dropped(drop):
arriving = drop.items.map(\.id)
run = drop.items.map(TrashSlot.dropped)
}
let displaced = Set(arriving)
var result: [TrashSlot] = entries.filter { !displaced.contains($0.id) }.map(TrashSlot.entry)
result.insert(contentsOf: run, at: min(max(0, landing.index), result.count))
return result
}
/// One dropped card, drawn as the trash row it is about to become.
///
/// The card is still **live in this board's snapshot** at this instant — the write is in flight —
/// so its real symbol and title are right there to draw, and the payload's title is the fallback
/// for the case that cannot happen here but costs one `??` to survive.
private func droppedRow(_ item: DroppedItem) -> some View {
let card = store.snapshot.lanes.lazy
.flatMap(\.cards)
.first { $0.id == item.id }
return TrashRowPlate(
symbol: card.map { ItemSymbol.name($0.icon, fallback: ItemSymbol.card) } ?? ItemSymbol.card,
title: card?.title.value ?? item.title,
subtitle: nil,
isSelected: false
)
}
// 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
/// 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, entries.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 .entry(entry):
TrashEntryRow(
store: store,
entry: entry,
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)
case let .dropped(item):
// The same row one instant later: the release has settled and the
// tombstone is drawn where its shadow was, rather than the cards winking
// out for a round trip (`DragSession.trashLanding`).
droppedRow(item)
}
}
// A row is a tombstoned item, so it arrives and leaves in the card's dialect —
// a delete files one in, a Put Back 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: landing?.index)
// `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(side: .trashed))
}
}
}
// MARK: - The plate
/// The trash row's *appearance*, with none of anybody's behaviour — the compact, dimmed plate a
/// tombstone wears.
///
/// **Three views draw it and none of them may drift**: the row itself, 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), and the **settled delete's** stand-in row, which
/// draws a card that has been tombstoned on disk but is not in the snapshot yet
/// (`TrashLaneView.droppedRow`). The last one is why this is a view rather than a computed property:
/// it renders for an item that has no `TrashEntry` at all.
private struct TrashRowPlate: View {
let symbol: String
/// The title as written, or `nil` for an untitled item — "Untitled" is a rendering, never a value
/// (03-board-ui.md § Card face).
let title: String?
/// A lane entry's returning-card count, and nothing else takes a second line.
let subtitle: 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)
VStack(alignment: .leading, spacing: 2) {
Text(title ?? "Untitled")
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
if let subtitle {
Text(subtitle)
.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)
)
}
}
// 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 tombstone the snapshot already holds.
case entry(TrashEntry)
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash).
case shadow(index: Int)
/// One of the **dropped** cards, drawn as the row it is about to be, from the instant of release
/// until the echo reload brings the real entry.
case dropped(DroppedItem)
var id: String {
switch self {
case let .entry(entry): Self.identity(of: entry.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)"
// **The one landing on this board that can always promise a key.** A tombstone keeps the
// card's GUID — a delete remints nothing — so the settled row wears the arriving entry's own
// identity and the echo swaps content inside one element, where the masonry's `.dropped` has
// to key positionally whenever a copy or an import boundary might remint.
case let .dropped(item): Self.identity(of: item.id)
}
}
/// An entry 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 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…. 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 drops: BoardDropContext
/// Where the rubber band looks up what it is sweeping — the card face's rule, on the trashed
/// side (`View.marqueeTarget`).
let registry: MarqueeTargetRegistry
/// **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 rather than refused — no session, no image, no snap-back. A click
/// still selects either way.
@ViewBuilder
var body: some View {
if entry.isLaneEntry {
plate
} else {
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(entry.id) ? ClipboardTreatment.dimmedOpacity : 1)
// The deferred cut wears the same dim wherever it lands, so the treatment is stated for
// every surface a `pendingCut` could name rather than for two of the three. In practice
// it never fires here: ⌘X is disabled on tombstoned selections (04-interactions.md ▸ The
// trash), and a pending cut is homogeneous by liveness — a reload that tombstones a cut
// card *ejects* it from the set rather than moving it to the other side.
.cutTreatment(of: entry.id, in: store)
.contentShape(Rectangle())
.onTapGesture { select() }
.marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)
.contextMenu { menu }
}
/// This entry 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(entry.icon, fallback: symbolFallback),
title: entry.title,
subtitle: laneSubtitle,
isSelected: isSelected
)
}
private var symbolFallback: String {
entry.isLaneEntry ? ItemSymbol.lane : ItemSymbol.card
}
/// "N cards" — what Put Back brings back with a tombstoned lane, not how many folders sit inside
/// it (`TrashModel.entries`' returning-count rule). Card entries have no second line.
private var laneSubtitle: String? {
guard case let .lane(_, returning) = entry else { return nil }
return "\(returning) card\(returning == 1 ? "" : "s")"
}
// MARK: - Selection
private var isSelected: Bool {
store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
}
/// A click selects this row on the **trashed** side, through the same grammar the board's
/// surfaces use — plain replaces, ⌘ toggles, ⇧ ranges (`SelectionGrammar`).
///
/// The row's kind travels with the click, and that is what keeps the trash's second homogeneity
/// axis true: a ⌘-click across the card/lane-entry boundary replaces rather than mixing, and a
/// ⇧-range walks only its own kind's rows (04-interactions.md ▸ The trash). No `togglesOnRepeat`
/// — click-again-to-unselect is the lane's behaviour, not a row'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: entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed),
modifier: .current
)
}
// MARK: - Drag out
/// Begins the row's drag out of the trash — an ordinary **card session on the trashed side**,
/// which is the whole of what makes it a restore rather than a move (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 "restores it at the drop position" is the same arithmetic every
/// other card drop uses. What a release *means* differs by locality and modifier, and that lives
/// in one place (`BoardDropContext.commitDrop`): within the board a restore, ⌥ a live copy-out,
/// across boards a live copy with ⌘ forcing the true restore-move.
///
/// **Multi-drag carries the whole trashed selection**, in the trash's own sorted order — the
/// order the rows are drawn in, which is the only relative order a set of tombstones has.
///
/// 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.liveness == .trashed
&& selection.ids.contains(entry.id)
&& selection.ids.count > 1
? selection.ids
: [entry.id]
// Card entries only: a lane entry cannot be dragged at all, so one caught up in a mixed
// selection is simply not carried. (The selection is homogeneous by kind anyway — this is
// belt over braces.)
let rows: [(id: ItemID, laneID: ItemID, title: String?)] = TrashModel.entries(of: store.snapshot)
.compactMap { candidate in
guard ids.contains(candidate.id), case let .card(card, laneID) = candidate else { return nil }
return (card.id, laneID, card.title.value)
}
guard !rows.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .cards,
side: .trashed,
items: rows.map {
DragPayload.Item(
id: $0.id.rawValue,
folder: TrashModel.ItemPath(laneID: $0.laneID, cardID: $0.id).folder(under: root).path,
title: $0.title
)
}
)
drops.session.beginCards(
rows.map(\.id),
folders: payload.folders,
// What a cross-board arrival's overlay draws with (`DroppedItem`) — the payload's own
// titles, which for a trash row are the tombstone's.
titles: payload.items.map(\.title),
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
side: .trashed,
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.liveness == .trashed && store.selection.ids.contains(entry.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 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) }
}
}