Files
lanework/Kanban/UI/Board/DragSession.swift
T
rzen 33bf425f25 Drop a card on the shown trash to delete it
04's ruling makes the drag the pointer's delete gesture: the shown
trash column accepts live same-board card drags, the shadow pinned
topmost — honest, since the trash sorts by deleted newest-first — and
release tombstones through the same write path as Backspace, extracted
so the two gestures cannot drift. DropTarget grew a container case for
the quasi-lane (it has no lane id by construction); lane drags,
cross-board arrivals, option-copies (re-checked at release, the one
input that can flip without a callback), trashed-side payloads, hidden
trash, and the read-only lock all refuse — and a refusal falls through
to the strip retarget, never cancelling the drag. The settle draws the
tombstoned rows in the trash under the cards' own GUIDs, so the echo is
an invisible content swap and nothing winks out for a round trip.
Selection needs no surgery: the reload's resolve rule ejects tombstoned
members as the vanish it is, pinned by a test contrasting both gestures.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 08:12:09 -04:00

820 lines
41 KiB
Swift

import AppKit
import Foundation
import Observation
import SwiftUI
// MARK: - Where a drag would land
/// The drop proposal: which board, which container, which slot.
///
/// One value for every layout, because they differ only in what the container is, and `boardRoot` is
/// what makes a proposal cross-board-aware: only the board whose root it names renders the shadows,
/// and only that board's delegate may commit it.
struct DropTarget: Equatable, Sendable {
/// The three surfaces a drop can name, spelled as a sum so the impossible combinations cannot be
/// written down at all.
///
/// The trash is a case rather than an id because **it has no id**: the quasi-lane is not in the
/// snapshot — it is `TrashModel.entries` derived from it — so there is nothing to put in a
/// `lane`, and its index is not a position the pointer chose either (see `TrashDrop`).
enum Container: Equatable, Sendable {
/// The **lane strip**: the index counts live lanes with the dragged run removed.
case strip
/// That lane's **masonry**: the index is a position in its logical card order
/// (DRAG-REORDER.md § The card masonry).
case lane(ItemID)
/// The **trash quasi-lane**, which a live card drag proposes into to delete it
/// (04-interactions.md ▸ The trash, settled 2026-07-28). The index is always the topmost row.
case trash
}
var boardRoot: URL
var container: Container
var index: Int
/// The lane this proposal names, or `nil` for the strip and the trash — the shape the container
/// wore before there were three of them, kept because most readers only ask this one question.
var laneID: ItemID? {
if case let .lane(id) = container { return id }
return nil
}
/// Whether this proposal names the trash column, and therefore means *delete*.
var isTrash: Bool { container == .trash }
}
// MARK: - Dropping on the trash
/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28: "the drag becomes
/// the pointer's delete gesture — release tombstones the dragged card(s), exactly the ⌫ tombstone"),
/// as the two pure facts the gesture is made of (`TrashDropTests`).
///
/// Kept out of the drop context so the ruling is checkable without a window, and stated once so the
/// **hover** and the **release** cannot disagree about what the trash takes — the hazard being a
/// modifier pressed *after* the proposal stood, which no `dropUpdated` need ever report.
enum TrashDrop {
/// The row the shadow takes, always: **the topmost**.
///
/// Not arbitrary, and the sort is what makes it honest: the trash orders by `deleted`
/// newest-first (03-board-ui.md § Trash), so a fresh tombstone genuinely lands on top. The drop
/// therefore still lands exactly where the shadow shows — the one positional promise every other
/// drop in this app makes — while being the only proposal on the board the *pointer* does not
/// choose.
static let landingIndex = 0
/// Whether the shown trash takes this session — the whole of the gate, and every clause is a
/// refusal 04 states in its own words:
///
/// - **Lanes are not deliverable this way** — "a lane drag proposes only lane slots". (The strip's
/// slot list has never contained the quasi-lane, so this is belt over braces; it is written down
/// because a guard that is only true by construction is one refactor from being false.)
/// - **A trash row is already there.** A `.trashed` session's vocabulary is restore and copy-out;
/// dropping it back where it came from writes nothing.
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign card
/// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the
/// design gives no name and no undo story. The card stays where it is.
/// - **⌥ is refused.** Copying into the trash is not a thing — the copy grammar promises the
/// original stays exactly where it was, and there is nothing to tombstone but the original.
/// - **Hidden, the trash is invisible to every gesture.** True by construction too (the column is
/// not rendered, so it has no drop region), and stated here so the claim is testable.
/// - **The mutating-gesture rule**, like every other write the pointer can start.
static func accepts(
kind: DragKind?,
side: Liveness,
isWithinBoard: Bool,
operation: TransferOperation,
isTrashShown: Bool,
acceptsMutations: Bool
) -> Bool {
guard isTrashShown, acceptsMutations else { return false }
guard kind == .cards, side == .live, isWithinBoard else { return false }
return operation == .move
}
}
// MARK: - Where an external file drag would land
/// Where an external **Finder file** session would land (04-interactions.md ▸ Drag and drop, "Files
/// from Finder").
///
/// A separate type from `DropTarget` because a file session is a separate *mode*: nothing of ours is
/// in flight, so there is no dragged run to lift out of the resting layout, no operation to resolve
/// against modifiers, and no source board to compare roots with — only a destination and what the
/// files would become there.
struct FileDropTarget: Equatable, Sendable {
/// The two landings 04 gives a file drop, and the whole of its behavioural split.
enum Landing: Equatable, Sendable {
/// **Onto a card**: the files copy into that card's `attachments/`. A card under the cursor
/// always wins over the lane behind it — attach beats create, anywhere on the card's bounds.
case attach(cardID: ItemID)
/// **Onto lane empty space**: one card per file, landing at this position in the lane's
/// logical card order.
case create(laneID: ItemID, index: Int)
}
/// 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 landing: Landing
/// How many files ride along — the create path's shadow run length, one shadow per card that
/// will land. Read off the session's item providers at hover time, floored at one.
///
/// **Folders are not counted** (04-interactions.md ▸ Drag and drop: "a mixed drag proposes for
/// its files only"): `FinderDrop.importableCount` reads the providers' declared types, so a
/// mixed drag draws shadows for its files alone and a folders-only drag never proposes at all.
var fileCount: Int
}
// MARK: - The committed-overlay hold
/// One item the hold is drawing: the identity it travelled under, and the title it wore on the way.
///
/// The title is not a convenience. A **cross-board arrival** has no presence in the board it just
/// landed on — the write is in flight and the destination has never seen that folder — so the
/// payload's title is the whole of what its face can say until the echo brings the real card. A
/// within-board landing finds itself in the snapshot and draws its real face instead (`LaneView`).
struct DroppedItem: Equatable, Sendable {
var id: ItemID
var title: String?
}
/// What a container draws at the drop proposal — **one value for both phases of a release**, because
/// the slot is the same slot throughout and only its content changes.
///
/// 03-board-ui.md § Motion (sharpened 2026-07-28): "at release the shadow is replaced by the dropped
/// card(s) drawn in place immediately, the appear never waiting for the echo — a lingering shadow
/// over a hidden card is the hold failing its one job". Keeping the *index* outside the phase split
/// is what lets the reflow's animation key stay put across the release: the settle renders, it does
/// not move, so nothing about it may key motion.
struct DropLanding: Equatable, Sendable {
/// The dropped run, as the settled overlay draws it.
struct Dropped: Equatable, Sendable {
/// The items that landed, in landing order.
var items: [DroppedItem]
/// Whether the arriving items keep the identities they travelled under, so the overlay's
/// slots may wear the arriving cards' own keys and the echo becomes a content swap inside
/// one element — the new-card placeholder's handoff exactly (`LaneSlot`). True for a
/// **within-board move** and nothing else: a copy mints fresh GUIDs, and a cross-board
/// arrival may be reminted at the import boundary, so neither can promise a key.
var keepsIdentity: Bool
/// Whether this board already holds these items — a rearrangement of its own, whose faces it
/// can therefore draw straight from its snapshot. False for an arrival from another board,
/// which has only `DroppedItem.title` to go on.
var isLocal: Bool
}
enum Run: Equatable, Sendable {
/// The drag is in flight: N hit-transparent shadows hold the space open (`DragShadow`).
case shadows
/// The release has settled: the dropped items themselves, drawn at the slot the shadows were
/// holding.
case dropped(Dropped)
}
/// Where the run opens, in the container's own order.
var index: Int
var run: Run
}
/// **The committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold; 03-board-ui.md §
/// Motion) — the drop proposal, and the run that landed under it, kept as overlay state past the
/// release. A value, so the state machine is testable without a filesystem.
///
/// At release the write goes to disk and the *snapshot does not change* — the one-way flow means the
/// board only shows the new order once the watcher's reload lands (02-architecture.md). Dropping the
/// drag state at release would snap every sibling back to the pre-drop layout for a frame. So the
/// session flips from *proposing* to *committed*, keeps rendering the arrangement it was showing,
/// and stands until the destination store applies its next snapshot — **any** snapshot: the
/// app-mediated echo is normally next, and a foreign one that lands first re-grounds everything
/// anyway.
///
/// ### Rendering the arrangement means rendering the card
///
/// The hold carries *what* landed and not only *where*, because the arrangement is not an outline:
/// "at release the shadow is replaced by the dropped card(s) drawn in place immediately" — the
/// system drag image's fade then dissolves over a card that is already there, which is the whole
/// promise of the settle. `landing` is that run, in the order it lands.
///
/// `operation` is the other half of what the overlay draws, and it settles two questions at once
/// because a move and a copy differ in exactly those two ways — see `removesOriginals` and
/// `keepsIdentity`.
///
/// The `timeout` is the same guarantee the drag session's watchdog gives the drag itself: a write
/// that was refused outright (a read-only board) produces no reload at all, and an overlay with no
/// hand-off coming must still dissolve and let the snapshot be the authority again.
struct CommittedHold: Equatable, Sendable {
/// The board whose next applied snapshot retires this hold.
var boardRoot: URL
/// That board's `snapshotGeneration` at the moment of the commit.
var generation: Int
/// The run that landed, in landing order — the dropped cards the overlay draws in place.
/// Defaulted so the hand-off condition above can still be stated on its own.
var landing: [DroppedItem] = []
/// The effective operation the release committed, re-resolved at the drop.
var operation: TransferOperation = .move
/// Whether the source board keeps its originals lifted out of the resting layout. A move took
/// them away, so it does; a **copy left them exactly where they were**, so they come back the
/// instant the write is issued — the arrangement the hold renders has the originals *and* the
/// arrivals in it, which is what the echo will show.
var removesOriginals: Bool { operation == .move }
/// Whether the arriving items keep the identities they travelled under (`Dropped.keepsIdentity`
/// is this, narrowed to a within-board landing).
var keepsIdentity: Bool { operation == .move }
/// How long the hold may stand with no snapshot arriving. Comfortably longer than a write plus
/// a watcher round trip, short enough that a refused write does not leave the board drawing an
/// arrangement it never got.
static let timeout: Duration = .milliseconds(1500)
/// 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
}
}
// MARK: - Locality
/// **Locality picks the default — the Finder volume model** (04-interactions.md ▸ Drag and drop,
/// settled), as pure functions (`DragLocalityTests`).
///
/// Within a board a drag is a **move**: rearranging. Between boards it is a **copy**: transferring,
/// with the system copy badge showing over the foreign board. **⌥ always forces copy** and **⌘
/// always forces move** — Finder's exact modifier grammar — and each is a no-op where its behavior
/// 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.
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:
///
/// - **Lane drags never copy within their board.** ⌥ is simply ignored there: the drag stays a
/// clean reorder and the badge never shows copy. The within-board lane duplicate exists, but
/// its home is the clipboard (▸ Clipboard, Lane paste).
/// - **A trash row's drag is copy-out grammar** (▸ The trash). Within its own board it is the
/// restore — a move, no badge; across boards the default is the live copy that leaves the
/// tombstoned original in place, exactly as ⌘C out of the trash behaves. ⌘ forces the true
/// restore-move either way, and ⌥ forces the live copy either way ("⌘C, ⌥-drag, and the
/// cross-board drag default always yield *live* copies").
static func operation(
kind: DragKind,
side: Liveness,
isWithinBoard: Bool,
modifiers: NSEvent.ModifierFlags
) -> TransferOperation {
let forcesCopy = modifiers.contains(.option)
let forcesMove = modifiers.contains(.command)
// The lane carve-out comes first, because it is an outright refusal of the modifier rather
// than a different default: a within-board lane drag is a reorder and nothing else.
if kind == .lanes, isWithinBoard { return .move }
if forcesMove { return .move }
if forcesCopy { return .copy }
_ = side // the side changes which commit runs, never which operation the badge shows
return isWithinBoard ? .move : .copy
}
}
// MARK: - DragSession
/// The app's one drag session — what is in flight, where it would land, and what a release means
/// (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
///
/// **App-wide, because a drag crosses windows.** The identities are UUIDs and the folders are paths,
/// so the source board hides the dragged items while *any* open board's drop delegates propose a
/// landing spot for them. It is owned by `AppModel` and reached through the environment; the system
/// `NSItemProvider` payload is the formal drop data (`DragPayload`), and this is what every hover
/// actually reads — decoding a provider is asynchronous, and `dropUpdated` must answer synchronously
/// to drive the reflow.
///
/// ### What is frozen and what is not
///
/// 03-board-ui.md § Motion's animation-proof rule, made structural: the **only** inputs frozen at
/// drag start are the *dragged items'* own sizes — card heights and lane unit counts, which the
/// pickup transition corrupts the instant it starts. Everything else (the resting zones, the
/// destination's standard width, which lanes are live) is recomputed from the current snapshot on
/// every sample, which is rule 1 of the mid-drag re-grounding trio: a foreign reload just moves the
/// zones.
///
/// ### Termination is structural
///
/// `begin` arms a watchdog that polls the physical mouse-button state and clears the session shortly
/// after the button is released, no matter where the drop landed — on a delegate (whose
/// `performDrop` already cleaned up, making the watchdog a no-op), on empty window space, in another
/// window, outside every window, or on a cancelled drag. This is the pathfinder's hard-won lifecycle
/// pair: cleanup on session-phase events must be gated on the button being physically up (a finished
/// session's events can arrive *after* the next drag has started), and the watchdog is the
/// guaranteed path for the sessions SwiftUI never reports at all.
@MainActor
@Observable
final class DragSession {
// MARK: What is in flight
/// Which level is being dragged; `nil` when no session is in flight — which is what "no drag"
/// means here rather than a separate flag.
private(set) var kind: DragKind?
/// The side the drag started on. A trash row's drag is a `.cards` session on the `.trashed`
/// side, and that is the whole of what makes it one (04-interactions.md ▸ The trash).
private(set) var side: Liveness = .live
/// The dragged items in **flatten order** — the order they will land in.
private(set) var members: [ItemID] = []
/// Fast identity test for hiding the originals wherever they render.
private(set) var memberSet: Set<ItemID> = []
/// The dragged items' folders, aligned 1:1 with `members` — what the cross-board commits take.
@ObservationIgnored private(set) var folders: [URL] = []
/// The dragged items' titles, aligned 1:1 with `members` — captured at pickup off the very
/// payload the pasteboard carries, and read again at the drop so the committed hold can draw a
/// **cross-board arrival**'s face before that board has ever heard of it (`DroppedItem`).
@ObservationIgnored private(set) var titles: [String?] = []
/// 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?
@ObservationIgnored private(set) weak var sourceStore: BoardStore?
/// The dragged cards' heights, **frozen at drag start**. `DropSlotMath.cardSlot` caps the
/// vertical trigger region at the first one (the shadow the cursor is over), and the shadows are
/// drawn at all of them.
@ObservationIgnored private(set) var cardHeights: [CGFloat] = []
/// The dragged lanes' width units, frozen at drag start — the run's span, measured against
/// whichever board's standard width it is being proposed into.
@ObservationIgnored private(set) var laneUnits: [Int] = []
// MARK: Where it would land
/// The current proposal, or `nil` when the drag has none — a fresh session before the first
/// sample, or one whose target lane was tombstoned in a reload (rule 2 of the re-grounding
/// trio). **Release with no valid proposal cancels.**
private(set) var proposal: DropTarget?
/// The effective operation, re-resolved on every `dropUpdated` so the system badge tracks live.
private(set) var operation: TransferOperation = .move
/// The committed-overlay hold, or `nil` while the session is still proposing. See
/// `CommittedHold`.
private(set) var hold: CommittedHold?
// MARK: The external file mode
/// Where an external Finder file drag would land, or `nil` when there is none in flight or it is
/// over nothing that accepts it (`FileDropTarget`).
///
/// **A distinct mode, deliberately kept out of everything above.** A file session arms none of
/// this object's own state — `kind` stays `nil`, so `isActive` stays false, no member is hidden
/// from any resting layout, and the marquee and card/lane machinery carry on as if no drag
/// existed. It lives here rather than in a drop delegate for the reason the rest does: the
/// highlight and the shadows render off it, so it has to be observable and it has to be one
/// value the whole window agrees on.
private(set) var fileTarget: FileDropTarget?
@ObservationIgnored private var watchdog: Task<Void, Never>?
@ObservationIgnored private var fileWatchdog: Task<Void, Never>?
@ObservationIgnored private var holdTimeoutTask: Task<Void, Never>?
/// How long this session's holds may stand with no snapshot arriving — `CommittedHold.timeout`,
/// and a `var` for one reason only: the discard path is a `Task` sleeping on the main actor, and
/// a test that had to wait the real figure out would be a 1.5 s wall clock in the suite
/// (`DragSessionTests`). Nothing in the app writes it.
@ObservationIgnored var holdTimeout: Duration = CommittedHold.timeout
init() {}
// MARK: Queries
var isActive: Bool { kind != nil }
var isDraggingCards: Bool { kind == .cards }
var isDraggingLanes: Bool { kind == .lanes }
/// Whether the release has **settled**: the write is issued, the proposal is being held as
/// overlay state, and every surface that was drawing shadows is now drawing the dropped items
/// (`CommittedHold`).
var isSettled: Bool { hold != nil }
/// N — the number of contiguous shadows the proposal draws.
var shadowCount: Int { members.count }
/// Whether `id` is one of the dragged items.
func isDragging(_ id: ItemID) -> Bool { memberSet.contains(id) }
/// The items to **leave out of the resting layout** on the board rooted at `root`.
///
/// Only the source board hides anything, and only for a live-side session: a trash row's drag
/// carries items that render in the quasi-lane, not in any lane's masonry, so no lane loses a
/// card to it.
///
/// **The dragged run is lifted out whatever the effective operation is** *while the drag is in
/// flight* (DRAG-REORDER.md § Resting-layout zones): ⌥ can be pressed and released mid-drag, and
/// a layout that re-admitted the originals on every modifier flip would flap the whole board
/// under the cursor.
///
/// **At release the operation stops being a guess**, and a settled copy's originals come back at
/// once (`CommittedHold.removesOriginals`): the copy left them exactly where they were, so the
/// arrangement the hold is drawing has both them and the arrivals in it, and hiding them a round
/// trip longer would be the same lie the lingering shadow was. A settled *move* keeps hiding
/// them, because the write really did take them away — the overlay draws them at their landing
/// slot instead, which is the whole of "rendering the arrangement means rendering the card"
/// (03-board-ui.md § Motion).
///
/// **A drop on the trash is a move by this rule and needs no clause of its own**: the tombstone
/// really did take the cards off the live side, and the landing slot the overlay draws them at is
/// the trash's topmost row (`trashLanding`).
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
guard isActive, side == .live, let sourceRoot,
DragLocality.isSameBoard(root, sourceRoot)
else { return [] }
if let hold, !hold.removesOriginals { return [] }
return 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? {
guard kind == .lanes, let proposal, proposal.container == .strip,
DragLocality.isSameBoard(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? {
guard kind == .cards, let proposal, proposal.container == .lane(laneID),
DragLocality.isSameBoard(proposal.boardRoot, root)
else { return nil }
return proposal.index
}
/// The proposal's index when it names this board's **trash column**, else `nil` — the delete
/// gesture's shadow row (04-interactions.md ▸ The trash).
///
/// Always `TrashDrop.landingIndex`, and read through this accessor anyway so the column asks the
/// same "is it me?" question every other container asks, and gets the position from the same
/// place.
func trashProposal(onBoardRooted root: URL) -> Int? {
guard kind == .cards, let proposal, proposal.container == .trash,
DragLocality.isSameBoard(proposal.boardRoot, root)
else { return nil }
return proposal.index
}
/// **What `laneID`'s masonry draws at the proposal** — the shadow run while the drag is in
/// flight, the dropped cards themselves once the release has settled (`DropLanding`), and `nil`
/// when no proposal names this lane.
///
/// The index is the same index in both phases, deliberately: the settle changes what the slot
/// *contains*, never where it is, so a view can key its reflow on the index and be sure the
/// release itself animates nothing (03-board-ui.md § Motion — the un-hide is rendering).
func cardLanding(onBoardRooted root: URL, laneID: ItemID) -> DropLanding? {
guard let index = laneProposal(onBoardRooted: root, laneID: laneID) else { return nil }
return DropLanding(index: index, run: landingRun)
}
/// The lane strip's twin of `cardLanding` — the shadow run, or the dropped lanes drawn at the
/// slot they landed in.
func laneLanding(onBoardRooted root: URL) -> DropLanding? {
guard let index = stripProposal(onBoardRooted: root) else { return nil }
return DropLanding(index: index, run: landingRun)
}
/// The **trash column's** twin: the shadow rows the delete gesture opens at the top, and — once
/// the release has settled — the tombstoned rows themselves, drawn there from the instant of
/// release until the echo reload brings the real ones (`TrashLaneView`).
///
/// The settle is not decoration here, it is the whole of the gesture being legible: at release
/// the dragged cards are already lifted out of their lanes (`hiddenMembers` — a delete removes
/// its originals exactly as a move does), so with nothing drawn in the trash they would simply
/// wink out of existence for a round trip.
func trashLanding(onBoardRooted root: URL) -> DropLanding? {
guard let index = trashProposal(onBoardRooted: root) else { return nil }
return DropLanding(index: index, run: landingRun)
}
/// The phase, as the two accessors above read it.
///
/// `isLocal` is what keeps a **colliding cross-board arrival** from being drawn twice: only a
/// board that already holds the dragged items may resolve their faces (and their identities)
/// against its own snapshot, and an arrival's payload title is the honest answer everywhere
/// else.
private var landingRun: DropLanding.Run {
guard let hold else { return .shadows }
let isLocal = sourceRoot.map { DragLocality.isSameBoard($0, hold.boardRoot) } ?? false
return .dropped(DropLanding.Dropped(
items: hold.landing,
keepsIdentity: hold.keepsIdentity && isLocal,
isLocal: isLocal
))
}
/// The members that are still there — **rule 3 of the re-grounding trio**: drag membership is a
/// UUID set that vanished items leave silently (`TransientBoardState.dragMembers`), and when the
/// last one goes the drag has emptied itself. Partial vanishing drops the survivors, matching
/// the pending-cut precedent.
///
/// The source store answers because it is the one re-grounding the set against each reload; with
/// its window gone there is nothing left to invalidate the set, and the folders on disk are as
/// good as they were.
var survivors: [Int] {
guard let store = sourceStore else { return Array(members.indices) }
let live = store.transient.dragMembers.ids
return members.indices.filter { live.contains(members[$0]) }
}
// MARK: The file mode's queries and lifecycle
/// 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? {
guard let fileTarget,
DragLocality.isSameBoard(fileTarget.boardRoot, root),
case let .attach(cardID) = fileTarget.landing
else { return nil }
return cardID
}
/// The shadow run a file drop would open in `laneID` on this board — where the created cards
/// land and how many there are — or `nil` when the proposal is elsewhere.
func fileLaneProposal(onBoardRooted root: URL, laneID: ItemID) -> (index: Int, count: Int)? {
guard let fileTarget,
DragLocality.isSameBoard(fileTarget.boardRoot, root),
case let .create(lane, index) = fileTarget.landing,
lane == laneID
else { return nil }
return (index: index, count: max(1, fileTarget.fileCount))
}
/// Records where the files would land. `nil` withdraws the proposal — over a gap, the outer
/// margin, the trash column, or a board that refuses the drop outright.
///
/// **No hysteresis, unlike our own sessions.** A card or lane proposal deliberately *holds* while
/// the cursor crosses ambiguous territory, because the drop must land where the shadows show even
/// if the cursor drifted off a live zone. A file drop has no such contract: it is a plain "what is
/// under the cursor right now", so leaving every target simply clears the highlight and the drop
/// is refused (the pathfinder's `retargetFile` precedent).
func proposeFile(_ target: FileDropTarget?) {
guard fileTarget != target else { return }
let wasHovering = fileTarget != nil
fileTarget = target
if target == nil {
fileWatchdog?.cancel()
fileWatchdog = nil
} else if !wasHovering {
armFileWatchdog()
}
}
// MARK: Lifecycle
/// Begins a card session — live faces or trash rows.
///
/// - Parameters:
/// - members: the dragged cards in flatten order (`SelectionGrammar.liveCards`, or the trash's
/// own sorted order for a trash-row drag).
/// - heights: their measured heights, captured **before** the pickup transition starts.
func beginCards(
_ members: [ItemID],
folders: [URL],
titles: [String?],
heights: [CGFloat],
side: Liveness,
source: BoardStore
) {
begin(kind: .cards, members: members, folders: folders, titles: titles, side: side, source: source)
cardHeights = heights
laneUnits = []
}
/// Begins a lane session.
func beginLanes(_ members: [ItemID], folders: [URL], titles: [String?], units: [Int], source: BoardStore) {
begin(kind: .lanes, members: members, folders: folders, titles: titles, side: .live, source: source)
laneUnits = units
cardHeights = []
}
private func begin(
kind: DragKind,
members: [ItemID],
folders: [URL],
titles: [String?],
side: Liveness,
source: BoardStore
) {
endHold()
self.kind = kind
self.members = members
self.memberSet = Set(members)
self.folders = folders
self.titles = titles
self.side = side
self.sourceStore = source
self.sourceRoot = source.rootURL
self.proposal = nil
self.operation = .move
// The reload-resolved drag set: vanished members leave it silently, which is what
// `survivors` reads and what "an emptied drag cancels itself" is stated in terms of.
source.transient.dragMembers = ItemReferenceSet(ids: memberSet, liveness: side)
armWatchdog()
}
/// Records a new proposal. `nil` withdraws it — rule 2's "the shadow withdraws".
///
/// **A settled release is past retargeting**: the write naming that slot is already on its way,
/// so a late callback arriving after the commit — a stray `dropUpdated`, a revalidation — must
/// not move or withdraw the arrangement the hold is drawing.
func propose(_ target: DropTarget?) {
guard isActive, hold == nil, proposal != target else { return }
proposal = target
}
/// Re-resolves the effective operation against the board under the cursor and the modifiers
/// **right now**, and hands it back for the `DropProposal` the badge tracks.
@discardableResult
func resolveOperation(destinationRoot: URL) -> TransferOperation {
guard let kind, let sourceRoot else { return operation }
let resolved = DragLocality.operation(
kind: kind,
side: side,
isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot),
modifiers: NSEvent.modifierFlags
)
if resolved != operation { operation = resolved }
return resolved
}
/// Ends the session outright — a cancel, a release with no valid proposal, or the watchdog.
func end() {
sourceStore?.transient.dragMembers = .empty
kind = nil
members = []
memberSet = []
folders = []
titles = []
cardHeights = []
laneUnits = []
proposal = nil
operation = .move
sourceStore = nil
sourceRoot = nil
watchdog?.cancel()
watchdog = nil
endHold()
}
/// Enters the committed phase: the arrangement the session was showing stays on screen until
/// `store` applies its next snapshot (`CommittedHold`).
///
/// Everything that drives the rendering — the proposal, the members, the source root — is kept
/// exactly as it was, so "keeps rendering the arrangement it was showing" needs no second
/// mechanism. What *changes* at this instant is what the proposal's slot draws: the shadows are
/// over, and `survivors` — the run this drop actually wrote, vanished members already dropped —
/// becomes the hold's `landing`, drawn there as ordinary card faces (`DropLanding`). A copy's
/// originals come back in the same render pass (`hiddenMembers`); a move's stay lifted, because
/// the overlay is now drawing them at their landing slot.
///
/// - Parameters:
/// - survivors: indices into `members` — what `BoardDropContext.commitDrop` is writing, which
/// is exactly what the overlay must show.
/// - operation: the effective operation, re-resolved at the drop. It decides both halves of
/// the overlay's grammar (`CommittedHold.removesOriginals`, `.keepsIdentity`).
func commit(into store: BoardStore, survivors: [Int], operation: TransferOperation) {
guard isActive else { return }
sourceStore?.transient.dragMembers = .empty
watchdog?.cancel()
watchdog = nil
let hold = CommittedHold(
boardRoot: store.rootURL,
generation: store.snapshotGeneration,
landing: survivors.map {
DroppedItem(id: members[$0], title: titles.indices.contains($0) ? titles[$0] : nil)
},
operation: operation
)
self.hold = hold
let timeout = holdTimeout
holdTimeoutTask?.cancel()
holdTimeoutTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: timeout)
guard !Task.isCancelled, let self else { return }
self.expire(hold)
}
}
/// **The failed write's path**: no echo is coming, so the hold discards and the board animates
/// back to snapshot order (03-board-ui.md § Motion — "the width-drag rollback posture: the
/// action visibly doesn't happen"). The one path in the settle that *is* motion, and the reason
/// it wears `Motion.dragReflow`: what moves is the arrangement un-happening.
///
/// The timeout's own body, spelled as a method rather than inlined in the `Task`, so the discard
/// can be pinned directly as well as through the clock (`DragSessionTests`). A hold that has
/// already been handed off — or replaced by a second drag's — is not this one's to end.
func expire(_ hold: CommittedHold) {
guard self.hold == hold else { return }
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { end() }
}
/// The hand-off: a snapshot landed on `root`, so an overlay standing in for it dissolves.
///
/// 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) {
guard let hold, hold.isRetired(byRoot: root, generation: generation) else { return }
end()
}
private func endHold() {
hold = nil
holdTimeoutTask?.cancel()
holdTimeoutTask = nil
}
/// Cleanup for a session-phase event SwiftUI reports.
///
/// **Gated on the physical button being up**, because a finished session's `.ended` /
/// `.dataTransferCompleted` events can be delivered *after the user has already started the next
/// drag*, and a naive handler wipes the new session's state — no shadow, drop dead. The watchdog
/// covers every genuinely-ended session anyway, so refusing here costs nothing.
func endIfButtonReleased() {
guard NSEvent.pressedMouseButtons == 0 else { return }
guard isActive, hold == nil else { return }
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { end() }
}
// MARK: The watchdog — the single guaranteed termination path
/// Polls the physical mouse-button state while a drag is in flight. When the button is released
/// and, after a short grace period (long enough for a landing `performDrop` to run first), a
/// session still lingers, it is cleared here. Backstops every path a drop delegate cannot see.
private func armWatchdog() {
watchdog?.cancel()
watchdog = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(120))
guard let self, self.isActive, self.hold == nil else { return }
guard NSEvent.pressedMouseButtons == 0 else { continue }
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled, self.isActive, self.hold == nil else { return }
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { self.end() }
return
}
}
}
/// The file mode's own watchdog, and its only guaranteed termination path.
///
/// An external session is not ours to end: no `performDrop` runs when the user drops the files
/// somewhere else entirely, and `onDragSessionUpdated` reports only sessions this app started. A
/// drag is a button held down, so the same poll the internal watchdog uses answers here — when
/// the button has been up for a grace period and a target is still standing, the highlight is
/// stale and goes.
private func armFileWatchdog() {
fileWatchdog?.cancel()
fileWatchdog = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(120))
guard let self, self.fileTarget != nil else { return }
guard NSEvent.pressedMouseButtons == 0 else { continue }
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled, self.fileTarget != nil else { return }
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) {
self.proposeFile(nil)
}
return
}
}
}
}