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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user