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 — `//`, or `/.trash/`. 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.landedReloads`: **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. /// /// The counter is the *walk's*, not the applied snapshot's, and it has to be: comments are outside the /// snapshot, so a comment arriving leaves the model value-equal and a value-equal reload skips its /// assignment (blessed 2026-07-31). Keyed on the applied counter, the index would sleep through the /// one kind of change it exists to notice. /// 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 = [] /// 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? /// **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 { let filter = SearchFilter(query: query) guard filter.isActive else { return [] } var matches: Set = [] 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 } }