Files
lanework/Kanban/UI/Card/CardAttachments.swift
T
rzen 46397c740e 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
2026-07-28 11:52:37 -04:00

242 lines
11 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?
/// 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 }
}
}