From ef423bb9d2d00a4ada349eb962ba2636fe38d1b9 Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 1 Aug 2026 17:55:48 -0400 Subject: [PATCH] =?UTF-8?q?Every=20board=20root=20keys=20once=20at=20its?= =?UTF-8?q?=20store=20=E2=80=94=20the=20hover=20path=20stops=20walking=20t?= =?UTF-8?q?he=20filesystem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- Kanban/LiveStore/BoardStore.swift | 41 ++++++ Kanban/UI/Board/BoardDrops.swift | 38 +++--- Kanban/UI/Board/BoardView.swift | 10 +- Kanban/UI/Board/CardFaceView.swift | 2 +- Kanban/UI/Board/DragSession.swift | 59 ++++----- Kanban/UI/Board/LaneView.swift | 6 +- Kanban/UI/Board/TrashLaneView.swift | 2 +- KanbanTests/DragSessionTests.swift | 198 +++++++++++++++++++--------- 8 files changed, 232 insertions(+), 124 deletions(-) diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 204a490..91218ba 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -8,6 +8,32 @@ import SwiftUI // 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 /// 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. 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 /// 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. public init(rootURL: URL, loaded result: LoadResult, skipping: Set = []) { self.rootURL = rootURL + self.rootKey = BoardRootKey(rootURL) self.snapshot = result.model self.loadWarnings = result.warnings self.defects = result.defects @@ -1228,6 +1268,7 @@ public final class BoardStore: HealHost { guard newRoot != rootURL else { return } Self.logger.debug("board root relocated; Writer URLs now derive from the new location") rootURL = newRoot + rootKey = BoardRootKey(newRoot) } /// Raises the vanished-root read-only lock — the registry's call, after bookmark re-resolution diff --git a/Kanban/UI/Board/BoardDrops.swift b/Kanban/UI/Board/BoardDrops.swift index 80886fa..1f6c003 100644 --- a/Kanban/UI/Board/BoardDrops.swift +++ b/Kanban/UI/Board/BoardDrops.swift @@ -147,7 +147,7 @@ struct BoardDropContext { /// disagree. func revalidateProposal() { guard let proposal = session.proposal, - DragLocality.isSameBoard(proposal.boardRoot, store.rootURL), + proposal.boardRoot == store.rootKey, let laneID = proposal.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. func retargetLanes() { 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 restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) } let slot = DropSlotMath.laneSlot( @@ -188,11 +188,11 @@ struct BoardDropContext { draggedUnits: session.laneUnits, standard: standard(), 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 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. @@ -215,7 +215,7 @@ struct BoardDropContext { } 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 heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight } 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 // is the trigger rect the cursor is over (the rest stack below it). 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 - 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. @@ -280,8 +280,8 @@ struct BoardDropContext { return TrashDrop.accepts( kind: session.kind, container: session.container, - isWithinBoard: DragLocality.isSameBoard(sourceRoot, store.rootURL), - operation: session.resolveOperation(destinationRoot: store.rootURL), + isWithinBoard: sourceRoot == store.rootKey, + operation: session.resolveOperation(destinationRoot: store.rootKey), isTrashShown: store.transient.isTrashVisible, acceptsMutations: store.acceptsBoardMutations ) @@ -304,7 +304,7 @@ struct BoardDropContext { return } session.propose(DropTarget( - boardRoot: store.rootURL, + boardRoot: store.rootKey, container: .trash, index: TrashDrop.landingIndex )) @@ -390,7 +390,7 @@ struct BoardDropContext { placement: placement, heights: heights, nominalHeight: LaneDropRegistry.nominalCardHeight, - current: session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: laneID)?.index + current: session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: laneID)?.index ) switch landing { @@ -399,13 +399,13 @@ struct BoardDropContext { case let .attach(index): guard rendered.indices.contains(index) else { return } session.proposeFile(FileDropTarget( - boardRoot: store.rootURL, + boardRoot: store.rootKey, landing: .attach(cardID: rendered[index].id), fileCount: count )) case let .create(index): session.proposeFile(FileDropTarget( - boardRoot: store.rootURL, + boardRoot: store.rootKey, landing: .create(laneID: laneID, index: index), fileCount: count )) @@ -463,7 +463,7 @@ struct BoardDropContext { func commitFileDrop(_ info: DropInfo) -> Bool { guard acceptsFileDrops, let target = session.fileTarget, - DragLocality.isSameBoard(target.boardRoot, store.rootURL) + target.boardRoot == store.rootKey else { session.proposeFile(nil) return false @@ -503,9 +503,9 @@ struct BoardDropContext { /// live as the cursor crosses a board boundary (04-interactions.md ▸ Drag and drop). func dropProposal() -> DropProposal { guard let proposal = session.proposal, - DragLocality.isSameBoard(proposal.boardRoot, store.rootURL) + proposal.boardRoot == store.rootKey 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) } @@ -541,7 +541,7 @@ struct BoardDropContext { } revalidateProposal() guard let target = session.proposal, - DragLocality.isSameBoard(target.boardRoot, store.rootURL) + target.boardRoot == store.rootKey else { cancelDrop() return false @@ -570,8 +570,8 @@ struct BoardDropContext { } let ids = survivors.map { session.members[$0] } let folders = survivors.map { session.folders[$0] } - let within = DragLocality.isSameBoard(sourceRoot, store.rootURL) - let operation = session.resolveOperation(destinationRoot: store.rootURL) + let within = sourceRoot == store.rootKey + let operation = session.resolveOperation(destinationRoot: store.rootKey) switch kind { case .lanes: diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 3f936d2..be690b4 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -170,7 +170,7 @@ struct BoardView: View { // discards itself the moment a snapshot lands — because holding a moment longer would draw // the arrangement twice. .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) // 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 { let session = appModel.dragSession guard session.isDraggingLanes, - session.stripProposal(onBoardRooted: store.rootURL) != nil, + session.stripProposal(onBoardRooted: store.rootKey) != nil, let source = session.sourceRoot, - !DragLocality.isSameBoard(source, store.rootURL) + source != store.rootKey else { return 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 /// shadow run opens. 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. @@ -635,7 +635,7 @@ struct BoardView: View { /// the shadows opened at the proposal. private var stripSlots: [StripSlot] { 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) guard let index = stripProposal else { return slots } let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) } diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index 6e21b17..5e39626 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -720,7 +720,7 @@ struct CardFaceView: View { /// ▸ 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. 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" diff --git a/Kanban/UI/Board/DragSession.swift b/Kanban/UI/Board/DragSession.swift index cce6d1d..757f1a6 100644 --- a/Kanban/UI/Board/DragSession.swift +++ b/Kanban/UI/Board/DragSession.swift @@ -31,7 +31,7 @@ struct DropTarget: Equatable, Sendable { case trash } - var boardRoot: URL + var boardRoot: BoardRootKey var container: Container 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 shadows, exactly as `DropTarget.boardRoot` works for our own sessions. - var boardRoot: URL + var boardRoot: BoardRootKey var landing: Landing @@ -175,7 +175,7 @@ struct FileDropTarget: Equatable, Sendable { struct CommittedHold: Equatable, Sendable { /// 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. 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 /// board says nothing about this one, and the *same* generation is the one already on screen at /// the commit. - func isRetired(byRoot root: URL, generation: Int) -> Bool { - DragLocality.isSameBoard(root, boardRoot) && generation > self.generation + func isRetired(byRoot root: BoardRootKey, generation: Int) -> Bool { + 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 /// 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. +/// +/// 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 { - /// 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. /// /// 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 /// away with its window mid-drag while the root — the left-hand side of the locality /// comparison — stays perfectly usable. - private(set) var sourceRoot: URL? + private(set) var sourceRoot: BoardRootKey? @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 /// 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. - func hiddenMembers(onBoardRooted root: URL) -> Set { - guard isActive, container == .board, let sourceRoot, - DragLocality.isSameBoard(root, sourceRoot) + func hiddenMembers(onBoardRooted root: BoardRootKey) -> Set { + guard isActive, container == .board, let sourceRoot, root == sourceRoot else { return [] } return operation == .copy ? [] : memberSet } /// The proposal's index when it names this board's strip, else `nil` — the lane strip's shadow /// run position. - func stripProposal(onBoardRooted root: URL) -> Int? { + func stripProposal(onBoardRooted root: BoardRootKey) -> Int? { guard kind == .lanes, let proposal, proposal.container == .strip, - DragLocality.isSameBoard(proposal.boardRoot, root) + proposal.boardRoot == root else { return nil } return proposal.index } /// 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. - 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), - DragLocality.isSameBoard(proposal.boardRoot, root) + proposal.boardRoot == root else { return nil } 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 /// 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. - func trashProposal(onBoardRooted root: URL) -> Int? { + func trashProposal(onBoardRooted root: BoardRootKey) -> Int? { guard isActive, let proposal, proposal.container == .trash, - DragLocality.isSameBoard(proposal.boardRoot, root) + proposal.boardRoot == root else { return nil } 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 /// highlight's one input (`CardFaceView`). - func fileAttachTarget(onBoardRooted root: URL) -> ItemID? { + func fileAttachTarget(onBoardRooted root: BoardRootKey) -> ItemID? { guard let fileTarget, - DragLocality.isSameBoard(fileTarget.boardRoot, root), + fileTarget.boardRoot == root, case let .attach(cardID) = fileTarget.landing else { return nil } 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 /// 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. - 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, - DragLocality.isSameBoard(fileTarget.boardRoot, root), + fileTarget.boardRoot == root, case let .create(lane, index) = fileTarget.landing, lane == laneID else { return nil } @@ -576,7 +571,7 @@ final class DragSession { self.container = container self.mixesKinds = mixesKinds self.sourceStore = source - self.sourceRoot = source.rootURL + self.sourceRoot = source.rootKey self.proposal = nil self.operation = .move // The reload-resolved drag set: vanished members leave it silently, which is what @@ -609,14 +604,14 @@ final class DragSession { /// pure function is. @discardableResult func resolveOperation( - destinationRoot: URL, + destinationRoot: BoardRootKey, modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags ) -> TransferOperation { guard let kind, let sourceRoot, hold == nil else { return operation } let resolved = DragLocality.operation( kind: kind, container: container, - isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot), + isWithinBoard: sourceRoot == destinationRoot, modifiers: modifiers ) if resolved != operation { operation = resolved } @@ -656,7 +651,7 @@ final class DragSession { sourceStore?.transient.dragMembers = .empty watchdog?.cancel() watchdog = nil - let hold = CommittedHold(boardRoot: store.rootURL, generation: store.snapshotGeneration) + let hold = CommittedHold(boardRoot: store.rootKey, generation: store.snapshotGeneration) self.hold = hold let timeout = holdTimeout 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 /// 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 } end() } diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index ae6b613..ac429c0 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -788,7 +788,7 @@ struct LaneView: View { /// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere. 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 @@ -805,7 +805,7 @@ struct LaneView: View { if let position = cardProposal { 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( position: proposal.index, heights: Array(repeating: LaneDropRegistry.nominalCardHeight, count: proposal.count) @@ -892,7 +892,7 @@ struct LaneView: View { private var renderedCards: [Card] { Self.rendered( lane.cards, - hiddenByDrag: drops.session.hiddenMembers(onBoardRooted: store.rootURL), + hiddenByDrag: drops.session.hiddenMembers(onBoardRooted: store.rootKey), filter: store.searchFilter, renaming: store.transient.renameEditor?.targetID ) diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index 6b6aa64..9897e1c 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -194,7 +194,7 @@ struct TrashLaneView: View { /// 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. 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 diff --git a/KanbanTests/DragSessionTests.swift b/KanbanTests/DragSessionTests.swift index a91a499..74e18c5 100644 --- a/KanbanTests/DragSessionTests.swift +++ b/KanbanTests/DragSessionTests.swift @@ -69,10 +69,15 @@ struct DragPayloadTests { } } -// MARK: - Locality +// MARK: - Board identity -@Suite("DragLocality") -struct DragLocalityTests { +/// **The one canonical spelling every locality comparison is made against** (`BoardRootKey`). +/// +/// 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 // 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 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 option: NSEvent.ModifierFlags = [.option] 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. @Test("Locality picks the default — within is a move, across is a copy") func theDefault() { @@ -320,8 +391,8 @@ struct MixedTrashDragTests { @Suite("CommittedHold") struct CommittedHoldTests { - private static let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true) - private static let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true) + private static let here = BoardRootKey(URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)) + private static let there = BoardRootKey(URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)) 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") 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") @@ -404,7 +476,7 @@ struct DropSettleTests { ) -> DragSession { let session = DragSession() 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 } @@ -449,8 +521,8 @@ struct DropSettleTests { let session = proposing(store) #expect(!session.isSettled) - #expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 2) - #expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1]) + #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 2) + #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1]) } // MARK: The hold @@ -469,8 +541,8 @@ struct DropSettleTests { session.commit(into: store) #expect(session.isSettled) - #expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 2) - #expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1]) + #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 2) + #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1]) } @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.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 @@ -495,7 +567,7 @@ struct DropSettleTests { let session = DragSession() pickUp(session, from: store, members: members) session.propose(DropTarget( - boardRoot: store.rootURL, + boardRoot: store.rootKey, container: .trash, index: TrashDrop.landingIndex )) @@ -509,13 +581,13 @@ struct DropSettleTests { let store = try BoardStore(rootURL: fixture.root) 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 // nothing, and neither does the strip. - #expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil) - #expect(session.stripProposal(onBoardRooted: store.rootURL) == nil) + #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil) + #expect(session.stripProposal(onBoardRooted: store.rootKey) == nil) // 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 @@ -529,9 +601,9 @@ struct DropSettleTests { session.commit(into: store) - #expect(session.trashProposal(onBoardRooted: store.rootURL) == 0) + #expect(session.trashProposal(onBoardRooted: store.rootKey) == 0) #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 @@ -549,12 +621,12 @@ struct DropSettleTests { units: [1], 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) // 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 @@ -567,12 +639,12 @@ struct DropSettleTests { let session = proposing(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.isActive) - #expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil) - #expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty) + #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil) + #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty) } @Test("Ending the session outright ends the hold with it") @@ -586,8 +658,8 @@ struct DropSettleTests { session.end() #expect(!session.isSettled) - #expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil) - #expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty) + #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil) + #expect(session.hiddenMembers(onBoardRooted: store.rootKey).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(!session.isActive) - #expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil) - #expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty) + #expect(session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == nil) + #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty) #expect(store.transient.dragMembers.ids.isEmpty) } @@ -627,7 +699,7 @@ struct DropSettleTests { session.commit(into: store) 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") session.expire(hold) @@ -646,7 +718,7 @@ struct DropSettleTests { // 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. - 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")]) 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 /// 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 /// real to name. @@ -725,12 +797,12 @@ struct DragRestingLayoutTests { let store = try BoardStore(rootURL: fixture.root) let session = cardSession(store, members: [Self.card1, Self.card2]) - #expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) == .move) - #expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2]) + #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: none) == .move) + #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. #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. @@ -742,12 +814,12 @@ struct DragRestingLayoutTests { let session = cardSession(store, members: [Self.card1, Self.card2]) // The within-board ⌥-copy. - #expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .copy) - #expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty) + #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .copy) + #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty) // And the cross-board default, which is a copy with no modifier at all. #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 @@ -760,8 +832,8 @@ struct DragRestingLayoutTests { let session = cardSession(store) for modifiers in [none, option, none, option] { - session.resolveOperation(destinationRoot: store.rootURL, modifiers: modifiers) - let hidden = session.hiddenMembers(onBoardRooted: store.rootURL) + session.resolveOperation(destinationRoot: store.rootKey, modifiers: modifiers) + let hidden = session.hiddenMembers(onBoardRooted: store.rootKey) #expect(hidden == (modifiers == option ? [] : [Self.card1])) } } @@ -778,11 +850,11 @@ struct DragRestingLayoutTests { let store = try BoardStore(rootURL: fixture.root) let session = cardSession(store, members: [ItemID(rawValue: Ident.card3)], container: .trash) - #expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) == .move) - #expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty) + #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: none) == .move) + #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty) - #expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .copy) - #expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty) + #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .copy) + #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty) } @Test("Only the source board hides anything, whatever the operation") @@ -794,7 +866,7 @@ struct DragRestingLayoutTests { session.resolveOperation(destinationRoot: elsewhere, modifiers: command) #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 @@ -814,11 +886,11 @@ struct DragRestingLayoutTests { source: store ) - #expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .move) - #expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.lane1]) + #expect(session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) == .move) + #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.lane1]) #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 @@ -833,13 +905,13 @@ struct DragRestingLayoutTests { let store = try BoardStore(rootURL: fixture.root) let session = cardSession(store) - session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) + session.resolveOperation(destinationRoot: store.rootKey, modifiers: option) 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.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") @@ -849,11 +921,11 @@ struct DragRestingLayoutTests { let store = try BoardStore(rootURL: fixture.root) let session = cardSession(store) - session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) + session.resolveOperation(destinationRoot: store.rootKey, modifiers: none) 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.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1]) + #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1]) } }