diff --git a/docs/12-audit-fix-report.md b/docs/12-audit-fix-report.md new file mode 100644 index 0000000..5b93ae6 --- /dev/null +++ b/docs/12-audit-fix-report.md @@ -0,0 +1,64 @@ +# 审计修复报告(对应 docs/11,2026-11-13 晚) + +按用户拍板顺序执行:高危逻辑 → 样式 → 性能,并顺带处理中危中的低成本项。 +验证:`npx tsc -b` 0 错误;`npx vitest run` **71 通过 + 2 跳过**(修复前 61+2,新增回归 10 例:tabs 1、tabkey 2、audit-fix 7);`npm run build` 通过;dev 冒烟 200;Rust 侧 search/base64 以独立 crate 实跑断言(符号链接环终止、GBK 中文可搜、base64 往返);commands.rs 过 rustfmt 语法解析。本地未推送。 + +## 一、高危逻辑(A1–A10)——全部已修 + +| # | 修复 | +|---|---| +| A1 保存竞态 | `saved` action 改为携带写盘快照 `savedContent`;saveTab 用调用时刻快照落盘并回填。写盘期间的新输入保持脏。回归测试已加(tabs.test)。 | +| A2 重载被卸载回写覆盖 | EditorPane 新增 `onFinalMarkdown` 通道专门承接卸载回写;重载活动标签前 App 先把该标签加入抑制集合并 1.5 秒兜底清除,回写被丢弃。 | +| A3 重载检查—执行竞态 | reloadTabFromDisk 增加 `expectedContent` 校验与 isDirty 复检,读取期间状态已变则放弃重载、交下一轮按冲突处理。 | +| A4 ?file= 被挡死 | 启动 effect 重构:恢复计划改为条件块,`?file=` 打开移到无条件路径;多窗口主路径恢复。 | +| A5 偏好启动竞态 | 偏好改为最先读回,读完置 `uiLoadedRef` 后 `[ui]` 保存 effect 才允许写库;落库再加 400ms 防抖(顺带解决自定义 CSS 逐字写库)。 | +| A6 depth=0 RangeError | cursorFromSelection 对 depth=0 单独处理(行=起点前文本块数+1、列=1),并顺手给常规路径加提前终止。回归测试覆盖全选与公式块 NodeSelection。 | +| A7 查找命中过期 | 编辑器/源码两侧:文档一变立即作废命中表,下个查找操作先按新内容重算;同时统一了替换契约——pane 内不再自查一遍,刷新与定位由 App 层单次完成(修掉 B 的双重计算与序号错位)。 | +| A8 自动保存覆盖外部修改 | 自动保存跳过正处于冲突弹窗中的标签。 | +| A9 搜索无限递归 | search.rs 按规范化路径去重(链接环直接跳过)+ 深度上限 24 + 已扫描文件上限 20000。独立 crate 验证链接环可终止。 | +| A10 主线程同步命令 | read_text_file / write_text_file / read_file_base64 / workspace_search 改 async + spawn_blocking;文本读 64MB、base64 读 32MB 上限。 | + +## 二、中危顺带修复 + +- 关窗恢复写入改为先 preventDefault、等全部 saveRecovery 落盘再决定放行/弹窗(原 fire-and-forget 丢未命名草稿)。 +- 另存为后新旧两个恢复键都清理(原只清新键,启动误报草稿)。 +- 全局快捷键在任一弹窗打开时整体让行(anyModalRef)。 +- 打印在源码模式下改为等渲染编辑器就绪再打印(原固定 60ms 赛跑)。 +- 原子写临时名加纳秒后缀(并发保存不共用临时文件);create_text_file 改 create_new 原子创建;资源落盘改 create_new 循环(消除 dedupe 竞态与兜底覆盖)。 +- 删除到回收站返回实际去向,前端对永久删除明确提示。 +- 编码:BOM+坏字节不再 lossy 吞字(退 GBK);搜索改为 GBK 感知解码(GBK 文档中文可搜,独立 crate 验证)。 +- DB 锁中毒自愈(into_inner 取回连接);文件名清洗补控制字符/结尾点空格/Windows 保留名。 +- 图片粘贴改 base64 过 IPC(原 number[] 数组);落盘失败有错误提示(uploader try/catch + onImageError)。 +- 快捷键:录入无修饰键组合直接拦截并提示;`Ctrl+Shift+=` 归一为 `ctrl+=`(与缩放默认键对齐);源码模式切主题改 Compartment 热切换(不丢光标/撤销史);源码字号跟随缩放变量。 +- 导出:坏公式不再整篇失败(throwOnError:false);Mermaid 导出 id 加序号防重复;导出图片解析去查询串 + URL 解码。 +- 工作区搜索:结果记住搜索时的词(点击不再用输入框现值)、换根目录清旧结果。 +- 粘贴多行纯文本按段落插入(原塞单文本节点、往返不保真,回归测试已加)。 +- Mermaid 视图渲染代次守卫(旧渲染不覆盖新预览);锚点兜底跳过空段落;list_item 视图闭包节点更新 + label 刷新;代码块语言类名同步;setAlignColumn 恢复完整选区;代码块 Tab 多行选区整体缩进;表格 Tab 命令失败不吞键;Stage5 弹窗补背景点击/Esc 关闭;docOverrides/conflictSeen 随标签关闭回收。 + +## 三、样式(D)——高危三项全修 + +- `--panel` → `--bg-panel`(3 处,查找条不再透明)。 +- 新增 `.input` 基础样式(边框/背景/字色/聚焦环,全主题变量),查找框/搜索框/弹窗输入框告别原生外观。 +- notion 补 `--doc-ink:#37352f`,github 补 `--doc-paper:#ffffff` + `--doc-ink:#1f2328`,深色 UI 下也可读。 +- 打印隐藏清单补 `.findbar`、`.mermaid-head`;`.modal-note` 重复定义合并;文件末重复规则删除;新增全应用细滚动条与选区色;FindBar min-width 340 + max-width 兜底。 +- themes.ts 的死字段 `darkPaper` 升级为真正被消费的 `paperTone`:源码模式明暗按文档主题纸面决定(修 D5 源码/文档主题不联动)。 + +## 四、性能(C) + +- 状态栏计数与大纲改为只随正文变化重算(useMemo 按 content),大纲再经 useDeferredValue 延后提取;Sidebar memo 化(App 传稳定回调);tabs reducer 对同内容 updateContent 直接返回原 state。 +- 专注装饰改「映射复用」:活动块不变时复用映射后的旧装饰集,只在跨块/强制时重建;顺带修 depth=0 选中顶层原子时整篇变淡。 +- 查找输入防抖 150ms(Enter/翻页立即冲刷挂起查找)。 +- 选区行列计算提前终止(不再每次选区变化数完整篇)。 +- 图片 IPC 改 base64(见中危)。 + +## 五、待核实项结论 + +- **E1 Tab 抢先:排除。** 新增 tests/tabkey.test.ts 向组装好的编辑器派发真实 Tab:表格内跳格、文档未变;代码块行首缩进、非光标处插入。两项行为正确,indent 插件未抢先。此测试常驻回归。 +- E3/E4(KaTeX 抛错、Mermaid 导出 id)已按预防性修复处理(见中危)。 +- E2 CSP 维持现状(需结合真机 WebView 行为专项评估,不在本批)。 +- E5 双进程同开一个 SQLite 的 SQLITE_BUSY 未处理:同进程多窗口共用 State 连接无此问题;双开进程属边缘场景,列入阶段 7 真机清单观察。 + +## 六、本批未动的(架构性/需真机) + +- C2:Milkdown listener 每 200ms 全量序列化 + 深比较是大文档输入的固有成本,根治需把「状态持有 Markdown 字符串」改为按需序列化,属架构级改动,建议单独立项评估,不在本批冒险。 +- 整套 Tauri 构建与真机行为(多窗口、关窗拦截、回收站去向提示、打印)仍待 Windows 验证——本批所有 Rust 改动只过了语法与纯逻辑验证,未过完整 cargo 构建(沙箱限制,同前)。 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6ee0018..cf810c9 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -20,8 +20,23 @@ pub struct FileContent { pub had_bom: bool, } +/// 审计修复:重 IO 命令改 async 并在阻塞线程池执行,避免占用 UI 主线程; +/// 同时加大小上限,误开超大文件直接报错而不是把内存吃爆。 #[tauri::command] -pub fn read_text_file(path: String) -> Result { +pub async fn read_text_file(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || read_text_file_sync(path)) + .await + .map_err(|e| format!("读取任务失败:{e}"))? +} + +const MAX_TEXT_FILE_BYTES: u64 = 64 * 1024 * 1024; + +fn read_text_file_sync(path: String) -> Result { + if let Ok(meta) = fs::metadata(&path) { + if meta.len() > MAX_TEXT_FILE_BYTES { + return Err("文件过大(超过 64MB),已拒绝打开".to_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") { @@ -40,11 +55,12 @@ pub fn read_text_file(path: String) -> Result { 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, - ); + if let Ok(s) = std::str::from_utf8(rest) { + return (s.to_string(), "UTF-8".into(), true); + } + // 带 BOM 但正文不是合法 UTF-8:按 GBK 解码,避免 lossy 把坏字节永久替换成 � + let (text, _, _) = encoding_rs::GBK.decode(rest); + return (text.into_owned(), "GBK".into(), true); } if bytes.starts_with(&[0xFF, 0xFE]) { let (text, _, _) = encoding_rs::UTF_16LE.decode(bytes); @@ -65,7 +81,13 @@ fn decode_bytes(bytes: &[u8]) -> (String, String, bool) { /// 原子写:先写同目录临时文件并落盘,再替换目标。`keep_bom` 为真时补回 UTF-8 BOM。 #[tauri::command] -pub fn write_text_file(path: String, content: String, keep_bom: bool) -> Result<(), String> { +pub async fn write_text_file(path: String, content: String, keep_bom: bool) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || write_text_file_sync(path, content, keep_bom)) + .await + .map_err(|e| format!("写入任务失败:{e}"))? +} + +fn write_text_file_sync(path: String, content: String, keep_bom: bool) -> Result<(), String> { let target = PathBuf::from(&path); let dir = target .parent() @@ -76,7 +98,12 @@ pub fn write_text_file(path: String, content: String, keep_bom: bool) -> Result< .ok_or_else(|| "目标路径没有文件名".to_string())? .to_string_lossy() .into_owned(); - let tmp = dir.join(format!(".{file_name}.tmp-{}", std::process::id())); + // 临时名带进程号 + 纳秒时间戳:自动保存与手动保存并发写同一文件时不共用临时文件 + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let tmp = dir.join(format!(".{file_name}.tmp-{}-{nanos}", std::process::id())); let mut data: Vec = Vec::with_capacity(content.len() + 3); if keep_bom { @@ -126,21 +153,34 @@ fn sanitize_file_name(name: &str) -> String { let cleaned: String = base .chars() .map(|c| { - if matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') { + if matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') || c.is_control() { '_' } else { c } }) .collect(); - if cleaned.trim().is_empty() { + // Windows 会自动剥离结尾的点与空格,先在此对齐,避免登记名与落盘名不一致 + let trimmed = cleaned.trim_end_matches(['.', ' ']); + let mut result = if trimmed.is_empty() { "file".to_string() } else { - cleaned + trimmed.to_string() + }; + // Windows 保留设备名(CON/NUL 等)直接写会失败,加前缀避开 + let stem_upper = result.split('.').next().unwrap_or("").to_ascii_uppercase(); + let reserved = matches!(stem_upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (stem_upper.len() == 4 + && (stem_upper.starts_with("COM") || stem_upper.starts_with("LPT")) + && stem_upper.as_bytes()[3].is_ascii_digit()); + if reserved { + result = format!("_{result}"); } + result } -/// 在资源目录里找一个不重名的目标路径(重名追加 -1、-2…)。 +/// 在资源目录里找一个不重名的目标路径(重名追加 -1、-2…)。落盘已改走 write_new_file,此函数保留备查。 +#[allow(dead_code)] fn dedupe_path(dir: &Path, file_name: &str) -> PathBuf { let candidate = dir.join(file_name); if !candidate.exists() { @@ -175,6 +215,38 @@ fn relative_assets_path(doc_path: &str, target: &Path) -> String { format!("{stem}.assets/{name}") } +/// 以 create_new 方式逐个尝试候选名落盘:目标已存在就换下一个名字, +/// 绝不截断覆盖已有文件(消除 dedupe 先查后写的竞态,含兜底覆盖问题)。 +fn write_new_file(dir: &Path, file_name: &str, data: &[u8]) -> Result { + for candidate in std::iter::once(dir.join(file_name)).chain((1..1000).map(|i| { + let stem = Path::new(file_name) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let ext = Path::new(file_name) + .extension() + .map(|e| format!(".{}", e.to_string_lossy())) + .unwrap_or_default(); + dir.join(format!("{stem}-{i}{ext}")) + })) { + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&candidate) + { + Ok(mut f) => { + use std::io::Write as _; + f.write_all(data) + .map_err(|e| format!("写入资源文件失败:{e}"))?; + return Ok(candidate); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(format!("写入资源文件失败:{e}")), + } + } + Err("资源文件名冲突过多".to_string()) +} + /// 把外部文件复制进文档资源目录,返回相对文档的路径(正斜杠,可直接写进 Markdown)。 #[tauri::command] pub fn copy_to_assets(source: String, doc_path: String) -> Result { @@ -185,25 +257,69 @@ pub fn copy_to_assets(source: String, doc_path: String) -> Result 传参,Tauri 按 JSON 数字数组序列化,5MB 图要传 500 万个数字; +/// 改传 base64 字符串,体积与解析开销都降一个量级。 #[tauri::command] pub fn save_bytes_to_assets( doc_path: String, file_name: String, - bytes: Vec, + base64: String, ) -> Result { let name = sanitize_file_name(&file_name); + let bytes = base64_decode(&base64)?; let dir = assets_dir(&doc_path)?; - let target = dedupe_path(&dir, &name); - fs::write(&target, &bytes).map_err(|e| format!("写入资源文件失败:{e}"))?; + let target = write_new_file(&dir, &name, &bytes)?; Ok(relative_assets_path(&doc_path, &target)) } +fn base64_decode(input: &str) -> Result, String> { + fn value(b: u8) -> Result { + match b { + b'A'..=b'Z' => Ok((b - b'A') as u32), + b'a'..=b'z' => Ok((b - b'a' + 26) as u32), + b'0'..=b'9' => Ok((b - b'0' + 52) as u32), + b'+' => Ok(62), + b'/' => Ok(63), + _ => Err("base64 内容非法".to_string()), + } + } + let clean: Vec = input.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + if clean.len() % 4 == 1 { + return Err("base64 长度非法".to_string()); + } + let mut out = Vec::with_capacity(clean.len() / 4 * 3 + 3); + for chunk in clean.chunks(4) { + let mut n: u32 = 0; + let mut pad = 0; + for (i, &b) in chunk.iter().enumerate() { + if b == b'=' { + pad += 1; + n <<= 6; + } else { + n = (n << 6) | value(b)?; + } + let _ = i; + } + if chunk.len() < 4 { + n <<= 6 * (4 - chunk.len()) as u32; + } + out.push((n >> 16) as u8); + if pad < 2 && chunk.len() > 2 { + out.push((n >> 8) as u8); + } + if pad < 1 && chunk.len() > 3 { + out.push(n as u8); + } + } + Ok(out) +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct DirEntry { @@ -289,17 +405,20 @@ pub fn stat_file(path: String) -> FileStat { } } -/// 新建空文本文件;目标已存在时报错(不覆盖)。 +/// 新建空文本文件;目标已存在时报错(不覆盖)。用 create_new 保证检查与创建原子。 #[tauri::command] pub fn create_text_file(path: String) -> Result<(), String> { let p = Path::new(&path); - if p.exists() { - return Err("同名文件已存在".to_string()); - } if let Some(parent) = p.parent() { fs::create_dir_all(parent).map_err(|e| format!("创建目录失败:{e}"))?; } - fs::write(p, "").map_err(|e| format!("创建文件失败:{e}")) + match fs::OpenOptions::new().write(true).create_new(true).open(p) { + Ok(_) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + Err("同名文件已存在".to_string()) + } + Err(e) => Err(format!("创建文件失败:{e}")), + } } #[tauri::command] @@ -316,15 +435,19 @@ pub fn rename_path(from: String, to: String) -> Result<(), String> { fs::rename(&from, &to).map_err(|e| format!("重命名失败:{e}")) } -/// 删除到系统回收站(失败时退回永久删除仅限文件)。 +/// 删除到系统回收站。返回实际去向:"trash" 或 "permanent"(回收站不可用时 +/// 对文件退回永久删除)——审计修复:此前静默永久删除,用户以为可恢复; +/// 现在把去向回给前端明示。 #[tauri::command] -pub fn delete_to_trash(path: String) -> Result<(), String> { +pub fn delete_to_trash(path: String) -> Result { match trash::delete(&path) { - Ok(()) => Ok(()), + Ok(()) => Ok("trash".to_string()), Err(e) => { let p = Path::new(&path); if p.is_file() { - fs::remove_file(p).map_err(|e2| format!("删除失败:{e};备用删除也失败:{e2}")) + fs::remove_file(p) + .map(|_| "permanent".to_string()) + .map_err(|e2| format!("删除失败:{e};备用删除也失败:{e2}")) } else { Err(format!("删除失败:{e}")) } @@ -338,15 +461,14 @@ use crate::storage; use crate::AppDb; use tauri::State; -fn lock_db<'a>( - db: &'a State<'a, AppDb>, -) -> Result, String> { - db.0.lock().map_err(|_| "数据库锁损坏".to_string()) +fn lock_db<'a>(db: &'a State<'a, AppDb>) -> std::sync::MutexGuard<'a, rusqlite::Connection> { + // 锁中毒(某命令持锁时 panic)后取回内部连接继续服务,而不是此后所有 DB 命令永久失败 + db.0.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } #[tauri::command] pub fn db_recent_list(db: State<'_, AppDb>) -> Result, String> { - let conn = lock_db(&db)?; + let conn = lock_db(&db); storage::recent_list(&conn, 30) } @@ -357,49 +479,49 @@ pub fn db_recent_add( 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) } #[tauri::command] pub fn db_recent_remove(db: State<'_, AppDb>, path: String) -> Result<(), String> { - let conn = lock_db(&db)?; + let conn = lock_db(&db); storage::recent_remove(&conn, &path) } #[tauri::command] pub fn db_recent_clear(db: State<'_, AppDb>) -> Result<(), String> { - let conn = lock_db(&db)?; + let conn = lock_db(&db); storage::recent_clear(&conn) } #[tauri::command] pub fn db_workspace_list(db: State<'_, AppDb>) -> Result, String> { - let conn = lock_db(&db)?; + let conn = lock_db(&db); storage::workspace_list(&conn) } #[tauri::command] pub fn db_workspace_add(db: State<'_, AppDb>, path: String, opened_at: i64) -> Result<(), String> { - let conn = lock_db(&db)?; + let conn = lock_db(&db); storage::workspace_add(&conn, &path, opened_at) } #[tauri::command] pub fn db_pref_get(db: State<'_, AppDb>, key: String) -> Result, String> { - let conn = lock_db(&db)?; + let conn = lock_db(&db); storage::pref_get(&conn, &key) } #[tauri::command] pub fn db_pref_set(db: State<'_, AppDb>, key: String, value: String) -> Result<(), String> { - let conn = lock_db(&db)?; + let conn = lock_db(&db); storage::pref_set(&conn, &key, &value) } #[tauri::command] pub fn db_recovery_list(db: State<'_, AppDb>) -> Result, String> { - let conn = lock_db(&db)?; + let conn = lock_db(&db); storage::recovery_list(&conn) } @@ -412,7 +534,7 @@ pub fn db_recovery_save( content: String, updated_at: i64, ) -> Result<(), String> { - let conn = lock_db(&db)?; + let conn = lock_db(&db); storage::recovery_save( &conn, &doc_key, @@ -425,7 +547,7 @@ pub fn db_recovery_save( #[tauri::command] pub fn db_recovery_delete(db: State<'_, AppDb>, doc_key: String) -> Result<(), String> { - let conn = lock_db(&db)?; + let conn = lock_db(&db); storage::recovery_delete(&conn, &doc_key) } @@ -457,11 +579,20 @@ fn base64_encode(data: &[u8]) -> String { out } -/// 读任意文件并以 base64 返回(导出图片内嵌、DOCX 导入共用)。 +/// 读任意文件并以 base64 返回(导出图片内嵌、DOCX 导入共用)。异步执行 + 32MB 上限。 #[tauri::command] -pub fn read_file_base64(path: String) -> Result { - let bytes = fs::read(&path).map_err(|e| format!("读取文件失败:{e}"))?; - Ok(base64_encode(&bytes)) +pub async fn read_file_base64(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if let Ok(meta) = fs::metadata(&path) { + if meta.len() > 32 * 1024 * 1024 { + return Err("文件过大(超过 32MB)".to_string()); + } + } + let bytes = fs::read(&path).map_err(|e| format!("读取文件失败:{e}"))?; + Ok(base64_encode(&bytes)) + }) + .await + .map_err(|e| format!("读取任务失败:{e}"))? } // ————————————————— 阶段 6:工作区全文搜索(逻辑在 search.rs) ————————————————— @@ -469,10 +600,14 @@ pub fn read_file_base64(path: String) -> Result { use crate::search; #[tauri::command] -pub fn workspace_search( +pub async fn workspace_search( root: String, query: String, case_sensitive: bool, ) -> Vec { - search::search_workspace(&root, &query, case_sensitive) + tauri::async_runtime::spawn_blocking(move || { + search::search_workspace(&root, &query, case_sensitive) + }) + .await + .unwrap_or_default() } diff --git a/src-tauri/src/search.rs b/src-tauri/src/search.rs index c868bc5..889b486 100644 --- a/src-tauri/src/search.rs +++ b/src-tauri/src/search.rs @@ -1,9 +1,17 @@ -//! 工作区全文搜索(阶段 6):递归扫描 Markdown/文本文件做逐行包含匹配。 +//! 工作区全文搜索:递归扫描 Markdown/文本文件做逐行包含匹配。 //! 纯逻辑、不依赖 Tauri 类型,命令层只转发;带上限保护(文件大小/结果数/单文件命中数)。 +//! +//! 审计修复(2026-11-13): +//! - 原实现用 `is_dir()` 跟随符号链接无条件递归,工作区里有指向父目录的 +//! junction/符号链接时会无限递归直至栈溢出——现按规范化路径去重 + 深度上限; +//! - 增加「已扫描文件总数」上限,无命中时也不会把整棵树无界扫完; +//! - 解码与读文件路径对齐:UTF-8 失败时按 GBK 解码(原先 lossy 解码导致 +//! GBK 文档的中文永远搜不到)。 use serde::Serialize; +use std::collections::HashSet; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] @@ -24,6 +32,8 @@ 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; +const MAX_DEPTH: usize = 24; +const MAX_SCANNED_FILES: usize = 20_000; fn is_searchable(path: &Path) -> bool { matches!( @@ -32,8 +42,46 @@ fn is_searchable(path: &Path) -> bool { ) } -fn scan_dir(dir: &Path, query: &str, needle: &str, case_sensitive: bool, out: &mut Vec) { - if out.len() >= MAX_FILES_WITH_MATCHES { +/// 与 commands::decode_bytes 对齐的文本解码:UTF-8(含 BOM)优先,否则 GBK。 +fn decode_text(bytes: &[u8]) -> String { + if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + if let Ok(s) = std::str::from_utf8(rest) { + return s.to_string(); + } + let (text, _, _) = encoding_rs::GBK.decode(rest); + return text.into_owned(); + } + match std::str::from_utf8(bytes) { + Ok(s) => s.to_string(), + Err(_) => { + let (text, _, _) = encoding_rs::GBK.decode(bytes); + text.into_owned() + } + } +} + +struct ScanState { + visited_dirs: HashSet, + scanned_files: usize, +} + +fn scan_dir( + dir: &Path, + depth: usize, + needle: &str, + case_sensitive: bool, + state: &mut ScanState, + out: &mut Vec, +) { + if out.len() >= MAX_FILES_WITH_MATCHES || depth > MAX_DEPTH || state.scanned_files >= MAX_SCANNED_FILES { + return; + } + // 按规范化路径去重:符号链接/junction 指回已访目录时直接跳过,杜绝无限递归 + let canonical = match fs::canonicalize(dir) { + Ok(c) => c, + Err(_) => return, + }; + if !state.visited_dirs.insert(canonical) { return; } let entries = match fs::read_dir(dir) { @@ -43,7 +91,7 @@ fn scan_dir(dir: &Path, query: &str, needle: &str, case_sensitive: bool, out: &m 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 { + if out.len() >= MAX_FILES_WITH_MATCHES || state.scanned_files >= MAX_SCANNED_FILES { return; } let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); @@ -52,8 +100,9 @@ fn scan_dir(dir: &Path, query: &str, needle: &str, case_sensitive: bool, out: &m if name.starts_with('.') || name == "node_modules" { continue; } - scan_dir(&path, query, needle, case_sensitive, out); + scan_dir(&path, depth + 1, needle, case_sensitive, state, out); } else if is_searchable(&path) { + state.scanned_files += 1; if let Ok(meta) = path.metadata() { if meta.len() > MAX_FILE_BYTES { continue; @@ -63,7 +112,7 @@ fn scan_dir(dir: &Path, query: &str, needle: &str, case_sensitive: bool, out: &m Ok(b) => b, Err(_) => continue, }; - let text = String::from_utf8_lossy(&bytes); + let text = decode_text(&bytes); let mut matches = Vec::new(); for (idx, line) in text.lines().enumerate() { let hit = if case_sensitive { @@ -96,7 +145,8 @@ pub fn search_workspace(root: &str, query: &str, case_sensitive: bool) -> Vec 上次确认的磁盘状态 */ const baselinesRef = useRef(new Map()) + /** 外部重载时抑制旧编辑器卸载回写的标签集合(见 reloadTabFromDisk) */ + const suppressFinalWriteRef = useRef(new Set()) + /** UI 偏好是否已完成启动读库(之前不许把默认值写回库,防启动竞态覆盖) */ + const uiLoadedRef = useRef(false) + /** 任一弹窗打开时为真:全局快捷键整体让行 */ + const anyModalRef = useRef(false) const conflictSeenRef = useRef(new Set()) const recoveryWrittenRef = useRef(new Map()) const lastTabsRef = useRef(new Map()) @@ -125,6 +131,8 @@ export default function App() { const findUiRef = useRef(findUi) findUiRef.current = findUi const pendingFindRef = useRef(null) + const findTimerRef = useRef(undefined) + const findPendingRef = useRef(false) const activeFindApi = useCallback((): Pick | SourceEditorApi | null => { return sourceModeRef.current ? sourceApiRef.current : paneFindRef.current @@ -154,6 +162,25 @@ export default function App() { window.setTimeout(() => runFind(query, findUiRef.current.opts), 30) }, [runFind]) + const scheduleFind = useCallback((query: string, opts: FindOptions) => { + // 审计修复(性能):逐字输入查询词时每键都全量重建字符索引,大文档明显卡顿;防抖 150ms。 + // Enter/上下翻页会先把挂起的查找立即执行(见 FindBar 的 onNext/onPrev)。 + window.clearTimeout(findTimerRef.current) + findPendingRef.current = true + findTimerRef.current = window.setTimeout(() => { + findPendingRef.current = false + runFind(query, opts) + }, 150) + }, [runFind]) + + const flushPendingFind = useCallback((): boolean => { + if (!findPendingRef.current) return false + window.clearTimeout(findTimerRef.current) + findPendingRef.current = false + runFind(findUiRef.current.query, findUiRef.current.opts) + return true + }, [runFind]) + const closeFind = useCallback(() => { activeFindApi()?.clearFind() setFindUi((f) => ({ ...f, open: false, count: 0, current: -1, error: null })) @@ -161,9 +188,14 @@ export default function App() { const tauriMode = isTauri() + const uiSaveTimerRef = useRef(undefined) useEffect(() => { document.documentElement.dataset.theme = ui.theme - store.saveUiPref(JSON.stringify(ui)) + if (!uiLoadedRef.current) return + window.clearTimeout(uiSaveTimerRef.current) + uiSaveTimerRef.current = window.setTimeout(() => { + store.saveUiPref(JSON.stringify(ui)) + }, 400) }, [ui]) // 工作区搜索点击结果:文件打开后自动打开查找条并定位 @@ -240,6 +272,8 @@ export default function App() { } }, [tauriMode, openPath]) + const handleSidebarOpenFile = useCallback((path: string) => { void openPath(path) }, [openPath]) + const openSearchResult = useCallback((path: string, query: string) => { pendingFindRef.current = query void openPath(path) @@ -271,11 +305,17 @@ export default function App() { } }, []) + const handleSidebarOpenFolder = useCallback(() => { void openFolder() }, [openFolder]) + const saveTab = useCallback(async (target: DocTab, saveAs: boolean): Promise => { + // 审计修复:保存用调用时刻的快照;saved 只把这份快照标为已保存, + // 写盘期间新输入的内容保持「脏」,不会被误标、不会丢恢复记录。 + const snapshot = target.content + const oldKey = recoveryKey(target) try { if (!tauriMode) { - downloadTextFile(`${target.title}.md`, target.content) - dispatch({ type: 'saved', id: target.id }) + downloadTextFile(`${target.title}.md`, snapshot) + dispatch({ type: 'saved', id: target.id, savedContent: snapshot }) return true } let path = target.path @@ -283,13 +323,16 @@ export default function App() { path = await pickSaveFile(path ?? `${target.title}.md`) if (!path) return false } - await writeTextFile(path, toDiskContent(target.content, target.lineEnding), target.hadBom) - dispatch({ type: 'saved', id: target.id, path, title: baseName(path) }) + await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom) + dispatch({ type: 'saved', id: target.id, savedContent: snapshot, path, title: baseName(path) }) const stat = await statFile(path) if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size }) - const key = recoveryKey({ id: target.id, path }) - recoveryWrittenRef.current.delete(key) - void store.deleteRecovery(key) + // 恢复记录可能按旧键(未命名/旧路径)写入,新旧键都清,避免下次启动误报草稿 + const newKey = recoveryKey({ id: target.id, path }) + for (const key of new Set([oldKey, newKey])) { + recoveryWrittenRef.current.delete(key) + void store.deleteRecovery(key) + } pushRecent(path) return true } catch (e) { @@ -309,17 +352,30 @@ export default function App() { }, [requestClose]) // ——— 阶段 4:存储初始化与启动恢复 ——— - const reloadTabFromDisk = useCallback(async (target: DocTab) => { + const reloadTabFromDisk = useCallback(async (target: DocTab, expectedContent?: string) => { if (!target.path) return try { const file = await readTextFile(target.path) + // 审计修复(检查—执行竞态):读取期间用户已输入或状态已变,则放弃这次重载, + // 交给下一轮轮询按冲突流程处理,绝不用磁盘内容整表覆盖新输入。 + const current = stateRef.current.tabs.find((t) => t.id === target.id) + if (!current) return + if (expectedContent !== undefined && current.content !== expectedContent) return + if (isDirty(current)) return + const isActive = stateRef.current.activeId === target.id + // 审计修复(卸载回写覆盖):活动标签重挂时,旧 EditorPane 的卸载回写会把旧正文 + // 写回状态、盖掉刚载入的内容——重载前先给该标签的这次最终回写上闸门。 + if (isActive) { + suppressFinalWriteRef.current.add(target.id) + window.setTimeout(() => suppressFinalWriteRef.current.delete(target.id), 1500) + } dispatch({ type: 'loadContent', id: target.id, content: file.content, encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom, }) const stat = await statFile(target.path) if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size }) - if (stateRef.current.activeId === target.id) setEditorEpoch((n) => n + 1) + if (isActive) setEditorEpoch((n) => n + 1) } catch (e) { setError(String(e)) } }, []) @@ -354,26 +410,31 @@ export default function App() { useEffect(() => { let cancelled = false void (async () => { - const recentFromDb = await store.loadRecent() - if (!cancelled && recentFromDb) setRecent(recentFromDb.map((r) => r.path)) + // 偏好先于最近列表读回并置位 uiLoadedRef:[ui] 保存 effect 在此之前不得写库, + // 否则挂载时的初始默认值会先落库、把用户设置覆盖掉(启动竞态)。 const uiJson = await store.loadUiPref() if (!cancelled && uiJson) { try { setUi((prev) => ({ ...prev, ...JSON.parse(uiJson) })) } catch { /* 偏好损坏时用默认值 */ } } + uiLoadedRef.current = true + const recentFromDb = await store.loadRecent() + if (!cancelled && recentFromDb) setRecent(recentFromDb.map((r) => r.path)) // 启动恢复:先读磁盘现状再划分(与磁盘一致的记录静默清理) const records = await store.listRecovery() - if (cancelled || records.length === 0) return - const disk = new Map() - for (const record of records) { - if (!record.path || disk.has(record.path)) continue - try { - disk.set(record.path, (await readTextFile(record.path)).content) - } catch { disk.set(record.path, null) } + if (!cancelled && records.length > 0) { + const disk = new Map() + for (const record of records) { + if (!record.path || disk.has(record.path)) continue + try { + disk.set(record.path, (await readTextFile(record.path)).content) + } catch { disk.set(record.path, null) } + } + const plan = planRecovery(records, (path) => disk.get(path) ?? null) + for (const key of plan.purgeKeys) void store.deleteRecovery(key) + if (plan.offer.length > 0) setRecoveryOffer(plan.offer) } - const plan = planRecovery(records, (path) => disk.get(path) ?? null) - for (const key of plan.purgeKeys) void store.deleteRecovery(key) - if (!cancelled && plan.offer.length > 0) setRecoveryOffer(plan.offer) - // 多窗口:新窗口通过 ?file= 参数打开指定文档(阶段 6) + // 多窗口:新窗口通过 ?file= 参数打开指定文档(阶段 6)。 + // 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。 const startupFile = new URLSearchParams(window.location.search).get('file') if (!cancelled && startupFile) void openPath(startupFile) })() @@ -391,6 +452,15 @@ export default function App() { for (const [id, prev] of lastTabsRef.current) { if (!current.has(id)) { baselinesRef.current.delete(id) + for (const key of [...conflictSeenRef.current]) { + if (key.startsWith(`${id}:`)) conflictSeenRef.current.delete(key) + } + setDocOverrides((prevMap) => { + if (!(id in prevMap)) return prevMap + const next = { ...prevMap } + delete next[id] + return next + }) const key = recoveryKey({ id, path: prev.path }) if (recoveryWrittenRef.current.delete(key)) void store.deleteRecovery(key) } @@ -425,7 +495,8 @@ export default function App() { if (!ui.autosave) return const timer = window.setInterval(() => { for (const t of stateRef.current.tabs) { - if (t.path && isDirty(t)) void saveTabRef.current(t, false) + // 冲突弹窗正等用户裁决的标签不自动保存,否则会抢先覆盖外部修改 + if (t.path && isDirty(t) && conflictRef.current !== t.id) void saveTabRef.current(t, false) } }, Math.max(10, ui.autosaveSeconds) * 1000) return () => window.clearInterval(timer) @@ -449,7 +520,7 @@ export default function App() { } if (stat.mtimeMs === base.mtimeMs && stat.size === base.size) continue if (!isDirty(t)) { - await reloadTabFromDisk(t) + await reloadTabFromDisk(t, t.content) flash(`《${t.title}》已被外部修改,已自动重新加载`) } else if (conflictRef.current !== t.id && !conflictSeenRef.current.has(`${t.id}:${stat.mtimeMs}`)) { conflictSeenRef.current.add(`${t.id}:${stat.mtimeMs}`) @@ -467,17 +538,19 @@ export default function App() { let unlisten: (() => void) | undefined void (async () => { const { getCurrentWindow } = await import('@tauri-apps/api/window') - unlisten = await getCurrentWindow().onCloseRequested((event) => { + const win = getCurrentWindow() + unlisten = await win.onCloseRequested(async (event) => { + // 审计修复:原实现恢复写入是 fire-and-forget,只有未命名草稿时直接放行关窗, + // IPC 未落盘进程已退出、草稿照样丢。现先拦下关闭,等恢复记录全部写完再决定。 + event.preventDefault() const dirty = stateRef.current.tabs.filter(isDirty) - // 先把所有草稿刷进恢复记录,保证任何退出路径都不丢内容 - for (const t of dirty) { - void store.saveRecovery({ - docKey: recoveryKey(t), path: t.path, title: t.title, content: t.content, updatedAt: Date.now(), - }) - } + await Promise.all(dirty.map((t) => store.saveRecovery({ + docKey: recoveryKey(t), path: t.path, title: t.title, content: t.content, updatedAt: Date.now(), + }))) if (dirty.some((t) => t.path)) { - event.preventDefault() setQuitOpen(true) + } else { + await win.destroy() } }) })() @@ -547,7 +620,7 @@ export default function App() { setConfirmSpec(null) void (async () => { try { - await deleteToTrash(entry.path) + const dest = await deleteToTrash(entry.path) for (const t of stateRef.current.tabs) { if (t.path && (t.path === entry.path || t.path.startsWith(`${entry.path}/`) || t.path.startsWith(`${entry.path}\\`))) { dispatch({ type: 'close', id: t.id }) @@ -557,7 +630,9 @@ export default function App() { } store.removeRecent(entry.path) setTreeRefresh((n) => n + 1) - flash(`已删除 ${entry.name}`) + flash(dest === 'permanent' + ? `已永久删除 ${entry.name}(回收站不可用,未进回收站)` + : `已删除 ${entry.name}`) } catch (e) { setError(String(e)) } })() }, @@ -593,6 +668,7 @@ export default function App() { useEffect(() => { const onKey = (e: KeyboardEvent) => { if (isImeComposing(e)) return // IME 组合期间全局快捷键一律不触发(阶段 2 守卫) + if (anyModalRef.current) return // 任一弹窗打开时让行,避免在弹窗背后触发编辑器动作 if (e.key === 'Escape' && findUiRef.current.open) { e.preventDefault(); closeFind(); return } if (!e.ctrlKey && !e.metaKey && !e.altKey) return const combo = comboFromEvent(e) @@ -654,8 +730,7 @@ export default function App() { if (isTauri() && current?.path) { const ext = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg') const named = file.name && file.name !== 'image.png' ? file.name : `image-${Date.now()}.${ext}` - const bytes = Array.from(new Uint8Array(await file.arrayBuffer())) - return saveBytesToAssets(current.path, named, bytes) + return saveBytesToAssets(current.path, named, await fileToBase64(file)) } return new Promise((resolve) => { const reader = new FileReader() @@ -697,8 +772,10 @@ export default function App() { const dir = docPath.replace(/[\\/][^\\/]*$/, '') return async (src: string): Promise => { try { - const isAbsolute = /^[a-zA-Z]:[\\/]/.test(src) || src.startsWith('/') || src.startsWith('\\') - const raw = isAbsolute ? src : `${dir}/${src}` + let decoded = src + try { decoded = decodeURIComponent(src) } catch { /* 非法转义时用原文 */ } + const isAbsolute = /^[a-zA-Z]:[\\/]/.test(decoded) || decoded.startsWith('/') || decoded.startsWith('\\') + const raw = isAbsolute ? decoded : `${dir}/${decoded}` const drive = raw.match(/^[a-zA-Z]:/)?.[0] ?? '' const segments: string[] = [] for (const part of raw.slice(drive.length).replace(/\\/g, '/').split('/')) { @@ -706,7 +783,8 @@ export default function App() { else if (part !== '.' && part !== '') segments.push(part) } const joined = drive + '/' + segments.join('/') - const ext = (src.split('.').pop() ?? '').toLowerCase() + const cleanSrc = src.split(/[?#]/)[0] + const ext = (cleanSrc.split('.').pop() ?? '').toLowerCase() const mime = ext === 'png' ? 'image/png' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : ext === 'svg' ? 'image/svg+xml' : 'application/octet-stream' const base64 = await readFileBase64(joined) @@ -764,9 +842,21 @@ export default function App() { const printDoc = useCallback(() => { if (!tab) return - if (sourceMode) toggleSourceMode() flash('在打印对话框中可选择「另存为 PDF」导出 PDF') - window.setTimeout(() => window.print(), 60) + if (!sourceMode) { + window.setTimeout(() => window.print(), 60) + return + } + // 审计修复:原先固定等 60ms 就打印,源码切渲染是异步重建,大文档必然赛跑。 + // 改为等渲染编辑器就绪(最多 3 秒兜底)后再打印。 + toggleSourceMode() + const started = Date.now() + const timer = window.setInterval(() => { + if (editorRef.current || Date.now() - started > 3000) { + window.clearInterval(timer) + window.requestAnimationFrame(() => window.print()) + } + }, 50) }, [tab, sourceMode, toggleSourceMode, flash]) const printRef = useRef(printDoc) printRef.current = printDoc @@ -792,7 +882,23 @@ export default function App() { } catch (e) { setError(String(e)) } }, [tauriMode, flash]) - const outline = useMemo(() => (tab ? extractOutline(tab.content) : []), [tab]) + anyModalRef.current = Boolean( + modal || appearanceOpen || exportOpen || pendingClose || conflict + || promptSpec || confirmSpec || quitOpen || recoveryOffer.length > 0, + ) + + // 大纲与状态栏计数只随正文变化重算(原先每次光标移动/击键都对全文重算); + // 大纲再经 useDeferredValue 让高频输入时延后提取,不堵击键路径。 + const deferredContent = useDeferredValue(tab?.content ?? '') + const outline = useMemo( + () => (tab ? extractOutline(deferredContent) : []), + // eslint-disable-next-line react-hooks/exhaustive-deps + [tab?.id, deferredContent], + ) + const statusCounts = useMemo(() => { + if (!tab) return { words: 0, chars: 0, lines: 0 } + return { words: countWords(tab.content), chars: countChars(tab.content), lines: tab.content.split('\n').length } + }, [tab?.id, tab?.content]) const menus: MenuDef[] = useMemo(() => [ { @@ -927,8 +1033,8 @@ export default function App() { onModeChange={setSidebarMode} workspaceRoot={workspaceRoot} activePath={tab?.path ?? null} - onOpenFile={(path) => void openPath(path)} - onOpenFolder={() => void openFolder()} + onOpenFile={handleSidebarOpenFile} + onOpenFolder={handleSidebarOpenFolder} onCreateFile={tauriMode && workspaceRoot ? createFileInWorkspace : undefined} onRenameEntry={tauriMode ? renameEntry : undefined} onDeleteEntry={tauriMode ? deleteEntry : undefined} @@ -953,11 +1059,13 @@ export default function App() { ) : sourceMode ? ( 正在加载源码编辑器…}> dispatch({ type: 'updateContent', id: tab.id, content: text })} onReady={(api) => { sourceApiRef.current = api }} /> @@ -968,10 +1076,15 @@ export default function App() { tabId={tab.id} initialMarkdown={tab.content} onMarkdown={(id, md) => dispatch({ type: 'updateContent', id, content: md })} + onFinalMarkdown={(id, md) => { + if (suppressFinalWriteRef.current.delete(id)) return // 外部重载的卸载回写,丢弃 + dispatch({ type: 'updateContent', id, content: md }) + }} onCursor={setCursor} focusMode={ui.focusMode} typewriter={ui.typewriter} onImageFile={handleImageFile} + onImageError={(message) => setError(`图片插入失败:${message}`)} onReady={(api) => { paneFindRef.current = api.editor ? api : null editorRef.current = api.editor @@ -993,12 +1106,12 @@ export default function App() { count={findUi.count} current={findUi.current} error={findUi.error} - onQueryChange={(value) => { setFindUi((f) => ({ ...f, query: value })); runFind(value, findUiRef.current.opts) }} + onQueryChange={(value) => { setFindUi((f) => ({ ...f, query: value })); scheduleFind(value, findUiRef.current.opts) }} onReplacementChange={(value) => setFindUi((f) => ({ ...f, replacement: value }))} onToggleReplace={() => setFindUi((f) => ({ ...f, replace: !f.replace }))} - onOptsChange={(opts) => { setFindUi((f) => ({ ...f, opts })); runFind(findUiRef.current.query, opts) }} - onNext={() => { const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current + 1); setFindUi((f) => ({ ...f, current: cur })) } }} - onPrev={() => { const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current - 1); setFindUi((f) => ({ ...f, current: cur })) } }} + onOptsChange={(opts) => { setFindUi((f) => ({ ...f, opts })); scheduleFind(findUiRef.current.query, opts) }} + onNext={() => { if (flushPendingFind()) return; const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current + 1); setFindUi((f) => ({ ...f, current: cur })) } }} + onPrev={() => { if (flushPendingFind()) return; const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current - 1); setFindUi((f) => ({ ...f, current: cur })) } }} onReplace={() => { const api = activeFindApi() if (!api || !api.replaceCurrent(findUiRef.current.replacement)) return @@ -1019,9 +1132,9 @@ export default function App() { void + /** 卸载前的最终回写(与实时 onMarkdown 分开,供外部重载时拦截旧内容回写) */ + onFinalMarkdown?: (tabId: string, markdown: string) => void onCursor: (cursor: CursorInfo) => void onReady: (api: EditorPaneApi) => void onImageFile?: (file: File) => Promise + onImageError?: (message: string) => void focusMode: boolean typewriter: boolean } @@ -44,7 +47,7 @@ const EMPTY_API: EditorPaneApi = { } /** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */ -export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCursor, onReady, onImageFile, focusMode, typewriter }: Props) { +export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError, focusMode, typewriter }: Props) { const wrapRef = useRef(null) const findRef = useRef<{ matches: MatchRange[]; current: number; query: string; opts: FindOptions | null }>({ matches: [], current: -1, query: '', opts: null, @@ -65,8 +68,8 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso }, []) const hostRef = useRef(null) const initialRef = useRef(initialMarkdown) - const callbacksRef = useRef({ onMarkdown, onCursor, onReady, onImageFile }) - callbacksRef.current = { onMarkdown, onCursor, onReady, onImageFile } + const callbacksRef = useRef({ onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError }) + callbacksRef.current = { onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError } useEffect(() => { if (!hostRef.current) return @@ -90,13 +93,23 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso }) } const editor = createMarkdownEditor(hostRef.current, initialRef.current, { - onMarkdown: (md) => callbacksRef.current.onMarkdown(tabId, md), + onMarkdown: (md) => { + // 文档变化后已算出的查找命中立即过期:下一次查找操作会先按新内容重算, + // 杜绝替换打在偏移后的错误位置上(审计高危)。 + if (findRef.current.matches.length > 0) { + findRef.current = { matches: [], current: -1, query: findRef.current.query, opts: findRef.current.opts } + } + callbacksRef.current.onMarkdown(tabId, md) + }, onCursor: (c) => { callbacksRef.current.onCursor(c) if (modesRef.current.typewriter) centerCursorLine() }, onTableContext: (inTable, el) => { tableElRef.current = el; if (inTable) updateTableBar(); else setTableBar(null) }, - }, { onImageFile: (file) => callbacksRef.current.onImageFile ? callbacksRef.current.onImageFile(file) : Promise.resolve(URL.createObjectURL(file)) }) + }, { + onImageFile: (file) => callbacksRef.current.onImageFile ? callbacksRef.current.onImageFile(file) : Promise.resolve(URL.createObjectURL(file)), + onImageError: (message) => callbacksRef.current.onImageError?.(message), + }) editorObjRef.current = editor const jumpToHeading = (text: string) => { const els = hostRef.current?.querySelectorAll('h1,h2,h3,h4,h5,h6') ?? [] @@ -148,7 +161,11 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso } } const gotoMatch = (index: number): number => { - const found = findRef.current + let found = findRef.current + if (found.matches.length === 0 && found.query && found.opts) { + findAll(found.query, found.opts) + found = findRef.current + } if (found.matches.length === 0) return -1 const wrapped = ((index % found.matches.length) + found.matches.length) % found.matches.length found.current = wrapped @@ -161,7 +178,11 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso return wrapped } const replaceCurrent = (replacement: string): boolean => { - const found = findRef.current + let found = findRef.current + if (found.matches.length === 0 && found.query && found.opts) { + findAll(found.query, found.opts) + found = findRef.current + } if (found.current < 0 || !found.opts) return false const m = found.matches[found.current] if (!m) return false @@ -170,13 +191,16 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso view.dispatch(view.state.tr.insertText(replacement, m.from, m.to)) view.focus() }) - const previous = found.current - const count = findAll(found.query, found.opts) - if (count > 0) gotoMatch(Math.min(previous, count - 1)) + // 命中刷新与定位统一由 App 层在替换后做(原先两层各算一遍、序号还对不齐) + findRef.current = { matches: [], current: -1, query: found.query, opts: found.opts } return true } const replaceAll = (replacement: string): number => { - const found = findRef.current + let found = findRef.current + if (found.matches.length === 0 && found.query && found.opts) { + findAll(found.query, found.opts) + found = findRef.current + } if (found.matches.length === 0) return 0 const total = found.matches.length const reversed = [...found.matches].reverse() @@ -210,7 +234,9 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso editorObjRef.current = null setTableBar(null) try { - callbacksRef.current.onMarkdown(tabId, getMarkdown(editor)) + const finalMarkdown = getMarkdown(editor) + if (callbacksRef.current.onFinalMarkdown) callbacksRef.current.onFinalMarkdown(tabId, finalMarkdown) + else callbacksRef.current.onMarkdown(tabId, finalMarkdown) } catch { /* 编辑器未就绪时跳过回写 */ } callbacksRef.current.onReady(EMPTY_API) editor.destroy().catch(() => undefined) diff --git a/src/components/Modals.tsx b/src/components/Modals.tsx index cd35711..8bda8e1 100644 --- a/src/components/Modals.tsx +++ b/src/components/Modals.tsx @@ -46,6 +46,7 @@ export function ShortcutsModal({ overrides, onChange, onClose }: { onClose: () => void }) { const [recordingId, setRecordingId] = useState(null) + const [recordHint, setRecordHint] = useState(null) const [conflict, setConflict] = useState<{ id: string; combo: string; ownerLabel: string; ownerId: string } | null>(null) const combos = effectiveCombos(overrides) @@ -57,6 +58,12 @@ export function ShortcutsModal({ overrides, onChange, onClose }: { if (e.key === 'Escape') { setRecordingId(null); return } const combo = comboFromEvent(e) if (!combo) return + if (!combo.includes('ctrl') && !combo.includes('alt') && !combo.includes('meta')) { + // 全局分发只处理带修饰键的组合,纯按键绑定永不触发,录入时直接拦下说明 + setRecordHint('快捷键需要带 Ctrl 或 Alt 修饰键,纯按键不会被应用触发。') + return + } + setRecordHint(null) const owner = comboOwner(combo, overrides, recordingId) setRecordingId(null) if (owner) { @@ -93,6 +100,7 @@ export function ShortcutsModal({ overrides, onChange, onClose }: { ) })} + {recordHint &&

{recordHint}

} {conflict && (

{prettyCombo(conflict.combo)} 已被「{conflict.ownerLabel}」占用。 diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 226a365..898c3f5 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { memo, useCallback, useEffect, useState } from 'react' import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform' import type { OutlineItem } from '../utils/text' import { baseName } from '../utils/text' @@ -20,7 +20,7 @@ interface Props { tauriMode: boolean } -export default function Sidebar(p: Props) { +function Sidebar(p: Props) { return (