Every board root keys once at its store — the hover path stops walking the filesystem
`DragLocality.isSameBoard` resolved symlinks on both URLs, which stats every path component. It ran five-plus times per `dropUpdated` and again per lane per body evaluation through `renderedCards` → `hiddenMembers` — order of 50–100 stat calls per mouse-move, and worse on iCloud-backed paths. A pickup stall sample had it on ~47 of 943 stacks. `BoardRootKey` mints that canonical spelling once, where the root is, and every locality comparison downstream is an `==` on two strings. The drag carriers hold keys rather than URLs, so re-deriving under the cursor is no longer expressible. `rootURL` keeps the user's spelling — the folder name is the display-name fallback, and canonicalizing there would visibly rename a board opened through a pin. The key follows the folder through `relocate(to:)`: a key frozen at open would collide with a new board opened at the vacated path, and two boards comparing as one is a cross-board drag silently behaving as a move. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -8,6 +8,32 @@ import SwiftUI
|
|||||||
|
|
||||||
// MARK: - Vocabulary
|
// MARK: - Vocabulary
|
||||||
|
|
||||||
|
/// **A board root's identity**, as the one canonical spelling every locality comparison is made
|
||||||
|
/// against (04-interactions.md ▸ Drag and drop — the Finder volume model).
|
||||||
|
///
|
||||||
|
/// Two roots name the same board when they name the same place: symlinks are resolved first — a
|
||||||
|
/// board reached through a pinned symlink is the same board as the one reached directly
|
||||||
|
/// (01-storage-format.md's symlink pins) — and the standardized path is the comparison key, so
|
||||||
|
/// `/Boards/./Work.kanban/` and `/Boards/Other/../Work.kanban` are one board.
|
||||||
|
///
|
||||||
|
/// **Minting touches the filesystem; comparing does not.** `resolvingSymlinksInPath` stats every
|
||||||
|
/// component of the path — the trap `EchoLedger.key` refuses outright for the same reason — and the
|
||||||
|
/// drag's locality question is asked several times per `dropUpdated` and again per lane per body
|
||||||
|
/// evaluation. Resolution therefore happens exactly once per board, where the root is
|
||||||
|
/// (`BoardStore.rootKey`), and every reader downstream compares two strings. Holding one of these
|
||||||
|
/// rather than a `URL` is what makes the re-derivation unwriteable rather than merely avoided.
|
||||||
|
public struct BoardRootKey: Hashable, Sendable {
|
||||||
|
|
||||||
|
/// The resolved, standardized path — kept for equality and for nothing else. The root's *user*
|
||||||
|
/// spelling stays on `BoardStore.rootURL`, which is what the display-name fallback and every
|
||||||
|
/// derived write path read: canonicalizing there would rename a board reached through a pin.
|
||||||
|
public let path: String
|
||||||
|
|
||||||
|
public init(_ url: URL) {
|
||||||
|
path = url.resolvingSymlinksInPath().standardizedFileURL.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Why a board is refusing writes — the read-only lock's cause, and now the whole of the
|
/// Why a board is refusing writes — the read-only lock's cause, and now the whole of the
|
||||||
/// vocabulary 02-architecture.md names.
|
/// vocabulary 02-architecture.md names.
|
||||||
///
|
///
|
||||||
@@ -370,6 +396,19 @@ public final class BoardStore: HealHost {
|
|||||||
/// snapshot at the new root, after which the two agree again.
|
/// snapshot at the new root, after which the two agree again.
|
||||||
public private(set) var rootURL: URL
|
public private(set) var rootURL: URL
|
||||||
|
|
||||||
|
/// This board's **identity**, minted once here because minting is the filesystem-touching step
|
||||||
|
/// (`BoardRootKey`). Every locality comparison a drag makes — at hover, at release, and in every
|
||||||
|
/// lane's resting layout — is an `==` against this value.
|
||||||
|
///
|
||||||
|
/// **Follows the folder, exactly as `rootURL` does** — an absorbed rename or move re-mints it
|
||||||
|
/// (`relocate(to:)`), at one filesystem touch per relocation and none on the hover path. A key
|
||||||
|
/// frozen at open would outlive the place it names, and a *new* board opened at the vacated path
|
||||||
|
/// would then mint the same one: two boards comparing as one, which is a cross-board drag
|
||||||
|
/// silently behaving as a within-board move. The residue of following instead — a rename
|
||||||
|
/// absorbed mid-drag re-reads that drag's locality — is the cosmetic failure of the pair, and
|
||||||
|
/// is what shipped before the key existed.
|
||||||
|
public private(set) var rootKey: BoardRootKey
|
||||||
|
|
||||||
// MARK: Banners
|
// MARK: Banners
|
||||||
|
|
||||||
/// The board window's banner strip, as a model (02-architecture.md § The banner surface).
|
/// The board window's banner strip, as a model (02-architecture.md § The banner surface).
|
||||||
@@ -710,6 +749,7 @@ public final class BoardStore: HealHost {
|
|||||||
/// surface.
|
/// surface.
|
||||||
public init(rootURL: URL, loaded result: LoadResult, skipping: Set<String> = []) {
|
public init(rootURL: URL, loaded result: LoadResult, skipping: Set<String> = []) {
|
||||||
self.rootURL = rootURL
|
self.rootURL = rootURL
|
||||||
|
self.rootKey = BoardRootKey(rootURL)
|
||||||
self.snapshot = result.model
|
self.snapshot = result.model
|
||||||
self.loadWarnings = result.warnings
|
self.loadWarnings = result.warnings
|
||||||
self.defects = result.defects
|
self.defects = result.defects
|
||||||
@@ -1228,6 +1268,7 @@ public final class BoardStore: HealHost {
|
|||||||
guard newRoot != rootURL else { return }
|
guard newRoot != rootURL else { return }
|
||||||
Self.logger.debug("board root relocated; Writer URLs now derive from the new location")
|
Self.logger.debug("board root relocated; Writer URLs now derive from the new location")
|
||||||
rootURL = newRoot
|
rootURL = newRoot
|
||||||
|
rootKey = BoardRootKey(newRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Raises the vanished-root read-only lock — the registry's call, after bookmark re-resolution
|
/// Raises the vanished-root read-only lock — the registry's call, after bookmark re-resolution
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ struct BoardDropContext {
|
|||||||
/// disagree.
|
/// disagree.
|
||||||
func revalidateProposal() {
|
func revalidateProposal() {
|
||||||
guard let proposal = session.proposal,
|
guard let proposal = session.proposal,
|
||||||
DragLocality.isSameBoard(proposal.boardRoot, store.rootURL),
|
proposal.boardRoot == store.rootKey,
|
||||||
let laneID = proposal.laneID
|
let laneID = proposal.laneID
|
||||||
else { return }
|
else { return }
|
||||||
guard !store.snapshot.lanes.contains(where: { $0.id == laneID }) else { return }
|
guard !store.snapshot.lanes.contains(where: { $0.id == laneID }) else { return }
|
||||||
@@ -179,7 +179,7 @@ struct BoardDropContext {
|
|||||||
/// `receiveLanes` keep the index space they always had.
|
/// `receiveLanes` keep the index space they always had.
|
||||||
func retargetLanes() {
|
func retargetLanes() {
|
||||||
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
||||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
|
||||||
let resting = store.snapshot.lanes.filter { !hidden.contains($0.id) }
|
let resting = store.snapshot.lanes.filter { !hidden.contains($0.id) }
|
||||||
let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) }
|
let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) }
|
||||||
let slot = DropSlotMath.laneSlot(
|
let slot = DropSlotMath.laneSlot(
|
||||||
@@ -188,11 +188,11 @@ struct BoardDropContext {
|
|||||||
draggedUnits: session.laneUnits,
|
draggedUnits: session.laneUnits,
|
||||||
standard: standard(),
|
standard: standard(),
|
||||||
gap: gap,
|
gap: gap,
|
||||||
current: session.stripProposal(onBoardRooted: store.rootURL)
|
current: session.stripProposal(onBoardRooted: store.rootKey)
|
||||||
)
|
)
|
||||||
guard let slot else { return } // a dead region: hold the current proposal
|
guard let slot else { return } // a dead region: hold the current proposal
|
||||||
let index = min(max(0, slot), restingUnits.count)
|
let index = min(max(0, slot), restingUnits.count)
|
||||||
session.propose(DropTarget(boardRoot: store.rootURL, container: .strip, index: index))
|
session.propose(DropTarget(boardRoot: store.rootKey, container: .strip, index: index))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a **card** session would land in `laneID`'s masonry.
|
/// Where a **card** session would land in `laneID`'s masonry.
|
||||||
@@ -215,7 +215,7 @@ struct BoardDropContext {
|
|||||||
}
|
}
|
||||||
guard let grid = registry.grids[laneID] else { return }
|
guard let grid = registry.grids[laneID] else { return }
|
||||||
|
|
||||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
|
||||||
let rendered = lane.cards.filter { !hidden.contains($0.id) }
|
let rendered = lane.cards.filter { !hidden.contains($0.id) }
|
||||||
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
|
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
|
||||||
let placement = MasonryPlacement(
|
let placement = MasonryPlacement(
|
||||||
@@ -232,10 +232,10 @@ struct BoardDropContext {
|
|||||||
// The run's footprint at the landing spot: the first dragged card's frozen height, which
|
// The run's footprint at the landing spot: the first dragged card's frozen height, which
|
||||||
// is the trigger rect the cursor is over (the rest stack below it).
|
// is the trigger rect the cursor is over (the rest stack below it).
|
||||||
draggedHeight: session.cardHeights.first ?? LaneDropRegistry.nominalCardHeight,
|
draggedHeight: session.cardHeights.first ?? LaneDropRegistry.nominalCardHeight,
|
||||||
current: session.laneProposal(onBoardRooted: store.rootURL, laneID: laneID)
|
current: session.laneProposal(onBoardRooted: store.rootKey, laneID: laneID)
|
||||||
)
|
)
|
||||||
guard let slot else { return } // a dead region: hold
|
guard let slot else { return } // a dead region: hold
|
||||||
session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(laneID), index: slot))
|
session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(laneID), index: slot))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The strip's fall-through for card sessions: which lane is under the cursor, analytically.
|
/// The strip's fall-through for card sessions: which lane is under the cursor, analytically.
|
||||||
@@ -280,8 +280,8 @@ struct BoardDropContext {
|
|||||||
return TrashDrop.accepts(
|
return TrashDrop.accepts(
|
||||||
kind: session.kind,
|
kind: session.kind,
|
||||||
container: session.container,
|
container: session.container,
|
||||||
isWithinBoard: DragLocality.isSameBoard(sourceRoot, store.rootURL),
|
isWithinBoard: sourceRoot == store.rootKey,
|
||||||
operation: session.resolveOperation(destinationRoot: store.rootURL),
|
operation: session.resolveOperation(destinationRoot: store.rootKey),
|
||||||
isTrashShown: store.transient.isTrashVisible,
|
isTrashShown: store.transient.isTrashVisible,
|
||||||
acceptsMutations: store.acceptsBoardMutations
|
acceptsMutations: store.acceptsBoardMutations
|
||||||
)
|
)
|
||||||
@@ -304,7 +304,7 @@ struct BoardDropContext {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
session.propose(DropTarget(
|
session.propose(DropTarget(
|
||||||
boardRoot: store.rootURL,
|
boardRoot: store.rootKey,
|
||||||
container: .trash,
|
container: .trash,
|
||||||
index: TrashDrop.landingIndex
|
index: TrashDrop.landingIndex
|
||||||
))
|
))
|
||||||
@@ -390,7 +390,7 @@ struct BoardDropContext {
|
|||||||
placement: placement,
|
placement: placement,
|
||||||
heights: heights,
|
heights: heights,
|
||||||
nominalHeight: LaneDropRegistry.nominalCardHeight,
|
nominalHeight: LaneDropRegistry.nominalCardHeight,
|
||||||
current: session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: laneID)?.index
|
current: session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: laneID)?.index
|
||||||
)
|
)
|
||||||
|
|
||||||
switch landing {
|
switch landing {
|
||||||
@@ -399,13 +399,13 @@ struct BoardDropContext {
|
|||||||
case let .attach(index):
|
case let .attach(index):
|
||||||
guard rendered.indices.contains(index) else { return }
|
guard rendered.indices.contains(index) else { return }
|
||||||
session.proposeFile(FileDropTarget(
|
session.proposeFile(FileDropTarget(
|
||||||
boardRoot: store.rootURL,
|
boardRoot: store.rootKey,
|
||||||
landing: .attach(cardID: rendered[index].id),
|
landing: .attach(cardID: rendered[index].id),
|
||||||
fileCount: count
|
fileCount: count
|
||||||
))
|
))
|
||||||
case let .create(index):
|
case let .create(index):
|
||||||
session.proposeFile(FileDropTarget(
|
session.proposeFile(FileDropTarget(
|
||||||
boardRoot: store.rootURL,
|
boardRoot: store.rootKey,
|
||||||
landing: .create(laneID: laneID, index: index),
|
landing: .create(laneID: laneID, index: index),
|
||||||
fileCount: count
|
fileCount: count
|
||||||
))
|
))
|
||||||
@@ -463,7 +463,7 @@ struct BoardDropContext {
|
|||||||
func commitFileDrop(_ info: DropInfo) -> Bool {
|
func commitFileDrop(_ info: DropInfo) -> Bool {
|
||||||
guard acceptsFileDrops,
|
guard acceptsFileDrops,
|
||||||
let target = session.fileTarget,
|
let target = session.fileTarget,
|
||||||
DragLocality.isSameBoard(target.boardRoot, store.rootURL)
|
target.boardRoot == store.rootKey
|
||||||
else {
|
else {
|
||||||
session.proposeFile(nil)
|
session.proposeFile(nil)
|
||||||
return false
|
return false
|
||||||
@@ -503,9 +503,9 @@ struct BoardDropContext {
|
|||||||
/// live as the cursor crosses a board boundary (04-interactions.md ▸ Drag and drop).
|
/// live as the cursor crosses a board boundary (04-interactions.md ▸ Drag and drop).
|
||||||
func dropProposal() -> DropProposal {
|
func dropProposal() -> DropProposal {
|
||||||
guard let proposal = session.proposal,
|
guard let proposal = session.proposal,
|
||||||
DragLocality.isSameBoard(proposal.boardRoot, store.rootURL)
|
proposal.boardRoot == store.rootKey
|
||||||
else { return DropProposal(operation: .cancel) }
|
else { return DropProposal(operation: .cancel) }
|
||||||
let operation = session.resolveOperation(destinationRoot: store.rootURL)
|
let operation = session.resolveOperation(destinationRoot: store.rootKey)
|
||||||
return DropProposal(operation: operation == .copy ? .copy : .move)
|
return DropProposal(operation: operation == .copy ? .copy : .move)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +541,7 @@ struct BoardDropContext {
|
|||||||
}
|
}
|
||||||
revalidateProposal()
|
revalidateProposal()
|
||||||
guard let target = session.proposal,
|
guard let target = session.proposal,
|
||||||
DragLocality.isSameBoard(target.boardRoot, store.rootURL)
|
target.boardRoot == store.rootKey
|
||||||
else {
|
else {
|
||||||
cancelDrop()
|
cancelDrop()
|
||||||
return false
|
return false
|
||||||
@@ -570,8 +570,8 @@ struct BoardDropContext {
|
|||||||
}
|
}
|
||||||
let ids = survivors.map { session.members[$0] }
|
let ids = survivors.map { session.members[$0] }
|
||||||
let folders = survivors.map { session.folders[$0] }
|
let folders = survivors.map { session.folders[$0] }
|
||||||
let within = DragLocality.isSameBoard(sourceRoot, store.rootURL)
|
let within = sourceRoot == store.rootKey
|
||||||
let operation = session.resolveOperation(destinationRoot: store.rootURL)
|
let operation = session.resolveOperation(destinationRoot: store.rootKey)
|
||||||
|
|
||||||
switch kind {
|
switch kind {
|
||||||
case .lanes:
|
case .lanes:
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ struct BoardView: View {
|
|||||||
// discards itself the moment a snapshot lands — because holding a moment longer would draw
|
// discards itself the moment a snapshot lands — because holding a moment longer would draw
|
||||||
// the arrangement twice.
|
// the arrangement twice.
|
||||||
.onChange(of: store.snapshotGeneration) { _, generation in
|
.onChange(of: store.snapshotGeneration) { _, generation in
|
||||||
appModel.dragSession.handOff(root: store.rootURL, generation: generation)
|
appModel.dragSession.handOff(root: store.rootKey, generation: generation)
|
||||||
}
|
}
|
||||||
.trashPurgeAlert(store: store, confirmations: confirmations)
|
.trashPurgeAlert(store: store, confirmations: confirmations)
|
||||||
// The board's own anchor for the Style… popover — the surface a board-targeted session hangs
|
// The board's own anchor for the Style… popover — the surface a board-targeted session hangs
|
||||||
@@ -602,9 +602,9 @@ struct BoardView: View {
|
|||||||
private var arrivingLaneUnits: Int {
|
private var arrivingLaneUnits: Int {
|
||||||
let session = appModel.dragSession
|
let session = appModel.dragSession
|
||||||
guard session.isDraggingLanes,
|
guard session.isDraggingLanes,
|
||||||
session.stripProposal(onBoardRooted: store.rootURL) != nil,
|
session.stripProposal(onBoardRooted: store.rootKey) != nil,
|
||||||
let source = session.sourceRoot,
|
let source = session.sourceRoot,
|
||||||
!DragLocality.isSameBoard(source, store.rootURL)
|
source != store.rootKey
|
||||||
else { return 0 }
|
else { return 0 }
|
||||||
return session.laneUnits.reduce(0, +)
|
return session.laneUnits.reduce(0, +)
|
||||||
}
|
}
|
||||||
@@ -612,7 +612,7 @@ struct BoardView: View {
|
|||||||
/// The strip's current lane-drop proposal — the reflow's narrow animation key, and where the
|
/// The strip's current lane-drop proposal — the reflow's narrow animation key, and where the
|
||||||
/// shadow run opens.
|
/// shadow run opens.
|
||||||
private var stripProposal: Int? {
|
private var stripProposal: Int? {
|
||||||
appModel.dragSession.stripProposal(onBoardRooted: store.rootURL)
|
appModel.dragSession.stripProposal(onBoardRooted: store.rootKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One position in the strip: a lane, or one of the drag's N contiguous shadows.
|
/// One position in the strip: a lane, or one of the drag's N contiguous shadows.
|
||||||
@@ -635,7 +635,7 @@ struct BoardView: View {
|
|||||||
/// the shadows opened at the proposal.
|
/// the shadows opened at the proposal.
|
||||||
private var stripSlots: [StripSlot] {
|
private var stripSlots: [StripSlot] {
|
||||||
let session = appModel.dragSession
|
let session = appModel.dragSession
|
||||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
|
||||||
var slots = boardLanes.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 }
|
guard let index = stripProposal else { return slots }
|
||||||
let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) }
|
let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) }
|
||||||
|
|||||||
@@ -720,7 +720,7 @@ struct CardFaceView: View {
|
|||||||
/// ▸ The trash), and the trash column's own delegate clears the file highlight rather than
|
/// ▸ The trash), and the trash column's own delegate clears the file highlight rather than
|
||||||
/// proposing one — so this is a second, structural statement of the same rule.
|
/// proposing one — so this is a second, structural statement of the same rule.
|
||||||
private var isFileHovered: Bool {
|
private var isFileHovered: Bool {
|
||||||
role.container == .board && drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == card.id
|
role.container == .board && drops.session.fileAttachTarget(onBoardRooted: store.rootKey) == card.id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **Board-only** — "everything edit-shaped is disabled on trash selections … Rename"
|
/// **Board-only** — "everything edit-shaped is disabled on trash selections … Rename"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ struct DropTarget: Equatable, Sendable {
|
|||||||
case trash
|
case trash
|
||||||
}
|
}
|
||||||
|
|
||||||
var boardRoot: URL
|
var boardRoot: BoardRootKey
|
||||||
var container: Container
|
var container: Container
|
||||||
var index: Int
|
var index: Int
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ struct FileDropTarget: Equatable, Sendable {
|
|||||||
|
|
||||||
/// The board under the cursor — only that board's delegates may commit, and only its lanes draw
|
/// The board under the cursor — only that board's delegates may commit, and only its lanes draw
|
||||||
/// the shadows, exactly as `DropTarget.boardRoot` works for our own sessions.
|
/// the shadows, exactly as `DropTarget.boardRoot` works for our own sessions.
|
||||||
var boardRoot: URL
|
var boardRoot: BoardRootKey
|
||||||
|
|
||||||
var landing: Landing
|
var landing: Landing
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ struct FileDropTarget: Equatable, Sendable {
|
|||||||
struct CommittedHold: Equatable, Sendable {
|
struct CommittedHold: Equatable, Sendable {
|
||||||
|
|
||||||
/// The board whose next applied snapshot retires this hold.
|
/// The board whose next applied snapshot retires this hold.
|
||||||
var boardRoot: URL
|
var boardRoot: BoardRootKey
|
||||||
|
|
||||||
/// That board's `snapshotGeneration` at the moment of the commit.
|
/// That board's `snapshotGeneration` at the moment of the commit.
|
||||||
var generation: Int
|
var generation: Int
|
||||||
@@ -188,8 +188,8 @@ struct CommittedHold: Equatable, Sendable {
|
|||||||
/// Whether a snapshot applied on `root` at `generation` retires this hold. A snapshot on another
|
/// Whether a snapshot applied on `root` at `generation` retires this hold. A snapshot on another
|
||||||
/// board says nothing about this one, and the *same* generation is the one already on screen at
|
/// board says nothing about this one, and the *same* generation is the one already on screen at
|
||||||
/// the commit.
|
/// the commit.
|
||||||
func isRetired(byRoot root: URL, generation: Int) -> Bool {
|
func isRetired(byRoot root: BoardRootKey, generation: Int) -> Bool {
|
||||||
DragLocality.isSameBoard(root, boardRoot) && generation > self.generation
|
root == boardRoot && generation > self.generation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,16 +204,12 @@ struct CommittedHold: Equatable, Sendable {
|
|||||||
/// is already the default. The operation is therefore a function of (source board, board under the
|
/// is already the default. The operation is therefore a function of (source board, board under the
|
||||||
/// cursor, modifiers) sampled every frame, not a decision taken at pickup, which is what lets the
|
/// cursor, modifiers) sampled every frame, not a decision taken at pickup, which is what lets the
|
||||||
/// badge track live as the cursor crosses a boundary.
|
/// badge track live as the cursor crosses a boundary.
|
||||||
|
///
|
||||||
|
/// Which board a root names is `BoardRootKey`'s question, and it is asked as an `==`: the two sides
|
||||||
|
/// of every comparison below are keys their own stores minted once (`BoardStore.rootKey`), never
|
||||||
|
/// paths re-resolved under the cursor.
|
||||||
enum DragLocality {
|
enum DragLocality {
|
||||||
|
|
||||||
/// Whether two roots name the same board. Symlinks are resolved first — a board reached through
|
|
||||||
/// a pinned symlink is the same board as the one reached directly (01-storage-format.md's
|
|
||||||
/// symlink pins) — and the standardized path is the comparison key.
|
|
||||||
static func isSameBoard(_ lhs: URL, _ rhs: URL) -> Bool {
|
|
||||||
lhs.resolvingSymlinksInPath().standardizedFileURL.path
|
|
||||||
== rhs.resolvingSymlinksInPath().standardizedFileURL.path
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The effective operation, live.
|
/// The effective operation, live.
|
||||||
///
|
///
|
||||||
/// Two carve-outs, both 04-interactions.md's:
|
/// Two carve-outs, both 04-interactions.md's:
|
||||||
@@ -314,7 +310,7 @@ final class DragSession {
|
|||||||
/// The board the drag started in. Root and store are kept separately because the store may go
|
/// The board the drag started in. Root and store are kept separately because the store may go
|
||||||
/// away with its window mid-drag while the root — the left-hand side of the locality
|
/// away with its window mid-drag while the root — the left-hand side of the locality
|
||||||
/// comparison — stays perfectly usable.
|
/// comparison — stays perfectly usable.
|
||||||
private(set) var sourceRoot: URL?
|
private(set) var sourceRoot: BoardRootKey?
|
||||||
|
|
||||||
@ObservationIgnored private(set) weak var sourceStore: BoardStore?
|
@ObservationIgnored private(set) weak var sourceStore: BoardStore?
|
||||||
|
|
||||||
@@ -408,27 +404,26 @@ final class DragSession {
|
|||||||
/// **The hold freezes the answer**, because `resolveOperation` refuses to move once a release
|
/// **The hold freezes the answer**, because `resolveOperation` refuses to move once a release
|
||||||
/// has settled: a settled copy keeps its originals on screen and a settled move keeps them
|
/// has settled: a settled copy keeps its originals on screen and a settled move keeps them
|
||||||
/// lifted, in both cases until the echo snapshot lands and the real faces take over.
|
/// lifted, in both cases until the echo snapshot lands and the real faces take over.
|
||||||
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
|
func hiddenMembers(onBoardRooted root: BoardRootKey) -> Set<ItemID> {
|
||||||
guard isActive, container == .board, let sourceRoot,
|
guard isActive, container == .board, let sourceRoot, root == sourceRoot
|
||||||
DragLocality.isSameBoard(root, sourceRoot)
|
|
||||||
else { return [] }
|
else { return [] }
|
||||||
return operation == .copy ? [] : memberSet
|
return operation == .copy ? [] : memberSet
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The proposal's index when it names this board's strip, else `nil` — the lane strip's shadow
|
/// The proposal's index when it names this board's strip, else `nil` — the lane strip's shadow
|
||||||
/// run position.
|
/// run position.
|
||||||
func stripProposal(onBoardRooted root: URL) -> Int? {
|
func stripProposal(onBoardRooted root: BoardRootKey) -> Int? {
|
||||||
guard kind == .lanes, let proposal, proposal.container == .strip,
|
guard kind == .lanes, let proposal, proposal.container == .strip,
|
||||||
DragLocality.isSameBoard(proposal.boardRoot, root)
|
proposal.boardRoot == root
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return proposal.index
|
return proposal.index
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The proposal's index when it names `laneID` on this board, else `nil` — the masonry's shadow
|
/// The proposal's index when it names `laneID` on this board, else `nil` — the masonry's shadow
|
||||||
/// run position, in the lane's logical card order.
|
/// run position, in the lane's logical card order.
|
||||||
func laneProposal(onBoardRooted root: URL, laneID: ItemID) -> Int? {
|
func laneProposal(onBoardRooted root: BoardRootKey, laneID: ItemID) -> Int? {
|
||||||
guard kind == .cards, let proposal, proposal.container == .lane(laneID),
|
guard kind == .cards, let proposal, proposal.container == .lane(laneID),
|
||||||
DragLocality.isSameBoard(proposal.boardRoot, root)
|
proposal.boardRoot == root
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return proposal.index
|
return proposal.index
|
||||||
}
|
}
|
||||||
@@ -443,9 +438,9 @@ final class DragSession {
|
|||||||
/// **Either kind proposes here** (lanes extended 2026-07-29), so there is no kind clause: what
|
/// **Either kind proposes here** (lanes extended 2026-07-29), so there is no kind clause: what
|
||||||
/// makes the proposal legal is `TrashDrop.accepts`, asked at hover and again at release, and a
|
/// makes the proposal legal is `TrashDrop.accepts`, asked at hover and again at release, and a
|
||||||
/// second copy of its answer written here could only ever disagree with it.
|
/// second copy of its answer written here could only ever disagree with it.
|
||||||
func trashProposal(onBoardRooted root: URL) -> Int? {
|
func trashProposal(onBoardRooted root: BoardRootKey) -> Int? {
|
||||||
guard isActive, let proposal, proposal.container == .trash,
|
guard isActive, let proposal, proposal.container == .trash,
|
||||||
DragLocality.isSameBoard(proposal.boardRoot, root)
|
proposal.boardRoot == root
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return proposal.index
|
return proposal.index
|
||||||
}
|
}
|
||||||
@@ -468,9 +463,9 @@ final class DragSession {
|
|||||||
|
|
||||||
/// The card an external file drag is hovering **on this board**, or `nil` — the attach
|
/// The card an external file drag is hovering **on this board**, or `nil` — the attach
|
||||||
/// highlight's one input (`CardFaceView`).
|
/// highlight's one input (`CardFaceView`).
|
||||||
func fileAttachTarget(onBoardRooted root: URL) -> ItemID? {
|
func fileAttachTarget(onBoardRooted root: BoardRootKey) -> ItemID? {
|
||||||
guard let fileTarget,
|
guard let fileTarget,
|
||||||
DragLocality.isSameBoard(fileTarget.boardRoot, root),
|
fileTarget.boardRoot == root,
|
||||||
case let .attach(cardID) = fileTarget.landing
|
case let .attach(cardID) = fileTarget.landing
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return cardID
|
return cardID
|
||||||
@@ -487,9 +482,9 @@ final class DragSession {
|
|||||||
/// The floor restates `FinderDrop.shadowCount`'s, at the render end rather than in place of it: a
|
/// The floor restates `FinderDrop.shadowCount`'s, at the render end rather than in place of it: a
|
||||||
/// proposal that somehow carried a zero would otherwise open a run of no shadows at all, which is
|
/// proposal that somehow carried a zero would otherwise open a run of no shadows at all, which is
|
||||||
/// a landing spot the user cannot see.
|
/// a landing spot the user cannot see.
|
||||||
func fileLaneProposal(onBoardRooted root: URL, laneID: ItemID) -> (index: Int, count: Int)? {
|
func fileLaneProposal(onBoardRooted root: BoardRootKey, laneID: ItemID) -> (index: Int, count: Int)? {
|
||||||
guard let fileTarget,
|
guard let fileTarget,
|
||||||
DragLocality.isSameBoard(fileTarget.boardRoot, root),
|
fileTarget.boardRoot == root,
|
||||||
case let .create(lane, index) = fileTarget.landing,
|
case let .create(lane, index) = fileTarget.landing,
|
||||||
lane == laneID
|
lane == laneID
|
||||||
else { return nil }
|
else { return nil }
|
||||||
@@ -576,7 +571,7 @@ final class DragSession {
|
|||||||
self.container = container
|
self.container = container
|
||||||
self.mixesKinds = mixesKinds
|
self.mixesKinds = mixesKinds
|
||||||
self.sourceStore = source
|
self.sourceStore = source
|
||||||
self.sourceRoot = source.rootURL
|
self.sourceRoot = source.rootKey
|
||||||
self.proposal = nil
|
self.proposal = nil
|
||||||
self.operation = .move
|
self.operation = .move
|
||||||
// The reload-resolved drag set: vanished members leave it silently, which is what
|
// The reload-resolved drag set: vanished members leave it silently, which is what
|
||||||
@@ -609,14 +604,14 @@ final class DragSession {
|
|||||||
/// pure function is.
|
/// pure function is.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func resolveOperation(
|
func resolveOperation(
|
||||||
destinationRoot: URL,
|
destinationRoot: BoardRootKey,
|
||||||
modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags
|
modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags
|
||||||
) -> TransferOperation {
|
) -> TransferOperation {
|
||||||
guard let kind, let sourceRoot, hold == nil else { return operation }
|
guard let kind, let sourceRoot, hold == nil else { return operation }
|
||||||
let resolved = DragLocality.operation(
|
let resolved = DragLocality.operation(
|
||||||
kind: kind,
|
kind: kind,
|
||||||
container: container,
|
container: container,
|
||||||
isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot),
|
isWithinBoard: sourceRoot == destinationRoot,
|
||||||
modifiers: modifiers
|
modifiers: modifiers
|
||||||
)
|
)
|
||||||
if resolved != operation { operation = resolved }
|
if resolved != operation { operation = resolved }
|
||||||
@@ -656,7 +651,7 @@ final class DragSession {
|
|||||||
sourceStore?.transient.dragMembers = .empty
|
sourceStore?.transient.dragMembers = .empty
|
||||||
watchdog?.cancel()
|
watchdog?.cancel()
|
||||||
watchdog = nil
|
watchdog = nil
|
||||||
let hold = CommittedHold(boardRoot: store.rootURL, generation: store.snapshotGeneration)
|
let hold = CommittedHold(boardRoot: store.rootKey, generation: store.snapshotGeneration)
|
||||||
self.hold = hold
|
self.hold = hold
|
||||||
let timeout = holdTimeout
|
let timeout = holdTimeout
|
||||||
holdTimeoutTask?.cancel()
|
holdTimeoutTask?.cancel()
|
||||||
@@ -684,7 +679,7 @@ final class DragSession {
|
|||||||
///
|
///
|
||||||
/// Called from every board window's own snapshot-generation watch, which is why the hold names
|
/// Called from every board window's own snapshot-generation watch, which is why the hold names
|
||||||
/// the board it belongs to — a reload on some other board says nothing about this one.
|
/// the board it belongs to — a reload on some other board says nothing about this one.
|
||||||
func handOff(root: URL, generation: Int) {
|
func handOff(root: BoardRootKey, generation: Int) {
|
||||||
guard let hold, hold.isRetired(byRoot: root, generation: generation) else { return }
|
guard let hold, hold.isRetired(byRoot: root, generation: generation) else { return }
|
||||||
end()
|
end()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -788,7 +788,7 @@ struct LaneView: View {
|
|||||||
|
|
||||||
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere.
|
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere.
|
||||||
private var cardProposal: Int? {
|
private var cardProposal: Int? {
|
||||||
drops.session.laneProposal(onBoardRooted: store.rootURL, laneID: lane.id)
|
drops.session.laneProposal(onBoardRooted: store.rootKey, laneID: lane.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The shadow run this lane opens, or `nil` when no proposal names it — the masonry's one
|
/// The shadow run this lane opens, or `nil` when no proposal names it — the masonry's one
|
||||||
@@ -805,7 +805,7 @@ struct LaneView: View {
|
|||||||
if let position = cardProposal {
|
if let position = cardProposal {
|
||||||
return ShadowRun(position: position, heights: drops.session.cardHeights)
|
return ShadowRun(position: position, heights: drops.session.cardHeights)
|
||||||
}
|
}
|
||||||
if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: lane.id) {
|
if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: lane.id) {
|
||||||
return ShadowRun(
|
return ShadowRun(
|
||||||
position: proposal.index,
|
position: proposal.index,
|
||||||
heights: Array(repeating: LaneDropRegistry.nominalCardHeight, count: proposal.count)
|
heights: Array(repeating: LaneDropRegistry.nominalCardHeight, count: proposal.count)
|
||||||
@@ -892,7 +892,7 @@ struct LaneView: View {
|
|||||||
private var renderedCards: [Card] {
|
private var renderedCards: [Card] {
|
||||||
Self.rendered(
|
Self.rendered(
|
||||||
lane.cards,
|
lane.cards,
|
||||||
hiddenByDrag: drops.session.hiddenMembers(onBoardRooted: store.rootURL),
|
hiddenByDrag: drops.session.hiddenMembers(onBoardRooted: store.rootKey),
|
||||||
filter: store.searchFilter,
|
filter: store.searchFilter,
|
||||||
renaming: store.transient.renameEditor?.targetID
|
renaming: store.transient.renameEditor?.targetID
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ struct TrashLaneView: View {
|
|||||||
/// Where the delete gesture's shadow run opens in this column — always the topmost row — or `nil`
|
/// Where the delete gesture's shadow run opens in this column — always the topmost row — or `nil`
|
||||||
/// when no proposal names the trash, which is every other moment of the app's life.
|
/// when no proposal names the trash, which is every other moment of the app's life.
|
||||||
private var proposal: Int? {
|
private var proposal: Int? {
|
||||||
drops.session.trashProposal(onBoardRooted: store.rootURL)
|
drops.session.trashProposal(onBoardRooted: store.rootKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The slots the column lays out: the rows, with the delete gesture's shadow run opened at the
|
/// The slots the column lays out: the rows, with the delete gesture's shadow run opened at the
|
||||||
|
|||||||
@@ -69,10 +69,15 @@ struct DragPayloadTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Locality
|
// MARK: - Board identity
|
||||||
|
|
||||||
@Suite("DragLocality")
|
/// **The one canonical spelling every locality comparison is made against** (`BoardRootKey`).
|
||||||
struct DragLocalityTests {
|
///
|
||||||
|
/// Minting is where the filesystem is touched — once per board, at `BoardStore.rootKey` — and these
|
||||||
|
/// are the claims that one touch has to buy: every spelling of a place is one key, and two places
|
||||||
|
/// are two keys.
|
||||||
|
@Suite("BoardRootKey")
|
||||||
|
struct BoardRootKeyTests {
|
||||||
|
|
||||||
// Instance members, not `static`: every case below names them bare, and a static member is not
|
// Instance members, not `static`: every case below names them bare, and a static member is not
|
||||||
// reachable unqualified from an instance method. Swift Testing builds a fresh instance per test,
|
// reachable unqualified from an instance method. Swift Testing builds a fresh instance per test,
|
||||||
@@ -80,18 +85,84 @@ struct DragLocalityTests {
|
|||||||
private let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
|
private let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
|
||||||
private let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)
|
private let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)
|
||||||
|
|
||||||
|
@Test("Roots key on their standardized path, so the same board is the same board")
|
||||||
|
func standardization() {
|
||||||
|
#expect(BoardRootKey(here) == BoardRootKey(here))
|
||||||
|
#expect(BoardRootKey(here) == BoardRootKey(URL(fileURLWithPath: "/Boards/./Work.kanban/")))
|
||||||
|
#expect(BoardRootKey(here) == BoardRootKey(URL(fileURLWithPath: "/Boards/Other/../Work.kanban")))
|
||||||
|
#expect(BoardRootKey(here) != BoardRootKey(there))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why the key resolves at all: "a board reached through a pinned symlink is the same board as
|
||||||
|
/// the one reached directly" (01-storage-format.md's symlink pins). A real link over a real
|
||||||
|
/// folder, because the claim is a filesystem fact and nothing else can stand in for one.
|
||||||
|
@Test("A board reached through a pin is the board the pin points at")
|
||||||
|
func pinsNameTheirTarget() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let real = fixture.url("Work.kanban")
|
||||||
|
try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true)
|
||||||
|
let pin = fixture.url("Pinned.kanban")
|
||||||
|
try FileManager.default.createSymbolicLink(at: pin, withDestinationURL: real)
|
||||||
|
|
||||||
|
#expect(BoardRootKey(pin) == BoardRootKey(real))
|
||||||
|
// The key names the target rather than the link — the pin is a spelling, not a second board.
|
||||||
|
#expect(BoardRootKey(pin).path.hasSuffix("Work.kanban"))
|
||||||
|
// A sibling the link does not point at stays a board of its own.
|
||||||
|
#expect(BoardRootKey(pin) != BoardRootKey(fixture.url("Other.kanban")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The store mints once and keeps both**: the key is the board's identity, and `rootURL` keeps
|
||||||
|
/// the user's spelling because the folder name is the board's display-name fallback
|
||||||
|
/// (01-storage-format.md § Frontmatter) — canonicalizing there would visibly rename a board
|
||||||
|
/// opened through a pin.
|
||||||
|
@MainActor
|
||||||
|
@Test("A board opened through a pin keeps the pin's spelling and the target's identity")
|
||||||
|
func theStoreKeepsTheSpellingAndCachesTheIdentity() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let real = try fixture.item("Work.kanban", Item.board)
|
||||||
|
let pin = fixture.url("Pinned.kanban")
|
||||||
|
try FileManager.default.createSymbolicLink(at: pin, withDestinationURL: real)
|
||||||
|
|
||||||
|
let store = try BoardStore(rootURL: pin)
|
||||||
|
#expect(store.rootKey == BoardRootKey(real))
|
||||||
|
#expect(store.rootURL.lastPathComponent == "Pinned.kanban")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key follows the folder. A board renamed away and a new board opened at the vacated path
|
||||||
|
/// are two boards, and nothing about the drag they share may say otherwise — which is only true
|
||||||
|
/// if the relocation re-mints.
|
||||||
|
@MainActor
|
||||||
|
@Test("An absorbed relocation re-mints the key, so the vacated path is somebody else's board")
|
||||||
|
func relocationRemintsTheKey() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let original = try fixture.item("Work.kanban", Item.board)
|
||||||
|
let store = try BoardStore(rootURL: original)
|
||||||
|
let before = store.rootKey
|
||||||
|
|
||||||
|
let moved = fixture.url("Renamed.kanban")
|
||||||
|
try FileManager.default.moveItem(at: original, to: moved)
|
||||||
|
store.relocate(to: moved)
|
||||||
|
|
||||||
|
#expect(store.rootKey == BoardRootKey(moved))
|
||||||
|
#expect(store.rootKey != before)
|
||||||
|
|
||||||
|
let successor = try BoardStore(rootURL: try fixture.item("Work.kanban", Item.board))
|
||||||
|
#expect(successor.rootKey != store.rootKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Locality
|
||||||
|
|
||||||
|
@Suite("DragLocality")
|
||||||
|
struct DragLocalityTests {
|
||||||
|
|
||||||
private let none: NSEvent.ModifierFlags = []
|
private let none: NSEvent.ModifierFlags = []
|
||||||
private let option: NSEvent.ModifierFlags = [.option]
|
private let option: NSEvent.ModifierFlags = [.option]
|
||||||
private let command: NSEvent.ModifierFlags = [.command]
|
private let command: NSEvent.ModifierFlags = [.command]
|
||||||
|
|
||||||
@Test("Roots compare by their standardized path, so the same board is the same board")
|
|
||||||
func rootComparison() {
|
|
||||||
#expect(DragLocality.isSameBoard(here, here))
|
|
||||||
#expect(DragLocality.isSameBoard(here, URL(fileURLWithPath: "/Boards/./Work.kanban/")))
|
|
||||||
#expect(DragLocality.isSameBoard(here, URL(fileURLWithPath: "/Boards/Other/../Work.kanban")))
|
|
||||||
#expect(!DragLocality.isSameBoard(here, there))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The Finder volume model: within a board a drag rearranges, between boards it transfers.
|
/// The Finder volume model: within a board a drag rearranges, between boards it transfers.
|
||||||
@Test("Locality picks the default — within is a move, across is a copy")
|
@Test("Locality picks the default — within is a move, across is a copy")
|
||||||
func theDefault() {
|
func theDefault() {
|
||||||
@@ -320,8 +391,8 @@ struct MixedTrashDragTests {
|
|||||||
@Suite("CommittedHold")
|
@Suite("CommittedHold")
|
||||||
struct CommittedHoldTests {
|
struct CommittedHoldTests {
|
||||||
|
|
||||||
private static let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
|
private static let here = BoardRootKey(URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true))
|
||||||
private static let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)
|
private static let there = BoardRootKey(URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true))
|
||||||
|
|
||||||
private static let hold = CommittedHold(boardRoot: here, generation: 7)
|
private static let hold = CommittedHold(boardRoot: here, generation: 7)
|
||||||
|
|
||||||
@@ -343,7 +414,8 @@ struct CommittedHoldTests {
|
|||||||
|
|
||||||
@Test("The board is matched by identity, not by string")
|
@Test("The board is matched by identity, not by string")
|
||||||
func rootMatchingUsesTheLocalityComparison() {
|
func rootMatchingUsesTheLocalityComparison() {
|
||||||
#expect(Self.hold.isRetired(byRoot: URL(fileURLWithPath: "/Boards/./Work.kanban/"), generation: 8))
|
let sameBoard = BoardRootKey(URL(fileURLWithPath: "/Boards/./Work.kanban/"))
|
||||||
|
#expect(Self.hold.isRetired(byRoot: sameBoard, generation: 8))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A stale generation never retires it")
|
@Test("A stale generation never retires it")
|
||||||
@@ -404,7 +476,7 @@ struct DropSettleTests {
|
|||||||
) -> DragSession {
|
) -> DragSession {
|
||||||
let session = DragSession()
|
let session = DragSession()
|
||||||
pickUp(session, from: store, members: members)
|
pickUp(session, from: store, members: members)
|
||||||
session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(Self.lane1), index: index))
|
session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: index))
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,8 +521,8 @@ struct DropSettleTests {
|
|||||||
let session = proposing(store)
|
let session = proposing(store)
|
||||||
|
|
||||||
#expect(!session.isSettled)
|
#expect(!session.isSettled)
|
||||||
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 2)
|
#expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 2)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: The hold
|
// MARK: The hold
|
||||||
@@ -469,8 +541,8 @@ struct DropSettleTests {
|
|||||||
session.commit(into: store)
|
session.commit(into: store)
|
||||||
|
|
||||||
#expect(session.isSettled)
|
#expect(session.isSettled)
|
||||||
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 2)
|
#expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 2)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A settled release is past retargeting: a late callback cannot move or withdraw it")
|
@Test("A settled release is past retargeting: a late callback cannot move or withdraw it")
|
||||||
@@ -482,9 +554,9 @@ struct DropSettleTests {
|
|||||||
session.commit(into: store)
|
session.commit(into: store)
|
||||||
|
|
||||||
session.propose(nil)
|
session.propose(nil)
|
||||||
session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(Self.lane1), index: 0))
|
session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0))
|
||||||
|
|
||||||
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 2)
|
#expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: The trash's landing
|
// MARK: The trash's landing
|
||||||
@@ -495,7 +567,7 @@ struct DropSettleTests {
|
|||||||
let session = DragSession()
|
let session = DragSession()
|
||||||
pickUp(session, from: store, members: members)
|
pickUp(session, from: store, members: members)
|
||||||
session.propose(DropTarget(
|
session.propose(DropTarget(
|
||||||
boardRoot: store.rootURL,
|
boardRoot: store.rootKey,
|
||||||
container: .trash,
|
container: .trash,
|
||||||
index: TrashDrop.landingIndex
|
index: TrashDrop.landingIndex
|
||||||
))
|
))
|
||||||
@@ -509,13 +581,13 @@ struct DropSettleTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let session = proposingIntoTheTrash(store)
|
let session = proposingIntoTheTrash(store)
|
||||||
|
|
||||||
#expect(session.trashProposal(onBoardRooted: store.rootURL) == 0)
|
#expect(session.trashProposal(onBoardRooted: store.rootKey) == 0)
|
||||||
// The proposal names one container and one only: the lane the cards came out of draws
|
// The proposal names one container and one only: the lane the cards came out of draws
|
||||||
// nothing, and neither does the strip.
|
// nothing, and neither does the strip.
|
||||||
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
|
#expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil)
|
||||||
#expect(session.stripProposal(onBoardRooted: store.rootURL) == nil)
|
#expect(session.stripProposal(onBoardRooted: store.rootKey) == nil)
|
||||||
// And they are lifted out of the lane, as ever.
|
// And they are lifted out of the lane, as ever.
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The delete's own hold: the write takes the cards off the live side, and the shadow rows keep
|
/// The delete's own hold: the write takes the cards off the live side, and the shadow rows keep
|
||||||
@@ -529,9 +601,9 @@ struct DropSettleTests {
|
|||||||
|
|
||||||
session.commit(into: store)
|
session.commit(into: store)
|
||||||
|
|
||||||
#expect(session.trashProposal(onBoardRooted: store.rootURL) == 0)
|
#expect(session.trashProposal(onBoardRooted: store.rootKey) == 0)
|
||||||
#expect(session.shadowCount == 2)
|
#expect(session.shadowCount == 2)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1, Self.card2])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The column draws the delete gesture's shadow for a **lane** session too (lanes extended
|
/// The column draws the delete gesture's shadow for a **lane** session too (lanes extended
|
||||||
@@ -549,12 +621,12 @@ struct DropSettleTests {
|
|||||||
units: [1],
|
units: [1],
|
||||||
source: store
|
source: store
|
||||||
)
|
)
|
||||||
session.propose(DropTarget(boardRoot: store.rootURL, container: .trash, index: 0))
|
session.propose(DropTarget(boardRoot: store.rootKey, container: .trash, index: 0))
|
||||||
|
|
||||||
#expect(session.trashProposal(onBoardRooted: store.rootURL) == 0)
|
#expect(session.trashProposal(onBoardRooted: store.rootKey) == 0)
|
||||||
#expect(session.shadowCount == 1)
|
#expect(session.shadowCount == 1)
|
||||||
// Another board's column draws nothing, like every other proposal accessor.
|
// Another board's column draws nothing, like every other proposal accessor.
|
||||||
#expect(session.trashProposal(onBoardRooted: URL(filePath: "/tmp/other-board")) == nil)
|
#expect(session.trashProposal(onBoardRooted: BoardRootKey(URL(filePath: "/tmp/other-board"))) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: The hand-off
|
// MARK: The hand-off
|
||||||
@@ -567,12 +639,12 @@ struct DropSettleTests {
|
|||||||
let session = proposing(store)
|
let session = proposing(store)
|
||||||
session.commit(into: store)
|
session.commit(into: store)
|
||||||
|
|
||||||
session.handOff(root: store.rootURL, generation: store.snapshotGeneration + 1)
|
session.handOff(root: store.rootKey, generation: store.snapshotGeneration + 1)
|
||||||
|
|
||||||
#expect(!session.isSettled)
|
#expect(!session.isSettled)
|
||||||
#expect(!session.isActive)
|
#expect(!session.isActive)
|
||||||
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
|
#expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Ending the session outright ends the hold with it")
|
@Test("Ending the session outright ends the hold with it")
|
||||||
@@ -586,8 +658,8 @@ struct DropSettleTests {
|
|||||||
session.end()
|
session.end()
|
||||||
|
|
||||||
#expect(!session.isSettled)
|
#expect(!session.isSettled)
|
||||||
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
|
#expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
|
||||||
#expect(store.transient.dragMembers.ids.isEmpty)
|
#expect(store.transient.dragMembers.ids.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,8 +683,8 @@ struct DropSettleTests {
|
|||||||
|
|
||||||
#expect(await settles { !session.isSettled }, "the deadline must dissolve an overlay with no hand-off coming")
|
#expect(await settles { !session.isSettled }, "the deadline must dissolve an overlay with no hand-off coming")
|
||||||
#expect(!session.isActive)
|
#expect(!session.isActive)
|
||||||
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
|
#expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
|
||||||
#expect(store.transient.dragMembers.ids.isEmpty)
|
#expect(store.transient.dragMembers.ids.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,7 +699,7 @@ struct DropSettleTests {
|
|||||||
session.commit(into: store)
|
session.commit(into: store)
|
||||||
let hold = try #require(session.hold)
|
let hold = try #require(session.hold)
|
||||||
|
|
||||||
session.expire(CommittedHold(boardRoot: store.rootURL, generation: 999))
|
session.expire(CommittedHold(boardRoot: store.rootKey, generation: 999))
|
||||||
#expect(session.isSettled, "a hold this session is not holding is not this session's to end")
|
#expect(session.isSettled, "a hold this session is not holding is not this session's to end")
|
||||||
|
|
||||||
session.expire(hold)
|
session.expire(hold)
|
||||||
@@ -646,7 +718,7 @@ struct DropSettleTests {
|
|||||||
|
|
||||||
// The echo lands well inside the deadline, and the user starts another drag immediately —
|
// The echo lands well inside the deadline, and the user starts another drag immediately —
|
||||||
// the lifecycle trap the watchdog was written for, at the hold's end of the session.
|
// the lifecycle trap the watchdog was written for, at the hold's end of the session.
|
||||||
session.handOff(root: store.rootURL, generation: store.snapshotGeneration + 1)
|
session.handOff(root: store.rootKey, generation: store.snapshotGeneration + 1)
|
||||||
pickUp(session, from: store, members: [(Self.card2, "Second")])
|
pickUp(session, from: store, members: [(Self.card2, "Second")])
|
||||||
|
|
||||||
try? await Task.sleep(for: .milliseconds(80))
|
try? await Task.sleep(for: .milliseconds(80))
|
||||||
@@ -681,7 +753,7 @@ struct DragRestingLayoutTests {
|
|||||||
|
|
||||||
/// Another board entirely — the right-hand side of every cross-board resolution below. It needs
|
/// Another board entirely — the right-hand side of every cross-board resolution below. It needs
|
||||||
/// no fixture: locality compares paths, and nothing here reads the foreign board's contents.
|
/// no fixture: locality compares paths, and nothing here reads the foreign board's contents.
|
||||||
private let elsewhere = URL(fileURLWithPath: "/Boards/Elsewhere.kanban", isDirectory: true)
|
private let elsewhere = BoardRootKey(URL(fileURLWithPath: "/Boards/Elsewhere.kanban", isDirectory: true))
|
||||||
|
|
||||||
/// One lane holding two cards, plus a trashed card so a `.trash`-container session has something
|
/// One lane holding two cards, plus a trashed card so a `.trash`-container session has something
|
||||||
/// real to name.
|
/// real to name.
|
||||||
@@ -725,12 +797,12 @@ struct DragRestingLayoutTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let session = cardSession(store, members: [Self.card1, Self.card2])
|
let session = cardSession(store, members: [Self.card1, Self.card2])
|
||||||
|
|
||||||
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) == .move)
|
#expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: none) == .move)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1, Self.card2])
|
||||||
|
|
||||||
// ⌘ over a foreign board is the cross-board move, and it lifts them out just the same.
|
// ⌘ over a foreign board is the cross-board move, and it lifts them out just the same.
|
||||||
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: command) == .move)
|
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: command) == .move)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1, Self.card2])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The card the user filed: ⌥ says "leave the originals here", so the originals are drawn here.
|
/// The card the user filed: ⌥ says "leave the originals here", so the originals are drawn here.
|
||||||
@@ -742,12 +814,12 @@ struct DragRestingLayoutTests {
|
|||||||
let session = cardSession(store, members: [Self.card1, Self.card2])
|
let session = cardSession(store, members: [Self.card1, Self.card2])
|
||||||
|
|
||||||
// The within-board ⌥-copy.
|
// The within-board ⌥-copy.
|
||||||
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .copy)
|
#expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .copy)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
|
||||||
|
|
||||||
// And the cross-board default, which is a copy with no modifier at all.
|
// And the cross-board default, which is a copy with no modifier at all.
|
||||||
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: none) == .copy)
|
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: none) == .copy)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The flip is a deliberate user action and the one-shot reflow is its feedback — which means
|
/// The flip is a deliberate user action and the one-shot reflow is its feedback — which means
|
||||||
@@ -760,8 +832,8 @@ struct DragRestingLayoutTests {
|
|||||||
let session = cardSession(store)
|
let session = cardSession(store)
|
||||||
|
|
||||||
for modifiers in [none, option, none, option] {
|
for modifiers in [none, option, none, option] {
|
||||||
session.resolveOperation(destinationRoot: store.rootURL, modifiers: modifiers)
|
session.resolveOperation(destinationRoot: store.rootKey, modifiers: modifiers)
|
||||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
|
||||||
#expect(hidden == (modifiers == option ? [] : [Self.card1]))
|
#expect(hidden == (modifiers == option ? [] : [Self.card1]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -778,11 +850,11 @@ struct DragRestingLayoutTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let session = cardSession(store, members: [ItemID(rawValue: Ident.card3)], container: .trash)
|
let session = cardSession(store, members: [ItemID(rawValue: Ident.card3)], container: .trash)
|
||||||
|
|
||||||
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) == .move)
|
#expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: none) == .move)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
|
||||||
|
|
||||||
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .copy)
|
#expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .copy)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Only the source board hides anything, whatever the operation")
|
@Test("Only the source board hides anything, whatever the operation")
|
||||||
@@ -794,7 +866,7 @@ struct DragRestingLayoutTests {
|
|||||||
|
|
||||||
session.resolveOperation(destinationRoot: elsewhere, modifiers: command)
|
session.resolveOperation(destinationRoot: elsewhere, modifiers: command)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: elsewhere).isEmpty)
|
#expect(session.hiddenMembers(onBoardRooted: elsewhere).isEmpty)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The lane carve-out, read as a layout claim: a within-board lane drag never resolves to
|
/// The lane carve-out, read as a layout claim: a within-board lane drag never resolves to
|
||||||
@@ -814,11 +886,11 @@ struct DragRestingLayoutTests {
|
|||||||
source: store
|
source: store
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .move)
|
#expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .move)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.lane1])
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.lane1])
|
||||||
|
|
||||||
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: none) == .copy)
|
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: none) == .copy)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: The hold freezes it
|
// MARK: The hold freezes it
|
||||||
@@ -833,13 +905,13 @@ struct DragRestingLayoutTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let session = cardSession(store)
|
let session = cardSession(store)
|
||||||
|
|
||||||
session.resolveOperation(destinationRoot: store.rootURL, modifiers: option)
|
session.resolveOperation(destinationRoot: store.rootKey, modifiers: option)
|
||||||
session.commit(into: store)
|
session.commit(into: store)
|
||||||
|
|
||||||
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) == .copy)
|
#expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: none) == .copy)
|
||||||
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: command) == .copy)
|
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: command) == .copy)
|
||||||
#expect(session.operation == .copy)
|
#expect(session.operation == .copy)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A settled move keeps its originals lifted, whatever the modifiers do next")
|
@Test("A settled move keeps its originals lifted, whatever the modifiers do next")
|
||||||
@@ -849,11 +921,11 @@ struct DragRestingLayoutTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let session = cardSession(store)
|
let session = cardSession(store)
|
||||||
|
|
||||||
session.resolveOperation(destinationRoot: store.rootURL, modifiers: none)
|
session.resolveOperation(destinationRoot: store.rootKey, modifiers: none)
|
||||||
session.commit(into: store)
|
session.commit(into: store)
|
||||||
|
|
||||||
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .move)
|
#expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .move)
|
||||||
#expect(session.operation == .move)
|
#expect(session.operation == .move)
|
||||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user