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:
@@ -126,7 +126,7 @@ struct LaneView: View {
|
||||
// the drag off until the pointer actually moves, so a click is never a drag.
|
||||
.onTapGesture {
|
||||
store.click(
|
||||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
||||
SelectionTarget(id: lane.id, kind: .lane, container: .board),
|
||||
modifier: .current,
|
||||
togglesOnRepeat: true
|
||||
)
|
||||
@@ -232,17 +232,17 @@ struct LaneView: View {
|
||||
/// alone — standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept).
|
||||
/// Right-clicking something outside the selection acts on what was clicked.
|
||||
private var styleTarget: StyleTarget {
|
||||
guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else {
|
||||
guard store.selection.container == .board, store.selection.ids.contains(lane.id) else {
|
||||
return .items([lane.id])
|
||||
}
|
||||
return .items(store.selection.ids)
|
||||
}
|
||||
|
||||
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
||||
/// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here
|
||||
/// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
|
||||
/// on the live side).
|
||||
private var targetIDs: Set<ItemID> {
|
||||
guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else {
|
||||
guard store.selection.container == .board, store.selection.ids.contains(lane.id) else {
|
||||
return [lane.id]
|
||||
}
|
||||
return store.selection.ids
|
||||
@@ -352,20 +352,20 @@ struct LaneView: View {
|
||||
private func startLaneDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.liveness == .live
|
||||
let ids: Set<ItemID> = selection.container == .board
|
||||
&& selection.ids.contains(lane.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
: [lane.id]
|
||||
|
||||
let members = store.snapshot.lanes.filter { !$0.isDeleted && ids.contains($0.id) }
|
||||
let members = store.snapshot.lanes.filter { ids.contains($0.id) }
|
||||
guard !members.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .lanes,
|
||||
side: .live,
|
||||
container: .board,
|
||||
items: members.map {
|
||||
DragPayload.Item(
|
||||
id: $0.id.rawValue,
|
||||
@@ -405,7 +405,7 @@ struct LaneView: View {
|
||||
|
||||
private var draggedLaneCount: Int {
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, selection.ids.contains(lane.id) else { return 1 }
|
||||
guard selection.container == .board, selection.ids.contains(lane.id) else { return 1 }
|
||||
return selection.ids.count
|
||||
}
|
||||
|
||||
@@ -544,7 +544,7 @@ struct LaneView: View {
|
||||
// shares (04-interactions.md § Selection), and the modifier grammar on top of it.
|
||||
.onTapGesture {
|
||||
store.click(
|
||||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
||||
SelectionTarget(id: lane.id, kind: .lane, container: .board),
|
||||
modifier: .current,
|
||||
togglesOnRepeat: true
|
||||
)
|
||||
@@ -552,7 +552,7 @@ struct LaneView: View {
|
||||
// The rubber band's first surface — "click-drag rubber-bands across lanes". Simultaneous
|
||||
// so the taps above stay instant; the band's own begin guard is what keeps a drag that
|
||||
// started on a card face out of it (`MarqueeControl`).
|
||||
.simultaneousGesture(marquee.gesture(side: .live))
|
||||
.simultaneousGesture(marquee.gesture(in: .board))
|
||||
// The same menu the header carries — "one menu, invoked on the header or lane empty
|
||||
// space alike" (03-board-ui.md § Lane, settled).
|
||||
.contextMenu { laneMenu }
|
||||
@@ -688,7 +688,7 @@ struct LaneView: View {
|
||||
renaming: ItemID?
|
||||
) -> [Card] {
|
||||
cards.filter { card in
|
||||
guard !card.isDeleted, !hidden.contains(card.id) else { return false }
|
||||
guard !hidden.contains(card.id) else { return false }
|
||||
return filter.matches(card) || card.id == renaming
|
||||
}
|
||||
}
|
||||
@@ -696,7 +696,7 @@ struct LaneView: View {
|
||||
// MARK: - Selection
|
||||
|
||||
private var isSelected: Bool {
|
||||
store.selection.liveness == .live && store.selection.ids.contains(lane.id)
|
||||
store.selection.container == .board && store.selection.ids.contains(lane.id)
|
||||
}
|
||||
|
||||
/// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet —
|
||||
@@ -922,7 +922,7 @@ private struct CardFaceView: View {
|
||||
// Board ▸ Rename. The modifier grammar — plain replaces, ⌘ toggles, ⇧ ranges — is
|
||||
// `SelectionGrammar`'s, reached through the store's one funnel.
|
||||
.onTapGesture {
|
||||
store.click(SelectionTarget(id: card.id, kind: .card, side: .live), modifier: .current)
|
||||
store.click(SelectionTarget(id: card.id, kind: .card, container: .board), modifier: .current)
|
||||
}
|
||||
// "A fast double-click opens the card window (⌘↩'s pointer twin)" (04 ▸ Selection).
|
||||
//
|
||||
@@ -948,7 +948,7 @@ private struct CardFaceView: View {
|
||||
drops.registry.update(height: height, for: card.id)
|
||||
}
|
||||
.onDisappear { drops.registry.removeHeight(card.id) }
|
||||
.marqueeTarget(card.id, kind: .card, side: .live, in: marquee.registry)
|
||||
.marqueeTarget(card.id, kind: .card, container: .board, in: marquee.registry)
|
||||
.contextMenu { cardMenu }
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
@@ -960,7 +960,7 @@ private struct CardFaceView: View {
|
||||
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order** —
|
||||
/// "lane `order` first, then card `order`", `SelectionGrammar.liveCards`' single definition of
|
||||
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
|
||||
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
@@ -969,7 +969,7 @@ private struct CardFaceView: View {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let snapshot = store.snapshot
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.liveness == .live
|
||||
let ids: Set<ItemID> = selection.container == .board
|
||||
&& selection.ids.contains(card.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
@@ -978,21 +978,21 @@ private struct CardFaceView: View {
|
||||
// Flatten order, and the lane each member currently lives in — the folder path's middle
|
||||
// component.
|
||||
var lanesByCard: [ItemID: ItemID] = [:]
|
||||
var titles: [ItemID: String?] = [:]
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
for member in lane.cards where !member.isDeleted && ids.contains(member.id) {
|
||||
var titles: [ItemID: String] = [:]
|
||||
for lane in snapshot.lanes {
|
||||
for member in lane.cards where ids.contains(member.id) {
|
||||
lanesByCard[member.id] = lane.id
|
||||
titles[member.id] = member.title.value
|
||||
}
|
||||
}
|
||||
let ordered = SelectionGrammar.liveCards(in: snapshot).filter { ids.contains($0) }
|
||||
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
|
||||
guard !ordered.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .cards,
|
||||
side: .live,
|
||||
container: .board,
|
||||
items: ordered.compactMap { id in
|
||||
guard let laneID = lanesByCard[id] else { return nil }
|
||||
return DragPayload.Item(
|
||||
@@ -1001,7 +1001,7 @@ private struct CardFaceView: View {
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||
.path,
|
||||
title: titles[id] ?? nil
|
||||
title: titles[id]
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -1012,7 +1012,7 @@ private struct CardFaceView: View {
|
||||
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||||
// span-cap (03-board-ui.md § Motion).
|
||||
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
|
||||
side: .live,
|
||||
container: .board,
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
@@ -1021,7 +1021,7 @@ private struct CardFaceView: View {
|
||||
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
|
||||
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
|
||||
private var dragReplica: some View {
|
||||
let count = store.selection.liveness == .live && store.selection.ids.contains(card.id)
|
||||
let count = store.selection.container == .board && store.selection.ids.contains(card.id)
|
||||
? max(1, store.selection.ids.count)
|
||||
: 1
|
||||
return ZStack {
|
||||
@@ -1098,17 +1098,17 @@ private struct CardFaceView: View {
|
||||
/// alone — standard macOS context-menu targeting, shared by Style… (`styleTarget`) and Delete
|
||||
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
|
||||
private var styleTarget: StyleTarget {
|
||||
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
|
||||
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
|
||||
return .items([card.id])
|
||||
}
|
||||
return .items(store.selection.ids)
|
||||
}
|
||||
|
||||
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
||||
/// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here
|
||||
/// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
|
||||
/// on the live side).
|
||||
private var targetIDs: Set<ItemID> {
|
||||
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
|
||||
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
|
||||
return [card.id]
|
||||
}
|
||||
return store.selection.ids
|
||||
@@ -1205,7 +1205,7 @@ private struct CardFaceView: View {
|
||||
// MARK: - Selection and rename plumbing
|
||||
|
||||
private var isSelected: Bool {
|
||||
store.selection.liveness == .live && store.selection.ids.contains(card.id)
|
||||
store.selection.container == .board && store.selection.ids.contains(card.id)
|
||||
}
|
||||
|
||||
/// Whether an external Finder file drag is hovering **this** card — the attach highlight
|
||||
@@ -1330,14 +1330,14 @@ private struct NewCardStubView: View {
|
||||
///
|
||||
/// The lane is read before the commit, because every discard path clears the overlay that holds
|
||||
/// it — and re-checked after, because one of those paths is *the lane vanished*, and selecting
|
||||
/// something that renders nowhere would break the homogeneous-by-liveness invariant until the
|
||||
/// something that renders nowhere would break the one-container invariant until the
|
||||
/// next reload swept it away.
|
||||
@discardableResult
|
||||
private func commit() -> ItemID? {
|
||||
let lane = store.transient.newCardPlaceholder?.laneID
|
||||
let id = store.commitPlaceholder()
|
||||
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) {
|
||||
store.select([lane], liveness: .live)
|
||||
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane }) {
|
||||
store.select([lane], in: .board)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user