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
539 lines
25 KiB
Swift
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) }
|
|
}
|
|
}
|