Skip to content

Commit f4eea79

Browse files
committed
Search: auto-apply toggle + Settings > Search section
- New `search.autoApply` boolean setting (default on) under `Behavior > Search`, live-applied via the dialog's reactive `onSpecificSettingChange` subscription. - `SearchSection.svelte` hosts the auto-apply switch and mirrors `search.recentSearches.maxCount` for discoverability, per the settings mirror pattern. - Debounce bumped from 200 ms to 1 s via the new `SEARCH_AUTO_APPLY_DEBOUNCE_MS` constant in `search-state.svelte.ts`. - AI mode never auto-applies regardless of the setting; the bar shows a subtle "Press Enter to search" hint in the right gutter when the query has drifted since the last run. - IME composition guard: `oncompositionstart` suppresses scheduling, `oncompositionend` fires exactly one debounced search after composition completes so CJK input doesn't trigger mid-character searches. - New `⏎` run button on the right end of the bar. Always visible; equivalent to pressing Enter. - Updated the E2E sidebar-order test and the `lib/search/CLAUDE.md` + `lib/settings/CLAUDE.md` docs with the new section, mirror, IME guard, and run-hint rules.
1 parent 1f03ff4 commit f4eea79

17 files changed

Lines changed: 716 additions & 11 deletions

apps/desktop/src/lib/search/CLAUDE.md

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,35 @@ for results, Tab between filters) that would fight `ModalDialog`'s focus managem
118118
**Two-cursor hover model**: Same as command palette. `cursorIndex` (keyboard) and `hoveredIndex` (mouse) are
119119
independent.
120120

121-
**Live search with debounce**: 200ms debounce on filename/regex modes only. AI mode never auto-applies: the AI call
122-
costs money and the user must explicitly opt in via Enter / `⌘Enter` / the chip's Run action. Enter bypasses debounce
123-
for immediate search.
121+
**Live search with debounce**: 1 s debounce on filename/regex modes only, gated by the `search.autoApply` setting
122+
(default on, in `Settings > Behavior > Search`). AI mode never auto-applies regardless of the setting: AI calls cost
123+
money and the user must explicitly opt in via Enter / `⌘Enter` / the `` run button on the right of the bar.
124+
125+
The debounce constant lives in `search-state.svelte.ts` as `SEARCH_AUTO_APPLY_DEBOUNCE_MS = 1000`. All auto-apply
126+
callsites read it from there so changing the value is one edit. The bump from 200 ms to 1 s in M6 matches Spotlight's
127+
feel on a 10M-entry index: the user gets to finish a word before we react. Enter / ⌘Enter / the ⏎ button bypass the
128+
debounce for immediate search.
129+
130+
**Auto-apply gates (M6)**: `scheduleSearch()` returns early in three cases:
131+
132+
1. `mode === 'ai'`: AI never auto-applies.
133+
2. `search.autoApply === false`: the user runs every search explicitly.
134+
3. IME composition is in progress: we don't fire mid-character on Chinese / Japanese / Korean input. On
135+
`compositionend`, the parent calls `scheduleSearch` again so the user gets one fire after the composed character
136+
lands.
137+
138+
The setting is mirrored into the dialog's local `autoApplyEnabled` state via
139+
`onSpecificSettingChange('search.autoApply', ...)`. Live-applied: toggling in the settings window updates the dialog
140+
immediately without reopening.
141+
142+
**`` run button**: Always visible on the right end of the bar. Clicking it is equivalent to pressing Enter in the
143+
input: AI mode runs `runAiFromQuery()`, every other mode runs `executeSearch()`. The button is the mouse-first path; the
144+
keyboard-first path is Enter.
145+
146+
**"Press Enter to search" hint**: Appears in the right gutter of the bar in `--color-text-tertiary` when (a) the query
147+
is non-empty and (b) it has changed since the last actually-issued search and (c) auto-apply won't pick it up
148+
(`mode === 'ai'` OR `search.autoApply === false`). Tracked by `lastRunQuery`, set by `executeSearch()` after a
149+
successful backend call. `⌘N` resets `lastRunQuery` to `null` along with the rest of state.
124150

125151
**Scope row**: Below the chips. Comma-separated folder paths with `!` prefix for exclusions. Parsed via
126152
`parseSearchScope()` IPC call in `executeSearch()` (async, so not part of `buildSearchQuery()`). ⌥F sets scope to the
@@ -152,6 +178,11 @@ contract as the Content mode chip: visible-disabled with an explanatory tooltip
152178
and the active mode is `ai`, the dialog quietly flips to `filename`. The user wouldn't be able to run a search
153179
otherwise.
154180

181+
**IME composition guard**: The dialog tracks `imeComposing` via `oncompositionstart` / `oncompositionend` on the search
182+
bar input. While composing, `scheduleSearch()` is a no-op so we don't fire mid-character on Chinese / Japanese / Korean
183+
input. On `compositionend` the dialog calls `scheduleSearch()` once so the user gets exactly one auto-apply fire after
184+
the composed character lands. Non-negotiable for IME users: see search-redesign-plan §3.6.
185+
155186
**Deferred loading indicator**: The "Loading drive index..." message in the results area only appears when the user has
156187
triggered a search while the index is still loading. On initial open, the results area is empty (no loading message)
157188
since the user is still typing their query.

apps/desktop/src/lib/search/SearchBar.a11y.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ function baseProps(overrides: Partial<Props> = {}): Props {
1919
mode: 'filename',
2020
disabled: false,
2121
aiHighlight: false,
22+
showRunHint: false,
2223
onInput: () => {},
24+
onRun: () => {},
25+
onCompositionStart: () => {},
26+
onCompositionEnd: () => {},
2327
...overrides,
2428
}
2529
}

apps/desktop/src/lib/search/SearchBar.svelte

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,20 @@
66
* so the user can see at a glance what kind of input the bar expects. Switching mode preserves
77
* the typed query; this component is presentational, the parent owns `query` and `mode`.
88
*
9+
* The right gutter shows two things, both managed by the parent dialog:
10+
* - A subtle "Press Enter to search" hint when auto-apply is off (or AI mode) and the query
11+
* has changed since the last run. Visible state, not interactive.
12+
* - A small ⏎ run button. Always present; clicking it is equivalent to pressing Enter.
13+
*
14+
* IME composition is also surfaced: `oncompositionstart` and `oncompositionend` let the parent
15+
* suppress auto-apply mid-composition and fire exactly once on completion (M6 addition).
16+
*
917
* Keyboard contract (handled by the parent dialog, not here):
1018
* - Enter runs the search in the active mode.
1119
* - ⌘Enter runs an AI search regardless (only when AI is enabled).
1220
* - ⌘1/⌘2/⌘3 switch modes (numbering changes when AI is off).
1321
*/
22+
import IconCornerDownLeft from '~icons/lucide/corner-down-left'
1423
import type { SearchMode } from './search-state.svelte'
1524
1625
interface Props {
@@ -20,11 +29,30 @@
2029
mode: SearchMode
2130
disabled: boolean
2231
aiHighlight: boolean
32+
/** True when the bar should show the "Press Enter to search" hint. Owned by the parent. */
33+
showRunHint?: boolean
2334
onInput: (value: string) => void
35+
/** Click handler for the ⏎ run button. Equivalent to pressing Enter in the input. */
36+
onRun: () => void
37+
/** IME composition entry: parent suppresses auto-apply between start and end. */
38+
onCompositionStart?: () => void
39+
/** IME composition exit: parent fires exactly one debounced search after this. */
40+
onCompositionEnd?: () => void
2441
}
2542
2643
/* eslint-disable prefer-const -- $bindable() requires `let` destructuring */
27-
let { inputElement = $bindable(), query, mode, disabled, aiHighlight, onInput }: Props = $props()
44+
let {
45+
inputElement = $bindable(),
46+
query,
47+
mode,
48+
disabled,
49+
aiHighlight,
50+
showRunHint = false,
51+
onInput,
52+
onRun,
53+
onCompositionStart,
54+
onCompositionEnd,
55+
}: Props = $props()
2856
/* eslint-enable prefer-const */
2957
3058
/** Placeholder text per mode. Filenames are the workhorse, so we name the wildcards there. */
@@ -39,6 +67,9 @@
3967
if (mode === 'regex') return 'Regex search pattern'
4068
return 'Filename search pattern'
4169
})
70+
71+
/** AI mode runs only on explicit Enter / ⌘Enter / Run-button click. Show the hint title to match. */
72+
const runTitle = $derived(mode === 'ai' ? 'Run AI search (Enter)' : 'Run search (Enter)')
4273
</script>
4374

4475
<div class="search-bar" class:is-disabled={disabled}>
@@ -56,12 +87,31 @@
5687
oninput={(e: Event) => {
5788
onInput((e.target as HTMLInputElement).value)
5889
}}
90+
oncompositionstart={() => {
91+
onCompositionStart?.()
92+
}}
93+
oncompositionend={() => {
94+
onCompositionEnd?.()
95+
}}
5996
{disabled}
6097
aria-label={ariaLabel}
6198
spellcheck="false"
6299
autocomplete="off"
63100
autocapitalize="off"
64101
/>
102+
{#if showRunHint}
103+
<span class="run-hint" aria-hidden="true">Press Enter to search</span>
104+
{/if}
105+
<button
106+
type="button"
107+
class="run-button"
108+
{disabled}
109+
onclick={onRun}
110+
title={runTitle}
111+
aria-label={runTitle}
112+
>
113+
<IconCornerDownLeft />
114+
</button>
65115
</div>
66116

67117
<style>
@@ -103,4 +153,40 @@
103153
border-radius: var(--radius-sm);
104154
transition: background 1.5s ease-out;
105155
}
156+
157+
.run-hint {
158+
flex-shrink: 0;
159+
color: var(--color-text-tertiary);
160+
font-size: var(--font-size-xs);
161+
white-space: nowrap;
162+
}
163+
164+
.run-button {
165+
flex-shrink: 0;
166+
display: inline-flex;
167+
align-items: center;
168+
justify-content: center;
169+
padding: var(--spacing-xxs) var(--spacing-xs);
170+
background: transparent;
171+
border: 1px solid var(--color-border-subtle);
172+
border-radius: var(--radius-sm);
173+
color: var(--color-text-secondary);
174+
cursor: default;
175+
line-height: 1;
176+
}
177+
178+
.run-button:hover:not(:disabled) {
179+
background: var(--color-bg-tertiary);
180+
color: var(--color-text-primary);
181+
}
182+
183+
.run-button:focus-visible {
184+
outline: 2px solid var(--color-accent);
185+
outline-offset: 1px;
186+
}
187+
188+
.run-button:disabled {
189+
opacity: 0.5;
190+
cursor: default;
191+
}
106192
</style>

apps/desktop/src/lib/search/SearchBar.svelte.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,21 @@ import { mount, tick } from 'svelte'
1111
import SearchBar from './SearchBar.svelte'
1212
import type { SearchMode } from './search-state.svelte'
1313

14-
function mountBar(overrides: Partial<{ query: string; mode: SearchMode }>): {
14+
function mountBar(overrides: Partial<{ query: string; mode: SearchMode; showRunHint: boolean }>): {
15+
target: HTMLDivElement
1516
input: HTMLInputElement
1617
onInput: ReturnType<typeof vi.fn>
18+
onRun: ReturnType<typeof vi.fn>
19+
onCompositionStart: ReturnType<typeof vi.fn>
20+
onCompositionEnd: ReturnType<typeof vi.fn>
1721
cleanup: () => void
1822
} {
1923
const target = document.createElement('div')
2024
document.body.appendChild(target)
2125
const onInput = vi.fn()
26+
const onRun = vi.fn()
27+
const onCompositionStart = vi.fn()
28+
const onCompositionEnd = vi.fn()
2229
mount(SearchBar, {
2330
target,
2431
props: {
@@ -27,14 +34,22 @@ function mountBar(overrides: Partial<{ query: string; mode: SearchMode }>): {
2734
mode: overrides.mode ?? 'filename',
2835
disabled: false,
2936
aiHighlight: false,
37+
showRunHint: overrides.showRunHint ?? false,
3038
onInput,
39+
onRun,
40+
onCompositionStart,
41+
onCompositionEnd,
3142
},
3243
})
3344
const input = target.querySelector('input.query-input') as HTMLInputElement | null
3445
if (!input) throw new Error('input not found')
3546
return {
47+
target,
3648
input,
3749
onInput,
50+
onRun,
51+
onCompositionStart,
52+
onCompositionEnd,
3853
cleanup: () => {
3954
target.remove()
4055
},
@@ -78,4 +93,37 @@ describe('SearchBar', () => {
7893
expect(onInput).toHaveBeenCalledWith('photo*')
7994
cleanup()
8095
})
96+
97+
it('renders the ⏎ run button and calls onRun when clicked', async () => {
98+
const { target, onRun, cleanup } = mountBar({})
99+
await tick()
100+
const button = target.querySelector('button.run-button') as HTMLButtonElement | null
101+
expect(button).not.toBeNull()
102+
button?.click()
103+
expect(onRun).toHaveBeenCalledTimes(1)
104+
cleanup()
105+
})
106+
107+
it('shows the "Press Enter to search" hint only when showRunHint is true', async () => {
108+
const { target, cleanup } = mountBar({ showRunHint: true })
109+
await tick()
110+
const hint = target.querySelector('.run-hint')
111+
expect(hint?.textContent).toMatch(/Press Enter to search/i)
112+
cleanup()
113+
114+
const { target: noHintTarget, cleanup: cleanup2 } = mountBar({ showRunHint: false })
115+
await tick()
116+
expect(noHintTarget.querySelector('.run-hint')).toBeNull()
117+
cleanup2()
118+
})
119+
120+
it('forwards compositionstart and compositionend to the parent (IME guard)', async () => {
121+
const { input, onCompositionStart, onCompositionEnd, cleanup } = mountBar({})
122+
await tick()
123+
input.dispatchEvent(new CompositionEvent('compositionstart'))
124+
expect(onCompositionStart).toHaveBeenCalledTimes(1)
125+
input.dispatchEvent(new CompositionEvent('compositionend'))
126+
expect(onCompositionEnd).toHaveBeenCalledTimes(1)
127+
cleanup()
128+
})
81129
})

apps/desktop/src/lib/search/SearchDialog.a11y.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,12 @@ let aiProvider: 'off' | 'local' | 'cloud' = 'off'
3434
vi.mock('$lib/settings', () => ({
3535
getSetting: vi.fn((key: string) => {
3636
if (key === 'ai.provider') return aiProvider
37+
if (key === 'search.autoApply') return true
3738
return undefined
3839
}),
40+
// SearchDialog subscribes to `search.autoApply` changes; the a11y suite doesn't toggle it, so a
41+
// no-op unsubscribe is enough.
42+
onSpecificSettingChange: vi.fn(() => () => {}),
3943
}))
4044

4145
vi.mock('$lib/indexing', () => ({

0 commit comments

Comments
 (0)