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:
@@ -15,16 +15,17 @@ struct DropTarget: Equatable, Sendable {
|
||||
/// The three surfaces a drop can name, spelled as a sum so the impossible combinations cannot be
|
||||
/// written down at all.
|
||||
///
|
||||
/// The trash is a case rather than an id because **it has no id**: the quasi-lane is not in the
|
||||
/// snapshot — it is `TrashModel.entries` derived from it — so there is nothing to put in a
|
||||
/// `lane`, and its index is not a position the pointer chose either (see `TrashDrop`).
|
||||
/// The trash is a case rather than an id because **it has no id**: `.trash/` "holds card
|
||||
/// folders directly — same shape as a lane's children, no `index.md` of its own"
|
||||
/// (01-storage-format.md § Deletion), so there is nothing to put in a `lane`, and its index is
|
||||
/// not a position the pointer chose either (see `TrashDrop`).
|
||||
enum Container: Equatable, Sendable {
|
||||
/// The **lane strip**: the index counts live lanes with the dragged run removed.
|
||||
/// The **lane strip**: the index counts the board's lanes with the dragged run removed.
|
||||
case strip
|
||||
/// That lane's **masonry**: the index is a position in its logical card order
|
||||
/// (DRAG-REORDER.md § The card masonry).
|
||||
case lane(ItemID)
|
||||
/// The **trash quasi-lane**, which a live card drag proposes into to delete it
|
||||
/// The **trash column**, which a board card drag proposes into to delete it
|
||||
/// (04-interactions.md ▸ The trash, settled 2026-07-28). The index is always the topmost row.
|
||||
case trash
|
||||
}
|
||||
@@ -57,8 +58,9 @@ enum TrashDrop {
|
||||
|
||||
/// The row the shadow takes, always: **the topmost**.
|
||||
///
|
||||
/// Not arbitrary, and the sort is what makes it honest: the trash orders by `deleted`
|
||||
/// newest-first (03-board-ui.md § Trash), so a fresh tombstone genuinely lands on top. The drop
|
||||
/// Not arbitrary, and the ranks are what make it honest: "every trash arrival mints a rank
|
||||
/// above the current top" (04-interactions.md ▸ The trash), so a fresh delete genuinely lands on
|
||||
/// top. The drop
|
||||
/// therefore still lands exactly where the shadow shows — the one positional promise every other
|
||||
/// drop in this app makes — while being the only proposal on the board the *pointer* does not
|
||||
/// choose.
|
||||
@@ -70,7 +72,7 @@ enum TrashDrop {
|
||||
/// - **Lanes are not deliverable this way** — "a lane drag proposes only lane slots". (The strip's
|
||||
/// slot list has never contained the quasi-lane, so this is belt over braces; it is written down
|
||||
/// because a guard that is only true by construction is one refactor from being false.)
|
||||
/// - **A trash row is already there.** A `.trashed` session's vocabulary is restore and copy-out;
|
||||
/// - **A trash card is already there.** A `.trash` session's vocabulary is restore and copy-out;
|
||||
/// dropping it back where it came from writes nothing.
|
||||
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign card
|
||||
/// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the
|
||||
@@ -82,14 +84,14 @@ enum TrashDrop {
|
||||
/// - **The mutating-gesture rule**, like every other write the pointer can start.
|
||||
static func accepts(
|
||||
kind: DragKind?,
|
||||
side: Liveness,
|
||||
container: ItemContainer,
|
||||
isWithinBoard: Bool,
|
||||
operation: TransferOperation,
|
||||
isTrashShown: Bool,
|
||||
acceptsMutations: Bool
|
||||
) -> Bool {
|
||||
guard isTrashShown, acceptsMutations else { return false }
|
||||
guard kind == .cards, side == .live, isWithinBoard else { return false }
|
||||
guard kind == .cards, container == .board, isWithinBoard else { return false }
|
||||
return operation == .move
|
||||
}
|
||||
}
|
||||
@@ -207,14 +209,14 @@ enum DragLocality {
|
||||
/// - **Lane drags never copy within their board.** ⌥ is simply ignored there: the drag stays a
|
||||
/// clean reorder and the badge never shows copy. The within-board lane duplicate exists, but
|
||||
/// its home is the clipboard (▸ Clipboard, Lane paste).
|
||||
/// - **A trash row's drag is copy-out grammar** (▸ The trash). Within its own board it is the
|
||||
/// restore — a move, no badge; across boards the default is the live copy that leaves the
|
||||
/// tombstoned original in place, exactly as ⌘C out of the trash behaves. ⌘ forces the true
|
||||
/// restore-move either way, and ⌥ forces the live copy either way ("⌘C, ⌥-drag, and the
|
||||
/// cross-board drag default always yield *live* copies").
|
||||
/// - **A trash card's drag is the restore** (▸ The trash). Within its own board it is "an
|
||||
/// ordinary move to the drop position"; across boards the default is the copy that leaves the
|
||||
/// original in the source trash, and "⌘-drag forces the true cross-board restore-move". Both
|
||||
/// fall out of the ordinary locality rule with no trash clause at all, which is the pivot's
|
||||
/// whole point.
|
||||
static func operation(
|
||||
kind: DragKind,
|
||||
side: Liveness,
|
||||
container: ItemContainer,
|
||||
isWithinBoard: Bool,
|
||||
modifiers: NSEvent.ModifierFlags
|
||||
) -> TransferOperation {
|
||||
@@ -227,7 +229,7 @@ enum DragLocality {
|
||||
|
||||
if forcesMove { return .move }
|
||||
if forcesCopy { return .copy }
|
||||
_ = side // the side changes which commit runs, never which operation the badge shows
|
||||
_ = container // the container changes which commit runs, never which operation the badge shows
|
||||
return isWithinBoard ? .move : .copy
|
||||
}
|
||||
}
|
||||
@@ -272,9 +274,9 @@ final class DragSession {
|
||||
/// means here rather than a separate flag.
|
||||
private(set) var kind: DragKind?
|
||||
|
||||
/// The side the drag started on. A trash row's drag is a `.cards` session on the `.trashed`
|
||||
/// side, and that is the whole of what makes it one (04-interactions.md ▸ The trash).
|
||||
private(set) var side: Liveness = .live
|
||||
/// The container the drag started in. A trash card's drag is a `.cards` session in `.trash`,
|
||||
/// and that is the whole of what makes it a restore (04-interactions.md ▸ The trash).
|
||||
private(set) var container: ItemContainer = .board
|
||||
|
||||
/// The dragged items in **flatten order** — the order they will land in.
|
||||
private(set) var members: [ItemID] = []
|
||||
@@ -358,8 +360,8 @@ final class DragSession {
|
||||
|
||||
/// The items to **leave out of the resting layout** on the board rooted at `root`.
|
||||
///
|
||||
/// Only the source board hides anything, and only for a live-side session: a trash row's drag
|
||||
/// carries items that render in the quasi-lane, not in any lane's masonry, so no lane loses a
|
||||
/// Only the source board hides anything, and only for a board-side session: a trash card's drag
|
||||
/// carries items that render in the trash column, not in any lane's masonry, so no lane loses a
|
||||
/// card to it.
|
||||
///
|
||||
/// **The dragged run is lifted out whatever the effective operation is** (DRAG-REORDER.md §
|
||||
@@ -368,7 +370,7 @@ final class DragSession {
|
||||
/// originals reappear when the write lands — the hold keeps them lifted for that round trip, so
|
||||
/// the arrangement on screen is the one the release proposed and stays still until the echo.
|
||||
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
|
||||
guard isActive, side == .live, let sourceRoot,
|
||||
guard isActive, container == .board, let sourceRoot,
|
||||
DragLocality.isSameBoard(root, sourceRoot)
|
||||
else { return [] }
|
||||
return memberSet
|
||||
@@ -473,27 +475,27 @@ final class DragSession {
|
||||
|
||||
// MARK: Lifecycle
|
||||
|
||||
/// Begins a card session — live faces or trash rows.
|
||||
/// Begins a card session — board faces or trash cards.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - members: the dragged cards in flatten order (`SelectionGrammar.liveCards`, or the trash's
|
||||
/// own sorted order for a trash-row drag).
|
||||
/// - members: the dragged cards in flatten order (`SelectionGrammar.boardCards`, or the
|
||||
/// trash's own order for a trash-card drag).
|
||||
/// - heights: their measured heights, captured **before** the pickup transition starts.
|
||||
func beginCards(
|
||||
_ members: [ItemID],
|
||||
folders: [URL],
|
||||
heights: [CGFloat],
|
||||
side: Liveness,
|
||||
container: ItemContainer,
|
||||
source: BoardStore
|
||||
) {
|
||||
begin(kind: .cards, members: members, folders: folders, side: side, source: source)
|
||||
begin(kind: .cards, members: members, folders: folders, container: container, source: source)
|
||||
cardHeights = heights
|
||||
laneUnits = []
|
||||
}
|
||||
|
||||
/// Begins a lane session.
|
||||
func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) {
|
||||
begin(kind: .lanes, members: members, folders: folders, side: .live, source: source)
|
||||
begin(kind: .lanes, members: members, folders: folders, container: .board, source: source)
|
||||
laneUnits = units
|
||||
cardHeights = []
|
||||
}
|
||||
@@ -502,7 +504,7 @@ final class DragSession {
|
||||
kind: DragKind,
|
||||
members: [ItemID],
|
||||
folders: [URL],
|
||||
side: Liveness,
|
||||
container: ItemContainer,
|
||||
source: BoardStore
|
||||
) {
|
||||
endHold()
|
||||
@@ -510,14 +512,14 @@ final class DragSession {
|
||||
self.members = members
|
||||
self.memberSet = Set(members)
|
||||
self.folders = folders
|
||||
self.side = side
|
||||
self.container = container
|
||||
self.sourceStore = source
|
||||
self.sourceRoot = source.rootURL
|
||||
self.proposal = nil
|
||||
self.operation = .move
|
||||
// The reload-resolved drag set: vanished members leave it silently, which is what
|
||||
// `survivors` reads and what "an emptied drag cancels itself" is stated in terms of.
|
||||
source.transient.dragMembers = ItemReferenceSet(ids: memberSet, liveness: side)
|
||||
source.transient.dragMembers = ItemReferenceSet(ids: memberSet, container: container)
|
||||
armWatchdog()
|
||||
}
|
||||
|
||||
@@ -538,7 +540,7 @@ final class DragSession {
|
||||
guard let kind, let sourceRoot else { return operation }
|
||||
let resolved = DragLocality.operation(
|
||||
kind: kind,
|
||||
side: side,
|
||||
container: container,
|
||||
isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot),
|
||||
modifiers: NSEvent.modifierFlags
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user