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 — `//`. `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 } } }