Files
lanework/Kanban/UI/Card/CardAttachments.swift
T
rzen 200fbce276 The card window's ⌘V drops its picture branch — the attachments header now offers one instead
Raw image data on a card window's ⌘V was a keyboard shortcut with no visible
trigger; the sidebar's Attachments header now grows a quiet control — beside
the existing add affordance, present only while the pasteboard holds a
picture this card could take — that pastes it through the exact seam the
retired branch used (ClipboardStore.pasteImage(intoCard:in:), the board's
"Paste Image into Card" row's own call). The file-URL branch stays on ⌘V; a
Finder copy is still unambiguous. CardBodyTextView's paste-yield mechanism
needed no change at all — it forwards by capability, not by picture-specific
logic, so a screenshot ⌘V with the body editor focused is now a genuine
no-op there, served by the new control instead.

The pasteboard's re-read gains a fourth checkpoint — a window becoming key —
alongside menu-tracking, ⌘-down and app activation: a persistent visible
control has to read true continuously while its window is frontmost, not
only at the instant a menu or chord probes it.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 10:37:55 -04:00

405 lines
19 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)?
/// **The header's paste-image affordance's write** — filled by the host with `ClipboardStore
/// .pasteImage(intoCard:in:)`, the same call the card window's own ⌘V used before the control
/// replaced it, and the board's "Paste Image into Card" context-menu row still uses
/// (04-interactions.md ▸ Clipboard, re-ruled 2026-08-09). One import path behind three pointers
/// at it now, none of them a second implementation.
public var pasteImage: (() -> Void)?
/// **Whether the affordance shows at all** — filled by the host with `CardPasteImageAffordance
/// .isVisible(clipboard:cardID:in:)`, read fresh on every view evaluation rather than cached, so
/// the control's presence and `pasteImage()`'s success can never disagree.
public var canPasteImage: (() -> Bool)?
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 }
}
}