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:
@@ -140,7 +140,7 @@ struct BoardDropContext {
|
||||
DragLocality.isSameBoard(proposal.boardRoot, store.rootURL),
|
||||
let laneID = proposal.laneID
|
||||
else { return }
|
||||
guard !store.snapshot.lanes.contains(where: { $0.id == laneID && !$0.isDeleted }) else { return }
|
||||
guard !store.snapshot.lanes.contains(where: { $0.id == laneID }) else { return }
|
||||
session.propose(nil)
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ struct BoardDropContext {
|
||||
func retargetLanes() {
|
||||
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
let resting = store.snapshot.lanes.filter { !$0.isDeleted && !hidden.contains($0.id) }
|
||||
let resting = store.snapshot.lanes.filter { !hidden.contains($0.id) }
|
||||
let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) }
|
||||
let slot = DropSlotMath.laneSlot(
|
||||
cursorX: cursor.x,
|
||||
@@ -181,14 +181,14 @@ struct BoardDropContext {
|
||||
/// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll).
|
||||
func retargetCards(inLane laneID: ItemID) {
|
||||
guard session.isDraggingCards, let cursor = globalCursor() else { return }
|
||||
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else {
|
||||
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID }) else {
|
||||
revalidateProposal()
|
||||
return
|
||||
}
|
||||
guard let grid = registry.grids[laneID] else { return }
|
||||
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
let rendered = lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) }
|
||||
let rendered = lane.cards.filter { !hidden.contains($0.id) }
|
||||
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
|
||||
let placement = MasonryPlacement(
|
||||
columnCount: grid.columns,
|
||||
@@ -218,7 +218,7 @@ struct BoardDropContext {
|
||||
/// answers `nil` there — and the proposal simply **holds**, which is the hysteresis contract.
|
||||
func retargetCardsFromStrip() {
|
||||
guard session.isDraggingCards, let cursor = stripCursor() else { return }
|
||||
let lanes = store.snapshot.lanes.filter { !$0.isDeleted }
|
||||
let lanes = store.snapshot.lanes
|
||||
let index = LaneLayoutMath.laneIndex(
|
||||
atX: cursor.x,
|
||||
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||
@@ -251,7 +251,7 @@ struct BoardDropContext {
|
||||
guard let sourceRoot = session.sourceRoot else { return false }
|
||||
return TrashDrop.accepts(
|
||||
kind: session.kind,
|
||||
side: session.side,
|
||||
container: session.container,
|
||||
isWithinBoard: DragLocality.isSameBoard(sourceRoot, store.rootURL),
|
||||
operation: session.resolveOperation(destinationRoot: store.rootURL),
|
||||
isTrashShown: store.transient.isTrashVisible,
|
||||
@@ -338,14 +338,14 @@ struct BoardDropContext {
|
||||
/// cursor and the snapshot, so it cannot oscillate — the drawn layout never feeds back into it.
|
||||
func retargetFile(inLane laneID: ItemID, info: DropInfo) {
|
||||
guard acceptsFileDrop(info), let cursor = globalCursor(),
|
||||
let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }),
|
||||
let lane = store.snapshot.lanes.first(where: { $0.id == laneID }),
|
||||
let grid = registry.grids[laneID]
|
||||
else {
|
||||
session.proposeFile(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let rendered = lane.cards.filter { !$0.isDeleted }
|
||||
let rendered = lane.cards
|
||||
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
|
||||
let placement = MasonryPlacement(
|
||||
columnCount: grid.columns,
|
||||
@@ -399,7 +399,7 @@ struct BoardDropContext {
|
||||
session.proposeFile(nil)
|
||||
return
|
||||
}
|
||||
let lanes = store.snapshot.lanes.filter { !$0.isDeleted }
|
||||
let lanes = store.snapshot.lanes
|
||||
let index = LaneLayoutMath.laneIndex(
|
||||
atX: cursor.x,
|
||||
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||
@@ -498,7 +498,7 @@ struct BoardDropContext {
|
||||
/// set's size (DRAG-REORDER.md § The drop commits).
|
||||
///
|
||||
/// **One of the containers is not a destination but a verb.** A proposal naming the trash commits
|
||||
/// a tombstone — the same write ⌫ performs, through the same `BoardWriter.deleteItem` in the same
|
||||
/// a delete — the same write ⌫ performs, through the same `BoardWriter.deleteCardToTrash` in the same
|
||||
/// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk
|
||||
/// from one deleted by keystroke (04-interactions.md ▸ The trash, settled 2026-07-28).
|
||||
///
|
||||
@@ -547,15 +547,16 @@ struct BoardDropContext {
|
||||
case .cards:
|
||||
if target.isTrash {
|
||||
// **The pointer's delete gesture** (04-interactions.md ▸ The trash, settled
|
||||
// 2026-07-28): "release tombstones the dragged card(s), exactly the ⌫ tombstone".
|
||||
// 2026-07-28): "release moves the dragged card(s) into `.trash/`" — exactly the ⌫
|
||||
// delete.
|
||||
//
|
||||
// The gate is re-asked here rather than trusted from the hover, because the one input
|
||||
// that can change between them arrives through no callback at all: ⌥ pressed after
|
||||
// the proposal stood would otherwise tombstone an original the copy grammar had just
|
||||
// the proposal stood would otherwise delete an original the copy grammar had just
|
||||
// promised to leave alone. A refusal cancels — items return, nothing is written.
|
||||
guard TrashDrop.accepts(
|
||||
kind: kind,
|
||||
side: session.side,
|
||||
container: session.container,
|
||||
isWithinBoard: within,
|
||||
operation: operation,
|
||||
isTrashShown: store.transient.isTrashVisible,
|
||||
@@ -571,27 +572,20 @@ struct BoardDropContext {
|
||||
cancelDrop()
|
||||
return false
|
||||
}
|
||||
switch (session.side, within) {
|
||||
case (.live, true):
|
||||
// **The trash side needs no branch of its own any more** (04-interactions.md ▸ The
|
||||
// trash, resettled 2026-07-28: "Drag-to-restore follows the locality model: dropping a
|
||||
// trash card into one of its own board's lanes is an ordinary move to the drop
|
||||
// position"). `moveCards`/`copyCards` resolve their members in either container, so a
|
||||
// restore *is* the within-board move and a cross-board restore *is* the ordinary
|
||||
// arrival — which is exactly what retiring the restore-specific machinery bought.
|
||||
if within {
|
||||
if operation == .copy {
|
||||
store.copyCards(Set(ids), toLane: laneID, at: target.index)
|
||||
} else {
|
||||
store.moveCards(Set(ids), toLane: laneID, at: target.index)
|
||||
}
|
||||
case (.live, false):
|
||||
} else {
|
||||
store.receiveCards(folders, operation: operation, toLane: laneID, at: target.index)
|
||||
case (.trashed, true):
|
||||
// Drag-to-restore, and its ⌥ twin. "Dropping a tombstoned card into one of its own
|
||||
// board's lanes restores it at the drop position"; ⌥ is the copy-out instead — a
|
||||
// live copy lands and the tombstoned original stays (04-interactions.md ▸ The trash,
|
||||
// "⌘C, ⌥-drag … always yield live copies").
|
||||
if operation == .copy {
|
||||
store.receiveRestoredCards(folders, operation: .copy, toLane: laneID, at: target.index)
|
||||
} else {
|
||||
store.restoreByDrag(cardIDs: ids, intoLane: laneID, at: target.index)
|
||||
}
|
||||
case (.trashed, false):
|
||||
store.receiveRestoredCards(folders, operation: operation, toLane: laneID, at: target.index)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user