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> {
|
pub fn read_text_file(path: String) -> Result<FileContent, String> {
|
||||||
let bytes = fs::read(&path).map_err(|e| format!("读取文件失败:{e}"))?;
|
let bytes = fs::read(&path).map_err(|e| format!("读取文件失败:{e}"))?;
|
||||||
let (content, encoding, had_bom) = decode_bytes(&bytes);
|
let (content, encoding, had_bom) = decode_bytes(&bytes);
|
||||||
let line_ending = if content.contains("\r\n") { "CRLF" } else { "LF" }.to_string();
|
let line_ending = if content.contains("\r\n") {
|
||||||
Ok(FileContent { content, encoding, line_ending, had_bom })
|
"CRLF"
|
||||||
|
} else {
|
||||||
|
"LF"
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
|
Ok(FileContent {
|
||||||
|
content,
|
||||||
|
encoding,
|
||||||
|
line_ending,
|
||||||
|
had_bom,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_bytes(bytes: &[u8]) -> (String, String, bool) {
|
fn decode_bytes(bytes: &[u8]) -> (String, String, bool) {
|
||||||
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
|
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]) {
|
if bytes.starts_with(&[0xFF, 0xFE]) {
|
||||||
let (text, _, _) = encoding_rs::UTF_16LE.decode(bytes);
|
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/,不存在则创建。返回目录路径。
|
/// 资源目录:<文档目录>/<文档主名>.assets/,不存在则创建。返回目录路径。
|
||||||
fn assets_dir(doc_path: &str) -> Result<PathBuf, String> {
|
fn assets_dir(doc_path: &str) -> Result<PathBuf, String> {
|
||||||
let doc = PathBuf::from(doc_path);
|
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
|
let stem = doc
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.map(|s| s.to_string_lossy().into_owned())
|
.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());
|
.unwrap_or_else(|| "file".to_string());
|
||||||
let cleaned: String = base
|
let cleaned: String = base
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| if matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') { '_' } else { c })
|
.map(|c| {
|
||||||
|
if matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
|
||||||
|
'_'
|
||||||
|
} else {
|
||||||
|
c
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if cleaned.trim().is_empty() { "file".to_string() } else { cleaned }
|
if cleaned.trim().is_empty() {
|
||||||
|
"file".to_string()
|
||||||
|
} else {
|
||||||
|
cleaned
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 在资源目录里找一个不重名的目标路径(重名追加 -1、-2…)。
|
/// 在资源目录里找一个不重名的目标路径(重名追加 -1、-2…)。
|
||||||
@@ -152,7 +179,11 @@ fn relative_assets_path(doc_path: &str, target: &Path) -> String {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
|
pub fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
|
||||||
let src = PathBuf::from(&source);
|
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 dir = assets_dir(&doc_path)?;
|
||||||
let target = dedupe_path(&dir, &name);
|
let target = dedupe_path(&dir, &name);
|
||||||
fs::copy(&src, &target).map_err(|e| format!("复制资源文件失败:{e}"))?;
|
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]
|
#[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 name = sanitize_file_name(&file_name);
|
||||||
let dir = assets_dir(&doc_path)?;
|
let dir = assets_dir(&doc_path)?;
|
||||||
let target = dedupe_path(&dir, &name);
|
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())
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
.map(|d| d.as_millis() as i64)
|
.map(|d| d.as_millis() as i64)
|
||||||
.unwrap_or(0);
|
.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 crate::AppDb;
|
||||||
use tauri::State;
|
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())
|
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]
|
#[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)?;
|
let conn = lock_db(&db)?;
|
||||||
storage::recent_add(&conn, &path, &name, opened_at, 30)
|
storage::recent_add(&conn, &path, &name, opened_at, 30)
|
||||||
}
|
}
|
||||||
@@ -363,7 +413,14 @@ pub fn db_recovery_save(
|
|||||||
updated_at: i64,
|
updated_at: i64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let conn = lock_db(&db)?;
|
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]
|
#[tauri::command]
|
||||||
@@ -378,12 +435,24 @@ fn base64_encode(data: &[u8]) -> String {
|
|||||||
const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
|
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
|
||||||
for chunk in data.chunks(3) {
|
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;
|
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 >> 18) as usize & 63] as char);
|
||||||
out.push(TABLE[(n >> 12) 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() > 1 {
|
||||||
out.push(if chunk.len() > 2 { TABLE[n as usize & 63] as char } else { '=' });
|
TABLE[(n >> 6) as usize & 63] as char
|
||||||
|
} else {
|
||||||
|
'='
|
||||||
|
});
|
||||||
|
out.push(if chunk.len() > 2 {
|
||||||
|
TABLE[n as usize & 63] as char
|
||||||
|
} else {
|
||||||
|
'='
|
||||||
|
});
|
||||||
}
|
}
|
||||||
out
|
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}"))?;
|
let bytes = fs::read(&path).map_err(|e| format!("读取文件失败:{e}"))?;
|
||||||
Ok(base64_encode(&bytes))
|
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 commands;
|
||||||
|
mod search;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -43,6 +44,7 @@ pub fn run() {
|
|||||||
commands::db_recovery_save,
|
commands::db_recovery_save,
|
||||||
commands::db_recovery_delete,
|
commands::db_recovery_delete,
|
||||||
commands::read_file_base64,
|
commands::read_file_base64,
|
||||||
|
commands::workspace_search,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running new-markdown");
|
.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