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:
1
design/full-text-search.pen
Normal file
1
design/full-text-search.pen
Normal file
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ pub mod frontmatter;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
pub mod menu;
|
||||
pub mod search;
|
||||
pub mod settings;
|
||||
pub mod vault;
|
||||
|
||||
@@ -12,6 +13,7 @@ use ai_chat::{AiChatRequest, AiChatResponse};
|
||||
use frontmatter::FrontmatterValue;
|
||||
use git::{GitCommit, ModifiedFile};
|
||||
use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
use search::SearchResponse;
|
||||
use settings::Settings;
|
||||
use vault::{RenameResult, VaultEntry};
|
||||
|
||||
@@ -151,6 +153,16 @@ async fn github_get_user(token: String) -> Result<GitHubUser, String> {
|
||||
github::github_get_user(&token).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn search_vault(
|
||||
vault_path: String,
|
||||
query: String,
|
||||
mode: String,
|
||||
limit: Option<usize>,
|
||||
) -> Result<SearchResponse, String> {
|
||||
search::search_vault(&vault_path, &query, &mode, limit.unwrap_or(20))
|
||||
}
|
||||
|
||||
fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
match result {
|
||||
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
|
||||
@@ -232,7 +244,8 @@ pub fn run() {
|
||||
clone_repo,
|
||||
github_device_flow_start,
|
||||
github_device_flow_poll,
|
||||
github_get_user
|
||||
github_get_user,
|
||||
search_vault
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
236
src-tauri/src/search.rs
Normal file
236
src-tauri/src/search.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SearchResult {
|
||||
pub title: String,
|
||||
pub path: String,
|
||||
pub snippet: String,
|
||||
pub score: f64,
|
||||
pub note_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SearchResponse {
|
||||
pub results: Vec<SearchResult>,
|
||||
pub elapsed_ms: u64,
|
||||
pub query: String,
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QmdResult {
|
||||
pub file: String,
|
||||
pub title: String,
|
||||
pub snippet: String,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
fn find_qmd_binary() -> Option<String> {
|
||||
let candidates = [
|
||||
dirs::home_dir().map(|h| h.join(".bun/bin/qmd").to_string_lossy().to_string()),
|
||||
Some("/usr/local/bin/qmd".to_string()),
|
||||
Some("/opt/homebrew/bin/qmd".to_string()),
|
||||
];
|
||||
for candidate in candidates.into_iter().flatten() {
|
||||
if Path::new(&candidate).exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
// Fallback: try PATH
|
||||
Command::new("which")
|
||||
.arg("qmd")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn qmd_uri_to_vault_path(uri: &str, vault_path: &str) -> String {
|
||||
// qmd://laputa/essay/foo.md → essay/foo.md
|
||||
let relative = uri
|
||||
.strip_prefix("qmd://")
|
||||
.and_then(|s| s.find('/').map(|i| &s[i + 1..]))
|
||||
.unwrap_or(uri);
|
||||
format!("{}/{}", vault_path, relative)
|
||||
}
|
||||
|
||||
fn extract_clean_snippet(raw_snippet: &str) -> String {
|
||||
// qmd snippets start with "@@ -N,N @@ (N before, N after)\n"
|
||||
// We want just the content lines
|
||||
let lines: Vec<&str> = raw_snippet.lines().collect();
|
||||
let content_start = lines.iter().position(|l| !l.starts_with("@@")).unwrap_or(0);
|
||||
let content: String = lines[content_start..]
|
||||
.iter()
|
||||
.filter(|l| !l.starts_with("---"))
|
||||
.take(3)
|
||||
.copied()
|
||||
.collect::<Vec<&str>>()
|
||||
.join(" ");
|
||||
// Trim to reasonable length
|
||||
if content.len() > 200 {
|
||||
format!("{}...", &content[..200])
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_collection_name(vault_path: &str) -> String {
|
||||
// Try to find which qmd collection maps to this vault path
|
||||
let qmd_bin = match find_qmd_binary() {
|
||||
Some(b) => b,
|
||||
None => return "laputa".to_string(),
|
||||
};
|
||||
|
||||
let output = Command::new(&qmd_bin).args(["collection", "list"]).output();
|
||||
|
||||
match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
let vault_name = Path::new(vault_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("laputa")
|
||||
.to_lowercase();
|
||||
// Look for collection that matches vault directory name
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.contains(&vault_name) && trimmed.contains("qmd://") {
|
||||
// Extract collection name from "name (qmd://name/)"
|
||||
if let Some(name) = trimmed.split_whitespace().next() {
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
vault_name
|
||||
}
|
||||
_ => Path::new(vault_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("laputa")
|
||||
.to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search_vault(
|
||||
vault_path: &str,
|
||||
query: &str,
|
||||
mode: &str,
|
||||
limit: usize,
|
||||
) -> Result<SearchResponse, String> {
|
||||
let start = Instant::now();
|
||||
|
||||
let qmd_bin =
|
||||
find_qmd_binary().ok_or_else(|| "qmd binary not found. Install qmd first.".to_string())?;
|
||||
|
||||
let collection = detect_collection_name(vault_path);
|
||||
|
||||
let search_cmd = match mode {
|
||||
"semantic" => "vsearch",
|
||||
"hybrid" => "query",
|
||||
_ => "search", // "keyword" default
|
||||
};
|
||||
|
||||
let limit_str = limit.to_string();
|
||||
let output = Command::new(&qmd_bin)
|
||||
.args([
|
||||
search_cmd,
|
||||
query,
|
||||
"--collection",
|
||||
&collection,
|
||||
"--json",
|
||||
"-n",
|
||||
&limit_str,
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run qmd: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("qmd search failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let qmd_results: Vec<QmdResult> =
|
||||
serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse qmd output: {}", e))?;
|
||||
|
||||
let results: Vec<SearchResult> = qmd_results
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let path = qmd_uri_to_vault_path(&r.file, vault_path);
|
||||
let snippet = extract_clean_snippet(&r.snippet);
|
||||
SearchResult {
|
||||
title: r.title,
|
||||
path,
|
||||
snippet,
|
||||
score: r.score,
|
||||
note_type: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
Ok(SearchResponse {
|
||||
results,
|
||||
elapsed_ms,
|
||||
query: query.to_string(),
|
||||
mode: mode.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_qmd_uri_to_vault_path() {
|
||||
assert_eq!(
|
||||
qmd_uri_to_vault_path("qmd://laputa/essay/foo.md", "/Users/luca/Laputa"),
|
||||
"/Users/luca/Laputa/essay/foo.md"
|
||||
);
|
||||
assert_eq!(
|
||||
qmd_uri_to_vault_path(
|
||||
"qmd://laputa/event/2025-10-15-retreat.md",
|
||||
"/Users/luca/Laputa"
|
||||
),
|
||||
"/Users/luca/Laputa/event/2025-10-15-retreat.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_clean_snippet() {
|
||||
let raw = "@@ -2,4 @@ (1 before, 9 after)\naliases:\n - \"Refactoring Retreat\"\n\"Is A\":\n - Event";
|
||||
let clean = extract_clean_snippet(raw);
|
||||
assert!(clean.starts_with("aliases:"));
|
||||
assert!(clean.contains("Refactoring Retreat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_clean_snippet_long() {
|
||||
let raw = format!("@@ -1,1 @@\n{}", "a".repeat(300));
|
||||
let clean = extract_clean_snippet(&raw);
|
||||
assert!(clean.len() <= 203); // 200 + "..."
|
||||
assert!(clean.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_collection_fallback() {
|
||||
// With a non-existent vault path, should return either the lowercase dir name
|
||||
// (if qmd is available and collection list succeeds) or "laputa" (if qmd is not installed).
|
||||
// Both are valid fallbacks — this test verifies the function doesn't panic.
|
||||
let name = detect_collection_name("/tmp/test-vault");
|
||||
assert!(
|
||||
name == "test-vault" || name == "laputa",
|
||||
"Expected 'test-vault' or 'laputa', got '{}'",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Provide a localStorage mock that supports all methods (jsdom's may be incomplete)
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { store = {} },
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
||||
|
||||
// Mock @tauri-apps/api/core before importing App
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
@@ -104,6 +116,7 @@ import App from './App'
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.removeItem('laputa-view-mode')
|
||||
})
|
||||
|
||||
it('renders the four-panel layout', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ResizeHandle } from './components/ResizeHandle'
|
||||
import { CreateTypeDialog } from './components/CreateTypeDialog'
|
||||
import { QuickOpenPalette } from './components/QuickOpenPalette'
|
||||
import { CommandPalette } from './components/CommandPalette'
|
||||
import { SearchPanel } from './components/SearchPanel'
|
||||
import { Toast } from './components/Toast'
|
||||
import { CommitDialog } from './components/CommitDialog'
|
||||
import { StatusBar } from './components/StatusBar'
|
||||
@@ -104,6 +105,7 @@ function App() {
|
||||
entries: vault.entries, allContent: vault.allContent,
|
||||
modifiedCount: vault.modifiedFiles.length, selection,
|
||||
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
|
||||
onSearch: dialogs.openSearch,
|
||||
onCreateNote: notes.handleCreateNoteImmediate, onSave: handleSave,
|
||||
onOpenSettings: dialogs.openSettings,
|
||||
onTrashNote: entryActions.handleTrashNote, onArchiveNote: entryActions.handleArchiveNote,
|
||||
@@ -171,6 +173,7 @@ function App() {
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={vaultSwitcher.vaultPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} />
|
||||
|
||||
274
src/components/SearchPanel.test.tsx
Normal file
274
src/components/SearchPanel.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
273
src/components/SearchPanel.tsx
Normal file
273
src/components/SearchPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ interface AppCommandsConfig {
|
||||
selection: SidebarSelection
|
||||
onQuickOpen: () => void
|
||||
onCommandPalette: () => void
|
||||
onSearch: () => void
|
||||
onCreateNote: () => void
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
@@ -39,6 +40,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
useAppKeyboard({
|
||||
onQuickOpen: config.onQuickOpen,
|
||||
onCommandPalette: config.onCommandPalette,
|
||||
onSearch: config.onSearch,
|
||||
onCreateNote: config.onCreateNote,
|
||||
onSave: config.onSave,
|
||||
onOpenSettings: config.onOpenSettings,
|
||||
|
||||
@@ -2,12 +2,13 @@ import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useAppKeyboard } from './useAppKeyboard'
|
||||
|
||||
function fireKey(key: string, mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean } = {}) {
|
||||
function fireKey(key: string, mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {}) {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
altKey: mods.altKey ?? false,
|
||||
metaKey: mods.metaKey ?? false,
|
||||
ctrlKey: mods.ctrlKey ?? false,
|
||||
shiftKey: mods.shiftKey ?? false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
@@ -18,6 +19,7 @@ function makeActions() {
|
||||
return {
|
||||
onQuickOpen: vi.fn(),
|
||||
onCommandPalette: vi.fn(),
|
||||
onSearch: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
@@ -94,4 +96,19 @@ describe('useAppKeyboard', () => {
|
||||
fireKey('k', { metaKey: true })
|
||||
expect(actions.onCommandPalette).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+F triggers search', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('f', { metaKey: true, shiftKey: true })
|
||||
expect(actions.onSearch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+F does not trigger other shortcuts', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('f', { metaKey: true, shiftKey: true })
|
||||
expect(actions.onQuickOpen).not.toHaveBeenCalled()
|
||||
expect(actions.onCreateNote).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ViewMode } from './useViewMode'
|
||||
interface KeyboardActions {
|
||||
onQuickOpen: () => void
|
||||
onCommandPalette: () => void
|
||||
onSearch: () => void
|
||||
onCreateNote: () => void
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
@@ -46,7 +47,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
|
||||
}
|
||||
|
||||
export function useAppKeyboard({
|
||||
onQuickOpen, onCommandPalette, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
|
||||
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
|
||||
onSetViewMode, activeTabPathRef, handleCloseTabRef,
|
||||
}: KeyboardActions) {
|
||||
useEffect(() => {
|
||||
@@ -68,11 +69,17 @@ export function useAppKeyboard({
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Cmd+Shift+F: full-text search (distinct from Cmd+F browser find)
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'f') {
|
||||
e.preventDefault()
|
||||
onSearch()
|
||||
return
|
||||
}
|
||||
if (!handleViewModeKey(e, onSetViewMode)) {
|
||||
handleCmdKey(e, cmdKeyMap)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onQuickOpen, onCommandPalette, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode])
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode])
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export function useDialogs() {
|
||||
const [showAIChat, setShowAIChat] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showGitHubVault, setShowGitHubVault] = useState(false)
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
|
||||
const openCreateType = useCallback(() => setShowCreateTypeDialog(true), [])
|
||||
const closeCreateType = useCallback(() => setShowCreateTypeDialog(false), [])
|
||||
@@ -19,6 +20,8 @@ export function useDialogs() {
|
||||
const openGitHubVault = useCallback(() => setShowGitHubVault(true), [])
|
||||
const closeGitHubVault = useCallback(() => setShowGitHubVault(false), [])
|
||||
const toggleAIChat = useCallback(() => setShowAIChat((c) => !c), [])
|
||||
const openSearch = useCallback(() => setShowSearch(true), [])
|
||||
const closeSearch = useCallback(() => setShowSearch(false), [])
|
||||
|
||||
return {
|
||||
showCreateTypeDialog, openCreateType, closeCreateType,
|
||||
@@ -27,5 +30,6 @@ export function useDialogs() {
|
||||
showAIChat, toggleAIChat,
|
||||
showSettings, openSettings, closeSettings,
|
||||
showGitHubVault, openGitHubVault, closeGitHubVault,
|
||||
showSearch, openSearch, closeSearch,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +204,24 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
return { status: 'complete', access_token: 'gho_mock_oauth_token_xyz', error: null }
|
||||
},
|
||||
github_get_user: (): GitHubUser => ({ login: 'lucaong', name: 'Luca Ongaro', avatar_url: 'https://avatars.githubusercontent.com/u/123456?v=4' }),
|
||||
search_vault: (args: { query: string; mode: string }) => {
|
||||
const q = (args.query ?? '').toLowerCase()
|
||||
if (!q) return { results: [], elapsed_ms: 0, query: q, mode: args.mode }
|
||||
const matches = MOCK_ENTRIES
|
||||
.filter(e => {
|
||||
const content = MOCK_CONTENT[e.path] ?? ''
|
||||
return e.title.toLowerCase().includes(q) || content.toLowerCase().includes(q)
|
||||
})
|
||||
.slice(0, 20)
|
||||
.map((e, i) => ({
|
||||
title: e.title,
|
||||
path: e.path,
|
||||
snippet: e.snippet || '',
|
||||
score: 1.0 - i * 0.05,
|
||||
note_type: e.isA,
|
||||
}))
|
||||
return { results: matches, elapsed_ms: 42, query: q, mode: args.mode }
|
||||
},
|
||||
}
|
||||
|
||||
export function addMockEntry(_entry: VaultEntry, content: string): void {
|
||||
|
||||
@@ -2,6 +2,14 @@ import '@testing-library/jest-dom/vitest'
|
||||
import { vi } from 'vitest'
|
||||
import { createElement, type ReactNode, type ComponentType } from 'react'
|
||||
|
||||
// Mock scrollIntoView for jsdom (not implemented)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
// Mock @tauri-apps/plugin-opener for test environment
|
||||
vi.mock('@tauri-apps/plugin-opener', () => ({
|
||||
openUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock react-virtuoso: JSDOM has no real viewport, so render all items directly
|
||||
vi.mock('react-virtuoso', () => ({
|
||||
Virtuoso: ({ data, itemContent, components }: {
|
||||
|
||||
17
src/types.ts
17
src/types.ts
@@ -80,6 +80,23 @@ export interface GithubRepo {
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
title: string
|
||||
path: string
|
||||
snippet: string
|
||||
score: number
|
||||
noteType: string | null
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
results: SearchResult[]
|
||||
elapsedMs: number
|
||||
query: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
export type SearchMode = 'keyword' | 'semantic' | 'hybrid'
|
||||
|
||||
export type SidebarSelection =
|
||||
| { kind: 'filter'; filter: 'all' | 'favorites' | 'archived' | 'trash' | 'changes' }
|
||||
| { kind: 'sectionGroup'; type: string }
|
||||
|
||||
Reference in New Issue
Block a user