@@ -41,8 +41,7 @@ redundant dropdown preview.
4141 wildcards only.
4242- ** Facet counts across the full corpus.** Counts reflect the visible
4343 result set only (client-side compute from the loaded ` files ` array).
44- Tracked as a follow-up optimization; file a ` needs-web-search ` issue
45- if users complain.
44+ Tracked as a follow-up issue (file at implementation time).
4645- ** List cap pagination.** The 100-row cap on ` listFilesWithTags ` is
4746 an existing limitation, not introduced here. Files outside the cap
4847 are not surfaced by search. Tracked as a separate follow-up issue.
@@ -106,13 +105,19 @@ searchHash: string | null // was v0.6.1's drill-down hack
106105
107106// NEW:
108107searchQuery : string // raw user input, lifted from SearchBar
109- searchHits : Set < string > | null // blake3-hash set of matching files; null when no search
110- // Set (not array) for O(1) intersection
108+ searchHits : Set < string > | null // blake3-hash set of matching files;
109+ // null = no search active (show all / tag-filter only)
110+ // empty Set = search active but zero matches (show empty list)
111+ // Set (not array) for O(1) intersection.
112+ hitRanks : Map < string , number > // hash → BM25 rank; populated alongside searchHits.
113+ // Used by sortByRank; new Map() when cleared.
111114
112115// DERIVED (not state; recomputed each render):
113116visibleFiles : FileWithTags [] // composeVisible(files, selectedTagId, searchHits)
114117facetCounts : Record < string , number > // computeFacets(visibleFiles)
115- searchActive : boolean // searchQuery.trim().length >= 2 && searchHits !== null
118+ searchActive : boolean // searchHits !== null
119+ // (the MIN_QUERY_LEN=2 guard lives in SearchBar; by the
120+ // time App sees a non-null searchHits, the guard passed)
116121```
117122
118123### Components
@@ -147,16 +152,20 @@ export function sortByRank(
147152- If the whole input is already wrapped in double-quotes → pass through
148153 verbatim (phrase query).
149154- If the input ends with ` * ` → treat as prefix query, split tokens,
150- wrap last token as ` "prefix"* ` .
155+ wrap last token as ` "prefix"* ` . Example: ` sunse* ` → ` "sunse"* ` .
151156- Otherwise: split on whitespace, quote each non-empty token, join with
152157 space (FTS5 implicit AND).
153158 - ` sunset photos ` → ` "sunset" "photos" `
154159 - ` it's fine ` → ` "it's" "fine" ` (apostrophes safe inside quotes)
155160 - ` file/path.ext ` → ` "file/path.ext" ` (slashes + dots OK in quotes)
156- - Strip any characters that remain parse-unsafe (` " ` inside a token, raw
157- parentheses, ` - ` at the start of a token → FTS5 negation).
161+ - Strip any characters that remain parse-unsafe BEFORE tokenisation:
162+ bare ` " ` (unpaired), raw ` ( ` / ` ) ` , and leading ` - ` on a token
163+ (FTS5 negation). Then tokenise and quote as above.
158164 - Honest subset: phrase + prefix + implicit AND. Everything else
159165 is quoted away.
166+ - Example: ` (foo OR bar) ` → parens stripped → tokens ` foo ` , ` OR ` ,
167+ ` bar ` → output ` "foo" "OR" "bar" ` (three implicit-AND tokens;
168+ FTS5 OR keyword is lost, but input doesn't parse-error).
160169
161170** ` computeFacets ` reducer:**
162171``` typescript
@@ -196,14 +205,26 @@ export function composeVisible(
196205
197206** Changes from v0.6.1:**
198207- Remove dropdown render (the ` <div role="listbox"> ` block entirely).
199- - Remove local ` hits: SearchHit[] | null ` and ` open: boolean ` state.
208+ - Remove local ` hits: SearchHit[] | null ` , ` open: boolean ` , and
209+ ` searching: boolean ` state.
210+ - Remove ` containerRef ` + the outside-click ` useEffect ` that closed
211+ the dropdown.
200212- Remove ` handleHitClick ` and ` onResultClick ` prop.
201213- Keep: debounce, MIN_QUERY_LEN=2 guard, clear-button, error swallow.
202214- New prop: ` onQueryChange(query: string, hits: SearchHit[] | null) ` .
203215 App.tsx uses this to lift ` searchQuery ` + the hash-set derived from
204216 ` hits ` into App state.
205- - On query clear (input emptied): fire ` onQueryChange("", null) ` so App
206- can restore prior sort + full sidebar.
217+ - ** SearchBar still calls ` api.search(buildFtsQuery(raw), 500) `
218+ internally** — the sanitizer + IPC stay co-located with the input.
219+ After the debounce resolves, SearchBar calls
220+ ` onQueryChange(raw, hits) ` to lift the result upward. (Limit raised
221+ from 50 → 500 to feed the in-list re-sort.)
222+ - ** Three fire-cases for ` onQueryChange ` :**
223+ 1 . User typed ≥ MIN_QUERY_LEN and debounce resolved →
224+ ` onQueryChange(raw, hits) ` (hits may be ` [] ` if nothing matched).
225+ 2 . User typed < MIN_QUERY_LEN (1 char, or cleared to empty) →
226+ ` onQueryChange("", null) ` so App resets ` searchHits ` to null.
227+ 3 . User clicked clear button → ` onQueryChange("", null) ` .
207228
208229``` typescript
209230interface SearchBarProps {
@@ -255,8 +276,15 @@ interface TagSidebarProps {
255276#### ` apps/desktop/src/App.tsx ` (MODIFIED)
256277
257278** State changes:**
258- - Remove ` searchHash: string | null ` .
259- - Add ` searchQuery: string ` and ` searchHits: Set<string> | null ` .
279+ - Remove ` searchHash: string | null ` (line 52 of current App.tsx).
280+ - Remove ` handleSearchResult ` function (lines 179–182 of current
281+ App.tsx) — it's the exact source of #25 (` setSelectedTagId(null) ` ).
282+ - Remove the ` ✕ search ` badge JSX block from the header (lines
283+ 190–199 of current App.tsx); the SearchBar's own clear button
284+ replaces it.
285+ - Add ` searchQuery: string ` , ` searchHits: Set<string> | null ` , and
286+ ` hitRanks: Map<string, number> ` state (all three initialized as
287+ ` "" ` , ` null ` , ` new Map() ` ).
260288
261289** Effect on mount (unchanged):** ` listFilesWithTags(100) ` + ` listTags() ` .
262290
@@ -287,9 +315,10 @@ const sidebarTotalCount = searchActive ? visibleFiles.length : files.length;
287315```
288316
289317** Header JSX change:**
290- - Remove the ` ✕ search ` badge (no longer needed; SearchBar's own clear
291- button handles it).
292- - ` <SearchBar onQueryChange={handleSearchChange} /> `
318+ - Replace ` <SearchBar onResultClick={handleSearchResult} /> ` with
319+ ` <SearchBar onQueryChange={handleSearchChange} /> ` .
320+ - (The ` ✕ search ` badge removal was already listed above under State
321+ changes.)
293322
294323** Sidebar JSX change:**
295324- ` <TagSidebar ... mode={sidebarMode} totalCount={sidebarTotalCount} counts={facetCounts} /> `
@@ -324,8 +353,9 @@ const sidebarTotalCount = searchActive ? visibleFiles.length : files.length;
324353 - input with slash/dot ` a/b.jpg ` → ` "a/b.jpg" `
325354 - empty / whitespace-only → ` "" ` (SearchBar's MIN_QUERY_LEN guard
326355 blocks upstream; test anyway)
327- - adversarial: ` " ` alone → stripped; ` (foo OR bar) ` → quoted
328- ` "(foo" "OR" "bar)" ` (degrades to plain tokens)
356+ - adversarial: bare ` " ` stripped → empty output; ` (foo OR bar) ` →
357+ parens stripped → ` "foo" "OR" "bar" ` (OR keyword becomes a
358+ literal token; documented limitation)
329359- ` computeFacets ` :
330360 - empty files → ` {} `
331361 - file with no tags → ` {} `
@@ -358,18 +388,21 @@ const sidebarTotalCount = searchActive ? visibleFiles.length : files.length;
358388 - New: ` mode="facets" All row count reflects sum of counts ` .
359389 - Existing tests still pass with ` mode="all" ` default.
360390- ` App.test.tsx ` (MODIFIED):
361- - Existing file-event debounce test: keep, but change mock expectations
362- — no more ` list_files_with_metadata ` ; just ` list_files_with_tags `
363- and ` list_tags ` (already updated in Task 6 / commit ` cd55627 ` ).
391+ - Existing file-event debounce test: already correct in current code
392+ (mock expectations updated in ` cd55627 ` ); no change needed.
364393 - Existing watcher-banner test: keep as-is.
365394- ` App.compose.test.tsx ` (NEW): ** composition snapshot test** . Given
366395 fixture inputs (files, tagId, hits), assert visibleFiles matches an
367- expected snapshot. Pins the exact bug that shipped in v0.6.1. Four
396+ expected snapshot. Pins the exact bug that shipped in v0.6.1. Five
368397 cases:
369398 1 . No search, no tag → all files.
370399 2 . Tag filter only → tag-narrowed.
371400 3 . Search only → hit-narrowed, sorted by rank.
372401 4 . Search + tag → intersected, sorted by rank (the invariant #25 broke).
402+ 5 . Search active but zero hits (` searchHits = new Set() ` ) → empty
403+ visible list, sidebarMode = "facets", facetCounts = ` {} `
404+ (empty-state branch). Distinct from case 1 (` searchHits = null ` ,
405+ sidebarMode = "all").
373406
374407### No Playwright; no Tauri IPC integration test changes (search command's ` search_inner ` smoke test already exists from I8 fix).
375408
@@ -409,6 +442,17 @@ separate `fix(desktop): ...` commits if needed.
409442- ** Escape only clearing input (v0.6.2) will feel inconsistent when
410443 the detail view lands** (#27 ). Explicit v0.6.2 limitation; #28 fixes
411444 it holistically.
445+ - ** Stale search hits after file-watcher refresh.** ` searchHits ` is
446+ not automatically re-queried when the watcher fires a list refresh
447+ (the existing 300ms debounced ` listFilesWithTags(100) ` reload).
448+ After a watcher-triggered reload, if the user was searching, their
449+ ` searchHits ` set still reflects the pre-reload corpus — so new
450+ files matching the query won't appear, and deleted files will be
451+ filtered out by the missing hash. User-visible as "the list isn't
452+ updating even though I know I just added a file." Mitigation for
453+ v0.6.2: documented; user can retype to re-fire. Post-v1 fix: the
454+ file-event effect in App.tsx re-calls ` buildFtsQuery ` + ` api.search `
455+ alongside the ` listFilesWithTags ` refresh when a search is active.
412456
413457## Decisions and why
414458
0 commit comments