Realign code with the 2026-07-31 rulings
The trash sorts by modified descending — the arrival rank mint retires (Ranks.isOrderedForTrash one comparator, loader + merged order agree; the legacy deleted: migration stamps modified from the tombstone timestamp where parseable; delete undo steps validate existence-only; agent guide v8). Trash selection goes kind-blind — ranges, marquee, Select All, and the successor walk sweep both kinds; the guard moves to the exits (mixed-payload drop refusal, copy/cut validation). The copy stamping preflight widens back to comment depth (load-scoped posture — the board always loads, the gesture refuses whole). Fixes a latent no-op: trashed-lane drag restore never fired (DragSession.beginLanes hard-coded the board container). 2403 tests in 413 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -535,6 +535,23 @@ struct BoardDropContext {
|
||||
cancelDrop()
|
||||
return false
|
||||
}
|
||||
// **A mixed-kind drag never leaves the trash** (04-interactions.md ▸ The trash, ruled
|
||||
// 2026-07-31 with kind-blind trash selection): "pickup is allowed — the selection is legal —
|
||||
// but every out-of-trash drop target refuses the mixed payload, and the release surfaces a
|
||||
// notice explaining the rule … the refused drag ends like any refusal, rows staying put".
|
||||
//
|
||||
// One guard for every out-of-trash target, because every one of them funnels through this
|
||||
// commit — the lane masonries, the strip, the cross-board arrivals, and the window fallback
|
||||
// alike. A *within*-trash release cannot reach it: the column declines `.trash`-container
|
||||
// sessions outright (`TrashDrop.accepts`), so a trash-origin drag never holds a `.trash`
|
||||
// proposal, and "within-trash drops stay inert" needs no clause here.
|
||||
//
|
||||
// The notice is the destination board's strip, which is where the user is looking.
|
||||
guard !(session.container == .trash && session.mixesKinds) else {
|
||||
store.banners.postMixedTrashDrag()
|
||||
cancelDrop()
|
||||
return false
|
||||
}
|
||||
let ids = survivors.map { session.members[$0] }
|
||||
let folders = survivors.map { session.folders[$0] }
|
||||
let within = DragLocality.isSameBoard(sourceRoot, store.rootURL)
|
||||
|
||||
@@ -893,14 +893,20 @@ struct BoardView: View {
|
||||
return .handled
|
||||
}
|
||||
|
||||
/// **⇧-arrow extends, and stops at both boundaries** (04 ▸ The trash, settled): "a ⇧-arrow whose
|
||||
/// next step would cross from live cards into the trash (or back), or from card entries onto a
|
||||
/// lane entry within it, is simply inert".
|
||||
/// **⇧-arrow extends, and stops at the container boundary** (04 ▸ The trash: "⇧-arrow extension
|
||||
/// still stops at the container boundary") — a ⇧-arrow whose next step would cross from live
|
||||
/// cards into the trash, or back, is simply inert.
|
||||
///
|
||||
/// **The kind boundary is no longer one of the stops inside the trash** (re-ruled 2026-07-31):
|
||||
/// "⇧-click ranges, ⇧-arrow extension … all sweep every row" there, so a ⇧-arrow from a trash
|
||||
/// card onto a lane row extends across it. On the board the kind test stands — the live grammar
|
||||
/// is still cards XOR lanes.
|
||||
///
|
||||
/// The *step* that would cross is what goes inert — the crossing item is never stepped over in
|
||||
/// search of a legal one, because that would silently drop the held range for a longer reach
|
||||
/// than the user asked for. So the nearest neighbour is computed **unrestricted** and then
|
||||
/// tested: a different side or a different kind means this press does nothing at all.
|
||||
/// tested: a different side (or, on the board, a different kind) means this press does nothing
|
||||
/// at all.
|
||||
private func extend(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
|
||||
guard let origin = marqueeTargets.targets[head],
|
||||
let nextID = NavigationMath.nearest(
|
||||
@@ -910,7 +916,7 @@ struct BoardView: View {
|
||||
),
|
||||
let next = marqueeTargets.targets[nextID],
|
||||
next.container == origin.container,
|
||||
next.kind == origin.kind
|
||||
next.container == .trash || next.kind == origin.kind
|
||||
else { return .handled }
|
||||
|
||||
// An extension with no anchor makes one of where it started — the keyboard's equivalent of
|
||||
|
||||
@@ -356,12 +356,19 @@ struct CardFaceView: View {
|
||||
/// every other card drop uses, committed by the same `moveCards`.
|
||||
///
|
||||
/// **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.
|
||||
/// rows are drawn in, which since 2026-07-31 is `modified` descending (03 § Trash).
|
||||
///
|
||||
/// **A kind-blind selection can span both kinds, and the session says so** (04-interactions.md ▸
|
||||
/// The trash, ruled 2026-07-31): the lane rows in it cannot ride a `.cards` session, so rather
|
||||
/// than let them fall silently out of the payload the flag travels and the *drop* refuses with
|
||||
/// the notice (`DragSession.mixesKinds`). Pickup stays allowed — the selection is legal.
|
||||
private func startTrashCardDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let ids = draggedIDs
|
||||
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
|
||||
guard !rows.isEmpty else { return NSItemProvider() }
|
||||
let mixesKinds = SelectionGrammar.mixesKinds(
|
||||
ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot)
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
@@ -381,7 +388,8 @@ struct CardFaceView: View {
|
||||
folders: payload.folders,
|
||||
heights: rows.map { drops.registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight },
|
||||
container: .trash,
|
||||
source: store
|
||||
source: store,
|
||||
mixesKinds: mixesKinds
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
@@ -60,12 +60,11 @@ enum TrashDrop {
|
||||
|
||||
/// The row the shadow takes, always: **the topmost**.
|
||||
///
|
||||
/// 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.
|
||||
/// Not arbitrary, and the stamp is what makes it honest: "every trash arrival stamps `modified`
|
||||
/// and the trash sorts newest-first by that stamp" (04-interactions.md ▸ The trash, re-ruled
|
||||
/// 2026-07-31), 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.
|
||||
static let landingIndex = 0
|
||||
|
||||
/// Whether the shown trash takes this session — the whole of the gate, and every clause is a
|
||||
@@ -284,6 +283,18 @@ final class DragSession {
|
||||
/// and that is the whole of what makes it a restore (04-interactions.md ▸ The trash).
|
||||
private(set) var container: ItemContainer = .board
|
||||
|
||||
/// **Whether the selection this drag was picked up from spanned both kinds** — only ever true in
|
||||
/// the trash, whose selection went kind-blind (04-interactions.md ▸ The trash, ruled 2026-07-31).
|
||||
///
|
||||
/// A session carries items of exactly one `kind` — the pasteboard type is per-kind and a
|
||||
/// `DragPayload` names one — so a mixed selection dragged from a trash row starts a session over
|
||||
/// *its* kind and silently leaves the other behind. That silence is what this flag exists to
|
||||
/// prevent: "pickup is allowed — the selection is legal — but every out-of-trash drop target
|
||||
/// refuses the mixed payload, and the release surfaces a notice explaining the rule". The refusal
|
||||
/// is at the commit (`BoardDropContext.commitDrop`), where every drop out of the trash funnels,
|
||||
/// rather than at hover — 04 puts the explanation at the release.
|
||||
private(set) var mixesKinds = false
|
||||
|
||||
/// The dragged items in **flatten order** — the order they will land in.
|
||||
private(set) var members: [ItemID] = []
|
||||
|
||||
@@ -496,16 +507,33 @@ final class DragSession {
|
||||
folders: [URL],
|
||||
heights: [CGFloat],
|
||||
container: ItemContainer,
|
||||
source: BoardStore
|
||||
source: BoardStore,
|
||||
mixesKinds: Bool = false
|
||||
) {
|
||||
begin(kind: .cards, members: members, folders: folders, container: container, source: source)
|
||||
begin(
|
||||
kind: .cards, members: members, folders: folders,
|
||||
container: container, source: source, mixesKinds: mixesKinds
|
||||
)
|
||||
cardHeights = heights
|
||||
laneUnits = []
|
||||
}
|
||||
|
||||
/// Begins a lane session.
|
||||
func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) {
|
||||
begin(kind: .lanes, members: members, folders: folders, container: .board, source: source)
|
||||
///
|
||||
/// `container` is a parameter because a **trashed lane row**'s drag is a `.lanes` session in
|
||||
/// `.trash` — the restore at the lane level (04-interactions.md ▸ The trash ▸ Drag-to-restore).
|
||||
func beginLanes(
|
||||
_ members: [ItemID],
|
||||
folders: [URL],
|
||||
units: [Int],
|
||||
container: ItemContainer = .board,
|
||||
source: BoardStore,
|
||||
mixesKinds: Bool = false
|
||||
) {
|
||||
begin(
|
||||
kind: .lanes, members: members, folders: folders,
|
||||
container: container, source: source, mixesKinds: mixesKinds
|
||||
)
|
||||
laneUnits = units
|
||||
cardHeights = []
|
||||
}
|
||||
@@ -515,7 +543,8 @@ final class DragSession {
|
||||
members: [ItemID],
|
||||
folders: [URL],
|
||||
container: ItemContainer,
|
||||
source: BoardStore
|
||||
source: BoardStore,
|
||||
mixesKinds: Bool
|
||||
) {
|
||||
endHold()
|
||||
self.kind = kind
|
||||
@@ -523,6 +552,7 @@ final class DragSession {
|
||||
self.memberSet = Set(members)
|
||||
self.folders = folders
|
||||
self.container = container
|
||||
self.mixesKinds = mixesKinds
|
||||
self.sourceStore = source
|
||||
self.sourceRoot = source.rootURL
|
||||
self.proposal = nil
|
||||
|
||||
@@ -183,6 +183,11 @@ struct TrashLaneRowView: View {
|
||||
/// **Multi-drag carries the whole trash-side lane selection**, in the column's own order, and a
|
||||
/// row outside the selection drags alone — the card face's targeting rule at the other level.
|
||||
///
|
||||
/// **A kind-blind selection can span both kinds, and the session says so** (04-interactions.md ▸
|
||||
/// The trash, ruled 2026-07-31): a `.lanes` session cannot carry the cards in it, so rather than
|
||||
/// drop them silently the flag travels and the release refuses with the notice
|
||||
/// (`DragSession.mixesKinds`) — "pickup is allowed — the selection is legal".
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
|
||||
private func startDrag() -> NSItemProvider {
|
||||
@@ -190,6 +195,8 @@ struct TrashLaneRowView: View {
|
||||
let ids = draggedIDs
|
||||
let rows = store.snapshot.trashedLanes.filter { ids.contains($0.id) }
|
||||
guard !rows.isEmpty else { return NSItemProvider() }
|
||||
let mixesKinds = SelectionGrammar.mixesKinds(
|
||||
ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot)
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
@@ -212,7 +219,12 @@ struct TrashLaneRowView: View {
|
||||
// standard width; the restored lane then draws at whatever `width` its untouched
|
||||
// frontmatter still says, on the next reload.
|
||||
units: rows.map { _ in 1 },
|
||||
source: store
|
||||
// **The session's container is this row's**, matching the payload's — which is what
|
||||
// routes the release to `BoardStore.restoreLanes` (the move *out* of `.trash/`) rather
|
||||
// than to `moveLanes`' strip permutation, and what the mixed-payload exit keys on.
|
||||
container: .trash,
|
||||
source: store,
|
||||
mixesKinds: mixesKinds
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import SwiftUI
|
||||
///
|
||||
/// **Its cards are ordinary cards in a special place**, so there is nothing to derive and nothing
|
||||
/// to draw differently: 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 — through the very
|
||||
/// same card parse the lanes use and sorted by `modified` descending, the trash's own order — through the very
|
||||
/// same `CardFaceView` a lane renders. "A trashed card is an ordinary card in a special place —
|
||||
/// search, selection, rendering, styling, and clipboard all treat it exactly like any other card"
|
||||
/// (03 § Trash), and one view is the only way to make *rendering* literally true rather than
|
||||
@@ -24,11 +24,11 @@ import SwiftUI
|
||||
/// **A trashed lane is an opaque unit** — "one distinct dimmed row showing its title and held-card
|
||||
/// count … no styling accents, never expandable" (03 § Trash) — so it gets its own small view
|
||||
/// (`TrashLaneRowView`) rather than a third card-face role: what a card face draws is exactly what
|
||||
/// the design says this row must not. The two kinds **interleave purely by trash rank**, which is
|
||||
/// the design says this row must not. The two kinds **interleave by `modified` descending**, which is
|
||||
/// `BoardModel.trashEntries`' merge and not a second one written here.
|
||||
///
|
||||
/// Newest-first falls out of the ranks (every arrival of either kind mints one above the current
|
||||
/// top), so there is no timestamp sort and no entry type here at all.
|
||||
/// Newest-first falls out of the stamp (every arrival of either kind restamps `modified` on the way
|
||||
/// in — 03 § Trash, re-ruled 2026-07-31), so no rank is minted anywhere in this container.
|
||||
///
|
||||
/// ### What makes it a column and not a lane
|
||||
///
|
||||
@@ -52,9 +52,9 @@ import SwiftUI
|
||||
/// trash, lanes extended 2026-07-29) — the drag becomes the pointer's delete gesture, and release
|
||||
/// moves the dragged item(s) into `.trash/`. So the column declares an `onDrop`
|
||||
/// (`TrashDropDelegate`), and it is the narrowest one on the board: a board 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.
|
||||
/// unmodified. It diverges from every other drop in one way, and the stamp is what makes the
|
||||
/// divergence honest: **the shadow always takes the topmost row**, because every arrival restamps
|
||||
/// `modified` and the column is sorted by it, newest first.
|
||||
///
|
||||
/// The drag *out* is the restore, and it is deliberately not special at either level: a trash card's
|
||||
/// drag is an ordinary `.cards` session in the `.trash` container
|
||||
@@ -155,8 +155,9 @@ struct TrashLaneView: View {
|
||||
))
|
||||
}
|
||||
|
||||
/// The rows the column shows — **both kinds, interleaved by rank** (03-board-ui.md § Trash:
|
||||
/// "Lane rows and cards interleave in the one trash column purely by trash rank").
|
||||
/// The rows the column shows — **both kinds, interleaved by `modified` descending**
|
||||
/// (03-board-ui.md § Trash: "Lane rows and cards interleave in the one trash column by `modified`
|
||||
/// descending").
|
||||
///
|
||||
/// **Shown, the trash's contents "participate in the filter exactly like any other card"**
|
||||
/// (03 § Trash — "the point of the pivot"), so the search predicate narrows this collection
|
||||
|
||||
Reference in New Issue
Block a user