356 lines
11 KiB
Rust
356 lines
11 KiB
Rust
//! 文件索引: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<String>,
|
||
}
|
||
|
||
struct Inner {
|
||
conn: Mutex<Connection>,
|
||
}
|
||
|
||
static INDEX: std::sync::OnceLock<Mutex<Option<Inner>>> = std::sync::OnceLock::new();
|
||
|
||
fn index_slot() -> &'static Mutex<Option<Inner>> {
|
||
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, R>(f: F) -> Option<R>
|
||
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<i64, String> {
|
||
// 清空旧数据
|
||
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<FileRecord> {
|
||
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<String, Error>,统一处理失败
|
||
let res: rusqlite::Result<String> = 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::<Vec<String>>(&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<Mutex<Option<RecommendedWatcher>>> = std::sync::OnceLock::new();
|
||
|
||
fn watcher_slot() -> &'static Mutex<Option<RecommendedWatcher>> {
|
||
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<notify::Event>| {
|
||
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);
|
||
}
|
||
}
|
||
_ => {
|
||
// 忽略访问/其他事件
|
||
}
|
||
}
|
||
}
|