Skip to content

Commit 219d389

Browse files
MarkShawn2020claude
andcommitted
feat(tauri+ui): wire frontend to lovcode-core; floating palette + hotkey
Phase 4. Backend (src-tauri): - New Tauri commands: list_sources, search, rebuild_index, plus toggle_search_overlay. All thin wrappers over lovcode-core. Index is lazily opened once per process via OnceLock. - Global hotkey: ⌘⇧K (Ctrl+Shift+K on win/linux) registered in setup() via tauri-plugin-global-shortcut; toggles the `search` palette window. Frontend (src/): - src/lib/api.ts — typed wrappers for the 4 Tauri commands. - src/components/SearchUI.tsx — single component, two modes: • full (main window): header with corpus count + rebuild button, source filter dropdown, search input, debounced 150ms, results list with date / source / score / title / snippet. • compact (palette): no chrome, top-10 results, Esc closes. - pages/index.tsx + pages/search-overlay.tsx now render <SearchUI />. Verified `cargo build --workspace` ✔ and `pnpm build` ✔. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5d4bfdd commit 219d389

6 files changed

Lines changed: 323 additions & 60 deletions

File tree

src-tauri/src/commands.rs

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,72 @@
11
//! Tauri command wrappers — thin adapters over `lovcode-core`.
2-
//!
3-
//! Real commands (`search`, `list_sources`, `get_conversation`, `index_refresh`,
4-
//! `register_global_hotkey`, ...) land in phase 1.3 once core is wired up.
2+
3+
use lovcode_core::adapter::builtin_adapters;
4+
use lovcode_core::index::{default_index_dir, LovcodeIndex};
5+
use lovcode_core::query;
6+
use lovcode_core::types::{SearchQuery, SearchResult};
7+
use lovcode_core::watcher;
8+
use serde::Serialize;
9+
use std::sync::OnceLock;
10+
11+
fn index() -> &'static LovcodeIndex {
12+
static OWNED: OnceLock<LovcodeIndex> = OnceLock::new();
13+
OWNED.get_or_init(|| {
14+
let dir = default_index_dir();
15+
LovcodeIndex::open_or_create(&dir).expect("open lovcode index")
16+
})
17+
}
518

619
#[tauri::command]
720
pub fn ping() -> &'static str {
821
"pong"
922
}
23+
24+
#[derive(Serialize)]
25+
pub struct SourceSummary {
26+
pub id: String,
27+
pub name: String,
28+
pub count: usize,
29+
}
30+
31+
#[tauri::command]
32+
pub fn list_sources() -> Vec<SourceSummary> {
33+
builtin_adapters()
34+
.iter()
35+
.map(|a| SourceSummary {
36+
id: a.id().into(),
37+
name: a.name().into(),
38+
count: a.discover().map(|v| v.len()).unwrap_or(0),
39+
})
40+
.collect()
41+
}
42+
43+
#[tauri::command]
44+
pub fn search(
45+
q: String,
46+
source: Option<String>,
47+
project: Option<String>,
48+
since: Option<String>,
49+
limit: Option<usize>,
50+
) -> Result<Vec<SearchResult>, String> {
51+
let query = SearchQuery {
52+
q,
53+
source,
54+
project,
55+
since: since.as_deref().and_then(query::parse_since),
56+
until: None,
57+
role: None,
58+
limit,
59+
};
60+
query::search(index(), &query).map_err(|e| e.to_string())
61+
}
62+
63+
#[tauri::command]
64+
pub async fn rebuild_index() -> Result<usize, String> {
65+
tokio::task::spawn_blocking(|| {
66+
let adapters = builtin_adapters();
67+
watcher::index_all(index(), &adapters)
68+
})
69+
.await
70+
.map_err(|e| e.to_string())?
71+
.map_err(|e| e.to_string())
72+
}

src-tauri/src/lib.rs

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,61 @@
1-
//! Lovcode Tauri shell.
2-
//!
3-
//! This is a thin layer over `lovcode-core`. All search logic lives in the
4-
//! core crate. Tauri commands here are 1:1 wrappers that adapt core types
5-
//! to JSON-serializable shapes for the React frontend.
6-
//!
7-
//! Phase 1: skeleton. Concrete commands land alongside the lovcode-core
8-
//! implementation in phase 1.2 / 1.3.
1+
//! Lovcode Tauri shell — thin layer over `lovcode-core`.
92
103
mod commands;
114

5+
use tauri::Manager;
6+
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
7+
128
#[cfg_attr(mobile, tauri::mobile_entry_point)]
139
pub fn run() {
1410
tauri::Builder::default()
1511
.plugin(tauri_plugin_opener::init())
1612
.plugin(tauri_plugin_dialog::init())
1713
.plugin(tauri_plugin_process::init())
1814
.plugin(tauri_plugin_updater::Builder::new().build())
19-
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
15+
.plugin(
16+
tauri_plugin_global_shortcut::Builder::new()
17+
.with_handler(|app, _shortcut, event| {
18+
if event.state() == ShortcutState::Pressed {
19+
toggle_search_window(app);
20+
}
21+
})
22+
.build(),
23+
)
24+
.setup(|app| {
25+
// Global hotkey: ⌘⇧K (Ctrl+Shift+K on win/linux).
26+
let modifiers = if cfg!(target_os = "macos") {
27+
Modifiers::SUPER | Modifiers::SHIFT
28+
} else {
29+
Modifiers::CONTROL | Modifiers::SHIFT
30+
};
31+
let shortcut = Shortcut::new(Some(modifiers), Code::KeyK);
32+
app.global_shortcut().register(shortcut).ok();
33+
Ok(())
34+
})
2035
.invoke_handler(tauri::generate_handler![
2136
commands::ping,
37+
commands::list_sources,
38+
commands::search,
39+
commands::rebuild_index,
40+
toggle_search_overlay,
2241
])
2342
.run(tauri::generate_context!())
2443
.expect("error while running tauri application");
2544
}
45+
46+
fn toggle_search_window(app: &tauri::AppHandle) {
47+
if let Some(win) = app.get_webview_window("search") {
48+
let visible = win.is_visible().unwrap_or(false);
49+
if visible {
50+
let _ = win.hide();
51+
} else {
52+
let _ = win.show();
53+
let _ = win.set_focus();
54+
}
55+
}
56+
}
57+
58+
#[tauri::command]
59+
fn toggle_search_overlay(app: tauri::AppHandle) {
60+
toggle_search_window(&app);
61+
}

src/components/SearchUI.tsx

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { useEffect, useMemo, useRef, useState } from "react";
2+
import { listSources, rebuildIndex, search, type SearchResult, type SourceSummary } from "@/lib/api";
3+
4+
interface Props {
5+
/** Compact = floating palette (no header, no rebuild). */
6+
compact?: boolean;
7+
}
8+
9+
export function SearchUI({ compact = false }: Props) {
10+
const [q, setQ] = useState("");
11+
const [source, setSource] = useState<string>("");
12+
const [sources, setSources] = useState<SourceSummary[]>([]);
13+
const [results, setResults] = useState<SearchResult[]>([]);
14+
const [loading, setLoading] = useState(false);
15+
const [reindexing, setReindexing] = useState(false);
16+
const [reindexMsg, setReindexMsg] = useState<string | null>(null);
17+
const inputRef = useRef<HTMLInputElement>(null);
18+
19+
useEffect(() => {
20+
listSources().then(setSources).catch(() => {});
21+
inputRef.current?.focus();
22+
}, []);
23+
24+
// Debounced search.
25+
useEffect(() => {
26+
if (!q.trim()) {
27+
setResults([]);
28+
return;
29+
}
30+
const handle = setTimeout(async () => {
31+
setLoading(true);
32+
try {
33+
const r = await search({
34+
q,
35+
source: source || undefined,
36+
limit: compact ? 10 : 30,
37+
});
38+
setResults(r);
39+
} catch (e) {
40+
console.error(e);
41+
} finally {
42+
setLoading(false);
43+
}
44+
}, 150);
45+
return () => clearTimeout(handle);
46+
}, [q, source, compact]);
47+
48+
const totalDocs = useMemo(
49+
() => sources.reduce((s, x) => s + x.count, 0),
50+
[sources],
51+
);
52+
53+
return (
54+
<div className={compact ? "flex h-screen flex-col" : "min-h-screen"}>
55+
{!compact && (
56+
<header className="border-b border-border bg-card px-6 py-4">
57+
<div className="mx-auto flex max-w-5xl items-baseline gap-4">
58+
<h1 className="font-serif text-xl text-foreground">Lovcode</h1>
59+
<span className="text-xs text-muted-foreground">
60+
{totalDocs.toLocaleString()} conversations indexed
61+
</span>
62+
<div className="ml-auto flex items-center gap-2">
63+
{reindexMsg && (
64+
<span className="text-xs text-muted-foreground">{reindexMsg}</span>
65+
)}
66+
<button
67+
type="button"
68+
disabled={reindexing}
69+
onClick={async () => {
70+
setReindexing(true);
71+
setReindexMsg(null);
72+
try {
73+
const n = await rebuildIndex();
74+
setReindexMsg(`Indexed ${n.toLocaleString()}`);
75+
const fresh = await listSources();
76+
setSources(fresh);
77+
} catch (e) {
78+
setReindexMsg(`Error: ${e}`);
79+
} finally {
80+
setReindexing(false);
81+
}
82+
}}
83+
className="rounded-lg border border-border bg-card px-3 py-1.5 text-xs text-foreground hover:bg-muted disabled:opacity-50"
84+
>
85+
{reindexing ? "Indexing…" : "Rebuild index"}
86+
</button>
87+
</div>
88+
</div>
89+
</header>
90+
)}
91+
92+
<div className={compact ? "flex-1 overflow-hidden p-4" : "mx-auto max-w-5xl px-6 py-6"}>
93+
<div className="flex items-center gap-2">
94+
<input
95+
ref={inputRef}
96+
type="text"
97+
value={q}
98+
onChange={(e) => setQ(e.target.value)}
99+
placeholder="Search every conversation…"
100+
className="w-full rounded-xl border border-border bg-card px-4 py-3 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
101+
/>
102+
{!compact && (
103+
<select
104+
value={source}
105+
onChange={(e) => setSource(e.target.value)}
106+
className="rounded-xl border border-border bg-card px-3 py-3 text-sm text-foreground focus:outline-none"
107+
>
108+
<option value="">All sources</option>
109+
{sources.map((s) => (
110+
<option key={s.id} value={s.id}>
111+
{s.name} ({s.count})
112+
</option>
113+
))}
114+
</select>
115+
)}
116+
</div>
117+
118+
<div className={compact ? "mt-3 flex-1 overflow-y-auto" : "mt-6"}>
119+
{loading && results.length === 0 && (
120+
<p className="text-sm text-muted-foreground">Searching…</p>
121+
)}
122+
{!loading && q && results.length === 0 && (
123+
<p className="text-sm text-muted-foreground">No results.</p>
124+
)}
125+
<ul className="space-y-3">
126+
{results.map((r) => (
127+
<ResultCard key={r.conversation_id + r.source} r={r} compact={compact} />
128+
))}
129+
</ul>
130+
</div>
131+
</div>
132+
</div>
133+
);
134+
}
135+
136+
function ResultCard({ r, compact }: { r: SearchResult; compact: boolean }) {
137+
const date = r.timestamp ? new Date(r.timestamp).toISOString().slice(0, 10) : "—";
138+
return (
139+
<li className={`rounded-xl border border-border bg-card ${compact ? "p-3" : "p-4"}`}>
140+
<div className="flex items-baseline gap-3 text-xs text-muted-foreground">
141+
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">{r.source}</span>
142+
<span>{date}</span>
143+
{r.project && <span className="truncate">{r.project}</span>}
144+
<span className="ml-auto font-mono">{r.score.toFixed(1)}</span>
145+
</div>
146+
{r.title && (
147+
<div className={`mt-1 font-medium text-foreground ${compact ? "line-clamp-1" : "line-clamp-2"}`}>
148+
{r.title}
149+
</div>
150+
)}
151+
<div className={`mt-1 text-sm text-muted-foreground ${compact ? "line-clamp-2" : "line-clamp-3"}`}>
152+
{r.snippet}
153+
</div>
154+
</li>
155+
);
156+
}

src/lib/api.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { invoke } from "@tauri-apps/api/core";
2+
3+
export type Role = "user" | "assistant" | "system" | "tool" | "other";
4+
5+
export interface SearchResult {
6+
conversation_id: string;
7+
source: string;
8+
project: string | null;
9+
title: string | null;
10+
snippet: string;
11+
score: number;
12+
timestamp: string | null;
13+
}
14+
15+
export interface SourceSummary {
16+
id: string;
17+
name: string;
18+
count: number;
19+
}
20+
21+
export function listSources(): Promise<SourceSummary[]> {
22+
return invoke<SourceSummary[]>("list_sources");
23+
}
24+
25+
export function search(args: {
26+
q: string;
27+
source?: string;
28+
project?: string;
29+
since?: string;
30+
limit?: number;
31+
}): Promise<SearchResult[]> {
32+
return invoke<SearchResult[]>("search", args);
33+
}
34+
35+
export function rebuildIndex(): Promise<number> {
36+
return invoke<number>("rebuild_index");
37+
}

src/pages/index.tsx

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,5 @@
1-
import { useState } from "react";
2-
import { invoke } from "@tauri-apps/api/core";
1+
import { SearchUI } from "@/components/SearchUI";
32

43
export default function SearchPage() {
5-
const [q, setQ] = useState("");
6-
const [pong, setPong] = useState<string | null>(null);
7-
8-
return (
9-
<main className="mx-auto max-w-3xl px-6 pt-20">
10-
<h1 className="font-serif text-3xl text-foreground">Lovcode</h1>
11-
<p className="mt-2 text-sm text-muted-foreground">
12-
Search every conversation you've ever had with an AI.
13-
</p>
14-
15-
<input
16-
type="text"
17-
value={q}
18-
onChange={(e) => setQ(e.target.value)}
19-
placeholder="Search your conversations…"
20-
className="mt-8 w-full rounded-xl border border-border bg-card px-4 py-3 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
21-
autoFocus
22-
/>
23-
24-
<div className="mt-12 rounded-xl border border-dashed border-border bg-card/40 p-6 text-sm text-muted-foreground">
25-
<p className="font-medium text-foreground">v0.40 rewrite in progress.</p>
26-
<p className="mt-2">
27-
The search backend is being rebuilt on top of <code className="rounded bg-muted px-1.5 py-0.5 text-xs">lovcode-core</code>.
28-
Until phase 1.3 lands, this page is a placeholder.
29-
</p>
30-
<p className="mt-4">
31-
Legacy v0.39 codebase lives on the{" "}
32-
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">legacy/v0.39-workbench</code> branch.
33-
</p>
34-
<button
35-
type="button"
36-
onClick={async () => setPong(await invoke<string>("ping"))}
37-
className="mt-6 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
38-
>
39-
Ping backend
40-
</button>
41-
{pong && <span className="ml-3 text-foreground">{pong}</span>}
42-
</div>
43-
</main>
44-
);
4+
return <SearchUI />;
455
}

0 commit comments

Comments
 (0)