* 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>
237 lines
6.9 KiB
Rust
237 lines
6.9 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|