feat: full-text search with qmd backend and SearchPanel UI (Cmd+Shift+F) (#64)

* feat: add search backend (qmd CLI) and design file

- Add search.rs: qmd integration for keyword (BM25), semantic (vsearch),
  and hybrid search modes via CLI JSON output
- Register search_vault Tauri command in lib.rs
- Design file with 3 frames: empty, results, no-results states
- Fix pre-existing test failures: install @tauri-apps/plugin-opener,
  add mock in test setup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add SearchPanel UI with Cmd+Shift+F shortcut

- SearchPanel component: full-text search overlay with keyword/semantic
  mode toggle, debounced search (200ms), arrow key navigation, result
  count + elapsed time display, note type badges from vault entries
- Cmd+Shift+F shortcut registered in useAppKeyboard
- Mock search_vault handler in mock-tauri for browser dev mode
- SearchResult/SearchResponse types in types.ts
- Tests: 15 new tests for SearchPanel + 2 for keyboard shortcut
- scrollIntoView mock in test setup for jsdom compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: correct SearchPanel imports for Tauri/browser dual-mode

Use invoke from @tauri-apps/api/core and mockInvoke from mock-tauri
with a searchCall wrapper, fixing the TypeScript build error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: make test_detect_collection_fallback robust when qmd not installed

* fix: reset localStorage between App tests to prevent view mode state leak

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-25 17:09:50 +01:00
committed by GitHub
parent b5f69fa58c
commit 2fe473ebbd
14 changed files with 890 additions and 4 deletions

View File

@@ -0,0 +1,274 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { SearchPanel } from './SearchPanel'
import type { VaultEntry } from '../types'
// Mock the mock-tauri module (component uses mockInvoke when isTauri() is false)
vi.mock('../mock-tauri', () => ({
mockInvoke: vi.fn(),
isTauri: () => false,
}))
import { mockInvoke } from '../mock-tauri'
const mockInvokeFn = vi.mocked(mockInvoke)
const MOCK_ENTRIES: VaultEntry[] = [
{
path: '/vault/essay/ai-apis.md',
filename: 'ai-apis.md',
title: 'How to Design AI-first APIs',
isA: 'Essay',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000,
createdAt: Date.now() / 1000,
fileSize: 500,
snippet: 'A guide to designing APIs for AI',
relationships: {},
icon: null,
color: null,
order: null,
},
{
path: '/vault/event/retreat.md',
filename: 'retreat.md',
title: 'Refactoring Retreat',
isA: 'Event',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000,
createdAt: Date.now() / 1000,
fileSize: 300,
snippet: 'Team retreat event',
relationships: {},
icon: null,
color: null,
order: null,
},
]
describe('SearchPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders nothing when closed', () => {
const { container } = render(
<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()} />
)
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()} />
)
expect(screen.getByText('Search across all note contents')).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} />
)
// Click the overlay (outermost div)
const overlay = screen.getByPlaceholderText('Search in all notes...').closest('.fixed')!
fireEvent.click(overlay)
expect(onClose).toHaveBeenCalled()
})
it('calls onClose on Escape key', () => {
const onClose = vi.fn()
render(
<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 () => {
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' },
],
elapsed_ms: 48,
})
render(
<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' } })
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('search_vault', {
vaultPath: '/vault',
query: 'api design',
mode: 'keyword',
limit: 20,
})
})
await waitFor(() => {
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
})
})
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()} />
)
const input = screen.getByPlaceholderText('Search in all notes...')
fireEvent.change(input, { target: { value: 'xyznonexistent' } })
await waitFor(() => {
expect(screen.getByText('No results found')).toBeInTheDocument()
})
})
it('navigates results with arrow keys', async () => {
mockInvokeFn.mockResolvedValue({
results: [
{ title: 'Result One', path: '/vault/essay/ai-apis.md', snippet: 'First result', score: 0.9, note_type: null },
{ title: 'Result Two', path: '/vault/event/retreat.md', snippet: 'Second result', score: 0.8, note_type: null },
],
elapsed_ms: 20,
})
render(
<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: 'test' } })
await waitFor(() => {
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')
})
})
it('selects result on Enter and calls onSelectNote', async () => {
mockInvokeFn.mockResolvedValue({
results: [
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'First', score: 0.9, note_type: null },
],
elapsed_ms: 20,
})
const onSelectNote = vi.fn()
const onClose = vi.fn()
render(
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={onSelectNote} onClose={onClose} />
)
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } })
await waitFor(() => {
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
})
fireEvent.keyDown(window, { key: 'Enter' })
expect(onSelectNote).toHaveBeenCalledWith(MOCK_ENTRIES[0])
expect(onClose).toHaveBeenCalled()
})
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: [
{ title: 'Result', path: '/vault/essay/ai-apis.md', snippet: 'Content', score: 0.9, note_type: null },
],
elapsed_ms: 123,
})
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' } })
await waitFor(() => {
expect(screen.getByText(/1 result/)).toBeInTheDocument()
expect(screen.getByText(/123ms/)).toBeInTheDocument()
})
})
it('displays note type badge from vault entries', async () => {
mockInvokeFn.mockResolvedValue({
results: [
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'Content', score: 0.9, note_type: null },
],
elapsed_ms: 20,
})
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: 'api' } })
await waitFor(() => {
expect(screen.getByText('Essay')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,273 @@
import { useState, 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)
}
interface SearchPanelProps {
open: boolean
vaultPath: string
entries: VaultEntry[]
onSelectNote: (entry: VaultEntry) => void
onClose: () => void
}
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 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)
}
}, [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
selected?.scrollIntoView({ block: 'nearest' })
}, [selectedIndex])
const handleSelect = useCallback((result: SearchResult) => {
const entry = entries.find(e => e.path === result.path)
if (entry) {
onSelectNote(entry)
onClose()
}
}, [entries, onSelectNote, onClose])
useEffect(() => {
if (!open) return
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onClose()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex(i => Math.min(i + 1, results.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
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')
}
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [open, results, selectedIndex, handleSelect, onClose])
if (!open) return null
const entryTypeMap = new Map(entries.map(e => [e.path, e.isA]))
return (
<div
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
onClick={onClose}
>
<div
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>
</div>
</div>
)
}