Implement Finder file drops

Files from Finder land on the board per 04-interactions.md § Drag & drop:

- Dropped on a card, they copy into its attachments/ (any type, multi-file),
  the face highlighting while hovered; the hovered card is resolved by
  hit-testing the same analytic masonry frames the card zones are built
  from, so attach-beats-create adds no drop region and cannot drift from
  the dispatch.
- Dropped on lane empty space, one card per file — filename minus extension
  as the title (a blank stem omits the key), fresh GUID, rank at the drop
  position through the ordinary insertion machinery, the file attached —
  all in one bracket; a failed import removes the just-minted card, so
  creating-then-abandoning never leaves an empty card behind.
- Every board drop surface now declares .fileURL beside the two board
  types (the single-target-dispatch rule); tombstoned surfaces are inert;
  file sessions ride a distinct session mode with their own watchdog and
  no hysteresis, leaving the board-drag machinery untouched.
- Also: four empty fixture directories pinned with .keep files so git
  preserves them, and a test-only visibility fix in DragSessionTests.

811 unit tests (12 new).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 21:20:46 -04:00
parent 90cf82d740
commit 8f116934b4
13 changed files with 992 additions and 59 deletions
+297 -35
View File
@@ -227,6 +227,189 @@ struct BoardDropContext {
}
}
// MARK: Retargeting external Finder file sessions
/// Whether `info` is an **external Finder file** session rather than one of ours.
///
/// Never ambiguous: our own drags arm `DragSession` synchronously at `.onDrag` time, before any
/// drop callback can arrive, so a session that is not active but carries `.fileURL` items came
/// from outside the app. (A board drag also carries a plain-text representation and no file URL,
/// so the two type sets never overlap.)
func isFileSession(_ info: DropInfo) -> Bool {
!session.isActive && info.hasItemsConforming(to: [.fileURL])
}
/// Whether this board accepts a file drop at all **the mutating-gesture rule, applied to the
/// one gesture that arrives from outside the app**: under the read-only lock (02-architecture.md
/// § The lock's scope) or with an inline title editor focused (04-interactions.md Grammar's
/// focused-editor rule) a file drop refuses at the board, with no highlight and no proposal
/// the same refusal `.onDrag` makes by handing back an empty item provider.
var acceptsFileDrops: Bool {
!store.isReadOnly && !store.isEditingInline
}
/// How many files the session carries the shadow run's length on the create path.
///
/// Read from `info` on every sample rather than captured at `dropEntered`: a delegate can be
/// entered without this window ever having seen the enter callback (single-target dispatch hands
/// the session to whichever region is deepest), and a count that was never set would draw the
/// wrong number of shadows.
private func fileCount(_ info: DropInfo) -> Int {
max(1, info.itemProviders(for: [.fileURL]).count)
}
/// Where a **file** session would land in `laneID` the file mode's twin of `retargetCards`,
/// resolved against the very same analytic masonry geometry.
///
/// Two answers, in this order:
///
/// 1. **A card under the cursor always wins** (04-interactions.md Drag and drop): attach beats
/// create, anywhere on the card's bounds. The bounds are the resting frames
/// `MasonryPlacement.frames(heights:)` replays the same reconstruction the card-slot zones
/// are built from, never a measured frame (03-board-ui.md § Motion). While a create shadow is
/// open the *drawn* cards sit lower than their resting frames, which is exactly the tradeoff
/// every proposal in this app makes: the answer stays a pure function of the cursor and the
/// snapshot, so it cannot oscillate the drawn layout never feeds back into it.
/// 2. **Otherwise the create slot**, from `DropSlotMath.cardSlot` "new cards land at the drop
/// position using the same card-grid zone math ordinary card drags use". The footprint the
/// span cap is measured against is the nominal card height, since the cards being proposed do
/// not exist yet to have one; the cap only ever truncates a zone that lies *over* an existing
/// card, and that region is case 1's.
///
/// **Positional landing is filed for design ratification.** 04's bullet says only "dropped on
/// lane empty space creates a card"; that the card lands at the *drop position* rather than at
/// the lane's bottom is this milestone's reading of the pathfinder's `retargetFile` precedent,
/// implemented here and awaiting the design's word.
func retargetFile(inLane laneID: ItemID, info: DropInfo) {
guard acceptsFileDrops, let cursor = globalCursor(),
let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }),
let grid = registry.grids[laneID]
else {
session.proposeFile(nil)
return
}
let rendered = lane.cards.filter { !$0.isDeleted }
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
let placement = MasonryPlacement(
columnCount: grid.columns,
columnWidth: MasonryPlacement.columnWidth(
totalWidth: grid.frame.width, columnCount: grid.columns, spacing: grid.spacing),
spacing: grid.spacing,
origin: grid.frame.origin
)
let count = fileCount(info)
// Closed containment a cursor sitting exactly on a shared edge still counts, and the first
// match wins, so the answer is deterministic however the frames abut.
let frames = placement.frames(heights: heights)
if let index = frames.firstIndex(where: { frame in
cursor.x >= frame.minX && cursor.x <= frame.maxX
&& cursor.y >= frame.minY && cursor.y <= frame.maxY
}), index < rendered.count {
session.proposeFile(FileDropTarget(
boardRoot: store.rootURL,
landing: .attach(cardID: rendered[index].id),
fileCount: count
))
return
}
let slot = DropSlotMath.cardSlot(
cursor: cursor,
placement: placement,
heights: heights,
draggedHeight: LaneDropRegistry.nominalCardHeight,
current: session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: laneID)?.index
)
guard let slot else { return } // a dead region: hold whatever the create slot already was
session.proposeFile(FileDropTarget(
boardRoot: store.rootURL,
landing: .create(laneID: laneID, index: slot),
fileCount: count
))
}
/// The strip's fall-through for file sessions: which lane is under the cursor, analytically.
///
/// Both a safety net for a lane whose own drop region goes dead (DRAG-REORDER.md § Single-target
/// dispatch) and the live handler for the strip's own surfaces and unlike a card session, those
/// surfaces genuinely clear the proposal rather than holding it. A cursor over a gap, the outer
/// margin, or **the trash column** is over no lane at all (`LaneLayoutMath.laneIndex` answers
/// `nil` there, since the quasi-lane is absent from the live lane list by construction), so the
/// highlight withdraws and a release refuses: "Finder file drops (attachment import) on
/// tombstoned cards are inert" and the trash column is never a target (04-interactions.md The
/// trash).
func retargetFileFromStrip(_ info: DropInfo) {
guard acceptsFileDrops, let cursor = stripCursor() else {
session.proposeFile(nil)
return
}
let lanes = store.snapshot.lanes.filter { !$0.isDeleted }
let index = LaneLayoutMath.laneIndex(
atX: cursor.x,
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
standard: standard(),
gap: gap
)
guard let index, lanes.indices.contains(index) else {
session.proposeFile(nil)
return
}
retargetFile(inLane: lanes[index].id, info: info)
}
/// What `dropUpdated` answers for a file session: always `.copy` importing a file leaves the
/// original where it was, which is what the badge should say and `.cancel` when nothing under
/// the cursor will take it.
func fileDropProposal() -> DropProposal {
session.fileTarget != nil ? DropProposal(operation: .copy) : DropProposal(operation: .cancel)
}
/// Commits a file drop: the files land where the highlight or the shadows showed.
///
/// **The target is captured and the hover state cleared before the load starts.** A provider's
/// file URL loads asynchronously it is never synchronous for a Finder drag and a fast second
/// drag arriving in the meantime must not find a stale target sitting there. The store call then
/// happens back on the main actor, one `performWrite` bracket per gesture, whatever the file
/// count (`BoardStore.importAttachments` / `createCards`).
func commitFileDrop(_ info: DropInfo) -> Bool {
guard acceptsFileDrops,
let target = session.fileTarget,
DragLocality.isSameBoard(target.boardRoot, store.rootURL)
else {
session.proposeFile(nil)
return false
}
let providers = info.itemProviders(for: [.fileURL])
session.proposeFile(nil)
guard !providers.isEmpty else { return false }
let store = self.store
Task { @MainActor in
var urls: [URL] = []
for provider in providers {
if let url = await FileDropLoading.url(from: provider) { urls.append(url) }
}
guard !urls.isEmpty else { return }
// The sandbox's half: a Finder drag hands the app an extension for what it dropped, and
// the copy is the read that needs it. `start` answers false for a URL that carries no
// scope of its own an ordinary in-container path so only the ones that opened are
// closed again.
let scoped = urls.filter { $0.startAccessingSecurityScopedResource() }
defer { for url in scoped { url.stopAccessingSecurityScopedResource() } }
switch target.landing {
case let .attach(cardID):
store.importAttachments(urls, toCard: cardID)
case let .create(laneID, index):
store.createCards(fromFiles: urls, inLane: laneID, at: index)
}
}
return true
}
// MARK: The drop proposal the badge tracks
/// What `dropUpdated` answers: the effective operation while the shadows are on *this* board,
@@ -326,56 +509,112 @@ struct BoardDropContext {
}
}
// MARK: - Loading what Finder dropped
/// The one place an `NSItemProvider` from an external drag is unwrapped into a file URL.
enum FileDropLoading {
/// The file URL a dropped provider carries, or `nil` when it carries none.
///
/// **`loadItem` on `.fileURL`, not `loadInPlaceFileRepresentation`.** A `public.file-url` item is
/// what Finder actually puts on the dragging pasteboard, and the drag itself is what grants the
/// sandbox the extension to read it the caller opens the scope and copies. The in-place
/// representation would hand back a URL valid only for the duration of its own completion block,
/// forcing the copy to happen off the main actor inside a callback, for no benefit here.
///
/// The completion fires on an arbitrary queue never assume the main actor so this is a plain
/// continuation wrapper; a `CheckedContinuation` resumes from any queue whatever isolation the
/// call site started from. Both shapes a `.fileURL` item arrives in are accepted: the `Data`
/// encoding it normally takes, and a bare `URL`.
///
/// `@MainActor` for a concurrency reason rather than a behavioural one: the provider comes off a
/// `DropInfo` on the main actor and is not `Sendable`, so a nonisolated entry point would be
/// *sending* it across domains. Staying on the actor it came from keeps the hand-off to the
/// completion handler which fires wherever AppKit likes the only crossing there is.
@MainActor
static func url(from provider: NSItemProvider) async -> URL? {
await withCheckedContinuation { continuation in
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in
if let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) {
continuation.resume(returning: url)
} else if let url = item as? URL {
continuation.resume(returning: url)
} else {
continuation.resume(returning: nil)
}
}
}
}
}
// MARK: - Drop delegates
// **Single-target dispatch** (DRAG-REORDER.md, the constraint of the same name): SwiftUI/macOS
// delivers a drag session to the *deepest* drop region under the cursor with no fall-through, not
// even when that target's declared content types don't match the session's payload. So every
// delegate below accepts **both** board types and routes internally; a region whose topmost target
// understood only one of them would be a dead zone for the other no hover callbacks, and a release
// there would snap back instead of committing.
// delegate below accepts **all three** session types cards, lanes, and external Finder file drags
// and routes internally; a region whose topmost target understood only some of them would be a
// dead zone for the rest no hover callbacks, and a release there would snap back instead of
// committing.
//
// m5-finder-drops: the next card adds external Finder file sessions (`.fileURL`) to this same
// dispatch files onto a card become attachments, files onto lane empty space become cards
// (04-interactions.md Drag and drop) and the same dead-region rule applies to them, so the type
// list and the routing switch in each delegate below grow by one case rather than gaining a delegate
// of their own.
// **The file routing is resolved inside these delegates, not by a drop target of the card's own.**
// A per-face `onDrop` would be the deepest region under the cursor and would therefore have to
// re-implement card and lane routing too, just to avoid becoming a dead zone for them a second
// copy of the dispatch, able to disagree with this one. Instead a card under the cursor is found by
// hit-testing the *analytic* masonry frames the card zones are already built from
// (`BoardDropContext.retargetFile`), which adds no drop region at all and cannot drift from the
// geometry the shadows use.
/// The board types every delegate declares. Spelled once so no target can accidentally accept fewer.
/// The board's own drag types. Spelled once so no target can accidentally accept fewer.
let boardDragTypes: [UTType] = [.laneworkCards, .laneworkLanes]
/// Every type a board surface's `onDrop` declares: our own two, plus the external Finder file drag
/// (04-interactions.md Drag and drop). **A target that left `.fileURL` off would be a dead zone
/// that strands a file session** no hover callbacks, and a refused release with nothing to explain
/// why (DRAG-REORDER.md § Single-target dispatch).
let boardDropTypes: [UTType] = boardDragTypes + [.fileURL]
/// One lane's drop target, attached to the whole lane body.
///
/// **Card sessions** resolve against this lane's masonry zones. **Lane sessions** are forwarded to
/// the strip's logic (cursor converted to strip space by the shared context), so lane reordering
/// keeps working while the cursor crosses lane bodies.
/// keeps working while the cursor crosses lane bodies. **File sessions** resolve against those same
/// masonry zones onto a card they become attachments, onto empty space one card per file.
struct LaneDropDelegate: DropDelegate {
// m5-finder-drops: a file session resolves against these same masonry zones onto a card it
// becomes attachments, onto empty space a card per file (04-interactions.md Drag and drop).
let context: BoardDropContext
let laneID: ItemID
func validateDrop(info: DropInfo) -> Bool {
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
if context.isFileSession(info) { return context.acceptsFileDrops }
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
}
func dropEntered(info: DropInfo) { retarget() }
func dropEntered(info: DropInfo) { retarget(info) }
func dropUpdated(info: DropInfo) -> DropProposal? {
retarget()
return context.dropProposal()
retarget(info)
return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
}
// No `dropExited`, deliberately: the proposal is meant to **hold** while the cursor leaves for
// ambiguous territory that is the hysteresis contract (DRAG-REORDER.md § Hysteresis).
/// **Only file sessions have an exit hook.** A card or lane proposal is meant to *hold* while the
/// cursor leaves for ambiguous territory that is the hysteresis contract (DRAG-REORDER.md §
/// Hysteresis) while a file proposal is only ever "what is under the cursor now", so leaving
/// the lane body clears the highlight.
func dropExited(info: DropInfo) {
guard context.isFileSession(info) else { return }
context.session.proposeFile(nil)
}
func performDrop(info: DropInfo) -> Bool {
context.commitDrop()
context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
}
private func retarget() {
private func retarget(_ info: DropInfo) {
if context.isFileSession(info) {
context.retargetFile(inLane: laneID, info: info)
return
}
context.revalidateProposal()
if context.session.isDraggingLanes {
context.retargetLanes()
@@ -392,26 +631,41 @@ struct LaneDropDelegate: DropDelegate {
/// Lane sessions retarget against the strip's analytic zones; card sessions retarget through the
/// same shared function the lane delegates use, resolving the lane under the cursor analytically
/// the safety net for a lane whose own drop region goes dead.
/// File sessions are the strip's twin safety net, and here its own surfaces are a *live* handler for
/// them rather than only a fallback: a file drag has no proposal to hold, so hovering a gap, the
/// outer margin, or the trash column clears the target outright.
struct StripDropDelegate: DropDelegate {
// m5-finder-drops: the strip is a file session's safety net too the same dead-region
// hit-testing bug can strand one, and without file support here it would have nowhere to land.
let context: BoardDropContext
func validateDrop(info: DropInfo) -> Bool {
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
if context.isFileSession(info) { return context.acceptsFileDrops }
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
}
func dropEntered(info: DropInfo) { context.retargetFromStrip() }
func dropEntered(info: DropInfo) { retarget(info) }
func dropUpdated(info: DropInfo) -> DropProposal? {
context.retargetFromStrip()
return context.dropProposal()
retarget(info)
return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
}
/// See `LaneDropDelegate.dropExited` the file mode's clear, and nothing for our own sessions.
func dropExited(info: DropInfo) {
guard context.isFileSession(info) else { return }
context.session.proposeFile(nil)
}
func performDrop(info: DropInfo) -> Bool {
context.commitDrop()
context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
}
private func retarget(_ info: DropInfo) {
if context.isFileSession(info) {
context.retargetFileFromStrip(info)
} else {
context.retargetFromStrip()
}
}
}
@@ -419,22 +673,30 @@ struct StripDropDelegate: DropDelegate {
/// do not cover (the banner strip, the window's edges) commits the current proposal rather than
/// leaking the session into a cancel-snapback. It retargets nothing the drop lands where the
/// shadows already show, which is what the shadows promise.
/// A file session released over uncovered window chrome commits whatever the file target currently
/// names, exactly as this commits the current card/lane proposal and with no target standing it
/// simply refuses, which is the honest answer for a release over nothing.
struct BoardFallbackDropDelegate: DropDelegate {
// m5-finder-drops: a file session released over uncovered window chrome commits whatever the
// file target currently names, exactly as this commits the current card/lane proposal.
let context: BoardDropContext
func validateDrop(info: DropInfo) -> Bool {
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
if context.isFileSession(info) { return context.acceptsFileDrops }
return context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
}
func dropUpdated(info: DropInfo) -> DropProposal? {
context.dropProposal()
context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal()
}
/// The drag left the window's last covering region with nothing below this one to take over,
/// a standing file highlight would be stale.
func dropExited(info: DropInfo) {
guard context.isFileSession(info) else { return }
context.session.proposeFile(nil)
}
func performDrop(info: DropInfo) -> Bool {
context.commitDrop()
context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop()
}
}
+5 -4
View File
@@ -125,9 +125,10 @@ struct BoardView: View {
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { laneDrops.stripFrame = $0 }
// **The strip's drop target** the backdrop, the gaps, the outer margin, and the trash
// column's footprint, which is never a landing spot of its own (04-interactions.md The
// trash) and so simply falls through to here. It accepts *both* board types and routes
// internally, because single-target dispatch has no fall-through (DRAG-REORDER.md).
.onDrop(of: boardDragTypes, delegate: StripDropDelegate(context: dropContext))
// trash) and so simply falls through to here. It accepts *every* session type ours and
// external Finder file drags alike and routes internally, because single-target
// dispatch has no fall-through (DRAG-REORDER.md).
.onDrop(of: boardDropTypes, delegate: StripDropDelegate(context: dropContext))
}
.background(boardBackground)
// The window-level fallback, *behind* the specific targets: a release over any in-window
@@ -135,7 +136,7 @@ struct BoardView: View {
// cancel-snapback the drop lands where the shadows show, which is what the shadows promise.
.background {
Color.clear
.onDrop(of: boardDragTypes, delegate: BoardFallbackDropDelegate(context: dropContext))
.onDrop(of: boardDropTypes, delegate: BoardFallbackDropDelegate(context: dropContext))
}
// **The committed-overlay hold's hand-off** (DRAG-REORDER.md § The committed-overlay hold):
// the overlay stands in for an arrangement that is on disk but not yet in the snapshot, and
+113
View File
@@ -19,6 +19,38 @@ struct DropTarget: Equatable, Sendable {
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.
var fileCount: Int
}
// MARK: - The committed-overlay hold
/// The hand-off condition for **the committed-overlay hold** (DRAG-REORDER.md § The
@@ -192,7 +224,21 @@ final class DragSession {
/// `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 holdTimeout: Task<Void, Never>?
init() {}
@@ -258,6 +304,49 @@ final class DragSession {
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.
@@ -415,4 +504,28 @@ final class DragSession {
}
}
}
/// 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
}
}
}
}
+59 -13
View File
@@ -98,13 +98,11 @@ struct LaneView: View {
.background(selectionBackground)
.overlay(selectionStroke)
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 }
// **This lane's drop target**, on the whole body. It accepts *both* board types and routes
// **This lane's drop target**, on the whole body. It accepts *every* session type and routes
// internally card sessions against this lane's masonry zones, lane sessions forwarded to
// the strip's logic because single-target dispatch has no fall-through (DRAG-REORDER.md).
//
// m5-finder-drops: external Finder file sessions join this same target and this same
// routing; the type list grows by `.fileURL` and the delegate by one branch.
.onDrop(of: boardDragTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
// the strip's logic, external Finder file sessions against those same zones because
// single-target dispatch has no fall-through (DRAG-REORDER.md).
.onDrop(of: boardDropTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
}
// MARK: - Header
@@ -452,11 +450,11 @@ struct LaneView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's drop proposal**
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's shadow run**
// and nothing broader (03-board-ui.md § Motion). `MasonryLayout` is a `Layout` over one
// `ForEach` precisely so the round-robin reshuffle animates as positional slides rather
// than as remove/insert blinks (DRAG-REORDER.md § The card masonry).
.animation(Motion.dragReflow(reduced: reduceMotion), value: cardProposal)
.animation(Motion.dragReflow(reduced: reduceMotion), value: shadowRun)
// Where this lane's card grid is drawn, in the window's global space the analytic
// resting grid the drop model replays `MasonryPlacement` over. Registered rather than
// re-derived, so the zones and the drawn grid cannot disagree.
@@ -511,12 +509,34 @@ struct LaneView: View {
}
}
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere the
/// shadow run's position, and the reflow's narrow animation key.
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere.
private var cardProposal: Int? {
drops.session.laneProposal(onBoardRooted: store.rootURL, laneID: lane.id)
}
/// The shadow run this lane opens, or `nil` when no proposal names it the masonry's one
/// make-room mechanism, and the reflow's narrow animation key.
///
/// Two sessions feed it and they are mutually exclusive by construction (a file session never
/// arms `DragSession`, so `isActive` is false for exactly as long as one is in flight):
///
/// - **a card drag**, at the dragged cards' frozen heights the run's real footprint, so the
/// drop lands exactly where the shadows are;
/// - **a Finder file drag**, at the nominal height, one shadow per file the cards being
/// proposed do not exist yet, so there is no measured height to be faithful to.
private var shadowRun: ShadowRun? {
if let position = cardProposal {
return ShadowRun(position: position, heights: drops.session.cardHeights)
}
if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: lane.id) {
return ShadowRun(
position: proposal.index,
heights: Array(repeating: LaneDropRegistry.nominalCardHeight, count: proposal.count)
)
}
return nil
}
/// What the masonry lays out: the rendered cards, the drag's N contiguous shadows at the
/// proposal, and the new-card placeholder when this lane is the one being created into.
///
@@ -529,14 +549,15 @@ struct LaneView: View {
private var slots: [LaneSlot] {
var result = renderedCards.map(LaneSlot.card)
let shadowPosition = cardProposal.map { min(max(0, $0), result.count) }
let run = shadowRun
let shadowPosition = run.map { min(max(0, $0.position), result.count) }
var placeholderPosition: Int?
if let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id {
placeholderPosition = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
?? result.count
}
let heights = drops.session.cardHeights
let heights = run?.heights ?? []
if let shadowPosition {
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
result.insert(contentsOf: shadows, at: shadowPosition)
@@ -604,6 +625,16 @@ struct LaneView: View {
// MARK: - Lane slots
/// The run of shadows a lane opens for whichever session is proposing into it where it starts and
/// what each shadow is worth in height.
///
/// `Equatable` because it is the reflow's animation key: within a session the heights never change,
/// so the value moves exactly when the proposal does.
private struct ShadowRun: Equatable {
var position: Int
var heights: [CGFloat]
}
/// What a lane's masonry lays out its cards, plus at most one pseudo-card.
///
/// The placeholder is not a `Card` and never will be: it has no disk presence and no UUID until its
@@ -704,9 +735,16 @@ private struct CardFaceView: View {
.padding(.leading, stripeWidth)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
// highlights while hovered" (04-interactions.md Drag and drop), and the accent stroke is
// already this face's vocabulary for "this one". The hover draws it a touch heavier so a
// hovered card that is *also* selected still reads as the target.
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
.strokeBorder(
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
lineWidth: isFileHovered ? 2.5 : 1.5
)
)
.contentShape(Rectangle())
// **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's
@@ -961,6 +999,14 @@ private struct CardFaceView: View {
store.selection.liveness == .live && store.selection.ids.contains(card.id)
}
/// Whether an external Finder file drag is hovering **this** card the attach highlight
/// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
/// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
/// to one implementation (`BoardDrops`).
private var isFileHovered: Bool {
drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == card.id
}
private var isRenaming: Bool {
store.transient.renameEditor?.targetID == card.id
}
+3 -1
View File
@@ -21,7 +21,9 @@ import SwiftUI
/// snapshot's live lanes;
/// - it is **never a drop target** "no move or paste ever targets the trash" (04-interactions.md
/// The trash), so nothing here declares an `onDrop` at all and a session over the column falls
/// through to the strip's own target, where a cursor over no lane simply holds the proposal;
/// through to the strip's own target, where a cursor over no lane simply holds the proposal. That
/// covers **Finder file drops** too, which "on tombstoned cards are inert" ( The trash): a file
/// session over this column resolves to no lane, so no row highlights and a release refuses;
/// - it has **no new-card button**: nothing is created in the trash.
///
/// ### No editing in the trash