Files
lanework/Kanban/UI/Card/CardWindowDrop.swift
rzen 46397c740e 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
2026-07-28 11:52:37 -04:00

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
}
}