import AppKit import SwiftUI import UniformTypeIdentifiers // This file is a **pure extraction** from `BoardDrops.swift`, moved verbatim the moment a second // window needed it: the card window's whole-window attachment drop (05-card-window.md ▸ Attachments) // runs the same two rules this file holds — how a dropped `NSItemProvider` becomes a file URL, and // which half of a Finder drag is a file rather than a folder. Both windows import through // `BoardStore.importAttachments`, so a second copy of either rule would be a second folder-refusal // semantics able to disagree with the board's about what a package is. // // Nothing here knows about a board's geometry, its lanes, or its drag session — which is what made // the move mechanical. `FileDropTarget.Landing`, the one board-shaped type `land` still takes, stays // in `BoardDrops.swift`: the card window passes `.attach(cardID:)` and the board resolves its own. // 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: - Files in, folders out /// What a Finder drag is carrying and where it lands — **the folder refusal, both halves** /// (04-interactions.md ▸ Drag and drop: "Folders are refused at hover … the attachment model is flat /// top-level files, and the importer refuses directories by design"). /// /// ### Why the payload is read twice /// /// The rule is a *hover* rule, and hover has only the providers' declared types to go on: a file URL /// is not resolved until the drop, and resolving one during hover is neither offered nor affordable. /// So the drag is read twice, and the two reads have different jobs: /// /// - **At hover, from `registeredTypeIdentifiers`** — Finder registers the concrete UTI beside /// `public.file-url`, so a folder announces itself as `public.folder` before anything is loaded. /// That is what makes a folders-only drag refuse *at the cursor*: no highlight, no shadows, the /// incompatible-payload read (`BoardDropContext.acceptsFileDrop`). /// - **At the drop, from the URLs themselves** — `FinderDrop.partition`, which is a filesystem fact /// rather than a declaration and therefore the authority. It is what actually decides what gets /// written. /// /// **Unknown at hover is treated as a file**, deliberately: a provider that registers only /// `public.file-url` and no concrete type — a synthetic drag, or an unusual source — cannot be /// classified until its URL resolves, and the optimistic read means such a drag still engages, still /// shows its shadows, and is sorted out authoritatively at the drop. The pessimistic read would make /// an ordinary file drag silently dead, which is the far worse failure. /// /// **A package is a directory.** Conformance to `public.directory` — not equality with /// `public.folder` — is the test, so a `.app`, an `.rtfd`, or any other bundle is refused exactly as /// a plain folder is: the flat top-level attachment model has no more room for one than for the /// other, and `isDirectory(at:)` says the same thing at the drop. enum FinderDrop { // MARK: The hover read — declared types /// Whether a provider's registered types describe a directory (folders and packages alike). /// /// An identifier the system does not know, and an empty list, are *not* directories: this is the /// optimistic side of the unknown-at-hover rule above. nonisolated static func isDirectory(typeIdentifiers: [String]) -> Bool { typeIdentifiers.contains { identifier in UTType(identifier)?.conforms(to: .directory) ?? false } } /// How many of these providers are importable — the file count every hover-time proposal is /// sized by, and `0` is the refusal that keeps a folders-only drag from ever engaging. /// /// `@MainActor` for the same reason `FileDropLoading.url(from:)` is: an `NSItemProvider` off a /// `DropInfo` is not `Sendable`, so it stays on the actor it arrived on. @MainActor static func importableCount(_ providers: [NSItemProvider]) -> Int { providers.filter { !isDirectory(typeIdentifiers: $0.registeredTypeIdentifiers) }.count } /// How many shadows the create path draws — **one nominal-height shadow per incoming file** /// (04-interactions.md ▸ Drag and drop, settled 2026-07-28: the multi-drag precedent), **floored /// at one**: "when macOS withholds item counts during hover the count floors at one shadow, the /// commit unaffected". /// /// The floor is a *hover* concession and nothing more. A drag whose providers cannot be counted /// still opens a slot the user can aim at, and the write is `FinderDrop.land`'s — resolved URLs, /// partitioned against the filesystem — so one shadow standing in for three files costs the drop /// nothing. It is read from the drag 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. @MainActor static func shadowCount(_ providers: [NSItemProvider]) -> Int { max(1, importableCount(providers)) } // MARK: The drop read — the filesystem /// Whether `url` is a directory, as the filesystem answers it — the authoritative read. /// /// `resourceValues` first (the real answer, packages included), `fileExists` as the fallback for /// a URL whose resource values cannot be read, and the purely lexical `hasDirectoryPath` last, /// for a source that has already vanished between the drag and the drop. nonisolated static func isDirectory(at url: URL) -> Bool { if let flag = try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory { return flag } var isDirectory: ObjCBool = false if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) { return isDirectory.boolValue } return url.hasDirectoryPath } /// Splits a dropped set into what will be written and what will be named as skipped, preserving /// input order in both halves — the create path mints its cards in drop order, and the order the /// user dropped in is the only order there is. nonisolated static func partition(_ urls: [URL]) -> (files: [URL], folders: [URL]) { var files: [URL] = [] var folders: [URL] = [] for url in urls { if isDirectory(at: url) { folders.append(url) } else { files.append(url) } } return (files, folders) } // MARK: The write /// The drop's **write half**: the files land where the highlight or the shadows showed, and the /// folders are named in a loss row rather than attempted. /// /// **No card is ever minted for an import that cannot succeed** (04-interactions.md): the folders /// are gone before `createCards` sees the list, so the create path only ever fires with files — /// "the mint-fail-remove dance is gone" for this reason, not because the store stopped doing it. /// `BoardStore.createCards` still removes a card whose import failed for a *genuine* reason (an /// unreadable source, a full disk), and `BoardWriter.importAttachments` still refuses a directory /// outright: that throw stays as the model layer's backstop for every other caller, and folders /// simply never reach it from here. /// /// **Zero files is a valid arrival, and writes nothing.** The hover refusal means a folders-only /// drag normally never gets here at all; a payload whose types were unknown at hover can, and the /// honest answer is the loss row alone — no write, no empty card, nothing to undo. /// /// A **loss row, not a failure one-shot** (02-architecture.md § the banner vocabulary): nothing /// failed here. The files the user dropped arrived; the folders were never things this app could /// take, and `postSkippedFolders` is silent at zero, so an all-files drop says nothing at all. @MainActor static func land(_ urls: [URL], landing: FileDropTarget.Landing, into store: BoardStore) { let (files, folders) = partition(urls) if !files.isEmpty { switch landing { case let .attach(cardID): store.importAttachments(files, toCard: cardID) case let .create(laneID, index): store.createCards(fromFiles: files, inLane: laneID, at: index) } } store.banners.postSkippedFolders(count: folders.count) } }