The card window recomposes into three componentized panes (body, comments, attributes) with two mounts — beside or body-over-comments at ~3:2 — behind View ▸ Comments Beside Body. View ▸ Show Comments is one persisted app-wide bit, no content-derived auto-show; File ▸ Add Comment flips it on and focuses the composer. The thread renders author lines, edited markers, card-subset Markdown bodies, and read-only Quick Look chips under a count header with the sort- direction control. The composer edits comments/.draft/ on the slow cadence (blur, close, quit, ~30s interval), Escape only moves focus, ⌘↩ posts. Inline edit is a body-edit session in miniature: 700ms debounce, Save/⌘↩ commits, Cancel and Escape revert to session-start bytes, close flushes. File drops within either authoring surface carve out of the window-wide card default into that surface's attachments/; paperclips cover the no-drag path. Close flush runs inline flush, then draft save, then the comments/.trash purge; open sweeps crash residue. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
255 lines
12 KiB
Swift
255 lines
12 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 = 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)
|
|
}
|
|
|
|
// 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: - 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 }
|
|
}
|
|
}
|