Skip to content

Commit 89204c2

Browse files
committed
Select dialog: a size or date filter with an empty name bar now selects matching files
Fixes the headline correctness bug: in the Select/Deselect dialog, a size filter like `≥ 1 MB` with an empty name pattern returned zero results. `buildMatchQuery()` returned `null` whenever the pattern was empty, so the JS matcher short-circuited to `[]` and the size/date chips were effectively decorative unless a glob was also typed. Now an empty name bar WITH an active filter (size ≠ any or date ≠ any) runs as match-all: `buildMatchQuery` substitutes a glob `*`, and the size/date predicates narrow the set, so `≥ 1 MB` with no glob selects every file ≥ 1 MB. Empty pattern AND no filters still yields `null` (nothing to run). The new `hasActiveFilter()` predicate is the single "is there anything to run?" check, written so a future type filter slots in cleanly. - The run path already re-fires correctly: the size/date popovers call `scheduleSearch` on edit, and the Enter / `runOnMount` paths have no empty-query gate. No change needed there. - Search needed no change: its backend already match-alls on a null `namePattern` with size filters, and `buildSearchQuery` already builds `{ namePattern: null, minSize: X }` for an empty bar. Added a regression test proving the frontend doesn't block it. - Tests (TDD red→green): a `SelectionDialog.svelte` test (empty bar + `≥ 1 MB` size popover pick → commit selects exactly the matching index) that failed red against the old null-gate; a `selection-matching` test pinning the match-all + size contract; a `search-state` regression test for the match-all `SearchQuery`. - Docs: `selection-dialog/CLAUDE.md` § Match semantics now documents the filter-only behavior plus a gotcha against reintroducing the empty-pattern early-return.
1 parent a5c6035 commit 89204c2

5 files changed

Lines changed: 114 additions & 3 deletions

File tree

apps/desktop/src/lib/search/search-state.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,20 @@ describe('buildSearchQuery', () => {
9898
expect(query.modifiedBefore).toBeNull()
9999
})
100100

101+
it('builds a match-all query from a size filter alone (empty pattern is not blocked)', () => {
102+
// Regression for the filter-only fix: an empty name bar + a size filter must
103+
// produce a match-all query (`namePattern: null`) carrying the size bound. The
104+
// backend match-alls on a null pattern, so no frontend gate may drop this case.
105+
clearSearchState()
106+
setSizeFilter('gte')
107+
setSizeValue('1')
108+
setSizeUnit('MB')
109+
const query = buildSearchQuery()
110+
expect(query.namePattern).toBeNull()
111+
expect(query.minSize).toBe(1024 * 1024)
112+
expect(query.maxSize).toBeNull()
113+
})
114+
101115
it('includes the query text as namePattern when set', () => {
102116
clearSearchState()
103117
setQuery('*.pdf')

apps/desktop/src/lib/selection-dialog/CLAUDE.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,13 @@ doesn't care which kind it's running against.
4747

4848
Glob translation matches the Rust side: `*``.*`, `?``.`, regex metacharacters escaped, anchored with `^…$`. The JS
4949
regex engine is what does the matching (Selection has no Rust IPC for the match itself — it's microseconds in JS against
50-
a few hundred entries). Bad regex (`SyntaxError`) → `[]`. Empty pattern → `[]`.
50+
a few hundred entries). Bad regex (`SyntaxError`) → `[]`.
51+
52+
**Empty pattern WITH an active filter → match-all on the name; empty pattern AND no filters → `[]`.** Filter-only
53+
queries are valid: `buildMatchQuery` substitutes a match-all glob `*` when the bar is empty but `hasActiveFilter()` is
54+
true, so `≥ 1 MB` with no glob selects every file ≥ 1 MB. Gotcha: don't reintroduce an empty-pattern early-return in
55+
`buildMatchQuery` that ignores the filters, or the size/date chips go decorative. The matcher's `compilePattern` still
56+
returns `null` on an empty pattern, so the wrapper, not the matcher, owns the substitution.
5157

5258
## Folder sampling
5359

apps/desktop/src/lib/selection-dialog/SelectionDialog.svelte

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,28 @@
152152
}
153153
}
154154
155+
/**
156+
* Whether any non-pattern filter is active. When it is, a filter-only query
157+
* (empty name bar) is still meaningful and runs as match-all on the name. Extend
158+
* this predicate as new filters land (the M4 type filter slots in here) so
159+
* `buildMatchQuery` stays the single source of truth for "is there anything to
160+
* run?".
161+
*/
162+
function hasActiveFilter(): boolean {
163+
return selectionQueryState.getSizeFilter() !== 'any' || selectionQueryState.getDateFilter() !== 'any'
164+
}
165+
155166
/**
156167
* Builds a `SelectionMatchQuery` from current state. AI mode hands the pattern
157168
* via the hand-typed buffer for the kind the AI produced (filename buffer for
158169
* glob, regex buffer for regex). Filename mode reads from the bar; regex mode
159170
* reads from the bar.
171+
*
172+
* Filter-only queries are valid: an empty name pattern WITH an active size/date
173+
* filter runs as match-all (glob `*`, which the matcher anchors to `.*`), so
174+
* `≥ 1 MB` with no glob selects every file ≥ 1 MB. Only an empty pattern AND no
175+
* filters yields `null` (genuinely nothing to do). Don't reintroduce an
176+
* empty-pattern early-return that ignores the filters.
160177
*/
161178
function buildMatchQuery(): SelectionMatchQuery | null {
162179
const m = selectionQueryState.getMode()
@@ -181,10 +198,17 @@
181198
pattern = aiGlob
182199
kind = 'glob'
183200
} else {
184-
return null
201+
pattern = ''
202+
kind = 'glob'
185203
}
186204
}
187-
if (!pattern.trim()) return null
205+
if (!pattern.trim()) {
206+
if (!hasActiveFilter()) return null
207+
// Filter-only query: match every name, then let the size/date predicates
208+
// narrow the set. A glob `*` translates to `.*` in the matcher.
209+
pattern = '*'
210+
kind = 'glob'
211+
}
188212
189213
const size = readSizePredicate()
190214
const date = readDatePredicate()

apps/desktop/src/lib/selection-dialog/SelectionDialog.svelte.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,53 @@ describe('SelectionDialog', () => {
525525
second.cleanup()
526526
})
527527

528+
it('matches on a size filter alone when the name bar is empty', async () => {
529+
// The headline M2 fix: a `≥ 1 MB` size filter with an EMPTY name pattern must
530+
// select every file ≥ 1 MB. Before the fix, `buildMatchQuery` returned null on
531+
// an empty pattern and the matcher short-circuited to [], so filter-only queries
532+
// silently selected nothing.
533+
const matched: number[][] = []
534+
const { overlay, cleanup } = await mountDialog({
535+
entries: [
536+
buildEntry('small.txt', { size: 1000 }),
537+
buildEntry('big.bin', { size: 2_000_000 }),
538+
buildEntry('tiny.log', { size: 50 }),
539+
],
540+
onCommit: (idxs) => matched.push(idxs),
541+
})
542+
543+
// Leave the bar empty. Configure a `≥ 1 MB` size filter via the popover.
544+
const sizeChip = Array.from(overlay.querySelectorAll<HTMLButtonElement>('.filter-chip')).find((c) =>
545+
c.textContent.trim().startsWith('Size'),
546+
)
547+
if (!sizeChip) throw new Error('size chip not found')
548+
sizeChip.click()
549+
await tick()
550+
const sizePopover = document.querySelector('[aria-label="Size filter options"]')
551+
if (!sizePopover) throw new Error('size popover did not open')
552+
const radios = Array.from(sizePopover.querySelectorAll('button[role="radio"]'))
553+
const gteCell = radios.find((b) => b.textContent.trim() === '≥')
554+
const onePreset = radios.find((b) => b.textContent.trim() === '1')
555+
if (!gteCell || !onePreset) throw new Error('size popover cells not found')
556+
;(gteCell as HTMLButtonElement).click()
557+
await tick()
558+
;(onePreset as HTMLButtonElement).click()
559+
await tick()
560+
// The size pick schedules an auto-apply run; wait for the debounce to fire.
561+
await new Promise((r) => setTimeout(r, 1100))
562+
await tick()
563+
564+
// Commit selects exactly the 2 MB file's index. The committed set is the
565+
// authoritative proof the filter-only run matched (virtual-scroll row rendering
566+
// is unreliable under jsdom, so we assert on the matched indices, not the DOM).
567+
dispatchKey(overlay, 'Enter')
568+
await tick()
569+
expect(matched).toHaveLength(1)
570+
expect(matched[0]).toEqual([1])
571+
572+
cleanup()
573+
})
574+
528575
it('toggles caseSensitive via the FilterChips extras callback', async () => {
529576
// Mount and let the dialog's onMount complete, then toggle via the chip button.
530577
const { overlay, cleanup } = await mountDialog()

apps/desktop/src/lib/selection-dialog/selection-matching.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,26 @@ describe('matchEntries: size predicate', () => {
100100
expect(matchEntries(accessors, list.length, q)).toEqual([0, 1])
101101
})
102102

103+
it('match-all `*` + size predicate selects only files over the bound (filter-only contract)', () => {
104+
// The contract M2's filter-only fix relies on: an empty name bar becomes a
105+
// match-all glob `*`, and the size predicate alone picks the matching files.
106+
// A folder snapshot of a 2 MB file + small files + a dir (null size) returns
107+
// exactly the 2 MB file's index.
108+
const names = ['notes.txt', 'photo.bin', 'subdir']
109+
const fileSizes: (number | null)[] = [1000, 2_000_000, null] // last is a dir
110+
const acc: MatchAccessors = {
111+
getNameFor: (i) => names[i],
112+
getSizeFor: (i) => fileSizes[i],
113+
}
114+
const q: SelectionMatchQuery = {
115+
pattern: '*',
116+
kind: 'glob',
117+
caseSensitive: false,
118+
size: { kind: 'gte', min: 1_048_576 },
119+
}
120+
expect(matchEntries(acc, names.length, q)).toEqual([1])
121+
})
122+
103123
it('drops entries with no size when a size predicate is set', () => {
104124
const q: SelectionMatchQuery = {
105125
pattern: '*',

0 commit comments

Comments
 (0)