diff --git a/design/full-text-search.pen b/design/full-text-search.pen new file mode 100644 index 00000000..badff37f --- /dev/null +++ b/design/full-text-search.pen @@ -0,0 +1 @@ +{"children":[{"children":[{"alignItems":"center","children":[{"content":"๐Ÿ”","fill":"#8888AA","fontFamily":"Inter","fontSize":16,"fontWeight":"normal","id":"PjHq0","name":"searchIcon","type":"text"},{"content":"Search in all notes...","fill":"#666680","fontFamily":"Inter","fontSize":15,"fontWeight":"normal","id":"rkrbE","name":"searchInput","textGrowth":"fixed-width","type":"text","width":"fill_container"},{"alignItems":"center","children":[{"alignItems":"center","children":[{"content":"Keyword","fill":"#CCCCDD","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"R5D8g","name":"kwText","type":"text"}],"cornerRadius":4,"fill":"#4C4C6D","id":"mcpPa","name":"kwBadge","padding":[4,8],"type":"frame"},{"alignItems":"center","children":[{"content":"Semantic","fill":"#666680","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"NJYVj","name":"semText","type":"text"}],"cornerRadius":4,"fill":"#33334D","id":"W1DvY","name":"semBadge","padding":[4,8],"type":"frame"}],"gap":4,"id":"1az05","name":"Mode Toggle","type":"frame"}],"cornerRadius":8,"fill":"#2A2A3C","gap":10,"height":48,"id":"x1pHS","name":"Search Bar","padding":[0,16],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"Search across all note contents โ€” Cmd+Shift+F","fill":"#555570","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"6O2iX","name":"hintText","type":"text"}],"id":"73imz","justifyContent":"center","name":"shortcutHint","padding":[24,16],"type":"frame","width":"fill_container"}],"cornerRadius":12,"fill":"#1E1E2E","id":"3aG9b","layout":"vertical","name":"Full-text Search โ€” Empty State","type":"frame","width":500,"x":0,"y":0},{"children":[{"alignItems":"center","children":[{"content":"๐Ÿ”","fill":"#8888AA","fontFamily":"Inter","fontSize":16,"fontWeight":"normal","id":"9pswg","name":"icon2","type":"text"},{"content":"time management","fill":"#E0E0F0","fontFamily":"Inter","fontSize":15,"fontWeight":"normal","id":"IG4Oy","name":"input2","textGrowth":"fixed-width","type":"text","width":"fill_container"},{"alignItems":"center","children":[{"alignItems":"center","children":[{"content":"Keyword","fill":"#CCCCDD","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"aL4o7","name":"kwLabel","type":"text"}],"cornerRadius":4,"fill":"#4C4C6D","id":"7jTLP","name":"kwActive","padding":[4,8],"type":"frame"},{"alignItems":"center","children":[{"content":"Semantic","fill":"#666680","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"Kq87g","name":"semLabel","type":"text"}],"cornerRadius":4,"fill":"#33334D","id":"cTlcM","name":"semInactive","padding":[4,8],"type":"frame"}],"gap":4,"id":"hgbNF","name":"Mode Toggle","type":"frame"}],"cornerRadius":8,"fill":"#2A2A3C","gap":10,"height":48,"id":"mNK9x","name":"Search Bar","padding":[0,16],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"12 results โ€” 148ms","fill":"#555570","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"ox2wo","name":"countText","type":"text"}],"id":"SAQ8v","name":"countBar","padding":[6,16],"type":"frame","width":"fill_container"},{"children":[{"children":[{"content":"How to Manage your Time","fill":"#E0E0F0","fontFamily":"Inter","fontSize":14,"fontWeight":"600","id":"tCIvh","name":"r1title","type":"text"},{"content":"...how people can improve their time management skills. They gathered results in this article...","fill":"#888899","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"jAqxc","lineHeight":1.4,"name":"r1snippet","textGrowth":"fixed-width","type":"text","width":"fill_container"},{"alignItems":"center","children":[{"children":[{"content":"Essay","fill":"#8888AA","fontFamily":"Inter","fontSize":10,"fontWeight":"normal","id":"Peatz","name":"r1typeText","type":"text"}],"cornerRadius":3,"fill":"#33334D","id":"a4ghT","name":"r1type","padding":[2,6],"type":"frame"},{"content":"score: 0.87","fill":"#555570","fontFamily":"Inter","fontSize":10,"fontWeight":"normal","id":"xS0iX","name":"r1score","type":"text"}],"gap":8,"id":"AP1fQ","name":"r1meta","type":"frame"}],"fill":"#2A2A3C","gap":4,"id":"g7JoL","layout":"vertical","name":"Result Item โ€” Selected","padding":[10,16],"type":"frame","width":"fill_container"},{"children":[{"content":"On solo work, self-reflection and decision fatigue","fill":"#C0C0D0","fontFamily":"Inter","fontSize":14,"id":"eSx2r","name":"r2title","type":"text"},{"content":"...I organize my time carefully, create recurring activities, block time on my calendar...","fill":"#666680","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"MB5C8","lineHeight":1.4,"name":"r2snippet","textGrowth":"fixed-width","type":"text","width":"fill_container"},{"alignItems":"center","children":[{"children":[{"content":"Evergreen","fill":"#8888AA","fontFamily":"Inter","fontSize":10,"fontWeight":"normal","id":"lVC0y","name":"r2typeText","type":"text"}],"cornerRadius":3,"fill":"#33334D","id":"DZDtT","name":"r2type","padding":[2,6],"type":"frame"},{"content":"score: 0.75","fill":"#555570","fontFamily":"Inter","fontSize":10,"fontWeight":"normal","id":"q5ohu","name":"r2score","type":"text"}],"gap":8,"id":"kUzQY","name":"r2meta","type":"frame"}],"gap":4,"id":"wa3Pe","layout":"vertical","name":"Result Item","padding":[10,16],"type":"frame","width":"fill_container"},{"children":[{"content":"Time Management Is About More Than Life Hacks","fill":"#C0C0D0","fontFamily":"Inter","fontSize":14,"fontWeight":"normal","id":"fDBcr","name":"r3title","type":"text"},{"content":"...the results ran counter to popular admonitions of either the virtues or the detriments of multitasking...","fill":"#666680","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"9X1rc","lineHeight":1.4,"name":"r3snippet","textGrowth":"fixed-width","type":"text","width":"fill_container"},{"alignItems":"center","children":[{"children":[{"content":"Reading","fill":"#8888AA","fontFamily":"Inter","fontSize":10,"fontWeight":"normal","id":"ql9Q1","name":"r3typeText","type":"text"}],"cornerRadius":3,"fill":"#33334D","id":"IHIHs","name":"r3type","padding":[2,6],"type":"frame"},{"content":"score: 0.73","fill":"#555570","fontFamily":"Inter","fontSize":10,"fontWeight":"normal","id":"XQs3c","name":"r3score","type":"text"}],"gap":8,"id":"gMuVI","name":"r3meta","type":"frame"}],"gap":4,"id":"EATNG","layout":"vertical","name":"Result Item","padding":[10,16],"type":"frame","width":"fill_container"}],"id":"cGRXu","layout":"vertical","name":"Results List","type":"frame","width":"fill_container"}],"cornerRadius":12,"fill":"#1E1E2E","id":"K1O2x","layout":"vertical","name":"Full-text Search โ€” Results","type":"frame","width":500,"x":560,"y":0},{"children":[{"alignItems":"center","children":[{"content":"๐Ÿ”","fill":"#8888AA","fontFamily":"Inter","fontSize":16,"fontWeight":"normal","id":"nrIc1","name":"icon3","type":"text"},{"content":"xyznonexistent","fill":"#E0E0F0","fontFamily":"Inter","fontSize":15,"fontWeight":"normal","id":"nrIc2","name":"input3","textGrowth":"fixed-width","type":"text","width":"fill_container"},{"alignItems":"center","children":[{"alignItems":"center","children":[{"content":"Keyword","fill":"#CCCCDD","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"nrIc3","type":"text"}],"cornerRadius":4,"fill":"#4C4C6D","id":"nrIc4","padding":[4,8],"type":"frame"},{"alignItems":"center","children":[{"content":"Semantic","fill":"#666680","fontFamily":"Inter","fontSize":11,"fontWeight":"normal","id":"nrIc5","type":"text"}],"cornerRadius":4,"fill":"#33334D","id":"nrIc6","padding":[4,8],"type":"frame"}],"gap":4,"id":"nrIc7","name":"Mode Toggle","type":"frame"}],"cornerRadius":8,"fill":"#2A2A3C","gap":10,"height":48,"id":"nrIc8","name":"Search Bar","padding":[0,16],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"No results found","fill":"#888899","fontFamily":"Inter","fontSize":14,"fontWeight":"normal","id":"nrIcA","type":"text"},{"content":"Try different keywords or switch to semantic search","fill":"#555570","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"nrIcB","type":"text"}],"gap":8,"id":"nrIc9","justifyContent":"center","layout":"vertical","name":"No Results Body","padding":[32,16],"type":"frame","width":"fill_container"}],"cornerRadius":12,"fill":"#1E1E2E","id":"nrIcZ","layout":"vertical","name":"Full-text Search โ€” No Results","type":"frame","width":500,"x":1120,"y":0}],"variables":{}} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1450610a..6bc17dfa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { github::github_get_user(&token).await } +#[tauri::command] +fn search_vault( + vault_path: String, + query: String, + mode: String, + limit: Option, +) -> Result { + search::search_vault(&vault_path, &query, &mode, limit.unwrap_or(20)) +} + fn log_startup_result(label: &str, result: Result) { 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"); diff --git a/src-tauri/src/search.rs b/src-tauri/src/search.rs new file mode 100644 index 00000000..5467540f --- /dev/null +++ b/src-tauri/src/search.rs @@ -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, +} + +#[derive(Debug, Serialize)] +pub struct SearchResponse { + pub results: Vec, + 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 { + 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::>() + .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 { + 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 = + serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse qmd output: {}", e))?; + + let results: Vec = 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 + ); + } +} diff --git a/src/App.test.tsx b/src/App.test.tsx index a8e3ae52..78dfcd18 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -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 = {} + 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 () => { diff --git a/src/App.tsx b/src/App.tsx index 15fcae2b..4ed4b014 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { setToastMessage(null)} /> + diff --git a/src/components/SearchPanel.test.tsx b/src/components/SearchPanel.test.tsx new file mode 100644 index 00000000..4b4f2278 --- /dev/null +++ b/src/components/SearchPanel.test.tsx @@ -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( + + ) + expect(container.innerHTML).toBe('') + }) + + it('renders search input when open', () => { + render( + + ) + expect(screen.getByPlaceholderText('Search in all notes...')).toBeInTheDocument() + }) + + it('shows empty state hint when no query', () => { + render( + + ) + expect(screen.getByText('Search across all note contents')).toBeInTheDocument() + }) + + it('calls onClose when clicking overlay', () => { + const onClose = vi.fn() + render( + + ) + // 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( + + ) + 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( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + 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( + + ) + + // 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( + + ) + + 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( + + ) + + 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( + + ) + + fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } }) + + await waitFor(() => { + expect(screen.getByText('Essay')).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx new file mode 100644 index 00000000..792bf99c --- /dev/null +++ b/src/components/SearchPanel.tsx @@ -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): Promise { + return isTauri() ? invoke('search_vault', args) : mockInvoke('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('keyword') + const [results, setResults] = useState([]) + const [selectedIndex, setSelectedIndex] = useState(0) + const [loading, setLoading] = useState(false) + const [elapsedMs, setElapsedMs] = useState(null) + const [searchError, setSearchError] = useState(null) + const inputRef = useRef(null) + const listRef = useRef(null) + const debounceRef = useRef | 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 ( +
+
e.stopPropagation()} + > + {/* Search input row */} +
+ + + + + setQuery(e.target.value)} + /> +
+ + +
+
+ + {/* Content area */} +
+ {loading && ( +
+ Searching... +
+ )} + + {!loading && !query.trim() && ( +
+

Search across all note contents

+

+ Tab to toggle keyword/semantic ยท Enter to open ยท Esc to close +

+
+ )} + + {!loading && query.trim() && results.length === 0 && !searchError && ( +
+

No results found

+

+ Try different keywords or switch to semantic search +

+
+ )} + + {searchError && ( +
+

Search error

+

{searchError}

+
+ )} + + {!loading && results.length > 0 && ( + <> +
+ + {results.length} result{results.length !== 1 ? 's' : ''}{elapsedMs !== null ? ` ยท ${elapsedMs}ms` : ''} + +
+
+ {results.map((result, i) => { + const noteType = entryTypeMap.get(result.path) ?? result.noteType + return ( +
handleSelect(result)} + onMouseEnter={() => setSelectedIndex(i)} + > +
+ {result.title} + {noteType && ( + + {noteType} + + )} +
+ {result.snippet && ( +

+ {result.snippet} +

+ )} +
+ ) + })} +
+ + )} +
+
+
+ ) +} diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index fe1dae18..9bd42438 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -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, diff --git a/src/hooks/useAppKeyboard.test.ts b/src/hooks/useAppKeyboard.test.ts index 3e3b0538..c8544990 100644 --- a/src/hooks/useAppKeyboard.test.ts +++ b/src/hooks/useAppKeyboard.test.ts @@ -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() + }) }) diff --git a/src/hooks/useAppKeyboard.ts b/src/hooks/useAppKeyboard.ts index d4c3eb95..92894283 100644 --- a/src/hooks/useAppKeyboard.ts +++ b/src/hooks/useAppKeyboard.ts @@ -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) } 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]) } diff --git a/src/hooks/useDialogs.ts b/src/hooks/useDialogs.ts index 931b534a..d1cfae96 100644 --- a/src/hooks/useDialogs.ts +++ b/src/hooks/useDialogs.ts @@ -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, } } diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 5af85f49..db96c47a 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -204,6 +204,24 @@ export const mockHandlers: Record 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 { diff --git a/src/test/setup.ts b/src/test/setup.ts index 966dc7cc..781c8d7c 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -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 }: { diff --git a/src/types.ts b/src/types.ts index 15a34e2e..b763c945 100644 --- a/src/types.ts +++ b/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 }