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