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:
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user