feat(stage6): Rust 工作区全文搜索模块与多窗口能力配置
This commit is contained in:
@@ -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-*"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
102
src-tauri/src/search.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user