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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user