import SwiftUI import UniformTypeIdentifiers // MARK: - The payload split /// **Drop precedence is split by payload** (05-card-window.md ▸ Attachments, settled): "file drops /// import as attachments anywhere in the window — Edit mode included, the text editor never /// intercepts a file drop; dragged *text* lands in the Edit editor at the caret within its bounds as /// ordinary insertion, and is inert elsewhere in the window." /// /// ### The split is enforced from both ends, and neither end knows about the other /// /// - **The editor declines files.** `CardBodyTextView.acceptableDragTypes` filters `public.file-url` /// (and the Carbon-era `NSFilenamesPboardType` AppKit still puts beside it) out of what the text /// view registers for, so AppKit's drag hit-test walks *past* the text view to the window-level /// drop target above it. That is the whole of "the text editor never intercepts a file drop" — a /// deregistration, not a handler that re-dispatches. /// - **The window declines text.** The delegate below is attached with `.onDrop(of: [.fileURL], …)` /// and its `validateDrop` asks the predicate here, so a text drag never matches it at all and /// AppKit offers the drag to the text view instead — which takes it as an ordinary insertion at /// the caret, `NSTextView`'s own behaviour, untouched. Outside the editor's bounds nothing accepts /// it, which is 05's "inert everywhere else". /// /// Two deregistrations meeting in the middle, rather than one arbiter deciding: there is no third /// state where both accept, and no ordering between them to get wrong. enum CardWindowDrop { /// Whether a drag's declared types make it a **file** payload — the window's — as opposed to /// text, which is the editor's. /// /// Conformance to `public.file-url`, not equality: a Finder drag registers the concrete type /// (`public.png`) beside the file URL, and a synthetic drag may register a subtype of it. A URL /// dragged out of a browser is `public.url`, which does *not* conform to `public.file-url` — so /// it stays the editor's, exactly as the rule says a dragged link should. nonisolated static func isFilePayload(typeIdentifiers: [String]) -> Bool { typeIdentifiers.contains { identifier in UTType(identifier)?.conforms(to: .fileURL) ?? false } } /// Whether the window will import this drag: **at least one payload that is a file and not a /// directory.** /// /// The folder half is `FinderDrop.isDirectory(typeIdentifiers:)` — the board's own hover read, /// called rather than re-derived, so "a package is a directory" cannot come to mean two things /// in one app. A folders-only drag answers `false` here and is therefore an incompatible payload /// at the cursor: no highlight, a refusal cursor, and nothing written. A mixed drag answers /// `true`, imports its files, and names the folders it skipped in a loss row (`FinderDrop.land`) /// — the board-side refusal semantics, applied here because the importer they protect is the /// same one. nonisolated static func accepts(payloads: [[String]]) -> Bool { payloads.contains { types in isFilePayload(typeIdentifiers: types) && !FinderDrop.isDirectory(typeIdentifiers: types) } } } // MARK: - The window-wide drop delegate /// **The drop surface is the whole window** (05-card-window.md ▸ Attachments) — one delegate over /// the card window's entire content area, body column and sidebar alike, Edit mode and the /// raw-source outlet included. /// /// It is attached at the top of `CardWindowView`'s body rather than to the attachments section, /// which is what the design asks for and what makes the rule cheap: there is exactly one drop region /// in this window, so there is no single-target-dispatch problem to solve here at all (contrast the /// board, where lane, card and strip regions overlap — `BoardDrops`). The text view is *inside* this /// region and simply does not accept the file types, so the drag falls through to it. /// /// The write is `BoardStore.importAttachments(_:toCard:)`, reached through `FinderDrop.land` — the /// same store method, the same Finder-style collision rename, the same banners as the board window's /// drop onto a card face. Nothing about importing an attachment is re-implemented here; only *which /// card* is decided differently, and in a card window that is not a decision at all. struct CardWindowDropDelegate: DropDelegate { let store: BoardStore let cardID: ItemID /// **The mutating-gesture rule, applied to the one gesture that arrives from outside the app** /// (`BoardDropContext.acceptsFileDrops`): under the read-only lock a file drop refuses at the /// window, with no proposal — the board's own posture, and the same predicate. The board's /// second clause (an inline title editor focused) has no card-window counterpart: this window's /// editors are the body and the raw-source outlet, and 05 puts a file drop *through* both of /// them on purpose. private var acceptsFileDrops: Bool { !store.isReadOnly } func validateDrop(info: DropInfo) -> Bool { guard acceptsFileDrops else { return false } return CardWindowDrop.accepts( payloads: info.itemProviders(for: [.fileURL]).map(\.registeredTypeIdentifiers) ) } /// Always `.copy` for a payload we will take — importing a file leaves the original where it /// was, which is what the badge should say — and `.cancel` for one we will not. func dropUpdated(info: DropInfo) -> DropProposal? { DropProposal(operation: validateDrop(info: info) ? .copy : .cancel) } /// Commits the drop. `BoardDropContext.commitFileDrop`'s shape, minus the board's hover state: /// a provider's file URL loads asynchronously (it is never synchronous for a Finder drag), the /// store call happens back on the main actor, and the resolved URLs — not the declared types — /// are the authority on what is a folder. func performDrop(info: DropInfo) -> Bool { guard acceptsFileDrops else { return false } let providers = info.itemProviders(for: [.fileURL]) guard !providers.isEmpty else { return false } let store = store let cardID = cardID 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, so only the ones that opened are closed again. let scoped = urls.filter { $0.startAccessingSecurityScopedResource() } defer { for url in scoped { url.stopAccessingSecurityScopedResource() } } FinderDrop.land(urls, landing: .attach(cardID: cardID), into: store) } return true } }