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