diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 3623ead..4d13f7b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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"]} \ No newline at end of file +{ + "$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-*" + ] +} \ No newline at end of file diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 187d433..6ee0018 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -24,13 +24,27 @@ pub struct FileContent { pub fn read_text_file(path: String) -> Result { 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 { 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 { 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) -> Result { +pub fn save_bytes_to_assets( + doc_path: String, + file_name: String, + bytes: Vec, +) -> Result { 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, String> { +fn lock_db<'a>( + db: &'a State<'a, AppDb>, +) -> Result, String> { db.0.lock().map_err(|_| "数据库锁损坏".to_string()) } @@ -306,7 +351,12 @@ pub fn db_recent_list(db: State<'_, AppDb>) -> Result, } #[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 { 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::search_workspace(&root, &query, case_sensitive) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a24bdbc..360590d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src-tauri/src/search.rs b/src-tauri/src/search.rs new file mode 100644 index 0000000..c868bc5 --- /dev/null +++ b/src-tauri/src/search.rs @@ -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, +} + +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) { + 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 { + 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 +}