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:
@@ -0,0 +1,130 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user