Files
lanework/Kanban/UI/Board/TrashLaneView.swift
T
rzen 7f1adf47c5 Back out the render-at-release drop presentation
The settle rule 3f4125e introduced — at release the shadow swaps for the
dropped card(s) drawn in place immediately — is withdrawn on review. The
board returns to the presentation the committed-overlay hold has always
had: the shadows keep standing at their landing slot and the originals
keep their lift until the echo reload lands, whatever the operation was,
and the real faces arrive with the snapshot that carries the write.

Gone with it: the DropLanding/DroppedItem seam and the three landing
accessors, the hold's landing/operation payload and the removesOriginals
and keepsIdentity halves it decided, the pickup-captured titles a
cross-board arrival's face needed, LaneSlot.dropped with DroppedFace and
DroppedCardFace, the strip's dropped-lane run, and the trash column's
settled tombstone rows — the same presentation at the delete gesture's
end, which goes for the same reason while the drop-to-delete write and
its shadow row stay exactly as they were.

Kept: the hold itself, which predates the commit, along with the 1500 ms
failed-write timeout, its injectable duration and its extracted expire,
and the guard that keeps a settled release past retargeting. The tests
pinning those stay; the ones asserting what the landing slot drew are
gone, and the suite now pins the hold's own claim — the arrangement does
not move at the release.

The pause between the release and the echo is therefore an open design
question again, filed on the Redesign board.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-28 09:57:32 -04:00

595 lines
28 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
/// 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 = entries.map(TrashSlot.entry)
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 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)
}
}
// 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: 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(side: .trashed))
}
}
}
// 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 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)
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)"
}
}
/// 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,
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) }
}
}