feat: unified search panel — progressive keyword+semantic results (#80)
* feat: unify search panel — remove keyword/semantic toggle, progressive results Replace separate keyword/semantic mode toggle with a unified search that streams results progressively: keyword results appear first (<500ms), then hybrid results augment the list (~1-2s). Loading spinner shows in the input field while semantic search runs. Stale requests are discarded via generation counter for safe rapid typing. - Extract search logic into useUnifiedSearch hook (code health 9.26+) - 300ms debounce, 5s timeout on hybrid, graceful fallback on errors - 15 tests covering progressive search, spinner, stale results, failures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add design wireframes for unified search panel Two frames showing the search panel states: - "keyword results loaded, semantic loading" (with spinner) - "all results loaded" (no spinner, more results including semantic) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: coverage artifacts --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
path: '/vault/event/retreat.md',
|
||||
@@ -58,6 +59,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -68,31 +70,39 @@ describe('SearchPanel', () => {
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(
|
||||
<SearchPanel open={false} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />
|
||||
<SearchPanel open={false} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders search input when open', () => {
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByPlaceholderText('Search in all notes...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty state hint when no query', () => {
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText('Search across all note contents')).toBeInTheDocument()
|
||||
expect(screen.getByText('Enter to open · Esc to close')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has no keyword/semantic toggle', () => {
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(screen.queryByText('Keyword')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Semantic')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClose when clicking overlay', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={onClose} />
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={onClose} />,
|
||||
)
|
||||
// Click the overlay (outermost div)
|
||||
const overlay = screen.getByPlaceholderText('Search in all notes...').closest('.fixed')!
|
||||
fireEvent.click(overlay)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
@@ -101,13 +111,13 @@ describe('SearchPanel', () => {
|
||||
it('calls onClose on Escape key', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={onClose} />
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={onClose} />,
|
||||
)
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('performs search on query input with debounce', async () => {
|
||||
it('performs unified search with keyword then hybrid', async () => {
|
||||
mockInvokeFn.mockResolvedValue({
|
||||
results: [
|
||||
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: '...designing APIs for AI...', score: 0.87, note_type: 'Essay' },
|
||||
@@ -116,12 +126,13 @@ describe('SearchPanel', () => {
|
||||
})
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('Search in all notes...')
|
||||
fireEvent.change(input, { target: { value: 'api design' } })
|
||||
|
||||
// Should call keyword search first
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('search_vault', {
|
||||
vaultPath: '/vault',
|
||||
@@ -131,16 +142,27 @@ describe('SearchPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Results should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should also call hybrid search
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('search_vault', {
|
||||
vaultPath: '/vault',
|
||||
query: 'api design',
|
||||
mode: 'hybrid',
|
||||
limit: 20,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no results message when search returns empty', async () => {
|
||||
mockInvokeFn.mockResolvedValue({ results: [], elapsed_ms: 10 })
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('Search in all notes...')
|
||||
@@ -161,7 +183,7 @@ describe('SearchPanel', () => {
|
||||
})
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('Search in all notes...')
|
||||
@@ -171,10 +193,8 @@ describe('SearchPanel', () => {
|
||||
expect(screen.getByText('Result One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Arrow down should highlight second result
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
|
||||
// The second item should now have bg-accent class
|
||||
await waitFor(() => {
|
||||
const resultTwo = screen.getByText('Result Two').closest('[class*="cursor-pointer"]')!
|
||||
expect(resultTwo.className).toContain('bg-accent')
|
||||
@@ -192,7 +212,7 @@ describe('SearchPanel', () => {
|
||||
const onSelectNote = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={onSelectNote} onClose={onClose} />
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={onSelectNote} onClose={onClose} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } })
|
||||
@@ -201,7 +221,6 @@ describe('SearchPanel', () => {
|
||||
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Wait for the effect to re-register with new results before firing Enter
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -210,32 +229,6 @@ describe('SearchPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles search mode with Tab key', async () => {
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />
|
||||
)
|
||||
|
||||
// Initial mode is keyword — the keyword button should be styled active
|
||||
const keywordBtn = screen.getByText('Keyword')
|
||||
expect(keywordBtn.className).toContain('bg-secondary')
|
||||
|
||||
// Tab toggles to semantic
|
||||
fireEvent.keyDown(window, { key: 'Tab' })
|
||||
|
||||
const semanticBtn = screen.getByText('Semantic')
|
||||
expect(semanticBtn.className).toContain('bg-secondary')
|
||||
})
|
||||
|
||||
it('toggles mode via button click', () => {
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Semantic'))
|
||||
expect(screen.getByText('Semantic').className).toContain('bg-secondary')
|
||||
expect(screen.getByText('Keyword').className).not.toContain('bg-secondary')
|
||||
})
|
||||
|
||||
it('shows result count and elapsed time', async () => {
|
||||
mockInvokeFn.mockResolvedValue({
|
||||
results: [
|
||||
@@ -245,7 +238,7 @@ describe('SearchPanel', () => {
|
||||
})
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'test' } })
|
||||
@@ -265,7 +258,7 @@ describe('SearchPanel', () => {
|
||||
})
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } })
|
||||
@@ -274,4 +267,105 @@ describe('SearchPanel', () => {
|
||||
expect(screen.getByText('Essay')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading spinner while searching', async () => {
|
||||
const resolvers: ((v: unknown) => void)[] = []
|
||||
mockInvokeFn.mockImplementation(
|
||||
() => new Promise(resolve => { resolvers.push(resolve) }),
|
||||
)
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'test' } })
|
||||
|
||||
// Spinner appears when keyword search starts (after debounce)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('search-spinner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Resolve keyword search
|
||||
resolvers[0]({
|
||||
results: [{ title: 'Result', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null }],
|
||||
elapsed_ms: 30,
|
||||
})
|
||||
|
||||
// Keyword results appear, spinner still visible (hybrid in progress)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Result')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('search-spinner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Wait for hybrid call then resolve it
|
||||
await waitFor(() => { expect(resolvers).toHaveLength(2) })
|
||||
resolvers[1]({
|
||||
results: [{ title: 'Result', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null }],
|
||||
elapsed_ms: 150,
|
||||
})
|
||||
|
||||
// Spinner disappears after hybrid completes
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('search-spinner')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('discards stale results when query changes rapidly', async () => {
|
||||
mockInvokeFn.mockImplementation(async (_cmd: string, args?: Record<string, unknown>) => {
|
||||
const q = (args as Record<string, string>)?.query
|
||||
if (q === 'second') {
|
||||
return {
|
||||
results: [{ title: 'Second Result', path: '/vault/event/retreat.md', snippet: '', score: 0.9, note_type: null }],
|
||||
elapsed_ms: 30,
|
||||
}
|
||||
}
|
||||
return { results: [], elapsed_ms: 0 }
|
||||
})
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('Search in all notes...')
|
||||
// Type first query, then immediately change to second (within debounce)
|
||||
fireEvent.change(input, { target: { value: 'first' } })
|
||||
fireEvent.change(input, { target: { value: 'second' } })
|
||||
|
||||
// Only second query results should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Second Result')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps keyword results when hybrid search fails', async () => {
|
||||
mockInvokeFn.mockImplementation(async (_cmd: string, args?: Record<string, unknown>) => {
|
||||
const mode = (args as Record<string, string>)?.mode
|
||||
if (mode === 'keyword') {
|
||||
return {
|
||||
results: [{ title: 'Keyword Only', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null }],
|
||||
elapsed_ms: 30,
|
||||
}
|
||||
}
|
||||
throw new Error('qmd unavailable')
|
||||
})
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'test' } })
|
||||
|
||||
// Keyword results appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Keyword Only')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Spinner disappears after hybrid fails
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('search-spinner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Keyword results remain
|
||||
expect(screen.getByText('Keyword Only')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,26 +1,8 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { SearchMode, SearchResult, VaultEntry } from '../types'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
|
||||
interface SearchResultData {
|
||||
title: string
|
||||
path: string
|
||||
snippet: string
|
||||
score: number
|
||||
note_type: string | null
|
||||
}
|
||||
|
||||
interface SearchResponseData {
|
||||
results: SearchResultData[]
|
||||
elapsed_ms: number
|
||||
}
|
||||
|
||||
function searchCall(args: Record<string, unknown>): Promise<SearchResponseData> {
|
||||
return isTauri() ? invoke<SearchResponseData>('search_vault', args) : mockInvoke<SearchResponseData>('search_vault', args)
|
||||
}
|
||||
import type { SearchResult, VaultEntry } from '../types'
|
||||
import { useUnifiedSearch } from '../hooks/useUnifiedSearch'
|
||||
|
||||
interface SearchPanelProps {
|
||||
open: boolean
|
||||
@@ -31,76 +13,17 @@ interface SearchPanelProps {
|
||||
}
|
||||
|
||||
export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }: SearchPanelProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [mode, setMode] = useState<SearchMode>('keyword')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [elapsedMs, setElapsedMs] = useState<number | null>(null)
|
||||
const [searchError, setSearchError] = useState<string | null>(null)
|
||||
const {
|
||||
query, setQuery, results, selectedIndex, setSelectedIndex, loading, elapsedMs,
|
||||
} = useUnifiedSearch(vaultPath, open)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setSelectedIndex(0)
|
||||
setElapsedMs(null)
|
||||
setSearchError(null)
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
if (open) setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}, [open])
|
||||
|
||||
const performSearch = useCallback(async (q: string, m: SearchMode) => {
|
||||
if (!q.trim()) {
|
||||
setResults([])
|
||||
setElapsedMs(null)
|
||||
setSearchError(null)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setSearchError(null)
|
||||
try {
|
||||
const response = await searchCall({
|
||||
vaultPath,
|
||||
query: q,
|
||||
mode: m,
|
||||
limit: 20,
|
||||
})
|
||||
const mapped = response.results.map((r: SearchResultData) => ({
|
||||
title: r.title,
|
||||
path: r.path,
|
||||
snippet: r.snippet,
|
||||
score: r.score,
|
||||
noteType: r.note_type,
|
||||
}))
|
||||
setResults(mapped)
|
||||
setElapsedMs(response.elapsed_ms)
|
||||
setSelectedIndex(0)
|
||||
} catch (err) {
|
||||
setSearchError(String(err))
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [vaultPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
if (!query.trim()) {
|
||||
setResults([])
|
||||
setElapsedMs(null)
|
||||
return
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
performSearch(query, mode)
|
||||
}, 200)
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [query, mode, performSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return
|
||||
const selected = listRef.current.children[selectedIndex] as HTMLElement | undefined
|
||||
@@ -129,17 +52,12 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }:
|
||||
setSelectedIndex(i => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (results[selectedIndex]) {
|
||||
handleSelect(results[selectedIndex])
|
||||
}
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
setMode(m => m === 'keyword' ? 'semantic' : 'keyword')
|
||||
if (results[selectedIndex]) handleSelect(results[selectedIndex])
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [open, results, selectedIndex, handleSelect, onClose])
|
||||
}, [open, results, selectedIndex, handleSelect, setSelectedIndex, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
@@ -154,120 +72,145 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }:
|
||||
className="flex w-[540px] max-w-[90vw] max-h-[480px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Search input row */}
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<svg className="h-4 w-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="flex-1 bg-transparent text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
type="text"
|
||||
placeholder="Search in all notes..."
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-md px-2 py-1 text-[11px] font-medium transition-colors",
|
||||
mode === 'keyword'
|
||||
? "bg-secondary text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setMode('keyword')}
|
||||
>
|
||||
Keyword
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-md px-2 py-1 text-[11px] font-medium transition-colors",
|
||||
mode === 'semantic'
|
||||
? "bg-secondary text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setMode('semantic')}
|
||||
>
|
||||
Semantic
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !query.trim() && (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-[13px] text-muted-foreground">Search across all note contents</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/60">
|
||||
Tab to toggle keyword/semantic · Enter to open · Esc to close
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && query.trim() && results.length === 0 && !searchError && (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-[13px] text-muted-foreground">No results found</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/60">
|
||||
Try different keywords or switch to semantic search
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchError && (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-[13px] text-destructive">Search error</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">{searchError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results.length > 0 && (
|
||||
<>
|
||||
<div className="border-b border-border/50 px-4 py-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{results.length} result{results.length !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div ref={listRef}>
|
||||
{results.map((result, i) => {
|
||||
const noteType = entryTypeMap.get(result.path) ?? result.noteType
|
||||
return (
|
||||
<div
|
||||
key={result.path}
|
||||
className={cn(
|
||||
"cursor-pointer px-4 py-2.5 transition-colors",
|
||||
i === selectedIndex ? "bg-accent" : "hover:bg-secondary"
|
||||
)}
|
||||
onClick={() => handleSelect(result)}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-foreground">{result.title}</span>
|
||||
{noteType && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{noteType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{result.snippet && (
|
||||
<p className="mt-1 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
|
||||
{result.snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SearchInput
|
||||
ref={inputRef}
|
||||
query={query}
|
||||
loading={loading}
|
||||
onChange={setQuery}
|
||||
/>
|
||||
<SearchContent
|
||||
query={query}
|
||||
results={results}
|
||||
selectedIndex={selectedIndex}
|
||||
loading={loading}
|
||||
elapsedMs={elapsedMs}
|
||||
entryTypeMap={entryTypeMap}
|
||||
listRef={listRef}
|
||||
onSelect={handleSelect}
|
||||
onHover={setSelectedIndex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
interface SearchInputProps {
|
||||
query: string
|
||||
loading: boolean
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
function SearchInput({ query, loading, onChange }, ref) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<svg className="h-4 w-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
ref={ref}
|
||||
className="flex-1 bg-transparent text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
type="text"
|
||||
placeholder="Search in all notes..."
|
||||
value={query}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
{loading && (
|
||||
<svg
|
||||
className="h-4 w-4 shrink-0 animate-spin text-muted-foreground"
|
||||
data-testid="search-spinner"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
interface SearchContentProps {
|
||||
query: string
|
||||
results: SearchResult[]
|
||||
selectedIndex: number
|
||||
loading: boolean
|
||||
elapsedMs: number | null
|
||||
entryTypeMap: Map<string, string | null>
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
onSelect: (result: SearchResult) => void
|
||||
onHover: (index: number) => void
|
||||
}
|
||||
|
||||
function SearchContent({
|
||||
query, results, selectedIndex, loading, elapsedMs, entryTypeMap, listRef, onSelect, onHover,
|
||||
}: SearchContentProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!query.trim() && (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-[13px] text-muted-foreground">Search across all note contents</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/60">
|
||||
Enter to open · Esc to close
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.trim() && results.length === 0 && loading && (
|
||||
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.trim() && results.length === 0 && !loading && (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-[13px] text-muted-foreground">No results found</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<>
|
||||
<div className="border-b border-border/50 px-4 py-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{results.length} result{results.length !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div ref={listRef}>
|
||||
{results.map((result, i) => {
|
||||
const noteType = entryTypeMap.get(result.path) ?? result.noteType
|
||||
return (
|
||||
<div
|
||||
key={result.path}
|
||||
className={cn(
|
||||
"cursor-pointer px-4 py-2.5 transition-colors",
|
||||
i === selectedIndex ? "bg-accent" : "hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => onSelect(result)}
|
||||
onMouseEnter={() => onHover(i)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-foreground">{result.title}</span>
|
||||
{noteType && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{noteType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{result.snippet && (
|
||||
<p className="mt-1 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
|
||||
{result.snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user