⌘V grows an image-data branch, below the app's own clipboard format and refused outright while a file URL is on the pasteboard: a screenshot or a browser's Copy Image lands as "Pasted Image.png" in the anchor card's attachments/, through the very import path Finder file drops and ⇧⌘A take — one bracket, one Finder-style collision ladder, one set of banners, and the same silence a drop's arrival has. A card window's ⌘V pastes onto its own card; a focused text field still wins the selector natively. A file-shaped flavor travels byte for byte, PNG preferred when several are offered; TIFF and BMP are re-encoded to PNG, being interchange encodings rather than files anyone wants in a folder. The hero key gets the setter it was born owing: "Set as Hero" on any image row of the attachment list, "Remove Hero" on the row that holds it, with menu-bar twins so the context entry is nobody's only home. It writes as a restyle — one key, one bracket, one invertible step on the window's own stack — and replaces rather than refusing, because a card has one hero and the row that has it says Remove instead. Edit ▸ Paste as Board Background is the same payload's other destination, taking the existing background.image convention at its word: the picture into the board folder as "Pasted Background.png", the colour subkey untouched, the generator's overwrite-our-own-name rule inherited and its echo memo taught to tell the two producers apart. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
393 lines
18 KiB
Swift
393 lines
18 KiB
Swift
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?
|
|
|
|
/// **The file this card's `hero` key names**, republished from the snapshot exactly as `names`
|
|
/// is (`Card.hero`) — `nil` for a card with no hero, and for one whose key is malformed, which is
|
|
/// the same "no band" the face renders (03-board-ui.md § Card face ▸ Hero image).
|
|
///
|
|
/// Here rather than derived in the section for `names`' reason: the sidebar must not be a second
|
|
/// reading of the card able to disagree with the board face's. It drives one thing only — which
|
|
/// of the two hero rows a row's context menu offers.
|
|
public var hero: 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)?
|
|
|
|
/// Points the card's `hero` key at a file, or removes it — filled by the host with `BoardStore
|
|
/// .setHero(_:onCard:on:)`, this window's undo stack attached, so the step lands on the stack the
|
|
/// gesture was issued on (13-native-undo.md ▸ Rules ▸ two levels).
|
|
public var setHeroFile: ((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 = AttachmentPanel.chooseFiles(message: "Choose files to attach to this card.")
|
|
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)
|
|
}
|
|
|
|
/// **Set as Hero** — the row's file becomes the card's banner picture (05 ▸ Attachments, ruled
|
|
/// 2026-08-09; 03-board-ui.md § Card face ▸ Hero image).
|
|
public func setAsHero(_ name: String) {
|
|
guard Self.canSetHero(name, hero: hero, names: names, isEditable: isEditable) else { return }
|
|
setHeroFile?(name)
|
|
}
|
|
|
|
/// **Remove Hero** — the key goes, the file stays. Removing the *hero* is not removing the
|
|
/// attachment: the picture is still one of the card's files and is still in the list, which is
|
|
/// what keeps this row distinct from the Remove one sitting below it.
|
|
public func removeHero() {
|
|
guard isEditable, hero != nil else { return }
|
|
setHeroFile?(nil)
|
|
}
|
|
|
|
// MARK: - The hero rows' rules
|
|
|
|
/// Whether a row offers **Set as Hero** — the row is an image, the section can write, the file is
|
|
/// actually in the listing, and the card's hero is not already this very file.
|
|
///
|
|
/// **Image-type only** (`PastedImage.isImageName`), because the key means a picture: offering the
|
|
/// row on a `.zip` would let a user set a hero that can never draw, and 03's structural degrade
|
|
/// would leave them with a key and no band and nothing to explain it.
|
|
///
|
|
/// **Absent, not disabled, on the current hero's row**: that row shows Remove Hero instead, which
|
|
/// is the same slot saying the true thing. Everywhere else "Set as Hero" *replaces* whatever hero
|
|
/// the card had — one hero per card, and a Remove-then-Set dance would be ceremony (see
|
|
/// `BoardStore.setHero(_:onCard:on:)`).
|
|
///
|
|
/// A pure static for `moved`/`settle`'s reason: the menu's two branches become lines of test
|
|
/// rather than a context menu somebody has to open.
|
|
public nonisolated static func canSetHero(
|
|
_ name: String,
|
|
hero: String?,
|
|
names: [String],
|
|
isEditable: Bool
|
|
) -> Bool {
|
|
guard isEditable, names.contains(name), hero != name else { return false }
|
|
return PastedImage.isImageName(name)
|
|
}
|
|
|
|
/// Whether a row offers **Remove Hero** — it is the card's current hero, and the section can
|
|
/// write. The image test is deliberately *not* repeated: a hero somebody hand-wrote to a
|
|
/// non-image file is exactly the state this row exists to get out of.
|
|
public nonisolated static func canRemoveHero(
|
|
_ name: String,
|
|
hero: String?,
|
|
isEditable: Bool
|
|
) -> Bool {
|
|
isEditable && hero == 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 every "add a file" affordance in the card window — File ▸ Add
|
|
/// Attachment… and the attachments header's plus (card-scoped), and the composer's and inline
|
|
/// editor's paperclips (comment-scoped, 05-card-window.md ▸ The comments column).
|
|
///
|
|
/// **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.
|
|
///
|
|
/// Shared rather than copied per surface for `BoardWriter.importFiles`' reason one layer up: the
|
|
/// paperclip is "the section header's add-affordance pattern" and a second panel that happened to
|
|
/// allow folders would make that sentence false.
|
|
@MainActor
|
|
enum AttachmentPanel {
|
|
|
|
/// - Parameter message: the panel's one line of guidance — the only thing that differs between
|
|
/// the card-scoped and comment-scoped calls, because it is the only thing that *is* different.
|
|
static func chooseFiles(message: String) -> [URL] {
|
|
let panel = NSOpenPanel()
|
|
panel.canChooseFiles = true
|
|
panel.canChooseDirectories = false
|
|
panel.allowsMultipleSelection = true
|
|
panel.resolvesAliases = true
|
|
panel.prompt = "Add"
|
|
panel.message = message
|
|
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: - File ▸ Set as Hero / Remove Hero
|
|
|
|
/// **The attachment row's hero pair, as menu rows** — card window only, acting on the attachments
|
|
/// section's *selected* row (11-command-nexus.md; 05-card-window.md ▸ Attachments; 03-board-ui.md
|
|
/// § Card face ▸ Hero image).
|
|
///
|
|
/// ### Why the menu rows exist at all
|
|
///
|
|
/// 11's context-menu contract: "Every entry is a twin of a menu command, a fixed grammar key, or a
|
|
/// configuration control — **no function's only home**". The row's pointer path is the context menu;
|
|
/// these are its required twins, and they are also what makes the gesture keyboard-reachable in a
|
|
/// section 05 went out of its way to make keyboard-native.
|
|
///
|
|
/// ### Two rows, not one row with two titles
|
|
///
|
|
/// **Titles are API** (04-interactions.md ▸ Configurable bindings) — a user's custom binding is
|
|
/// stored against the title — so a single row that renamed itself would silently drop that binding
|
|
/// every time the selection moved. Two rows is `Collapse Lane`/`Expand Lane`'s answer to the same
|
|
/// shape, and for the same reason. In the *context* menu the two share one slot, because a context
|
|
/// menu is built fresh per row and carries no bindings.
|
|
///
|
|
/// The subject is the **selected** row rather than a row under a pointer, which is what a menu-bar
|
|
/// item can address at all — File ▸ Reveal in Finder's card-window scope reads the same selection.
|
|
struct SetAsHeroCommand: View {
|
|
|
|
@FocusedValue(\.cardAttachments) private var attachments
|
|
|
|
/// The row's validation as a value a test can hold — `AddAttachmentCommand.isEnabled`'s shape,
|
|
/// for its reason. It is `CardAttachments.canSetHero` applied to the selected row, so the menu
|
|
/// row and the context row can never disagree about what "an image that is not already the hero"
|
|
/// means.
|
|
static func isEnabled(_ attachments: CardAttachments?) -> Bool {
|
|
guard let attachments, let selected = attachments.selected else { return false }
|
|
return CardAttachments.canSetHero(
|
|
selected,
|
|
hero: attachments.hero,
|
|
names: attachments.names,
|
|
isEditable: attachments.isEditable
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
Button("Set as Hero") {
|
|
guard let attachments, let selected = attachments.selected else { return }
|
|
attachments.setAsHero(selected)
|
|
}
|
|
.disabled(!Self.isEnabled(attachments))
|
|
}
|
|
}
|
|
|
|
/// Set as Hero's other direction — see it for why the pair is two rows.
|
|
struct RemoveHeroCommand: View {
|
|
|
|
@FocusedValue(\.cardAttachments) private var attachments
|
|
|
|
static func isEnabled(_ attachments: CardAttachments?) -> Bool {
|
|
guard let attachments, let selected = attachments.selected else { return false }
|
|
return CardAttachments.canRemoveHero(
|
|
selected,
|
|
hero: attachments.hero,
|
|
isEditable: attachments.isEditable
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
Button("Remove Hero") {
|
|
attachments?.removeHero()
|
|
}
|
|
.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 }
|
|
}
|
|
}
|