Files
lanework/Kanban/UI/Board/DragSession.swift
T
rzen 524488122f Land Finder file drops positionally, header release topmost
04's settled clauses were mostly shipped already — the create landing
resolved through DropSlotMath.cardSlot with one nominal shadow per
importable file — but a release on the lane header fell through to the
card zones, which clamp inward, so a scrolled lane could propose behind
the header stripe. FileDropZones now folds header, attach hit-test, and
card-slot resolution into one pure seam asked in that order, the header
answering topmost per the ruling; lane headers register their frames
for it. FinderDrop.shadowCount names the floor-at-one rule. New tests
pin the header boundary, a differential against cardSlot's own zones
(same zones, not similar), and a store-level differential proving a
file landing takes the very ranks a card move there takes.

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

841 lines
42 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.
/// This is the one file landing that **highlights**, and only because it has no shadow to
/// show instead (04-interactions.md ▸ Drag and drop, settled 2026-07-28).
case attach(cardID: ItemID)
/// **Onto the lane's card grid**: one card per file, landing at this position in the lane's
/// logical card order.
///
/// **Positional, everywhere the grid reaches** (04-interactions.md ▸ Drag and drop, settled
/// 2026-07-28): "created cards land at the drop position — resolved through the same
/// card-grid zones an ordinary card drag uses, shadow included", and a release on the lane
/// **header** resolves to index 0, the topmost position. Append-at-bottom is the creation
/// trio's rule (⌘N, Return, a double click), never the drop's. `FileDropZones.landing` is
/// where the index comes from.
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 — **one nominal-height shadow per incoming file** on the create path
/// (04-interactions.md ▸ Drag and drop, settled 2026-07-28: the multi-drag precedent), one shadow
/// per card that will land. Read off the session's item providers at hover time
/// (`FinderDrop.shadowCount`), **floored at one** for the drag whose count macOS withholds — the
/// commit is unaffected either way, since the write counts resolved URLs, not providers.
///
/// **Folders are not counted** (same bullet: "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.
///
/// **This run is the create path's whole feedback** (04-interactions.md ▸ Drag and drop, settled
/// 2026-07-28): there is no lane-level highlight to go with it, deliberately — each target gets
/// one clear signal, and `fileAttachTarget` above is the highlight precisely because a card
/// target has no shadow.
///
/// 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)? {
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
}
}
}
}