diff --git a/docs/29-全项目审计.md b/docs/29-全项目审计.md new file mode 100644 index 0000000..6fbb10e --- /dev/null +++ b/docs/29-全项目审计.md @@ -0,0 +1,185 @@ +# docs/29 — 全项目审计(性能 + 逻辑,三线并行 + App 编排层自审) + +时间:2026-11-14 晚。范围:src/editor/ 全部、EditorPane/SourceEditor、 +src/services/、src/components/、src/hooks/、src/utils/、App.tsx 编排层、 +src-tauri/src/ 全部(1289 行)。先说结论:发现 **1 个高危数据问题** +(已修)、中低问题一批(已修 20 余处),其余记录待办;三线均未再 +发现新的高危。搜索结果上限(每文件 20 条/共 300 文件)顺带核实存在。 + +## 已修复 + +### 高危 + +- **H1 分片载入未完成时窗格卸载,部分文档被静默回写并截断**: + EditorPane 卸载 cleanup 无条件回写最终序列化,而实时回写路径有 + muted(载入中)守卫、唯独卸载路径漏了。载入中被保活淘汰(打开 + 第 7 个标签)或关标签时,回写的是已追加的部分文本; + syncEditorContent 对干净标签会把 savedContent 一起对齐到部分 + 文本——不标脏、无提示,下一次保存即把截断内容写入磁盘。修复: + cleanup 在 muted 时跳过回写(状态里本就是磁盘完整原文);载入 + 失败路径已主动解除 muted 并如实回写,不受守卫影响。同处顺带修: + `editor.create()` 完成回调无取消守卫,卸载后仍会把已销毁编辑器 + 的 API 注册回 App → 补 cancelled 检查并销毁。回归测试见 + tests/editor-pane-unload.test.tsx(对照组);中途卸载的交错在 + jsdom 里无法自动化复现(React act 会抽干异步追加,三种卸载方式 + 实测均如此),该守卫靠代码复核保证:muted 仅在追加完成/失败的 + 收尾回调里解除。 + +### 性能(中) + +- **P1 Sidebar 的 memo 被三个不稳定 props 击穿,击键时整棵文件树 + 重渲染**:gitSync 内联对象、onCollapseSidebar 内联回调、随正文 + 变化重建的 outline 数组。三处稳定化:gitSyncUi 用 useMemo、 + collapseSidebar 用 useCallback、非大纲模式传模块级空数组常量。 + 两条审计线独立撞到同一问题,属实。 +- **P2 光标行列每次选区变化从文档头全量遍历**:Milkdown + selectionUpdated 每事务同步分发,600K 文档光标在文末时单次上万 + 节点。改为 rAF 合并:闭包缓存最新一次计算、逐帧最多算一次 + (行列与表格上下文同帧合并)。 +- **P3 图片显示解析无缓存**:同一图片出现 N 次/切标签重挂/epoch + 重建都重复读盘转 base64。App 层加(文档路径|src)→Promise 缓存, + 上限 60 条 FIFO。 +- **P4 表格工具栏滚动监听无节流**:每次 scroll 都量 DOM + setState。 + 改 rAF 合并 + 位置未变不 setState。打字机模式逐键重启平滑滚动 + 改 behavior:'auto' 防抖动。 +- **P5 分片切分的行偏移 O(切点数×行数)**:改行偏移前缀表 O(1) 查表。 +- **P6 导出 embed 图片串行逐张解析**:改 Promise.all 并发; + exportHtml 高亮器初始化失败后 rejected Promise 被永久缓存、 + 整会话高亮失效 → 失败时清缓存,下次导出重试。 +- **P7(Rust)list_dir 排序比较器内每次比较分配两个小写字符串**: + 改 sort_by_cached_key 预计算键。 + +### 逻辑/健壮性(中) + +- **L1(Rust)资源复制/保存命令是同步命令且无大小上限**: + copy_to_assets 对任意文件整读(冻 UI 线程 + 内存整载), + save_bytes_to_assets 同样无上限。两命令改 async + spawn_blocking, + 统一 32MB 上限(与 read_file_base64 对齐)。写入侧补 64MB 上限, + 与读侧对称。 +- **L2(Rust)工作区搜索跟随 junction/符号链接逃出工作区**:去重 + 只防环,指向外部的 junction 是「新目录」会被整扫、耗尽配额并 + 越界读文件。scan_dir 加 canonical 包含校验,越界即跳过。顺带: + 元数据取不到时由 fail-open 改为跳过;隐藏文件与隐藏目录同口径 + 跳过。 +- **L3(Rust)recovery 草稿表无任何上限**:单条可达数十 MB、条数 + 无限增长、启动时全量正文过 IPC。recovery_save 加单条 10MB 上限 + (超限静默跳过,草稿是副本、.md 是正本,不报错刷屏)+ 只保留 + 最近 20 条;workspaces 表同样只留最近 20 条。 +- **L4(Rust)启动期数据库失败直接 panic 且循环复现**:DB 损坏/ + 磁盘异常时用户连界面都进不去。改为:打开/建表失败时把坏库 + (含 -wal/-shm)改名 `.broken-<时间戳>` 备份后新建空库继续 + 启动;正文在磁盘 .md 中不受影响。连接另设 WAL + busy_timeout + 3000(多开写操作排队而非直接 SQLITE_BUSY)。 +- **L5 路径比较未做大小写归一(Windows 语义)**:pathsEqual/ + isPathInside 大小写敏感,盘符/目录大小写不一致时 DnD 守卫漏判、 + 活动文件高亮消失、恢复键/Git 设置键/最近列表键同根裂键。新增 + pathCompareKey(Windows 风格路径折叠小写,POSIX 保持敏感), + pathsEqual/isPathInside/文件树高亮/recoveryKey/Git 设置键与 + 租约键/legacy recent 去重共用。注意:升级后旧恢复记录键与新键 + 不一致,旧草稿可能多提示一次、随后被 20 条修剪清掉,无数据 + 损失。顺带修 parentDirOf('C:/a.md') 返回盘符相对路径 'C:' → + 返回 'C:/'。 +- **L6 文件树与标签页右键菜单无视口夹取**:靠边弹出溢出屏幕、尾 + 部条目够不到(编辑区菜单已有夹取,这两处漏了)。抽共享 + clampMenuPosition(utils/menuPos.ts),三处菜单共用。 +- **L7 逻辑小项打包**:①工作区搜索无乱序保护,慢的旧请求会覆盖 + 新结果 → 序号守卫;②换工作区时文件树短暂显示旧目录(expanded + 按绝对路径残留)→ FileTree 按 root 加 key 重挂;③TabBar 拖拽 + dragover 以浏览器节律重复 dispatch 同一移动 → (dragId:下标) + 去重;④Dropdown 给 useDismiss 传内联 onClose,打开期间每次渲染 + 重注册 5 个全局监听 → useCallback 稳定;⑤Git 租约 check-then- + write 双窗口同启可同时取得 → 写入后回读确认持有者;⑥ + windowClaims 注册表坏条目(keys 非数组)可让启动扫描抛错 → + prune 时整条丢弃坏条目;⑦store 的 UI 偏好 Web 模式只写不读 → + loadUiPref Web 分支读 localStorage;⑧多行粘贴尾随换行多建一个 + 空段落 → split 后丢弃末尾空项。 + +### 本轮新功能面(Git 同步)复审补强 + +- validate_branch 补:尾随 `.`、开头 `/`、连续 `//`、`@{`; + validate_remote_url 补 ext::/fd:: 可执行 transport 黑名单 + (fetch 时会执行命令,用户自填场景威胁弱,属防患)。两函数已 + 抽出在沙箱实跑断言(合法/非法各一组,全过)。 +- git_init_repo:.gitignore 播种提前到绑远程之前,部分失败不再 + 留「有仓库无忽略规则」的半成品;已有 origin 改 set-url 不再 + 报错。默认 .gitignore 补 `.*.tmp-*`:原子写临时文件在崩溃 + 残留时原本会被 `git add -A` 提交推送。 +- git_sync_now 游离 HEAD 时不再猜分支名 "main"(会把成果推错 + 地方),直接报错提示先切回分支。 + +### 一致性 + +- FindBar 与表格工具栏的原生 title 提示迁到自绘 Tooltip(跟随 + 主题,与大王既定偏好对齐;路径类完整信息 title 保留);FindBar + 开关按钮补 aria-pressed。 +- GitSyncModal 补 Esc/点背幕关闭(忙碌时禁用),与 Stage5 弹窗 + 范式对齐。 +- extractOutline 围栏判定改按字符+长度跟踪:四反引号围栏内的 + 三反引号行不再误翻转状态(与分片切分的围栏跟踪口径一致)。 + +### 已实测澄清(非问题) + +- **分片切分会把「列表内缩进围栏」误判为顶层切点**(审计推导项): + 已构造 >60K 含列表内嵌围栏的文档实测——旧逻辑下分片序列化与 + 整篇**确实不等**,属实。修复:围栏作切点时只认顶格;回归测试 + 已加入 tests/chunk-load.test.ts(关掉修复即失败,判别有效)。 +- **替换文本含换行符**:实测 replaceAll('line1\nline2') 序列化为 + 段落内软换行,不拆段、不丢内容。语义与「粘贴多行建多段」不 + 同,属可接受差异,维持现状。 + +## 记录待办(本轮未改,附理由) + +- D1(中)非 UTF-8 文件首次保存被静默转码为 UTF-8(GBK/UTF-16 + 文档存一次即变 UTF-8):编辑器内部统一 UTF-8 是既定设计,但 + 落盘编码变更应至少首次提示一次。需前端交互拍板,未擅自加。 +- D2(中)专注模式跨块时全量重建装饰集:大文档每次回车重建与 + 顶层块数等量的装饰对象。改增量方案需重构插件,单独立项。 +- D3(低)源码模式每次击键物化全文回写 App(无渲染模式的 200ms + 防抖等价物):加防抖要先理清源码模式下保存读的是哪份状态, + 贸然加会存旧文本,单独立项处理。 +- D4(低)查找索引每次 findAll 全量重建(600K 约 5MB 瞬时): + 可按 doc 引用缓存,未改。 +- D5(低)专注模式开关是模块级全局变量,多窗格/隐藏解析编辑器 + 共享:目前靠各窗格写同一 App 值维持一致,「按窗格模式」时 + 需改为注入式。 +- D6(低)文件树刷新时全部展开层级同时重拉(IPC burst)、拖拽 + 悬停整树重渲染:TreeLevel 未 memo 化,属结构性优化,单独立项。 +- D7(低,Rust)git_status 串行拉起 6–7 个 git 子进程(每次约 + 百毫秒级):可改 `git status --porcelain=v2 --branch` 一次拿 + 全,解析重写风险大于当前收益,未改。 +- D8(低,Rust)rename_path:仅大小写不同的改名被 exists 预检 + 误判冲突;跨卷移动直接报错而非复制+删除。前端入口目前多在 + 同盘同树内,影响有限,未改。 +- D9(低,Rust)schema 无版本号/迁移机制:今后给表加列时 + CREATE TABLE IF NOT EXISTS 不会迁移老库(配合 L4 的坏库重建, + 最坏情况是偏好重置、正文无损)。加列前须先落 user_version。 +- 沿袭:docs/28 的 B1–B4(空仓变基、git 子进程无超时、设置与 + 同步交错、SSH 主机密钥)与 C1(CSP=null)状态未变。 + +## 复核通过(无问题,不展开) + +- Rust:原子写主链(同目录临时文件 + sync_all + rename,失败 + 清理)、编码探测顺序与 BOM 处理、sanitize_file_name 对 + Windows 非法字符/保留名的处理、SQL 全部参数化、搜索四重 + 上限、git 同步链错误路径(变基失败先 abort 再报错)。 +- 前端:useDismiss/MenuBar 监听清理完整;windowClaims 心跳 + 10s/过期 45s 与 Git 租约 10s 续租/25s TTL 配比合理; + exportHtml 代码块替换的降序 splice 与路径重写逻辑无误; + SourceEditor replaceAll 倒序 changes 数组经 CM6 实测正确。 +- App 编排层:外部修改检测基线在打开/保存/重载各点维护完整, + 不会误报自身写入;本轮另给 4 秒轮询加了防重叠(上轮未完跳过 + 本轮)。自动保存与手动保存并发同标签经原子写 + 幂等成立, + 安全。菜单定义随击键重建属小开销,可接受。 + +## 验证边界 + +- vitest 122+2(新增:EditorPane 卸载对照、列表内嵌围栏等价、 + 路径/大纲新例、recoveryKey 新契约),tsc 与生产构建通过。 +- Rust:沙箱仍无法跑完整 Tauri 构建(缺 GTK/WebKit 系统库); + 四个改动文件已过 rustfmt 语法解析,validate_branch/ + validate_remote_url 已抽出实跑断言全过;命令数与注册数对账 + 28=28 不变(两个命令改 async 不影响注册)。async 化、WAL、 + 坏库重建、搜索包含校验的实效待 Windows 构建与真机确认。 +- H1 的守卫交错无法在 jsdom 自动化复现(见上),真机侧可用 + 「打开 >60K 文档后立刻连开多个标签挤掉其窗格,再回看该文档 + 是否完整」一验。 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3acdc51..7126acc 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -88,6 +88,9 @@ pub async fn write_text_file(path: String, content: String, keep_bom: bool) -> R } fn write_text_file_sync(path: String, content: String, keep_bom: bool) -> Result<(), String> { + if content.len() > 64 * 1024 * 1024 { + return Err("文档过大(超过 64MB),无法保存".to_string()); + } let target = PathBuf::from(&path); let dir = target .parent() @@ -129,6 +132,9 @@ fn write_text_file_sync(path: String, content: String, keep_bom: bool) -> Result } /// 资源目录:<文档目录>/<文档主名>.assets/,不存在则创建。返回目录路径。 +/// 资源文件大小上限(与 read_file_base64 对齐)。 +const MAX_ASSET_BYTES: u64 = 32 * 1024 * 1024; + fn assets_dir(doc_path: &str) -> Result { let doc = PathBuf::from(doc_path); let dir = doc @@ -248,34 +254,51 @@ fn write_new_file(dir: &Path, file_name: &str, data: &[u8]) -> Result 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 dir = assets_dir(&doc_path)?; - let data = fs::read(&src).map_err(|e| format!("读取源文件失败:{e}"))?; - let target = write_new_file(&dir, &name, &data)?; - Ok(relative_assets_path(&doc_path, &target)) +pub async fn copy_to_assets(source: String, doc_path: String) -> Result { + tokio::task::spawn_blocking(move || { + 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 dir = assets_dir(&doc_path)?; + let meta = fs::metadata(&src).map_err(|e| format!("读取源文件失败:{e}"))?; + if meta.len() > MAX_ASSET_BYTES { + return Err("文件过大(超过 32MB),无法作为资源复制".to_string()); + } + let data = fs::read(&src).map_err(|e| format!("读取源文件失败:{e}"))?; + let target = write_new_file(&dir, &name, &data)?; + Ok(relative_assets_path(&doc_path, &target)) + }) + .await + .map_err(|e| e.to_string())? } /// 把 base64 内容(如粘贴的图片)写入文档资源目录,返回相对路径。 /// 审计修复:原先用 Vec 传参,Tauri 按 JSON 数字数组序列化,5MB 图要传 500 万个数字; /// 改传 base64 字符串,体积与解析开销都降一个量级。 #[tauri::command] -pub fn save_bytes_to_assets( +pub async fn save_bytes_to_assets( doc_path: String, file_name: String, base64: String, ) -> Result { - let name = sanitize_file_name(&file_name); - let bytes = base64_decode(&base64)?; - let dir = assets_dir(&doc_path)?; - let target = write_new_file(&dir, &name, &bytes)?; - Ok(relative_assets_path(&doc_path, &target)) + tokio::task::spawn_blocking(move || { + let name = sanitize_file_name(&file_name); + let bytes = base64_decode(&base64)?; + if bytes.len() as u64 > MAX_ASSET_BYTES { + return Err("内容过大(超过 32MB),无法保存为资源文件".to_string()); + } + let dir = assets_dir(&doc_path)?; + let target = write_new_file(&dir, &name, &bytes)?; + Ok(relative_assets_path(&doc_path, &target)) + }) + .await + .map_err(|e| e.to_string())? } fn base64_decode(input: &str) -> Result, String> { @@ -361,11 +384,8 @@ pub fn list_dir(path: String) -> Result, String> { is_dir, }); } - entries.sort_by(|a, b| { - b.is_dir - .cmp(&a.is_dir) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - }); + // 排序键预计算一次(原比较器每次比较都分配两个小写副本,大目录下浪费明显) + entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase())); Ok(entries) } @@ -659,9 +679,13 @@ fn validate_branch(branch: &str) -> Result { let bad = b.len() > 100 || b.starts_with('-') || b.starts_with('.') + || b.starts_with('/') || b.ends_with('/') + || b.ends_with('.') || b.ends_with(".lock") || b.contains("..") + || b.contains("//") + || b.contains("@{") || b.chars().any(|c| c.is_whitespace() || c.is_control() || "~^:?*[\\]".contains(c)); if bad { return Err(format!("分支名不合法:{b}")); @@ -675,7 +699,14 @@ fn validate_remote_url(url: &str) -> Result { if u.is_empty() { return Ok(String::new()); } - if u.len() > 2000 || u.starts_with('-') || u.chars().any(|c| c.is_whitespace() || c.is_control()) { + // ext::/fd:: 是可执行 transport(fetch 时会跑命令),一律拒绝 + let lower = u.to_lowercase(); + if u.len() > 2000 + || u.starts_with('-') + || lower.starts_with("ext::") + || lower.starts_with("fd::") + || u.chars().any(|c| c.is_whitespace() || c.is_control()) + { return Err("远程仓库地址不合法".to_string()); } Ok(u.to_string()) @@ -773,18 +804,27 @@ pub async fn git_init_repo(root: String, remote_url: String, branch: String) -> let b = validate_branch(&branch)?; let url = validate_remote_url(&remote_url)?; run_git(dir, &["init", "-b", &b])?; - if !url.is_empty() { - run_git(dir, &["remote", "add", "origin", &url])?; - } - // 同步会提交文件夹内全部未忽略文件:新建仓库时种一份保守的 - // .gitignore(系统垃圾、临时文件、.env),避免敏感/无用文件被推走 + // 同步会提交文件夹内全部未忽略文件:先种一份保守的 .gitignore + // (系统垃圾、临时文件、.env),避免敏感/无用文件被推走; + // .*.tmp-* 是编辑器原子写的临时文件,崩溃残留不应被同步提交。 + // 播种放在绑远程之前,任一步失败也不会留下「有仓库没忽略规则」的半成品。 let gitignore = dir.join(".gitignore"); if !gitignore.exists() { let _ = std::fs::write( &gitignore, - ".DS_Store\nThumbs.db\ndesktop.ini\n*.tmp\n~$*\n.env\n.env.*\n", + ".DS_Store\nThumbs.db\ndesktop.ini\n*.tmp\n.*.tmp-*\n~$*\n.env\n.env.*\n", ); } + if !url.is_empty() { + let has_origin = run_git(dir, &["remote"]) + .map(|r| r.lines().any(|l| l.trim() == "origin")) + .unwrap_or(false); + if has_origin { + run_git(dir, &["remote", "set-url", "origin", &url])?; + } else { + run_git(dir, &["remote", "add", "origin", &url])?; + } + } Ok(git_status_inner(&root)) }) .await @@ -838,7 +878,11 @@ pub async fn git_sync_now(root: String, message: String) -> Result b, + None => return Err("当前处于游离 HEAD 状态,请先切回分支再同步".to_string()), + }; let mut pulled = false; if has_remote { let remote_ref = format!("origin/{branch}"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 22aaab5..fc94a72 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,16 @@ use tauri::Manager; /// 应用数据库句柄(阶段 4):连接在启动时打开并建表,命令层经 State 访问。 pub struct AppDb(pub Mutex); +/// 打开数据库并建表:WAL 降低并发写冲突,busy_timeout 让多开实例的写操作排队而非直接报错。 +fn open_and_init(path: &std::path::Path) -> Result { + let conn = rusqlite::Connection::open(path).map_err(|e| e.to_string())?; + let _ = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get::<_, String>(0)); + conn.busy_timeout(std::time::Duration::from_millis(3000)) + .map_err(|e| e.to_string())?; + storage::init_schema(&conn)?; + Ok(conn) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -15,9 +25,29 @@ pub fn run() { .setup(|app| { let data_dir = app.path().app_data_dir().expect("app data dir"); std::fs::create_dir_all(&data_dir).expect("create app data dir"); - let conn = rusqlite::Connection::open(data_dir.join("new-markdown.db")) - .expect("open app database"); - storage::init_schema(&conn).expect("init app database schema"); + let db_path = data_dir.join("new-markdown.db"); + let conn = match open_and_init(&db_path) { + Ok(c) => c, + Err(first_err) => { + // 数据库损坏/磁盘异常时不再启动即崩:把坏库改名备份后新建空库 + // 继续启动。偏好/草稿会重置,但正文都在磁盘 .md 里,不受影响(docs/29) + eprintln!("app database unusable ({first_err}); backing it up and recreating"); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let _ = std::fs::rename(&db_path, data_dir.join(format!("new-markdown.db.broken-{ts}"))); + let _ = std::fs::rename( + data_dir.join("new-markdown.db-wal"), + data_dir.join(format!("new-markdown.db-wal.broken-{ts}")), + ); + let _ = std::fs::rename( + data_dir.join("new-markdown.db-shm"), + data_dir.join(format!("new-markdown.db-shm.broken-{ts}")), + ); + open_and_init(&db_path).expect("open app database") + } + }; app.manage(AppDb(Mutex::new(conn))); Ok(()) }) diff --git a/src-tauri/src/search.rs b/src-tauri/src/search.rs index 889b486..a62a0ab 100644 --- a/src-tauri/src/search.rs +++ b/src-tauri/src/search.rs @@ -70,6 +70,7 @@ fn scan_dir( depth: usize, needle: &str, case_sensitive: bool, + root_canonical: &Path, state: &mut ScanState, out: &mut Vec, ) { @@ -81,6 +82,11 @@ fn scan_dir( Ok(c) => c, Err(_) => return, }; + // 越界即跳过:junction/符号链接指向工作区外时,去重防环挡不住(对它来说是「新目录」), + // 会把扫描配额耗在外部文件系统上并越界读用户文件(docs/29) + if !canonical.starts_with(root_canonical) { + return; + } if !state.visited_dirs.insert(canonical) { return; } @@ -100,13 +106,20 @@ fn scan_dir( if name.starts_with('.') || name == "node_modules" { continue; } - scan_dir(&path, depth + 1, needle, case_sensitive, state, out); + scan_dir(&path, depth + 1, needle, case_sensitive, root_canonical, state, out); } else if is_searchable(&path) { + if name.starts_with('.') { + continue; + } state.scanned_files += 1; - if let Ok(meta) = path.metadata() { - if meta.len() > MAX_FILE_BYTES { - continue; + match path.metadata() { + Ok(meta) => { + if meta.len() > MAX_FILE_BYTES { + continue; + } } + // 元数据取不到时按跳过处理(原先是 fail-open,会整读任意大小文件) + Err(_) => continue, } let bytes = match fs::read(&path) { Ok(b) => b, @@ -147,6 +160,10 @@ pub fn search_workspace(root: &str, query: &str, case_sensitive: bool) -> Vec c, + Err(_) => return Vec::new(), + }; + scan_dir(Path::new(root), 0, &needle, case_sensitive, &root_canonical, &mut state, &mut out); out } diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index f824291..cc803b6 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -111,6 +111,12 @@ pub fn workspace_add(conn: &Connection, path: &str, opened_at: i64) -> Result<() params![path, opened_at], ) .map_err(err)?; + // 只留最近 20 条(与 recent 的修剪口径对齐,避免列表只增不减) + conn.execute( + "DELETE FROM workspaces WHERE path NOT IN (SELECT path FROM workspaces ORDER BY opened_at DESC LIMIT 20)", + [], + ) + .map_err(err)?; Ok(()) } @@ -167,6 +173,12 @@ pub fn recovery_list(conn: &Connection) -> Result, String> { Ok(out) } +/// 恢复记录单条上限:草稿是正文副本(.md 才是唯一正本),超限直接跳过、 +/// 不报错——否则自动保存每 6 秒都会撞一次错误提示。 +const RECOVERY_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; +/// 恢复记录最多保留最近更新的条数。 +const RECOVERY_KEEP: usize = 20; + pub fn recovery_save( conn: &Connection, doc_key: &str, @@ -175,6 +187,9 @@ pub fn recovery_save( content: &str, updated_at: i64, ) -> Result<(), String> { + if content.len() > RECOVERY_MAX_CONTENT_BYTES { + return Ok(()); + } conn.execute( "INSERT INTO recovery (doc_key, path, title, content, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(doc_key) DO UPDATE SET path = excluded.path, title = excluded.title, @@ -182,6 +197,11 @@ pub fn recovery_save( params![doc_key, path, title, content, updated_at], ) .map_err(err)?; + conn.execute( + "DELETE FROM recovery WHERE doc_key NOT IN (SELECT doc_key FROM recovery ORDER BY updated_at DESC LIMIT ?1)", + params![RECOVERY_KEEP as i64], + ) + .map_err(err)?; Ok(()) } diff --git a/src/App.tsx b/src/App.tsx index 7a074fb..8361dfe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -73,6 +73,12 @@ function toDiskContent(content: string, lineEnding: string): string { return lineEnding === 'CRLF' ? content.replace(/\n/g, '\r\n') : content } +/** 编辑区图片显示解析缓存:(文档路径|src) → data URL Promise,上限 60 条 FIFO。 */ +const imageDisplayCache = new Map>() + +/** 非大纲模式下传给 Sidebar 的稳定空大纲(保住 memo,避免正文变化连带整栏重渲染)。 */ +const EMPTY_OUTLINE: never[] = [] + export default function App() { const [state, dispatch] = useReducer(tabsReducer, undefined, (): TabsState => { const welcome = createTab({ title: '欢迎', content: WELCOME_MD }) @@ -734,9 +740,13 @@ export default function App() { // ——— 阶段 4:外部修改检测(4 秒轮询 stat) ——— const conflictRef = useRef(null) conflictRef.current = conflict?.tabId ?? null + const externalCheckBusyRef = useRef(false) useEffect(() => { const timer = window.setInterval(() => { + if (externalCheckBusyRef.current) return + externalCheckBusyRef.current = true void (async () => { + try { for (const t of stateRef.current.tabs) { if (!t.path) continue const base = baselinesRef.current.get(t.id) @@ -756,6 +766,9 @@ export default function App() { setConflict({ tabId: t.id }) } } + } finally { + externalCheckBusyRef.current = false + } })() }, 4000) return () => window.clearInterval(timer) @@ -1184,6 +1197,10 @@ export default function App() { /** 编辑区图片显示解析:相对/本地路径按文档目录归一化后读成 data URL(仅显示层)。 */ const resolveDisplayImage = useCallback(async (docPath: string, src: string): Promise => { + const cacheKey = `${docPath}|${src}` + const cached = imageDisplayCache.get(cacheKey) + if (cached) return cached + const promise = (async (): Promise => { try { if (/^(https?:|data:|blob:)/i.test(src)) return null let decoded = src @@ -1207,6 +1224,13 @@ export default function App() { } catch { return null } + })() + imageDisplayCache.set(cacheKey, promise) + if (imageDisplayCache.size > 60) { + const oldest = imageDisplayCache.keys().next().value + if (oldest) imageDisplayCache.delete(oldest) + } + return promise }, []) const buildExportHtml = useCallback(async ( @@ -1464,6 +1488,29 @@ export default function App() { ? state.tabs.filter((t) => pendingClose.ids.includes(t.id) && isDirty(t)).map((t) => t.title).join('、') : '' + // Sidebar 是 memo 组件:gitSync 对象与收起回调必须保持引用稳定, + // 否则每次击键的 App 重渲染都会连带整棵文件树重渲染(docs/29) + const collapseSidebar = useCallback(() => setUi((u) => ({ ...u, sidebar: false })), []) + const gitSyncUi = useMemo(() => (tauriMode && workspaceRoot ? { + isRepo: Boolean(gitInfo?.isRepo), + label: gitStatusLabel(gitInfo, gitSyncing), + syncing: gitSyncing, + lastMessage: gitMsg?.text ?? null, + lastIsError: gitMsg?.isError ?? false, + onSyncNow: () => { void runGitSync(false) }, + onOpenSettings: () => { + // 仓库状态还没读出来时不开弹窗:否则会把已有仓库误判成 + // 未初始化(远程地址显示为空、保存走 init 流程报错) + if (!gitInfo) { + setGitMsg({ text: '正在读取仓库状态,请稍候再打开设置', isError: false }) + if (workspaceRoot) void refreshGitStatus(workspaceRoot) + return + } + setGitSettingsError(null) + setGitSettingsOpen(true) + }, + } : undefined), [tauriMode, workspaceRoot, gitInfo, gitSyncing, gitMsg, runGitSync, refreshGitStatus]) + return (
@@ -1488,31 +1535,13 @@ export default function App() { onSaveAsEntry={saveAsEntry} onExportEntry={exportEntry} onPrintEntry={printEntry} - onCollapseSidebar={() => setUi((u) => ({ ...u, sidebar: false }))} + onCollapseSidebar={collapseSidebar} refreshSignal={treeRefresh} - outline={outline} + outline={sidebarMode === 'outline' ? outline : EMPTY_OUTLINE} onJumpHeading={jumpHeading} onOpenSearchResult={openSearchResult} tauriMode={tauriMode} - gitSync={tauriMode && workspaceRoot ? { - isRepo: Boolean(gitInfo?.isRepo), - label: gitStatusLabel(gitInfo, gitSyncing), - syncing: gitSyncing, - lastMessage: gitMsg?.text ?? null, - lastIsError: gitMsg?.isError ?? false, - onSyncNow: () => { void runGitSync(false) }, - onOpenSettings: () => { - // 仓库状态还没读出来时不开弹窗:否则会把已有仓库误判成 - // 未初始化(远程地址显示为空、保存走 init 流程报错) - if (!gitInfo) { - setGitMsg({ text: '正在读取仓库状态,请稍候再打开设置', isError: false }) - if (workspaceRoot) void refreshGitStatus(workspaceRoot) - return - } - setGitSettingsError(null) - setGitSettingsOpen(true) - }, - } : undefined} + gitSync={gitSyncUi} />
)} diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index da6009a..1aa92f5 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -1,7 +1,7 @@ // 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。 // 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、 // 选中 --accent-weak);点别处/Esc 关闭走统一的 useDismiss;支持上下键选行。 -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useDismiss } from '../hooks/useDismiss' export interface DropdownOption { value: string; label: string } @@ -16,7 +16,8 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: { const [open, setOpen] = useState(false) const ref = useRef(null) const menuRef = useRef(null) - useDismiss(ref, open, () => setOpen(false)) + const close = useCallback(() => setOpen(false), []) + useDismiss(ref, open, close) const current = options.find((o) => o.value === value) ?? options[0] diff --git a/src/components/EditorContextMenu.tsx b/src/components/EditorContextMenu.tsx index 1035bd0..63ec866 100644 --- a/src/components/EditorContextMenu.tsx +++ b/src/components/EditorContextMenu.tsx @@ -1,5 +1,6 @@ import { useRef } from 'react' import { useDismiss } from '../hooks/useDismiss' +import { clampMenuPosition } from '../utils/menuPos' export type EditorMenuAction = | 'cut' | 'copy' | 'paste' | 'selectAll' @@ -26,9 +27,7 @@ export default function EditorContextMenu({ x, y, hasSelection, sourceMode, onAc useDismiss(ref, true, onClose) const act = (a: EditorMenuAction) => { onClose(); onAction(a) } // 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式) - const left = Math.max(4, Math.min(x, window.innerWidth - 196)) - const estHeight = sourceMode ? 236 : 388 - const top = Math.max(4, Math.min(y, window.innerHeight - estHeight - 8)) + const { left, top } = clampMenuPosition(x, y, 188, sourceMode ? 236 : 388) return (
(cur && cur.top === next.top && cur.left === next.left ? cur : next)) }, []) const hostRef = useRef(null) const initialRef = useRef(initialMarkdown) @@ -97,7 +98,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal const lineCenter = (coords.top + coords.bottom) / 2 - rect.top + container.scrollTop const target = lineCenter - container.clientHeight / 2 if (Math.abs(target - container.scrollTop) > 6) { - container.scrollTo({ top: target, behavior: 'smooth' }) + container.scrollTo({ top: target, behavior: 'auto' }) } }) } catch { /* 视图未就绪时跳过 */ } @@ -232,6 +233,9 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal } editor.create() .then(() => { + // 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已 + // 注销并 destroy,这里若继续会把已销毁编辑器的 API 注册回 App + if (cancelled) { void editor.destroy().catch(() => undefined); return } setReady(true) setFocusModeEnabled(modesRef.current.focusMode) try { @@ -267,7 +271,11 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal }) .catch((e) => console.error('编辑器创建失败', e)) const scrollParent = wrapRef.current?.closest('.editor-scroll') - const onScroll = () => { if (editorObjRef.current) updateTableBar() } + let scrollRaf = 0 + const onScroll = () => { + if (!editorObjRef.current || scrollRaf) return + scrollRaf = requestAnimationFrame(() => { scrollRaf = 0; updateTableBar() }) + } scrollParent?.addEventListener('scroll', onScroll, { passive: true }) return () => { cancelled = true @@ -275,9 +283,15 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal editorObjRef.current = null setTableBar(null) try { - const finalMarkdown = getMarkdown(editor) - if (callbacksRef.current.onFinalMarkdown) callbacksRef.current.onFinalMarkdown(tabId, finalMarkdown) - else callbacksRef.current.onMarkdown(tabId, finalMarkdown) + // 分片载入未收尾(muted)时编辑器里只有部分文档:跳过最终回写。 + // 状态里存的是磁盘完整原文,回写部分序列化会把文档静默截断 + // (syncEditorContent 对干净标签会把 savedContent 一起对齐)。 + // 载入失败路径已主动解除 muted 并如实回写,不受此守卫影响。 + if (!muted) { + 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/FindBar.tsx b/src/components/FindBar.tsx index 0e4af85..b45d9ae 100644 --- a/src/components/FindBar.tsx +++ b/src/components/FindBar.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react' +import Tooltip from './Tooltip' import type { FindOptions } from '../editor/search' interface Props { @@ -29,21 +30,26 @@ export default function FindBar(p: Props) { }, []) const toggle = (key: keyof FindOptions, label: string, title: string) => ( - + + + ) return (
- + + + {p.error ? p.error : p.query ? (p.count === 0 ? '无结果' : `${p.current + 1} / ${p.count}`) : ''} - - - + + +
{p.showReplace && (
diff --git a/src/components/GitSyncModal.tsx b/src/components/GitSyncModal.tsx index d7efcf3..4f7bdb9 100644 --- a/src/components/GitSyncModal.tsx +++ b/src/components/GitSyncModal.tsx @@ -1,6 +1,6 @@ // Git 同步设置:远程仓库地址、(初始化时的)分支、定时同步间隔。 // 工作区还不是仓库时,保存=初始化仓库并绑定远程;已是仓库时只更新设置。 -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Dropdown } from './Dropdown' const INTERVAL_OPTIONS = [ @@ -21,13 +21,18 @@ export function GitSyncModal({ isRepo, branch, initialRemoteUrl, initialInterval onSave: (remoteUrl: string, branch: string, intervalMin: number) => void onClose: () => void }) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !busy) onClose() } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [busy, onClose]) const [remoteUrl, setRemoteUrl] = useState(initialRemoteUrl) const [branchInput, setBranchInput] = useState('main') const [intervalMin, setIntervalMin] = useState(String(initialIntervalMin)) return ( -
-
+
{ if (!busy) onClose() }}> +
e.stopPropagation()}>

Git 同步设置

同步顺序:提交本地更改 → 拉取远程更新(变基)→ 推送。若本地与远程改了相同内容, diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 4ea8e49..3ecd115 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { useDismiss } from '../hooks/useDismiss' import Tooltip from './Tooltip' +import { clampMenuPosition } from '../utils/menuPos' import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform' import { isPathInside, parentDirOf, pathsEqual } from '../utils/text' import type { OutlineItem } from '../utils/text' @@ -83,6 +84,7 @@ function Sidebar(p: Props) { <> {p.gitSync && } {menu && ( -

e.stopPropagation()}> +
e.stopPropagation()}> {!menu.entry.isDir && } {!menu.entry.isDir && onOpenInNewWindowEntry && } {onRevealEntry && } @@ -277,7 +279,7 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu {entries.map((entry) => (
{menuFor && menuTab && ( -
+
{onOpenInNewWindowTab && } diff --git a/src/components/TableToolbar.tsx b/src/components/TableToolbar.tsx index 6a0c650..7eac9fa 100644 --- a/src/components/TableToolbar.tsx +++ b/src/components/TableToolbar.tsx @@ -1,5 +1,6 @@ import type { Editor } from '@milkdown/kit/core' import { editorCommands } from '../editor/commands' +import Tooltip from './Tooltip' interface Props { editor: Editor @@ -10,7 +11,9 @@ interface Props { /** 表格浮动工具栏:光标在表格内时出现在表格上方(EditorPane 负责定位与显隐)。 */ export default function TableToolbar({ editor, top, left }: Props) { const btn = (label: string, fn: () => void, title?: string) => ( - + + + ) return (
diff --git a/src/editor/chunkLoader.ts b/src/editor/chunkLoader.ts index f50d3b4..8d1f689 100644 --- a/src/editor/chunkLoader.ts +++ b/src/editor/chunkLoader.ts @@ -12,6 +12,8 @@ export const ASYNC_LOAD_THRESHOLD = 60_000 const CHUNK_TARGET = 24_000 const FENCE_RE = /^\s{0,3}(```+|~~~+)/ +/** 围栏作切点时只认顶格:列表项内缩进的围栏是列表内容,切开会改变归属(docs/29) */ +const FENCE_CUT_RE = /^(`{3,}|~{3,})/ const HEADING_RE = /^#{1,6}\s/ const HR_RE = /^\s{0,3}(\*\*\*+|---+|___+)\s*$/ const LINK_DEF_RE = /^\s{0,3}\[[^\]]+\]:\s*\S+/ @@ -44,20 +46,18 @@ export function splitMarkdownChunks(markdown: string): string[] { if (inFence) continue if (line.trim() !== '' || i + 1 >= lines.length) continue const next = lines[i + 1] - if (HEADING_RE.test(next) || FENCE_RE.test(next) || HR_RE.test(next)) cuts.push(i + 1) + if (HEADING_RE.test(next) || FENCE_CUT_RE.test(next) || HR_RE.test(next)) cuts.push(i + 1) } if (cuts.length === 0) return [markdown] // 按目标大小合并相邻片段:切点很多时攒够一片再切,避免碎成几百片 const chunks: string[] = [] let startLine = 0 let startOffset = 0 - const lineOffset = (idx: number) => { - let off = 0 - for (let i = 0; i < idx; i++) off += lines[i].length + 1 - return off - } + // 行偏移前缀表(原实现每个切点都从头累加,切点密集时 O(切点×行数)) + const lineOffsets: number[] = [0] + for (let i = 0; i < lines.length; i++) lineOffsets.push(lineOffsets[i] + lines[i].length + 1) for (const cut of cuts) { - const off = lineOffset(cut) + const off = lineOffsets[cut] if (off - startOffset >= CHUNK_TARGET && cut > startLine) { chunks.push(markdown.slice(startOffset, off)) startLine = cut diff --git a/src/editor/editor.ts b/src/editor/editor.ts index aba4acd..4361d9c 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -44,21 +44,34 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string, lm.markdownUpdated((_ctx, markdown) => callbacks.onMarkdown?.(markdown)) } if (callbacks.onCursor || callbacks.onTableContext) { + // 选区事件在每个事务后同步分发,而行列计算要从文档头逐块数到 + // 光标(大文档 O(光标位置)):用 rAF 合并,逐帧最多算一次(docs/29) + let cursorRaf = 0 + let pendingRun: (() => void) | null = null lm.selectionUpdated((selCtx, selection) => { - callbacks.onCursor?.(cursorFromSelection(selection)) - if (callbacks.onTableContext) { - const inTable = selectionInTable(selection as never) - let tableEl: HTMLElement | null = null - if (inTable) { - try { - const view = selCtx.get(editorViewCtx) - const at = view.domAtPos(selection.from) - const base = at.node.nodeType === Node.ELEMENT_NODE ? (at.node as Element) : at.node.parentElement - tableEl = (base?.closest('table') as HTMLElement | null) ?? null - } catch { /* 定位失败时工具栏不显示 */ } + pendingRun = () => { + callbacks.onCursor?.(cursorFromSelection(selection)) + if (callbacks.onTableContext) { + const inTable = selectionInTable(selection as never) + let tableEl: HTMLElement | null = null + if (inTable) { + try { + const view = selCtx.get(editorViewCtx) + const at = view.domAtPos(selection.from) + const base = at.node.nodeType === Node.ELEMENT_NODE ? (at.node as Element) : at.node.parentElement + tableEl = (base?.closest('table') as HTMLElement | null) ?? null + } catch { /* 定位失败时工具栏不显示 */ } + } + callbacks.onTableContext(inTable, tableEl) } - callbacks.onTableContext(inTable, tableEl) } + if (cursorRaf) return + cursorRaf = requestAnimationFrame(() => { + cursorRaf = 0 + const run = pendingRun + pendingRun = null + run?.() + }) }) } ctx.update(uploadConfig.key, (prev: UploadOptions) => ({ diff --git a/src/editor/paste.ts b/src/editor/paste.ts index 3db1238..ef0b5c6 100644 --- a/src/editor/paste.ts +++ b/src/editor/paste.ts @@ -16,6 +16,8 @@ interface ViewLike { * 不产生空段落(上一版 replaceSelectionWith + insert 在段中粘贴会留一个
空段)。 */ export function insertPlainTextIntoView(view: ViewLike, text: string): boolean { const lines = text.replace(/\r\n?/g, '\n').split('\n') + // 原文以换行结尾时末项是空串:丢弃,否则粘贴后会多一个空段落 + if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop() const paragraph = view.state.schema.nodes.paragraph const parentType = view.state.selection.$from.parent.type.name if (lines.length <= 1 || !paragraph || (parentType !== 'paragraph' && parentType !== 'heading')) { diff --git a/src/services/exportHtml.ts b/src/services/exportHtml.ts index 900e698..9b9ac6c 100644 --- a/src/services/exportHtml.ts +++ b/src/services/exportHtml.ts @@ -67,13 +67,14 @@ async function postProcess(tree: Root, opts: ExportOptions): Promise { // 图片内嵌 if (opts.imageMode === 'embed' && opts.resolveImage) { - for (const { node } of images) { + // 并发解析(原先逐张串行 IPC,图片多的文档导出线性变慢) + await Promise.all(images.map(async ({ node }) => { const src = node.properties?.src if (typeof src === 'string' && !/^(https?:|data:)/i.test(src)) { - const data = await opts.resolveImage(src) + const data = await opts.resolveImage?.(src) if (data) node.properties.src = data } - } + })) } // 相对模式按导出文件位置重写图片路径(验收 E3:原先原样保留文档相对路径, @@ -291,6 +292,9 @@ function loadHighlighter(): Promise { }) })() } + // 初始化失败时不永久缓存 rejected Promise(否则整会话高亮失效): + // 本次照常抛给调用方兜底,下次导出重新尝试初始化 + highlighterPromise.catch(() => { highlighterPromise = null }) return highlighterPromise } diff --git a/src/services/gitSync.ts b/src/services/gitSync.ts index c8b112d..2d41fe5 100644 --- a/src/services/gitSync.ts +++ b/src/services/gitSync.ts @@ -2,6 +2,7 @@ // 定时同步用 localStorage 租约保证多窗口只有一个执行(与草稿认领同一思路)。 import { dbPrefGet, dbPrefSet, type GitStatusInfo } from './platform' +import { pathCompareKey } from '../utils/text' export interface GitSyncSettings { /** 定时同步间隔(分钟);0=只手动 */ @@ -23,7 +24,7 @@ export function parseGitSyncSettings(raw: string | null): GitSyncSettings { export async function loadGitSyncSettings(root: string): Promise { try { - return parseGitSyncSettings(await dbPrefGet(`gitsync:${root}`)) + return parseGitSyncSettings(await dbPrefGet(`gitsync:${pathCompareKey(root)}`)) } catch { return { ...DEFAULT_GIT_SYNC_SETTINGS } } @@ -31,7 +32,7 @@ export async function loadGitSyncSettings(root: string): Promise { try { - await dbPrefSet(`gitsync:${root}`, JSON.stringify(settings)) + await dbPrefSet(`gitsync:${pathCompareKey(root)}`, JSON.stringify(settings)) } catch { // 偏好写入失败不挡同步本身 } @@ -72,7 +73,7 @@ interface Lease { /** 尝试取得/续租该工作区的定时同步租约;被别的窗口新鲜占用时返回 false。 */ export function acquireGitSyncLease(root: string, windowId: string, now = Date.now()): boolean { try { - const key = LEASE_PREFIX + root + const key = LEASE_PREFIX + pathCompareKey(root) const raw = localStorage.getItem(key) if (raw) { const lease = JSON.parse(raw) as Lease @@ -80,7 +81,14 @@ export function acquireGitSyncLease(root: string, windowId: string, now = Date.n } const mine: Lease = { win: windowId, ts: now } localStorage.setItem(key, JSON.stringify(mine)) - return true + // 回读确认:双窗口同一瞬间都通过检查时,后写者为准,先写者回读会发现 + // 持有者不是自己,放弃本次(避免并发跑 git 撞 index.lock) + try { + const back = JSON.parse(localStorage.getItem(key) ?? 'null') as Lease | null + return back?.win === windowId + } catch { + return true + } } catch { return true // 存储不可用时不拦(单窗口场景) } @@ -88,7 +96,7 @@ export function acquireGitSyncLease(root: string, windowId: string, now = Date.n export function releaseGitSyncLease(root: string, windowId: string): void { try { - const key = LEASE_PREFIX + root + const key = LEASE_PREFIX + pathCompareKey(root) const raw = localStorage.getItem(key) if (raw && (JSON.parse(raw) as Lease).win === windowId) localStorage.removeItem(key) } catch { diff --git a/src/services/store.ts b/src/services/store.ts index d7dc2e2..7c11e34 100644 --- a/src/services/store.ts +++ b/src/services/store.ts @@ -5,6 +5,7 @@ import { dbPrefGet, dbPrefSet, dbRecoveryList, dbRecoverySave, dbRecoveryDelete, type RecoveryEntry, } from './platform' +import { pathsEqual } from '../utils/text' export type { RecoveryEntry } @@ -52,7 +53,7 @@ export function addRecent(path: string, name: string): void { void dbRecentAdd(path, name, Date.now()).catch(() => undefined) return } - const list = readLegacyRecent().filter((r) => r.path !== path) + const list = readLegacyRecent().filter((r) => !pathsEqual(r.path, path)) list.unshift({ path, name }) localStorage.setItem(LS_RECENT, JSON.stringify(list.slice(0, 10))) } @@ -62,7 +63,7 @@ export function removeRecent(path: string): void { void dbRecentRemove(path).catch(() => undefined) return } - localStorage.setItem(LS_RECENT, JSON.stringify(readLegacyRecent().filter((r) => r.path !== path))) + localStorage.setItem(LS_RECENT, JSON.stringify(readLegacyRecent().filter((r) => !pathsEqual(r.path, path)))) } export function addWorkspace(path: string): void { @@ -90,7 +91,9 @@ export async function loadLastWorkspace(): Promise { } export async function loadUiPref(): Promise { - if (!isTauri()) return null + if (!isTauri()) { + try { return localStorage.getItem(LS_UI) } catch { return null } + } try { const fromDb = await dbPrefGet('ui') if (fromDb) { diff --git a/src/services/windowClaims.ts b/src/services/windowClaims.ts index 4dfd63c..eee6d79 100644 --- a/src/services/windowClaims.ts +++ b/src/services/windowClaims.ts @@ -47,7 +47,10 @@ export function createWindowClaims( const t = now() const out: Record = {} for (const [id, entry] of Object.entries(reg)) { - if (entry && typeof entry.ts === 'number' && t - entry.ts <= CLAIM_STALE_MS) out[id] = entry + // keys 必须是字符串数组:注册表被写坏(旧版本/外部改动)时整条丢弃, + // 避免 liveClaimedKeys 逐字迭代字符串或对不可迭代值抛错 + const keysOk = Array.isArray(entry?.keys) && entry.keys.every((k) => typeof k === 'string') + if (entry && typeof entry.ts === 'number' && keysOk && t - entry.ts <= CLAIM_STALE_MS) out[id] = entry } return out } diff --git a/src/utils/menuPos.ts b/src/utils/menuPos.ts new file mode 100644 index 0000000..f8dfe6a --- /dev/null +++ b/src/utils/menuPos.ts @@ -0,0 +1,7 @@ +/** 右键菜单位置夹取:不让菜单溢出窗口右/下边(尺寸为估算值,调用方按条目数给)。 */ +export function clampMenuPosition(x: number, y: number, width: number, estHeight: number): { left: number; top: number } { + return { + left: Math.max(4, Math.min(x, window.innerWidth - width - 8)), + top: Math.max(4, Math.min(y, window.innerHeight - estHeight - 8)), + } +} diff --git a/src/utils/recovery.ts b/src/utils/recovery.ts index 6486623..64096d3 100644 --- a/src/utils/recovery.ts +++ b/src/utils/recovery.ts @@ -1,5 +1,6 @@ // 崩溃恢复的纯逻辑(阶段 4):恢复记录的键、启动时该提供/该清理的划分。 import type { RecoveryEntry } from '../services/platform' +import { pathCompareKey } from './text' export interface RecoveryTabLike { id: string @@ -8,7 +9,7 @@ export interface RecoveryTabLike { /** 恢复记录的稳定键:文件文档按路径(跨会话一致),未命名文档按标签 id(仅本机延续)。 */ export function recoveryKey(tab: RecoveryTabLike): string { - return tab.path ? `path:${tab.path}` : `untitled:${tab.id}` + return tab.path ? `path:${pathCompareKey(tab.path)}` : `untitled:${tab.id}` } export interface RecoveryPlan { diff --git a/src/utils/text.ts b/src/utils/text.ts index 9ae6902..7ff61d3 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -20,14 +20,27 @@ export interface OutlineItem { line: number } -/** 从 Markdown 文本提取大纲。注意跳过代码块内的 # 行。 */ +/** 从 Markdown 文本提取大纲。注意跳过代码块内的 # 行。 + * 围栏按字符+长度跟踪:四反引号围栏内的三反引号行不算闭合(docs/29)。 */ export function extractOutline(markdown: string): OutlineItem[] { const items: OutlineItem[] = [] const lines = markdown.split('\n') - let inFence = false + let fenceChar = '' + let fenceLen = 0 lines.forEach((raw, i) => { - if (/^\s*(```|~~~)/.test(raw)) inFence = !inFence - if (inFence) return + const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(raw) + if (fence) { + const run = fence[1] + if (!fenceChar) { + fenceChar = run[0] + fenceLen = run.length + } else if (run[0] === fenceChar && run.length >= fenceLen) { + fenceChar = '' + fenceLen = 0 + } + return + } + if (fenceChar) return const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw) if (m) items.push({ level: m[1].length, text: m[2].trim(), line: i }) }) @@ -48,21 +61,28 @@ export function normalizeSlashes(path: string): string { return path.replace(/\\/g, '/') } -/** 归一化后比较两条路径是否相同。 */ +/** 路径比较键:统一斜杠;Windows 风格路径(盘符/UNC)大小写不敏感,一并折叠小写。 */ +export function pathCompareKey(path: string): string { + const slashed = normalizeSlashes(path).replace(/\/+$/, '') + return /^[a-zA-Z]:/.test(slashed) || slashed.startsWith('//') ? slashed.toLowerCase() : slashed +} + +/** 归一化后比较两条路径是否相同(Windows 路径忽略大小写)。 */ export function pathsEqual(a?: string | null, b?: string | null): boolean { - return Boolean(a && b && normalizeSlashes(a) === normalizeSlashes(b)) + return Boolean(a && b && pathCompareKey(a) === pathCompareKey(b)) } /** child 是否位于 parent 目录之内(不含 parent 本身)。 */ export function isPathInside(child: string, parent: string): boolean { - const c = normalizeSlashes(child) - const p = normalizeSlashes(parent).replace(/\/+$/, '') - return c.startsWith(`${p}/`) + return pathCompareKey(child).startsWith(`${pathCompareKey(parent)}/`) } -/** 取父目录(保持原分隔符风格)。 */ +/** 取父目录(保持原分隔符风格);盘符根下的文件返回盘符根(C:/ 而非相对的 C:)。 */ export function parentDirOf(path: string): string { - return path.replace(/[\\/][^\\/]*$/, '') + const parent = path.replace(/[\\/][^\\/]*$/, '') + if (/^[a-zA-Z]:$/.test(parent)) return `${parent}/` + if (parent === '' && path.startsWith('/')) return '/' + return parent } /** 拼接路径(分隔符跟随 dir 的风格)。 */ diff --git a/tests/chunk-load.test.ts b/tests/chunk-load.test.ts index 7d8bc93..021f53e 100644 --- a/tests/chunk-load.test.ts +++ b/tests/chunk-load.test.ts @@ -103,3 +103,31 @@ describe('分片载入与整篇解析等价', () => { expect(chunked).toBe(whole) }, 60_000) }) + +describe('列表内嵌围栏不被切开(docs/29)', () => { + it('分片序列化与整篇逐字相等', async () => { + const parts: string[] = [] + for (let s = 1; s <= 30; s++) { + parts.push(`## 小节 ${s}`) + parts.push('') + parts.push('正文填充。'.repeat(420)) + parts.push('') + parts.push('- 列表项,内含缩进围栏代码:') + parts.push('') + parts.push(' ```js') + parts.push(` const v${s} = ${s}`) + parts.push(' ```') + parts.push('') + parts.push('- 下一项') + parts.push('') + } + const md = parts.join('\n') + expect(md.length).toBeGreaterThan(60_000) + const whole = getMarkdown(await load(md)) + const handle = prepareChunkLoad(md) + expect(handle).not.toBeNull() + const editor = await load(handle!.firstChunk) + await handle!.appendRest(editor, () => undefined, () => false) + expect(getMarkdown(editor)).toBe(whole) + }, 60000) +}) diff --git a/tests/editor-pane-unload.test.tsx b/tests/editor-pane-unload.test.tsx new file mode 100644 index 0000000..42f10aa --- /dev/null +++ b/tests/editor-pane-unload.test.tsx @@ -0,0 +1,43 @@ +// 回归(docs/29 高危):EditorPane 卸载最终回写的守卫。 +// 分片载入未收尾(muted)时 cleanup 跳过回写——否则部分序列化会把干净 +// 标签静默截断。该交错(卸载提交时追加仍在途)在 jsdom 里无法自动化 +// 复现:React act 会把待办异步工作抽干,分片追加总是先跑完(已实测 +// 三种卸载方式均如此),故中途卸载一例靠代码复核保证(muted 仅在追加 +// 完成/失败的收尾回调里解除,cleanup 只在 !muted 时回写),这里只锁 +// 对照组:正常文档卸载时最终回写照常发生(守卫不能误伤正常路径)。 +import { describe, it, expect, vi } from 'vitest' +import { createRoot } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import EditorPane from '../src/components/EditorPane' + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +describe('EditorPane 卸载回写', () => { + it('普通文档卸载:最终回写照常', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onFinalMarkdown = vi.fn() + let readyApi: { editor: unknown } | null = null + const onReady = vi.fn((api: { editor: unknown }) => { if (api.editor) readyApi = api }) + await act(async () => { + root.render( + undefined} + onFinalMarkdown={onFinalMarkdown} + onCursor={() => undefined} + onReady={onReady} + focusMode={false} + typewriter={false} + />, + ) + }) + for (let i = 0; i < 200 && !readyApi; i++) await sleep(10) + expect(readyApi).not.toBeNull() + await act(async () => { root.unmount() }) + expect(onFinalMarkdown).toHaveBeenCalledTimes(1) + expect(onFinalMarkdown.mock.calls[0][1]).toContain('你好,世界。') + }, 30000) +}) diff --git a/tests/stage4.test.ts b/tests/stage4.test.ts index 51d9072..3534f88 100644 --- a/tests/stage4.test.ts +++ b/tests/stage4.test.ts @@ -6,7 +6,9 @@ import type { RecoveryEntry } from '../src/services/platform' describe('recoveryKey', () => { it('文件文档按路径键,未命名按标签 id', () => { - expect(recoveryKey({ id: 't1', path: 'D:\\a.md' })).toBe('path:D:\\a.md') + // docs/29:路径先归一(斜杠统一 + Windows 大小写折叠),同文件不同写法同键 + expect(recoveryKey({ id: 't1', path: 'D:\\a.md' })).toBe('path:d:/a.md') + expect(recoveryKey({ id: 't1', path: 'd:/A.md' })).toBe('path:d:/a.md') expect(recoveryKey({ id: 't2', path: null })).toBe('untitled:t2') }) }) diff --git a/tests/text.test.ts b/tests/text.test.ts index de78c72..357423f 100644 --- a/tests/text.test.ts +++ b/tests/text.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { countWords, countChars, extractOutline, baseName, isMarkdownPath } from '../src/utils/text' +import { countWords, countChars, extractOutline, baseName, isMarkdownPath, pathsEqual, isPathInside, parentDirOf } from '../src/utils/text' describe('字数统计', () => { it('中文按字、英文按词、标点不计', () => { @@ -56,3 +56,24 @@ describe('路径助手(拖放/改名校验)', () => { expect(isPathInside('C:\\ws\\sub\\deep', 'C:/ws/sub')).toBe(true) }) }) + +describe('路径比较与大纲围栏(docs/29)', () => { + it('Windows 路径大小写/斜杠归一后相等,POSIX 路径仍分大小写', () => { + expect(pathsEqual('C:\\WS\\A.md', 'c:/ws/a.md')).toBe(true) + expect(pathsEqual('/home/A.md', '/home/a.md')).toBe(false) + expect(isPathInside('c:/ws/sub/a.md', 'C:\\WS')).toBe(true) + expect(isPathInside('C:/ws2/a.md', 'C:/ws')).toBe(false) + }) + + it('parentDirOf 盘符根文件返回盘符根', () => { + expect(parentDirOf('C:/a.md')).toBe('C:/') + expect(parentDirOf('C:\\dir\\a.md')).toBe('C:\\dir') + expect(parentDirOf('/a.md')).toBe('/') + }) + + it('四反引号围栏内的三反引号与 # 行不干扰大纲', () => { + const md = ['# 标题一', '', '````md', '```', '# 假标题', '```', '````', '', '# 标题二'].join('\n') + const outline = extractOutline(md) + expect(outline.map((o) => o.text)).toEqual(['标题一', '标题二']) + }) +})