Skip to content

Commit ac4c634

Browse files
committed
Search: rebuild dialog shell, no reset on close
- Bump dialog width 900 -> 1080 px to fit upcoming chip rows and path-pill column - Replace heavy row borders with tonal-surface separation, hairlines only where needed - Taller search input (larger font, more padding) on the pattern row - Soften column header to match the FullList pattern - Drop resetSearchState on unmount: close + reopen now preserves query, filters, scope, results, cursor - Add clearSearchState; wire Cmd+N to it and refocus the active input - Cover Cmd+N and state preservation with Vitest - Update lib/search/CLAUDE.md with the new contract and a Gotcha against unmount-time resets
1 parent 62aef44 commit ac4c634

9 files changed

Lines changed: 741 additions & 222 deletions

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,14 @@
7373
.input-row {
7474
display: flex;
7575
align-items: center;
76-
padding: var(--spacing-sm) var(--spacing-md);
77-
border-bottom: 1px solid var(--color-border-strong);
76+
padding: var(--spacing-md) var(--spacing-lg);
7877
background: var(--color-bg-primary);
7978
gap: var(--spacing-sm);
8079
}
8180
82-
/* AI prompt row styling: subtle left accent border */
81+
/* AI prompt row styling: subtle left accent border on a slightly elevated tonal surface.
82+
The hairline below sits against the next .input-row in SearchInputArea, which carries
83+
its own border-top via the adjacent-sibling rule there. */
8384
.ai-prompt-row {
8485
border-left: 2px solid var(--color-accent);
8586
background: var(--color-bg-secondary);
@@ -151,7 +152,7 @@
151152
flex-shrink: 0;
152153
padding: var(--spacing-xxs) var(--spacing-sm);
153154
font-size: var(--font-size-sm);
154-
border: 1px solid var(--color-border-strong);
155+
border: 1px solid var(--color-border);
155156
border-radius: var(--radius-sm);
156157
background: var(--color-bg-secondary);
157158
color: var(--color-text-secondary);

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ include/exclude) filters. Optional AI mode translates natural language queries i
66
Backend: `src-tauri/src/search/` (index, engine, query, AI pipeline), `src-tauri/src/commands/search.rs` (thin IPC
77
wrappers).
88

9+
Dialog width: 1080 px (was 900 px). Internal layout is fluid; no fixed inner widths. The bump leaves room for the filter
10+
chip row and path-pill column landing in later milestones.
11+
912
## Files
1013

1114
| File | Purpose |
@@ -63,6 +66,15 @@ pass. The previous two-pass system caused ~15% regressions; deterministic struct
6366
triggered a search while the index is still loading. On initial open, the results area is empty (no loading message)
6467
since the user is still typing their query.
6568

69+
**State preservation across close + reopen**: The module-level `$state` in `search-state.svelte.ts` survives dialog
70+
unmount. Closing the dialog (Escape or overlay click) does NOT wipe query, filters, scope, results, or cursor. Reopening
71+
the dialog lands the user back where they left off. The only reset path is `⌘N` ("new search") inside the dialog, which
72+
calls `clearSearchState()` and refocuses the active input.
73+
74+
**`⌘N` shortcut**: Hard-coded in `SearchDialog.svelte`'s `handleModifierShortcuts`. Captured before the dialog's global
75+
`stopPropagation` would let it reach the route-level `⌘N` (new tab) handler. The choice of `⌘N` matches the macOS "new
76+
X" idiom (new tab, new document) for the same reason the user reads "fresh search" the same way.
77+
6678
## Key decisions
6779

6880
**Decision**: Dialog, not a panel or sidebar. **Why**: Search is a focused, transient task. A command-palette-style
@@ -80,6 +92,11 @@ the dialog and trigger quick-search or navigation.
8092
`get_read_pool()` returns `None` (indexing disabled or not started). The dialog catches this and enters the disabled
8193
state.
8294

95+
**Gotcha**: Don't call `clearSearchState()` from `onDestroy`. **Why**: The dialog's lifecycle (mount on open, unmount on
96+
close) doesn't match the user's mental model of "the search I was working on." Wiping state on unmount turned every
97+
close + reopen into a lost-work moment. The only sanctioned reset path is `⌘N`. If you find yourself wanting to wipe
98+
state from a lifecycle hook, you probably want a user-initiated action instead.
99+
83100
## References
84101

85102
- [AI search eval history](../../../../../docs/notes/ai-search-eval-history.md) -- Four rounds of prompt tuning for the

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

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
getCaveat,
7878
setCaveat,
7979
buildSearchQuery,
80-
resetSearchState,
80+
clearSearchState,
8181
} from './search-state.svelte'
8282
import AiSearchRow from './AiSearchRow.svelte'
8383
import SearchInputArea from './SearchInputArea.svelte'
@@ -246,7 +246,8 @@
246246
// Clean up any in-progress column drag
247247
document.removeEventListener('mousemove', handleColumnDragMove)
248248
document.removeEventListener('mouseup', handleColumnDragEnd)
249-
resetSearchState()
249+
// State is intentionally NOT cleared here. Close + reopen preserves the user's last
250+
// query, filters, scope, results, and cursor. Explicit reset lives behind ⌘N.
250251
})
251252
252253
function scheduleSearch(): void {
@@ -478,28 +479,52 @@
478479
return document.activeElement === patternInputElement
479480
}
480481
481-
/** Handles modifier-key shortcuts (⌥F, ⌥D, ⌘Enter). Returns true if handled. */
482+
/** Matches a plain modifier-key combo (one of cmd/alt, no others, no shift). */
483+
function matchKey(e: KeyboardEvent, key: string, mod: 'meta' | 'alt'): boolean {
484+
if (e.key !== key || e.shiftKey) return false
485+
return mod === 'meta' ? e.metaKey && !e.altKey : e.altKey && !e.metaKey
486+
}
487+
488+
/** Clears all dialog state (⌘N "new search") and refocuses the active input. */
489+
function clearAndRefocus(): void {
490+
clearSearchState()
491+
void tick().then(() => {
492+
focusActiveInput()
493+
})
494+
}
495+
496+
/** Runs an AI search from the current prompt; no-op when AI is off or the prompt is empty. */
497+
function runAiFromPrompt(): void {
498+
if (!aiEnabled) return
499+
const prompt = getAiPrompt().trim()
500+
if (prompt) void executeAiSearch(prompt)
501+
}
502+
503+
/** Handles modifier-key shortcuts (⌘N, ⌥F, ⌥D, ⌘Enter). Returns true if handled. */
482504
function handleModifierShortcuts(e: KeyboardEvent): boolean {
483-
// ⌥F: set scope to current folder path
484-
if (e.altKey && !e.metaKey && !e.shiftKey && e.key === 'f') {
505+
// ⌘N: clear search state and start fresh. Captured here so the global ⌘N (new tab) doesn't
506+
// fire while the dialog is open. The dialog already calls stopPropagation on every keydown,
507+
// but this handler is also the source of truth for the in-dialog "new search" affordance.
508+
if (matchKey(e, 'n', 'meta')) {
509+
e.preventDefault()
510+
clearAndRefocus()
511+
return true
512+
}
513+
if (matchKey(e, 'f', 'alt')) {
485514
e.preventDefault()
486515
setScope(currentFolderPath)
487516
scheduleSearch()
488517
return true
489518
}
490-
// ⌥D: clear scope (search entire drive)
491-
if (e.altKey && !e.metaKey && !e.shiftKey && e.key === 'd') {
519+
if (matchKey(e, 'd', 'alt')) {
492520
e.preventDefault()
493521
setScope('')
494522
scheduleSearch()
495523
return true
496524
}
497-
// ⌘Enter triggers AI search
498-
if (e.key === 'Enter' && e.metaKey && !e.shiftKey && !e.altKey) {
525+
if (matchKey(e, 'Enter', 'meta')) {
499526
e.preventDefault()
500-
if (!aiEnabled) return true
501-
const prompt = getAiPrompt().trim()
502-
if (prompt) void executeAiSearch(prompt)
527+
runAiFromPrompt()
503528
return true
504529
}
505530
return false
@@ -670,9 +695,9 @@
670695
671696
.search-dialog {
672697
background: var(--color-bg-secondary);
673-
border: 1px solid var(--color-border-strong);
698+
border: 1px solid var(--color-border-subtle);
674699
border-radius: var(--radius-lg);
675-
width: 900px;
700+
width: 1080px;
676701
display: flex;
677702
flex-direction: column;
678703
box-shadow: var(--shadow-lg);
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* Behavior tests for `SearchDialog.svelte`.
3+
*
4+
* Covers M1's two new contracts:
5+
* 1. `⌘N` inside the dialog clears state (and the active input is refocused).
6+
* 2. Close + reopen preserves state (the dialog no longer wipes state on unmount).
7+
*
8+
* State preservation is the load-bearing change behind the new "search-state stays alive
9+
* across dialog close/reopen" UX. The module-level `$state` in `search-state.svelte.ts`
10+
* already outlives the component; we just verify nothing in the dialog secretly wipes it.
11+
*/
12+
13+
import { describe, it, expect, vi, beforeEach } from 'vitest'
14+
import { mount, unmount, tick } from 'svelte'
15+
import { writable } from 'svelte/store'
16+
import SearchDialog from './SearchDialog.svelte'
17+
import {
18+
clearSearchState,
19+
getNamePattern,
20+
setNamePattern,
21+
getScope,
22+
setScope,
23+
getAiPrompt,
24+
setAiPrompt,
25+
getCursorIndex,
26+
setCursorIndex,
27+
} from './search-state.svelte'
28+
29+
vi.mock('$lib/tauri-commands', () => ({
30+
notifyDialogOpened: vi.fn(() => Promise.resolve()),
31+
notifyDialogClosed: vi.fn(() => Promise.resolve()),
32+
prepareSearchIndex: vi.fn(() => Promise.resolve({ ready: true, entryCount: 1234 })),
33+
searchFiles: vi.fn(() => Promise.resolve({ entries: [], totalCount: 0 })),
34+
releaseSearchIndex: vi.fn(() => Promise.resolve()),
35+
translateSearchQuery: vi.fn(() => Promise.resolve({ display: {}, query: {} })),
36+
parseSearchScope: vi.fn(() => Promise.resolve({ includePaths: [], excludePatterns: [] })),
37+
getSystemDirExcludes: vi.fn(() => Promise.resolve([])),
38+
onSearchIndexReady: vi.fn(() => Promise.resolve(() => {})),
39+
formatBytes: vi.fn((n: number) => `${String(n)} B`),
40+
}))
41+
42+
vi.mock('$lib/settings', () => ({
43+
getSetting: vi.fn(() => 'off'),
44+
}))
45+
46+
vi.mock('$lib/indexing', () => ({
47+
isScanning: vi.fn(() => false),
48+
getEntriesScanned: vi.fn(() => 0),
49+
}))
50+
51+
vi.mock('$lib/icon-cache', () => ({
52+
iconCacheVersion: writable(0),
53+
getCachedIcon: vi.fn(() => undefined),
54+
}))
55+
56+
function dispatchKey(target: Element, key: string, meta = false): KeyboardEvent {
57+
const event = new KeyboardEvent('keydown', {
58+
key,
59+
metaKey: meta,
60+
bubbles: true,
61+
cancelable: true,
62+
})
63+
target.dispatchEvent(event)
64+
return event
65+
}
66+
67+
describe('SearchDialog state preservation and ⌘N', () => {
68+
beforeEach(() => {
69+
clearSearchState()
70+
})
71+
72+
it('preserves state across close and reopen', async () => {
73+
const target = document.createElement('div')
74+
document.body.appendChild(target)
75+
76+
const component = mount(SearchDialog, {
77+
target,
78+
props: {
79+
onNavigate: () => {},
80+
onClose: () => {},
81+
currentFolderPath: '/Users/test',
82+
},
83+
})
84+
await tick()
85+
86+
setNamePattern('*.pdf')
87+
setScope('~/Documents')
88+
setCursorIndex(3)
89+
90+
void unmount(component)
91+
target.remove()
92+
await tick()
93+
94+
expect(getNamePattern()).toBe('*.pdf')
95+
expect(getScope()).toBe('~/Documents')
96+
expect(getCursorIndex()).toBe(3)
97+
98+
const target2 = document.createElement('div')
99+
document.body.appendChild(target2)
100+
mount(SearchDialog, {
101+
target: target2,
102+
props: {
103+
onNavigate: () => {},
104+
onClose: () => {},
105+
currentFolderPath: '/Users/test',
106+
},
107+
})
108+
await tick()
109+
110+
expect(getNamePattern()).toBe('*.pdf')
111+
expect(getScope()).toBe('~/Documents')
112+
expect(getCursorIndex()).toBe(3)
113+
})
114+
115+
it('⌘N clears state inside the dialog', async () => {
116+
const target = document.createElement('div')
117+
document.body.appendChild(target)
118+
119+
mount(SearchDialog, {
120+
target,
121+
props: {
122+
onNavigate: () => {},
123+
onClose: () => {},
124+
currentFolderPath: '/Users/test',
125+
},
126+
})
127+
await tick()
128+
129+
setNamePattern('*.pdf')
130+
setScope('~/Documents')
131+
setAiPrompt('large screenshots')
132+
setCursorIndex(5)
133+
134+
const overlay = target.querySelector('.search-overlay')
135+
expect(overlay).toBeTruthy()
136+
dispatchKey(overlay as Element, 'n', true)
137+
await tick()
138+
139+
expect(getNamePattern()).toBe('')
140+
expect(getScope()).toBe('')
141+
expect(getAiPrompt()).toBe('')
142+
expect(getCursorIndex()).toBe(0)
143+
})
144+
})

0 commit comments

Comments
 (0)