Build the attachments sidebar section
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
This commit is contained in:
@@ -606,177 +606,10 @@ 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: - 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)
|
||||
}
|
||||
}
|
||||
// The Finder-drag payload rules this file used to hold — `FileDropLoading` and `FinderDrop` — moved
|
||||
// to `Kanban/UI/FinderDrop.swift` verbatim when the card window's whole-window attachment drop
|
||||
// (05-card-window.md ▸ Attachments) became their second caller. Everything below still calls them by
|
||||
// the same names; only their file changed.
|
||||
|
||||
// MARK: - Drop delegates
|
||||
|
||||
|
||||
Reference in New Issue
Block a user