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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user