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 — `//`. `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:)`. @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)? /// **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)? /// **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 } 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 } } }