Files
lanework/Kanban/UI/Card/CardComments.swift
T
rzen fe3ffac48e Comments, phase 2 — the pane, the composer, and the inline session
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
2026-07-30 20:19:52 -04:00

330 lines
15 KiB
Swift

import AppKit
import Observation
import SwiftUI
// MARK: - The window's comments pane, as a handle
/// One card window's comments pane, reduced to what things *outside* it need: the thread it is
/// showing, the composer's buffer, whichever comment has an inline edit session open, and the writes
/// the pane can start (05-card-window.md ▸ The comments column).
///
/// `CardAttachments`' shape and for its reason — one per window, `@State` in the host (on its
/// session, so the close flush can reach it), published through the focus system so **menu items**
/// (File ▸ Add Comment; View ▸ Show Comments' validation) 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 have two
/// different threads, two different drafts and two different sessions.
///
/// ### What it is not
///
/// It is **not** the thread's source of truth, and it is emphatically not a cache. Comments are
/// window-scoped and outside the board snapshot (01-storage-format.md § Enhanced schema), so there is
/// no snapshot to republish from — this handle re-reads the thread from disk through the store's own
/// read, at exactly two moments: when the window opens, and when a reload lands. Everything else
/// here is a buffer or a seam.
///
/// ### The three preference bits are not here either
///
/// Show Comments, Comments Beside Body and the sort direction are **app-wide and persisted**
/// (`AppPreferences`), so they are read where they are used — `@AppStorage` in the views and the menu
/// rows — rather than mirrored onto every window's handle, which would be one copy per window of a
/// value that has exactly one.
@MainActor
@Observable
public final class CardComments {
// MARK: What the pane shows
/// The thread as the last read found it, in the loader's order (`created` ascending). The header's
/// sort control reverses it for display and never re-sorts — see `CommentSortDirection`.
public private(set) var thread: CommentThread = .empty
/// The card's own folder — `<root>/<lane>/<card>`. `nil` until the window has joined its board,
/// which is exactly while there is nothing to comment on. Comment attachment URLs and Reveal in
/// Finder resolve against it.
public var cardFolder: URL?
/// Whether the pane's mutations are offered at all — `!store.isReadOnly`. Under the lock the
/// composer, the paperclips, Post, Edit and Delete disable in place, which is
/// 02-architecture.md's every-entry-point predicate applied to this pane.
public var isEditable = false
// MARK: The two authoring surfaces
/// The composer's buffer — always present, because the composer is always visible when the pane
/// is (05: "an always-visible text area").
public let composer = CommentDraftSession()
/// The one open inline edit session, or `nil`. **One at a time**: 05 describes Edit as *the*
/// comment's session and the window's close flushes *the* session, and two editors over one
/// thread would each hold their own session-start bytes over files the other was writing.
public private(set) var editing: CommentEditSession?
/// Bumped by File ▸ Add Comment (and by the pane's own affordances) to ask the composer for the
/// keyboard. A **counter**, not a flag, so two Add Comments in a row are two focus requests —
/// a `Bool` would need clearing, and a clear that raced the view would swallow the second one.
public private(set) var focusComposerRequests = 0
// MARK: Seams — filled in by the host with the store's own bracketed methods
/// Re-reads the thread — `BoardStore.commentThread(inCard:)`.
@ObservationIgnored
public var readThread: (() -> CommentThread)?
/// Re-reads the draft — `BoardStore.commentDraft(inCard:)`.
@ObservationIgnored
public var readDraft: (() -> CommentDraft?)?
/// The crash-residue sweep, run once when the window opens —
/// `BoardStore.sweepCommentTrashResidue(inCard:)`.
@ObservationIgnored
public var sweepTrashResidue: (() -> Void)?
/// The close purge — `BoardStore.purgeCommentTrash(inCard:)`.
@ObservationIgnored
public var purgeTrash: (() -> Void)?
/// Displaces the claimed names a thread read found squatted, and surfaces what moved —
/// `BoardStore.displaceCommentClaimedNames(_:)` joined to `BannerCenter.postDisplacedClaimedNames`.
@ObservationIgnored
public var displaceSquatters: (([ClaimedNameSquatter]) -> Void)?
/// Deletes one comment — `BoardStore.deleteComment(_:inCard:)`. Immediate, no confirm, and the
/// undo step is already registered store-side.
@ObservationIgnored
public var deleteComment: ((ItemID) -> Bool)?
/// One inline edit session's save — `BoardStore.editComment(_:inCard:body:)`, handed to each
/// session as it opens.
@ObservationIgnored
public var editComment: ((ItemID, String) -> Bool)?
/// Imports files into an authoring surface's `attachments/` —
/// `BoardStore.importCommentAttachments(_:inCard:target:)`.
@ObservationIgnored
public var importAttachments: (([URL], CommentTarget) -> Void)?
/// Moves one authoring chip's file to the system Trash —
/// `BoardStore.removeCommentAttachment(named:inCard:target:)`.
@ObservationIgnored
public var removeAttachment: ((String, CommentTarget) -> Void)?
public init() {}
// MARK: - Reading
/// **The window-open sequence** — the sweep first, then the read (01-storage-format.md § Enhanced
/// schema: "crash residue sweeps at the next card-window open"; the brief's order).
///
/// The sweep goes first because it *removes* folders, and a thread read taken before it would
/// describe a `comments/.trash/` that is about to stop existing. It costs no bracket at all on a
/// board that closed cleanly (`HealScheduler`'s rest branch), which is every open but one.
public func open() {
sweepTrashResidue?()
reload()
}
/// Re-reads the thread and the draft, and routes anything the read found to be repaired.
///
/// **Foreign arrivals snap** (05 ▸ The comments column: "foreign arrivals snap in per the motion
/// language"). `withAnimation(nil)` is the motion language's own spelling of that — the bare
/// assignment `Motion.reloadAnimation` returns `nil` for, applied here rather than inherited,
/// because this runs from an `onChange` that may still be inside the store's own reload
/// transaction and a thread must not ride the board's structural spring.
public func reload() {
guard let readThread else { return }
let thread = readThread()
let draft = readDraft?()
withAnimation(nil) {
self.thread = thread
}
composer.adopt(draft: draft)
// The open session follows disk under the same dirty-buffer-wins rule the body has: a clean
// editor takes the foreign edit, a dirty one keeps the keystrokes. A session whose comment
// has gone — deleted here, or by another window — is simply dropped; there is no error UI to
// show for a file that is not there (05 ▸ Deletion & lifecycle's "nowhere left to write").
if let editing {
guard let comment = thread.comments.first(where: { $0.id == editing.commentID }) else {
self.editing = nil
return
}
editing.adopt(diskBody: comment.body)
}
// The thread's own claimed-name squatters — `comments/.draft`, `comments/.trash`, and a
// comment's `attachments` — displaced through the store's batch, with the warning-tone
// notice naming what moved. Detection is the read's, the repair is the store's, and the
// notice is the banner surface's; this line is only the join.
let squatters: [ClaimedNameSquatter] = thread.defects.compactMap {
if case let .claimedNameSquatted(work) = $0 { work } else { nil }
}
if !squatters.isEmpty {
displaceSquatters?(squatters)
}
}
// MARK: - The composer
/// **File ▸ Add Comment**, and the pane's own "add a comment" affordances: ask the composer for
/// the keyboard.
///
/// Turning Show Comments *on* is deliberately not here — it is the menu row's, because the row is
/// the thing that knows the preference and because the pane has to be mounted before there is a
/// composer to focus. See `AddCommentCommand`, which does both in the one order that works.
public func focusComposer() {
focusComposerRequests += 1
}
// MARK: - The inline edit session
/// Opens a session over one comment — the context menu's **Edit** (05 ▸ The comments column).
///
/// A session already open is **committed** first rather than abandoned: the user asked to edit a
/// different comment, which is not a request to throw away what they typed in this one. Editing
/// the comment that is already open is a no-op, so a double-click on Edit cannot restart a session
/// and lose its start-of-session bytes.
public func beginEdit(_ commentID: ItemID) {
guard isEditable else { return }
guard editing?.commentID != commentID else { return }
endEdit()
guard let comment = thread.comments.first(where: { $0.id == commentID }) else { return }
let session = CommentEditSession(commentID: commentID, body: comment.body)
session.save = { [weak self] text in
self?.editComment?(commentID, text) ?? false
}
editing = session
}
/// **Save / ⌘↩** — the session's commit point.
public func commitEdit() {
editing?.commit()
editing = nil
}
/// **Cancel / Escape** — reverts to session-start bytes.
public func cancelEdit() {
editing?.cancel()
editing = nil
reload()
}
/// The window close's end of the session — a flush, never a revert (see
/// `CommentEditSession.endOnClose`).
private func endEdit() {
editing?.commit()
editing = nil
}
// MARK: - Delete
/// The context menu's **Delete** — "immediate and undoable, no confirm" (05 ▸ The comments
/// column). The step is `BoardStore.deleteComment`'s; nothing is registered here.
///
/// A session open over the comment being deleted ends first, and ends as a *commit*: the user's
/// last keystrokes belong in the file that is about to move into `comments/.trash/`, so that an
/// undo brings back what they wrote rather than what the file said a debounce ago.
public func delete(_ commentID: ItemID) {
guard isEditable else { return }
if editing?.commentID == commentID {
endEdit()
}
guard deleteComment?(commentID) == true else { return }
reload()
}
/// The context menu's **Reveal in Finder** — the comment's own folder.
public func reveal(_ commentID: ItemID) {
guard let cardFolder else { return }
let folder = CommentThread.commentFolder(commentID, inCard: cardFolder)
NSWorkspace.shared.activateFileViewerSelecting([folder])
}
// MARK: - Attachments on the two authoring surfaces
/// Where a target's files live on disk — what a chip's thumbnail, its Quick Look and its Reveal
/// resolve against. `nil` before the window has joined its board.
public func attachmentURL(_ name: String, in target: CommentTarget) -> URL? {
guard let cardFolder else { return nil }
return target.folder(inCard: cardFolder)
.appendingPathComponent(BoardWriter.attachmentsFolderName, isDirectory: true)
.appendingPathComponent(name)
}
/// The paperclip affordance and the drop carve-out — **one act with two pointers at it**, the
/// attachments section's add-affordance rule one level down.
///
/// The security-scope dance is `CardAttachments.add()`'s, verbatim and for its reason: `start…`
/// answers false for a URL that carries no scope of its own, so only the ones that opened are
/// closed again.
public func addAttachments(to target: CommentTarget) {
guard isEditable, cardFolder != nil else { return }
let urls = AttachmentPanel.chooseFiles(message: "Choose files to attach to this comment.")
guard !urls.isEmpty else { return }
importFiles(urls, to: target)
}
/// The drop's write. Named separately from `addAttachments(to:)` because a drop already has its
/// URLs and must not open a panel.
public func importFiles(_ urls: [URL], to target: CommentTarget) {
guard isEditable, !urls.isEmpty else { return }
let scoped = urls.filter { $0.startAccessingSecurityScopedResource() }
defer { for url in scoped { url.stopAccessingSecurityScopedResource() } }
importAttachments?(urls, target)
reload()
}
/// An authoring chip's Remove — the **system** Trash (05 ▸ The comments column).
public func removeFile(named name: String, from target: CommentTarget) {
guard isEditable else { return }
removeAttachment?(name, target)
reload()
}
// MARK: - The close flush
/// **The window's close, in the order the brief fixes: saves first, purge last.**
///
/// The inline session flushes as the body's does (a flush, never a revert — a close is not an
/// abandon), then the composer's draft lands, and only then is `comments/.trash/` emptied. The
/// purge going last is what makes it safe at all: it removes the folders a delete moved aside, and
/// running it before a session's save could remove a folder that save was about to write into.
///
/// Ending twice does nothing the second time — the sessions latch, and a purge over an empty
/// trash is a no-op — which is what makes the two paths that call this (a window closed on its
/// own, and the board's close flush driving it) safe to both exist.
public func endSession() {
editing?.endOnClose()
editing = nil
composer.flush()
purgeTrash?()
}
/// Whether this pane holds content its files do not — the inline session's buffer, and only it.
///
/// **The draft is deliberately not counted.** 05 is explicit that the composer needs no
/// save-or-lose ceremony ("Close and quit just proceed — no DirtyBufferGuard, nothing to lose"),
/// and this property's one consumer is File ▸ Save as Template's carve-out, which is about
/// keystrokes a suspended save cannot flush. An inline comment edit *is* such a case — it is a
/// body-edit session in miniature — so it counts exactly as the body's does.
public var holdsUnsavedContent: Bool {
editing?.isDirty == true
}
}
// MARK: - The focused value
/// The focused card window's comments pane, beside `FocusedValues.cardAttachments` — see
/// `FocusedBoardStoreKey` for why window-scoped menu items reach their window this way.
struct FocusedCardCommentsKey: FocusedValueKey {
typealias Value = CardComments
}
extension FocusedValues {
var cardComments: CardComments? {
get { self[FocusedCardCommentsKey.self] }
set { self[FocusedCardCommentsKey.self] = newValue }
}
}