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:
@@ -240,7 +240,7 @@ struct BoardView: View {
|
||||
guard ClickModifier.current == .plain else { return }
|
||||
store.clearSelection()
|
||||
}
|
||||
.simultaneousGesture(marqueeControl.gesture(side: .live))
|
||||
.simultaneousGesture(marqueeControl.gesture(in: .board))
|
||||
}
|
||||
|
||||
/// The lanes and the drag's shadows, plus the trash column when it is shown.
|
||||
@@ -269,7 +269,7 @@ struct BoardView: View {
|
||||
if isTrashVisible {
|
||||
// Trailing, always — the quasi-lane has no position of its own to lose, which is
|
||||
// also why it never appears in the drop proposal's inputs (those are built from
|
||||
// `liveLanes`) and why the terminal slot clamps in front of it.
|
||||
// `boardLanes`) and why the terminal slot clamps in front of it.
|
||||
TrashLaneView(
|
||||
store: store,
|
||||
confirmations: confirmations,
|
||||
@@ -427,11 +427,11 @@ struct BoardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The lanes the strip lays out, in snapshot order. **Tombstoned lanes render nowhere here** —
|
||||
/// 03-board-ui.md § Trash collapses each into a single restorable entry in the trash quasi-lane
|
||||
/// (a later card), and a lane that is not on the board consumes none of the window's width.
|
||||
private var liveLanes: [Lane] {
|
||||
store.snapshot.lanes.filter { !$0.isDeleted }
|
||||
/// The lanes the strip lays out, in snapshot order — every lane the board has. Deleting a lane
|
||||
/// is physical now (03-board-ui.md § Trash: "Cards only. Lanes are never trashed"), so a lane in
|
||||
/// the snapshot is a lane on the board, with no hidden state to filter for.
|
||||
private var boardLanes: [Lane] {
|
||||
store.snapshot.lanes
|
||||
}
|
||||
|
||||
// MARK: - Trash
|
||||
@@ -443,16 +443,15 @@ struct BoardView: View {
|
||||
store.transient.isTrashVisible
|
||||
}
|
||||
|
||||
/// The trash's rows as the column is showing them — the shown trash "participates in the filter
|
||||
/// like any lane" (03-board-ui.md § Trash), and the arrows walk what is on screen
|
||||
/// (`TrashLaneView.entries` applies the identical predicate to the identical rows).
|
||||
/// The trash's cards as the column is showing them — the shown trash's cards "participate in
|
||||
/// the filter exactly like any other card" (03-board-ui.md § Trash), and the arrows walk what is
|
||||
/// on screen (`TrashLaneView` applies the identical predicate to the identical cards).
|
||||
///
|
||||
/// Read by the three keyboard destinations that reach into the column — the arrow origin's
|
||||
/// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a row the
|
||||
/// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a card the
|
||||
/// filter took away.
|
||||
private var trashEntries: [TrashEntry] {
|
||||
let filter = store.searchFilter
|
||||
return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) }
|
||||
private var trashCards: [ItemID] {
|
||||
SelectionGrammar.trashCards(in: store.snapshot, filter: store.searchFilter)
|
||||
}
|
||||
|
||||
// MARK: - The drag
|
||||
@@ -506,7 +505,7 @@ struct BoardView: View {
|
||||
/// (`arrivingLaneUnits`).
|
||||
private func standardWidth(stripWidth: CGFloat) -> CGFloat {
|
||||
if resize.isActive { return resize.standard }
|
||||
var units = LaneLayoutMath.totalUnits(of: liveLanes, trashUnits: isTrashVisible ? 1 : 0)
|
||||
var units = LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0)
|
||||
units += arrivingLaneUnits
|
||||
return LaneLayoutMath.standardWidth(
|
||||
stripWidth: stripWidth,
|
||||
@@ -569,7 +568,7 @@ struct BoardView: View {
|
||||
private var stripSlots: [StripSlot] {
|
||||
let session = appModel.dragSession
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
var slots = liveLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
|
||||
var slots = boardLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
|
||||
guard let index = stripProposal else { return slots }
|
||||
let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) }
|
||||
slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count))
|
||||
@@ -596,10 +595,10 @@ struct BoardView: View {
|
||||
}
|
||||
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live,
|
||||
guard selection.container == .board,
|
||||
selection.ids.count == 1,
|
||||
let id = selection.ids.first,
|
||||
let target = BoardStore.liveItem(id, in: store.snapshot)
|
||||
let target = BoardStore.boardItem(id, in: store.snapshot)
|
||||
else { return .ignored }
|
||||
|
||||
if target.cardID == nil {
|
||||
@@ -614,9 +613,11 @@ struct BoardView: View {
|
||||
/// grammar so no second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md ▸ The
|
||||
/// map).
|
||||
///
|
||||
/// Deliberately **live-only**: the nexus scopes this key to a live selection, and ⌫'s trash-side
|
||||
/// role belongs to the ⌘⌫ twins, not to the bare key. A tombstoned selection is therefore inert
|
||||
/// here — Put Back is a chord.
|
||||
/// **Both stagings**, unlike the tombstone era's live-only reading: "Plain ⌫ performs the same
|
||||
/// delete as fixed grammar" (04-interactions.md ▸ The map, resettled 2026-07-28), and the delete
|
||||
/// is staged by place inside the store (`BoardStore.deleteSelection`) rather than by two menu
|
||||
/// items sharing a chord. Put Back — the reason the bare key had to stay off the trash — is
|
||||
/// retired with the tombstone model.
|
||||
///
|
||||
/// Inert while an inline editor is open, like every grammar key: the field owns ⌫ as backspace,
|
||||
/// and a stray one reaching the board mid-edit would delete the item being renamed.
|
||||
@@ -631,7 +632,7 @@ struct BoardView: View {
|
||||
}
|
||||
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, !selection.isEmpty else { return .ignored }
|
||||
guard !selection.isEmpty else { return .ignored }
|
||||
store.deleteSelection()
|
||||
return .handled
|
||||
}
|
||||
@@ -716,7 +717,7 @@ struct BoardView: View {
|
||||
guard let origin = arrowOrigin() else { return seed(direction, mode) }
|
||||
return origin.isLaneDomain
|
||||
? laneArrow(direction, mode, from: origin.head)
|
||||
: cardArrow(direction, mode, from: origin.head, on: origin.side)
|
||||
: cardArrow(direction, mode, from: origin.head, in: origin.container)
|
||||
}
|
||||
|
||||
private static func direction(of key: KeyEquivalent) -> NavigationMath.Direction? {
|
||||
@@ -738,33 +739,32 @@ struct BoardView: View {
|
||||
/// Select All and a foreign reload leave the arrows somewhere sensible without any of them
|
||||
/// having to name a cursor.
|
||||
///
|
||||
/// The **trash's list is both kinds interleaved** (`TrashModel.entries`), because "arrows walk
|
||||
/// every trash entry in its sorted order — card and lane entries alike" (04 ▸ The trash). The
|
||||
/// per-kind lists are the *range*'s business, not the walk's.
|
||||
/// The **trash's list is its cards**, top to bottom — there are no lane entries to interleave
|
||||
/// any more (03-board-ui.md § Trash: "Cards only").
|
||||
///
|
||||
/// Both lists are the **filtered** board (04 § Search: "arrow nav … read[s] it"), so the
|
||||
/// fallback lands on the last *visible* member rather than on a card the query hid.
|
||||
private func arrowOrigin() -> (head: ItemID, side: Liveness, isLaneDomain: Bool)? {
|
||||
private func arrowOrigin() -> (head: ItemID, container: ItemContainer, isLaneDomain: Bool)? {
|
||||
let selection = store.selection
|
||||
guard !selection.isEmpty else { return nil }
|
||||
|
||||
let isLaneDomain: Bool
|
||||
let list: [ItemID]
|
||||
switch selection.liveness {
|
||||
case .live:
|
||||
switch selection.container {
|
||||
case .board:
|
||||
guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
|
||||
isLaneDomain = kind == .lane
|
||||
list = SelectionGrammar.order(of: kind, on: .live, in: store.snapshot, filter: store.searchFilter)
|
||||
case .trashed:
|
||||
list = SelectionGrammar.order(of: kind, in: .board, snapshot: store.snapshot, filter: store.searchFilter)
|
||||
case .trash:
|
||||
isLaneDomain = false
|
||||
list = trashEntries.map(\.id)
|
||||
list = trashCards
|
||||
}
|
||||
|
||||
if let head = store.transient.selectionHead, list.contains(head) {
|
||||
return (head, selection.liveness, isLaneDomain)
|
||||
return (head, selection.container, isLaneDomain)
|
||||
}
|
||||
guard let last = list.last(where: { selection.ids.contains($0) }) else { return nil }
|
||||
return (last, selection.liveness, isLaneDomain)
|
||||
return (last, selection.container, isLaneDomain)
|
||||
}
|
||||
|
||||
/// **An empty selection seeds at the first lane's first card** (04-interactions.md ▸ Grammar) —
|
||||
@@ -779,8 +779,8 @@ struct BoardView: View {
|
||||
if mode == .jump, direction == .left || direction == .right {
|
||||
return jumpToEndLane(direction)
|
||||
}
|
||||
guard let first = Self.firstCard(scanning: liveLanes, filter: store.searchFilter) else { return .handled }
|
||||
replaceSelection(with: first, on: .live)
|
||||
guard let first = Self.firstCard(scanning: boardLanes, filter: store.searchFilter) else { return .handled }
|
||||
replaceSelection(with: first, in: .board)
|
||||
return .handled
|
||||
}
|
||||
|
||||
@@ -790,7 +790,7 @@ struct BoardView: View {
|
||||
_ direction: NavigationMath.Direction,
|
||||
_ mode: ArrowMode,
|
||||
from head: ItemID,
|
||||
on side: Liveness
|
||||
in container: ItemContainer
|
||||
) -> KeyPress.Result {
|
||||
switch mode {
|
||||
case .step: step(direction, from: head)
|
||||
@@ -798,7 +798,7 @@ struct BoardView: View {
|
||||
case .jump:
|
||||
switch direction {
|
||||
case .left, .right: jumpToEndLane(direction)
|
||||
case .up, .down: jumpWithinContainer(direction, from: head, on: side)
|
||||
case .up, .down: jumpWithinContainer(direction, from: head, in: container)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -817,7 +817,7 @@ struct BoardView: View {
|
||||
),
|
||||
let next = marqueeTargets.targets[nextID]
|
||||
else { return .handled }
|
||||
replaceSelection(with: next.id, on: next.side)
|
||||
replaceSelection(with: next.id, in: next.container)
|
||||
return .handled
|
||||
}
|
||||
|
||||
@@ -837,7 +837,7 @@ struct BoardView: View {
|
||||
among: marqueeTargets.all
|
||||
),
|
||||
let next = marqueeTargets.targets[nextID],
|
||||
next.side == origin.side,
|
||||
next.container == origin.container,
|
||||
next.kind == origin.kind
|
||||
else { return .handled }
|
||||
|
||||
@@ -849,13 +849,13 @@ struct BoardView: View {
|
||||
from: anchor,
|
||||
to: next.id,
|
||||
kind: next.kind,
|
||||
on: next.side,
|
||||
in: store.snapshot,
|
||||
in: next.container,
|
||||
snapshot: store.snapshot,
|
||||
// The span is the *filtered* board's, so a range under a search collects exactly the
|
||||
// rows between the two endpoints that are on screen (04 § Search: "ranges … read it").
|
||||
// cards between the two endpoints that are on screen (04 § Search: "ranges … read it").
|
||||
filter: store.searchFilter
|
||||
) else { return .handled }
|
||||
store.select(ids, liveness: next.side, anchor: anchor, head: next.id)
|
||||
store.select(ids, in: next.container, anchor: anchor, head: next.id)
|
||||
return .handled
|
||||
}
|
||||
|
||||
@@ -869,30 +869,30 @@ struct BoardView: View {
|
||||
private func jumpWithinContainer(
|
||||
_ direction: NavigationMath.Direction,
|
||||
from head: ItemID,
|
||||
on side: Liveness
|
||||
in container: ItemContainer
|
||||
) -> KeyPress.Result {
|
||||
let container: [ItemID]
|
||||
let siblings: [ItemID]
|
||||
var lane: ItemID?
|
||||
switch side {
|
||||
case .trashed:
|
||||
container = trashEntries.map(\.id)
|
||||
case .live:
|
||||
switch container {
|
||||
case .trash:
|
||||
siblings = trashCards
|
||||
case .board:
|
||||
guard let home = store.snapshot.lanes.first(where: { lane in
|
||||
!lane.isDeleted && lane.cards.contains { $0.id == head && !$0.isDeleted }
|
||||
lane.cards.contains { $0.id == head }
|
||||
}) else { return .handled }
|
||||
lane = home.id
|
||||
// The container is what the lane is *showing*: a jump to "the lane's first card" under
|
||||
// a search means its first surviving card, not one the filter animated out.
|
||||
let filter = store.searchFilter
|
||||
container = home.cards.filter { !$0.isDeleted && filter.matches($0) }.map(\.id)
|
||||
siblings = home.cards.filter { filter.matches($0) }.map(\.id)
|
||||
}
|
||||
|
||||
guard let target = direction == .up ? container.first : container.last else { return .handled }
|
||||
guard let target = direction == .up ? siblings.first : siblings.last else { return .handled }
|
||||
if direction == .up, target == head, let lane, store.selection.ids == [head] {
|
||||
replaceSelection(with: lane, on: .live)
|
||||
replaceSelection(with: lane, in: .board)
|
||||
return .handled
|
||||
}
|
||||
replaceSelection(with: target, on: side)
|
||||
replaceSelection(with: target, in: container)
|
||||
return .handled
|
||||
}
|
||||
|
||||
@@ -904,11 +904,11 @@ struct BoardView: View {
|
||||
/// the jump falls through to the last lane. Empty lanes are scanned past in both directions —
|
||||
/// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
|
||||
private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
|
||||
if direction == .right, isTrashVisible, let first = trashEntries.first {
|
||||
replaceSelection(with: first.id, on: .trashed)
|
||||
if direction == .right, isTrashVisible, let first = trashCards.first {
|
||||
replaceSelection(with: first, in: .trash)
|
||||
return .handled
|
||||
}
|
||||
let lanes = liveLanes
|
||||
let lanes = boardLanes
|
||||
let filter = store.searchFilter
|
||||
// A lane the search emptied is scanned past exactly as an empty one is — the jump lands on
|
||||
// the first lane that is *showing* a card, which is what the user can see.
|
||||
@@ -916,7 +916,7 @@ struct BoardView: View {
|
||||
? Self.firstCard(scanning: lanes.reversed(), filter: filter)
|
||||
: Self.firstCard(scanning: lanes, filter: filter)
|
||||
guard let target else { return .handled }
|
||||
replaceSelection(with: target, on: .live)
|
||||
replaceSelection(with: target, in: .board)
|
||||
return .handled
|
||||
}
|
||||
|
||||
@@ -937,7 +937,7 @@ struct BoardView: View {
|
||||
_ mode: ArrowMode,
|
||||
from head: ItemID
|
||||
) -> KeyPress.Result {
|
||||
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
|
||||
let lanes = SelectionGrammar.lanes(in: store.snapshot)
|
||||
guard let index = lanes.firstIndex(of: head) else { return .handled }
|
||||
|
||||
switch (direction, mode) {
|
||||
@@ -945,30 +945,30 @@ struct BoardView: View {
|
||||
let next = index + (direction == .left ? -1 : 1)
|
||||
guard lanes.indices.contains(next) else { return .handled }
|
||||
if mode == .step {
|
||||
replaceSelection(with: lanes[next], on: .live)
|
||||
replaceSelection(with: lanes[next], in: .board)
|
||||
} else {
|
||||
let anchor = store.transient.selectionAnchor ?? head
|
||||
guard let ids = SelectionGrammar.range(
|
||||
from: anchor,
|
||||
to: lanes[next],
|
||||
kind: .lane,
|
||||
on: .live,
|
||||
in: store.snapshot
|
||||
in: .board,
|
||||
snapshot: store.snapshot
|
||||
) else { return .handled }
|
||||
store.select(ids, liveness: .live, anchor: anchor, head: lanes[next])
|
||||
store.select(ids, in: .board, anchor: anchor, head: lanes[next])
|
||||
}
|
||||
|
||||
case (.left, .jump), (.right, .jump):
|
||||
guard let target = direction == .left ? lanes.first : lanes.last else { return .handled }
|
||||
replaceSelection(with: target, on: .live)
|
||||
replaceSelection(with: target, in: .board)
|
||||
|
||||
case (.down, .step), (.down, .jump):
|
||||
guard let lane = store.snapshot.lanes.first(where: { $0.id == head && !$0.isDeleted }) else {
|
||||
guard let lane = store.snapshot.lanes.first(where: { $0.id == head }) else {
|
||||
return .handled
|
||||
}
|
||||
let cards = lane.cards.filter { !$0.isDeleted }
|
||||
let cards = lane.cards
|
||||
guard let target = mode == .jump ? cards.last : cards.first else { return .handled }
|
||||
replaceSelection(with: target.id, on: .live)
|
||||
replaceSelection(with: target.id, in: .board)
|
||||
|
||||
case (.up, _), (.down, .extend):
|
||||
// Nothing above the lane domain, and no vertical range within it.
|
||||
@@ -980,8 +980,8 @@ struct BoardView: View {
|
||||
// MARK: Shared
|
||||
|
||||
/// A jump's and a plain step's shared landing: one item, both cursors on it.
|
||||
private func replaceSelection(with id: ItemID, on side: Liveness) {
|
||||
store.select([id], liveness: side, anchor: id, head: id)
|
||||
private func replaceSelection(with id: ItemID, in container: ItemContainer) {
|
||||
store.select([id], in: container, anchor: id, head: id)
|
||||
}
|
||||
|
||||
/// The first rendered card of the first lane that has one — the scan every "first/last lane"
|
||||
@@ -994,7 +994,7 @@ struct BoardView: View {
|
||||
filter: SearchFilter = .inactive
|
||||
) -> ItemID? {
|
||||
for lane in lanes {
|
||||
if let card = lane.cards.first(where: { !$0.isDeleted && filter.matches($0) }) { return card.id }
|
||||
if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user