Build the attachments sidebar section

The card's complete file inventory: compact QuickLook-thumbnail rows
over Card.attachments — no reference tracking, subfolders tolerated
and unsurfaced — with a quiet header add affordance and the drop hint
empty state. The whole window is the file-drop surface, Edit mode
included (the editor's drag types were already filtered; now tested),
sharing the board's folder-refusal semantics literally: FinderDrop
moved verbatim into its own file so both windows run the same
partition and loss row. Dragged text still lands at the caret and is
inert elsewhere — the window delegate accepts file payloads only.
Rows open on double-click or Return, drag out their file URL, and
Remove is a bracketed write through FileManager.trashItem — the system
Trash, never a hard delete, returning the in-Trash URL so the promise
is testable; the attachment listing is the guard, so traversal and
subfolder names refuse in one line. Keyboard-native per 05: the
section is one Tab stop, arrows walk rows by name, Space toggles the
shared QuickLook panel, Backspace removes. File > Add Attachment
(shift-cmd-A) comes alive through the same import path.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 11:52:37 -04:00
parent 40c0a75c24
commit 46397c740e
16 changed files with 1823 additions and 214 deletions
+58 -1
View File
@@ -139,6 +139,15 @@ struct CardWindowHost: View {
/// (`CardRawSourceSession`). Beside the body handle rather than inside it: the two are different
/// scopes, and the Edit Body row reads both.
@State private var rawSource = CardRawSourceSession()
/// This window's attachments section the listing, the keyboard selection, and the two writes
/// it starts (05-card-window.md Attachments). Window-scoped for `CardBodyPresentation`'s
/// reason: two card windows on one board have two different selections, and the menu bar reaches
/// the frontmost one through the focus system.
@State private var attachments = CardAttachments()
/// This window's thumbnail memory. Held here rather than in the section so it survives every
/// snapshot the store applies a cache that died with the view would regenerate every thumbnail
/// on every reload (`AttachmentThumbnailCache`).
@State private var thumbnails = AttachmentThumbnailCache()
/// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not
/// flush; cleared by the resolution that lets the close resume.
@State private var isClosePending = false
@@ -210,6 +219,9 @@ struct CardWindowHost: View {
// reads it too "View Edit Body (E) disables while source mode is active"
// (05-card-window.md Raw source outlet).
.focusedSceneValue(\.cardRawSource, rawSource)
// File Add Attachment (A) and File Reveal in Finder's card-window scope reach the
// frontmost card window the same way (11-command-nexus.md).
.focusedSceneValue(\.cardAttachments, attachments)
// The raw-source outlet's detailed alert, presented over this window a validation
// refusal on Apply, or a file that could not be opened as source. It hangs *here* rather
// than inside the editor because the second of those fires while source mode is still
@@ -266,12 +278,32 @@ struct CardWindowHost: View {
rawSource: rawSource,
// "Under the read-only lock the controls disable in place an in-content mutation
// menu validation can't reach" (05 Preview). The checkbox is that control, and
// the store's own lock is the whole predicate.
// the store's own lock is the whole predicate and it is the attachments section's
// predicate too ("the attachment row's /Remove shares the posture").
isEditable: !store.isReadOnly,
attachments: attachments,
thumbnails: thumbnails,
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
onToggleTask: { offset, checked in
store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked)
}
)
// **The listing is the snapshot's, republished** `Card.attachments`, which the loader
// fills from `attachments/`'s top-level files in Finder order. Every write in the
// section is bracketed, so the reload that refreshes this arrives by itself and the
// section never lists a directory of its own (05 Attachments; the one-way flow).
.onChange(of: placement.card.attachments, initial: true) { _, names in
attachments.names = names
}
.onChange(of: Self.cardFolder(root: store.rootURL, placement: placement), initial: true) { _, folder in
// Re-derived from the store's *current* root, `cardFolder`'s rule: a mid-session
// folder rename moves the board, and rows resolving against where it used to be
// would open nothing.
attachments.cardFolder = folder
}
.onChange(of: store.isReadOnly, initial: true) { _, locked in
attachments.isEditable = !locked
}
} else {
// Nothing to render and nothing worth animating: this window is on its way out.
Color.clear
@@ -386,6 +418,31 @@ struct CardWindowHost: View {
store: store,
cardID: cardID
)
Self.configureAttachments(attachments, store: store, cardID: cardID)
}
/// Points the attachments section at its card **the one place Add Attachment and Remove
/// learn which card they act on** (05-card-window.md Attachments).
///
/// Both seams are the store's own bracketed methods, unchanged: `importAttachments(_:toCard:)`
/// is the *same* call the board window's Finder drop makes, so a file added through A, through
/// the header's plus, through a drop anywhere in this window, and through a drop on the card's
/// face on the board all take one path one collision rename, one set of banners, one commit
/// shape. There is deliberately no card-window import of its own to keep in step with it.
///
/// The store is captured **weakly**, `configureSession`'s rule: a panel still running after the
/// board window has gone should write nothing rather than resurrect a released store.
///
/// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason:
/// the target resolution is invisible in a running window until it is wrong, and this shape is
/// what lets a test drive the real wiring rather than a re-typed copy of it.
static func configureAttachments(_ attachments: CardAttachments, store: BoardStore, cardID: ItemID) {
attachments.importFiles = { [weak store] urls in
store?.importAttachments(urls, toCard: cardID)
}
attachments.removeFile = { [weak store] name in
store?.removeAttachment(named: name, fromCard: cardID)
}
}
/// Points the raw-source outlet at its card the outlet's three seams (05-card-window.md Raw