Implement live search filtering

The board's live title+body filter per 04-interactions.md § Search:

- SearchFilter — a pure value folding the query once (case- and
  diacritic-insensitive substring, locale-stable); title OR body matches,
  attachment filenames never searched; only the literal empty string is
  inactive.
- One universe: the filter threads through SelectionGrammar's order lists
  as a defaulted parameter, so ranges, Select All, arrow navigation, the
  marquee, drop zones, count badges, and the shown trash all read the same
  filtered set by construction; lanes are deliberately never filtered out
  (an emptied lane keeps its slot with a 0 badge). Hidden cards leave the
  selection through the existing constrain primitive, run on every query
  change and as the last line of the reload resolve; the delete successor
  is filtered so ⌫ never selects a hidden neighbour.
- The field: an NSSearchField-backed toolbar item (the toolbar's sole
  default item); Edit ▸ Find ⌘F focuses it through a focused-value
  presentation; stock field-editor dispatch — Return swallowed, Tab is the
  keep-filter path to the board, board commands stay enabled except the
  caret-chord pair, now one shared caretChordsYield expression.
- Escape is staged: clear the non-empty query (focus stays), hand an empty
  field back to the board, clear an active search from board focus —
  before Escape's clear-selection meaning.
- Creating a card clears the search (the placeholder funnel); a rename
  deliberately gets no carve-out; filter reflow rides the content spring
  keyed narrowly on the query.

903 unit tests (24 new).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 22:49:15 -04:00
parent 7eee0934ee
commit cf87b72092
15 changed files with 1359 additions and 84 deletions
+56 -7
View File
@@ -1907,7 +1907,9 @@ public final class BoardStore {
public func delete(_ ids: Set<ItemID>) {
let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot)
// The successor is drawn from what the lane is *showing*, so a delete under an active search
// walks the filtered lane rather than selecting a card the query has hidden.
let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot, filter: searchFilter)
try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
@@ -2122,8 +2124,52 @@ public final class BoardStore {
/// Whether an inline title editor is open `TransientBoardState.isEditingInline`, which owns
/// what it means and why every mutating command reads it.
///
/// **The search field is not one of these**, and that is 04-interactions.md § Search's settled
/// dispatch rule as one absence: "the field is a *control*, not a content editor the
/// focused-editor lockdown does not apply", so board menu commands stay enabled and act on the
/// selection while the user types a query. The narrow exception the caret chords is the
/// menu items' own (`caretChordsYield`), not this flag's.
public var isEditingInline: Bool { transient.isEditingInline }
// MARK: - The live search filter
/// The search field's text (04-interactions.md § Search), and **the one funnel every change to
/// it goes through**.
///
/// The setter is where the filter's one consequence lives: narrowing the query narrows what the
/// board shows, and "hidden cards leave the selection" so every write re-applies
/// `TransientBoardState.constrainToSearch(in:)` against the current snapshot. Putting it here
/// rather than at the field's binding is what makes it true for Escape's clear and for any later
/// caller equally, without either having to remember.
///
/// **The equality guard is not an optimisation.** `NSSearchField` reports its text on events
/// that did not change it, and a re-entrant assignment during a live keystroke would re-run the
/// constraint (harmlessly) and re-fire observation (not harmlessly the strip's animated
/// transaction is keyed on this value).
public var searchQuery: String {
get { transient.searchQuery }
set {
guard newValue != transient.searchQuery else { return }
transient.searchQuery = newValue
transient.constrainToSearch(in: snapshot)
}
}
/// 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) }
/// 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
/// search field's own Escape in a non-empty field.
///
/// Widening, so it constrains nothing; it goes through the setter anyway so there is exactly one
/// place the query is written on the store.
public func clearSearch() {
searchQuery = ""
}
/// Replaces the selection, and **records the lane it lands in** as the last-active one.
///
/// The lane bookkeeping lives here rather than in `TransientBoardState` for one reason: it
@@ -2153,7 +2199,9 @@ public final class BoardStore {
selection: selection,
anchor: transient.selectionAnchor,
snapshot: snapshot,
togglesOnRepeat: togglesOnRepeat
togglesOnRepeat: togglesOnRepeat,
// A -range walks the *filtered* board (04 § Search); the other two branches ignore it.
filter: searchFilter
)
guard !outcome.selection.isEmpty else {
clearSelection()
@@ -2186,16 +2234,17 @@ public final class BoardStore {
/// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor,
/// but it has no business discarding ones that are still standing inside what it selected.
///
// m5-search: "filter-respecting, like every surface" (04 The map). The universe here is
// `SelectionGrammar`'s order lists, which is where the filter threads in one change, and both
// this command and every -range narrow together.
/// **"All visible cards" means the filter's survivors** "filter-respecting, like every
/// surface" (04 The map). The universe is `SelectionGrammar`'s order lists, which is where the
/// filter threads in, so this command and every -range narrow together by construction.
public func selectAll() {
let filter = searchFilter
if transient.isTrashVisible, selection.liveness == .trashed, !selection.isEmpty,
let kind = SelectionGrammar.kind(of: selection, in: snapshot) {
apply(Set(SelectionGrammar.trashEntries(of: kind, in: snapshot)), on: .trashed)
apply(Set(SelectionGrammar.trashEntries(of: kind, in: snapshot, filter: filter)), on: .trashed)
return
}
apply(Set(SelectionGrammar.liveCards(in: snapshot)), on: .live)
apply(Set(SelectionGrammar.liveCards(in: snapshot, filter: filter)), on: .live)
}
/// Select All's storage half: an empty universe clears rather than storing an empty set, and the