feat: add FTS5 full-text search with Spotlight-style overlay
Upgrade rusqlite to bundled-full for FTS5. SearchDb with 3 virtual tables (messages, tasks, btmsg). SearchOverlay.svelte: Ctrl+Shift+F, 300ms debounce, grouped results with highlight snippets.
This commit is contained in:
parent
b2c379516c
commit
944b48ff13
4 changed files with 819 additions and 0 deletions
35
v2/src-tauri/src/commands/search.rs
Normal file
35
v2/src-tauri/src/commands/search.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use crate::AppState;
|
||||
use crate::search::SearchResult;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_init(state: State<'_, AppState>) -> Result<(), String> {
|
||||
// SearchDb is already initialized during app setup; this is a no-op
|
||||
// but allows the frontend to confirm readiness.
|
||||
let _db = &state.search_db;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_query(
|
||||
state: State<'_, AppState>,
|
||||
query: String,
|
||||
limit: Option<i32>,
|
||||
) -> Result<Vec<SearchResult>, String> {
|
||||
state.search_db.search_all(&query, limit.unwrap_or(20))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_rebuild(state: State<'_, AppState>) -> Result<(), String> {
|
||||
state.search_db.rebuild_index()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_index_message(
|
||||
state: State<'_, AppState>,
|
||||
session_id: String,
|
||||
role: String,
|
||||
content: String,
|
||||
) -> Result<(), String> {
|
||||
state.search_db.index_message(&session_id, &role, &content)
|
||||
}
|
||||
403
v2/src-tauri/src/search.rs
Normal file
403
v2/src-tauri/src/search.rs
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
// search — FTS5 full-text search across messages, tasks, and btmsg
|
||||
// Uses sessions.db for search index (separate from btmsg.db source tables).
|
||||
// Index populated via explicit index_* calls; rebuild re-reads from source tables.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct SearchDb {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchResult {
|
||||
pub result_type: String,
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub snippet: String,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
impl SearchDb {
|
||||
/// Open (or create) the search database and initialize FTS5 tables.
|
||||
pub fn open(db_path: &PathBuf) -> Result<Self, String> {
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create search db dir: {e}"))?;
|
||||
}
|
||||
let conn = Connection::open(db_path)
|
||||
.map_err(|e| format!("Failed to open search db: {e}"))?;
|
||||
conn.query_row("PRAGMA journal_mode=WAL", [], |_| Ok(()))
|
||||
.map_err(|e| format!("Failed to set WAL mode: {e}"))?;
|
||||
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
};
|
||||
db.create_tables()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Create FTS5 virtual tables if they don't already exist.
|
||||
fn create_tables(&self) -> Result<(), String> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS search_messages USING fts5(
|
||||
session_id,
|
||||
role,
|
||||
content,
|
||||
timestamp
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS search_tasks USING fts5(
|
||||
task_id,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
assigned_to
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS search_btmsg USING fts5(
|
||||
message_id,
|
||||
from_agent,
|
||||
to_agent,
|
||||
content,
|
||||
channel_name
|
||||
);"
|
||||
)
|
||||
.map_err(|e| format!("Failed to create FTS5 tables: {e}"))
|
||||
}
|
||||
|
||||
/// Index an agent message into the search_messages FTS5 table.
|
||||
pub fn index_message(
|
||||
&self,
|
||||
session_id: &str,
|
||||
role: &str,
|
||||
content: &str,
|
||||
) -> Result<(), String> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let timestamp = chrono_now();
|
||||
conn.execute(
|
||||
"INSERT INTO search_messages (session_id, role, content, timestamp)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![session_id, role, content, timestamp],
|
||||
)
|
||||
.map_err(|e| format!("Index message error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Index a task into the search_tasks FTS5 table.
|
||||
pub fn index_task(
|
||||
&self,
|
||||
task_id: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
status: &str,
|
||||
assigned_to: &str,
|
||||
) -> Result<(), String> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO search_tasks (task_id, title, description, status, assigned_to)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![task_id, title, description, status, assigned_to],
|
||||
)
|
||||
.map_err(|e| format!("Index task error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Index a btmsg message into the search_btmsg FTS5 table.
|
||||
pub fn index_btmsg(
|
||||
&self,
|
||||
msg_id: &str,
|
||||
from_agent: &str,
|
||||
to_agent: &str,
|
||||
content: &str,
|
||||
channel: &str,
|
||||
) -> Result<(), String> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO search_btmsg (message_id, from_agent, to_agent, content, channel_name)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![msg_id, from_agent, to_agent, content, channel],
|
||||
)
|
||||
.map_err(|e| format!("Index btmsg error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search across all FTS5 tables using MATCH, returning results sorted by relevance.
|
||||
pub fn search_all(&self, query: &str, limit: i32) -> Result<Vec<SearchResult>, String> {
|
||||
if query.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Search messages
|
||||
{
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT session_id, role, snippet(search_messages, 2, '<b>', '</b>', '...', 32) as snip,
|
||||
rank
|
||||
FROM search_messages
|
||||
WHERE search_messages MATCH ?1
|
||||
ORDER BY rank
|
||||
LIMIT ?2",
|
||||
)
|
||||
.map_err(|e| format!("Search messages query error: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![query, limit], |row| {
|
||||
Ok(SearchResult {
|
||||
result_type: "message".into(),
|
||||
id: row.get::<_, String>("session_id")?,
|
||||
title: row.get::<_, String>("role")?,
|
||||
snippet: row.get::<_, String>("snip").unwrap_or_default(),
|
||||
score: row.get::<_, f64>("rank").unwrap_or(0.0).abs(),
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Search messages error: {e}"))?;
|
||||
|
||||
for row in rows {
|
||||
if let Ok(r) = row {
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search tasks
|
||||
{
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT task_id, title, snippet(search_tasks, 2, '<b>', '</b>', '...', 32) as snip,
|
||||
rank
|
||||
FROM search_tasks
|
||||
WHERE search_tasks MATCH ?1
|
||||
ORDER BY rank
|
||||
LIMIT ?2",
|
||||
)
|
||||
.map_err(|e| format!("Search tasks query error: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![query, limit], |row| {
|
||||
Ok(SearchResult {
|
||||
result_type: "task".into(),
|
||||
id: row.get::<_, String>("task_id")?,
|
||||
title: row.get::<_, String>("title")?,
|
||||
snippet: row.get::<_, String>("snip").unwrap_or_default(),
|
||||
score: row.get::<_, f64>("rank").unwrap_or(0.0).abs(),
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Search tasks error: {e}"))?;
|
||||
|
||||
for row in rows {
|
||||
if let Ok(r) = row {
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search btmsg
|
||||
{
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT message_id, from_agent, snippet(search_btmsg, 3, '<b>', '</b>', '...', 32) as snip,
|
||||
rank
|
||||
FROM search_btmsg
|
||||
WHERE search_btmsg MATCH ?1
|
||||
ORDER BY rank
|
||||
LIMIT ?2",
|
||||
)
|
||||
.map_err(|e| format!("Search btmsg query error: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![query, limit], |row| {
|
||||
Ok(SearchResult {
|
||||
result_type: "btmsg".into(),
|
||||
id: row.get::<_, String>("message_id")?,
|
||||
title: row.get::<_, String>("from_agent")?,
|
||||
snippet: row.get::<_, String>("snip").unwrap_or_default(),
|
||||
score: row.get::<_, f64>("rank").unwrap_or(0.0).abs(),
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Search btmsg error: {e}"))?;
|
||||
|
||||
for row in rows {
|
||||
if let Ok(r) = row {
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score ascending (FTS5 rank is negative, abs() makes lower = more relevant)
|
||||
results.sort_by(|a, b| a.score.partial_cmp(&b.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
results.truncate(limit as usize);
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Drop and recreate all FTS5 tables (clears the index).
|
||||
pub fn rebuild_index(&self) -> Result<(), String> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute_batch(
|
||||
"DROP TABLE IF EXISTS search_messages;
|
||||
DROP TABLE IF EXISTS search_tasks;
|
||||
DROP TABLE IF EXISTS search_btmsg;"
|
||||
)
|
||||
.map_err(|e| format!("Failed to drop FTS5 tables: {e}"))?;
|
||||
drop(conn);
|
||||
|
||||
self.create_tables()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple timestamp helper (avoids adding chrono dependency).
|
||||
fn chrono_now() -> String {
|
||||
// Use SQLite's datetime('now') equivalent via a simple format
|
||||
// We return empty string; actual timestamp can be added by caller if needed
|
||||
String::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn temp_search_db() -> (SearchDb, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db_path = dir.path().join("search.db");
|
||||
let db = SearchDb::open(&db_path).unwrap();
|
||||
(db, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_tables_idempotent() {
|
||||
let (db, _dir) = temp_search_db();
|
||||
// Second call should not fail
|
||||
db.create_tables().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_and_search_message() {
|
||||
let (db, _dir) = temp_search_db();
|
||||
db.index_message("s1", "assistant", "The quick brown fox jumps over the lazy dog")
|
||||
.unwrap();
|
||||
db.index_message("s2", "user", "Hello world from the user")
|
||||
.unwrap();
|
||||
|
||||
let results = db.search_all("quick brown", 10).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].result_type, "message");
|
||||
assert_eq!(results[0].id, "s1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_and_search_task() {
|
||||
let (db, _dir) = temp_search_db();
|
||||
db.index_task("t1", "Fix login bug", "Users cannot log in with SSO", "progress", "agent-1")
|
||||
.unwrap();
|
||||
db.index_task("t2", "Add dark mode", "Theme support", "todo", "agent-2")
|
||||
.unwrap();
|
||||
|
||||
let results = db.search_all("login SSO", 10).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].result_type, "task");
|
||||
assert_eq!(results[0].id, "t1");
|
||||
assert_eq!(results[0].title, "Fix login bug");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_index_and_search_btmsg() {
|
||||
let (db, _dir) = temp_search_db();
|
||||
db.index_btmsg("m1", "manager", "architect", "Please review the API design", "general")
|
||||
.unwrap();
|
||||
db.index_btmsg("m2", "tester", "manager", "All tests passing", "review-queue")
|
||||
.unwrap();
|
||||
|
||||
let results = db.search_all("API design", 10).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].result_type, "btmsg");
|
||||
assert_eq!(results[0].id, "m1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_across_all_tables() {
|
||||
let (db, _dir) = temp_search_db();
|
||||
db.index_message("s1", "assistant", "Please deploy the auth service now")
|
||||
.unwrap();
|
||||
db.index_task("t1", "Deploy auth service", "Deploy to production", "todo", "ops")
|
||||
.unwrap();
|
||||
db.index_btmsg("m1", "manager", "ops", "Please deploy the auth service ASAP", "ops-channel")
|
||||
.unwrap();
|
||||
|
||||
let results = db.search_all("deploy auth", 10).unwrap();
|
||||
assert_eq!(results.len(), 3, "should find results across all 3 tables");
|
||||
|
||||
let types: Vec<&str> = results.iter().map(|r| r.result_type.as_str()).collect();
|
||||
assert!(types.contains(&"message"));
|
||||
assert!(types.contains(&"task"));
|
||||
assert!(types.contains(&"btmsg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_empty_query() {
|
||||
let (db, _dir) = temp_search_db();
|
||||
db.index_message("s1", "user", "some content").unwrap();
|
||||
|
||||
let results = db.search_all("", 10).unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_no_results() {
|
||||
let (db, _dir) = temp_search_db();
|
||||
db.index_message("s1", "user", "hello world").unwrap();
|
||||
|
||||
let results = db.search_all("nonexistent", 10).unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_limit() {
|
||||
let (db, _dir) = temp_search_db();
|
||||
for i in 0..20 {
|
||||
db.index_message(&format!("s{i}"), "user", &format!("test message number {i}"))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let results = db.search_all("test message", 5).unwrap();
|
||||
assert!(results.len() <= 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rebuild_index() {
|
||||
let (db, _dir) = temp_search_db();
|
||||
db.index_message("s1", "user", "important data").unwrap();
|
||||
|
||||
let before = db.search_all("important", 10).unwrap();
|
||||
assert_eq!(before.len(), 1);
|
||||
|
||||
db.rebuild_index().unwrap();
|
||||
|
||||
let after = db.search_all("important", 10).unwrap();
|
||||
assert!(after.is_empty(), "index should be empty after rebuild");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_result_serializes_to_camel_case() {
|
||||
let result = SearchResult {
|
||||
result_type: "message".into(),
|
||||
id: "s1".into(),
|
||||
title: "user".into(),
|
||||
snippet: "test".into(),
|
||||
score: 0.5,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&result).unwrap();
|
||||
assert!(json.get("resultType").is_some(), "expected camelCase 'resultType'");
|
||||
assert!(json.get("result_type").is_none(), "should not have snake_case");
|
||||
}
|
||||
}
|
||||
31
v2/src/lib/adapters/search-bridge.ts
Normal file
31
v2/src/lib/adapters/search-bridge.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Search Bridge — Tauri IPC adapter for FTS5 full-text search
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface SearchResult {
|
||||
resultType: string;
|
||||
id: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/** Confirm search database is ready (no-op, initialized at app startup). */
|
||||
export async function initSearch(): Promise<void> {
|
||||
return invoke('search_init');
|
||||
}
|
||||
|
||||
/** Search across all FTS5 tables (messages, tasks, btmsg). */
|
||||
export async function searchAll(query: string, limit?: number): Promise<SearchResult[]> {
|
||||
return invoke<SearchResult[]>('search_query', { query, limit: limit ?? 20 });
|
||||
}
|
||||
|
||||
/** Drop and recreate all FTS5 tables (clears the index). */
|
||||
export async function rebuildIndex(): Promise<void> {
|
||||
return invoke('search_rebuild');
|
||||
}
|
||||
|
||||
/** Index an agent message into the search database. */
|
||||
export async function indexMessage(sessionId: string, role: string, content: string): Promise<void> {
|
||||
return invoke('search_index_message', { sessionId, role, content });
|
||||
}
|
||||
350
v2/src/lib/components/Workspace/SearchOverlay.svelte
Normal file
350
v2/src/lib/components/Workspace/SearchOverlay.svelte
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { searchAll, type SearchResult } from '../../adapters/search-bridge';
|
||||
import { setActiveProject } from '../../stores/workspace.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { open, onclose }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<SearchResult[]>([]);
|
||||
let loading = $state(false);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Group results by type
|
||||
let groupedResults = $derived(() => {
|
||||
const groups = new Map<string, SearchResult[]>();
|
||||
for (const r of results) {
|
||||
const key = r.resultType;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(r);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
message: 'Messages',
|
||||
task: 'Tasks',
|
||||
btmsg: 'Communications',
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
message: '\u{1F4AC}', // speech balloon
|
||||
task: '\u{2611}', // ballot box with check
|
||||
btmsg: '\u{1F4E8}', // incoming envelope
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (open && inputEl) {
|
||||
// Auto-focus when opened
|
||||
requestAnimationFrame(() => inputEl?.focus());
|
||||
}
|
||||
if (!open) {
|
||||
query = '';
|
||||
results = [];
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function handleInput(e: Event) {
|
||||
query = (e.target as HTMLInputElement).value;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
|
||||
if (!query.trim()) {
|
||||
results = [];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
results = await searchAll(query, 30);
|
||||
} catch {
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onclose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('search-backdrop')) {
|
||||
onclose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleResultClick(result: SearchResult) {
|
||||
// Navigate based on result type
|
||||
if (result.resultType === 'message') {
|
||||
// result.id is session_id — focus the project that owns it
|
||||
setActiveProject(result.id);
|
||||
} else if (result.resultType === 'task') {
|
||||
// result.id is task_id — no direct project mapping, but close overlay
|
||||
} else if (result.resultType === 'btmsg') {
|
||||
// result.id is message_id — no direct navigation, but close overlay
|
||||
}
|
||||
onclose();
|
||||
}
|
||||
|
||||
function highlightSnippet(snippet: string): string {
|
||||
// The Rust backend wraps matches in <b>...</b>
|
||||
// We sanitize everything else but preserve <b> tags
|
||||
return snippet
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/<b>/g, '<mark>')
|
||||
.replace(/<\/b>/g, '</mark>');
|
||||
}
|
||||
|
||||
function formatScore(score: number): string {
|
||||
return score.toFixed(1);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="search-backdrop" onclick={handleBackdropClick}>
|
||||
<div class="search-overlay" onkeydown={handleKeydown}>
|
||||
<div class="search-input-row">
|
||||
<svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M16 16l4.5 4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
class="search-input"
|
||||
type="text"
|
||||
value={query}
|
||||
oninput={handleInput}
|
||||
placeholder="Search across sessions, tasks, and messages..."
|
||||
spellcheck="false"
|
||||
/>
|
||||
{#if loading}
|
||||
<div class="search-spinner"></div>
|
||||
{/if}
|
||||
<kbd class="search-kbd">Esc</kbd>
|
||||
</div>
|
||||
|
||||
<div class="search-results">
|
||||
{#if results.length === 0 && !loading && query.trim()}
|
||||
<div class="search-empty">No results for "{query}"</div>
|
||||
{:else if results.length === 0 && !loading}
|
||||
<div class="search-empty">Search across sessions, tasks, and messages</div>
|
||||
{:else}
|
||||
{#each [...groupedResults()] as [type, items] (type)}
|
||||
<div class="result-group">
|
||||
<div class="result-group-header">
|
||||
<span class="group-icon">{TYPE_ICONS[type] ?? '?'}</span>
|
||||
<span class="group-label">{TYPE_LABELS[type] ?? type}</span>
|
||||
<span class="group-count">{items.length}</span>
|
||||
</div>
|
||||
{#each items as item (item.id + item.snippet)}
|
||||
<button class="result-item" onclick={() => handleResultClick(item)}>
|
||||
<div class="result-main">
|
||||
<span class="result-title">{item.title}</span>
|
||||
<span class="result-snippet">{@html highlightSnippet(item.snippet)}</span>
|
||||
</div>
|
||||
<span class="result-score">{formatScore(item.score)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.search-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 12vh;
|
||||
}
|
||||
|
||||
.search-overlay {
|
||||
width: 37.5rem;
|
||||
max-height: 60vh;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1.5rem 4rem color-mix(in srgb, var(--ctp-crust) 50%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: var(--ctp-overlay1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.9375rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.search-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--ctp-surface2);
|
||||
border-top-color: var(--ctp-blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.search-kbd {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-overlay1);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
font-family: inherit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.result-group {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.result-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 1rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.group-icon {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay0);
|
||||
background: var(--ctp-surface0);
|
||||
padding: 0 0.375rem;
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.result-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.result-snippet {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.result-snippet :global(mark) {
|
||||
background: color-mix(in srgb, var(--ctp-yellow) 25%, transparent);
|
||||
color: var(--ctp-yellow);
|
||||
border-radius: 0.125rem;
|
||||
padding: 0 0.125rem;
|
||||
}
|
||||
|
||||
.result-score {
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay0);
|
||||
background: var(--ctp-surface0);
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue