//! 文件索引:walkdir 遍历 + rusqlite 存储/搜索。 //! //! schema: //! files(path TEXT PK, name TEXT, ext TEXT, size INT, mtime INT, is_dir INT) //! 索引:name LIKE 搜索(name_lower 已预存为小写,避免 lower() 全表扫描)。 use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::UNIX_EPOCH; use rusqlite::{params, Connection}; use serde::Serialize; use tauri::{AppHandle, Manager}; use walkdir::WalkDir; use specta::Type; /// 单个文件记录(返回给前端) #[derive(Debug, Clone, Serialize, Type)] #[serde(rename_all = "camelCase")] pub struct FileRecord { pub path: String, pub name: String, pub ext: String, pub size: i64, pub is_dir: bool, } /// 索引状态(返回给前端) #[derive(Debug, Clone, Serialize, Type)] #[serde(rename_all = "camelCase")] pub struct IndexStats { pub total: i64, pub last_built_at: i64, pub last_built_dirs: Vec, } struct Inner { conn: Mutex, } static INDEX: std::sync::OnceLock>> = std::sync::OnceLock::new(); fn index_slot() -> &'static Mutex> { INDEX.get_or_init(|| Mutex::new(None)) } /// 数据库路径:{app_data_dir}/quickpanel/files.db fn db_path(app: &AppHandle) -> PathBuf { app.path() .app_data_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join("quickpanel") .join("files.db") } /// 初始化数据库连接(创建表 + 索引)。若已初始化则跳过。 pub fn init(app: &AppHandle) { let path = db_path(app); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } let conn = match Connection::open(&path) { Ok(c) => c, Err(e) => { crate::logger::log_error("quickpanel", &format!("文件索引 DB 初始化失败: {}", e)); return; } }; conn.busy_timeout(std::time::Duration::from_secs(3)).ok(); let _ = conn.execute_batch( "CREATE TABLE IF NOT EXISTS files ( path TEXT PRIMARY KEY, name TEXT NOT NULL, name_lower TEXT NOT NULL, ext TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL DEFAULT 0, mtime INTEGER NOT NULL DEFAULT 0, is_dir INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_name_lower ON files(name_lower); CREATE INDEX IF NOT EXISTS idx_ext ON files(ext); CREATE TABLE IF NOT EXISTS files_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL );", ); let mut guard = index_slot().lock().unwrap_or_else(|e| e.into_inner()); *guard = Some(Inner { conn: Mutex::new(conn), }); crate::logger::log_info("quickpanel", &format!("文件索引 DB 已就绪: {}", path.display())); } /// 判断索引是否已初始化 fn with_conn(f: F) -> Option where F: FnOnce(&Connection) -> R, { let guard = index_slot().lock().unwrap_or_else(|e| e.into_inner()); if let Some(inner) = guard.as_ref() { if let Ok(conn) = inner.conn.lock() { return Some(f(&conn)); } } None } /// 遍历指定目录列表建立索引(全量重建)。 /// 返回索引条目数。在 spawn_blocking 中调用。 /// 重建完成后自动启动 notify 监听器做增量更新。 pub fn build_index(dirs: &[String]) -> Result { // 清空旧数据 let cleared = with_conn(|conn| { conn.execute("DELETE FROM files", []).ok() }).unwrap_or(None); if cleared.is_none() { return Err("文件索引未初始化".into()); } let mut count = 0i64; for dir in dirs { count += walk_and_index(dir); } // 记录构建元信息 let now = now_secs(); let dirs_json = serde_json::to_string(dirs).unwrap_or_default(); let _ = with_conn(|conn| { conn.execute( "INSERT OR REPLACE INTO files_meta (key, value) VALUES ('last_built_at', ?1)", params![now.to_string()], ).ok(); conn.execute( "INSERT OR REPLACE INTO files_meta (key, value) VALUES ('last_built_dirs', ?1)", params![dirs_json], ).ok() }); // 启动/刷新 notify 监听器 start_watcher(dirs); crate::logger::log_info("quickpanel", &format!("文件索引完成,共 {} 条", count)); Ok(count) } /// 遍历单个目录并写入索引,返回新增条目数 fn walk_and_index(dir: &str) -> i64 { let path = Path::new(dir); if !path.exists() { return 0; } let mut count = 0i64; for entry in WalkDir::new(path) .max_depth(10) .follow_links(false) .into_iter() .filter_map(|e| e.ok()) { let p = entry.path(); if upsert_path(p) { count += 1; } } count } /// 将单个路径写入索引(创建/修改)。返回 true 表示已写入。 /// 跳过隐藏文件(. 开头)、不存在的路径。 fn upsert_path(p: &Path) -> bool { let Some(name_os) = p.file_name() else { return false }; let name = name_os.to_string_lossy().to_string(); if name.starts_with('.') { return false; } let meta = match std::fs::metadata(p) { Ok(m) => m, Err(_) => return false, }; let name_lower = name.to_lowercase(); let ext = p.extension() .map(|e| e.to_string_lossy().to_lowercase()) .unwrap_or_default(); let size = meta.len() as i64; let mtime = meta.modified().ok() .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) .unwrap_or(0); let is_dir = meta.is_dir() as i32; let path_str = p.to_string_lossy().to_string(); let _ = with_conn(|conn| { conn.execute( "INSERT OR REPLACE INTO files (path, name, name_lower, ext, size, mtime, is_dir) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![path_str, name, name_lower, ext, size, mtime, is_dir], ) }); true } /// 从索引中删除指定路径 fn remove_path(p: &Path) { let path_str = p.to_string_lossy().to_string(); let _ = with_conn(|conn| { // 删除该路径本身及其直接子项(目录被删除时,子文件也失效)。 // 用"路径 + 分隔符"的前缀匹配(而非裸前缀),避免误删 dir2/directory 等兄弟目录。 let backslash_prefix = format!("{}\\{}", path_str, "%"); let slash_prefix = format!("{}/{}", path_str, "%"); conn.execute( "DELETE FROM files WHERE path = ?1 OR path LIKE ?2 OR path LIKE ?3", params![path_str, backslash_prefix, slash_prefix], ) }); } /// 搜索文件:name_lower LIKE %query%,按名字长度升序(短名优先)。 pub fn search(query: &str, limit: i64) -> Vec { let like = format!("%{}%", query.to_lowercase()); with_conn(|conn| { let mut stmt = match conn.prepare( "SELECT path, name, ext, size, is_dir FROM files WHERE name_lower LIKE ?1 ORDER BY LENGTH(name) ASC, name ASC LIMIT ?2", ) { Ok(s) => s, Err(_) => return vec![], }; stmt.query_map(params![like, limit], |r| { Ok(FileRecord { path: r.get(0)?, name: r.get(1)?, ext: r.get(2)?, size: r.get(3)?, is_dir: r.get::<_, i32>(4)? != 0, }) }) .map(|r| r.filter_map(|i| i.ok()).collect()) .unwrap_or_default() }) .unwrap_or_default() } /// 索引状态 pub fn stats() -> IndexStats { let total = with_conn(|conn| { conn.query_row("SELECT COUNT(*) FROM files", [], |r| r.get::<_, i64>(0)) .unwrap_or(0) }) .unwrap_or(0); let last_built_at = with_conn(|conn| { // query_row 返回 Result,统一处理失败 let res: rusqlite::Result = conn.query_row( "SELECT value FROM files_meta WHERE key = 'last_built_at'", [], |r| r.get::<_, String>(0), ); res.ok().and_then(|s| s.parse().ok()).unwrap_or(0) }) .unwrap_or(0); let last_built_dirs = with_conn(|conn| { conn.query_row( "SELECT value FROM files_meta WHERE key = 'last_built_dirs'", [], |r| r.get::<_, String>(0), ) .ok() .and_then(|s| serde_json::from_str::>(&s).ok()) .unwrap_or_default() }) .unwrap_or_default(); IndexStats { total, last_built_at, last_built_dirs, } } fn now_secs() -> i64 { std::time::SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } // ===== notify 增量监听 ===== use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; static WATCHER: std::sync::OnceLock>> = std::sync::OnceLock::new(); fn watcher_slot() -> &'static Mutex> { WATCHER.get_or_init(|| Mutex::new(None)) } /// 启动/刷新 notify 监听器。重建索引或修改索引目录后调用。 /// 会先停止旧监听器,再为新目录列表添加递归监听。 pub fn start_watcher(dirs: &[String]) { // 创建新 watcher(notify v6: recommended_watcher 只接受回调,Config 默认) let mut watcher = match notify::recommended_watcher( move |res: notify::Result| { if let Ok(event) = res { handle_fs_event(&event); } }, ) { Ok(w) => w, Err(e) => { crate::logger::log_error("quickpanel", &format!("notify watcher 创建失败: {}", e)); return; } }; // 为每个目录添加递归监听 for dir in dirs { let path = Path::new(dir); if !path.exists() { continue; } if let Err(e) = watcher.watch(path, RecursiveMode::Recursive) { crate::logger::log_error("quickpanel", &format!("watch {} 失败: {}", dir, e)); } } // 替换旧 watcher(drop 时自动 unwatch) let mut guard = watcher_slot().lock().unwrap_or_else(|e| e.into_inner()); *guard = Some(watcher); crate::logger::log_info("quickpanel", &format!("notify 监听已启动,监听 {} 个目录", dirs.len())); } /// 处理文件系统事件:创建/修改 → upsert,删除 → remove,重命名 → remove + upsert fn handle_fs_event(event: ¬ify::Event) { match event.kind { EventKind::Create(_) | EventKind::Modify(_) => { for path in &event.paths { if path.exists() { upsert_path(path); } } } EventKind::Remove(_) => { for path in &event.paths { remove_path(path); } } _ => { // 忽略访问/其他事件 } } }