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:
@@ -1564,6 +1564,117 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Finder file drops
|
||||
|
||||
// The writes an external Finder file drag performs (04-interactions.md ▸ Drag and drop, "Files
|
||||
// from Finder"): onto a card the files join its `attachments/`, onto lane empty space they become
|
||||
// one card each. The gesture's half — which card, which slot — is `BoardDropContext`'s; these are
|
||||
// ordinary store writes, with the drop commits' own rules above (one `performWrite` bracket per
|
||||
// gesture; a vanished or tombstoned destination is a silent no-op, the reload being the
|
||||
// authority; failures are the banner's).
|
||||
|
||||
/// Copies `urls` into `cardID`'s `attachments/` — the drop-on-a-card half.
|
||||
///
|
||||
/// **Liveness is ancestor-walked** (`liveItem`): a card under a tombstoned lane renders nowhere,
|
||||
/// so it is as gone as a deleted one, and a drop on a target that vanished under the gesture
|
||||
/// writes nothing at all. That is also the whole of "Finder file drops on tombstoned cards are
|
||||
/// inert" (04-interactions.md ▸ The trash) on the write side — the gesture refuses to propose one
|
||||
/// in the first place, and this refuses to serve one that slipped through a reload.
|
||||
///
|
||||
/// A lane id is refused for the same reason a lane folder is: attachments belong to cards.
|
||||
/// Multi-file, any type, and a name already taken is renamed Finder-style rather than
|
||||
/// overwritten — all `BoardWriter.importAttachments`', including its failure shape: the first
|
||||
/// failing file stops the batch and banners naming it, and everything already copied stays.
|
||||
public func importAttachments(_ urls: [URL], toCard cardID: ItemID) {
|
||||
guard !urls.isEmpty,
|
||||
let item = Self.liveItem(cardID, in: snapshot),
|
||||
let card = item.cardID
|
||||
else { return }
|
||||
|
||||
let folder = rootURL
|
||||
.appendingPathComponent(item.laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(card.rawValue, isDirectory: true)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
_ = try BoardWriter.importAttachments(urls, intoCard: folder)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates one card per file at `index` in `laneID`, each titled with its filename minus the
|
||||
/// extension and carrying that file as its attachment — the drop-on-empty-space half.
|
||||
///
|
||||
/// **Ordinary store writes, with no drop-only path**: a fresh GUID and an inserted rank per card
|
||||
/// (`Ranks.insertionRanks`, compacting and placing again when midpoint precision is exhausted,
|
||||
/// exactly as `moveCards` does), then the m2 import machinery for the file. The whole batch is one
|
||||
/// `performWrite` bracket, so a five-file drop rounds back as one reload and one commit.
|
||||
///
|
||||
/// **The title follows the empty-title rules**: a name that trims to nothing — a dotfile whose
|
||||
/// stem is blank, a file called `" .png"` — writes no `title` key at all rather than an empty
|
||||
/// string, since a missing key is the untitled state and `""` would be a real, blank title
|
||||
/// (01-storage-format.md § Frontmatter).
|
||||
///
|
||||
/// **Partial failure is honest, and leaves no half-made card.** The batch stops at the first file
|
||||
/// that cannot be imported — a folder rather than a file, an unreadable source — which banners
|
||||
/// naming it; the cards already made keep their files, matching `importAttachments`' own
|
||||
/// "everything already imported stays landed". The card whose import failed is removed again
|
||||
/// before the throw: it was minted moments earlier in this same bracket and holds nothing but
|
||||
/// what this call put there, and "creating-then-abandoning never leaves an empty card behind"
|
||||
/// (04-interactions.md ▸ Grammar) is the rule it would otherwise break.
|
||||
public func createCards(fromFiles urls: [URL], inLane laneID: ItemID, at index: Int) {
|
||||
guard !urls.isEmpty,
|
||||
let lane = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted })
|
||||
else { return }
|
||||
|
||||
let rendered = lane.cards.filter { !$0.isDeleted }
|
||||
let target = min(max(0, index), rendered.count)
|
||||
let root = rootURL
|
||||
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(
|
||||
amongVisible: rendered.map(\.order), at: target, count: urls.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
ranks = Ranks.insertionRanks(
|
||||
amongVisible: Ranks.renumbered(count: rendered.count), at: target, count: urls.count)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
|
||||
for (url, rank) in zip(urls, ranks) {
|
||||
// Create then place, `commitPlaceholder`'s pair: the Writer's create appends after the
|
||||
// visible siblings by contract, and the rank rides its same-parent degenerate reorder
|
||||
// inside this same bracket rather than widening the create's signature.
|
||||
let id = try BoardWriter.createCard(inLane: laneFolder, title: Self.cardTitle(forFile: url))
|
||||
let folder = laneFolder.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||
do throws(BoardWriteError) {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: folder,
|
||||
toParent: laneFolder,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: rank
|
||||
)
|
||||
_ = try BoardWriter.importAttachments([url], intoCard: folder)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: folder)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The title a dropped file's card takes: **the filename without its extension**
|
||||
/// (04-interactions.md ▸ Drag and drop), or `nil` — no `title` key — when that trims to nothing.
|
||||
///
|
||||
/// The split is `URL`'s own, which is also Finder's: an extension-less name keeps all of itself,
|
||||
/// and a multi-dot name loses only the last component (`archive.tar.gz` → `archive.tar`), matching
|
||||
/// the collision-rename rule the same file's attachment goes through.
|
||||
nonisolated static func cardTitle(forFile url: URL) -> String? {
|
||||
let stem = url.deletingPathExtension().lastPathComponent
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return stem.isEmpty ? nil : stem
|
||||
}
|
||||
|
||||
// MARK: - Within-lane sort
|
||||
|
||||
/// The lane and the new card ordering one ⌥⌘↑/⌥⌘↓ press would produce, or `nil` when the press
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user