import AppKit import SwiftUI import UniformTypeIdentifiers // MARK: - CardCommentsPane /// The card window's **comments pane** — the middle of the three componentized panes /// (05-card-window.md ▸ Composition, ▸ The comments column). /// /// ### It does not know where it is mounted /// /// Beside the body or under it, the pane is identical — "the panes are identical in both mounts" — /// so nothing in this file asks. It fills the frame it is given, scrolls its own content, and the /// arrangement is `CardWindowView`'s (`CommentsMount`). That is the componentization the 2026-07-29 /// re-composition asks for, stated as an absence: there is no layout parameter here to get wrong. /// /// ### Header, thread, composer — and the composer is at the newest end /// /// > The composer sits at the thread's newest end (bottom ascending, top descending) and the window /// > opens scrolled to it — a thread opens where the conversation is happening. /// /// Both halves come from one value (`CommentSortDirection.placesComposerFirst`), so the scroll target /// and the composer's position cannot disagree — a window that opened at the wrong end would be wrong /// only for the users who had flipped the sort, which is exactly the bug that ships. struct CardCommentsPane: View { let comments: CardComments /// The **card**'s folder — what relative images and links in every comment resolve against. let cardFolder: URL? /// The window's thumbnail memory, shared with the sidebar's attachment rows so a file shown in /// both places is rendered once. let thumbnails: AttachmentThumbnailCache /// **App-wide and persisted** (05 ▸ The comments column; 11-command-nexus.md files the header /// control under Configuration controls). Read here rather than mirrored onto the window's handle /// because there is exactly one of it and every open pane obeys it. @AppStorage(AppPreferences.commentsNewestFirstKey) private var newestFirst = false /// The composer's scroll anchor. A constant rather than a generated id because there is one /// composer and two possible places for it. private static let composerAnchor = "comments.composer" private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } private var direction: CommentSortDirection { CommentSortDirection(newestFirst: newestFirst) } private var ordered: [Comment] { direction.apply(to: comments.thread.comments) } var body: some View { VStack(alignment: .leading, spacing: 0) { header .padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: pointSize)) .padding(.top, CardWindowMetrics.gutter(bodyPointSize: pointSize)) thread } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) // 10-accessibility.md's container label for the pane ("Comments, N"). The elements inside it // and their custom actions are phase 3's; the container is here because the pane would // otherwise be an unnamed region the moment it exists. .accessibilityElement(children: .contain) .accessibilityLabel("Comments, \(comments.thread.comments.count)") } // MARK: - Header /// "Comments · 3" with the sort control beside it — the sidebar's own small-caps section header, /// shared rather than restated so the pane and the sidebar read as one window (05: "the section /// header carries the count and the sort-direction control"). private var header: some View { CardSidebarSectionHeader(title: CommentsHeader.title(count: comments.thread.comments.count)) { sortControl } } /// The sort-direction control — **Tab-reachable beside the count** (11-command-nexus.md ▸ /// Configuration controls). /// /// A button rather than a segmented picker: there are two states and the second one is the /// reverse of the first, so a toggle whose glyph says which way the thread currently runs is the /// smaller thing that says the same. Its help text and its accessibility label are the same /// string (`CommentSortDirection.controlLabel`) — one label, two readers. private var sortControl: some View { Button { newestFirst.toggle() } label: { Image(systemName: direction == .ascending ? "arrow.down" : "arrow.up") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) } .buttonStyle(.plain) .help(direction.controlLabel) .accessibilityLabel("Sort") .accessibilityValue(direction.controlLabel) } // MARK: - The thread private var thread: some View { ScrollViewReader { proxy in ScrollView(.vertical) { LazyVStack(alignment: .leading, spacing: CardWindowMetrics.commentSpacing(bodyPointSize: pointSize)) { if direction.placesComposerFirst { composer.id(Self.composerAnchor) } ForEach(ordered) { comment in CommentRowView( comment: comment, comments: comments, cardFolder: cardFolder, thumbnails: thumbnails ) .id(comment.id) } if !direction.placesComposerFirst { composer.id(Self.composerAnchor) } } .padding(CardWindowMetrics.gutter(bodyPointSize: pointSize)) .frame(maxWidth: .infinity, alignment: .leading) } // **The window opens scrolled to the composer** (05). Deferred one turn rather than run // inline: `scrollTo` needs the content laid out to have somewhere to scroll to, and a // thread's rows measure their own rendered height (`CommentBodyView`). .task { await Task.yield() proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom) } // File ▸ Add Comment focuses the composer — which is no use if the composer is off // screen, so the same request scrolls to it. One request, both effects. .onChange(of: comments.focusComposerRequests) { _, _ in proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom) } } } // MARK: - The composer /// **The composer edits `comments/.draft/`** — always visible, at the thread's newest end /// (05 ▸ The comments column). private var composer: some View { CommentComposerView(comments: comments, thumbnails: thumbnails) } } // MARK: - The authoring surfaces' drop carve-out /// **A file dropped within an authoring surface's bounds lands in *that* surface's `attachments/`** /// (05-card-window.md ▸ Attachments, ruled 2026-07-29 — the hover-target carve-out on the /// window-wide card default). /// /// The *arbitration* is SwiftUI's own innermost-target dispatch: this delegate is attached **inside** /// `CardWindowDropDelegate`'s region, so a drag released over the composer or over an open inline /// editor is offered here first and never reaches the window's card default. /// /// The *destination* is `CommentDropCarveOut`, which is why this takes a hover rather than a folder: /// the surface says what the pointer is over and the pure rule says where the files go, so the ruling /// — including which authoring surface wins where they would ever overlap — is checkable without a /// window and cannot drift from what the delegate actually does. A hover the rule resolves to the /// **card** never reaches here at all (no authoring surface is under the pointer, so no authoring /// surface has a drop target on screen), and this refuses it rather than guessing a folder. /// /// Everything else is `CardWindowDropDelegate`'s, deliberately: the same payload predicate (files, /// not folders, not text), the same read-only refusal, the same `.copy` badge, and the same /// asynchronous URL load with the sandbox's security scope around it. Only the destination differs, /// which is the entire point of the carve-out. struct CommentAttachmentDropDelegate: DropDelegate { let comments: CardComments /// What the pointer is over, as this surface knows it — see `CommentDropCarveOut.Hover`. let hover: CommentDropCarveOut.Hover /// Where the rule says the files go, or `nil` for the window-wide card default. private var target: CommentTarget? { guard case let .comment(target) = CommentDropCarveOut.landing(for: hover) else { return nil } return target } private var acceptsFileDrops: Bool { comments.isEditable && target != nil } func validateDrop(info: DropInfo) -> Bool { guard acceptsFileDrops else { return false } return CardWindowDrop.accepts( payloads: info.itemProviders(for: [.fileURL]).map(\.registeredTypeIdentifiers) ) } func dropUpdated(info: DropInfo) -> DropProposal? { DropProposal(operation: validateDrop(info: info) ? .copy : .cancel) } func performDrop(info: DropInfo) -> Bool { guard acceptsFileDrops, let target else { return false } let providers = info.itemProviders(for: [.fileURL]) guard !providers.isEmpty else { return false } let comments = comments Task { @MainActor in var urls: [URL] = [] for provider in providers { if let url = await FileDropLoading.url(from: provider) { urls.append(url) } } guard !urls.isEmpty else { return } comments.importFiles(urls, to: target) } return true } }