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:
2026-07-28 11:52:37 -04:00
parent 40c0a75c24
commit 46397c740e
16 changed files with 1823 additions and 214 deletions
+15 -4
View File
@@ -322,12 +322,15 @@ private final class DuplicateCancellation {
/// will drop) disables rather than falling back to the root: revealing the wrong thing is worse
/// than nothing, and only a genuinely empty selection means "the board".
///
// m6-card-window: the item's third scope the card's folder, or the selected attachment's file
// when the attachments section is focused adds a focused value and a branch here; the two below
// do not move.
/// **The card-window scope is the third branch**, and it is the one the attachment row's context
/// menu twins (11-command-nexus.md Context menus): "card window: the card's folder the selected
/// attachment's file instead when the attachments section is focused". The rule itself is
/// `CardAttachments.revealURLs`, so the menu row and the row's own Reveal cannot disagree about what
/// "the selected attachment" means.
struct RevealInFinderCommand: View {
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.cardAttachments) private var attachments
@FocusedValue(\.welcomeSelection) private var selection
var body: some View {
@@ -338,7 +341,8 @@ struct RevealInFinderCommand: View {
}
/// What the item would reveal, and therefore whether it is enabled one answer for both, the
/// codebase's usual shape. The board in front wins; the welcome branch stands when no board is.
/// codebase's usual shape. The board in front wins; the card-window branch stands when a card
/// window is; the welcome branch stands when neither is.
private var urls: [URL] {
if let store {
let ids = store.selection.ids
@@ -346,6 +350,13 @@ struct RevealInFinderCommand: View {
return TrashModel.paths(of: ids, on: store.selection.liveness, in: store.snapshot)
.map { $0.folder(under: store.rootURL) }
}
if let attachments {
return CardAttachments.revealURLs(
cardFolder: attachments.cardFolder,
selectedURL: attachments.selectedURL,
isSectionFocused: attachments.isFocused
)
}
guard let selection, selection.canReveal, let url = selection.url else { return [] }
return [url]
}
+58 -1
View File
@@ -139,6 +139,15 @@ struct CardWindowHost: View {
/// (`CardRawSourceSession`). Beside the body handle rather than inside it: the two are different
/// scopes, and the Edit Body row reads both.
@State private var rawSource = CardRawSourceSession()
/// This window's attachments section the listing, the keyboard selection, and the two writes
/// it starts (05-card-window.md Attachments). Window-scoped for `CardBodyPresentation`'s
/// reason: two card windows on one board have two different selections, and the menu bar reaches
/// the frontmost one through the focus system.
@State private var attachments = CardAttachments()
/// This window's thumbnail memory. Held here rather than in the section so it survives every
/// snapshot the store applies a cache that died with the view would regenerate every thumbnail
/// on every reload (`AttachmentThumbnailCache`).
@State private var thumbnails = AttachmentThumbnailCache()
/// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not
/// flush; cleared by the resolution that lets the close resume.
@State private var isClosePending = false
@@ -210,6 +219,9 @@ struct CardWindowHost: View {
// reads it too "View Edit Body (E) disables while source mode is active"
// (05-card-window.md Raw source outlet).
.focusedSceneValue(\.cardRawSource, rawSource)
// File Add Attachment (A) and File Reveal in Finder's card-window scope reach the
// frontmost card window the same way (11-command-nexus.md).
.focusedSceneValue(\.cardAttachments, attachments)
// The raw-source outlet's detailed alert, presented over this window a validation
// refusal on Apply, or a file that could not be opened as source. It hangs *here* rather
// than inside the editor because the second of those fires while source mode is still
@@ -266,12 +278,32 @@ struct CardWindowHost: View {
rawSource: rawSource,
// "Under the read-only lock the controls disable in place an in-content mutation
// menu validation can't reach" (05 Preview). The checkbox is that control, and
// the store's own lock is the whole predicate.
// the store's own lock is the whole predicate and it is the attachments section's
// predicate too ("the attachment row's /Remove shares the posture").
isEditable: !store.isReadOnly,
attachments: attachments,
thumbnails: thumbnails,
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
onToggleTask: { offset, checked in
store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked)
}
)
// **The listing is the snapshot's, republished** `Card.attachments`, which the loader
// fills from `attachments/`'s top-level files in Finder order. Every write in the
// section is bracketed, so the reload that refreshes this arrives by itself and the
// section never lists a directory of its own (05 Attachments; the one-way flow).
.onChange(of: placement.card.attachments, initial: true) { _, names in
attachments.names = names
}
.onChange(of: Self.cardFolder(root: store.rootURL, placement: placement), initial: true) { _, folder in
// Re-derived from the store's *current* root, `cardFolder`'s rule: a mid-session
// folder rename moves the board, and rows resolving against where it used to be
// would open nothing.
attachments.cardFolder = folder
}
.onChange(of: store.isReadOnly, initial: true) { _, locked in
attachments.isEditable = !locked
}
} else {
// Nothing to render and nothing worth animating: this window is on its way out.
Color.clear
@@ -386,6 +418,31 @@ struct CardWindowHost: View {
store: store,
cardID: cardID
)
Self.configureAttachments(attachments, store: store, cardID: cardID)
}
/// Points the attachments section at its card **the one place Add Attachment and Remove
/// learn which card they act on** (05-card-window.md Attachments).
///
/// Both seams are the store's own bracketed methods, unchanged: `importAttachments(_:toCard:)`
/// is the *same* call the board window's Finder drop makes, so a file added through A, through
/// the header's plus, through a drop anywhere in this window, and through a drop on the card's
/// face on the board all take one path one collision rename, one set of banners, one commit
/// shape. There is deliberately no card-window import of its own to keep in step with it.
///
/// The store is captured **weakly**, `configureSession`'s rule: a panel still running after the
/// board window has gone should write nothing rather than resurrect a released store.
///
/// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason:
/// the target resolution is invisible in a running window until it is wrong, and this shape is
/// what lets a test drive the real wiring rather than a re-typed copy of it.
static func configureAttachments(_ attachments: CardAttachments, store: BoardStore, cardID: ItemID) {
attachments.importFiles = { [weak store] urls in
store?.importAttachments(urls, toCard: cardID)
}
attachments.removeFile = { [weak store] name in
store?.removeAttachment(named: name, fromCard: cardID)
}
}
/// Points the raw-source outlet at its card the outlet's three seams (05-card-window.md Raw
+4 -21
View File
@@ -52,27 +52,10 @@ struct SaveAsTemplateCommand: View {
}
}
// MARK: - File Add Attachment
/// File Add Attachment (A) card window only (11-command-nexus.md).
///
// m6-card-window: the menu-bar twin of the attachments section's quiet add affordance
// (05-card-window.md § Attachments) and of a whole-window Finder file drop. Validation will be scope
// alone a card window in front, the read-only lock aside `BoardInfoCommand`'s shape for its own
// scope-only item.
//
// m5-context-menus, m6-card-window: the attachment row's own context menu is a second thing this
// milestone owes Open, Remove (system Trash), Reveal in Finder (11-command-nexus.md Context
// menus' Attachment row), twinning the focused section's grammar keys (Return open / remove 05
// Attachments) and Reveal in Finder's attachments-focused scope, exactly the way this milestone's
// card and lane menus twin their own grammar and menu-bar commands: no new store method, no parallel
// implementation. There is no row view to hang a `.contextMenu` off yet, so nothing scaffolds here
// beyond this marker.
struct AddAttachmentCommand: View {
var body: some View {
FutureCommand(title: "Add Attachment…", key: "a", modifiers: [.shift, .command])
}
}
// File Add Attachment (A) was the scaffold here and is now live, beside the focused value it
// reads (`AddAttachmentCommand`, in `CardAttachments.swift`) the diff this file predicts: the
// title and the chord did not move, the validation and the action filled in. The attachment row's
// context menu (Open / Remove / Reveal in Finder) shipped with it, on the row view that now exists.
// MARK: - Edit Find Next / Find Previous
+7
View File
@@ -631,6 +631,13 @@ public final class BannerCenter {
"Couldn't import '\(filename)'"
case .listAttachments:
"Couldn't read this card's attachments"
case let .removeAttachment(filename):
// **Finder's phrasing, deliberately** and the one place in this app that is allowed
// it. The naming constraint above reserves "move to the Trash" for the *system* Trash,
// and this is the operation that uses it: the file really did go (or fail to go) where
// Finder's own sends things, so saying anything else "remove", "delete" would
// describe the board's quasi-lane instead and promise the wrong recovery.
"Couldn't move '\(filename)' to the Trash"
case .renumberChildren:
"Couldn't renumber cards"
case let .relocateLooseFile(filename):
+33
View File
@@ -2067,6 +2067,39 @@ public final class BoardStore {
}
}
// MARK: - Removing an attachment
/// Moves one of a card's attachments to the **system** Trash the card window attachment row's
/// Remove, its twin, and nothing else (05-card-window.md Attachments).
///
/// **An ordinary bracketed write, which is the whole point of it being here** rather than a
/// `FileManager` call in the view: it mutates the card's folder, so the churn has to round back
/// as one *app-mediated* reload (the echo the watcher would otherwise read as a foreign edit),
/// it has to refuse under the read-only lock like every other mutation (`performWrite`'s gate),
/// and its failures have to reach the banner strip like every other write's. On git boards it
/// is also one commit, for free, for the same reason.
///
/// The guards are `importAttachments`' exactly, and its inverse in every way: **liveness is
/// ancestor-walked** (`liveItem`), so a card under a tombstoned lane is as gone as a deleted one
/// and its attachments are not removable from a window that is dismissing itself in the same
/// breath; a lane id is refused because attachments belong to cards. Which *file* may go is
/// `BoardWriter.removeAttachment`'s listing check, and a name that is no longer there is a
/// silent no-op rather than a failure the reload is the authority on what the card has.
public func removeAttachment(named name: String, fromCard cardID: ItemID) {
guard !name.isEmpty,
let item = Self.liveItem(cardID, in: snapshot),
let card = item.cardID
else { return }
let folder = rootURL
.appendingPathComponent(item.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.rawValue, isDirectory: true)
try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.removeAttachment(named: name, fromCard: folder)
}
}
// MARK: - The loose-file carve-out
/// Moves every loose file the last applied snapshot found beside a card's `index.md` into that
+79 -4
View File
@@ -1539,6 +1539,67 @@ public enum BoardWriter: Sendable {
return BoardLoader.attachmentNames(in: cardFolder)
}
/// Moves one of a card's attachments to the **system** Trash the attachment row's Remove and
/// its twin (05-card-window.md Attachments: "context menu Open / Reveal in Finder / Remove
/// (moves to the **system** Trash, never hard-deletes )").
///
/// ### `trashItem`, and never `removeItem`
///
/// The design says *system* Trash and means it: a board's own tombstone quasi-lane is a different
/// trash with a different vocabulary (03-board-ui.md § Trash's naming constraint), and an
/// attachment removed here is recoverable exactly the way a file dragged out of a Finder window
/// is by the user, in Finder, with no help from this app. `FileManager.trashItem` is that
/// sentence; `removeItem` would be a hard delete of the user's own file, which this app never
/// does to an attachment.
///
/// ### The listing is the guard
///
/// `name` is checked against `BoardLoader.attachmentNames(in:)` **the very set the sidebar
/// shows** before anything is touched, which is what makes the whole class of "the caller
/// passed something else" unreachable in one line rather than four: a path (`../index.md`), a
/// subfolder, a hidden file, a symlink, and the empty string are all simply not in the listing,
/// and none of them can be trashed through this call. It is also `relocateLooseFiles`' re-check
/// rule applied here the caller's name is re-read against disk at write time, not trusted from
/// whenever the row was drawn.
///
/// ### A name that is no longer there is not a failure
///
/// It returns `false` and writes nothing, `relocateLooseFiles`' rule again: "the reload is the
/// authority on what is there, and a file the user deleted between the walk and the write is not
/// a failure to report". A racing an external delete of the same file is exactly that race, and
/// a banner for it would name a removal the user got anyway.
///
/// - Returns: where the file now sits **inside the Trash**, or `nil` when there was nothing to
/// move. The URL is returned rather than discarded because it is the only proof this call
/// makes that the file still exists at all the difference between the promise ("never
/// hard-deletes") and a `removeItem` that would look identical from `attachments/`.
@discardableResult
public static func removeAttachment(
named name: String,
fromCard cardFolder: URL
) throws(BoardWriteError) -> URL? {
let operation = WriteOperation.removeAttachment(filename: name)
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
try checkIsUUIDShaped(cardFolder, operation: operation)
guard BoardLoader.attachmentNames(in: cardFolder).contains(name) else { return nil }
let fileURL = cardFolder
.appendingPathComponent(attachmentsFolderName, isDirectory: true)
.appendingPathComponent(name)
var trashedURL: NSURL?
do {
try FileManager.default.trashItem(at: fileURL, resultingItemURL: &trashedURL)
} catch {
throw BoardWriteError(
operation: operation,
path: fileURL.path,
reason: .io(message: "could not move file to the Trash: \(error.localizedDescription)")
)
}
return trashedURL as URL?
}
// MARK: - Move/copy pre-flight
/// The rank a moved or copied root lands on: the caller's explicit value a drop between
@@ -1725,6 +1786,18 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case importAttachment(filename: String)
case listAttachments
/// An attachment being moved to the **system** Trash the card window's attachment Remove and
/// its twin (05-card-window.md Attachments).
///
/// Its own case rather than a fold into `.delete`, on the vocabulary's standing reasoning and
/// then some: `.delete` is the board's *tombstone*, and this app has two trashes on purpose
/// "Finder's 'Move to Trash' phrasing is reserved for the system Trash; board deletion says
/// 'Delete'" (03-board-ui.md § Trash). A banner saying the app "couldn't delete 'shot.png'"
/// would claim the board's own trash took a hand in a file only Finder can give back.
/// `filename` is the attachment's name as the sidebar shows it.
case removeAttachment(filename: String)
case renumberChildren // order-maintenance sweep (compaction)
/// A loose file being moved out of a card folder into its `attachments/` the loose-file
@@ -1771,9 +1844,10 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case rawSource(title: String?)
/// Fills in the title once the Writer has read it off the document the operation is acting
/// on identity for the six cases with no title slot at all: `createBoard`/`createLane`/
/// `createCard` are minting a file, not reading one; `importAttachment`'s "title" is the
/// filename it already carries; `listAttachments` and `renumberChildren` name no single item.
/// on identity for the cases with no title slot at all: `createBoard`/`createLane`/
/// `createCard` are minting a file, not reading one; `importAttachment`, `removeAttachment` and
/// `relocateLooseFile` carry a filename, which is the name the user is looking at and the only
/// one their banner should say; `listAttachments` and `renumberChildren` name no single item.
/// Called once, right where the operation's `readDocument` succeeds `updateIndex` itself
/// (which covers every case that funnels through it: renumber, delete, restore, style, and
/// the tail end of move/copy) and the move/copy pre-flight, before the folder travels or the
@@ -1782,7 +1856,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
public func withTitle(_ title: String?) -> WriteOperation {
switch self {
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
.renumberChildren, .relocateLooseFile:
.removeAttachment, .renumberChildren, .relocateLooseFile:
self
case .move: .move(title: title)
case .reorder: .reorder(title: title)
@@ -1823,6 +1897,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .duplicateBoard(title): Self.phrase("duplicate board", title)
case let .importAttachment(filename): "import attachment '\(filename)'"
case .listAttachments: "list attachments"
case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash"
case .renumberChildren: "renumber children"
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
+4 -171
View File
@@ -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
+180
View File
@@ -0,0 +1,180 @@
import AppKit
import Observation
import QuickLookThumbnailing
// MARK: - AttachmentThumbnailKey
/// What a generated thumbnail is filed under: **which file, at which drawn size, as of which
/// bytes** (05-card-window.md Attachments "small QuickLook thumbnail (Finder-icon fallback)").
///
/// The third component is what makes the cache honest rather than merely fast. A card folder is a
/// live view over a directory anyone may write to (02-architecture.md), so a cache keyed on the path
/// alone would happily show a picture of a file that has since been replaced. Modification date
/// *and* size, because either alone misses a case: a same-second rewrite keeps the date, and an edit
/// that preserves the length keeps the size.
///
/// **Missing values are a legitimate key, not a refusal.** A file that cannot be stat'd it went
/// away between the listing and the render, a volume dropped keys as `(nil, nil)` and simply
/// misses on the next attempt, which is the ordinary "no thumbnail, show the icon" path rather than
/// a second error surface.
struct AttachmentThumbnailKey: Hashable, Sendable {
/// The half a *view* can name without touching the disk the path and the size it is drawn at.
///
/// The cache is looked up by this during a render and completed by the full key in a task,
/// which is the whole reason it is a type: a render must never `stat`, and a stale-check must
/// never be skipped.
struct Slot: Hashable, Sendable {
let path: String
let side: Int
/// The row's thumbnail is square and **buckets to whole points**: the row height derives
/// from the body font's metrics (`CardWindowMetrics`), which can land on a fraction, and a
/// thumbnail regenerated because the row grew by a third of a point would be a cache that
/// never hits.
init(path: String, side: CGFloat) {
self.path = path
self.side = max(1, Int(side.rounded()))
}
}
let slot: Slot
let modified: Date?
let size: Int64?
}
// MARK: - AttachmentThumbnailCache
/// One card window's thumbnail memory **per window**, held by `CardWindowHost` and read by the
/// attachments section beneath it.
///
/// ### Why the window
///
/// Per *row* would defeat the point: rows are rebuilt on every snapshot the store applies (a body
/// edit in another window, a watcher reload, a lane move), and a cache that died with the view would
/// regenerate every thumbnail each time. Per *app* would outlive the thing it is caching a card
/// window closing is the natural moment to forget its files. The window is the scope where "the
/// files I am looking at" is true.
///
/// ### What it does not do
///
/// It never watches, invalidates or evicts on change: the key carries the file's stamp, so a
/// rewritten file simply keys differently and the stale entry ages out under the cap below. There is
/// deliberately no invalidation path to keep honest the same posture `Card.attachments` takes.
///
/// This is the **minimal** re-creation of a cache the board face once had and that went with the
/// carousel (commit `1020d9f`, sole consumer): small square rows instead of page-sized pictures, and
/// nothing about paging, so the cap and the drawn size are both an order smaller.
@MainActor
@Observable
final class AttachmentThumbnailCache {
/// How many generated thumbnails one window keeps. A cap rather than unbounded growth because a
/// card may hold hundreds of attachments; a plain insertion-ordered drop rather than a recency
/// policy because a sidebar's access pattern is "the rows on screen", which is the recent set.
static let limit = 128
/// The resolved stamp for each drawn slot the render-time half of the lookup.
private var keys: [AttachmentThumbnailKey.Slot: AttachmentThumbnailKey] = [:]
private var images: [AttachmentThumbnailKey: CGImage] = [:]
/// Insertion order over `images`, for the cap.
private var order: [AttachmentThumbnailKey] = []
/// Keys QuickLook declined an unknown type, an unreadable one. Remembered so a
/// non-previewable attachment costs one generation attempt per version of itself rather than one
/// per redraw; the row shows its Finder icon and stops asking.
private var unpreviewable: Set<AttachmentThumbnailKey> = []
/// Finder icons, by path. **Deliberately outside observation** (`@ObservationIgnored`): this one
/// is filled *during* a render, because an icon is the fallback a row draws while its thumbnail
/// is still being made, and a tracked write there would invalidate the view that just read it.
/// Nothing depends on an icon changing `NSWorkspace` answers the same image for the same path
/// until the app is relaunched.
@ObservationIgnored private var icons: [String: NSImage] = [:]
// MARK: - Reading, during a render
/// This slot's thumbnail, or `nil` when there is not one *yet* the two dictionary reads a
/// render is allowed to do. A miss is not a failure; it is the icon fallback's cue.
func thumbnail(for slot: AttachmentThumbnailKey.Slot) -> CGImage? {
guard let key = keys[slot] else { return nil }
return images[key]
}
/// The file's Finder icon 05's "Finder-icon fallback", shown while a thumbnail is being
/// generated and kept for anything QuickLook cannot preview.
///
/// `icon(forFile:)` rather than an icon for the file's *type*, deliberately: it is what Finder
/// itself shows, custom icons and application bundles included, and an attachment that looks
/// different in Finder than in the sidebar would be the app disagreeing with the substrate it is
/// a view over.
func icon(forFileAt url: URL) -> NSImage {
if let cached = icons[url.path] { return cached }
let icon = NSWorkspace.shared.icon(forFile: url.path)
icons[url.path] = icon
return icon
}
// MARK: - Filling, off the render
/// Resolves this slot's key and generates its thumbnail if that key has none the whole of the
/// cache's write side, called from a row's `.task` and never from a body.
///
/// Both halves run **off the main actor** (`nonisolated`): the `stat` because a render is
/// waiting on this task, and the generation because it is a round trip to a QuickLook extension
/// in another process. Only `CGImage` crosses back, which is `Sendable`; the representation the
/// generator hands out is not, and never leaves the function that made it.
func load(_ slot: AttachmentThumbnailKey.Slot, url: URL, scale: CGFloat) async {
let key = await Self.resolve(slot, url: url)
keys[slot] = key
guard images[key] == nil, !unpreviewable.contains(key) else { return }
let side = CGFloat(slot.side)
guard let image = await Self.generate(url: url, size: CGSize(width: side, height: side), scale: scale) else {
unpreviewable.insert(key)
return
}
remember(image, for: key)
}
private func remember(_ image: CGImage, for key: AttachmentThumbnailKey) {
if images.updateValue(image, forKey: key) == nil {
order.append(key)
}
while order.count > Self.limit {
images.removeValue(forKey: order.removeFirst())
}
}
private nonisolated static func resolve(
_ slot: AttachmentThumbnailKey.Slot,
url: URL
) async -> AttachmentThumbnailKey {
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
return AttachmentThumbnailKey(
slot: slot,
modified: values?.contentModificationDate,
size: values?.fileSize.map(Int64.init)
)
}
/// One QuickLook request, at the row's own size. `.thumbnail` alone rather than `.all`: the icon
/// representations QuickLook would fall back to are exactly what `icon(forFileAt:)` already
/// draws, and taking them here would cache a decorated icon under a thumbnail's key and never
/// try for a real one again. `iconMode` is off for the same reason the row wants the picture,
/// not a page-curled document.
private nonisolated static func generate(url: URL, size: CGSize, scale: CGFloat) async -> CGImage? {
let request = QLThumbnailGenerator.Request(
fileAt: url,
size: size,
scale: scale,
representationTypes: .thumbnail
)
request.iconMode = false
guard let representation = try? await QLThumbnailGenerator.shared.generateBestRepresentation(for: request)
else { return nil }
return representation.cgImage
}
}
+241
View File
@@ -0,0 +1,241 @@
import AppKit
import Observation
import SwiftUI
// MARK: - The window's attachments, as a handle
/// One card window's attachments section, reduced to what things *outside* it need: which files
/// there are, which row the keyboard is on, and the two writes the section can start
/// (05-card-window.md Attachments).
///
/// `CardBodyPresentation`'s shape and for its reason one per window, `@State` in the host,
/// published through the focus system so **menu items** (File Add Attachment, A; File Reveal
/// in Finder's third scope) can reach the frontmost card window without anyone keeping a
/// which-window-is-key register. It is deliberately not on `BoardStore`: the store is the *board's*,
/// shared by every window on it, and two card windows open on two cards of one board have two
/// different selections.
///
/// ### What it is not
///
/// It is **not** the listing's source of truth. `names` is republished from every snapshot the store
/// applies (`Card.attachments`, which the loader fills from `attachments/`'s top-level files in
/// Finder order), so the section shows what the last reload found and nothing else the one-way
/// flow, with no second listing able to disagree with the board face's chip. Nothing here reads a
/// directory; nothing here writes a file. Both writes go out through the seams below, which the host
/// fills with the store's own bracketed methods.
@MainActor
@Observable
public final class CardAttachments {
/// The card's own folder `<root>/<lane>/<card>`. `nil` until the window has joined its board,
/// which is also exactly while there is nothing to add an attachment *to*.
public var cardFolder: URL?
/// The files the section shows, in Finder order `Card.attachments`, straight from the
/// snapshot. **Every top-level file of `attachments/`**, body-embedded ones included: "the
/// section is the card's complete file inventory, no reference-tracking magic" (05).
public var names: [String] = [] {
didSet { selected = Self.settle(selected, was: oldValue, is: names) }
}
/// Whether the section's mutations are offered at all `!store.isReadOnly`. Under the lock the
/// add affordance, Remove and disable in place, which is 02-architecture.md's every-entry-point
/// predicate applied to this section (05 names it: "the attachment row's /Remove shares the
/// posture").
public var isEditable = false
/// Which row the keyboard is on, by name names are unique within one folder, so a name is a
/// stabler identity than an index across a reload that inserted a file above it.
public var selected: String?
/// Whether the section currently holds keyboard focus. Read by File Reveal in Finder, whose
/// card-window scope is "the card's folder the selected attachment's file instead when the
/// attachments section is focused" (11-command-nexus.md).
public var isFocused = false
/// Imports files into this window's card filled by the host with `BoardStore
/// .importAttachments(_:toCard:)`, the **same** store method the board window's Finder drop
/// rides. One import path, one set of banners, one Finder-style collision rename.
public var importFiles: (([URL]) -> Void)?
/// Moves one attachment to the system Trash filled by the host with `BoardStore
/// .removeAttachment(named:fromCard:)`.
public var removeFile: ((String) -> Void)?
public init() {}
// MARK: - Derived
/// Where `name` lives on disk, or `nil` when this window has no folder yet.
public func url(for name: String) -> URL? {
guard let cardFolder, names.contains(name) else { return nil }
return cardFolder
.appendingPathComponent(BoardWriter.attachmentsFolderName, isDirectory: true)
.appendingPathComponent(name)
}
/// The selected row's file, when there is one.
public var selectedURL: URL? {
selected.flatMap { url(for: $0) }
}
// MARK: - The two writes
/// File Add Attachment (A) and the header's quiet add affordance **one act with two
/// pointers at it** (11-command-nexus.md: the affordance "is a pointer twin of File Add
/// Attachment, no separate behavior").
///
/// A cancelled panel imports nothing and says nothing; a panel that returns files hands them to
/// the very same store method a whole-window drop uses.
public func add() {
guard isEditable, cardFolder != nil else { return }
let urls = Self.chooseFiles()
guard !urls.isEmpty else { return }
// The sandbox's half, `BoardDropContext.commitFileDrop`'s rule: `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() } }
importFiles?(urls)
}
/// Remove / the system Trash, never a hard delete (05 Attachments).
public func remove(_ name: String) {
guard isEditable, names.contains(name) else { return }
removeFile?(name)
}
// MARK: - Row actions that are not writes
/// Double-click, Return, and the context menu's Open: the file's default app (05 Attachments).
/// Enabled under the read-only lock like every other read opening a file mutates nothing here.
public func open(_ name: String) {
guard let url = url(for: name) else { return }
NSWorkspace.shared.open(url)
}
public func reveal(_ name: String) {
guard let url = url(for: name) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
}
// MARK: - Keyboard
/// / over the rows. Moving with nothing selected selects the first row (going down) or the
/// last (going up), which is what makes the section usable the instant it takes focus.
public func moveSelection(by delta: Int) {
selected = Self.moved(selected, by: delta, in: names)
}
// MARK: - The pure rules
/// Where / lands: **clamped, never wrapping** a list is not a carousel, and an arrow at the
/// end of a short list should not silently jump to the other end of it.
///
/// A selection that is not in `names` (a file removed under the cursor) is treated as no
/// selection, so the next arrow re-enters the list from its edge.
public nonisolated static func moved(_ selected: String?, by delta: Int, in names: [String]) -> String? {
guard !names.isEmpty else { return nil }
guard let selected, let index = names.firstIndex(of: selected) else {
return delta < 0 ? names.last : names.first
}
return names[min(max(0, index + delta), names.count - 1)]
}
/// Where the selection goes when the listing changes underneath it **the row that took its
/// place**, which is the behaviour every list in macOS has after a delete: remove the third of
/// five files and the selection lands on the new third, not on nothing and not on the top.
///
/// A selection that survived the change keeps its row (the common case: another window's import
/// added a file elsewhere). An empty listing selects nothing. A selection that was never set
/// stays unset a reload must not select a row the user did not.
public nonisolated static func settle(_ selected: String?, was previous: [String], is names: [String]) -> String? {
guard let selected else { return nil }
if names.contains(selected) { return selected }
guard !names.isEmpty, let index = previous.firstIndex(of: selected) else { return nil }
return names[min(index, names.count - 1)]
}
/// What File Reveal in Finder reveals in a card window: **the selected attachment's file when
/// the attachments section is focused, the card's folder otherwise** (11-command-nexus.md).
///
/// `[]` which is the row's `disabled` condition only when there is no card folder at all: a
/// window on its way out. A focused section with nothing selected still reveals the card, which
/// is the honest fallback rather than a row that goes dead when the user tabs into a list.
public nonisolated static func revealURLs(
cardFolder: URL?,
selectedURL: URL?,
isSectionFocused: Bool
) -> [URL] {
if isSectionFocused, let selectedURL { return [selectedURL] }
guard let cardFolder else { return [] }
return [cardFolder]
}
// MARK: - The panel
/// The multi-select open panel behind Add Attachment **every file type**, because a card's
/// `attachments/` takes anything (01-storage-format.md § Attachments) and a filter here would be
/// this app deciding what the user may keep beside their card.
///
/// Directories are not choosable, which is the panel's own spelling of the same refusal a
/// folder drop gets (`FinderDrop`): the attachment model is flat top-level files.
private static func chooseFiles() -> [URL] {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = true
panel.resolvesAliases = true
panel.prompt = "Add"
panel.message = "Choose files to attach to this card."
guard panel.runModal() == .OK else { return [] }
return panel.urls
}
}
// MARK: - File Add Attachment
/// File Add Attachment (A) card window only (11-command-nexus.md; 05-card-window.md
/// Attachments).
///
/// The diff `FutureCommands` predicted, exactly: the title and the chord did not move, the
/// validation and the action filled in.
///
/// Validation is **scope plus the lock**. With no card window in front there is no `cardAttachments`
/// focused value and the row disables; with the board read-only it disables too, because this is a
/// mutation and 02-architecture.md's every-entry-point predicate covers menu rows as much as
/// affordances. (Contrast Edit Body, which is not a mutation and stays live under the lock.)
struct AddAttachmentCommand: View {
@FocusedValue(\.cardAttachments) private var attachments
/// The row's validation, as a value a test can hold `EditBodyCommand.isEnabled`'s shape, for
/// its reason: a menu item's `.disabled` is otherwise only observable by driving the menu bar.
static func isEnabled(_ attachments: CardAttachments?) -> Bool {
guard let attachments else { return false }
return attachments.isEditable && attachments.cardFolder != nil
}
var body: some View {
Button("Add Attachment…") {
attachments?.add()
}
.keyboardShortcut("a", modifiers: [.shift, .command])
.disabled(!Self.isEnabled(attachments))
}
}
// MARK: - The focused value
/// The focused card window's attachments section, beside `FocusedValues.cardBody` see
/// `FocusedBoardStoreKey` for why window-scoped menu items reach their window this way.
struct FocusedCardAttachmentsKey: FocusedValueKey {
typealias Value = CardAttachments
}
extension FocusedValues {
var cardAttachments: CardAttachments? {
get { self[FocusedCardAttachmentsKey.self] }
set { self[FocusedCardAttachmentsKey.self] = newValue }
}
}
+362
View File
@@ -0,0 +1,362 @@
import AppKit
import Quartz
import SwiftUI
import UniformTypeIdentifiers
// MARK: - The section header
/// A stacked small-caps header over a sidebar section (05-card-window.md The attributes sidebar:
/// "Stacked sections under small-caps headers"), with room for one quiet trailing affordance.
///
/// Shared by all five sections rather than restated in each: the attachments section is the only one
/// with an accessory today, and the header's type, weight and rule have to stay identical across the
/// stack or the accessory would be the reason one section looks different from the rest.
struct CardSidebarSectionHeader<Accessory: View>: View {
let title: String
@ViewBuilder var accessory: Accessory
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text(title)
.font(.caption.weight(.semibold))
.textCase(.uppercase)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
accessory
}
Divider()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
extension CardSidebarSectionHeader where Accessory == EmptyView {
init(title: String) {
self.init(title: title) { EmptyView() }
}
}
// MARK: - CardAttachmentsSection
/// The sidebar's **Attachments** section (05-card-window.md Attachments).
///
/// ### What it shows, and why it does not look
///
/// Every top-level file of `attachments/`, in Finder order, **including files also embedded in the
/// body** "the section is the card's complete file inventory, no reference-tracking magic; an
/// image appearing in both places is honest, not a bug". Subfolders are tolerated and not surfaced.
///
/// The list itself is `Card.attachments` off the snapshot, republished into `CardAttachments.names`
/// by the host. This view never lists a directory: the loader did that on the last reload, the board
/// face's chip reads the very same field, and a second listing here would be a surface able to
/// disagree with the first. Every write in this section is bracketed (`performWrite`), so the reload
/// that refreshes the list is app-mediated and arrives by itself.
///
/// ### Keyboard-native, which is what is new in the rewrite
///
/// "The section is focusable; arrows move between rows, **Space QuickLooks** the selected row,
/// Return opens it, removes it" the pathfinder's strip was pointer-only. The substrate is
/// SwiftUI's own focus system (`focusable` + `@FocusState` + `onKeyPress`) over one focusable
/// container, rather than an `NSTableView` or a row-per-focusable list: 05 says *the section* is
/// focusable, one focus stop is what a Tab user wants out of a five-section sidebar, and the rest of
/// this window is already SwiftUI. The board's keyboard grammar is a different window's and shares
/// nothing here.
struct CardAttachmentsSection: View {
let attachments: CardAttachments
let thumbnails: AttachmentThumbnailCache
/// Whether the section has the keyboard. Mirrored into `CardAttachments.isFocused` because File
/// Reveal in Finder's card-window scope turns on it.
@FocusState private var isFocused: Bool
@Environment(\.displayScale) private var displayScale
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
private var names: [String] { attachments.names }
var body: some View {
VStack(alignment: .leading, spacing: CardWindowMetrics.attachmentRowPadding(bodyPointSize: pointSize)) {
CardSidebarSectionHeader(title: "Attachments") { addAffordance }
if names.isEmpty {
emptyHint
} else {
rows
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.onChange(of: isFocused, initial: true) { _, focused in
attachments.isFocused = focused
}
}
// MARK: - Header
/// The header's **quiet add affordance** "a pointer twin of File Add Attachment, no
/// separate behavior" (11-command-nexus.md), which is why it calls the same method the menu row
/// does rather than opening a panel of its own.
///
/// Disabled under the read-only lock, where the menu row is disabled too: 02-architecture.md's
/// every-entry-point predicate does not care which entry point.
private var addAffordance: some View {
Button {
attachments.add()
} label: {
Image(systemName: "plus")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(!AddAttachmentCommand.isEnabled(attachments))
.help("Add Attachment…")
.accessibilityLabel("Add Attachment")
}
// MARK: - Empty
/// "Empty, the section stays with a one-line hint (drop files, or File Add Attachment,
/// A)" the section never disappears, because the drop surface it advertises is the whole
/// window and a hint the user cannot find teaches nothing.
private var emptyHint: some View {
Text("Drop files anywhere, or File ▸ Add Attachment… (⇧⌘A)")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// MARK: - Rows
private var rows: some View {
VStack(alignment: .leading, spacing: 1) {
ForEach(names, id: \.self) { name in
row(name)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
// One focus stop for the whole list 05's "the section is focusable".
.focusable()
.focused($isFocused)
.onKeyPress(.upArrow) { attachments.moveSelection(by: -1); return .handled }
.onKeyPress(.downArrow) { attachments.moveSelection(by: 1); return .handled }
.onKeyPress(.space) { quickLookSelected(); return .handled }
.onKeyPress(.return) { openSelected(); return .handled }
// , and only : 05 gives the section one destructive key and the board's own delete
// grammar is a different window's.
.onKeyPress(.delete) { removeSelected(); return .handled }
.accessibilityLabel("Attachments")
}
@ViewBuilder
private func row(_ name: String) -> some View {
let url = attachments.url(for: name)
let isSelected = attachments.selected == name
AttachmentRow(
name: name,
url: url,
isSelected: isSelected,
isSectionFocused: isFocused,
thumbnails: thumbnails,
pointSize: pointSize,
displayScale: displayScale
)
.contentShape(Rectangle())
// Double-click opens, a single click selects (05 Attachments; the window's click grammar,
// where clicking selects and never edits). The two-count gesture is declared first so
// SwiftUI gives it the chance to claim the second click.
.onTapGesture(count: 2) {
isFocused = true
attachments.selected = name
attachments.open(name)
}
.onTapGesture {
isFocused = true
attachments.selected = name
}
// **Rows drag out their file URL** (05 Attachments; 11-command-nexus.md Pointer-only
// affordances) which is what makes drag-to-Finder and drag-into-another-app work with no
// export path of this app's own. An empty provider for a row whose file has gone refuses the
// drag rather than starting one that would resolve to nothing.
.onDrag {
guard let url else { return NSItemProvider() }
return NSItemProvider(contentsOf: url) ?? NSItemProvider()
}
.contextMenu { menu(for: name) }
}
/// The attachment row's context menu **Open, Reveal in Finder, Remove** (11-command-nexus.md
/// Context menus), twins of the focused section's grammar keys (Return / ) and of File Reveal
/// in Finder in its attachments-focused context. No new store method, no parallel
/// implementation: every row here calls exactly what the keyboard calls.
///
/// It acts on **its own row**, not on the selection, which is what makes a right-click on an
/// unselected row unambiguous without a select-first dance.
@ViewBuilder
private func menu(for name: String) -> some View {
Button("Open") { attachments.open(name) }
Button("Reveal in Finder") { attachments.reveal(name) }
Divider()
Button("Remove") { attachments.remove(name) }
.disabled(!attachments.isEditable)
}
// MARK: - The grammar keys
private func openSelected() {
guard let selected = attachments.selected else { return }
attachments.open(selected)
}
private func removeSelected() {
guard let selected = attachments.selected else { return }
attachments.remove(selected)
}
/// **Space QuickLooks the selected row** (05 Attachments) Finder's own key, and Finder's own
/// panel: `QLPreviewPanel` previews every row of the section with the selected one showing, so
/// the panel's / walk the card's attachments exactly as it walks a Finder selection.
private func quickLookSelected() {
guard let selected = attachments.selected,
let index = names.firstIndex(of: selected)
else { return }
AttachmentQuickLook.shared.toggle(
urls: names.compactMap { attachments.url(for: $0) },
at: index
)
}
}
// MARK: - One row
/// A compact row: **small QuickLook thumbnail (Finder-icon fallback) + middle-truncated filename**
/// (05-card-window.md Attachments).
///
/// Middle truncation rather than tail, because a filename's tail is its extension and a sidebar
/// 26 characters wide would otherwise turn every screenshot into `"Screen Shot 2026-07-2"` the
/// one part of the name that says what the file *is* is the part that would go.
private struct AttachmentRow: View {
let name: String
let url: URL?
let isSelected: Bool
let isSectionFocused: Bool
let thumbnails: AttachmentThumbnailCache
let pointSize: CGFloat
let displayScale: CGFloat
private var side: CGFloat { CardWindowMetrics.attachmentThumbnailSide(bodyPointSize: pointSize) }
private var padding: CGFloat { CardWindowMetrics.attachmentRowPadding(bodyPointSize: pointSize) }
private var slot: AttachmentThumbnailKey.Slot? {
url.map { AttachmentThumbnailKey.Slot(path: $0.path, side: side) }
}
var body: some View {
HStack(spacing: padding) {
thumbnail
.frame(width: side, height: side)
Text(name)
.font(.callout)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.padding(.horizontal, padding)
.padding(.vertical, padding / 2)
.frame(maxWidth: .infinity, alignment: .leading)
.background(selectionFill, in: RoundedRectangle(cornerRadius: 4, style: .continuous))
.foregroundStyle(isSelected && isSectionFocused ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.help(name)
.accessibilityElement(children: .combine)
.accessibilityLabel(name)
.accessibilityAddTraits(isSelected ? .isSelected : [])
.task(id: url?.path) {
guard let slot, let url else { return }
await thumbnails.load(slot, url: url, scale: displayScale)
}
}
/// The selection fill follows **focus**, the standard macOS list treatment: the accent colour
/// while the section has the keyboard, a quiet grey when it does not, so a selected row never
/// claims to be the thing the arrow keys are about to move.
private var selectionFill: AnyShapeStyle {
guard isSelected else { return AnyShapeStyle(.clear) }
return isSectionFocused ? AnyShapeStyle(.tint) : AnyShapeStyle(.quaternary)
}
/// The generated thumbnail once there is one, the file's Finder icon until then and forever,
/// for anything QuickLook declines (05: "small QuickLook thumbnail (Finder-icon fallback)").
@ViewBuilder
private var thumbnail: some View {
if let slot, let image = thumbnails.thumbnail(for: slot) {
Image(decorative: image, scale: displayScale)
.resizable()
.aspectRatio(contentMode: .fit)
} else if let url {
Image(nsImage: thumbnails.icon(forFileAt: url))
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Image(systemName: "doc")
.foregroundStyle(.secondary)
}
}
}
// MARK: - QuickLook
/// The Space key's panel `QLPreviewPanel`, the system's own, shared across every card window.
///
/// **One shared presenter, because there is one shared panel**: `QLPreviewPanel.shared()` is a
/// process-wide singleton, so a per-window data source would be a set of objects racing to be the
/// one it points at. Space in a second card window simply re-points the panel at that window's
/// files, which is also what Finder does across two windows.
///
/// The panel is driven by setting its data source directly rather than through the responder
/// chain's `acceptsPreviewPanelControl(_:)` dance: this app's key view at that moment is a SwiftUI
/// focusable, which is not an `NSResponder` we own, and the direct route is the one that does not
/// depend on where SwiftUI happens to put its hosting views.
@MainActor
final class AttachmentQuickLook: NSObject, QLPreviewPanelDataSource {
static let shared = AttachmentQuickLook()
private var items: [URL] = []
/// Space **toggles**, Finder's own behaviour: pressing it again on the row already showing puts
/// the panel away rather than re-opening it.
func toggle(urls: [URL], at index: Int) {
guard let panel = QLPreviewPanel.shared() else { return }
guard !urls.isEmpty, urls.indices.contains(index) else { return }
if panel.isVisible, items == urls, panel.currentPreviewItemIndex == index {
panel.orderOut(nil)
return
}
items = urls
panel.dataSource = self
panel.reloadData()
panel.currentPreviewItemIndex = index
panel.makeKeyAndOrderFront(nil)
}
nonisolated func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int {
MainActor.assumeIsolated { items.count }
}
/// The bridge to `NSURL` happens **outside** the isolation hop on purpose: `any QLPreviewItem`
/// is not `Sendable`, so it may not be the thing `assumeIsolated` returns; `URL` is, so the
/// value that crosses is the plain one and the Objective-C cast is done here.
nonisolated func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! {
let url: URL? = MainActor.assumeIsolated {
items.indices.contains(index) ? items[index] : nil
}
guard let url else { return nil }
return url as NSURL
}
}
+130
View File
@@ -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
}
}
+21
View File
@@ -79,6 +79,27 @@ enum CardWindowMetrics {
columnWidth(characters: bodyMinimumCharacters, bodyPointSize: bodyPointSize)
}
// MARK: - The attachments section
/// An attachment row's thumbnail: a **small** square, one and a half ems on a side
/// (05-card-window.md Attachments: "Compact rows: small QuickLook thumbnail (Finder-icon
/// fallback) + middle-truncated filename, one row per file").
///
/// Derived rather than a point size, like everything else here, and deliberately *small*: this
/// is an inventory, not a gallery "viewing media is the card window's job" was settled about
/// the window, not about this list, and a row tall enough to see a screenshot in would push the
/// four sections beneath it off the sidebar.
static func attachmentThumbnailSide(bodyPointSize: CGFloat) -> CGFloat {
(bodyPointSize * 1.5).rounded()
}
/// The inset inside an attachment row, and the gap between its thumbnail and its filename
/// half a gutter, the rendered body's rhythm, so the sidebar's list and the body's blocks are
/// spaced by the same unit.
static func attachmentRowPadding(bodyPointSize: CGFloat) -> CGFloat {
previewPadding(bodyPointSize: bodyPointSize)
}
// MARK: - The rendered body
/// One step of structural indent in Preview a list level, a quote level. One and a half ems,
+48 -12
View File
@@ -1,4 +1,5 @@
import SwiftUI
import UniformTypeIdentifiers
// MARK: - CardWindowView
@@ -52,6 +53,14 @@ struct CardWindowView: View {
let rawSource: CardRawSourceSession
/// Whether a checkbox may write `false` under the board's read-only lock.
let isEditable: Bool
/// This window's attachments section: the listing, the selection, and the two writes it starts
/// (05 Attachments).
let attachments: CardAttachments
/// This window's thumbnail memory, held by the host so it outlives a snapshot.
let thumbnails: AttachmentThumbnailCache
/// The whole-window file drop (05 Attachments: "the drop surface remains the **whole
/// window**"). `nil` only where a caller has no store to import through.
let fileDrop: CardWindowDropDelegate?
/// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw.
let onToggleTask: (Int, Bool) -> Void
@@ -74,6 +83,18 @@ struct CardWindowView: View {
/// does survive is the buffer, which is the Edit session's, not the view's and it was flushed
/// to disk on the way in regardless.
var body: some View {
content
// **The drop surface is the whole window** (05 Attachments), which is why it hangs
// here outside the raw-source swap, so a file dropped while the source outlet is open
// still imports rather than on the attachments section it fills. The body editor lets
// file drags through to this by declining the file types
// (`CardBodyTextView.acceptableDragTypes`); dragged *text* never matches `.fileURL` and
// so is never offered here at all, which is the other half of the payload split.
.modifier(WindowFileDrop(delegate: fileDrop))
}
@ViewBuilder
private var content: some View {
if rawSource.isActive {
CardRawSourceView(session: rawSource, presentation: bodyPresentation)
} else {
@@ -183,9 +204,7 @@ struct CardWindowView: View {
private var sidebar: some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
// m6-card-attachments: every top-level file of `attachments/`, compact rows with a
// QuickLook thumbnail, keyboard-navigable.
section("Attachments")
CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails)
// m6-card-sidebar: the embedded style editor the same component the board popover
// and Style already host (`StyleEditor`).
section("Style")
@@ -204,16 +223,33 @@ struct CardWindowView: View {
}
/// A stacked small-caps header over the space its section will occupy (05: "Stacked sections
/// under small-caps headers").
/// under small-caps headers") the same header the Attachments section fills in for real, so
/// the four still-empty ones cannot drift from it.
private func section(_ title: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption.weight(.semibold))
.textCase(.uppercase)
.foregroundStyle(.secondary)
Divider()
CardSidebarSectionHeader(title: title)
.accessibilityElement(children: .combine)
}
}
// MARK: - The window-wide drop
/// Attaches the whole-window file drop, or nothing at all.
///
/// A modifier rather than an `if` inside `body` because `.onDrop` has to be applied to the *same*
/// view identity in both cases: a window whose store arrives a turn after its view would otherwise
/// re-mount its entire content when the drop target appeared, throwing away the body's scroll
/// position for nothing.
private struct WindowFileDrop: ViewModifier {
let delegate: CardWindowDropDelegate?
func body(content: Content) -> some View {
if let delegate {
// `.fileURL` alone: a text drag never matches, so it is never offered here and falls to
// the Edit editor, where `NSTextView` inserts it at the caret (05 Attachments).
content.onDrop(of: [.fileURL], delegate: delegate)
} else {
content
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
}
+186
View File
@@ -0,0 +1,186 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers
// This file is a **pure extraction** from `BoardDrops.swift`, moved verbatim the moment a second
// window needed it: the card window's whole-window attachment drop (05-card-window.md Attachments)
// runs the same two rules this file holds how a dropped `NSItemProvider` becomes a file URL, and
// which half of a Finder drag is a file rather than a folder. Both windows import through
// `BoardStore.importAttachments`, so a second copy of either rule would be a second folder-refusal
// semantics able to disagree with the board's about what a package is.
//
// Nothing here knows about a board's geometry, its lanes, or its drag session which is what made
// the move mechanical. `FileDropTarget.Landing`, the one board-shaped type `land` still takes, stays
// in `BoardDrops.swift`: the card window passes `.attach(cardID:)` and the board resolves its own.
// 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)
}
}