The card's complete file inventory: compact QuickLook-thumbnail rows over Card.attachments — no reference tracking, subfolders tolerated and unsurfaced — with a quiet header add affordance and the drop hint empty state. The whole window is the file-drop surface, Edit mode included (the editor's drag types were already filtered; now tested), sharing the board's folder-refusal semantics literally: FinderDrop moved verbatim into its own file so both windows run the same partition and loss row. Dragged text still lands at the caret and is inert elsewhere — the window delegate accepts file payloads only. Rows open on double-click or Return, drag out their file URL, and Remove is a bracketed write through FileManager.trashItem — the system Trash, never a hard delete, returning the in-Trash URL so the promise is testable; the attachment listing is the guard, so traversal and subfolder names refuse in one line. Keyboard-native per 05: the section is one Tab stop, arrows walk rows by name, Space toggles the shared QuickLook panel, Backspace removes. File > Add Attachment (shift-cmd-A) comes alive through the same import path. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
131 lines
7.1 KiB
Swift
131 lines
7.1 KiB
Swift
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
|
|
}
|
|
}
|