Comments, phase 3 — search, the thread find, announcements, and a11y
Board search reaches comment bodies through a search-owned transient
index: the first live-query keystroke sweeps comments/*/index.md
off-actor (.draft and comments/.trash excluded), keystrokes re-filter
in memory, the index discards on clear — the snapshot stays O(cards).
⌘F routes by focus: the comments pane gets an app-owned find bar
spanning the whole rendered thread (next/prev cross rows with
wraparound); body and composer keep NSTextFinder; Find Next/Previous
graduate from FutureCommands. Foreign comment changes speak
path-shaped beside the announcer's ladder ("New comment on 'X'",
plural folds), narrowed by EchoLedger receipts consumed through
CommentPath.classify — and that read fixed a latent footprint bug
where a comment receipt resolved against the card's attachment
listing, read .absent, and classified the user's own write as
foreign. The pane completes its a11y story: flattened comment
elements with Edit/Delete/Reveal custom actions (un-flattening
during inline edit), phrase-table vocabulary, labeled composer and
sort control, and an audit over the open pane on a comment-seeded
fixture (runnable only where automation permission exists).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -362,6 +362,11 @@ struct CardWindowHost: View {
|
||||
attachments.cardFolder = folder
|
||||
session.comments.cardFolder = folder
|
||||
}
|
||||
// The announcer's subject, re-derived from every snapshot for the folder's reason: a card
|
||||
// renamed mid-session is announced under its new name ("New comment on '⟨card⟩'").
|
||||
.onChange(of: placement.card.title.value, initial: true) { _, title in
|
||||
session.comments.cardTitle = title
|
||||
}
|
||||
.onChange(of: store.isReadOnly, initial: true) { _, locked in
|
||||
attachments.isEditable = !locked
|
||||
session.comments.isEditable = !locked
|
||||
@@ -372,13 +377,15 @@ struct CardWindowHost: View {
|
||||
// *Any* reload, not a filtered one, and that is a deliberate choice worth stating: the
|
||||
// store's observable surface publishes `snapshotGeneration` and a `BoardModel` — it does
|
||||
// not vend the changed paths, and comments are outside the snapshot entirely
|
||||
// (01-storage-format.md § Enhanced schema), so there is nothing here to run
|
||||
// `CommentPath.classify` against. Re-reading one card's thread is a handful of small
|
||||
// files and happens only while a card window is open; filtering would mean either
|
||||
// widening the store's surface to carry paths, or the pane keeping its own watcher — a
|
||||
// second stream over the same tree, which the one-way flow rules out. `initial:` is
|
||||
// deliberately absent: `start()` already did the opening read, after the residue sweep
|
||||
// that has to precede it.
|
||||
// (01-storage-format.md § Enhanced schema), so there is nothing to filter *on* here.
|
||||
// Re-reading one card's thread is a handful of small files and happens only while a card
|
||||
// window is open; filtering would mean either widening the store's surface to carry paths,
|
||||
// or the pane keeping its own watcher — a second stream over the same tree, which the
|
||||
// one-way flow rules out. The path shape is read on the other side of the re-read instead,
|
||||
// where there *are* two pictures to compare: the pane diffs its threads and consumes the
|
||||
// ledger's comment receipts through `CommentPath.classify` to tell a foreign arrival from
|
||||
// its own echo (`CardComments.reload`). `initial:` is deliberately absent: `start()`
|
||||
// already did the opening read, after the residue sweep that has to precede it.
|
||||
.onChange(of: store.snapshotGeneration) { _, _ in
|
||||
session.comments.reload()
|
||||
}
|
||||
@@ -577,6 +584,13 @@ struct CardWindowHost: View {
|
||||
comments.composer.post = { [weak store] in
|
||||
store?.postComment(inCard: cardID)
|
||||
}
|
||||
// The announcer's gate: which of this thread's changes the app itself wrote, consumed once per
|
||||
// reload (10-accessibility.md — "app-mediated echoes never announce", per comment). A store
|
||||
// that has gone vouches for nothing, which is the conservative direction and also the one the
|
||||
// announcement cannot reach anyway — a released store has no window left to speak in.
|
||||
comments.vouchedComments = { [weak store] in
|
||||
store?.vouchedComments(inCard: cardID) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// Points the attachments section at its card — **the one place Add Attachment… and Remove
|
||||
|
||||
@@ -54,14 +54,36 @@ struct FutureCommand: View {
|
||||
/// "disabled in the board window — board search is a live filter, not a cursor"
|
||||
/// (11-command-nexus.md).
|
||||
///
|
||||
// m6-card-window: joins `FindCommand` in the Edit menu once the card window's find-in-text exists
|
||||
// (05-card-window.md). Both rows are unconditionally disabled here rather than reading `boardStore`
|
||||
// to prove "board window" disables them: there is no card-window find session anywhere yet for
|
||||
// either validation branch to check.
|
||||
/// ### They are live for exactly one find, and disabled for the others on purpose
|
||||
///
|
||||
/// The card window has three finds (`CardWindowFindRoute`), and two of them are **`NSTextFinder`**'s —
|
||||
/// the body's and an authoring editor's. `NSTextView` already answers ⌘G and ⇧⌘G through the responder
|
||||
/// chain, and an *enabled* menu item's key equivalent fires before the responder chain is consulted,
|
||||
/// so a row that claimed the chord unconditionally would break the stepping it exists to provide. So
|
||||
/// these validate on the **thread** find alone — the one find with no responder to fall through to,
|
||||
/// because its bar is the app's own — and stay disabled everywhere else, which lets the platform's
|
||||
/// stepping keep working where the platform owns the find.
|
||||
///
|
||||
/// `.disabled` on the rows rather than a guard in the action, for the reason every menu row here
|
||||
/// wears its validation: a key equivalent that fires and does nothing is a chord the user cannot tell
|
||||
/// from a broken one.
|
||||
struct FindSteppingCommands: View {
|
||||
|
||||
@FocusedValue(\.cardComments) private var comments
|
||||
|
||||
/// The row's validation, as a value a test can hold: the pane's find bar is up, which is the only
|
||||
/// state in which this app owns ⌘G.
|
||||
static func isEnabled(_ comments: CardComments?) -> Bool {
|
||||
comments?.find.isShowing == true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
FutureCommand(title: "Find Next", key: "g", modifiers: .command)
|
||||
FutureCommand(title: "Find Previous", key: "g", modifiers: [.shift, .command])
|
||||
Button("Find Next") { comments?.find.step(forward: true) }
|
||||
.keyboardShortcut("g", modifiers: .command)
|
||||
.disabled(!Self.isEnabled(comments))
|
||||
Button("Find Previous") { comments?.find.step(forward: false) }
|
||||
.keyboardShortcut("g", modifiers: [.shift, .command])
|
||||
.disabled(!Self.isEnabled(comments))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -334,6 +334,40 @@ enum UITestLaunch {
|
||||
The audit fixture's attachment. Its only job is to exist, so the attachments section has a row.
|
||||
"""
|
||||
|
||||
/// **The rich card's comment thread** — three comments, because the pane's accessibility audit
|
||||
/// needs one of each shape 10-accessibility.md's comments row can take (▸ Comments: "each comment
|
||||
/// is one flattened element — author, date, edited state, body").
|
||||
///
|
||||
/// In order: an ordinary comment (author line, date, body), an **authorless** one (the date alone
|
||||
/// carries the line — "missing renders unattributed", and no placeholder stands in for a name),
|
||||
/// and one that has been **edited** (its author line ends "· edited", which is `modified`
|
||||
/// differing from `created` and no extra field). Between them they cover every branch of
|
||||
/// `CommentAuthorLine.text(author:timestamp:isEdited:)` that a written file can produce.
|
||||
static let commentBodies = [
|
||||
"""
|
||||
The audit's specimen thread. This one is ordinary: a name, a date, and a paragraph of \
|
||||
Markdown with some *emphasis* in it.
|
||||
""",
|
||||
"""
|
||||
This one has no `author` key at all, so it renders unattributed — a date and a body, and no \
|
||||
placeholder standing in for a name.
|
||||
""",
|
||||
"""
|
||||
And this one has been edited since it was posted, so its author line carries the edited marker.
|
||||
"""
|
||||
]
|
||||
|
||||
/// Which comment gets its `author` key removed, by index into `commentBodies`.
|
||||
static let authorlessCommentIndex = 1
|
||||
|
||||
/// Which comment is edited after posting, by index into `commentBodies`, and what it is edited to.
|
||||
static let editedCommentIndex = 2
|
||||
|
||||
static let editedCommentBody = """
|
||||
And this one has been edited since it was posted, so its author line carries the edited \
|
||||
marker — this sentence is the edit.
|
||||
"""
|
||||
|
||||
/// The card that is deleted into `.trash/`, named by `(lane, card)` index.
|
||||
///
|
||||
/// A trash with something in it is the only way the trash-shown audit reaches the elements
|
||||
@@ -460,6 +494,7 @@ enum UITestLaunch {
|
||||
let richCard = cardURLs[richCardIndex.lane][richCardIndex.card]
|
||||
try BoardWriter.writeBody(inItemFolder: richCard, body: richCardBody)
|
||||
try importFixtureAttachment(into: richCard)
|
||||
try seedCommentThread(into: richCard, cardTitle: cardTitles[richCardIndex.lane][richCardIndex.card])
|
||||
|
||||
// The delete goes last so the trashed card's identity is one the lanes above have already
|
||||
// finished with — and through the ordinary delete door, so `.trash/` ends up holding exactly
|
||||
@@ -534,6 +569,63 @@ enum UITestLaunch {
|
||||
return root
|
||||
}
|
||||
|
||||
/// Builds the rich card's thread **the way the composer does** — a draft saved, then posted, once
|
||||
/// per body — so the fixture's `comments/` is a folder the app made: minted identities, the
|
||||
/// `kind: comment` field table, `created`/`modified` restamped at the post.
|
||||
///
|
||||
/// Two of the three then need a shape no gesture in the app produces, and each is applied
|
||||
/// afterwards, narrowly:
|
||||
///
|
||||
/// - **The edit** is an ordinary Writer call (`editComment`), which is exactly what an inline edit
|
||||
/// session's save does — so the edited marker in the fixture is the real mechanism, not a
|
||||
/// hand-set field.
|
||||
/// - **The two frontmatter amendments** are raw writes, for the malformed variant's reason (see
|
||||
/// this type's note). Neither shape has a door in the app: it always writes the account's full
|
||||
/// name, and it cannot post a comment an hour ago. Both shapes are ordinary on disk, though —
|
||||
/// agents and tracker sync write comments with no `author` at all (01-storage-format.md ▸
|
||||
/// Enhanced schema), and every comment that has ever been edited was posted before it. Both go
|
||||
/// through `FrontmatterDocument`, so the rest of each file is byte-identical to what the Writer
|
||||
/// produced.
|
||||
///
|
||||
/// The backdating is not decoration: `created` and `modified` serialize to the second, and a
|
||||
/// comment posted and edited inside one second would render as **not** edited — the marker is
|
||||
/// `modified` differing from `created` and no extra field (`Comment.isEdited`).
|
||||
private static func seedCommentThread(into cardFolder: URL, cardTitle: String) throws {
|
||||
var posted: [ItemID] = []
|
||||
for body in commentBodies {
|
||||
try BoardWriter.saveCommentDraft(inCard: cardFolder, body: body, cardTitle: cardTitle)
|
||||
posted.append(try BoardWriter.postComment(inCard: cardFolder, cardTitle: cardTitle).id)
|
||||
}
|
||||
|
||||
try BoardWriter.editComment(
|
||||
at: CommentThread.commentFolder(posted[editedCommentIndex], inCard: cardFolder),
|
||||
body: editedCommentBody,
|
||||
cardTitle: cardTitle
|
||||
)
|
||||
|
||||
try amendComment(posted[authorlessCommentIndex], inCard: cardFolder) { document in
|
||||
document.remove(FrontmatterKeys.author)
|
||||
}
|
||||
try amendComment(posted[editedCommentIndex], inCard: cardFolder) { document in
|
||||
document.set(FrontmatterKeys.created, to: .date(Date().addingTimeInterval(-3600)))
|
||||
}
|
||||
}
|
||||
|
||||
/// One comment's frontmatter, amended in place — the fixture's narrow way around the Writer, kept
|
||||
/// to one function so both amendments share its round trip and neither invents a second one.
|
||||
private static func amendComment(
|
||||
_ id: ItemID,
|
||||
inCard cardFolder: URL,
|
||||
_ amend: (inout FrontmatterDocument) -> Void
|
||||
) throws {
|
||||
let indexURL = CommentThread
|
||||
.commentFolder(id, inCard: cardFolder)
|
||||
.appendingPathComponent(BoardLoader.indexFileName, isDirectory: false)
|
||||
var document = try FrontmatterDocument.parse(String(decoding: try Data(contentsOf: indexURL), as: UTF8.self))
|
||||
amend(&document)
|
||||
try Data(document.serialized().utf8).write(to: indexURL, options: .atomic)
|
||||
}
|
||||
|
||||
/// Writes the attachment's source into the scratch root and imports it the way a Finder drop
|
||||
/// would (`BoardWriter.importAttachments`), so the card ends up with a real `attachments/`
|
||||
/// folder rather than a hand-placed file the loader would have to normalize.
|
||||
|
||||
@@ -265,6 +265,34 @@ public enum BoardAnnouncer {
|
||||
return AccessibilityPhrases.boardChanged(facts.diff)
|
||||
}
|
||||
|
||||
// MARK: - A card window's thread
|
||||
|
||||
/// **What a card window's landed thread re-read says out loud** — the ladder's sibling for the one
|
||||
/// kind of change the board's snapshot cannot see (10-accessibility.md ▸ Comments;
|
||||
/// 01-storage-format.md ▸ Enhanced schema's path shape).
|
||||
///
|
||||
/// ### Why it is beside the ladder rather than a sixth rung on it
|
||||
///
|
||||
/// `speech(for:)` answers "what does *this board reload* say", and its rationing — one sentence per
|
||||
/// reload debounce — is about the board window's own churn. A thread change is not in that reload's
|
||||
/// picture at all: comments are outside the snapshot, so the facts arrive from the card window's
|
||||
/// own re-read, on a different window, about a different element. Folding it into the ladder would
|
||||
/// mean either the board reload waiting on a window-scoped disk read (which 01 rules out) or the
|
||||
/// ladder silently dropping a comment sentence whenever a banner also moved.
|
||||
///
|
||||
/// What it *does* share is everything that matters: the same doctrine (foreign only — the narrowing
|
||||
/// is the caller's, through `CommentThreadChanges.excluding(_:)`), the same rationing (one optional
|
||||
/// sentence, chosen by precedence), the same vocabulary (`AccessibilityPhrases`), and the same
|
||||
/// posting seam (`AccessibilityAnnouncer.post`, medium priority, attributed to the key window —
|
||||
/// which for a thread change is the card window the user is looking at).
|
||||
///
|
||||
/// `nil` — silence — for a reload that changed nothing in the thread, which is the overwhelming
|
||||
/// majority of them, and for one whose every change the app itself wrote.
|
||||
public static func commentSpeech(for changes: CommentThreadChanges, onCard title: String?) -> String? {
|
||||
guard !changes.isEmpty else { return nil }
|
||||
return AccessibilityPhrases.commentsChanged(changes, onCard: title)
|
||||
}
|
||||
|
||||
/// A standing condition that was not there before and is now — announced with the banner row's
|
||||
/// own label, so the sentence a VoiceOver user hears is the sentence the strip is showing.
|
||||
private static func raisedCondition(_ facts: ReloadFacts) -> String? {
|
||||
|
||||
@@ -348,6 +348,15 @@ public final class BoardStore: HealHost {
|
||||
@ObservationIgnored
|
||||
public let echoes = EchoLedger()
|
||||
|
||||
/// **Board search's transient comment index** (04-interactions.md ▸ Search, re-ruled 2026-07-29)
|
||||
/// — the sweep that lets a query reach comment bodies without the snapshot ever carrying one.
|
||||
///
|
||||
/// A `let` beside `echoes` and `heals`, and *not* `@ObservationIgnored`: `searchFilter` reads
|
||||
/// `matchingCards`, so every surface that filters through the store re-renders when a sweep lands.
|
||||
/// That refinement arriving a moment after the keystroke is the design's own accepted behaviour —
|
||||
/// see `CommentSearchIndex` for the freshness rule and its interim.
|
||||
public let commentIndex = CommentSearchIndex()
|
||||
|
||||
/// The rows the board window's strip renders, in precedence order.
|
||||
///
|
||||
/// Composed rather than stored: `readOnlyLock` and `reloadFailure` are the store's truths and
|
||||
@@ -756,7 +765,13 @@ public final class BoardStore: HealHost {
|
||||
// "ride whatever transaction is active rather than easing on its own", and a
|
||||
// re-grounding that landed outside this one would be exactly the independent ease
|
||||
// that rules out.
|
||||
transient.resolve(against: result.model)
|
||||
//
|
||||
// The comment index' current answer rides along, because the filter half of the
|
||||
// re-grounding is the *whole* filter (04 ▸ Search, re-ruled 2026-07-29) — a card
|
||||
// matching only through its comments must not be evicted from the selection by a
|
||||
// reload that asked a narrower question. The index is re-swept just below, outside
|
||||
// the transaction, and its landing re-runs this constraint through `onRefine`.
|
||||
transient.resolve(against: result.model, commentMatches: commentIndex.matchingCards)
|
||||
// And *then* the recovery, on top of the set rule rather than instead of it: the
|
||||
// resolution leaves an emptied selection wherever the focused item used to be, and
|
||||
// this is 10-accessibility.md's answer to the hole ("focus recovers to the card's
|
||||
@@ -772,6 +787,14 @@ public final class BoardStore: HealHost {
|
||||
// this one did not.
|
||||
reloadFailure = nil
|
||||
defects = result.defects
|
||||
// **The comment index' freshness signal, and its stated interim** (04-interactions.md ▸
|
||||
// Search: "kept fresh by the same FSEvents stream while a query is active"). The store has
|
||||
// no changed-path channel — the watcher reports only *that* the tree changed
|
||||
// (02-architecture.md) — so what a landed reload can offer an index of window-scoped
|
||||
// content is its generation, and a query still active re-sweeps on it. Coarser than the
|
||||
// ruling asks for and bounded by the same debounce; a no-op with no query running, which
|
||||
// is the overwhelmingly common reload.
|
||||
refreshCommentIndex()
|
||||
reconcileLock(after: origin)
|
||||
// The registry write-through, for the same "not board structure" reason the lock
|
||||
// clearing sits out here: whether this board's row needs a new title, icon, or
|
||||
@@ -3977,13 +4000,42 @@ public final class BoardStore: HealHost {
|
||||
set {
|
||||
guard newValue != transient.searchQuery else { return }
|
||||
transient.searchQuery = newValue
|
||||
transient.constrainToSearch(in: snapshot)
|
||||
// **The comment index tracks the query, not the reload** (04 ▸ Search, re-ruled
|
||||
// 2026-07-29): the first keystroke of a query kicks the sweep, every later one re-filters
|
||||
// what it found, and clearing throws the whole thing away. Before the constraint, so a
|
||||
// keystroke that *widens* the comment matches does not first evict a selection the very
|
||||
// next line would have kept.
|
||||
refreshCommentIndex()
|
||||
transient.constrainToSearch(in: snapshot, commentMatches: commentIndex.matchingCards)
|
||||
}
|
||||
}
|
||||
|
||||
/// The query as the predicate, for the selection grammar's order lists — read wherever the board
|
||||
/// asks "what is on the board, in what order" (`SelectionGrammar.order`).
|
||||
public var searchFilter: SearchFilter { SearchFilter(query: transient.searchQuery) }
|
||||
///
|
||||
/// **It carries the comment index' answer**, which is what makes "a card matches if any of its
|
||||
/// meaningful content matches" true for comments on every surface at once — the masonry, the
|
||||
/// order lists, the trash column, Select All — without any of them learning that comments exist.
|
||||
public var searchFilter: SearchFilter {
|
||||
SearchFilter(query: transient.searchQuery, commentMatches: commentIndex.matchingCards)
|
||||
}
|
||||
|
||||
/// Points the comment index at the current query and snapshot — the one funnel, called from the
|
||||
/// query's setter and from a landed reload (`land`).
|
||||
///
|
||||
/// The targets are computed **lazily**, inside the index' own decision: an already-fresh index
|
||||
/// re-filters in memory and never asks, so an ordinary keystroke costs no board walk at all.
|
||||
private func refreshCommentIndex() {
|
||||
commentIndex.onRefine = { [weak self] in
|
||||
guard let self else { return }
|
||||
transient.constrainToSearch(in: snapshot, commentMatches: commentIndex.matchingCards)
|
||||
}
|
||||
commentIndex.update(
|
||||
query: transient.searchQuery,
|
||||
generation: snapshotGeneration,
|
||||
targets: CommentSearchIndex.targets(in: snapshot)
|
||||
)
|
||||
}
|
||||
|
||||
/// Clears the search — **Escape's middle step** (04 § Search's staged Escape: "with *board*
|
||||
/// focus and an active search, one press clears the search and the full board returns"), and the
|
||||
|
||||
@@ -83,6 +83,21 @@ extension BoardStore {
|
||||
return CommentThread.loadDraft(inCard: card.folder)
|
||||
}
|
||||
|
||||
/// **Which of this card's comments the app itself just wrote** — the ledger's receipts, classified
|
||||
/// by path shape and retired (`EchoLedger.vouchedComments(inCard:cardPath:)`).
|
||||
///
|
||||
/// The card window asks this on every landed reload, before deciding whether its thread's changes
|
||||
/// are worth announcing: "app-mediated echoes never announce" (10-accessibility.md ▸ Live board
|
||||
/// announcements) needs a per-comment answer, and the board reload cannot give one because comments
|
||||
/// are not in the snapshot it compares.
|
||||
///
|
||||
/// `[]` for a card that is gone — the same vanished-target answer every other card-scoped call
|
||||
/// gives, and the conservative direction: nothing vouched for means everything speaks.
|
||||
public func vouchedComments(inCard id: ItemID) -> Set<ItemID> {
|
||||
guard let card = commentSubject(id) else { return [] }
|
||||
return echoes.vouchedComments(inCard: card.folder, cardPath: card.path)
|
||||
}
|
||||
|
||||
// MARK: The draft
|
||||
|
||||
/// Saves the composer's draft — one bracket, no step.
|
||||
|
||||
@@ -85,3 +85,88 @@ public struct CommentPath: Sendable, Equatable {
|
||||
return CommentPath(cardPath: cardPath, kind: .comment(ItemID(rawValue: entry)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentThreadChanges
|
||||
|
||||
/// **What happened to one card's thread between two reads** — `BoardDiff` one level down, and for the
|
||||
/// same consumer: something has to say what changed before anything can say it out loud.
|
||||
///
|
||||
/// ### Why the thread and not the snapshot
|
||||
///
|
||||
/// `BoardDiff` compares two `BoardModel`s, and a `BoardModel` has never heard of a comment — that is
|
||||
/// 01-storage-format.md ▸ Enhanced schema's stated exception to snapshot completeness, and it is why
|
||||
/// foreign comment changes are "described by **path shape**" rather than by a diff. The path shape
|
||||
/// answers *what kind* of change a path is (`CommentPath` above); this answers *which comments*
|
||||
/// changed, from the only two pictures anything in the app actually holds — the thread the card
|
||||
/// window was showing, and the thread it has just re-read (`CardComments.reload`).
|
||||
///
|
||||
/// ### Three buckets, and the one that is deliberately absent
|
||||
///
|
||||
/// Arrivals, edits and departures, which are the three the verb family names ("Comment on…", "Edit
|
||||
/// comment on…", "Delete comment on…" — 06-history-undo.md's family per 01). **An attachment landing
|
||||
/// on a comment is not a change here**: the comparison is the `index.md` document, so a file imported
|
||||
/// into a comment's `attachments/` moves nothing in this value. That is deliberate rather than an
|
||||
/// omission — 10-accessibility.md announces comment *arrivals*, the design's verb family is about the
|
||||
/// comment's text, and a chip appearing is a visible change with no sentence written for it.
|
||||
///
|
||||
/// `.draft` and `comments/.trash/` never appear because they are not in a thread at all
|
||||
/// (`CommentThread`'s own exclusion), so "a draft saved on another machine" and "a comment this window
|
||||
/// deleted a moment ago" are both silent by construction rather than by a filter.
|
||||
public struct CommentThreadChanges: Sendable, Equatable {
|
||||
|
||||
/// Comments in the new thread that were not in the old one.
|
||||
public var arrived: [ItemID] = []
|
||||
/// Comments in both, whose `index.md` differs.
|
||||
public var edited: [ItemID] = []
|
||||
/// Comments in the old thread that are not in the new one.
|
||||
public var deleted: [ItemID] = []
|
||||
|
||||
public init(arrived: [ItemID] = [], edited: [ItemID] = [], deleted: [ItemID] = []) {
|
||||
self.arrived = arrived
|
||||
self.edited = edited
|
||||
self.deleted = deleted
|
||||
}
|
||||
|
||||
public var isEmpty: Bool {
|
||||
arrived.isEmpty && edited.isEmpty && deleted.isEmpty
|
||||
}
|
||||
|
||||
/// The comparison. Order follows the **new** thread for the two buckets it can (the display order
|
||||
/// the loader produced) and the old thread for departures, so a caller naming "the" arrival names
|
||||
/// the one a reader would reach first.
|
||||
public static func between(_ old: CommentThread, _ new: CommentThread) -> CommentThreadChanges {
|
||||
let older = Dictionary(old.comments.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
||||
let newer = Set(new.comments.map(\.id))
|
||||
|
||||
var changes = CommentThreadChanges()
|
||||
for comment in new.comments {
|
||||
guard let was = older[comment.id] else {
|
||||
changes.arrived.append(comment.id)
|
||||
continue
|
||||
}
|
||||
if was.document != comment.document {
|
||||
changes.edited.append(comment.id)
|
||||
}
|
||||
}
|
||||
for comment in old.comments where !newer.contains(comment.id) {
|
||||
changes.deleted.append(comment.id)
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
/// This value with everything the `EchoLedger` vouched for removed — **the app's own writes never
|
||||
/// announce as foreign** (10-accessibility.md ▸ Live board announcements), applied per comment.
|
||||
///
|
||||
/// It is a narrowing rather than a gate for `EchoVerdicts.foreign`'s reason: a reload can carry an
|
||||
/// app-mediated edit *and* a foreign arrival at once (an agent files a comment in the same debounce
|
||||
/// window as the user's inline save), and the honest sentence describes the half nobody vouched
|
||||
/// for rather than all of it or none of it.
|
||||
public func excluding(_ vouched: Set<ItemID>) -> CommentThreadChanges {
|
||||
guard !vouched.isEmpty else { return self }
|
||||
return CommentThreadChanges(
|
||||
arrived: arrived.filter { !vouched.contains($0) },
|
||||
edited: edited.filter { !vouched.contains($0) },
|
||||
deleted: deleted.filter { !vouched.contains($0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
// MARK: - What one card contributes to the sweep
|
||||
|
||||
/// One card the sweep will read a thread from: its identity, and where its folder is right now.
|
||||
///
|
||||
/// A value rather than a pair of parallel arrays because the sweep runs **off the main actor** and
|
||||
/// the snapshot cannot travel with it — `BoardModel` is the board's whole tree, and handing it to a
|
||||
/// detached task to walk twice would be doing the walk the design keeps at O(cards) all over again.
|
||||
/// So the walk happens once, on the main actor, and what crosses the boundary is this: identity plus
|
||||
/// a URL, per card, and nothing else.
|
||||
public struct CommentSearchTarget: Sendable, Equatable {
|
||||
public let id: ItemID
|
||||
/// The card's folder — `<root>/<lane>/<card>`, or `<root>/.trash/<card>`. The thread hangs off it
|
||||
/// (`CommentThread.folder(inCard:)`).
|
||||
public let folder: URL
|
||||
|
||||
public init(id: ItemID, folder: URL) {
|
||||
self.id = id
|
||||
self.folder = folder
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentSearchIndex
|
||||
|
||||
/// **Board search's transient comment index** — the search-owned sweep that lets a query reach comment
|
||||
/// *bodies* without the board snapshot ever carrying one (04-interactions.md ▸ Search, re-ruled
|
||||
/// 2026-07-29; 01-storage-format.md ▸ Enhanced schema):
|
||||
///
|
||||
/// > **comment bodies join when comments ship** — via a search-owned transient comment index, never
|
||||
/// > the snapshot: the first live-query keystroke kicks an async sweep of `comments/*/index.md` bodies
|
||||
/// > (`.draft` and `comments/.trash/` excluded), kept fresh … while a query is active and discarded
|
||||
/// > when it clears — the board walk stays O(cards), 01's window-scoped read untouched.
|
||||
///
|
||||
/// ### Why it is its own object and not a member of `TransientBoardState`
|
||||
///
|
||||
/// Because `TransientBoardState`'s charter forbids exactly the thing this is. Its kind 2 is "derived
|
||||
/// state, stored as its inputs only — `searchQuery` is kept; its *result set* is deliberately absent
|
||||
/// … a stored result set would be a second, staler answer to a question the snapshot can always
|
||||
/// answer". This index is the **opposite** case: the snapshot cannot answer it at all, because comment
|
||||
/// content is deliberately outside the snapshot, so there is nothing to re-derive from and the answer
|
||||
/// has to be kept. Keeping it there would mean giving that type a freshness rule of its own — a sweep
|
||||
/// to schedule, a generation to compare, a task to cancel — which is precisely the accretion it exists
|
||||
/// to keep out. So the index lives here, beside `SearchFilter`, owned by the search: one `let` on
|
||||
/// `BoardStore` (`commentIndex`), created with the store and dying with it, like `echoes` and `heals`.
|
||||
///
|
||||
/// ### What it holds, and what that costs
|
||||
///
|
||||
/// The index proper is `[ItemID: [String]]` — one card's posted comment bodies, kept **as a list**
|
||||
/// rather than joined, so a query can never match across the seam between two comments. It exists only
|
||||
/// while a query does: the first keystroke builds it, every later keystroke re-filters it in memory
|
||||
/// (no I/O per keystroke — that is what makes it an index rather than a search), and clearing the
|
||||
/// query throws it away. The honest cost is stated rather than hidden: while a query is active, every
|
||||
/// comment body on the board is in memory. A board with a thousand comments of a paragraph each is a
|
||||
/// few hundred kilobytes, and the alternative — re-reading the tree per keystroke — is the thing an
|
||||
/// index is for.
|
||||
///
|
||||
/// ### Freshness is snapshot-generation-shaped, and that is an interim
|
||||
///
|
||||
/// 04 says "kept fresh by the same FSEvents stream while a query is active". The store has no
|
||||
/// changed-path channel — `FolderWatcher` reports only *that* the tree changed (02-architecture.md),
|
||||
/// and the reload seam turns that into two snapshots rather than a path list — so the freshness signal
|
||||
/// available today is `BoardStore.snapshotGeneration`: **while a query is active, a landed reload
|
||||
/// re-sweeps**. That is coarser than the ruling asks for (it re-reads threads a reload may not have
|
||||
/// touched) and it is bounded by the same thing that bounds the reload itself, the watcher's debounce.
|
||||
/// A changed-path channel would narrow it to the cards whose `comments/` actually moved; until one
|
||||
/// exists, this is the accepted interim and is written down here rather than discovered later.
|
||||
///
|
||||
/// ### Asynchrony, and the first keystroke's honest lag
|
||||
///
|
||||
/// The sweep is I/O and never runs on the main actor. Until it lands, the filter sees whatever the
|
||||
/// index held before — nothing at all on the first keystroke of a fresh query — so **the first
|
||||
/// keystroke may briefly show field-only matches**, and comment matches join a moment later. That is
|
||||
/// the accepted behaviour rather than a defect: the alternative is a synchronous tree read on the
|
||||
/// keystroke path, which is the one thing 01's window-scoped rule exists to prevent. Results
|
||||
/// *refine*; they never regress, because a landed sweep only ever adds the cards it found.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class CommentSearchIndex {
|
||||
|
||||
/// **The cards whose comments match the live query** — the index's whole public surface, and the
|
||||
/// set `BoardStore.searchFilter` hands to `SearchFilter` so that one predicate keeps answering
|
||||
/// "does this card match" for every surface on the board.
|
||||
///
|
||||
/// Empty means "no comment match", which is also what it means with no query, with no index yet,
|
||||
/// and on a board with no comments — four states with one honest answer, so no consumer branches.
|
||||
public private(set) var matchingCards: Set<ItemID> = []
|
||||
|
||||
/// The index proper: posted comment bodies per card, in the thread's own order. `nil` while there
|
||||
/// is no index at all — before the first sweep of a query, and after a clear.
|
||||
///
|
||||
/// Not `@ObservationIgnored`: a view reading `matchingCards` must not also wake on the megabytes
|
||||
/// behind it, but nothing outside this type reads this at all, so its observability costs nothing
|
||||
/// and its privacy is the guard.
|
||||
private var bodies: [ItemID: [String]]?
|
||||
|
||||
/// The query the current `matchingCards` was computed against.
|
||||
private var query = ""
|
||||
|
||||
/// The snapshot generation `bodies` was swept at, and the one a sweep in flight is for. Together
|
||||
/// they are the whole freshness rule: an index at the current generation is fresh, one at an older
|
||||
/// generation is re-swept, and a sweep already in flight for this generation is not started twice.
|
||||
private var sweptGeneration: Int?
|
||||
private var sweepingGeneration: Int?
|
||||
|
||||
/// The sweep in flight. Cancelled — never awaited — when a newer one supersedes it: a stale sweep's
|
||||
/// result would describe a tree that has already changed, and the landing guard drops it anyway.
|
||||
private var sweep: Task<Void, Never>?
|
||||
|
||||
/// **What to do when a sweep lands and the visible universe therefore widened or narrowed** —
|
||||
/// filled in by `BoardStore` with the selection constraint (04-interactions.md ▸ Search's "hidden
|
||||
/// cards leave the selection").
|
||||
///
|
||||
/// It is a seam rather than a call into the store because this type has no business knowing what a
|
||||
/// selection is: the refine is an event, and what it costs is the store's rule. `nil` in every test
|
||||
/// that drives the index alone.
|
||||
@ObservationIgnored
|
||||
public var onRefine: (() -> Void)?
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - The two things that happen to it
|
||||
|
||||
/// **Every write to the query, and every landed reload under one** — the single funnel, so the
|
||||
/// activation, the re-filter and the re-sweep are one decision rather than three call sites
|
||||
/// agreeing.
|
||||
///
|
||||
/// - An **inactive** query discards everything (below), which is 04's "discarded when it clears".
|
||||
/// - An index already at `generation` is simply **re-filtered** — no I/O, which is the whole point
|
||||
/// of holding bodies rather than a result set.
|
||||
/// - Anything else starts a sweep and re-filters against whatever is in hand meanwhile, so the
|
||||
/// board never blanks while a fresher answer is being read.
|
||||
public func update(query: String, generation: Int, targets: @autoclosure () -> [CommentSearchTarget]) {
|
||||
guard SearchFilter(query: query).isActive else {
|
||||
discard()
|
||||
return
|
||||
}
|
||||
self.query = query
|
||||
|
||||
if sweptGeneration != generation, sweepingGeneration != generation {
|
||||
startSweep(generation: generation, targets: targets())
|
||||
}
|
||||
recompute()
|
||||
}
|
||||
|
||||
/// **The query cleared** — the index is thrown away, the sweep in flight is cancelled, and the
|
||||
/// board goes back to being O(cards) with nothing held (04 ▸ Search: "discarded when it clears").
|
||||
///
|
||||
/// Idempotent: `BoardStore.searchQuery`'s setter funnels every clear through here, including the
|
||||
/// ones that were already clear.
|
||||
public func discard() {
|
||||
sweep?.cancel()
|
||||
sweep = nil
|
||||
sweepingGeneration = nil
|
||||
sweptGeneration = nil
|
||||
bodies = nil
|
||||
query = ""
|
||||
matchingCards = []
|
||||
}
|
||||
|
||||
// MARK: - The sweep
|
||||
|
||||
private func startSweep(generation: Int, targets: [CommentSearchTarget]) {
|
||||
sweep?.cancel()
|
||||
sweepingGeneration = generation
|
||||
sweep = Task { [weak self] in
|
||||
// Detached, so the tree read runs off the main actor: this is a directory enumeration and
|
||||
// a frontmatter parse per comment, and it is happening on the keystroke path.
|
||||
let swept = await Task.detached(priority: .userInitiated) {
|
||||
CommentSearchIndex.sweep(targets)
|
||||
}.value
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.land(swept, generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
/// One sweep's result, if it is still the one being waited for.
|
||||
///
|
||||
/// The guard is the stale-apply rule `BoardStore.apply` keeps for reloads, and for its reason: a
|
||||
/// result whose generation is no longer the one in flight describes a tree that has moved on, and
|
||||
/// installing it would make the index *less* fresh than the one it replaced.
|
||||
private func land(_ swept: [ItemID: [String]], generation: Int) {
|
||||
guard sweepingGeneration == generation else { return }
|
||||
sweepingGeneration = nil
|
||||
sweptGeneration = generation
|
||||
bodies = swept
|
||||
let before = matchingCards
|
||||
recompute()
|
||||
// Only a change is worth telling anyone about: a sweep that confirmed what the filter already
|
||||
// showed must not re-run the selection constraint, which is a board-sized set computation.
|
||||
guard matchingCards != before else { return }
|
||||
onRefine?()
|
||||
}
|
||||
|
||||
/// **The sweep itself** — every target's posted comment bodies, read off disk.
|
||||
///
|
||||
/// `nonisolated` and taking only `Sendable` values, because it runs on a detached task. It is total
|
||||
/// and silent: a card with no `comments/`, one whose thread is unreadable, and one holding nothing
|
||||
/// but strays all contribute nothing, and none of them is a defect this read has any business
|
||||
/// reporting — the thread's own loader is where comment defects are logged and healed
|
||||
/// (`CommentThread.load(inCard:path:)`), and a search sweep that logged a warning per stray on
|
||||
/// every keystroke's re-sweep would be a log nobody could read.
|
||||
nonisolated static func sweep(_ targets: [CommentSearchTarget]) -> [ItemID: [String]] {
|
||||
var index: [ItemID: [String]] = [:]
|
||||
for target in targets {
|
||||
if Task.isCancelled { return index }
|
||||
let bodies = CommentThread.searchableBodies(inCard: target.folder)
|
||||
guard !bodies.isEmpty else { continue }
|
||||
index[target.id] = bodies
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/// **Which cards the current query matches through their comments** — `SearchFilter`'s own
|
||||
/// predicate, borrowed rather than restated.
|
||||
///
|
||||
/// That is not tidiness: 04 gives the query *one* matching rule (case- and diacritic-insensitive
|
||||
/// substring, folded locale-stably), and a second spelling of it here would let a board's comments
|
||||
/// filter by a different rule than its titles do. `matches(title:body:)` with no title is exactly
|
||||
/// "does this text contain the query", which is all a comment body has to answer.
|
||||
nonisolated static func matchingCards(in bodies: [ItemID: [String]], query: String) -> Set<ItemID> {
|
||||
let filter = SearchFilter(query: query)
|
||||
guard filter.isActive else { return [] }
|
||||
var matches: Set<ItemID> = []
|
||||
for (id, texts) in bodies where texts.contains(where: { filter.matches(title: nil, body: $0) }) {
|
||||
matches.insert(id)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
private func recompute() {
|
||||
matchingCards = Self.matchingCards(in: bodies ?? [:], query: query)
|
||||
}
|
||||
|
||||
// MARK: - The board's cards, as targets
|
||||
|
||||
/// Every card whose thread the sweep reads — **the live board and the trash**, because a trashed
|
||||
/// card carries its `comments/` (01-storage-format.md ▸ Enhanced schema) and the trash column's
|
||||
/// rows are filtered by the same predicate as the board's.
|
||||
///
|
||||
/// One walk, on the main actor, producing values the sweep can carry (see `CommentSearchTarget`).
|
||||
/// Trashed *lanes* have no thread of their own — the row is an opaque unit matched by title alone
|
||||
/// (`SearchFilter.matches(_ lane:)`) — so they contribute nothing here.
|
||||
public nonisolated static func targets(in snapshot: BoardModel) -> [CommentSearchTarget] {
|
||||
let root = snapshot.rootURL
|
||||
var targets: [CommentSearchTarget] = []
|
||||
for lane in snapshot.lanes {
|
||||
for card in lane.cards {
|
||||
targets.append(CommentSearchTarget(
|
||||
id: card.id,
|
||||
folder: ItemPath.card(lane: lane.id, id: card.id).folder(under: root)
|
||||
))
|
||||
}
|
||||
}
|
||||
for card in snapshot.trash {
|
||||
targets.append(CommentSearchTarget(
|
||||
id: card.id,
|
||||
folder: ItemPath.trashCard(card.id).folder(under: root)
|
||||
))
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
/// Suspends until the sweep in flight has landed — the index's own `awaitQuiescence`, and the only
|
||||
/// way a suite can assert about an answer that arrives a task later.
|
||||
public func awaitSweep() async {
|
||||
await sweep?.value
|
||||
}
|
||||
}
|
||||
@@ -278,6 +278,69 @@ public final class EchoLedger: Sendable {
|
||||
receipts.withLock { $0[path]?.isHeal ?? false }
|
||||
}
|
||||
|
||||
/// **Which comments under one card the app itself just wrote** — the receipts, read through
|
||||
/// `CommentPath.classify`, and **retired on the way out**.
|
||||
///
|
||||
/// This is the comment half of "app-mediated echoes never announce" (10-accessibility.md ▸ Live
|
||||
/// board announcements), and it has to be a separate read because the board reload cannot do the
|
||||
/// job: comments are outside the snapshot, so `verdicts(from:to:diff:includingTrash:)` has no two
|
||||
/// pictures to compare and `Footprint.observations` deliberately skips comment paths (see its
|
||||
/// note). What *does* have two pictures is the card window, which re-reads its thread on every
|
||||
/// landed reload — so it asks this, diffs its thread, and speaks only about the changes nobody
|
||||
/// here vouched for (`CardComments.reload`).
|
||||
///
|
||||
/// ### Why classification rather than a prefix test
|
||||
///
|
||||
/// Because the answer has to distinguish three homes under one prefix, and `CommentPath` is the
|
||||
/// app's one reader of that distinction (01-storage-format.md ▸ Enhanced schema's path shape): a
|
||||
/// receipt under `comments/<uuid>/` vouches for a posted comment, one under `comments/.trash/<uuid>/`
|
||||
/// vouches for a *delete* of that comment — which is the same identity disappearing from the thread
|
||||
/// and must be just as silent — and one under `comments/.draft/` vouches for nothing in the thread
|
||||
/// at all, because a draft is not in it. A `hasPrefix` would fold all three together.
|
||||
///
|
||||
/// ### Retired, not merely read
|
||||
///
|
||||
/// Consumption is what keeps the rule "one write, one echo": the receipts are removed, so a second
|
||||
/// reload observing the same thread finds nothing vouching for it and would speak — which is
|
||||
/// correct, because by then the change is a second change. It is also what stops the ledger growing
|
||||
/// a receipt per comment write for the life of a session; nothing else ever collects them.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - cardFolder: the card's folder on disk. Receipts are keyed by absolute path, so this is what
|
||||
/// the sweep is rooted at.
|
||||
/// - cardPath: the same card's path relative to the board root (`<lane>/<card>`), which is the
|
||||
/// spelling `CommentPath.classify` reads. The two are handed in together rather than derived
|
||||
/// from each other because the store already holds both (`BoardStore.commentSubject`).
|
||||
/// - Returns: the identities of the posted and just-deleted comments the app wrote.
|
||||
func vouchedComments(inCard cardFolder: URL, cardPath: String) -> Set<ItemID> {
|
||||
let root = Self.key(cardFolder)
|
||||
let paths = receiptPaths(under: root)
|
||||
guard !paths.isEmpty else { return [] }
|
||||
|
||||
var vouched: Set<ItemID> = []
|
||||
var consumed: [String] = []
|
||||
for path in paths {
|
||||
let relative = cardPath + path.dropFirst(root.count)
|
||||
guard let comment = CommentPath.classify(relative) else { continue }
|
||||
consumed.append(path)
|
||||
if let id = comment.id { vouched.insert(id) }
|
||||
}
|
||||
guard !consumed.isEmpty else { return [] }
|
||||
receipts.withLock { store in
|
||||
for path in consumed {
|
||||
// A move pair is one fact under two keys, and both of a comment's ends are under this
|
||||
// card — so removing the observed key and then the pair's own two ends retires it once
|
||||
// and leaves nothing dangling at the other end.
|
||||
if case let .move(from, to) = store[path]?.receipt {
|
||||
store.removeValue(forKey: from)
|
||||
store.removeValue(forKey: to)
|
||||
}
|
||||
store.removeValue(forKey: path)
|
||||
}
|
||||
}
|
||||
return vouched
|
||||
}
|
||||
|
||||
/// Every path the ledger holds a receipt for strictly *below* `folder`.
|
||||
///
|
||||
/// Only a **card** may ask this. A lane's subtree is its cards' business and the board root's is
|
||||
@@ -503,6 +566,17 @@ extension EchoLedger {
|
||||
/// - A card's remaining receipts, resolved by the snapshot's attachment listing — a name the
|
||||
/// card still lists is `.present`, anything else (a removed attachment, a loose file that
|
||||
/// was relocated out) is `.absent`.
|
||||
///
|
||||
/// **A card's `comments/` is not observable here, and is therefore not observed** (added with
|
||||
/// comments, 01-storage-format.md ▸ Enhanced schema: "the board snapshot never loads comment
|
||||
/// content"). Two snapshots say nothing whatever about a thread — not that it changed, not
|
||||
/// that it did not, not even that it exists — so a comment receipt has no observation to be
|
||||
/// checked against, and the naive reading (anything not an attachment is `.absent`) would
|
||||
/// declare every comment the app itself just wrote *unsatisfied*: the very next foreign edit to
|
||||
/// the card's own `index.md` would then classify the card foreign twice over, and the user's
|
||||
/// own title edit would classify foreign once. Comment receipts are the **card window's** to
|
||||
/// read and retire (`EchoLedger.vouchedComments(inCard:cardPath:)`), which is the one place
|
||||
/// that does re-read a thread and can therefore say what happened to it.
|
||||
func observations(present: Bool, in ledger: EchoLedger) -> [String: EchoLedger.Observation] {
|
||||
var observations: [String: EchoLedger.Observation] = [
|
||||
folder: present ? .present : .absent,
|
||||
@@ -511,7 +585,9 @@ extension EchoLedger {
|
||||
]
|
||||
guard ownsItsSubtree else { return observations }
|
||||
let attachmentPrefix = folder + "/" + BoardWriter.attachmentsFolderName + "/"
|
||||
let commentPrefix = folder + "/" + IntegrityRules.commentsFolderName + "/"
|
||||
for path in ledger.receiptPaths(under: folder) where observations[path] == nil {
|
||||
guard !path.hasPrefix(commentPrefix) else { continue }
|
||||
let name = path.hasPrefix(attachmentPrefix) ? String(path.dropFirst(attachmentPrefix.count)) : nil
|
||||
observations[path] = present && name.map(attachments.contains) == true ? .present : .absent
|
||||
}
|
||||
|
||||
@@ -6,9 +6,21 @@ import Foundation
|
||||
/// 04-interactions.md § Search's one sentence of behaviour and nothing else:
|
||||
///
|
||||
/// > live filter: cards whose title *and* body both miss the query animate out; case/diacritic-
|
||||
/// > insensitive substring. Scope is **title + body only** (settled) — attachment filenames are not
|
||||
/// > searched.
|
||||
/// > insensitive substring. Scope is **all card content the format makes meaningful** (re-ruled
|
||||
/// > 2026-07-29): title + body today; **comment bodies join when comments ship** — via a search-owned
|
||||
/// > transient comment index, never the snapshot. Attachment filenames stay unsearched.
|
||||
///
|
||||
/// ### Comments arrive as a set of ids, not as text
|
||||
///
|
||||
/// The predicate cannot read a comment: they are window-scoped and outside the snapshot
|
||||
/// (01-storage-format.md ▸ Enhanced schema), so there is no body on a `Card` to fold. What the filter
|
||||
/// takes instead is `commentMatches` — the cards whose threads the search's own transient index found
|
||||
/// the query in (`CommentSearchIndex`) — and ORs it into the card clause. That keeps the design's
|
||||
/// contract exactly as it was ("a card matches if any of its meaningful content matches") with **one**
|
||||
/// spelling of the question, and keeps the I/O on the search's side of the boundary rather than in a
|
||||
/// predicate every layout pass runs.
|
||||
///
|
||||
|
||||
/// ### Why it is a value rather than a function
|
||||
///
|
||||
/// The needle is folded **once** per filter and matched against each item's folded haystack, so a
|
||||
@@ -44,9 +56,19 @@ public struct SearchFilter: Sendable, Equatable {
|
||||
/// = everything visible": every `matches` below short-circuits to `true`.
|
||||
private let needle: String
|
||||
|
||||
public init(query: String) {
|
||||
/// The cards whose **comment bodies** the query was found in — the search's transient index'
|
||||
/// contribution (`CommentSearchIndex.matchingCards`).
|
||||
///
|
||||
/// Empty by default and empty whenever the sweep has not landed yet, which is why the first
|
||||
/// keystroke of a fresh query shows field-only matches for a moment and then refines. A stale set
|
||||
/// cannot survive a query change: the store rebuilds this value from the query *and* the index on
|
||||
/// every read (`BoardStore.searchFilter`), and the index recomputes its set on every keystroke.
|
||||
private let commentMatches: Set<ItemID>
|
||||
|
||||
public init(query: String, commentMatches: Set<ItemID> = []) {
|
||||
self.query = query
|
||||
needle = Self.folded(query)
|
||||
self.commentMatches = commentMatches
|
||||
}
|
||||
|
||||
/// No search — the default every threaded parameter carries, so a call site with no query to
|
||||
@@ -74,11 +96,18 @@ public struct SearchFilter: Sendable, Equatable {
|
||||
return Self.folded(body).contains(needle)
|
||||
}
|
||||
|
||||
/// A card. **Its attachment filenames are not consulted** — scope is title + body only
|
||||
/// (04 § Search, settled), and that is enforced here by construction rather than by remembering
|
||||
/// not to add `card.attachments` to the line above.
|
||||
/// A card — **title, body, or its comments** (04 § Search, re-ruled 2026-07-29).
|
||||
///
|
||||
/// **Its attachment filenames are still not consulted**, which the re-ruling left standing
|
||||
/// ("attachment filenames stay unsearched"), and that is enforced here by construction rather than
|
||||
/// by remembering not to add `card.attachments` to the line above.
|
||||
///
|
||||
/// The comment clause is a set membership rather than a text test for the reason the type's note
|
||||
/// gives: the text is not on the card, and the index that read it is the search's own.
|
||||
public func matches(_ card: Card) -> Bool {
|
||||
matches(title: card.title.value, body: card.body)
|
||||
guard isActive else { return true }
|
||||
if matches(title: card.title.value, body: card.body) { return true }
|
||||
return commentMatches.contains(card.id)
|
||||
}
|
||||
|
||||
/// A trashed lane row — **by title only** (03-board-ui.md § Trash, re-ruled 2026-07-29: "The row
|
||||
|
||||
@@ -634,7 +634,13 @@ public final class TransientBoardState {
|
||||
/// on the freshly resolved sets, and the vanish rule and the filter rule compose in the one
|
||||
/// order that makes sense: gone first, then hidden. `isTrashVisible` is the only member with
|
||||
/// nothing to say here at all.
|
||||
public func resolve(against snapshot: BoardModel) {
|
||||
///
|
||||
/// - Parameter commentMatches: the transient comment index' current answer
|
||||
/// (`CommentSearchIndex.matchingCards`), threaded straight through to the constraint below.
|
||||
/// Defaulted, because it is the store's fact rather than this container's: everything else here
|
||||
/// is a rule about *this* state, and a caller with no index — every test of the resolution rules
|
||||
/// — means "no comment matches", which is what an empty set says.
|
||||
public func resolve(against snapshot: BoardModel, commentMatches: Set<ItemID> = []) {
|
||||
selection = selection.resolved(against: snapshot)
|
||||
dragMembers = dragMembers.resolved(against: snapshot)
|
||||
pendingCut = pendingCut.resolved(against: snapshot)
|
||||
@@ -661,7 +667,7 @@ public final class TransientBoardState {
|
||||
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
|
||||
}
|
||||
|
||||
constrainToSearch(in: snapshot)
|
||||
constrainToSearch(in: snapshot, commentMatches: commentMatches)
|
||||
}
|
||||
|
||||
/// **Hidden cards leave the selection** (04-interactions.md § Search) — the constraint rule with
|
||||
@@ -692,8 +698,13 @@ public final class TransientBoardState {
|
||||
/// (`RenameEditor`) — read for the materialized trash, true container crossings — so a foreign
|
||||
/// edit that stops the renaming card matching drops it from the selection here and leaves the
|
||||
/// keystrokes exactly where the user left them.
|
||||
public func constrainToSearch(in snapshot: BoardModel) {
|
||||
let filter = SearchFilter(query: searchQuery)
|
||||
/// **A third occasion joined the two** with the comment index (04 ▸ Search, re-ruled 2026-07-29):
|
||||
/// a landed sweep can *widen* the visible universe (a card matching only through its comments
|
||||
/// appears) and, on a re-sweep, narrow it again — so `CommentSearchIndex.onRefine` calls this too.
|
||||
/// Widening constrains nothing, which is why the seam is worth having anyway: the narrowing half is
|
||||
/// the one that would otherwise leave a selection on a card nobody can see.
|
||||
public func constrainToSearch(in snapshot: BoardModel, commentMatches: Set<ItemID> = []) {
|
||||
let filter = SearchFilter(query: searchQuery, commentMatches: commentMatches)
|
||||
guard filter.isActive else { return }
|
||||
|
||||
let universe = filter.visibleIDs(in: snapshot, container: selection.container)
|
||||
|
||||
@@ -327,6 +327,45 @@ public struct CommentThread: Sendable, Equatable {
|
||||
return CommentDraft(body: document.body, attachments: attachments)
|
||||
}
|
||||
|
||||
/// **Board search's read** — every posted comment's body under `cardFolder`, and nothing else
|
||||
/// (04-interactions.md ▸ Search: "an async sweep of `comments/*/index.md` bodies (`.draft` and
|
||||
/// `comments/.trash/` excluded)").
|
||||
///
|
||||
/// It is a second entry point rather than `load(inCard:path:)` reused, and the difference is
|
||||
/// deliberately narrow: **it is silent and it reports nothing**. The thread read logs every stray,
|
||||
/// records every coerce-tier field and hands back the claimed-name work a heal will act on — all of
|
||||
/// which is right for a window opening a thread and wrong for a sweep that re-runs whenever a
|
||||
/// reload lands under a live query. A warning per stray per keystroke's re-sweep would be a log a
|
||||
/// human could not read, and defects surfaced from a *search* would be repaired by a gesture the
|
||||
/// user never made.
|
||||
///
|
||||
/// `nonisolated` and total: this runs on a detached task (`CommentSearchIndex.sweep`), and a card
|
||||
/// with no `comments/`, one whose thread is held by a file, and one whose comments are all
|
||||
/// unreadable each answer `[]` — the same shrug the thread read gives, minus the paperwork.
|
||||
///
|
||||
/// The two exclusions are the enumeration's, exactly as in `load`: `.draft` and `.trash` are
|
||||
/// dot-prefixed, and `BoardLoader.directoryCandidates` skips hidden entries.
|
||||
public static func searchableBodies(inCard cardFolder: URL) -> [String] {
|
||||
let threadFolder = folder(inCard: cardFolder)
|
||||
guard IntegrityRules.node(at: threadFolder) == .directory else { return [] }
|
||||
|
||||
var bodies: [String] = []
|
||||
for commentURL in (try? BoardLoader.directoryCandidates(in: threadFolder)) ?? [] {
|
||||
guard IntegrityRules.isIdentityShaped(commentURL.lastPathComponent),
|
||||
let data = try? Data(contentsOf: commentURL.appendingPathComponent(IntegrityRules.indexFileName)),
|
||||
// A malformed comment is *tolerated*, which here means unsearched: a body the parser
|
||||
// could not find is not a body a query can honestly be said to miss, and searching
|
||||
// the raw bytes would let a query match frontmatter the thread never renders.
|
||||
let document = try? BoardLoader.parseDocument(data, path: commentURL.lastPathComponent),
|
||||
!document.body.isEmpty
|
||||
else {
|
||||
continue
|
||||
}
|
||||
bodies.append(document.body)
|
||||
}
|
||||
return bodies
|
||||
}
|
||||
|
||||
/// **Chronology, with the undated after the dated** (01-storage-format.md § Enhanced schema,
|
||||
/// ruled 2026-07-29): "the thread sorts by `created` ascending … ties and missing/malformed
|
||||
/// `created` (coerce-tier fallback, logged) sort after dated siblings, folder-name order".
|
||||
|
||||
@@ -226,6 +226,134 @@ enum AccessibilityPhrases {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The comments pane
|
||||
|
||||
/// The pane's container label — **"Comments, N"** (10-accessibility.md ▸ Comments: "the pane is a
|
||||
/// labeled container ('Comments, N')").
|
||||
///
|
||||
/// The count is the *thread's*, not the rendered rows' — there is no filter over a thread — and it
|
||||
/// is the same number the visible header shows (`CommentsHeader.title(count:)`), which is the same
|
||||
/// discipline the lane label keeps with its badge.
|
||||
static func commentsContainerLabel(count: Int) -> String {
|
||||
"Comments, \(count)"
|
||||
}
|
||||
|
||||
/// "3 comments", "1 comment" — the pane's plural folding, beside `cardCount` and `laneCount`.
|
||||
static func commentCount(_ count: Int) -> String {
|
||||
"\(count) comment\(count == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
/// **One comment as a single flattened element's label** (10 ▸ Comments: "each comment is **one
|
||||
/// flattened element** — author, date, edited state, body").
|
||||
///
|
||||
/// The author line is the label and the body is the value (`commentValue`), which is the same split
|
||||
/// the card element makes: the label is what the element *is*, the value is what it currently
|
||||
/// holds. A comment with no author line at all — no name, no date — falls back to the word
|
||||
/// "Comment", because an unlabeled element is an audit failure and "unattributed" is the absence of
|
||||
/// a name rather than a name to speak (`CommentAuthorLine`).
|
||||
static func commentLabel(authorLine: String?) -> String {
|
||||
guard let authorLine, !authorLine.isEmpty else { return commentSubject }
|
||||
return authorLine
|
||||
}
|
||||
|
||||
/// What a comment with nothing to attribute is called. Named rather than inlined because both the
|
||||
/// label fallback and the empty pane's hint read it.
|
||||
static let commentSubject = "Comment"
|
||||
|
||||
/// A comment element's **value**: its body, with the attachment count appended when it has files.
|
||||
///
|
||||
/// The body is spoken as it is rather than summarized: 10 puts "body" in the flattened element, and
|
||||
/// a comment is short by nature — the thing a thread is *for* is the text, and paraphrasing it
|
||||
/// would be the app deciding what a VoiceOver user may hear of somebody's comment. Empty for a
|
||||
/// comment with neither, which speaks as nothing (`cardValue`'s rule).
|
||||
static func commentValue(body: String, attachments: Int) -> String {
|
||||
var parts: [String] = []
|
||||
let text = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !text.isEmpty { parts.append(text) }
|
||||
if attachments > 0 { parts.append(attachmentCount(attachments)) }
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
|
||||
/// The three custom actions a comment element carries — its context menu's rows, which is what 10
|
||||
/// asks for ("with its context-menu rows (Edit / Delete / Reveal in Finder) riding as custom
|
||||
/// actions per the cut").
|
||||
///
|
||||
/// Constants rather than literals at the modifier because the menu row and the custom action must
|
||||
/// be the *same* string: a user who has learned the pointer inventory should hear the same three
|
||||
/// words from the rotor.
|
||||
static let commentEditAction = "Edit"
|
||||
static let commentDeleteAction = "Delete"
|
||||
static let commentRevealAction = "Reveal in Finder"
|
||||
|
||||
/// The composer, labeled — "the composer is a labeled text field (⌘↩ posts)" (10 ▸ Comments).
|
||||
static let commentComposerLabel = "Add a comment"
|
||||
|
||||
/// The sort control's label. Its *value* is the direction's own word
|
||||
/// (`CommentSortDirection.controlLabel`), which is also its help text — one string, three readers.
|
||||
static let commentSortLabel = "Sort"
|
||||
|
||||
/// The paperclip on either authoring surface.
|
||||
static let commentAttachFilesLabel = "Attach Files"
|
||||
|
||||
// MARK: - Foreign comment changes
|
||||
|
||||
/// **What a foreign comment change says out loud** — path-shaped, naming the card
|
||||
/// (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").
|
||||
///
|
||||
/// ### Precedence, not concatenation
|
||||
///
|
||||
/// One polite sentence per reload is the whole ladder's rule (`BoardAnnouncer`), so three
|
||||
/// simultaneous kinds of change pick one: **arrivals lead** — they are the news 10 names and the
|
||||
/// only kind that adds something to read — then edits, then deletions. A thread that gained one
|
||||
/// comment and lost another says the gain; the loss is visible in the pane and has no reader
|
||||
/// waiting on it.
|
||||
///
|
||||
/// ### The verbs are 06's family, and the plurals are built on them
|
||||
///
|
||||
/// 10 names one sentence and 01 names the family the other two come from ("a changed path under
|
||||
/// `…/comments/<uuid>/` composes 'Comment on ⟨card title⟩' / 'Edit comment on…' / 'Delete comment
|
||||
/// on…'"). The singular forms are those verbs verbatim; the plural forms fold a count in front,
|
||||
/// like every other count in the app, rather than repeating the sentence N times.
|
||||
static func commentsChanged(_ changes: CommentThreadChanges, onCard title: String?) -> String? {
|
||||
let card = displayTitle(title)
|
||||
if let phrase = commentFragment(
|
||||
count: changes.arrived.count,
|
||||
singular: "New comment",
|
||||
plural: { "\($0) new comments" },
|
||||
onCard: card
|
||||
) {
|
||||
return phrase
|
||||
}
|
||||
if let phrase = commentFragment(
|
||||
count: changes.edited.count,
|
||||
singular: "Edit comment",
|
||||
plural: { "\($0) comments edited" },
|
||||
onCard: card
|
||||
) {
|
||||
return phrase
|
||||
}
|
||||
return commentFragment(
|
||||
count: changes.deleted.count,
|
||||
singular: "Delete comment",
|
||||
plural: { "\($0) comments deleted" },
|
||||
onCard: card
|
||||
)
|
||||
}
|
||||
|
||||
/// One bucket's sentence, or `nil` at zero — the shape all three share, written once so the three
|
||||
/// cannot drift in punctuation or in where the card's name sits.
|
||||
private static func commentFragment(
|
||||
count: Int,
|
||||
singular: String,
|
||||
plural: (Int) -> String,
|
||||
onCard card: String
|
||||
) -> String? {
|
||||
guard count > 0 else { return nil }
|
||||
return "\(count == 1 ? singular : plural(count)) on '\(card)'"
|
||||
}
|
||||
|
||||
// MARK: - The banner strip
|
||||
|
||||
/// What VoiceOver says before a banner's headline. "Status" rather than "Info" because that is
|
||||
|
||||
@@ -194,11 +194,16 @@ extension FocusedValues {
|
||||
/// **A board window in front is now the whole of the scope**, where m5 additionally required the
|
||||
/// toolbar's field to exist: with the item removed, ⌘F raises the transient host instead
|
||||
/// (`BoardSearchPresentation.invokeSearch`), so there is no board window where the row is dead.
|
||||
/// **The card window's find routes by focus** (05-card-window.md ▸ Preview, comments clause): the
|
||||
/// body's substrate when the body has the keyboard, the whole rendered thread when the comments pane
|
||||
/// does, and that editor's own stock find bar when the composer or an inline session does. The rule
|
||||
/// itself is `CardWindowFind.route`, pure and pinned; this row only asks it.
|
||||
struct FindCommand: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@FocusedValue(\.boardSearch) private var search
|
||||
@FocusedValue(\.cardBody) private var cardBody
|
||||
@FocusedValue(\.cardComments) private var comments
|
||||
|
||||
/// **The card window wins when it is the focused scene**, which is the whole of 11's split
|
||||
/// ("Board window: board search; card window: find-in-text"): the two never both publish, so
|
||||
@@ -209,6 +214,12 @@ struct FindCommand: View {
|
||||
|
||||
var body: some View {
|
||||
Button("Find") {
|
||||
// The pane answers first and says whether it took the key: with the comments pane focused
|
||||
// — or its find bar already up — ⌘F is the thread's, and every other case falls through to
|
||||
// the body exactly as it did before the pane existed.
|
||||
if comments?.invokeFind(hasBody: findInText != nil) == true {
|
||||
return
|
||||
}
|
||||
if let findInText {
|
||||
findInText()
|
||||
} else {
|
||||
@@ -216,7 +227,7 @@ struct FindCommand: View {
|
||||
}
|
||||
}
|
||||
.keyboardShortcut("f", modifiers: .command)
|
||||
.disabled(findInText == nil && (store == nil || search == nil))
|
||||
.disabled(findInText == nil && comments == nil && (store == nil || search == nil))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -334,11 +334,16 @@ struct BoardView: View {
|
||||
// arrivers are the card and row transitions already attached inside the lanes and the trash
|
||||
// column; this is the survivors' spring around them.
|
||||
//
|
||||
// **Every way the query changes rides it**, which is the reason the key is the query rather
|
||||
// **Every way the filter changes rides it**, which is the reason the key is the filter rather
|
||||
// than the transaction being wrapped at each mutation: typing, the field's Escape, the
|
||||
// board's Escape, and creation's clear (`TransientBoardState.beginPlaceholder`) all land
|
||||
// here without any of them knowing about motion.
|
||||
.animation(Motion.contentReflow(reduced: reduceMotion), value: store.searchQuery)
|
||||
//
|
||||
// The *filter*, not the query, since comments joined the search's scope (04 ▸ Search, re-ruled
|
||||
// 2026-07-29): a landed comment-index sweep widens what matches without the query changing at
|
||||
// all, and keying on the query alone would make those cards pop in while every other arrival
|
||||
// eased. `SearchFilter` is `Equatable` and carries the index' answer, so one key covers both.
|
||||
.animation(Motion.contentReflow(reduced: reduceMotion), value: store.searchFilter)
|
||||
}
|
||||
|
||||
/// The rubber band itself: a translucent accent fill with a hairline border, in strip
|
||||
|
||||
@@ -44,6 +44,11 @@ public final class CardComments {
|
||||
/// 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.
|
||||
@@ -65,6 +70,29 @@ public final class CardComments {
|
||||
/// 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:)`.
|
||||
@@ -109,6 +137,19 @@ public final class CardComments {
|
||||
@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
|
||||
@@ -121,7 +162,11 @@ public final class CardComments {
|
||||
/// board that closed cleanly (`HealScheduler`'s rest branch), which is every open but one.
|
||||
public func open() {
|
||||
sweepTrashResidue?()
|
||||
reload()
|
||||
// **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.
|
||||
@@ -132,13 +177,25 @@ public final class CardComments {
|
||||
/// 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
|
||||
@@ -164,6 +221,27 @@ public final class CardComments {
|
||||
}
|
||||
}
|
||||
|
||||
/// **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
|
||||
@@ -176,6 +254,53 @@ public final class CardComments {
|
||||
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).
|
||||
|
||||
@@ -50,14 +50,38 @@ struct CardCommentsPane: View {
|
||||
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: pointSize))
|
||||
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: pointSize))
|
||||
|
||||
// **The find bar sits above the thread it searches** — `findBarPosition = .aboveContent`,
|
||||
// which is where every other find in this window puts its bar (`CardBodySurface`).
|
||||
if comments.find.isShowing {
|
||||
CommentFindBar(find: comments.find)
|
||||
.padding(.top, CardWindowMetrics.previewPadding(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.
|
||||
// 10-accessibility.md's container label for the pane ("Comments, N") — the count is the
|
||||
// thread's, so the spoken container and the visible header can never disagree.
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Comments, \(comments.thread.comments.count)")
|
||||
.accessibilityLabel(AccessibilityPhrases.commentsContainerLabel(count: comments.thread.comments.count))
|
||||
// The find's model of the rendered thread, rebuilt only while the bar is up: the search runs
|
||||
// over every comment, mounted or not (`CommentThreadFind`), and building it costs a render
|
||||
// pass the pane has no reason to spend on a window nobody is searching.
|
||||
.onChange(of: FindThreadKey(isShowing: comments.find.isShowing, thread: comments.thread), initial: true) { _, key in
|
||||
guard key.isShowing else { return }
|
||||
comments.find.setThread(key.thread.comments, pointSize: pointSize)
|
||||
}
|
||||
// **A find cannot outlive the surface it searches.** The pane unmounts on View ▸ Show Comments
|
||||
// and on the raw-source swap, and a session left showing would keep ⌘F and ⌘G pointed at a bar
|
||||
// nobody can see (`CardWindowFind.route`'s open-bar clause).
|
||||
.onDisappear { comments.find.dismiss() }
|
||||
}
|
||||
|
||||
/// The pair the find's rebuild is keyed on. A value rather than two `onChange`s so the bar opening
|
||||
/// and the thread changing take the same path, and so the rebuild cannot run twice for one update.
|
||||
private struct FindThreadKey: Equatable {
|
||||
let isShowing: Bool
|
||||
let thread: CommentThread
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -88,7 +112,7 @@ struct CardCommentsPane: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(direction.controlLabel)
|
||||
.accessibilityLabel("Sort")
|
||||
.accessibilityLabel(AccessibilityPhrases.commentSortLabel)
|
||||
.accessibilityValue(direction.controlLabel)
|
||||
}
|
||||
|
||||
@@ -129,6 +153,15 @@ struct CardCommentsPane: View {
|
||||
.onChange(of: comments.focusComposerRequests) { _, _ in
|
||||
proxy.scrollTo(Self.composerAnchor, anchor: direction.placesComposerFirst ? .top : .bottom)
|
||||
}
|
||||
// **A find that steps off screen brings the reader with it** — the half of "it reads as one
|
||||
// find" that the highlight alone cannot do. Scrolling to the comment rather than to the
|
||||
// range is what a row-shaped thread allows: rows are the scroll targets
|
||||
// (`ForEach(...).id(comment.id)`), and a comment is short enough that its top is the hit's
|
||||
// neighbourhood.
|
||||
.onChange(of: comments.find.currentMatch) { _, match in
|
||||
guard let match else { return }
|
||||
proxy.scrollTo(match.comment, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,15 @@ enum CardWindowMetrics {
|
||||
(lineHeight(bodyPointSize: bodyPointSize) * 5.5).rounded()
|
||||
}
|
||||
|
||||
/// The find bar's query field — **twelve characters**, the shortest measure that still shows a
|
||||
/// two-word search whole. It is deliberately narrower than the board's search field (seventeen):
|
||||
/// this bar shares a strip with a counter and three controls inside a fixed-width pane, and a
|
||||
/// field sized to the query would push the Done button off the end of the narrowest window the
|
||||
/// design allows.
|
||||
static func findFieldWidth(bodyPointSize: CGFloat) -> CGFloat {
|
||||
columnWidth(characters: 12, bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
/// The gap between two comments in the thread — a full gutter, one step larger than the rhythm
|
||||
/// *inside* a comment (`sidebarRowSpacing`), so the eye groups an author line with its body
|
||||
/// rather than with its neighbour.
|
||||
|
||||
@@ -23,8 +23,15 @@ import SwiftUI
|
||||
/// taken off and an intrinsic height instead: it lays out at the width it is proposed and reports
|
||||
/// exactly the height its text needs.
|
||||
///
|
||||
/// The pane's find-in-text over the whole rendered thread is phase 3's (05 ▸ Preview scopes ⌘F to
|
||||
/// "the comments pane, where it searches the whole rendered thread"); nothing here forecloses it.
|
||||
/// ### It is a find *result* surface, not a find client
|
||||
///
|
||||
/// 05 scopes ⌘F over the pane to "the whole rendered thread", which no per-row `NSTextFinder` could
|
||||
/// span (`CommentThreadFind` explains the mechanism chosen instead). What this view owes that
|
||||
/// mechanism is two things: the storage it draws must be the same text the search ran over — it is,
|
||||
/// because both come from `BodyMarkupRenderer` over the same body — and it must be able to draw a
|
||||
/// highlight over a range without touching that storage. `NSLayoutManager`'s **temporary attributes**
|
||||
/// are exactly that: a display-only overlay, discarded and reapplied freely, which cannot end up in
|
||||
/// anything the user copies out.
|
||||
struct CommentBodyView: NSViewRepresentable {
|
||||
|
||||
let body: String
|
||||
@@ -32,6 +39,14 @@ struct CommentBodyView: NSViewRepresentable {
|
||||
/// same way a card body's do, which is what makes `` mean one thing in
|
||||
/// this window (05 ▸ Preview).
|
||||
let cardFolder: URL?
|
||||
/// This comment's find hits, in the rendered text's coordinates (`CommentThreadFind.matches(in:)`).
|
||||
var highlights: [NSRange] = []
|
||||
/// Which of them is the current one, if it is in this comment — drawn in the stronger colour, the
|
||||
/// find bar's own convention.
|
||||
var currentHighlight: NSRange?
|
||||
/// The pane's focus register — this surface reports itself as a *reading* surface, which is what
|
||||
/// makes ⌘F over a clicked-into comment mean the thread (`CardComments.paneFocus`).
|
||||
var focus: CardComments?
|
||||
|
||||
/// The height a measurement pass lays out into — tall enough that no comment reaches it, finite
|
||||
/// so the arithmetic stays well-defined.
|
||||
@@ -41,7 +56,7 @@ struct CommentBodyView: NSViewRepresentable {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSTextView {
|
||||
func makeNSView(context: Context) -> CommentBodyTextView {
|
||||
// TextKit 1, explicitly, for `CardBodySurface`'s reason: `NSTextTable` — the browser sizing
|
||||
// rule GFM tables are laid out by — does not lay out in TextKit 2.
|
||||
let storage = NSTextStorage()
|
||||
@@ -52,7 +67,7 @@ struct CommentBodyView: NSViewRepresentable {
|
||||
container.lineFragmentPadding = 0
|
||||
layoutManager.addTextContainer(container)
|
||||
|
||||
let textView = NSTextView(frame: .zero, textContainer: container)
|
||||
let textView = CommentBodyTextView(frame: .zero, textContainer: container)
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = false
|
||||
// Selectable and copyable, the whole thread — Preview's own posture, and the reason a comment
|
||||
@@ -65,21 +80,64 @@ struct CommentBodyView: NSViewRepresentable {
|
||||
textView.textContainerInset = .zero
|
||||
textView.linkTextAttributes = [.cursor: NSCursor.pointingHand]
|
||||
textView.displaysLinkToolTips = true
|
||||
// The pane's focus register, so ⌘F over a comment the user has clicked into means the thread
|
||||
// (`CardComments.paneFocus`). A weak capture is not needed: the handle outlives the window.
|
||||
let focus = focus
|
||||
textView.onFocusChange = { gained in
|
||||
if gained {
|
||||
focus?.focusEntered(.thread)
|
||||
} else {
|
||||
focus?.focusLeft(.thread)
|
||||
}
|
||||
}
|
||||
return textView
|
||||
}
|
||||
|
||||
func updateNSView(_ textView: NSTextView, context: Context) {
|
||||
func updateNSView(_ textView: CommentBodyTextView, context: Context) {
|
||||
let key = Coordinator.RenderKey(
|
||||
body: body,
|
||||
cardFolder: cardFolder,
|
||||
pointSize: CardWindowMetrics.bodyPointSize
|
||||
)
|
||||
guard context.coordinator.rendered != key else { return }
|
||||
context.coordinator.rendered = key
|
||||
textView.textStorage?.setAttributedString(BodyMarkupRenderer.attributedString(
|
||||
for: BodyMarkup.parse(body),
|
||||
context: BodyMarkupRenderer.Context(pointSize: key.pointSize, cardFolder: cardFolder)
|
||||
))
|
||||
if context.coordinator.rendered != key {
|
||||
context.coordinator.rendered = key
|
||||
textView.textStorage?.setAttributedString(BodyMarkupRenderer.attributedString(
|
||||
for: BodyMarkup.parse(body),
|
||||
context: BodyMarkupRenderer.Context(pointSize: key.pointSize, cardFolder: cardFolder)
|
||||
))
|
||||
// The overlay describes ranges in text that has just been replaced, so it is reapplied
|
||||
// rather than assumed to have survived.
|
||||
context.coordinator.highlighted = nil
|
||||
}
|
||||
|
||||
let highlightKey = Coordinator.HighlightKey(ranges: highlights, current: currentHighlight)
|
||||
guard context.coordinator.highlighted != highlightKey else { return }
|
||||
context.coordinator.highlighted = highlightKey
|
||||
Self.applyHighlights(highlightKey, in: textView)
|
||||
}
|
||||
|
||||
/// Draws the find's hits — **temporary attributes only**, so nothing here can reach the text
|
||||
/// storage a reader copies out of, and clearing is a single call rather than a diff.
|
||||
///
|
||||
/// The two colours are the platform's own find vocabulary: every hit in the secondary selection
|
||||
/// colour, the current one in the *find* highlight colour, which is what an `NSTextFinder` bar
|
||||
/// draws and therefore what a user of this app's other finds has already learned.
|
||||
private static func applyHighlights(_ key: Coordinator.HighlightKey, in textView: CommentBodyTextView) {
|
||||
guard let layoutManager = textView.layoutManager, let storage = textView.textStorage else { return }
|
||||
let whole = NSRange(location: 0, length: storage.length)
|
||||
layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: whole)
|
||||
|
||||
for range in key.ranges where NSMaxRange(range) <= storage.length {
|
||||
layoutManager.addTemporaryAttributes(
|
||||
[.backgroundColor: NSColor.unemphasizedSelectedTextBackgroundColor],
|
||||
forCharacterRange: range
|
||||
)
|
||||
}
|
||||
guard let current = key.current, NSMaxRange(current) <= storage.length else { return }
|
||||
layoutManager.addTemporaryAttributes(
|
||||
[.backgroundColor: NSColor.findHighlightColor],
|
||||
forCharacterRange: current
|
||||
)
|
||||
}
|
||||
|
||||
/// **The intrinsic height** — the whole reason this is not a scroll view.
|
||||
@@ -87,7 +145,7 @@ struct CommentBodyView: NSViewRepresentable {
|
||||
/// The container is laid out at the proposed width and asked what it used. `ensureLayout` is not
|
||||
/// optional: `usedRect` is only meaningful once the glyphs have been laid, and an unlaid container
|
||||
/// answers a zero-height rect, which would collapse every comment in the thread to nothing.
|
||||
func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSTextView, context: Context) -> CGSize? {
|
||||
func sizeThatFits(_ proposal: ProposedViewSize, nsView: CommentBodyTextView, context: Context) -> CGSize? {
|
||||
guard let container = nsView.textContainer, let layoutManager = nsView.layoutManager else {
|
||||
return nil
|
||||
}
|
||||
@@ -112,7 +170,16 @@ struct CommentBodyView: NSViewRepresentable {
|
||||
let pointSize: CGFloat
|
||||
}
|
||||
|
||||
/// What the find overlay currently draws — kept for `RenderKey`'s reason one layer down: the
|
||||
/// update runs on every unrelated state change in the window, and reapplying an identical
|
||||
/// overlay would redraw every visible comment on each of them.
|
||||
struct HighlightKey: Equatable {
|
||||
let ranges: [NSRange]
|
||||
let current: NSRange?
|
||||
}
|
||||
|
||||
var rendered: RenderKey?
|
||||
var highlighted: HighlightKey?
|
||||
|
||||
/// Links behave exactly as they do in a card body: external URLs go to the browser, relative
|
||||
/// ones — already resolved to file URLs by the renderer — go to their default app.
|
||||
@@ -138,3 +205,29 @@ struct CommentBodyView: NSViewRepresentable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The row's text view
|
||||
|
||||
/// A rendered comment's text view, subclassed for one thing: **it says when it has the keyboard**.
|
||||
///
|
||||
/// `FocusReportingSearchField`'s shape, and for its reason — ⌘F's route is a *focus* question
|
||||
/// (05-card-window.md ▸ Preview: the find covers "the focused surface"), and the only responder that
|
||||
/// can answer it is the one taking and losing first responder. Both halves are overridden here rather
|
||||
/// than one being inferred from the other, because a reading surface has no editing session and
|
||||
/// therefore no `textDidEndEditing` to hang the loss on.
|
||||
final class CommentBodyTextView: NSTextView {
|
||||
|
||||
var onFocusChange: ((Bool) -> Void)?
|
||||
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
let accepted = super.becomeFirstResponder()
|
||||
if accepted { onFocusChange?(true) }
|
||||
return accepted
|
||||
}
|
||||
|
||||
override func resignFirstResponder() -> Bool {
|
||||
let resigned = super.resignFirstResponder()
|
||||
if resigned { onFocusChange?(false) }
|
||||
return resigned
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,11 @@ struct CommentAuthoringSurface<Actions: View>: View {
|
||||
onCommandReturn: onCommandReturn,
|
||||
onEscape: onEscape,
|
||||
onBlur: onBlur,
|
||||
focusRequest: focusRequest
|
||||
focusRequest: focusRequest,
|
||||
// Both authoring surfaces report themselves to the pane's focus register, which is
|
||||
// what routes ⌘F here to the editor's own find bar rather than to the thread's
|
||||
// (05 ▸ Preview; `CardWindowFind.route`).
|
||||
focus: comments
|
||||
)
|
||||
.frame(height: height)
|
||||
|
||||
@@ -124,7 +128,7 @@ struct CommentAuthoringSurface<Actions: View>: View {
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!comments.isEditable)
|
||||
.help("Attach Files…")
|
||||
.accessibilityLabel("Attach Files")
|
||||
.accessibilityLabel(AccessibilityPhrases.commentAttachFilesLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +187,10 @@ struct CommentComposerView: View {
|
||||
.controlSize(.small)
|
||||
.disabled(!comments.isEditable || !session.canPost)
|
||||
}
|
||||
.accessibilityLabel("Add a comment")
|
||||
// "The composer is a labeled text field (⌘↩ posts)" — 10-accessibility.md ▸ Comments. The
|
||||
// label is the placeholder's own sentence, so what a sighted user reads in the empty editor
|
||||
// and what VoiceOver announces are the same invitation.
|
||||
.accessibilityLabel(AccessibilityPhrases.commentComposerLabel)
|
||||
}
|
||||
|
||||
/// Post, then re-read: the rename moved a folder into the thread, and the pane shows the thread.
|
||||
|
||||
@@ -38,42 +38,70 @@ struct CommentRowView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: padding) {
|
||||
if let line = authorLine {
|
||||
Text(line)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
if let session {
|
||||
authorText
|
||||
editor(session)
|
||||
} else {
|
||||
reading
|
||||
|
||||
if !comment.attachments.isEmpty {
|
||||
// **Read-only** — "a posted comment's chips are read-only, Quick Look only — Edit
|
||||
// the comment to change its files" (05). `onRemove` left `nil` is that sentence,
|
||||
// and the editing branch above draws its *own* removable chips inside the
|
||||
// authoring surface, which is why these are the reading branch's alone.
|
||||
//
|
||||
// They sit **outside** the flattened element deliberately: 10 flattens "author,
|
||||
// date, edited state, body", and the chips are the one part of a comment that is
|
||||
// not text but a row of controls with a Quick Look behind each — reachable exactly
|
||||
// as the sidebar's are (`CommentAttachmentChips`). Flattening them in would have
|
||||
// made a comment's files announceable but not openable.
|
||||
CommentAttachmentChips(
|
||||
names: comment.attachments,
|
||||
url: { comments.attachmentURL($0, in: .comment(comment.id)) },
|
||||
thumbnails: thumbnails
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.contextMenu { menu }
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(authorLine ?? "Comment")
|
||||
}
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
/// The author line and the rendered body, as **one flattened element** with the three custom
|
||||
/// actions (10-accessibility.md ▸ Comments — see `CommentRowAccessibility`).
|
||||
private var reading: some View {
|
||||
VStack(alignment: .leading, spacing: padding) {
|
||||
CommentBodyView(body: comment.body, cardFolder: cardFolder)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
authorText
|
||||
|
||||
if !comment.attachments.isEmpty {
|
||||
// **Read-only** — "a posted comment's chips are read-only, Quick Look only — Edit the
|
||||
// comment to change its files" (05). `onRemove` left `nil` is that sentence.
|
||||
CommentAttachmentChips(
|
||||
names: comment.attachments,
|
||||
url: { comments.attachmentURL($0, in: .comment(comment.id)) },
|
||||
thumbnails: thumbnails
|
||||
)
|
||||
}
|
||||
CommentBodyView(
|
||||
body: comment.body,
|
||||
cardFolder: cardFolder,
|
||||
// The find's hits in this comment, and whether the current one is here — the row draws
|
||||
// them, the session found them (`CommentThreadFind`).
|
||||
highlights: comments.find.matches(in: comment.id),
|
||||
currentHighlight: comments.find.currentMatch.flatMap {
|
||||
$0.comment == comment.id ? $0.range : nil
|
||||
},
|
||||
focus: comments
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.modifier(CommentRowAccessibility(comment: comment, comments: comments, authorLine: authorLine))
|
||||
}
|
||||
|
||||
/// The author line, or nothing — "a comment with no `author` renders **without a name**"
|
||||
/// (`CommentAuthorLine`).
|
||||
@ViewBuilder
|
||||
private var authorText: some View {
|
||||
if let line = authorLine {
|
||||
Text(line)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,3 +174,50 @@ struct CommentRowView: View {
|
||||
date.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The row as one element
|
||||
|
||||
/// **One comment is one flattened element with three custom actions** (10-accessibility.md ▸
|
||||
/// Comments):
|
||||
///
|
||||
/// > each comment is **one flattened element** — author, date, edited state, body — with its
|
||||
/// > context-menu rows (Edit / Delete / Reveal in Finder) riding as custom actions per the cut.
|
||||
///
|
||||
/// ### It covers the reading surface only, and that is the whole of where it is applied
|
||||
///
|
||||
/// While an inline edit session is open the row is a text editor with two buttons in it, and
|
||||
/// collapsing *that* into one opaque element would put a VoiceOver user in front of a comment they
|
||||
/// can neither read into nor type into. So this hangs on the reading branch alone
|
||||
/// (`CommentRowView.reading`), and the editing branch is an ordinary container — the same asymmetry
|
||||
/// the body column keeps between Preview and Edit.
|
||||
///
|
||||
/// The actions duplicate the context menu deliberately: 10 asks for the pointer inventory to be
|
||||
/// reachable without the pointer, and the strings are shared with the menu (`AccessibilityPhrases`)
|
||||
/// so the two inventories cannot drift. They are also **not** disabled under the read-only lock —
|
||||
/// they call the same handles the menu rows do, and those already refuse (`CardComments.beginEdit`,
|
||||
/// `.delete`), which keeps one refusal rather than two.
|
||||
private struct CommentRowAccessibility: ViewModifier {
|
||||
|
||||
let comment: Comment
|
||||
let comments: CardComments
|
||||
let authorLine: String?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(AccessibilityPhrases.commentLabel(authorLine: authorLine))
|
||||
.accessibilityValue(AccessibilityPhrases.commentValue(
|
||||
body: comment.body,
|
||||
attachments: comment.attachments.count
|
||||
))
|
||||
.accessibilityAction(named: AccessibilityPhrases.commentEditAction) {
|
||||
comments.beginEdit(comment.id)
|
||||
}
|
||||
.accessibilityAction(named: AccessibilityPhrases.commentDeleteAction) {
|
||||
comments.delete(comment.id)
|
||||
}
|
||||
.accessibilityAction(named: AccessibilityPhrases.commentRevealAction) {
|
||||
comments.reveal(comment.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ struct CommentTextEditor: NSViewRepresentable {
|
||||
/// Bumped to ask for the keyboard — File ▸ Add Comment's second half, and an inline session
|
||||
/// opening. A counter rather than a flag: two requests in a row are two requests.
|
||||
var focusRequest: Int = 0
|
||||
/// The pane's focus register. This surface reports itself as an **authoring** one, which is what
|
||||
/// makes ⌘F here the editor's ordinary find rather than the thread's (05-card-window.md ▸ Preview:
|
||||
/// "the composer and an inline comment edit are their own focused text surfaces with the editor's
|
||||
/// ordinary find"). `nil` in a preview or a test that mounts the editor alone.
|
||||
var focus: CardComments?
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
@@ -98,6 +103,25 @@ struct CommentTextEditor: NSViewRepresentable {
|
||||
context.coordinator.textView = textView
|
||||
context.coordinator.onEdit = onEdit
|
||||
context.coordinator.onBlur = onBlur
|
||||
|
||||
// The pane's focus register, and — while this editor holds the keyboard — the find ⌘F runs.
|
||||
// `performTextFinderAction` takes its verb from the sender's `tag`, which is how the standard
|
||||
// Edit ▸ Find item drives it (`CardBodySurface`'s own note); the menu item's key equivalent
|
||||
// fires before this view ever sees ⌘F, so the action has to be reachable from outside.
|
||||
let focus = focus
|
||||
textView.onFocusChange = { [weak textView] gained in
|
||||
guard gained else {
|
||||
focus?.focusLeft(.authoring)
|
||||
return
|
||||
}
|
||||
focus?.focusEntered(.authoring) { [weak textView] in
|
||||
guard let textView else { return }
|
||||
textView.window?.makeFirstResponder(textView)
|
||||
let sender = NSMenuItem()
|
||||
sender.tag = NSTextFinder.Action.showFindInterface.rawValue
|
||||
textView.performTextFinderAction(sender)
|
||||
}
|
||||
}
|
||||
return scrollView
|
||||
}
|
||||
|
||||
@@ -187,6 +211,24 @@ final class CommentEditorTextView: NSTextView {
|
||||
var onCommandReturn: (() -> Void)?
|
||||
var onEscape: (() -> Void)?
|
||||
|
||||
/// **It says when it has the keyboard** — `CommentBodyTextView`'s pair, and for its reason: ⌘F's
|
||||
/// route is a focus question, and the responder is the only thing that can answer it. Reported
|
||||
/// here rather than through `textDidEndEditing` because that fires when the *field editor* ends,
|
||||
/// which for an uneditable editor (the read-only lock) never happens at all.
|
||||
var onFocusChange: ((Bool) -> Void)?
|
||||
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
let accepted = super.becomeFirstResponder()
|
||||
if accepted { onFocusChange?(true) }
|
||||
return accepted
|
||||
}
|
||||
|
||||
override func resignFirstResponder() -> Bool {
|
||||
let resigned = super.resignFirstResponder()
|
||||
if resigned { onFocusChange?(false) }
|
||||
return resigned
|
||||
}
|
||||
|
||||
/// **⌘↩** — "Post the draft … / end the edit session at its commit point"
|
||||
/// (11-command-nexus.md ▸ Fixed grammar keys). Intercepted before `super`, which would otherwise
|
||||
/// insert a newline: the chord is the gesture, not a decorated Return.
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Where the card window's ⌘F goes
|
||||
|
||||
/// Which of the card window's text surfaces Edit ▸ Find is about right now (05-card-window.md ▸
|
||||
/// Preview):
|
||||
///
|
||||
/// > **Edit ▸ Find (⌘F) is find-in-text here** — the standard find bar over the focused surface
|
||||
/// > (Preview's selectable text, the Edit editor, raw source — **and the comments pane, where it
|
||||
/// > searches the whole rendered thread, `.draft` excluded; the composer and an inline comment edit
|
||||
/// > are their own focused text surfaces with the editor's ordinary find**).
|
||||
public enum CardWindowFindRoute: Sendable, Equatable {
|
||||
/// The body column's substrate — Preview, Edit, or the raw-source outlet. `NSTextFinder` over the
|
||||
/// one hosted scroll view, which is what ⌘F has always meant here (`CardBodyPresentation.findInText`).
|
||||
case body
|
||||
/// The comments pane's **whole rendered thread**, across rows (`CommentThreadFind`).
|
||||
case thread
|
||||
/// The composer, or an open inline edit session: "their own text surfaces with the editor's
|
||||
/// ordinary find" — a stock `NSTextView` find bar over that one editor.
|
||||
case authoring
|
||||
}
|
||||
|
||||
/// Which surface *inside* the comments pane holds the keyboard, as the pane's own views report it.
|
||||
///
|
||||
/// Two cases and not three, because the routing only ever asks one question: is this a *reading*
|
||||
/// surface (a rendered comment, where find means the thread) or an *authoring* one (the composer or an
|
||||
/// inline session, where find means that editor). Which comment is being edited is the session's own
|
||||
/// business and no concern of ⌘F's.
|
||||
public enum CommentPaneFocus: Sendable, Equatable {
|
||||
case thread
|
||||
case authoring
|
||||
}
|
||||
|
||||
/// **The routing rule, as a pure function** — extracted for every menu-validation rule's reason in
|
||||
/// this codebase: a menu item's dispatch is otherwise only observable by driving the menu bar, and
|
||||
/// "⌘F follows focus" is exactly the kind of clause that regresses into "⌘F always means the body".
|
||||
public enum CardWindowFind {
|
||||
|
||||
/// - Parameters:
|
||||
/// - paneFocus: what the comments pane last reported (`CardComments.paneFocus`).
|
||||
/// - isThreadFindShowing: whether the thread's find bar is already up. **It outranks an absent
|
||||
/// pane focus**, because raising the bar takes the keyboard *out* of the thread and into the
|
||||
/// bar's own field — so a second ⌘F, which every user expects to return to the search field,
|
||||
/// would otherwise fall through to the body.
|
||||
/// - hasBody: whether a body surface exists to find in at all (`CardBodyPresentation.findInText`).
|
||||
/// - Returns: the route, or `nil` when this window has nothing to find in — which is also the
|
||||
/// window-less case, where Edit ▸ Find is the board's.
|
||||
public static func route(
|
||||
paneFocus: CommentPaneFocus?,
|
||||
isThreadFindShowing: Bool,
|
||||
hasBody: Bool
|
||||
) -> CardWindowFindRoute? {
|
||||
if paneFocus == .authoring { return .authoring }
|
||||
if paneFocus == .thread || isThreadFindShowing { return .thread }
|
||||
return hasBody ? .body : nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The thread's rows, and what a query finds in them
|
||||
|
||||
/// One comment as the find sees it: an identity, and the **rendered** text of its body.
|
||||
///
|
||||
/// Rendered rather than raw Markdown, and that is the whole reason this type exists. 05 scopes the
|
||||
/// find to "the whole *rendered* thread", the ranges it produces have to land in the text views the
|
||||
/// rows actually draw (`CommentBodyView`, whose storage is the same renderer's output), and a user
|
||||
/// searching for `code` should not match the backticks around it.
|
||||
public struct CommentThreadRow: Sendable, Equatable {
|
||||
public let id: ItemID
|
||||
public let text: String
|
||||
|
||||
public init(id: ItemID, text: String) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
}
|
||||
}
|
||||
|
||||
/// One hit: which comment, and where in its rendered text.
|
||||
public struct CommentThreadMatch: Sendable, Equatable {
|
||||
public let comment: ItemID
|
||||
public let range: NSRange
|
||||
|
||||
public init(comment: ItemID, range: NSRange) {
|
||||
self.comment = comment
|
||||
self.range = range
|
||||
}
|
||||
}
|
||||
|
||||
/// **Finding a query in a thread** — pure, so the whole of what ⌘F *means* over the comments pane is
|
||||
/// checkable without a window (05-card-window.md ▸ Preview).
|
||||
public enum CommentThreadSearch {
|
||||
|
||||
/// Every match, in reading order: rows in the order given, hits within a row left to right.
|
||||
///
|
||||
/// ### The comparison is the board search's, not `NSTextFinder`'s default
|
||||
///
|
||||
/// Case- **and diacritic-insensitive**, which is the rule 04-interactions.md fixes for the board's
|
||||
/// query and the one this app has therefore taught its users. It is done with
|
||||
/// `NSString.range(of:options:range:)` rather than by folding both sides, because folding can
|
||||
/// change a string's length — a decomposed character folds to fewer UTF-16 units — and a range
|
||||
/// measured in the folded string would highlight the wrong characters in the real one.
|
||||
///
|
||||
/// An **empty query finds nothing** (rather than everything): the find bar opens empty, and a bar
|
||||
/// that reported "1 of 4000" before a key was pressed would be noise.
|
||||
public static func matches(in rows: [CommentThreadRow], query: String) -> [CommentThreadMatch] {
|
||||
guard !query.isEmpty else { return [] }
|
||||
var matches: [CommentThreadMatch] = []
|
||||
for row in rows {
|
||||
let text = row.text as NSString
|
||||
var searched = NSRange(location: 0, length: text.length)
|
||||
while searched.length > 0 {
|
||||
let found = text.range(
|
||||
of: query,
|
||||
options: [.caseInsensitive, .diacriticInsensitive],
|
||||
range: searched
|
||||
)
|
||||
guard found.location != NSNotFound else { break }
|
||||
matches.append(CommentThreadMatch(comment: row.id, range: found))
|
||||
// Advance past the whole hit, so overlapping occurrences ("aa" in "aaa") are **one**
|
||||
// match — which is what `NSTextFinder` does, and therefore what the body's ⌘F in this
|
||||
// same window already does. `max(1, …)` keeps the loop advancing on principle; a
|
||||
// zero-length range cannot happen, because the query is non-empty.
|
||||
let next = found.location + max(1, found.length)
|
||||
searched = NSRange(location: next, length: max(0, text.length - next))
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
/// **Stepping, with wraparound** — the find bar's own grammar (Edit ▸ Find Next / Find Previous,
|
||||
/// ⌘G / ⇧⌘G — 11-command-nexus.md), and the thing that makes a find spanning several text views
|
||||
/// read as *one* find: next from the last hit of one comment is the first hit of the next.
|
||||
///
|
||||
/// `0` for an empty match list, which the caller never steps into anyway — stated so the function
|
||||
/// is total rather than trapping on a modulo by zero.
|
||||
public static func step(from index: Int, count: Int, forward: Bool) -> Int {
|
||||
guard count > 0 else { return 0 }
|
||||
let next = forward ? index + 1 : index - 1
|
||||
return ((next % count) + count) % count
|
||||
}
|
||||
|
||||
/// The bar's counter — "3 of 12", or the not-found phrase. Pure for the reason every count line in
|
||||
/// this app is: the zero case is the one that renders wrong.
|
||||
public static func status(index: Int, count: Int, query: String) -> String {
|
||||
guard !query.isEmpty else { return "" }
|
||||
guard count > 0 else { return "No matches" }
|
||||
return "\(index + 1) of \(count)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentThreadFind
|
||||
|
||||
/// **The comments pane's find session** — the bar's state, the matches, and where the current one is
|
||||
/// (05-card-window.md ▸ Preview's comments clause).
|
||||
///
|
||||
/// ### Why not `NSTextFinder`
|
||||
///
|
||||
/// Because there is no one text view to give it. The thread is a list of rows, each a real
|
||||
/// `NSTextView` with its own storage (`CommentBodyView`, whose note explains why a scroll view per row
|
||||
/// was rejected), and `NSTextFinder` finds within **one** client. The two ways out are a
|
||||
/// discontiguous `NSTextFinderClient` vending the concatenated thread and mapping ranges back to rows,
|
||||
/// or this: a small find bar over a **model** of the rendered thread, driving the rows' highlighting.
|
||||
/// This is the smaller correct one — the rows already know how to draw a highlight, the model is a
|
||||
/// pure function (`CommentThreadSearch`) rather than a protocol conformance whose contract is only
|
||||
/// observable by using it, and it searches comments whose rows are not even mounted, which a client
|
||||
/// built over live text views could not.
|
||||
///
|
||||
/// ### The honest limits, stated
|
||||
///
|
||||
/// - **It is the app's bar, not the system's.** Its grammar is deliberately the system's — a field,
|
||||
/// a counter, previous/next, Done, ⌘G/⇧⌘G, Escape — but Replace, the search-options menu (whole
|
||||
/// word, regular expression) and the find pasteboard are not there. The body's ⌘F is still the real
|
||||
/// `NSTextFinder`, and so is the composer's, so the two surfaces that are *documents* keep the full
|
||||
/// thing; the thread, which is a list, gets a list's find.
|
||||
/// - **It searches bodies, not author lines.** The thread's content is what a reader means by "find
|
||||
/// in this thread", and the author line is metadata rendered beside it, in a SwiftUI `Text` with no
|
||||
/// range to highlight. Names are searchable where names are content — the board's query over titles.
|
||||
/// - **The rows are rebuilt when the thread changes.** That is a render pass per reload *while the bar
|
||||
/// is open*, which is why the rows are built lazily and dropped with the bar.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class CommentThreadFind {
|
||||
|
||||
/// Whether the bar is on screen. ⌘F raises it; Done and Escape take it down.
|
||||
public private(set) var isShowing = false
|
||||
|
||||
/// The query, written live by the bar's field.
|
||||
public var query = "" {
|
||||
didSet {
|
||||
guard query != oldValue else { return }
|
||||
recompute(preservingPosition: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every hit in the thread, in reading order.
|
||||
public private(set) var matches: [CommentThreadMatch] = []
|
||||
|
||||
/// Which hit is current — an index into `matches`, meaningless (and unread) when it is empty.
|
||||
public private(set) var index = 0
|
||||
|
||||
/// Bumped to ask the bar's field for the keyboard. A **counter**, not a flag, for
|
||||
/// `CardComments.focusComposerRequests`' reason: two ⌘Fs in a row are two requests.
|
||||
public private(set) var focusRequests = 0
|
||||
|
||||
/// The rendered thread the matches were computed against, rebuilt whenever the thread changes
|
||||
/// while the bar is open.
|
||||
private var rows: [CommentThreadRow] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: The bar
|
||||
|
||||
/// **⌘F over a focused comments pane**: raise the bar if it is down, and put the keyboard in its
|
||||
/// field either way — the same "focus, not toggle" rule Edit ▸ Find keeps on the board
|
||||
/// (`FindCommand`), so a second press is a re-focus rather than a dismissal.
|
||||
public func invoke() {
|
||||
isShowing = true
|
||||
focusRequests += 1
|
||||
}
|
||||
|
||||
/// Done, and Escape — the bar's two ways out. The query is kept: re-opening the bar on the search
|
||||
/// the user was just running is what every find bar on this platform does.
|
||||
public func dismiss() {
|
||||
isShowing = false
|
||||
}
|
||||
|
||||
/// The thread, as the pane knows it — called when the bar opens and whenever the thread changes
|
||||
/// under an open bar.
|
||||
///
|
||||
/// **The position survives a re-read**: an agent editing a comment three rows up must not move the
|
||||
/// user's place in their own search, so the index is clamped rather than reset. It *is* reset by a
|
||||
/// query change, which is a new search.
|
||||
public func setThread(_ comments: [Comment], pointSize: CGFloat) {
|
||||
let rows = comments.map { comment in
|
||||
CommentThreadRow(id: comment.id, text: Self.renderedText(of: comment.body, pointSize: pointSize))
|
||||
}
|
||||
guard rows != self.rows else { return }
|
||||
self.rows = rows
|
||||
recompute(preservingPosition: true)
|
||||
}
|
||||
|
||||
/// The rendered plain text of one comment body — **the same renderer the row draws with**
|
||||
/// (`CommentBodyView` → `BodyMarkupRenderer`), so a range found here lands on the characters the
|
||||
/// user is looking at. A second way of turning Markdown into text would be a second answer to
|
||||
/// "what does this comment say", and the ranges would drift wherever the two disagreed.
|
||||
private static func renderedText(of body: String, pointSize: CGFloat) -> String {
|
||||
BodyMarkupRenderer.attributedString(
|
||||
for: BodyMarkup.parse(body),
|
||||
context: BodyMarkupRenderer.Context(pointSize: pointSize, cardFolder: nil)
|
||||
).string
|
||||
}
|
||||
|
||||
// MARK: Stepping
|
||||
|
||||
/// Edit ▸ Find Next / Find Previous, and the bar's own chevrons. A no-op with nothing found.
|
||||
public func step(forward: Bool) {
|
||||
guard !matches.isEmpty else { return }
|
||||
index = CommentThreadSearch.step(from: index, count: matches.count, forward: forward)
|
||||
}
|
||||
|
||||
/// The hit the pane scrolls to and draws in the current colour, or `nil` when there is none.
|
||||
///
|
||||
/// **`nil` with the bar down**, which is what takes the highlighting off the thread when the user
|
||||
/// presses Done: the query survives a dismissal (so re-opening lands on the same search), and
|
||||
/// without this gate the matches would survive as marks on a thread nobody is searching.
|
||||
public var currentMatch: CommentThreadMatch? {
|
||||
guard isShowing, matches.indices.contains(index) else { return nil }
|
||||
return matches[index]
|
||||
}
|
||||
|
||||
/// Every hit inside one comment — what a row highlights. `[]` for a row with none, which is the
|
||||
/// overwhelming majority and costs the row nothing, and `[]` for every row while the bar is down.
|
||||
public func matches(in comment: ItemID) -> [NSRange] {
|
||||
guard isShowing else { return [] }
|
||||
return matches.compactMap { $0.comment == comment ? $0.range : nil }
|
||||
}
|
||||
|
||||
/// The bar's counter.
|
||||
public var status: String {
|
||||
CommentThreadSearch.status(index: index, count: matches.count, query: query)
|
||||
}
|
||||
|
||||
private func recompute(preservingPosition: Bool) {
|
||||
matches = CommentThreadSearch.matches(in: rows, query: query)
|
||||
index = preservingPosition ? min(index, max(0, matches.count - 1)) : 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The bar
|
||||
|
||||
/// The comments pane's find bar — **the system find bar's grammar, in the app's own strip**
|
||||
/// (see `CommentThreadFind` for why it is not an `NSTextFinder` bar).
|
||||
///
|
||||
/// A field, a counter, previous/next, Done: the four things a find bar on this platform has, in the
|
||||
/// order it has them, so a user who has used the body's ⌘F recognises this one. Return steps forward
|
||||
/// and Escape dismisses — the field's own two keys — while ⌘G/⇧⌘G are the menu's
|
||||
/// (`FindSteppingCommands`), which is where the platform puts stepping that must work with the
|
||||
/// keyboard anywhere in the window.
|
||||
///
|
||||
/// **⇧Return is deliberately not bound.** It is the obvious twin of Return, and binding it would mean
|
||||
/// a window-wide key equivalent that outranks the composer's newline for as long as the bar is up —
|
||||
/// a bar left open while the user goes back to typing would silently swallow their line breaks.
|
||||
struct CommentFindBar: View {
|
||||
|
||||
let find: CommentThreadFind
|
||||
|
||||
@FocusState private var isFieldFocused: Bool
|
||||
|
||||
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: CardWindowMetrics.previewPadding(bodyPointSize: pointSize)) {
|
||||
field
|
||||
|
||||
Text(find.status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
// A live-updating count is a status, not a control: announced when it changes rather
|
||||
// than only when the rotor reaches it (10-accessibility.md's live-region posture).
|
||||
.accessibilityLabel(find.status)
|
||||
|
||||
stepper(forward: false, symbol: "chevron.up", label: "Find Previous")
|
||||
stepper(forward: true, symbol: "chevron.down", label: "Find Next")
|
||||
|
||||
Button("Done") { find.dismiss() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: pointSize))
|
||||
.padding(.vertical, CardWindowMetrics.previewPadding(bodyPointSize: pointSize))
|
||||
.background(.background.secondary)
|
||||
.overlay(alignment: .bottom) { Divider() }
|
||||
// ⌘F's second half: the bar is raised by the menu item one update earlier, so the keyboard is
|
||||
// claimed here, where the field exists (`BoardSearchFieldView.updateNSView`'s rule).
|
||||
.onChange(of: find.focusRequests, initial: true) { _, _ in
|
||||
isFieldFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
private var field: some View {
|
||||
TextField("Find", text: Binding(get: { find.query }, set: { find.query = $0 }))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.controlSize(.small)
|
||||
.frame(width: CardWindowMetrics.findFieldWidth(bodyPointSize: pointSize))
|
||||
.focused($isFieldFocused)
|
||||
.onSubmit { find.step(forward: true) }
|
||||
// Escape dismisses the bar and nothing else — it never clears the query, which is the
|
||||
// find pasteboard's convention and the reason re-opening lands on the same search.
|
||||
.onExitCommand { find.dismiss() }
|
||||
.accessibilityLabel("Find in comments")
|
||||
}
|
||||
|
||||
private func stepper(forward: Bool, symbol: String, label: String) -> some View {
|
||||
Button {
|
||||
find.step(forward: forward)
|
||||
} label: {
|
||||
Image(systemName: symbol)
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(find.matches.isEmpty)
|
||||
.help(label)
|
||||
.accessibilityLabel(label)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user