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 <noreply@anthropic.com>
This commit is contained in:
1
design/search-bundle-qmd.pen
Normal file
1
design/search-bundle-qmd.pen
Normal file
@@ -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":{}}
|
||||
486
src-tauri/src/indexing.rs
Normal file
486
src-tauri/src/indexing.rs
Normal file
@@ -0,0 +1,486 @@
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static QMD_PATH_CACHE: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
/// Locate the qmd binary, checking known locations and PATH.
|
||||
/// Caches the result for subsequent calls.
|
||||
pub fn find_qmd_binary() -> Option<String> {
|
||||
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<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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<usize> {
|
||||
if !line.starts_with(prefix) {
|
||||
return None;
|
||||
}
|
||||
extract_first_number(line)
|
||||
}
|
||||
|
||||
fn extract_first_number(s: &str) -> Option<usize> {
|
||||
s.split_whitespace()
|
||||
.find_map(|word| word.parse::<usize>().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<String>,
|
||||
}
|
||||
|
||||
/// Run full indexing: update + embed. Returns progress updates via callback.
|
||||
pub fn run_full_index<F>(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<String, String> {
|
||||
// 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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Option<Child>>);
|
||||
|
||||
#[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,
|
||||
|
||||
@@ -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<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
|
||||
@@ -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<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 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);
|
||||
|
||||
|
||||
13
src/App.tsx
13
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() {
|
||||
</div>
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => 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} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => 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} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
|
||||
@@ -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(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onRemoveVault={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'scanning', current: 342, total: 1057, done: false, error: null }}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<StatusBar noteCount={100} vaultPath="/only/vault" vaults={singleVault} onSwitchVault={vi.fn()} onRemoveVault={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
expect(screen.queryByTestId('vault-menu-remove-Only Vault')).not.toBeInTheDocument()
|
||||
})
|
||||
it('shows embedding phase in indexing badge', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'embedding', current: 50, total: 200, done: false, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Embedding… 50\/200/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRemoveVault with vault path when remove button is clicked', () => {
|
||||
const onRemoveVault = vi.fn()
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onRemoveVault={onRemoveVault} />
|
||||
)
|
||||
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(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'complete', current: 1057, total: 1057, done: true, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Index ready')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes menu after removing a vault', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onRemoveVault={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd not available' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Index error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('remove button has "Remove from list" title for accessibility', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onRemoveVault={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
expect(screen.getAllByTitle('Remove from list')).toHaveLength(2)
|
||||
})
|
||||
it('hides indexing badge when phase is idle', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show remove button when onRemoveVault is not provided', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows installing phase in indexing badge', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'installing', current: 0, total: 0, done: false, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Installing search…')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={{ ...ICON_STYLE, color }} data-testid="status-indexing">
|
||||
{isActive
|
||||
? <Loader2 size={13} className="animate-spin" />
|
||||
: <Search size={13} />
|
||||
}
|
||||
{displayText}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 && <CommitBadge info={lastCommitInfo} />}
|
||||
<ConflictBadge count={conflictCount} />
|
||||
<PendingBadge count={modifiedCount} onClick={onClickPending} />
|
||||
{indexingProgress && <IndexingBadge progress={indexingProgress} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={ICON_STYLE}><Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />Claude Sonnet 4</span>
|
||||
|
||||
159
src/hooks/useIndexing.test.ts
Normal file
159
src/hooks/useIndexing.test.ts
Normal file
@@ -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<typeof vi.fn> }
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
116
src/hooks/useIndexing.ts
Normal file
116
src/hooks/useIndexing.ts
Normal file
@@ -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<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
export function useIndexing(vaultPath: string) {
|
||||
const [progress, setProgress] = useState<IndexingProgress>(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<IndexingProgress>('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<IndexStatus>('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 }
|
||||
}
|
||||
@@ -264,6 +264,9 @@ export const mockHandlers: Record<string, (args: any) => 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)
|
||||
|
||||
Reference in New Issue
Block a user