The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
484 lines
24 KiB
Swift
484 lines
24 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?
|
|
|
|
/// The card's title, as the announcer needs it — **the subject of every path-shaped comment
|
|
/// sentence** ("New comment on '⟨card⟩'"). Re-derived from every snapshot by the host, so a card
|
|
/// renamed mid-session is announced under its new name.
|
|
public var cardTitle: String?
|
|
|
|
/// 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: Find
|
|
|
|
/// **The pane's find session** — Edit ▸ Find over the whole rendered thread (05-card-window.md ▸
|
|
/// Preview; `CommentThreadFind`). One per window like everything else here, because two card
|
|
/// windows are two threads and two searches.
|
|
public let find = CommentThreadFind()
|
|
|
|
/// **Which surface inside the pane holds the keyboard**, as its own text views report it — the
|
|
/// input ⌘F routes on (`CardWindowFind.route`).
|
|
///
|
|
/// `BoardSearchPresentation.isFocused`'s shape and for its reason: it is the *view's* answer,
|
|
/// written on `becomeFirstResponder`/`resignFirstResponder`, which is what makes it true for
|
|
/// AppKit's own key-view traversal as well as for a click. Inferring it from SwiftUI focus state
|
|
/// would be inferring it from the wrong responder chain — every text surface in this pane is an
|
|
/// `NSTextView`.
|
|
public private(set) var paneFocus: CommentPaneFocus?
|
|
|
|
/// Puts the stock find bar over whichever authoring editor is focused — filled in by that editor
|
|
/// as it takes the keyboard, and cleared as it loses it. `nil` whenever the focus is not an
|
|
/// authoring surface, which is exactly when there is no such find to run.
|
|
@ObservationIgnored
|
|
public private(set) var authoringFindInText: (() -> Void)?
|
|
|
|
// 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:)`.
|
|
///
|
|
/// **It is a sweep of *residue*, not of the trash** (13-native-undo.md ▸ Interaction with the
|
|
/// trash, ruled 2026-07-31): content a live board step still restores from is that step's backing
|
|
/// and is left standing, so reopening a card can never destroy the last session's undo. **Nor the
|
|
/// bytes a collision left**, since the same day's skip-purge decoupling: a coarse step popped as
|
|
/// stale applied nothing and destroys nothing, and what it was holding stands until the board
|
|
/// session ends. The gate lives store-side, where the stack is; this seam is unchanged and still
|
|
/// called exactly once, by `open()`.
|
|
@ObservationIgnored
|
|
public var sweepTrashResidue: (() -> Void)?
|
|
|
|
/// The deferred purge — `BoardStore.purgeCommentTrash(inCard:)`, run through `purgeTrashNow()`
|
|
/// by whoever owes it (see that method).
|
|
@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)?
|
|
|
|
/// One inline edit **session**'s undo step — `BoardStore.registerCommentEdit(...)`, handed to each
|
|
/// session beside its save and called once, at the session's commit point (13-native-undo.md
|
|
/// ▸ Rules ▸ coalescing; `CommentEditSession.registerUndo`).
|
|
@ObservationIgnored
|
|
public var registerCommentEdit: ((ItemID, String, String) -> Void)?
|
|
|
|
/// 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)?
|
|
|
|
/// **Which of this thread's changes the app itself wrote** — `BoardStore.vouchedComments(inCard:)`,
|
|
/// consumed once per reload so a foreign change is never mistaken for an echo or the other way
|
|
/// about (10-accessibility.md ▸ Live board announcements).
|
|
@ObservationIgnored
|
|
public var vouchedComments: (() -> Set<ItemID>)?
|
|
|
|
/// **This pane's outlet for spoken announcements** — `AccessibilityAnnouncer.post`, the app's one
|
|
/// `NSAccessibility.post` call site, injectable exactly as `BoardStore.announce` is and for its
|
|
/// reason: what is *said* is decided by pure functions, and a suite has to be able to read the
|
|
/// sentence without a screen reader attached.
|
|
@ObservationIgnored
|
|
public var announce: @MainActor (String?) -> Void = { AccessibilityAnnouncer.post($0) }
|
|
|
|
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?()
|
|
// **The opening read announces nothing.** Every comment on the card is "new" relative to the
|
|
// empty thread this pane starts with, and narrating a thread the user has just chosen to open
|
|
// would be the app describing its own window (10-accessibility.md's rationing, applied to the
|
|
// one reload that is not a change at all).
|
|
reload(announcing: false)
|
|
}
|
|
|
|
/// 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() {
|
|
reload(announcing: true)
|
|
}
|
|
|
|
/// - Parameter announcing: whether a foreign change in this re-read is worth speech. `false` for
|
|
/// the window's opening read only (see `open()`); every other caller — the FSEvents reload, and
|
|
/// the immediate re-read a gesture takes — passes `true`, because the *narrowing* that keeps a
|
|
/// gesture silent is the ledger's rather than the call site's.
|
|
private func reload(announcing: Bool) {
|
|
guard let readThread else { return }
|
|
let previous = thread
|
|
let thread = readThread()
|
|
let draft = readDraft?()
|
|
|
|
withAnimation(nil) {
|
|
self.thread = thread
|
|
}
|
|
if announcing {
|
|
announceForeignChanges(from: previous, to: 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)
|
|
}
|
|
}
|
|
|
|
/// **Foreign comment changes speak path-shaped** (10-accessibility.md ▸ Comments: "Foreign comment
|
|
/// arrivals announce path-shaped ('New comment on '⟨card⟩'') — the window-scoped read never blocks
|
|
/// the announcement, which composes from the path alone").
|
|
///
|
|
/// Three steps, each of them somebody else's rule: the two threads are diffed
|
|
/// (`CommentThreadChanges.between`), the ledger's receipts are consumed to narrow the result to
|
|
/// what nobody vouched for (`BoardStore.vouchedComments(inCard:)` — which is where
|
|
/// `CommentPath.classify` reads the path shape), and the announcer picks one polite sentence
|
|
/// (`BoardAnnouncer.commentSpeech(for:onCard:)`). Nothing here is a decision, which is what lets
|
|
/// all three be checked without a window.
|
|
///
|
|
/// **The receipts are consumed whether or not the thread changed.** A reload that observed an
|
|
/// app-mediated edit and a reload that observed nothing must both leave the ledger clean: an
|
|
/// uncollected comment receipt would silence the *next* change to that comment, which is exactly
|
|
/// the misattribution the one-write-one-echo rule exists to prevent.
|
|
private func announceForeignChanges(from old: CommentThread, to new: CommentThread) {
|
|
let vouched = vouchedComments?() ?? []
|
|
let changes = CommentThreadChanges.between(old, new).excluding(vouched)
|
|
announce(BoardAnnouncer.commentSpeech(for: changes, onCard: cardTitle))
|
|
}
|
|
|
|
// 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: - Focus, as the pane's text views report it
|
|
|
|
/// One of the pane's text surfaces took the keyboard.
|
|
///
|
|
/// - Parameter findInText: the stock find bar over *that* editor, for an authoring surface. A
|
|
/// reading surface passes none — its find is the thread's, which is this object's own.
|
|
public func focusEntered(_ focus: CommentPaneFocus, findInText: (() -> Void)? = nil) {
|
|
paneFocus = focus
|
|
authoringFindInText = findInText
|
|
}
|
|
|
|
/// One of them lost it — **ignored unless it is still the one on record**.
|
|
///
|
|
/// AppKit resigns the outgoing responder before the incoming one becomes first, so the ordinary
|
|
/// case is already safe; the guard covers the one that is not, a late resignation arriving after
|
|
/// a sibling has claimed focus. Without it, clicking from one comment straight into the composer
|
|
/// could leave the pane reporting no focus at all.
|
|
public func focusLeft(_ focus: CommentPaneFocus) {
|
|
guard paneFocus == focus else { return }
|
|
paneFocus = nil
|
|
authoringFindInText = nil
|
|
}
|
|
|
|
/// **⌘F with this pane focused** — routed by `CardWindowFind.route`, which is where the rule lives.
|
|
/// Answers whether it handled the key, so `FindCommand` can fall through to the body surface.
|
|
@discardableResult
|
|
public func invokeFind(hasBody: Bool) -> Bool {
|
|
switch CardWindowFind.route(
|
|
paneFocus: paneFocus,
|
|
isThreadFindShowing: find.isShowing,
|
|
hasBody: hasBody
|
|
) {
|
|
case .thread:
|
|
find.invoke()
|
|
return true
|
|
case .authoring:
|
|
// "The composer and an inline comment edit are their own focused text surfaces with the
|
|
// editor's ordinary find" (05). The editor already has a find bar and a scroll view to put
|
|
// it in (`CommentTextEditor`); all that was missing is the key, which the menu item's
|
|
// equivalent takes before any text view sees it.
|
|
authoringFindInText?()
|
|
return true
|
|
case .body, nil:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
session.registerUndo = { [weak self] prior, new in
|
|
self?.registerCommentEdit?(commentID, prior, new)
|
|
}
|
|
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 pane's half of the window's close: the saves.**
|
|
///
|
|
/// The inline session flushes as the body's does (a flush, never a revert — a close is not an
|
|
/// abandon), and then the composer's draft lands. Ending twice does nothing the second time — the
|
|
/// sessions latch — 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.
|
|
///
|
|
/// **The purge is no longer here** (re-ruled 2026-07-31 — 13-native-undo.md ▸ Interaction with the
|
|
/// trash): `comments/.trash/` is what the coarse close step's undo restores deleted comments from,
|
|
/// so emptying it belongs *after* that step exists and only when that step does not. The order
|
|
/// this method fixed is unchanged and still load-bearing — saves land before anything empties the
|
|
/// trash — it is now spelled one level up, where the step is registered (`CardWindowSession`),
|
|
/// because that is the only level that knows whether the purge is owed now or owed later.
|
|
public func endSession() {
|
|
editing?.endOnClose()
|
|
editing = nil
|
|
composer.flush()
|
|
}
|
|
|
|
/// **Empties `comments/.trash/`** — the deferred purge, run by whoever currently owes it: the
|
|
/// window's close when no coarse step took it, or that step's retirement when one did.
|
|
///
|
|
/// A purge over an empty trash is a no-op, which is what keeps the two owners from having to agree
|
|
/// about anything but who calls first.
|
|
public func purgeTrashNow() {
|
|
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 }
|
|
}
|
|
}
|