// 救援文件工具 —— 共享给 save() / openFile() / onFilesChanged / file-ops // (deleteFile / renameFile) 五条路径使用。 // // 背景:原逻辑「生成 `-rescued--.md` 名字 + 写盘 + // 弹 toast」在四处独立复制(约 90 行重复代码),命名规则不统一时极易漂移。 // 抽到本文件后: // - 命名规则 / 随机后缀长度 / 时间戳精度 只改一处 // - 错误文案(特别是 friendlyFsError 接受 r.message 还是 r.error)一处修 // - 各调用方只关心「我有 content、想救回」一件事 // // 设计要点: // - buildRescueName 是同步、纯函数,方便测试 // - rescueDirtyContent 是 fire-and-forget:写盘失败不抛、不阻塞调用方 // (save() / openFile() 入口已用 try/catch 兜底,但 rescue 路径本身不能 // 把 unhandled rejection 抛进全局) // - 不假设 api 是 window.api 还是 ctx.api —— 任意带 createFile 方法的即可 import { showToast } from './feedback.js'; /** * 生成救援文件名 * * 格式:`<原名去后缀>-rescued--<4 字符随机>.md` * * 设计动机(audit renderer-M9):毫秒精度 + 4 字符 base36 随机后缀让同一 * 秒内多次删除产生重名的概率约 1/1.6M(ms 撞上后还有 1.68M 空间)—— 旧版 * 用秒级 ISO 时间戳(slice(0,19))会让 createFile 走 EEXIST 分支,用户 * 「未保存内容丢失」。 * * @param {string} originalName - 原文件名(含或不含 .md 后缀都行) * @returns {string} */ export function buildRescueName(originalName) { const base = (originalName || 'untitled.md').replace(/\.md$/i, ''); const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23); // 含 .mmm const suffix = Math.random().toString(36).slice(2, 6); return `${base}-rescued-${stamp}-${suffix}.md`; } /** * 把「编辑器里待救回的 dirty 内容」异步写到数据目录的救援文件。 * 失败回退到 toast 提示用户;不会抛错(fire-and-forget)。 * * @param {object} args * @param {object} args.api - IPC API(window.api 或 file-ops ctx.api 任一带 createFile 即可) * @param {string} args.originalName - 原文件名(用于命名救援文件) * @param {string} args.content - 待写入的编辑器内容(调用方负责在 clearCurrentFile 之前取好) * @param {string} args.event - 触发救援的事件描述,用于 toast 文案前缀 * (例:「文件已不在」「原文件已被删除」「文件已被删除」)。 * 成功路径:`${event},未保存内容已救回到「${rescueName}」` * 失败路径:`${event};未保存内容丢失(救回失败:${friendlyFsError(...)})` * IPC 异常:`${event};未保存内容丢失` * @returns {string} 救援文件名(同步返回,不等 IPC 完成) */ export function rescueDirtyContent({ api, originalName, content, event }) { const rescueName = buildRescueName(originalName); // 第二阶段代码审查 (Phase 2 review #1):显式用 { initialContent } 对象形式, // 不再依赖 preload.js createFile 的 string-overload 兼容分支。 // 该 overload 是「向后兼容老调用」的临时垫片,未来若被移除(强约束新签名), // 所有 5 条 rescue 路径会静默失败 —— 用户只在 toast 上看到「未保存内容丢失」, // 完全不知道是 payload shape 不匹配。这里走与 main/file-ops.js#resolveFileName // 对齐的新签名,消除隐式依赖。 api.createFile(rescueName, { initialContent: content }).then((r) => { if (r && r.ok) { showToast(`${event},未保存内容已救回到「${rescueName}」`, 'warning', 4500); } else { // 注意:friendlyFsError 的第二个参数是「fallback 中文明文」, // 必须传 r?.message(IPC handler 已塞好的中文),不能传 r?.error // (业务码字符串如 'INVALID_NAME')—— 否则用户看到的是原始业务码 // 而非可读中文。统一收口在这里一处,调用方不再需要关心这个细节。 // // 第二阶段代码审查 (Phase 2 review #2):走注入的 api 参数而非 window.api。 // 之前用 window.api?.friendlyFsError 与本文件「不假设 api 是 window.api // 还是 ctx.api」的契约矛盾 —— createFile 走注入 api,friendlyFsError // 又走 window.api,未来若 ctx.api 与 window.api 不一致(DI / 测试桩) // 会 break。在生产里 window.api 与 ctx.api 同一对象所以无害,但破坏了 // 抽象一致性。 const friendly = api && typeof api.friendlyFsError === 'function' ? api.friendlyFsError(r?.code, r?.message || r?.error) : (r?.message || r?.error || '未知错误'); showToast(`${event};未保存内容丢失(救回失败:${friendly})`, 'error', 4500); } }).catch((e) => { console.error('[rescue] createFile IPC 异常:', e); showToast(`${event};未保存内容丢失`, 'error', 4500); }); return rescueName; }