Materialize the trash — store, undo, and the container universe
Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
+121
-177
@@ -4,63 +4,55 @@ import SwiftUI
|
||||
|
||||
// MARK: - TrashLaneView
|
||||
|
||||
/// The trash quasi-lane: the trailing, visually distinct column tombstoned items live in
|
||||
/// (03-board-ui.md § Trash).
|
||||
/// 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 pure view, and a quasi-lane
|
||||
/// ### A rendering of `snapshot.trash`, 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:
|
||||
/// **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 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;
|
||||
/// snapshot's lanes;
|
||||
/// - it has **no new-card button**: nothing is created in the trash.
|
||||
///
|
||||
/// ### The one drop it takes: the delete gesture
|
||||
/// ### The drop it takes, and the drag it starts
|
||||
///
|
||||
/// **"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`).
|
||||
/// **"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.
|
||||
///
|
||||
/// 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.
|
||||
/// 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** — "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.
|
||||
/// **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
|
||||
///
|
||||
/// "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.
|
||||
/// "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 a later card's
|
||||
/// ### What is still phase 3'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`).
|
||||
/// 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
|
||||
@@ -71,13 +63,13 @@ struct TrashLaneView: View {
|
||||
/// 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).
|
||||
/// 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, 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.
|
||||
/// 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
|
||||
@@ -91,9 +83,9 @@ struct TrashLaneView: View {
|
||||
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.
|
||||
/// 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 {
|
||||
@@ -113,16 +105,16 @@ struct TrashLaneView: View {
|
||||
.onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops))
|
||||
}
|
||||
|
||||
/// The rows the column shows.
|
||||
/// The cards 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] {
|
||||
/// **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 TrashModel.entries(of: store.snapshot).filter { filter.matches($0) }
|
||||
return store.snapshot.trash.filter { filter.matches($0) }
|
||||
}
|
||||
|
||||
// MARK: - The delete gesture's landing
|
||||
@@ -140,7 +132,7 @@ struct TrashLaneView: View {
|
||||
/// 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)
|
||||
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))
|
||||
@@ -186,11 +178,10 @@ struct TrashLaneView: View {
|
||||
.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).
|
||||
/// 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("\(entries.count)")
|
||||
Text("\(cards.count)")
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -210,7 +201,7 @@ struct TrashLaneView: View {
|
||||
ScrollViewReader { proxy in
|
||||
scrollableRows
|
||||
.onChange(of: store.transient.selectionHead) { _, head in
|
||||
guard let head, entries.contains(where: { $0.id == head }) else { return }
|
||||
guard let head, cards.contains(where: { $0.id == head }) else { return }
|
||||
proxy.scrollTo(TrashSlot.identity(of: head))
|
||||
}
|
||||
}
|
||||
@@ -227,10 +218,10 @@ struct TrashLaneView: View {
|
||||
ForEach(slots) { slot in
|
||||
Group {
|
||||
switch slot {
|
||||
case let .entry(entry):
|
||||
TrashEntryRow(
|
||||
case let .card(card):
|
||||
TrashCardRow(
|
||||
store: store,
|
||||
entry: entry,
|
||||
card: card,
|
||||
confirmations: confirmations,
|
||||
drops: drops,
|
||||
registry: marquee.registry
|
||||
@@ -243,10 +234,10 @@ struct TrashLaneView: View {
|
||||
.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`).
|
||||
// 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)
|
||||
@@ -271,7 +262,7 @@ struct TrashLaneView: View {
|
||||
// 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))
|
||||
.simultaneousGesture(marquee.gesture(in: .trash))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,13 +280,10 @@ private struct TrashRowPlate: View {
|
||||
|
||||
let symbol: String
|
||||
|
||||
/// The title as written, or `nil` for an untitled item — "Untitled" is a rendering, never a value
|
||||
/// 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?
|
||||
|
||||
/// 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
|
||||
@@ -305,18 +293,11 @@ private struct TrashRowPlate: View {
|
||||
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)
|
||||
Text(title ?? "Untitled")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
@@ -336,22 +317,22 @@ private struct TrashRowPlate: View {
|
||||
/// 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)
|
||||
/// 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 .entry(entry): Self.identity(of: entry.id)
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
|
||||
/// 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)" }
|
||||
}
|
||||
@@ -381,145 +362,113 @@ private struct DiagonalHatch: Shape {
|
||||
|
||||
// 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.
|
||||
/// 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.
|
||||
private struct TrashEntryRow: View {
|
||||
/// 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 entry: TrashEntry
|
||||
let card: Card
|
||||
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`).
|
||||
/// Where the rubber band looks up what it is sweeping — the card face's rule, in the trash
|
||||
/// container (`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 })
|
||||
}
|
||||
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)
|
||||
.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(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)
|
||||
.marqueeTarget(card.id, kind: .card, container: .trash, in: registry)
|
||||
.contextMenu { menu }
|
||||
}
|
||||
|
||||
/// This entry as the shared plate draws it — appearance only, no gesture, no context menu and
|
||||
/// 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(entry.icon, fallback: symbolFallback),
|
||||
title: entry.title,
|
||||
subtitle: laneSubtitle,
|
||||
symbol: ItemSymbol.name(card.icon, fallback: ItemSymbol.card),
|
||||
title: card.title.value,
|
||||
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)
|
||||
store.selection.container == .trash && store.selection.ids.contains(card.id)
|
||||
}
|
||||
|
||||
/// A click selects this row on the **trashed** side, through the same grammar the board's
|
||||
/// A click selects this card in the **trash** container, 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.
|
||||
/// 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: entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed),
|
||||
SelectionTarget(id: card.id, kind: .card, container: .trash),
|
||||
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`).
|
||||
/// 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 "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.
|
||||
/// 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 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.
|
||||
/// **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.liveness == .trashed
|
||||
&& selection.ids.contains(entry.id)
|
||||
let ids: Set<ItemID> = selection.container == .trash
|
||||
&& selection.ids.contains(card.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
: [entry.id]
|
||||
: [card.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)
|
||||
}
|
||||
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,
|
||||
side: .trashed,
|
||||
container: .trash,
|
||||
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
|
||||
folder: ItemPath.trashCard($0.id).folder(under: root).path,
|
||||
title: $0.title.value
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -527,7 +476,7 @@ private struct TrashEntryRow: View {
|
||||
rows.map(\.id),
|
||||
folders: payload.folders,
|
||||
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
|
||||
side: .trashed,
|
||||
container: .trash,
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
@@ -536,7 +485,7 @@ private struct TrashEntryRow: View {
|
||||
/// 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)
|
||||
let count = store.selection.container == .trash && store.selection.ids.contains(card.id)
|
||||
? max(1, store.selection.ids.count)
|
||||
: 1
|
||||
return ZStack {
|
||||
@@ -549,22 +498,18 @@ private struct TrashEntryRow: View {
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
// MARK: - The trash entry's context menu
|
||||
// MARK: - The trash card's context menu
|
||||
|
||||
/// Put Back, Delete Immediately, Reveal in Finder — the three rows 11-command-nexus.md gives a
|
||||
/// trash entry, and no others.
|
||||
/// 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 tombstoned selections", read-only lock included — inspecting a folder before a
|
||||
/// purge is exactly the errand it exists for.
|
||||
/// 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("Put Back") {
|
||||
store.putBack(targetIDs)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
Button("Delete Immediately") {
|
||||
Button("Delete") {
|
||||
confirmations.requestPurge(of: targetIDs, in: store)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
@@ -578,17 +523,16 @@ private struct TrashEntryRow: View {
|
||||
|
||||
/// 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.
|
||||
/// header apply to Style….
|
||||
private var targetIDs: Set<ItemID> {
|
||||
guard store.selection.liveness == .trashed, store.selection.ids.contains(entry.id) else {
|
||||
return [entry.id]
|
||||
guard store.selection.container == .trash, store.selection.ids.contains(card.id) else {
|
||||
return [card.id]
|
||||
}
|
||||
return store.selection.ids
|
||||
}
|
||||
|
||||
private var targetFolders: [URL] {
|
||||
TrashModel.paths(of: targetIDs, on: .trashed, in: store.snapshot)
|
||||
ItemPath.resolve(targetIDs, in: .trash, snapshot: store.snapshot)
|
||||
.map { $0.folder(under: store.rootURL) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user