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 both layouts, because they differ only in what the container is: `laneID == nil` /// is the **lane strip** (the index counts live lanes with the dragged run removed), and a lane id /// is that lane's **masonry** (the index is a position in its logical card order — DRAG-REORDER.md /// § The card masonry). `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 { var boardRoot: URL /// `nil` == the lane strip. var laneID: ItemID? var index: Int } // 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 = [] /// 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? @ObservationIgnored private var fileWatchdog: Task? @ObservationIgnored private var holdTimeoutTask: Task? /// 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). func hiddenMembers(onBoardRooted root: URL) -> Set { 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.laneID == nil, 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.laneID == laneID, 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 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 } } } }