From afa51bf147b9b2b10914892998581c4b54b8b53c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 3 Mar 2026 02:27:50 +0100 Subject: [PATCH] feat: auto-index vault on open + qmd auto-install + status bar progress Search now works out of the box on any vault without manual qmd installation. On vault open, the app checks the index status and auto-triggers background indexing (qmd update + embed) when needed. If qmd is not installed, it auto-installs via bun. Changes: - New indexing.rs module: find_qmd_binary (cached), check_index_status, ensure_collection, run_full_index with progress callbacks, run_incremental_update, auto_install_qmd - search.rs: delegates to indexing::find_qmd_binary (removes duplication) - lib.rs: new Tauri commands (get_index_status, start_indexing, trigger_incremental_index) - useIndexing hook: auto-triggers on vault open, listens for indexing-progress events, auto-dismisses complete state after 5s - StatusBar: IndexingBadge shows phase + progress counts with spinner - App.tsx: wires up useIndexing, triggers incremental index on file save - design/search-bundle-qmd.pen: documents indexing progress UI states Co-Authored-By: Claude Opus 4.6 --- design/search-bundle-qmd.pen | 1 + src-tauri/src/indexing.rs | 486 ++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 57 ++++ src-tauri/src/search.rs | 32 +- src/App.tsx | 13 +- src/components/StatusBar.test.tsx | 127 +++++--- src/components/StatusBar.tsx | 40 ++- src/hooks/useIndexing.test.ts | 159 ++++++++++ src/hooks/useIndexing.ts | 116 +++++++ src/mock-tauri/mock-handlers.ts | 3 + 10 files changed, 953 insertions(+), 81 deletions(-) create mode 100644 design/search-bundle-qmd.pen create mode 100644 src-tauri/src/indexing.rs create mode 100644 src/hooks/useIndexing.test.ts create mode 100644 src/hooks/useIndexing.ts diff --git a/design/search-bundle-qmd.pen b/design/search-bundle-qmd.pen new file mode 100644 index 00000000..759adb9f --- /dev/null +++ b/design/search-bundle-qmd.pen @@ -0,0 +1 @@ +{"children":[{"id":"status-bar-indexing","type":"frame","name":"StatusBar - Indexing Progress States","layout":"vertical","x":0,"y":0,"width":1400,"height":400,"gap":24,"padding":24,"fill":"#F7F6F3","children":[{"id":"title","type":"text","content":"StatusBar Indexing Progress - search-bundle-qmd","fontSize":20,"fontWeight":"600","fill":"#37352F"},{"id":"desc","type":"text","content":"The indexing badge appears in the StatusBar left section, after the pending changes badge. It shows the current indexing phase with a spinner icon (Loader2) during active phases and a Search icon when complete.","fontSize":14,"fill":"#9B9A97","width":900},{"id":"states","type":"frame","name":"States","layout":"vertical","gap":16,"width":1350,"children":[{"id":"state-idle","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-idle","type":"text","content":"idle:","fontSize":12,"fontWeight":"600","fill":"#9B9A97","width":100},{"id":"val-idle","type":"text","content":"(hidden - no badge shown)","fontSize":12,"fill":"#9B9A97"}]},{"id":"state-installing","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-install","type":"text","content":"installing:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-install","type":"text","content":"| [spinner] Installing search\u2026","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-scanning","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-scan","type":"text","content":"scanning:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-scan","type":"text","content":"| [spinner] Indexing\u2026 342/1,057","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-embedding","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-embed","type":"text","content":"embedding:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-embed","type":"text","content":"| [spinner] Embedding\u2026 50/200","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-complete","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-done","type":"text","content":"complete:","fontSize":12,"fontWeight":"600","fill":"#22c55e","width":100},{"id":"val-done","type":"text","content":"| [search icon] Index ready (auto-dismisses after 5s)","fontSize":12,"fill":"#22c55e"}]},{"id":"state-error","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-err","type":"text","content":"error:","fontSize":12,"fontWeight":"600","fill":"#f97316","width":100},{"id":"val-err","type":"text","content":"| [search icon] Index error (orange accent)","fontSize":12,"fill":"#f97316"}]}]},{"id":"flow","type":"frame","name":"Flow","layout":"vertical","gap":8,"width":1350,"children":[{"id":"flow-title","type":"text","content":"Auto-indexing Flow","fontSize":16,"fontWeight":"600","fill":"#37352F"},{"id":"flow-desc","type":"text","content":"1. Vault opens \u2192 useIndexing checks index status via get_index_status\n2. If qmd missing or collection missing or pending embeds \u2192 triggers start_indexing\n3. Rust emits indexing-progress events (installing \u2192 scanning \u2192 embedding \u2192 complete)\n4. StatusBar shows IndexingBadge with spinner + phase label + progress counts\n5. On file save \u2192 trigger_incremental_index runs qmd update in background\n6. Complete phase auto-dismisses after 5 seconds","fontSize":13,"fill":"#37352F","width":900}]}]}],"variables":{}} \ No newline at end of file diff --git a/src-tauri/src/indexing.rs b/src-tauri/src/indexing.rs new file mode 100644 index 00000000..dbf22fe3 --- /dev/null +++ b/src-tauri/src/indexing.rs @@ -0,0 +1,486 @@ +use serde::Serialize; +use std::path::Path; +use std::process::Command; +use std::sync::Mutex; + +static QMD_PATH_CACHE: Mutex> = Mutex::new(None); + +/// Locate the qmd binary, checking known locations and PATH. +/// Caches the result for subsequent calls. +pub fn find_qmd_binary() -> Option { + if let Ok(guard) = QMD_PATH_CACHE.lock() { + if let Some(ref cached) = *guard { + return Some(cached.clone()); + } + } + + let result = find_qmd_binary_uncached(); + + if let Some(ref path) = result { + if let Ok(mut guard) = QMD_PATH_CACHE.lock() { + *guard = Some(path.clone()); + } + } + + result +} + +fn find_qmd_binary_uncached() -> 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 + } + }) +} + +/// Clear the cached qmd path (e.g. after auto-install). +pub fn clear_qmd_cache() { + if let Ok(mut guard) = QMD_PATH_CACHE.lock() { + *guard = None; + } +} + +#[derive(Debug, Serialize, Clone)] +pub struct IndexStatus { + pub available: bool, + pub qmd_installed: bool, + pub collection_exists: bool, + pub indexed_count: usize, + pub embedded_count: usize, + pub pending_embed: usize, +} + +/// Check whether the vault has a qmd index and its status. +pub fn check_index_status(vault_path: &str) -> IndexStatus { + let qmd_bin = match find_qmd_binary() { + Some(b) => b, + None => { + return IndexStatus { + available: false, + qmd_installed: false, + collection_exists: false, + indexed_count: 0, + embedded_count: 0, + pending_embed: 0, + } + } + }; + + let vault_name = vault_dir_name(vault_path); + let output = Command::new(&qmd_bin).args(["status"]).output(); + + match output { + Ok(o) if o.status.success() => { + let stdout = String::from_utf8_lossy(&o.stdout); + parse_status_for_vault(&stdout, &vault_name) + } + _ => IndexStatus { + available: false, + qmd_installed: true, + collection_exists: false, + indexed_count: 0, + embedded_count: 0, + pending_embed: 0, + }, + } +} + +fn vault_dir_name(vault_path: &str) -> String { + Path::new(vault_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("laputa") + .to_lowercase() +} + +fn parse_status_for_vault(status_output: &str, vault_name: &str) -> IndexStatus { + let mut collection_exists = false; + let mut indexed_count = 0; + let mut embedded_count = 0; + let mut pending_embed = 0; + + // Look for collection section matching vault name + let mut in_vault_section = false; + for line in status_output.lines() { + let trimmed = line.trim(); + // Collection headers look like: " laputa (qmd://laputa/)" + if trimmed.contains(&format!("qmd://{vault_name}/")) { + collection_exists = true; + in_vault_section = true; + continue; + } + // New collection section starts + if trimmed.contains("qmd://") && !trimmed.contains(vault_name) { + in_vault_section = false; + continue; + } + if in_vault_section { + if let Some(count_str) = extract_count_from_line(trimmed, "Files:") { + indexed_count = count_str; + } + } + } + + // Global counts from the Documents section + for line in status_output.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("Total:") { + if let Some(n) = extract_first_number(trimmed) { + if embedded_count == 0 && indexed_count == 0 { + indexed_count = n; + } + } + } else if trimmed.starts_with("Vectors:") { + if let Some(n) = extract_first_number(trimmed) { + embedded_count = n; + } + } else if trimmed.starts_with("Pending:") { + if let Some(n) = extract_first_number(trimmed) { + pending_embed = n; + } + } + } + + IndexStatus { + available: true, + qmd_installed: true, + collection_exists, + indexed_count, + embedded_count, + pending_embed, + } +} + +fn extract_count_from_line(line: &str, prefix: &str) -> Option { + if !line.starts_with(prefix) { + return None; + } + extract_first_number(line) +} + +fn extract_first_number(s: &str) -> Option { + s.split_whitespace() + .find_map(|word| word.parse::().ok()) +} + +/// Ensure a qmd collection exists for this vault. Creates one if missing. +pub fn ensure_collection(vault_path: &str) -> Result<(), String> { + let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?; + let vault_name = vault_dir_name(vault_path); + + // Check if collection already exists + let output = Command::new(&qmd_bin) + .args(["collection", "list"]) + .output() + .map_err(|e| format!("Failed to list collections: {e}"))?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains(&format!("qmd://{vault_name}/")) { + return Ok(()); + } + } + + // Create collection + Command::new(&qmd_bin) + .args([ + "collection", + "add", + vault_path, + "--name", + &vault_name, + "--mask", + "**/*.md", + ]) + .output() + .map_err(|e| format!("Failed to create collection: {e}"))?; + + Ok(()) +} + +#[derive(Debug, Serialize, Clone)] +pub struct IndexingProgress { + pub phase: String, + pub current: usize, + pub total: usize, + pub done: bool, + pub error: Option, +} + +/// Run full indexing: update + embed. Returns progress updates via callback. +pub fn run_full_index(vault_path: &str, on_progress: F) -> Result<(), String> +where + F: Fn(IndexingProgress), +{ + let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?; + + ensure_collection(vault_path)?; + + // Phase 1: update (scan files) + on_progress(IndexingProgress { + phase: "scanning".to_string(), + current: 0, + total: 0, + done: false, + error: None, + }); + + let update_output = Command::new(&qmd_bin) + .args(["update"]) + .output() + .map_err(|e| format!("qmd update failed: {e}"))?; + + if !update_output.status.success() { + let stderr = String::from_utf8_lossy(&update_output.stderr); + let err = format!("qmd update failed: {stderr}"); + on_progress(IndexingProgress { + phase: "error".to_string(), + current: 0, + total: 0, + done: true, + error: Some(err.clone()), + }); + return Err(err); + } + + // Parse update output for counts + let update_stdout = String::from_utf8_lossy(&update_output.stdout); + let total = parse_indexed_count(&update_stdout); + + on_progress(IndexingProgress { + phase: "scanning".to_string(), + current: total, + total, + done: false, + error: None, + }); + + // Phase 2: embed (generate vectors) + on_progress(IndexingProgress { + phase: "embedding".to_string(), + current: 0, + total, + done: false, + error: None, + }); + + let embed_output = Command::new(&qmd_bin) + .args(["embed"]) + .output() + .map_err(|e| format!("qmd embed failed: {e}"))?; + + if !embed_output.status.success() { + let stderr = String::from_utf8_lossy(&embed_output.stderr); + // Embedding failure is non-fatal — keyword search still works + log::warn!("qmd embed failed (keyword search still works): {stderr}"); + on_progress(IndexingProgress { + phase: "complete".to_string(), + current: total, + total, + done: true, + error: Some("Embedding failed — keyword search only".to_string()), + }); + return Ok(()); + } + + on_progress(IndexingProgress { + phase: "complete".to_string(), + current: total, + total, + done: true, + error: None, + }); + + Ok(()) +} + +fn parse_indexed_count(update_output: &str) -> usize { + // qmd update output typically contains lines like "Indexed 9078 files" + for line in update_output.lines() { + if let Some(n) = extract_first_number(line) { + return n; + } + } + 0 +} + +/// Run incremental update for a single file change. +pub fn run_incremental_update(vault_path: &str) -> Result<(), String> { + let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?; + + // Verify collection exists + let vault_name = vault_dir_name(vault_path); + let list_output = Command::new(&qmd_bin) + .args(["collection", "list"]) + .output() + .map_err(|e| format!("Failed to list collections: {e}"))?; + + if list_output.status.success() { + let stdout = String::from_utf8_lossy(&list_output.stdout); + if !stdout.contains(&format!("qmd://{vault_name}/")) { + // Collection doesn't exist yet — skip incremental, full index needed + return Ok(()); + } + } + + let output = Command::new(&qmd_bin) + .args(["update"]) + .output() + .map_err(|e| format!("qmd incremental update failed: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("qmd update failed: {stderr}")); + } + + Ok(()) +} + +/// Attempt to auto-install qmd via bun. Returns Ok if successful. +pub fn auto_install_qmd() -> Result { + // Find bun + let bun = find_bun().ok_or("bun not installed — cannot auto-install qmd")?; + + let output = Command::new(&bun) + .args(["install", "-g", "qmd"]) + .output() + .map_err(|e| format!("Failed to install qmd: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("qmd installation failed: {stderr}")); + } + + // Clear cache so find_qmd_binary() re-discovers + clear_qmd_cache(); + + match find_qmd_binary() { + Some(path) => Ok(path), + None => Err("qmd installed but binary not found".to_string()), + } +} + +fn find_bun() -> Option { + let candidates = [ + dirs::home_dir().map(|h| h.join(".bun/bin/bun").to_string_lossy().to_string()), + Some("/opt/homebrew/bin/bun".to_string()), + Some("/usr/local/bin/bun".to_string()), + ]; + for candidate in candidates.into_iter().flatten() { + if Path::new(&candidate).exists() { + return Some(candidate); + } + } + Command::new("which") + .arg("bun") + .output() + .ok() + .and_then(|o| { + if o.status.success() { + Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) + } else { + None + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vault_dir_name_extracts_last_segment() { + assert_eq!(vault_dir_name("/Users/luca/Laputa"), "laputa"); + assert_eq!(vault_dir_name("/home/user/MyVault"), "myvault"); + } + + #[test] + fn vault_dir_name_fallback() { + assert_eq!(vault_dir_name(""), "laputa"); + } + + #[test] + fn extract_first_number_works() { + assert_eq!(extract_first_number("Total: 9078 files indexed"), Some(9078)); + assert_eq!(extract_first_number("Vectors: 14676 embedded"), Some(14676)); + assert_eq!(extract_first_number("no numbers here"), None); + } + + #[test] + fn parse_status_finds_collection() { + let status = r#" +QMD Status + +Index: /Users/luca/.cache/qmd/index.sqlite +Size: 100.9 MB + +Documents + Total: 9115 files indexed + Vectors: 14676 embedded + Pending: 26 need embedding + +Collections + laputa (qmd://laputa/) + Pattern: **/*.md + Files: 9078 (updated 20d ago) +"#; + let result = parse_status_for_vault(status, "laputa"); + assert!(result.collection_exists); + assert_eq!(result.indexed_count, 9078); + assert_eq!(result.embedded_count, 14676); + assert_eq!(result.pending_embed, 26); + } + + #[test] + fn parse_status_missing_collection() { + let status = r#" +QMD Status + +Documents + Total: 100 files indexed + Vectors: 50 embedded + Pending: 0 + +Collections + other (qmd://other/) + Files: 100 +"#; + let result = parse_status_for_vault(status, "laputa"); + assert!(!result.collection_exists); + } + + #[test] + fn extract_count_from_line_works() { + assert_eq!( + extract_count_from_line("Files: 9078 (updated 20d ago)", "Files:"), + Some(9078) + ); + assert_eq!( + extract_count_from_line("Pattern: **/*.md", "Files:"), + None + ); + } + + #[test] + fn parse_indexed_count_from_output() { + assert_eq!(parse_indexed_count("Indexed 342 files in 1.2s"), 342); + assert_eq!(parse_indexed_count("No output"), 0); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 26860cc8..17079950 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub mod claude_cli; pub mod frontmatter; pub mod git; pub mod github; +pub mod indexing; pub mod mcp; pub mod menu; pub mod search; @@ -21,6 +22,7 @@ use claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeS use frontmatter::FrontmatterValue; use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile}; use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo}; +use indexing::{IndexStatus, IndexingProgress}; use search::SearchResponse; use settings::Settings; use theme::{ThemeFile, VaultSettings}; @@ -341,6 +343,58 @@ async fn search_vault( .map_err(|e| format!("Search task failed: {}", e))? } +#[tauri::command] +fn get_index_status(vault_path: String) -> IndexStatus { + let vault_path = expand_tilde(&vault_path); + indexing::check_index_status(&vault_path) +} + +#[tauri::command] +async fn start_indexing(app_handle: tauri::AppHandle, vault_path: String) -> Result<(), String> { + use tauri::Emitter; + let vault_path = expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || { + // Auto-install qmd if not available + if indexing::find_qmd_binary().is_none() { + let _ = app_handle.emit("indexing-progress", IndexingProgress { + phase: "installing".to_string(), + current: 0, + total: 0, + done: false, + error: None, + }); + match indexing::auto_install_qmd() { + Ok(_) => log::info!("qmd auto-installed successfully"), + Err(e) => { + log::warn!("qmd auto-install failed: {e}"); + let _ = app_handle.emit("indexing-progress", IndexingProgress { + phase: "error".to_string(), + current: 0, + total: 0, + done: true, + error: Some(format!("qmd not available: {e}")), + }); + return Err(e); + } + } + } + + indexing::run_full_index(&vault_path, |progress| { + let _ = app_handle.emit("indexing-progress", &progress); + }) + }) + .await + .map_err(|e| format!("Indexing task failed: {e}"))? +} + +#[tauri::command] +async fn trigger_incremental_index(vault_path: String) -> Result<(), String> { + let vault_path = expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path)) + .await + .map_err(|e| format!("Incremental index failed: {e}"))? +} + struct WsBridgeChild(Mutex>); #[tauri::command] @@ -566,6 +620,9 @@ pub fn run() { github_device_flow_poll, github_get_user, search_vault, + get_index_status, + start_indexing, + trigger_incremental_index, create_getting_started_vault, check_vault_exists, get_default_vault_path, diff --git a/src-tauri/src/search.rs b/src-tauri/src/search.rs index 12be354a..e7689620 100644 --- a/src-tauri/src/search.rs +++ b/src-tauri/src/search.rs @@ -1,3 +1,4 @@ +use crate::indexing; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; @@ -30,31 +31,6 @@ struct QmdResult { 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 @@ -108,7 +84,7 @@ fn detect_collection_name(vault_path: &str) -> String { } fn detect_collection_name_uncached(vault_path: &str) -> String { - let qmd_bin = match find_qmd_binary() { + let qmd_bin = match indexing::find_qmd_binary() { Some(b) => b, None => return "laputa".to_string(), }; @@ -149,8 +125,8 @@ pub fn search_vault( ) -> Result { let start = Instant::now(); - let qmd_bin = - find_qmd_binary().ok_or_else(|| "qmd binary not found. Install qmd first.".to_string())?; + let qmd_bin = indexing::find_qmd_binary() + .ok_or_else(|| "qmd binary not found. Install qmd first.".to_string())?; let collection = detect_collection_name(vault_path); diff --git a/src/App.tsx b/src/App.tsx index 05ab33f5..bdce5521 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ import { useGitHistory } from './hooks/useGitHistory' import { useUpdater } from './hooks/useUpdater' import { useNavigationHistory } from './hooks/useNavigationHistory' import { useAutoSync } from './hooks/useAutoSync' +import { useIndexing } from './hooks/useIndexing' import { useZoom } from './hooks/useZoom' import { useBuildNumber } from './hooks/useBuildNumber' import { useOnboarding } from './hooks/useOnboarding' @@ -140,6 +141,8 @@ function App() { onToast: (msg) => setToastMessage(msg), }) + const indexing = useIndexing(resolvedPath) + // Ref bridges handleContentChange (created after notes) into useNoteActions. // Read at callback time, so it's always current when user presses Cmd+N. const contentChangeRef = useRef<(path: string, content: string) => void>(() => {}) @@ -225,9 +228,15 @@ function App() { } }, [handleGoBack, handleGoForward]) + const { triggerIncrementalIndex } = indexing + const onAfterSave = useCallback(() => { + vault.loadModifiedFiles() + triggerIncrementalIndex() + }, [vault, triggerIncrementalIndex]) + const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({ updateContent: vault.updateContent, updateEntry: vault.updateEntry, - setTabs: notes.setTabs, setToastMessage, onAfterSave: vault.loadModifiedFiles, + setTabs: notes.setTabs, setToastMessage, onAfterSave, onNotePersisted: vault.clearUnsaved, }) useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange]) @@ -452,7 +461,7 @@ function App() { - setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onRemoveVault={vaultSwitcher.removeVault} /> + setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} indexingProgress={indexing.progress} /> setToastMessage(null)} /> diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index cfcaf736..d541d24b 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -213,58 +213,89 @@ describe('StatusBar', () => { expect(screen.getByTitle('View pending changes')).toBeInTheDocument() }) - describe('vault removal', () => { - it('shows remove button for each vault when onRemoveVault is provided and multiple vaults exist', () => { - render( - - ) - fireEvent.click(screen.getByTitle('Switch vault')) - expect(screen.getByTestId('vault-menu-remove-Main Vault')).toBeInTheDocument() - expect(screen.getByTestId('vault-menu-remove-Work Vault')).toBeInTheDocument() - }) + it('shows indexing badge when indexing is in progress', () => { + render( + + ) + expect(screen.getByTestId('status-indexing')).toBeInTheDocument() + expect(screen.getByText(/Indexing… 342\/1,057/)).toBeInTheDocument() + }) - it('does not show remove button when only one vault exists', () => { - const singleVault: VaultOption[] = [{ label: 'Only Vault', path: '/only/vault' }] - render( - - ) - fireEvent.click(screen.getByTitle('Switch vault')) - expect(screen.queryByTestId('vault-menu-remove-Only Vault')).not.toBeInTheDocument() - }) + it('shows embedding phase in indexing badge', () => { + render( + + ) + expect(screen.getByText(/Embedding… 50\/200/)).toBeInTheDocument() + }) - it('calls onRemoveVault with vault path when remove button is clicked', () => { - const onRemoveVault = vi.fn() - render( - - ) - fireEvent.click(screen.getByTitle('Switch vault')) - fireEvent.click(screen.getByTestId('vault-menu-remove-Work Vault')) - expect(onRemoveVault).toHaveBeenCalledWith('/Users/luca/Work') - }) + it('shows index ready when indexing is complete', () => { + render( + + ) + expect(screen.getByText('Index ready')).toBeInTheDocument() + }) - it('closes menu after removing a vault', () => { - render( - - ) - fireEvent.click(screen.getByTitle('Switch vault')) - fireEvent.click(screen.getByTestId('vault-menu-remove-Work Vault')) - expect(screen.queryByTestId('vault-menu-remove-Work Vault')).not.toBeInTheDocument() - }) + it('shows error state in indexing badge', () => { + render( + + ) + expect(screen.getByText('Index error')).toBeInTheDocument() + }) - it('remove button has "Remove from list" title for accessibility', () => { - render( - - ) - fireEvent.click(screen.getByTitle('Switch vault')) - expect(screen.getAllByTitle('Remove from list')).toHaveLength(2) - }) + it('hides indexing badge when phase is idle', () => { + render( + + ) + expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument() + }) - it('does not show remove button when onRemoveVault is not provided', () => { - render( - - ) - fireEvent.click(screen.getByTitle('Switch vault')) - expect(screen.queryByTestId('vault-menu-remove-Main Vault')).not.toBeInTheDocument() - }) + it('hides indexing badge when no progress prop provided', () => { + render( + + ) + expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument() + }) + + it('shows installing phase in indexing badge', () => { + render( + + ) + expect(screen.getByText('Installing search…')).toBeInTheDocument() }) }) diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index a498e088..db49a76f 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect } from 'react' -import { Package, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, X } from 'lucide-react' +import { Package, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search } from 'lucide-react' import type { LastCommitInfo, SyncStatus } from '../types' +import type { IndexingProgress } from '../hooks/useIndexing' import { openExternalUrl } from '../utils/url' export interface VaultOption { @@ -28,7 +29,7 @@ interface StatusBarProps { zoomLevel?: number onZoomReset?: () => void buildNumber?: string - onRemoveVault?: (path: string) => void + indexingProgress?: IndexingProgress } function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) { @@ -218,6 +219,38 @@ function ConflictBadge({ count }: { count: number }) { ) } +const INDEXING_LABELS: Record = { + installing: 'Installing search…', + scanning: 'Indexing…', + embedding: 'Embedding…', + complete: 'Index ready', + error: 'Index error', +} + +function IndexingBadge({ progress }: { progress: IndexingProgress }) { + if (progress.phase === 'idle') return null + const label = INDEXING_LABELS[progress.phase] ?? progress.phase + const isActive = !progress.done + const showCount = progress.total > 0 && isActive + const displayText = showCount + ? `${label} ${progress.current.toLocaleString()}/${progress.total.toLocaleString()}` + : label + const color = progress.phase === 'error' ? 'var(--accent-orange)' : 'var(--accent-blue, #3b82f6)' + + return ( + <> + | + + {isActive + ? + : + } + {displayText} + + + ) +} + function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) { if (count <= 0) return null return ( @@ -236,7 +269,7 @@ function PendingBadge({ count, onClick }: { count: number; onClick?: () => void ) } -export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset, buildNumber, onRemoveVault }: StatusBarProps) { +export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset, buildNumber, indexingProgress }: StatusBarProps) { const [, setTick] = useState(0) useEffect(() => { const id = setInterval(() => setTick((t) => t + 1), 30_000) @@ -254,6 +287,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS {lastCommitInfo && } + {indexingProgress && }
Claude Sonnet 4 diff --git a/src/hooks/useIndexing.test.ts b/src/hooks/useIndexing.test.ts new file mode 100644 index 00000000..e046b0e1 --- /dev/null +++ b/src/hooks/useIndexing.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { useIndexing } from './useIndexing' + +vi.mock('../mock-tauri', () => ({ + isTauri: () => false, + mockInvoke: vi.fn().mockImplementation((cmd: string) => { + if (cmd === 'get_index_status') { + return Promise.resolve({ + available: true, + qmd_installed: true, + collection_exists: true, + indexed_count: 100, + embedded_count: 80, + pending_embed: 0, + }) + } + if (cmd === 'start_indexing') return Promise.resolve(null) + if (cmd === 'trigger_incremental_index') return Promise.resolve(null) + return Promise.resolve(null) + }), +})) + +const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType } + +describe('useIndexing', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('starts with idle progress', () => { + const { result } = renderHook(() => useIndexing('/vault')) + expect(result.current.progress.phase).toBe('idle') + expect(result.current.progress.done).toBe(false) + }) + + it('checks index status on mount', async () => { + renderHook(() => useIndexing('/vault')) + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('get_index_status', { vaultPath: '/vault' }) + }) + }) + + it('does not start indexing when collection exists and no pending embeds', async () => { + renderHook(() => useIndexing('/vault')) + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('get_index_status', { vaultPath: '/vault' }) + }) + expect(mockInvoke).not.toHaveBeenCalledWith('start_indexing', expect.anything()) + }) + + it('starts indexing when collection is missing', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'get_index_status') { + return Promise.resolve({ + available: true, + qmd_installed: true, + collection_exists: false, + indexed_count: 0, + embedded_count: 0, + pending_embed: 0, + }) + } + return Promise.resolve(null) + }) + + const { result } = renderHook(() => useIndexing('/vault')) + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' }) + }) + expect(result.current.progress.phase).not.toBe('idle') + }) + + it('starts indexing when qmd is not installed', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'get_index_status') { + return Promise.resolve({ + available: false, + qmd_installed: false, + collection_exists: false, + indexed_count: 0, + embedded_count: 0, + pending_embed: 0, + }) + } + return Promise.resolve(null) + }) + + renderHook(() => useIndexing('/vault')) + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' }) + }) + }) + + it('starts indexing when pending embeds exist', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'get_index_status') { + return Promise.resolve({ + available: true, + qmd_installed: true, + collection_exists: true, + indexed_count: 100, + embedded_count: 80, + pending_embed: 20, + }) + } + return Promise.resolve(null) + }) + + renderHook(() => useIndexing('/vault')) + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' }) + }) + }) + + it('triggerIncrementalIndex calls the command', async () => { + const { result } = renderHook(() => useIndexing('/vault')) + await act(async () => { + await result.current.triggerIncrementalIndex() + }) + expect(mockInvoke).toHaveBeenCalledWith('trigger_incremental_index', { vaultPath: '/vault' }) + }) + + it('handles index status check failure gracefully', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'get_index_status') return Promise.reject(new Error('network error')) + return Promise.resolve(null) + }) + + const { result } = renderHook(() => useIndexing('/vault')) + // Should remain idle — not crash + await waitFor(() => { + expect(result.current.progress.phase).toBe('idle') + }) + }) + + it('sets error phase when start_indexing fails', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'get_index_status') { + return Promise.resolve({ + available: false, + qmd_installed: false, + collection_exists: false, + indexed_count: 0, + embedded_count: 0, + pending_embed: 0, + }) + } + if (cmd === 'start_indexing') return Promise.reject(new Error('install failed')) + return Promise.resolve(null) + }) + + const { result } = renderHook(() => useIndexing('/vault')) + await waitFor(() => { + expect(result.current.progress.phase).toBe('error') + }) + expect(result.current.progress.error).toContain('install failed') + }) +}) diff --git a/src/hooks/useIndexing.ts b/src/hooks/useIndexing.ts new file mode 100644 index 00000000..5c6fa133 --- /dev/null +++ b/src/hooks/useIndexing.ts @@ -0,0 +1,116 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from '../mock-tauri' + +export interface IndexingProgress { + phase: 'idle' | 'installing' | 'scanning' | 'embedding' | 'complete' | 'error' + current: number + total: number + done: boolean + error: string | null +} + +interface IndexStatus { + available: boolean + qmd_installed: boolean + collection_exists: boolean + indexed_count: number + embedded_count: number + pending_embed: number +} + +const IDLE: IndexingProgress = { phase: 'idle', current: 0, total: 0, done: false, error: null } + +function invokeCmd(cmd: string, args?: Record): Promise { + return isTauri() ? invoke(cmd, args) : mockInvoke(cmd, args) +} + +export function useIndexing(vaultPath: string) { + const [progress, setProgress] = useState(IDLE) + const indexingRef = useRef(false) + const vaultPathRef = useRef(vaultPath) + + useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath]) + + // Listen for progress events from Rust + useEffect(() => { + if (!isTauri()) return + + let cleanup: (() => void) | null = null + + import('@tauri-apps/api/event').then(({ listen }) => { + const unlisten = listen('indexing-progress', (event) => { + setProgress(event.payload) + if (event.payload.done) { + indexingRef.current = false + } + }) + cleanup = () => { unlisten.then(fn => fn()) } + }) + + return () => { cleanup?.() } + }, []) + + // Check index status and auto-trigger indexing on vault open + useEffect(() => { + if (!vaultPath) return + let cancelled = false + + async function checkAndIndex() { + try { + const status = await invokeCmd('get_index_status', { vaultPath }) + if (cancelled) return + + // If qmd not installed or no collection or pending embeds, trigger indexing + const needsIndexing = !status.qmd_installed || !status.collection_exists || status.pending_embed > 0 + if (needsIndexing && !indexingRef.current) { + indexingRef.current = true + setProgress({ + phase: 'scanning', + current: 0, + total: status.indexed_count, + done: false, + error: null, + }) + // Fire and forget — progress updates come via events + invokeCmd('start_indexing', { vaultPath }).catch((err) => { + if (!cancelled) { + setProgress({ + phase: 'error', + current: 0, + total: 0, + done: true, + error: String(err), + }) + indexingRef.current = false + } + }) + } + } catch { + // get_index_status failed — likely qmd not available, non-fatal + } + } + + checkAndIndex() + return () => { cancelled = true } + }, [vaultPath]) + + // Auto-dismiss the "complete" status after 5 seconds + useEffect(() => { + if (progress.phase === 'complete') { + const timer = setTimeout(() => setProgress(IDLE), 5000) + return () => clearTimeout(timer) + } + }, [progress.phase]) + + const triggerIncrementalIndex = useCallback(async () => { + if (indexingRef.current) return + try { + await invokeCmd('trigger_incremental_index', { vaultPath: vaultPathRef.current }) + } catch { + // Incremental update failure is non-fatal + } + }, []) + + return { progress, triggerIncrementalIndex } +} diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index ac9aafa8..0efab01b 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -264,6 +264,9 @@ export const mockHandlers: Record any> = { }, create_getting_started_vault: () => '/Users/mock/Documents/Getting Started', register_mcp_tools: () => 'registered', + get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0 }), + start_indexing: () => null, + trigger_incremental_index: () => null, list_themes: (): ThemeFile[] => [...mockThemes], get_theme: (args: { themeId: string }): ThemeFile => { const t = mockThemes.find(t => t.id === args.themeId)