Files
lanework/Kanban/LiveStore/CommentSearchIndex.swift
T
rzen 9588f7b1f0 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
2026-07-30 21:30:22 -04:00

273 lines
14 KiB
Swift

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
}
}