feat(stage6): Rust 工作区全文搜索模块与多窗口能力配置

This commit is contained in:
Muse
2026-11-13 23:09:32 +08:00
parent 83cedbc5f3
commit db158fa4da
4 changed files with 222 additions and 17 deletions

View File

@@ -1 +1,20 @@
{"$schema":"../gen/schemas/desktop-schema.json","description":"主窗口默认权限:窗口控制(自定义标题栏)与文件对话框","identifier":"default","permissions":["core:default","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-close","core:window:allow-is-maximized","core:window:allow-start-dragging","dialog:default"],"windows":["main"]}
{
"$schema": "../gen/schemas/desktop-schema.json",
"description": "主窗口默认权限:窗口控制(自定义标题栏)与文件对话框",
"identifier": "default",
"permissions": [
"core:default",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"dialog:default",
"core:webview:allow-create-webview-window"
],
"windows": [
"main",
"doc-*"
]
}

View File

@@ -24,13 +24,27 @@ pub struct FileContent {
pub fn read_text_file(path: String) -> Result<FileContent, String> {
let bytes = fs::read(&path).map_err(|e| format!("读取文件失败:{e}"))?;
let (content, encoding, had_bom) = decode_bytes(&bytes);
let line_ending = if content.contains("\r\n") { "CRLF" } else { "LF" }.to_string();
Ok(FileContent { content, encoding, line_ending, had_bom })
let line_ending = if content.contains("\r\n") {
"CRLF"
} else {
"LF"
}
.to_string();
Ok(FileContent {
content,
encoding,
line_ending,
had_bom,
})
}
fn decode_bytes(bytes: &[u8]) -> (String, String, bool) {
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return (String::from_utf8_lossy(rest).into_owned(), "UTF-8".into(), true);
return (
String::from_utf8_lossy(rest).into_owned(),
"UTF-8".into(),
true,
);
}
if bytes.starts_with(&[0xFF, 0xFE]) {
let (text, _, _) = encoding_rs::UTF_16LE.decode(bytes);
@@ -90,7 +104,10 @@ pub fn write_text_file(path: String, content: String, keep_bom: bool) -> Result<
/// 资源目录:<文档目录>/<文档主名>.assets/,不存在则创建。返回目录路径。
fn assets_dir(doc_path: &str) -> Result<PathBuf, String> {
let doc = PathBuf::from(doc_path);
let dir = doc.parent().ok_or_else(|| "文档路径没有父目录".to_string())?.to_path_buf();
let dir = doc
.parent()
.ok_or_else(|| "文档路径没有父目录".to_string())?
.to_path_buf();
let stem = doc
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
@@ -108,9 +125,19 @@ fn sanitize_file_name(name: &str) -> String {
.unwrap_or_else(|| "file".to_string());
let cleaned: String = base
.chars()
.map(|c| if matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') { '_' } else { c })
.map(|c| {
if matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
'_'
} else {
c
}
})
.collect();
if cleaned.trim().is_empty() { "file".to_string() } else { cleaned }
if cleaned.trim().is_empty() {
"file".to_string()
} else {
cleaned
}
}
/// 在资源目录里找一个不重名的目标路径(重名追加 -1、-2…)。
@@ -152,7 +179,11 @@ fn relative_assets_path(doc_path: &str, target: &Path) -> String {
#[tauri::command]
pub fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
let src = PathBuf::from(&source);
let name = sanitize_file_name(&src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "file".into()));
let name = sanitize_file_name(
&src.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".into()),
);
let dir = assets_dir(&doc_path)?;
let target = dedupe_path(&dir, &name);
fs::copy(&src, &target).map_err(|e| format!("复制资源文件失败:{e}"))?;
@@ -161,7 +192,11 @@ pub fn copy_to_assets(source: String, doc_path: String) -> Result<String, String
/// 把字节内容(如粘贴的图片)写入文档资源目录,返回相对路径。
#[tauri::command]
pub fn save_bytes_to_assets(doc_path: String, file_name: String, bytes: Vec<u8>) -> Result<String, String> {
pub fn save_bytes_to_assets(
doc_path: String,
file_name: String,
bytes: Vec<u8>,
) -> Result<String, String> {
let name = sanitize_file_name(&file_name);
let dir = assets_dir(&doc_path)?;
let target = dedupe_path(&dir, &name);
@@ -240,9 +275,17 @@ pub fn stat_file(path: String) -> FileStat {
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
FileStat { exists: true, mtime_ms, size: meta.len() }
FileStat {
exists: true,
mtime_ms,
size: meta.len(),
}
}
Err(_) => FileStat { exists: false, mtime_ms: 0, size: 0 },
Err(_) => FileStat {
exists: false,
mtime_ms: 0,
size: 0,
},
}
}
@@ -295,7 +338,9 @@ use crate::storage;
use crate::AppDb;
use tauri::State;
fn lock_db<'a>(db: &'a State<'a, AppDb>) -> Result<std::sync::MutexGuard<'a, rusqlite::Connection>, String> {
fn lock_db<'a>(
db: &'a State<'a, AppDb>,
) -> Result<std::sync::MutexGuard<'a, rusqlite::Connection>, String> {
db.0.lock().map_err(|_| "数据库锁损坏".to_string())
}
@@ -306,7 +351,12 @@ pub fn db_recent_list(db: State<'_, AppDb>) -> Result<Vec<storage::RecentEntry>,
}
#[tauri::command]
pub fn db_recent_add(db: State<'_, AppDb>, path: String, name: String, opened_at: i64) -> Result<(), String> {
pub fn db_recent_add(
db: State<'_, AppDb>,
path: String,
name: String,
opened_at: i64,
) -> Result<(), String> {
let conn = lock_db(&db)?;
storage::recent_add(&conn, &path, &name, opened_at, 30)
}
@@ -363,7 +413,14 @@ pub fn db_recovery_save(
updated_at: i64,
) -> Result<(), String> {
let conn = lock_db(&db)?;
storage::recovery_save(&conn, &doc_key, path.as_deref(), &title, &content, updated_at)
storage::recovery_save(
&conn,
&doc_key,
path.as_deref(),
&title,
&content,
updated_at,
)
}
#[tauri::command]
@@ -378,12 +435,24 @@ fn base64_encode(data: &[u8]) -> String {
const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
for chunk in data.chunks(3) {
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
out.push(TABLE[(n >> 18) as usize & 63] as char);
out.push(TABLE[(n >> 12) as usize & 63] as char);
out.push(if chunk.len() > 1 { TABLE[(n >> 6) as usize & 63] as char } else { '=' });
out.push(if chunk.len() > 2 { TABLE[n as usize & 63] as char } else { '=' });
out.push(if chunk.len() > 1 {
TABLE[(n >> 6) as usize & 63] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
TABLE[n as usize & 63] as char
} else {
'='
});
}
out
}
@@ -394,3 +463,16 @@ pub fn read_file_base64(path: String) -> Result<String, String> {
let bytes = fs::read(&path).map_err(|e| format!("读取文件失败:{e}"))?;
Ok(base64_encode(&bytes))
}
// ————————————————— 阶段 6:工作区全文搜索(逻辑在 search.rs) —————————————————
use crate::search;
#[tauri::command]
pub fn workspace_search(
root: String,
query: String,
case_sensitive: bool,
) -> Vec<search::FileMatches> {
search::search_workspace(&root, &query, case_sensitive)
}

View File

@@ -1,4 +1,5 @@
mod commands;
mod search;
mod storage;
use std::sync::Mutex;
@@ -43,6 +44,7 @@ pub fn run() {
commands::db_recovery_save,
commands::db_recovery_delete,
commands::read_file_base64,
commands::workspace_search,
])
.run(tauri::generate_context!())
.expect("error while running new-markdown");

102
src-tauri/src/search.rs Normal file
View File

@@ -0,0 +1,102 @@
//! 工作区全文搜索(阶段 6):递归扫描 Markdown/文本文件做逐行包含匹配。
//! 纯逻辑、不依赖 Tauri 类型,命令层只转发;带上限保护(文件大小/结果数/单文件命中数)。
use serde::Serialize;
use std::fs;
use std::path::Path;
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SearchMatch {
pub line: i64,
pub text: String,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct FileMatches {
pub path: String,
pub name: String,
pub matches: Vec<SearchMatch>,
}
const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
const MAX_FILES_WITH_MATCHES: usize = 300;
const MAX_MATCHES_PER_FILE: usize = 20;
const SNIPPET_CHARS: usize = 160;
fn is_searchable(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()),
Some(ref ext) if ext == "md" || ext == "markdown" || ext == "txt"
)
}
fn scan_dir(dir: &Path, query: &str, needle: &str, case_sensitive: bool, out: &mut Vec<FileMatches>) {
if out.len() >= MAX_FILES_WITH_MATCHES {
return;
}
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
let mut paths: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.path())).collect();
paths.sort();
for path in paths {
if out.len() >= MAX_FILES_WITH_MATCHES {
return;
}
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if path.is_dir() {
// 跳过隐藏目录与依赖目录,避免噪音与无谓扫描
if name.starts_with('.') || name == "node_modules" {
continue;
}
scan_dir(&path, query, needle, case_sensitive, out);
} else if is_searchable(&path) {
if let Ok(meta) = path.metadata() {
if meta.len() > MAX_FILE_BYTES {
continue;
}
}
let bytes = match fs::read(&path) {
Ok(b) => b,
Err(_) => continue,
};
let text = String::from_utf8_lossy(&bytes);
let mut matches = Vec::new();
for (idx, line) in text.lines().enumerate() {
let hit = if case_sensitive {
line.contains(needle)
} else {
line.to_lowercase().contains(needle)
};
if hit {
let snippet: String = line.trim().chars().take(SNIPPET_CHARS).collect();
matches.push(SearchMatch { line: (idx + 1) as i64, text: snippet });
if matches.len() >= MAX_MATCHES_PER_FILE {
break;
}
}
}
if !matches.is_empty() {
out.push(FileMatches {
path: path.to_string_lossy().into_owned(),
name,
matches,
});
}
}
}
}
pub fn search_workspace(root: &str, query: &str, case_sensitive: bool) -> Vec<FileMatches> {
let query = query.trim();
if query.is_empty() {
return Vec::new();
}
let needle = if case_sensitive { query.to_string() } else { query.to_lowercase() };
let mut out = Vec::new();
scan_dir(Path::new(root), query, &needle, case_sensitive, &mut out);
out
}