Skip to content

Commit 0071a00

Browse files
committed
Size filter: honor a 0 bound and add a one-click = (equals) comparator
Two fixes to the shared Search + Select size filter (`query-ui/filter-chips`): - **`0` is now a usable bound.** `deriveSizeChip` gated on `minNumeric > 0`, so picking `0` (find empty files) read as "off" and the chip showed nothing. The guard is now `>= 0` across the `gte` / `lte` and `between` branches; an empty input is still `NaN` and stays unconfigured. So "= 0 B" / "≥ 0 B" render as real filters. - **New `=` comparator.** Adds `eq` to the `SizeFilter` union and a `=` cell to the size popover (single-bound, like `≥` / `≤`), so "size = 0" and "= 5 MB" are one click instead of an unobvious `between x x`. `eq` is a UI/chip-summary concern only: it never reaches the matcher's `SizePredicate` or any Rust type. Below the chip it's modeled as `between` with `sizeMin == sizeMax` (the matcher's `between` already matches exactly one value): - `applySizeQuery` pins both `minSize` and `maxSize`. - `readSizeFilters` emits `{ sizeMin: x, sizeMax: x }`; `SelectionDialog.readSizePredicate` returns `{ kind: 'between', min: x, max: x }`. - `applyHistoryFilters` rehydrates a stored `size_min == size_max` as `eq` (not `between`) by deliberate decision: the two match the same set and `= x` is the friendlier label, so a stored `between 5–5` returns as `= 5`. No `HistoryFilters` Rust change. - `applySizeFromAi` sets `eq` when an AI translation returns `min == max`, so a future "size = 0" AI result reads "= 0 B". Also `bytesToSize` now returns raw bytes for sub-kilobyte bounds ("= 0 B", "512 B") instead of a fractional "0.5 KB". Tests (real red→green): `filter-chip-state` (zero bound + eq summaries), `selection-matching` (eq == between-min==max matches exactly one value), `query-filter-state` (eq history round-trip persists as min==max, restores as eq), `apply-ai-filters` (min==max → eq), `FilterChips` (the `=` cell renders, selects, single-bound).
1 parent 89204c2 commit 0071a00

11 files changed

Lines changed: 212 additions & 18 deletions

apps/desktop/src/lib/query-ui/apply-ai-filters.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ describe('applySizeFromAi', () => {
3434
expect(state.getSizeValueMax()).toBe('1')
3535
expect(state.getSizeUnitMax()).toBe('GB')
3636
})
37+
38+
// When the AI returns min == max, that's an exact-size match: set the `eq` comparator so
39+
// the chip reads "= N" rather than "between N and N".
40+
it('sets an eq filter when min == max (so "size = 5 MB" reads as = not between)', () => {
41+
const state = createQueryFilterState({ defaultMode: 'filename' })
42+
expect(applySizeFromAi(state, 5 * 1024 * 1024, 5 * 1024 * 1024)).toBe(true)
43+
expect(state.getSizeFilter()).toBe('eq')
44+
expect(state.getSizeValue()).toBe('5')
45+
expect(state.getSizeUnit()).toBe('MB')
46+
})
47+
48+
it('sets an eq 0 B filter when the AI returns min == max == 0 (find empty files)', () => {
49+
const state = createQueryFilterState({ defaultMode: 'filename' })
50+
expect(applySizeFromAi(state, 0, 0)).toBe(true)
51+
expect(state.getSizeFilter()).toBe('eq')
52+
expect(state.getSizeValue()).toBe('0')
53+
expect(state.getSizeUnit()).toBe('B')
54+
})
3755
})
3856

3957
describe('applyDateFromAi', () => {

apps/desktop/src/lib/query-ui/apply-ai-filters.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ import { bytesToSize, type QueryFilterState } from './query-filter-state.svelte'
1717
*/
1818
export function applySizeFromAi(state: QueryFilterState, min: number | null, max: number | null): boolean {
1919
if (min == null && max == null) return false
20-
if (min != null && max != null) {
20+
if (min != null && max != null && min === max) {
21+
// Exact size (for example "size = 0" → find empty files): set `eq` so the chip reads
22+
// "= N" instead of "between N and N". `eq` is a UI label only (see `SizeFilter`).
23+
state.setSizeFilter('eq')
24+
const exact = bytesToSize(min)
25+
state.setSizeValue(exact.value)
26+
state.setSizeUnit(exact.unit)
27+
} else if (min != null && max != null) {
2128
state.setSizeFilter('between')
2229
const lo = bytesToSize(min)
2330
const hi = bytesToSize(max)

apps/desktop/src/lib/query-ui/filter-chips/CLAUDE.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ popover children).
8686

8787
**Size popover** (`SizeFilterPopover.svelte`):
8888

89-
- Col 1: `any`, ``, ``, `between` (one selected at a time).
89+
- Col 1: `any`, ``, ``, `=`, `between` (one selected at a time). `=` is single-bound (like `` / ``): it shows only
90+
cols 2 + 3, never the upper-bound cols.
9091
- Col 2: `0`, `1`, `5`, `10`, `20`, `50`, `100`, `200`, `500`, `Custom…`. Disabled when col 1 = `any`. Selecting
9192
`Custom…` reveals an inline `<input type="number">`.
9293
- Col 3: unit. The "byte(s)" cell label flips based on the selected value. The "kB/KB" cell follows
@@ -150,7 +151,16 @@ Clicking × clears the pattern only; the AI transparency strip stays put.
150151
## Gotchas
151152

152153
**Gotcha**: `parseSizeToBytes('0', unit)` is `0`, not `undefined`. The list-style grid lets the user explicitly pick 0
153-
as a lower or upper bound, so the helper honors it.
154+
as a lower or upper bound, so the helper honors it. `deriveSizeChip` likewise treats a `0` bound as configured (the
155+
guard is `>= 0`, not `> 0`); an empty input stays unconfigured because `parseFloat('')` is `NaN`. So "= 0 B" / "≥ 0 B"
156+
render as real filters.
157+
158+
**Gotcha**: `=` (the `eq` comparator) is a UI/chip-summary concern ONLY, never reaching the matcher's `SizePredicate` or
159+
any Rust type. Below the chip it's `between` with `sizeMin == sizeMax`: `applySizeQuery` pins both bounds,
160+
`readSizeFilters` emits `{ sizeMin: x, sizeMax: x }`, and `applyHistoryFilters` rehydrates a stored
161+
`size_min == size_max` as `eq` (not `between`) by deliberate decision (the two are identical; `= x` is the friendlier
162+
label, so a stored `between 5–5` returns as `= 5`). `applySizeFromAi` sets `eq` when the AI returns `min == max`. Don't
163+
add an `eq` kind to `SizePredicate` / `HistoryFilters`.
154164

155165
**Gotcha**: Size unit is `'B' | 'KB' | 'MB' | 'GB'`. The "byte(s)" cell is selectable from the unit column manually; the
156166
AI translator's `bytesToDisplaySize` still produces `KB | MB | GB`.

apps/desktop/src/lib/query-ui/filter-chips/FilterChips.svelte.test.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,9 +396,9 @@ describe('SearchFilterChips: scope popover behavior', () => {
396396
expect(grid).not.toBeNull()
397397
const cols = grid?.querySelectorAll('.list-col')
398398
expect(cols?.length).toBe(3) // comparator, lower value, lower unit
399-
// Comparator col exposes all 4 options.
399+
// Comparator col exposes all 5 options (any / ≥ / ≤ / = / between).
400400
const compCells = cols?.[0].querySelectorAll('.list-cell')
401-
expect(compCells?.length).toBe(4)
401+
expect(compCells?.length).toBe(5)
402402
// Selected comparator is `>=`.
403403
const selected = grid?.querySelectorAll('.list-cell.is-selected')
404404
expect(selected?.length).toBeGreaterThanOrEqual(2) // gte + value '5' + unit 'MB'
@@ -471,6 +471,32 @@ describe('SearchFilterChips: scope popover behavior', () => {
471471
})
472472
})
473473

474+
it('Size popover: the `=` (eq) comparator renders, selects, and is single-bound', async () => {
475+
clearSearchState()
476+
// Render with `eq` already chosen (the prop drives the popover's render).
477+
const { target, cleanup } = mountChips(baseProps({ sizeFilter: 'eq', sizeValue: '0', sizeUnit: 'B' }))
478+
await tick()
479+
findChip(target, 'Size')?.click()
480+
await tick()
481+
// The `=` comparator cell exists and reads as selected (the prop drives `is-selected`).
482+
const compCells = document.querySelectorAll<HTMLButtonElement>('.list-col:nth-child(1) .list-cell')
483+
const eqCell = [...compCells].find((b) => b.textContent.trim() === '=')
484+
expect(eqCell).not.toBeUndefined()
485+
expect(eqCell?.getAttribute('aria-checked')).toBe('true')
486+
// `eq` is single-bound: only comparator + lower value + lower unit columns, no upper bound.
487+
const cols = document.querySelectorAll('.list-grid .list-col')
488+
expect(cols.length).toBe(3)
489+
// Clicking the `=` cell writes `eq` to the shared state.
490+
eqCell?.click()
491+
await tick()
492+
const { getSizeFilter } = await import('$lib/search/search-state.svelte')
493+
expect(getSizeFilter()).toBe('eq')
494+
cleanup()
495+
document.querySelectorAll('.filter-chip-popover').forEach((el) => {
496+
el.remove()
497+
})
498+
})
499+
474500
it('R3 U5: clicking a Modified preset while `any` auto-promotes the comparator to `after`', async () => {
475501
clearSearchState()
476502
const { target, cleanup } = mountChips(baseProps({ dateFilter: 'any' }))

apps/desktop/src/lib/query-ui/filter-chips/SizeFilterPopover.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
{ value: 'any', label: 'any' },
6565
{ value: 'gte', label: '' },
6666
{ value: 'lte', label: '' },
67+
{ value: 'eq', label: '=' },
6768
{ value: 'between', label: 'between' },
6869
]
6970

apps/desktop/src/lib/query-ui/filter-chips/filter-chip-state.test.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,20 @@ describe('deriveSizeChip', () => {
2121
expect(deriveSizeChip('gte', '', 'MB', '', 'MB')).toEqual({ configured: false, summary: '' })
2222
})
2323

24-
it('returns default when value is zero (treated as "no value yet")', () => {
25-
expect(deriveSizeChip('gte', '0', 'MB', '', 'MB')).toEqual({ configured: false, summary: '' })
24+
it('treats a zero bound as a real value, not "off" (gte 0)', () => {
25+
// `0` is a valid bound (find empty files). Only an empty input (NaN) stays unconfigured.
26+
expect(deriveSizeChip('gte', '0', 'B', '', 'B')).toEqual({ configured: true, summary: '> 0 B' })
27+
})
28+
29+
it('treats a zero bound as a real value (lte 0)', () => {
30+
expect(deriveSizeChip('lte', '0', 'B', '', 'B')).toEqual({ configured: true, summary: '< 0 B' })
31+
})
32+
33+
it('treats a zero bound as a real value in a between range', () => {
34+
expect(deriveSizeChip('between', '0', 'B', '5', 'MB')).toEqual({
35+
configured: true,
36+
summary: '0 B – 5 MB',
37+
})
2638
})
2739

2840
it('formats a gte filter as "> N UNIT"', () => {
@@ -77,6 +89,28 @@ describe('deriveSizeChip', () => {
7789
it('format defaults to binary when omitted (back-compat)', () => {
7890
expect(deriveSizeChip('gte', '100', 'KB', '', 'KB').summary).toBe('> 100 KB')
7991
})
92+
93+
it('formats an eq filter as "= N UNIT"', () => {
94+
expect(deriveSizeChip('eq', '5', 'MB', '', 'MB')).toEqual({
95+
configured: true,
96+
summary: '= 5 MB',
97+
})
98+
})
99+
100+
it('formats "= 0 B" (find empty files, the headline eq use case)', () => {
101+
expect(deriveSizeChip('eq', '0', 'B', '', 'B')).toEqual({
102+
configured: true,
103+
summary: '= 0 B',
104+
})
105+
})
106+
107+
it('eq stays unconfigured until the user types a value', () => {
108+
expect(deriveSizeChip('eq', '', 'MB', '', 'MB')).toEqual({ configured: false, summary: '' })
109+
})
110+
111+
it('eq respects the SI / binary kB label', () => {
112+
expect(deriveSizeChip('eq', '100', 'KB', '', 'KB', 'si').summary).toBe('= 100 kB')
113+
})
80114
})
81115

82116
describe('deriveDateChip', () => {

apps/desktop/src/lib/query-ui/filter-chips/filter-chip-state.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,19 +48,22 @@ export function deriveSizeChip(
4848

4949
// A configured filter requires at least the first value (or both, for "between"). The chip
5050
// stays unconfigured if the user changed the comparator to "gte" but hasn't typed a number yet.
51+
// `0` is a real bound (find empty files), so we accept `>= 0`; an empty input is `NaN` and
52+
// stays unconfigured.
5153
const minNumeric = parseFloat(sizeValue)
52-
const minOk = !isNaN(minNumeric) && minNumeric > 0
53-
if (sizeFilter === 'gte') {
54-
if (!minOk) return { configured: false, summary: '' }
55-
return { configured: true, summary: `> ${sizeValue.trim()} ${unitLabel}` }
56-
}
57-
if (sizeFilter === 'lte') {
54+
const minOk = !isNaN(minNumeric) && minNumeric >= 0
55+
56+
// The three single-bound comparators differ only by their prefix glyph.
57+
const singleBoundPrefix: Partial<Record<SizeFilter, string>> = { gte: '>', lte: '<', eq: '=' }
58+
const prefix = singleBoundPrefix[sizeFilter]
59+
if (prefix !== undefined) {
5860
if (!minOk) return { configured: false, summary: '' }
59-
return { configured: true, summary: `< ${sizeValue.trim()} ${unitLabel}` }
61+
return { configured: true, summary: `${prefix} ${sizeValue.trim()} ${unitLabel}` }
6062
}
63+
6164
// between
6265
const maxNumeric = parseFloat(sizeValueMax)
63-
const maxOk = !isNaN(maxNumeric) && maxNumeric > 0
66+
const maxOk = !isNaN(maxNumeric) && maxNumeric >= 0
6467
if (!minOk && !maxOk) return { configured: false, summary: '' }
6568
if (minOk && !maxOk) return { configured: true, summary: `> ${sizeValue.trim()} ${unitLabel}` }
6669
if (!minOk && maxOk) return { configured: true, summary: `< ${sizeValueMax.trim()} ${unitMaxLabel}` }

apps/desktop/src/lib/query-ui/query-filter-state.svelte.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@
2222
import type { SearchResultEntry, PatternType, SearchQuery, HistoryFilters } from '$lib/tauri-commands'
2323
export type { PatternType }
2424

25-
export type SizeFilter = 'any' | 'gte' | 'lte' | 'between'
25+
/**
26+
* `eq` is a UI/chip-summary concern only: it round-trips through the matcher and history as
27+
* `between` with `sizeMin == sizeMax` (the matcher's `between` already matches exactly one
28+
* value). No `SizePredicate` or Rust `HistoryFilters` change carries it. See `applySizeQuery`,
29+
* `readSizeFilters`, and `applyHistoryFilters` (which rehydrates `size_min == size_max` as `eq`).
30+
*/
31+
export type SizeFilter = 'any' | 'gte' | 'lte' | 'eq' | 'between'
2632
export type DateFilter = 'any' | 'after' | 'before' | 'between'
2733
/**
2834
* Size unit. `B` (bytes) was added in round 2 (D10) so the list-style popover can let the
@@ -101,6 +107,10 @@ export function bytesToSize(bytes: number): { value: string; unit: SizeUnit } {
101107
if (bytes >= 1024 * 1024) {
102108
return { value: String(Math.round((bytes / (1024 * 1024)) * 100) / 100), unit: 'MB' }
103109
}
110+
// Sub-kilobyte bounds read as raw bytes ("= 0 B", "512 B") rather than a fractional "0.5 KB".
111+
if (bytes < 1024) {
112+
return { value: String(bytes), unit: 'B' }
113+
}
104114
return { value: String(Math.round((bytes / 1024) * 100) / 100), unit: 'KB' }
105115
}
106116

@@ -272,6 +282,10 @@ export function createQueryFilterState(options: CreateQueryFilterStateOptions =
272282
q.minSize = minBytes
273283
} else if (sizeFilter === 'lte' && minBytes !== undefined) {
274284
q.maxSize = minBytes
285+
} else if (sizeFilter === 'eq' && minBytes !== undefined) {
286+
// Exact match: pin both bounds to the same value (the engine's min..max range collapses).
287+
q.minSize = minBytes
288+
q.maxSize = minBytes
275289
} else if (sizeFilter === 'between') {
276290
if (minBytes !== undefined) q.minSize = minBytes
277291
const maxBytes = parseSizeToBytes(sizeValueMax, sizeUnitMax)
@@ -328,7 +342,14 @@ export function createQueryFilterState(options: CreateQueryFilterStateOptions =
328342

329343
if (!filters) return
330344

331-
if (filters.sizeMin != null && filters.sizeMax != null) {
345+
if (filters.sizeMin != null && filters.sizeMax != null && filters.sizeMin === filters.sizeMax) {
346+
// `between x x` and `eq x` match exactly the same set; rehydrate the friendlier `= x`
347+
// label (deliberate: there's no stored comparator kind, so we always collapse to eq).
348+
sizeFilter = 'eq'
349+
const exact = bytesToSize(filters.sizeMin)
350+
sizeValue = exact.value
351+
sizeUnit = exact.unit
352+
} else if (filters.sizeMin != null && filters.sizeMax != null) {
332353
sizeFilter = 'between'
333354
const min = bytesToSize(filters.sizeMin)
334355
const max = bytesToSize(filters.sizeMax)
@@ -366,6 +387,9 @@ export function createQueryFilterState(options: CreateQueryFilterStateOptions =
366387
const minBytes = parseSizeToBytes(sizeValue, sizeUnit)
367388
if (sizeFilter === 'gte') return minBytes !== undefined ? { sizeMin: minBytes } : {}
368389
if (sizeFilter === 'lte') return minBytes !== undefined ? { sizeMax: minBytes } : {}
390+
// `eq` persists as `size_min == size_max`; it rehydrates as `eq` (not `between`) in
391+
// `applyHistoryFilters` by deliberate decision.
392+
if (sizeFilter === 'eq') return minBytes !== undefined ? { sizeMin: minBytes, sizeMax: minBytes } : {}
369393
const maxBytes = parseSizeToBytes(sizeValueMax, sizeUnitMax)
370394
const out: { sizeMin?: number; sizeMax?: number } = {}
371395
if (minBytes !== undefined) out.sizeMin = minBytes

apps/desktop/src/lib/query-ui/query-filter-state.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,40 @@ describe('createQueryFilterState: history filters round-trip', () => {
230230
expect(s.getSizeFilter()).toBe('any')
231231
expect(s.getSizeValue()).toBe('')
232232
})
233+
234+
// `eq` persists as `size_min == size_max` (no Rust comparator field) and, by deliberate
235+
// decision, ALWAYS rehydrates as `eq` (not `between`): the two are semantically identical
236+
// and `= x` is the friendlier label.
237+
it('round-trips an eq filter (persists as min==max, restores as eq)', () => {
238+
const s = createQueryFilterState()
239+
s.setSizeFilter('eq')
240+
s.setSizeValue('5')
241+
s.setSizeUnit('MB')
242+
const filters = s.readHistoryFilters()
243+
expect(filters).toEqual({ sizeMin: 5 * 1024 * 1024, sizeMax: 5 * 1024 * 1024 })
244+
245+
const fresh = createQueryFilterState()
246+
fresh.applyHistoryFilters(filters)
247+
expect(fresh.getSizeFilter()).toBe('eq')
248+
expect(fresh.getSizeValue()).toBe('5')
249+
expect(fresh.getSizeUnit()).toBe('MB')
250+
expect(fresh.getSizeValueMax()).toBe('')
251+
})
252+
253+
it('round-trips eq 0 B (find empty files)', () => {
254+
const s = createQueryFilterState()
255+
s.setSizeFilter('eq')
256+
s.setSizeValue('0')
257+
s.setSizeUnit('B')
258+
const filters = s.readHistoryFilters()
259+
expect(filters).toEqual({ sizeMin: 0, sizeMax: 0 })
260+
261+
const fresh = createQueryFilterState()
262+
fresh.applyHistoryFilters(filters)
263+
expect(fresh.getSizeFilter()).toBe('eq')
264+
expect(fresh.getSizeValue()).toBe('0')
265+
expect(fresh.getSizeUnit()).toBe('B')
266+
})
233267
})
234268

235269
describe('createQueryFilterState: factory isolation', () => {

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,9 @@
228228
const hf = selectionQueryState.readHistoryFilters()
229229
if (f === 'gte') return hf.sizeMin != null ? { kind: 'gte', min: hf.sizeMin } : undefined
230230
if (f === 'lte') return hf.sizeMax != null ? { kind: 'lte', max: hf.sizeMax } : undefined
231-
// between
231+
// `between` and `eq` both land here: `eq` reads back as `sizeMin == sizeMax`, and the
232+
// matcher's `between` matches exactly one value when the bounds coincide. There's no
233+
// separate `eq` predicate kind by design (see `SizeFilter`).
232234
return { kind: 'between', min: hf.sizeMin ?? undefined, max: hf.sizeMax ?? undefined }
233235
}
234236

0 commit comments

Comments
 (0)