From e5ad58eb937fd7df70283b57fedb7645b4dbb96e Mon Sep 17 00:00:00 2001 From: Muse Date: Fri, 13 Nov 2026 22:43:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(stage4):=20=E8=87=AA=E5=8A=A8=E4=BF=9D?= =?UTF-8?q?=E5=AD=98/=E5=B4=A9=E6=BA=83=E6=81=A2=E5=A4=8D/=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E4=BF=AE=E6=94=B9=E6=A3=80=E6=B5=8B/=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E7=AE=A1=E7=90=86=EF=BC=88=E5=90=AB=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E9=AA=8C=E6=94=B6=E6=B8=85=E5=8D=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- docs/08-stage4-report.md | 37 +++ docs/08-windows-checklist.md | 62 +++++ src/App.tsx | 386 ++++++++++++++++++++++++++++++-- src/components/Sidebar.tsx | 53 ++++- src/components/Stage4Modals.tsx | 141 ++++++++++++ src/services/platform.ts | 85 +++++++ src/services/store.ts | 137 ++++++++++++ src/stores/tabs.ts | 24 ++ src/styles.css | 13 ++ src/utils/recovery.ts | 40 ++++ tests/stage4.test.ts | 52 +++++ 12 files changed, 1013 insertions(+), 21 deletions(-) create mode 100644 docs/08-stage4-report.md create mode 100644 docs/08-windows-checklist.md create mode 100644 src/components/Stage4Modals.tsx create mode 100644 src/services/store.ts create mode 100644 src/utils/recovery.ts create mode 100644 tests/stage4.test.ts diff --git a/README.md b/README.md index 4c999e7..b3f4443 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Tauri 2 + React + TypeScript + Milkdown(ProseMirror)。 ## 当前进度 -**阶段 0、1、2、3 已完成(自动化验证全绿),待 Windows 真机验收清单确认。** -阶段 3:表格全套、图片/附件落盘、KaTeX 公式、Mermaid 预览,报告见 `docs/07-stage3-report.md`。 +**阶段 0–4 已完成(自动化验证全绿),待 Windows 真机验收清单确认。** +阶段 4:SQLite 落库、自动保存、崩溃恢复、外部修改检测、工作区文件管理,报告见 `docs/08-stage4-report.md`。 阶段 1:Tauri 桌面骨架 + 打开/编辑/保存全链路,报告见 `docs/05-stage1-report.md` §4 清单。 在 Windows 上运行桌面版: diff --git a/docs/08-stage4-report.md b/docs/08-stage4-report.md new file mode 100644 index 0000000..bc89658 --- /dev/null +++ b/docs/08-stage4-report.md @@ -0,0 +1,37 @@ +# 阶段 4 · 文件管理与工作区报告(实测记录) + +> 日期:2026-11-13 · 范围(需求 §23 阶段 4):Rust/文件系统、最近文件(SQLite 迁移)、自动保存、文件恢复记录、外部修改检测、工作区文件管理、未保存提醒。 + +## 1. 本阶段实现内容 + +- **SQLite 存储层**(Rust `src-tauri/src/storage.rs`,rusqlite bundled):四张表 `recent_files / workspaces / prefs / recovery`,数据库位于应用数据目录 `new-markdown.db`,启动时建表并由 Tauri State 托管连接。红线不变:正文只在 .md 文件里,recovery 只是未保存草稿的副本。前端经 `services/store.ts` 访问:桌面走 SQLite、Web 降级走 localStorage(同构接口)。 +- **数据迁移**:阶段 1 的 localStorage 数据(最近文件是路径字符串数组、UI 偏好是 JSON)在桌面模式首次启动时一次性迁入 SQLite 并清掉本地副本;兼容两种历史格式。 +- **自动保存**:视图菜单可开关(默认开,30 秒);只自动保存「已落盘的脏文档」,保存后同步刷新外部检测基线并清掉对应恢复记录。 +- **崩溃恢复**:脏标签每 6 秒把最新草稿写入 recovery(文件按路径键、未命名按标签键);启动时先读磁盘现状做划分——与磁盘一致的记录静默清理,不一致/文件已删/未命名的逐项弹窗(恢复/丢弃/稍后再说);恢复文件文档时以磁盘内容为已保存基线、草稿为当前内容,dirty 状态准确。 +- **外部修改检测**:打开/保存时记录基线(mtime+大小),每 4 秒轮询 `stat_file`: + - 干净文档被外部改动 → 自动重新加载并右下角通知; + - 脏文档被外部改动 → 冲突弹窗三选:重新加载磁盘版 / 保留我的内容(更新基线,下次保存覆盖)/ 另存为; + - 文件被外部删除 → 通知,脏文档保留编辑内容、保存时重建。 +- **工作区文件管理**:文件树右键菜单(打开/重命名/删除到回收站,Rust `trash` 实现,失败对文件退回永久删除);树头「+」在工作区新建文档(自动补 .md、重名报错不覆盖);重命名已打开文档时标签路径、最近记录、恢复键同步迁移;删除已打开文档会连带关标签。另有 `stat_file / create_text_file / create_dir / rename_path` 命令(重名保护)。 +- **关窗拦截**:关闭窗口时先把全部草稿刷入恢复记录;若有已落盘文档未保存,弹「保存并退出 / 不保存退出 / 取消」;只有未命名草稿时直接放行(下次启动靠恢复弹窗找回);在退出弹窗里选「不保存」会清掉对应恢复记录,不让已明确放弃的内容下次再冒出来。 + +## 2. 构建与测试结果(实跑) + +| 项 | 结果 | +|---|---| +| 类型检查 `tsc -b` | ✅ 0 错误 | +| 自动化测试 | ✅ **48/48**(10 文件;本阶段新增 3:恢复键/划分计划、reducer 重载与改路径) | +| Rust 存储层 | ✅ 独立 crate 用真 rusqlite(bundled SQLite 编译运行)断言全过:最近文件上限 30 与置顶去重、偏好 upsert、工作区去重、恢复记录更新/排序/删除 | +| Rust 语法 | ✅ 三个源文件 rustfmt 解析通过(命令包装层的类型检查仍随 Windows 完整 Tauri 构建验,同前几阶段口径) | +| 生产构建 / dev 冒烟 | ✅ 主包 943KB / gzip 292KB;HTTP 200 | + +## 3. 已知问题与边界 + +1. **外部检测是轮询不是文件系统监听**:4 秒间隔、靠 mtime+大小判变;同一秒内同大小改写理论上可漏检。换 notify 监听是阶段 6 性能/体验项(需处理防抖与自身保存回声,轮询版已用基线机制规避了自保存误报)。 +2. 自动保存间隔当前固定 30 秒(偏好已落库,设置界面留到阶段 5 主题/设置页统一做);自动保存不处理未命名文档(靠恢复记录兜底,不悄悄替用户决定文件名)。 +3. 恢复弹窗「稍后再说」后,本次会话内不再重复弹(记录仍在,下次启动再问);同一文件在两台机器/被外部改名后恢复键失效属预期(按路径键)。 +4. 删除文件夹到回收站依赖 `trash` 在 Windows 的实现,真机验;删除后若文件树未即时刷新可用树头 ⟳ 手动刷新(已做 refreshSignal 自动刷新,真机复核)。 + +## 4. 下一阶段(阶段 5)预告 + +主题与输出:内置主题(默认/GitHub/Notion/简约蓝/深色护眼/专注写作/经典纸张)+ 自定义 CSS 作用域注入、文档级临时样式、导出 HTML/PDF、另存为 HTML、复制为 HTML、打印与打印预览、导出时资源处理(图片转相对路径/内嵌选项)。 diff --git a/docs/08-windows-checklist.md b/docs/08-windows-checklist.md new file mode 100644 index 0000000..043f85b --- /dev/null +++ b/docs/08-windows-checklist.md @@ -0,0 +1,62 @@ +# Windows 真机验收清单(阶段 0–3 合并版) + +> 整理时间:2026-11-13 晚 · 对应已推送版本:Gitea `xieyinlu/new-markdown` main = `ac2c29d`(阶段 0–3) +> 来源:docs/03 §4(阶段 0)、docs/05 §4(阶段 1)、docs/06 §3(阶段 2)、docs/07 §3(阶段 3)。 +> 本文件为合并整理,不含新功能;逐项打勾即可。发现的问题直接记在对应项后,阶段 4 前统一清零。 +> 注意:本文件写于推送之后,**未随 ac2c29d 推送**,随下一次推送一并提交即可。 + +## 0. 前置与首次构建 + +前置:Rust stable(rustup)、Visual Studio Build Tools(C++ 桌面开发)、WebView2(Win11 自带)。 + +```powershell +cd new-markdown +npm install +npm run tauri dev +``` + +- [ ] 首次真机构建通过,窗口正常启动(沙箱因 libzstd 定制版装不上 GTK/WebKit 开发包,未能完整构建,见 docs/05 §2——属系统层问题,非工程问题) + +## 1. 阶段 1:窗口 / 文件全链路 + +- [ ] 自定义标题栏可拖动、双击最大化、最小化/最大化/关闭三个按钮可用 +- [ ] 打开一个真实 .md,再打开一个 GBK 编码的老文件:内容正确,状态栏编码 / 换行符显示正确 +- [ ] 改两笔 → 标题栏与标签出现未保存圆点 → Ctrl+S 保存 → 圆点消失,用外部编辑器确认落盘内容 +- [ ] 打开文件夹 → 文件树展开、点开第二个文件成新标签;拖拽标签排序;右键「关闭其他」 +- [ ] 关一个未保存标签 → 弹保存确认,「保存 / 不保存 / 取消」三选都试一遍 +- [ ] 视图:深色主题、源码模式切回不丢内容、Ctrl+= 放大(Ctrl+0 为「段落」,恢复缩放走视图菜单,见 docs/05 §3) + +## 2. 阶段 0:IME 与即时渲染手感 + +- [ ] **中文 IME**:微软拼音连续输入中文长句,组合区不提前上屏、不触发 `#` / 列表等自动转换;搜狗拼音同验一轮 +- [ ] **即时渲染手感**:键入 `# `、`- [ ] `、`> `、表格语法,转换即时、无闪烁、光标不跳位 +- [ ] **大段粘贴**:从网页 / Word 粘贴富文本,格式映射合理、无整篇重排卡顿 + +## 3. 阶段 2:标记显隐 / 任务列表 / 源码模式 + +- [ ] **标记显隐**:光标进标题显 `#`、进引用显 `> `、进粗体/斜体/行内代码/删除线/链接显 `**` / `*` / `` ` `` / `~~` / `[…](href)`,移开即藏;保存后序列化与原文逐字一致 +- [ ] **任务列表勾选**:`- [ ]` 渲染出真实复选框,点击勾选 → 文字变灰,保存后变 `- [x]`;再点可取消(可撤销) +- [ ] **代码块 Tab**:代码块内 Tab 插两空格、Shift+Tab 反缩进;普通段落 Tab 行为不变 +- [ ] **源码模式(CodeMirror 6)**:行号、Markdown 高亮、深色跟随主题;渲染 ⇄ 源码切换光标大致回到原处、内容不丢 + - 已知边界:锚点同步是近似定位——同一段文字在全文重复出现时取首个匹配,可能落到同文的另一段(内容零丢失) +- [ ] **菜单粘贴**:菜单「粘贴」可用(已改 Clipboard API;阶段 1 的 execCommand 版在部分 WebView2 策略下可能被拒) + +## 4. 阶段 3:表格 / 图片 / 公式 / Mermaid + +- [ ] **表格**:Ctrl+T 插入 3×3;光标进表格出现浮动工具栏(加行 / 加列 / 删行 / 删列 / 整列对齐 / 删表,滚动跟随);在**数据格**点整列对齐也能生效;Tab 跳格、Shift+Tab 回退、末格 Tab 自动新增一行 +- [ ] **图片落盘**:粘贴截图 / 拖入图片 → 文件复制进 `<文档主名>.assets/`、Markdown 写相对路径;重名自动 `-1` 递增;文件名含 Windows 非法字符时被清洗 +- [ ] **附件**:插入任意附件文件 → 以 `[文件名](相对路径)` 插入,链接标记不丢 +- [ ] **数学公式**:行内 `$…$` 与块级 `$$…$$` 渲染成公式,保存往返无损 +- [ ] **Mermaid**:mermaid 代码块渲染出真 SVG 图表(沙箱缺布局引擎未验到这一步);块头可切「编辑源码 / 查看预览」;故意写错语法 → 显示错误原因、不白屏,仍能回源码 +- [ ] **脚注 / HTML**:脚注定义与引用、行内 ``、块级 `
` 往返稳定 + +## 5. 已知不做 / 留待阶段 6 的项(验到也不算问题) + +- 图片暂无缩放 / 对齐操作条;表格无行列拖动排序、无多格拖拽选区 +- 列表标记 `-` / `1.` 由浏览器样式天然呈现,不做显隐 +- 表格对齐冒号首轮会被规范化(` :--- ` → ` :- `),语义无损、次轮稳定 + +## 6. 验完之后 + +- 清单全绿 → 可拍板进**阶段 4**(SQLite 落库、自动保存与崩溃恢复、外部修改检测、工作区新建/重命名/删除) +- 有问题 → 把未通过项贴回对话,按项修,不急着进阶段 4 diff --git a/src/App.tsx b/src/App.tsx index 061e6d3..cc9e8a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,7 +21,11 @@ import { isTauri, pickOpenFile, pickSaveFile, pickFolder, readTextFile, writeTextFile, pickAndReadWebFile, downloadTextFile, pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, + statFile, createTextFile, renamePath, deleteToTrash, type DirEntry, type RecoveryEntry, } from './services/platform' +import * as store from './services/store' +import { recoveryKey, planRecovery } from './utils/recovery' +import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals' const WELCOME_MD = `# 欢迎使用 new-markdown @@ -43,10 +47,12 @@ const UI_KEY = 'new-markdown:ui' function loadRecent(): string[] { try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] } } -function loadUi(): { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number } { +interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number } +function loadUi(): UiState { + const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30 } try { - return { theme: 'light', sidebar: true, sidebarWidth: 250, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') } - } catch { return { theme: 'light', sidebar: true, sidebarWidth: 250 } } + return { ...defaults, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') } + } catch { return defaults } } function toDiskContent(content: string, lineEnding: string): string { @@ -71,6 +77,25 @@ export default function App() { const [modal, setModal] = useState<'about' | 'shortcuts' | 'link' | null>(null) const [error, setError] = useState(null) const [pendingClose, setPendingClose] = useState<{ ids: string[]; after: () => void } | null>(null) + // ——— 阶段 4 状态 ——— + const [notice, setNotice] = useState(null) + const [recoveryOffer, setRecoveryOffer] = useState([]) + const [recoveryDismissed, setRecoveryDismissed] = useState(false) + const [conflict, setConflict] = useState<{ tabId: string } | null>(null) + const [quitOpen, setQuitOpen] = useState(false) + const [promptSpec, setPromptSpec] = useState<{ title: string; label: string; initial: string; submit: (v: string) => void } | null>(null) + const [confirmSpec, setConfirmSpec] = useState<{ title: string; message: string; confirmLabel: string; onOk: () => void } | null>(null) + const [treeRefresh, setTreeRefresh] = useState(0) + /** 文件基线(外部修改检测):tabId -> 上次确认的磁盘状态 */ + const baselinesRef = useRef(new Map()) + const conflictSeenRef = useRef(new Set()) + const recoveryWrittenRef = useRef(new Map()) + const lastTabsRef = useRef(new Map()) + + const flash = useCallback((msg: string) => { + setNotice(msg) + window.setTimeout(() => setNotice((cur) => (cur === msg ? null : cur)), 6000) + }, []) const editorRef = useRef(null) const jumpRef = useRef<(text: string) => void>(() => undefined) @@ -82,28 +107,26 @@ export default function App() { useEffect(() => { document.documentElement.dataset.theme = ui.theme - localStorage.setItem(UI_KEY, JSON.stringify(ui)) + store.saveUiPref(JSON.stringify(ui)) }, [ui]) - useEffect(() => { - localStorage.setItem(RECENT_KEY, JSON.stringify(recent)) - }, [recent]) - const pushRecent = useCallback((path: string) => { setRecent((prev) => [path, ...prev.filter((p) => p !== path)].slice(0, 10)) + store.addRecent(path, baseName(path)) }, []) // ——— 文件动作 ——— const openPath = useCallback(async (path: string) => { try { const file = await readTextFile(path) - dispatch({ - type: 'open', - tab: createTab({ - path, title: baseName(path), content: file.content, - encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom, - }), + const created = createTab({ + path, title: baseName(path), content: file.content, + encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom, }) + const existing = stateRef.current.tabs.find((t) => t.path === path) + dispatch({ type: 'open', tab: created }) + const stat = await statFile(path) + if (stat.exists) baselinesRef.current.set(existing?.id ?? created.id, { mtimeMs: stat.mtimeMs, size: stat.size }) pushRecent(path) } catch (e) { setError(String(e)) } }, [pushRecent]) @@ -148,6 +171,11 @@ export default function App() { } await writeTextFile(path, toDiskContent(target.content, target.lineEnding), target.hadBom) dispatch({ type: 'saved', id: target.id, 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) pushRecent(path) return true } catch (e) { @@ -166,6 +194,259 @@ export default function App() { requestClose([id], () => dispatch({ type: 'close', id })) }, [requestClose]) + // ——— 阶段 4:存储初始化与启动恢复 ——— + const reloadTabFromDisk = useCallback(async (target: DocTab) => { + if (!target.path) return + try { + const file = await readTextFile(target.path) + 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) + } catch (e) { setError(String(e)) } + }, []) + + const restoreRecovery = useCallback(async (item: RecoveryEntry) => { + const finish = () => { + setRecoveryOffer((prev) => prev.filter((r) => r.docKey !== item.docKey)) + void store.deleteRecovery(item.docKey) + } + if (item.path) { + try { + const file = await readTextFile(item.path) + const created = createTab({ + path: item.path, title: baseName(item.path), content: file.content, + encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom, + }) + const existing = stateRef.current.tabs.find((t) => t.path === item.path) + dispatch({ type: 'open', tab: created }) + const targetId = existing?.id ?? created.id + dispatch({ type: 'updateContent', id: targetId, content: item.content }) + const stat = await statFile(item.path) + if (stat.exists) baselinesRef.current.set(targetId, { mtimeMs: stat.mtimeMs, size: stat.size }) + finish() + return + } catch { /* 文件已不存在时按未命名草稿恢复 */ } + } + const created = createTab({ title: item.title, content: '' }) + dispatch({ type: 'open', tab: created }) + dispatch({ type: 'updateContent', id: created.id, content: item.content }) + finish() + }, []) + + useEffect(() => { + let cancelled = false + void (async () => { + const recentFromDb = await store.loadRecent() + if (!cancelled && recentFromDb) setRecent(recentFromDb.map((r) => r.path)) + const uiJson = await store.loadUiPref() + if (!cancelled && uiJson) { + try { setUi((prev) => ({ ...prev, ...JSON.parse(uiJson) })) } catch { /* 偏好损坏时用默认值 */ } + } + // 启动恢复:先读磁盘现状再划分(与磁盘一致的记录静默清理) + 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) } + } + 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) + })() + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (workspaceRoot) store.addWorkspace(workspaceRoot) + }, [workspaceRoot]) + + // ——— 阶段 4:标签关闭后清理基线与恢复记录 ——— + useEffect(() => { + const current = new Map(state.tabs.map((t) => [t.id, { path: t.path, title: t.title }])) + for (const [id, prev] of lastTabsRef.current) { + if (!current.has(id)) { + baselinesRef.current.delete(id) + const key = recoveryKey({ id, path: prev.path }) + if (recoveryWrittenRef.current.delete(key)) void store.deleteRecovery(key) + } + } + lastTabsRef.current = current + }, [state.tabs]) + + // ——— 阶段 4:恢复记录写入(脏标签每 6 秒留存最新草稿) ——— + useEffect(() => { + const timer = window.setInterval(() => { + for (const t of stateRef.current.tabs) { + const key = recoveryKey(t) + if (isDirty(t)) { + if (recoveryWrittenRef.current.get(key) !== t.content) { + recoveryWrittenRef.current.set(key, t.content) + void store.saveRecovery({ + docKey: key, path: t.path, title: t.title, content: t.content, updatedAt: Date.now(), + }) + } + } else if (recoveryWrittenRef.current.delete(key)) { + void store.deleteRecovery(key) + } + } + }, 6000) + return () => window.clearInterval(timer) + }, []) + + // ——— 阶段 4:自动保存(仅已落盘的脏文档) ——— + const saveTabRef = useRef(saveTab) + saveTabRef.current = saveTab + useEffect(() => { + 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) + } + }, Math.max(10, ui.autosaveSeconds) * 1000) + return () => window.clearInterval(timer) + }, [ui.autosave, ui.autosaveSeconds]) + + // ——— 阶段 4:外部修改检测(4 秒轮询 stat) ——— + const conflictRef = useRef(null) + conflictRef.current = conflict?.tabId ?? null + useEffect(() => { + const timer = window.setInterval(() => { + void (async () => { + for (const t of stateRef.current.tabs) { + if (!t.path) continue + const base = baselinesRef.current.get(t.id) + if (!base) continue + const stat = await statFile(t.path) + if (!stat.exists) { + baselinesRef.current.delete(t.id) + flash(`《${t.title}》已被外部删除${isDirty(t) ? ',你编辑的版本仍在,保存将重新创建文件' : ''}`) + continue + } + if (stat.mtimeMs === base.mtimeMs && stat.size === base.size) continue + if (!isDirty(t)) { + await reloadTabFromDisk(t) + flash(`《${t.title}》已被外部修改,已自动重新加载`) + } else if (conflictRef.current !== t.id && !conflictSeenRef.current.has(`${t.id}:${stat.mtimeMs}`)) { + conflictSeenRef.current.add(`${t.id}:${stat.mtimeMs}`) + setConflict({ tabId: t.id }) + } + } + })() + }, 4000) + return () => window.clearInterval(timer) + }, [flash, reloadTabFromDisk]) + + // ——— 阶段 4:关窗拦截(有已落盘文档的未保存修改时先确认;未命名草稿靠恢复记录兜底) ——— + useEffect(() => { + if (!tauriMode) return + let unlisten: (() => void) | undefined + void (async () => { + const { getCurrentWindow } = await import('@tauri-apps/api/window') + unlisten = await getCurrentWindow().onCloseRequested((event) => { + 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(), + }) + } + if (dirty.some((t) => t.path)) { + event.preventDefault() + setQuitOpen(true) + } + }) + })() + return () => { unlisten?.() } + }, [tauriMode]) + + const destroyWindow = useCallback(async () => { + const { getCurrentWindow } = await import('@tauri-apps/api/window') + await getCurrentWindow().destroy() + }, []) + + // ——— 阶段 4:工作区文件管理 ——— + const joinPath = (dir: string, name: string) => `${dir.replace(/[\\/]+$/, '')}/${name}` + const dirOf = (path: string) => path.replace(/[\\/][^\\/]*$/, '') + + const createFileInWorkspace = useCallback(() => { + if (!workspaceRoot) return + setPromptSpec({ + title: '在工作区新建文档', label: '文件名', initial: '未命名.md', + submit: (name) => { + setPromptSpec(null) + const fileName = /\.(md|markdown|txt)$/i.test(name) ? name : `${name}.md` + const path = joinPath(workspaceRoot, fileName) + void (async () => { + try { + await createTextFile(path) + setTreeRefresh((n) => n + 1) + await openPath(path) + } catch (e) { setError(String(e)) } + })() + }, + }) + }, [workspaceRoot, openPath]) + + const renameEntry = useCallback((entry: DirEntry) => { + setPromptSpec({ + title: '重命名', label: '新名称', initial: entry.name, + submit: (name) => { + setPromptSpec(null) + if (name === entry.name) return + const target = joinPath(dirOf(entry.path), name) + void (async () => { + try { + await renamePath(entry.path, target) + const open = stateRef.current.tabs.find((t) => t.path === entry.path) + if (open) { + dispatch({ type: 'setPath', id: open.id, path: target, title: baseName(target) }) + store.removeRecent(entry.path) + store.addRecent(target, baseName(target)) + const oldKey = recoveryKey(open) + if (recoveryWrittenRef.current.delete(oldKey)) void store.deleteRecovery(oldKey) + } + setTreeRefresh((n) => n + 1) + flash(`已重命名为 ${name}`) + } catch (e) { setError(String(e)) } + })() + }, + }) + }, [flash]) + + const deleteEntry = useCallback((entry: DirEntry) => { + setConfirmSpec({ + title: '删除到回收站', + message: `确定把「${entry.name}」移到系统回收站吗?${entry.isDir ? '文件夹内的文档若已打开将一并关闭。' : '若该文档已打开将一并关闭。'}`, + confirmLabel: '删除', + onOk: () => { + setConfirmSpec(null) + void (async () => { + try { + 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 }) + const key = recoveryKey(t) + if (recoveryWrittenRef.current.delete(key)) void store.deleteRecovery(key) + } + } + store.removeRecent(entry.path) + setTreeRefresh((n) => n + 1) + flash(`已删除 ${entry.name}`) + } catch (e) { setError(String(e)) } + })() + }, + }) + }, [flash]) + // ——— 编辑器动作 ——— const withEditor = useCallback((fn: (e: Editor) => void) => { if (editorRef.current) { fn(editorRef.current); focusEditor(editorRef.current) } @@ -350,6 +631,7 @@ export default function App() { { divider: true }, { label: '源代码模式', checked: sourceMode, disabled: !tab, onClick: toggleSourceMode }, { label: '深色主题', checked: ui.theme === 'dark', onClick: () => setUi((u) => ({ ...u, theme: u.theme === 'dark' ? 'light' : 'dark' })) }, + { label: `自动保存(${ui.autosaveSeconds} 秒)`, checked: ui.autosave, onClick: () => setUi((u) => ({ ...u, autosave: !u.autosave })) }, { divider: true }, { label: '放大', shortcut: 'Ctrl+=', onClick: () => setZoom((z) => Math.min(1.4, +(z + 0.1).toFixed(2))) }, { label: '缩小', shortcut: 'Ctrl+-', onClick: () => setZoom((z) => Math.max(0.7, +(z - 0.1).toFixed(2))) }, @@ -396,6 +678,10 @@ export default function App() { activePath={tab?.path ?? null} onOpenFile={(path) => void openPath(path)} onOpenFolder={() => void openFolder()} + onCreateFile={tauriMode && workspaceRoot ? createFileInWorkspace : undefined} + onRenameEntry={tauriMode ? renameEntry : undefined} + onDeleteEntry={tauriMode ? deleteEntry : undefined} + refreshSignal={treeRefresh} outline={outline} onJumpHeading={jumpHeading} tauriMode={tauriMode} @@ -457,6 +743,78 @@ export default function App() { tauriMode={tauriMode} zoom={zoom} /> + {notice &&
{notice}
} + {recoveryOffer.length > 0 && !recoveryDismissed && ( + void restoreRecovery(item)} + onDiscard={(item) => { + setRecoveryOffer((prev) => prev.filter((r) => r.docKey !== item.docKey)) + void store.deleteRecovery(item.docKey) + }} + onClose={() => setRecoveryDismissed(true)} + /> + )} + {conflict && (() => { + const target = state.tabs.find((t) => t.id === conflict.tabId) + if (!target) return null + return ( + { setConflict(null); void reloadTabFromDisk(target) }} + onKeepMine={() => { + setConflict(null) + if (target.path) { + void statFile(target.path).then((stat) => { + if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size }) + }) + } + }} + onSaveAs={() => { setConflict(null); void saveTab(target, true) }} + /> + ) + })()} + {quitOpen && ( + t.path && isDirty(t)).map((t) => t.title)} + onCancel={() => setQuitOpen(false)} + onDiscardAndQuit={() => { + for (const t of state.tabs) { + if (isDirty(t)) void store.deleteRecovery(recoveryKey(t)) + } + void destroyWindow() + }} + onSaveAndQuit={() => { + void (async () => { + for (const t of stateRef.current.tabs) { + if (t.path && isDirty(t)) { + const ok = await saveTab(t, false) + if (!ok) { setQuitOpen(false); return } + } + } + await destroyWindow() + })() + }} + /> + )} + {promptSpec && ( + setPromptSpec(null)} + /> + )} + {confirmSpec && ( + setConfirmSpec(null)} + /> + )} {pendingClose && ( void onOpenFolder: () => void + onCreateFile?: () => void + onRenameEntry?: (entry: DirEntry) => void + onDeleteEntry?: (entry: DirEntry) => void + refreshSignal?: number outline: OutlineItem[] onJumpHeading: (item: OutlineItem) => void tauriMode: boolean @@ -38,7 +42,15 @@ export default function Sidebar(p: Props) { ))}
) : p.workspaceRoot ? ( - + ) : (

尚未打开文件夹。

@@ -51,25 +63,55 @@ export default function Sidebar(p: Props) { ) } -function FileTree({ root, activePath, onOpenFile }: { root: string; activePath: string | null; onOpenFile: (path: string) => void }) { +function FileTree({ root, activePath, onOpenFile, onCreateFile, onRenameEntry, onDeleteEntry, refreshSignal }: { + root: string + activePath: string | null + onOpenFile: (path: string) => void + onCreateFile?: () => void + onRenameEntry?: (entry: DirEntry) => void + onDeleteEntry?: (entry: DirEntry) => void + refreshSignal: number +}) { const [refreshKey, setRefreshKey] = useState(0) + const [menu, setMenu] = useState<{ x: number; y: number; entry: DirEntry } | null>(null) + useEffect(() => { if (refreshSignal > 0) setRefreshKey((k) => k + 1) }, [refreshSignal]) + useEffect(() => { + if (!menu) return + const close = () => setMenu(null) + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMenu(null) } + window.addEventListener('click', close) + window.addEventListener('keydown', onKey) + return () => { window.removeEventListener('click', close); window.removeEventListener('keydown', onKey) } + }, [menu]) return (
{baseName(root)} + {onCreateFile && }
- + setMenu({ entry, x, y })} + /> + {menu && ( +
e.stopPropagation()}> + {!menu.entry.isDir && } + {onRenameEntry && } + {onDeleteEntry && } +
+ )}
) } -function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey }: { +function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu }: { dir: string depth: number activePath: string | null onOpenFile: (path: string) => void refreshKey: number + onEntryMenu: (entry: DirEntry, x: number, y: number) => void }) { const [entries, setEntries] = useState(null) const [error, setError] = useState(null) @@ -106,13 +148,14 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey }: { className={`tree-item${!entry.isDir && entry.path === activePath ? ' active' : ''}`} style={{ paddingLeft: 8 + depth * 13 }} onClick={() => (entry.isDir ? toggle(entry.path) : onOpenFile(entry.path))} + onContextMenu={(e) => { e.preventDefault(); onEntryMenu(entry, e.clientX, e.clientY) }} title={entry.path} > {entry.isDir ? (expanded.has(entry.path) ? '▾' : '▸') : '·'} {entry.name} {entry.isDir && expanded.has(entry.path) && ( - + )}
))} diff --git a/src/components/Stage4Modals.tsx b/src/components/Stage4Modals.tsx new file mode 100644 index 0000000..8e2aba9 --- /dev/null +++ b/src/components/Stage4Modals.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef, useState } from 'react' +import type { RecoveryEntry } from '../services/platform' + +function Shell({ children, wide }: { children: React.ReactNode; wide?: boolean }) { + return ( +
+
{children}
+
+ ) +} + +/** 启动恢复:列出上次遗留的未保存草稿,逐项恢复或丢弃。 */ +export function RecoveryModal({ items, onRestore, onDiscard, onClose }: { + items: RecoveryEntry[] + onRestore: (item: RecoveryEntry) => void + onDiscard: (item: RecoveryEntry) => void + onClose: () => void +}) { + if (items.length === 0) return null + return ( + +

发现未保存的草稿

+

以下内容在上次退出时尚未保存(已自动留存),可逐项恢复或丢弃:

+
    + {items.map((item) => ( +
  • +
    + {item.title} + {new Date(item.updatedAt).toLocaleString()} · {item.content.length} 字 +
    +
    + + +
    +
  • + ))} +
+
+ +
+
+ ) +} + +/** 外部修改冲突:磁盘上的文件变了,而当前标签有未保存修改。 */ +export function ConflictModal({ title, onReload, onKeepMine, onSaveAs }: { + title: string + onReload: () => void + onKeepMine: () => void + onSaveAs: () => void +}) { + return ( + +

文件已被外部修改

+

《{title}》在磁盘上已被其他程序修改,而你有未保存的更改。请选择如何处理:

+
+ + + +
+
+ ) +} + +/** 退出前仍有已保存路径的脏文档时的确认。 */ +export function QuitModal({ titles, onSaveAndQuit, onDiscardAndQuit, onCancel }: { + titles: string[] + onSaveAndQuit: () => void + onDiscardAndQuit: () => void + onCancel: () => void +}) { + return ( + +

退出前保存?

+

以下文档有未保存的修改:{titles.join('、')}。未命名草稿已自动留存,下次启动可恢复。

+
+ + + +
+
+ ) +} + +/** 通用输入弹窗(新建/重命名)。 */ +export function PromptModal({ title, label, initial, onSubmit, onCancel }: { + title: string + label: string + initial: string + onSubmit: (value: string) => void + onCancel: () => void +}) { + const [value, setValue] = useState(initial) + const inputRef = useRef(null) + useEffect(() => { + inputRef.current?.focus() + inputRef.current?.select() + }, []) + return ( + +

{title}

+ +
+ + +
+
+ ) +} + +/** 通用确认弹窗(删除等危险操作)。 */ +export function ConfirmModal({ title, message, confirmLabel, onConfirm, onCancel }: { + title: string + message: string + confirmLabel: string + onConfirm: () => void + onCancel: () => void +}) { + return ( + +

{title}

+

{message}

+
+ + +
+
+ ) +} diff --git a/src/services/platform.ts b/src/services/platform.ts index 9ba4f09..3970b87 100644 --- a/src/services/platform.ts +++ b/src/services/platform.ts @@ -139,3 +139,88 @@ export function downloadTextFile(name: string, content: string): void { a.click() URL.revokeObjectURL(url) } + +// ————————————————— 阶段 4:文件状态/管理与 SQLite 存储 ————————————————— + +export interface FileStat { + exists: boolean + mtimeMs: number + size: number +} + +export async function statFile(path: string): Promise { + if (!isTauri()) return { exists: true, mtimeMs: 0, size: 0 } + return invoke('stat_file', { path }) +} + +export async function createTextFile(path: string): Promise { + return invoke('create_text_file', { path }) +} + +export async function createDirPath(path: string): Promise { + return invoke('create_dir', { path }) +} + +export async function renamePath(from: string, to: string): Promise { + return invoke('rename_path', { from, to }) +} + +export async function deleteToTrash(path: string): Promise { + return invoke('delete_to_trash', { path }) +} + +export interface RecentEntry { + path: string + name: string + openedAt: number +} + +export interface RecoveryEntry { + docKey: string + path: string | null + title: string + content: string + updatedAt: number +} + +export async function dbRecentList(): Promise { + return invoke('db_recent_list') +} + +export async function dbRecentAdd(path: string, name: string, openedAt: number): Promise { + return invoke('db_recent_add', { path, name, openedAt }) +} + +export async function dbRecentRemove(path: string): Promise { + return invoke('db_recent_remove', { path }) +} + +export async function dbWorkspaceAdd(path: string, openedAt: number): Promise { + return invoke('db_workspace_add', { path, openedAt }) +} + +export async function dbPrefGet(key: string): Promise { + return invoke('db_pref_get', { key }) +} + +export async function dbPrefSet(key: string, value: string): Promise { + return invoke('db_pref_set', { key, value }) +} + +export async function dbRecoveryList(): Promise { + return invoke('db_recovery_list') +} + +export async function dbRecoverySave(entry: RecoveryEntry): Promise { + return invoke('db_recovery_save', { + docKey: entry.docKey, + path: entry.path, + title: entry.title, + content: entry.content, + updatedAt: entry.updatedAt, + }) +} + +export async function dbRecoveryDelete(docKey: string): Promise { + return invoke('db_recovery_delete', { docKey }) +} diff --git a/src/services/store.ts b/src/services/store.ts new file mode 100644 index 0000000..43660d9 --- /dev/null +++ b/src/services/store.ts @@ -0,0 +1,137 @@ +// 持久化服务(阶段 4):桌面模式走 Rust/SQLite,Web 降级模式走 localStorage。 +// 阶段 1 的 localStorage 数据在桌面模式首次启动时一次性迁入 SQLite(迁完清掉本地副本)。 +import { + isTauri, dbRecentList, dbRecentAdd, dbRecentRemove, dbWorkspaceAdd, + dbPrefGet, dbPrefSet, dbRecoveryList, dbRecoverySave, dbRecoveryDelete, + type RecoveryEntry, +} from './platform' + +export type { RecoveryEntry } + +export interface RecentFile { + path: string + name: string +} + +const LS_RECENT = 'new-markdown:recent' +const LS_UI = 'new-markdown:ui' +const LS_RECOVERY = 'new-markdown:recovery' + +export async function loadRecent(): Promise { + if (!isTauri()) return null + try { + const dbList = (await dbRecentList()).map((r) => ({ path: r.path, name: r.name })) + const legacy = readLegacyRecent() + if (dbList.length === 0 && legacy.length > 0) { + // 一次性迁移:按旧顺序倒序写入(openedAt 递增),再清本地副本 + for (let i = legacy.length - 1; i >= 0; i--) { + await dbRecentAdd(legacy[i].path, legacy[i].name, Date.now() - (legacy.length - i) * 1000) + } + localStorage.removeItem(LS_RECENT) + return legacy + } + if (legacy.length > 0) localStorage.removeItem(LS_RECENT) + return dbList + } catch { + return null + } +} + +function readLegacyRecent(): RecentFile[] { + try { + const raw = JSON.parse(localStorage.getItem(LS_RECENT) ?? '[]') as (string | RecentFile)[] + // 阶段 1 存的是路径字符串数组,兼容对象数组 + return raw.map((r) => (typeof r === 'string' ? { path: r, name: r.split(/[\\/]/).pop() ?? r } : r)) + } catch { + return [] + } +} + +export function addRecent(path: string, name: string): void { + if (isTauri()) { + void dbRecentAdd(path, name, Date.now()).catch(() => undefined) + return + } + const list = readLegacyRecent().filter((r) => r.path !== path) + list.unshift({ path, name }) + localStorage.setItem(LS_RECENT, JSON.stringify(list.slice(0, 10))) +} + +export function removeRecent(path: string): void { + if (isTauri()) { + void dbRecentRemove(path).catch(() => undefined) + return + } + localStorage.setItem(LS_RECENT, JSON.stringify(readLegacyRecent().filter((r) => r.path !== path))) +} + +export function addWorkspace(path: string): void { + if (isTauri()) void dbWorkspaceAdd(path, Date.now()).catch(() => undefined) +} + +export async function loadUiPref(): Promise { + if (!isTauri()) return null + try { + const fromDb = await dbPrefGet('ui') + if (fromDb) { + localStorage.removeItem(LS_UI) + return fromDb + } + const legacy = localStorage.getItem(LS_UI) + if (legacy) { + await dbPrefSet('ui', legacy) + localStorage.removeItem(LS_UI) + return legacy + } + return null + } catch { + return null + } +} + +export function saveUiPref(json: string): void { + if (isTauri()) { + void dbPrefSet('ui', json).catch(() => undefined) + return + } + localStorage.setItem(LS_UI, json) +} + +// ——— 崩溃恢复记录:桌面走 SQLite,Web 走 localStorage(同构接口) ——— + +function readWebRecovery(): RecoveryEntry[] { + try { + return JSON.parse(localStorage.getItem(LS_RECOVERY) ?? '[]') as RecoveryEntry[] + } catch { + return [] + } +} + +export async function listRecovery(): Promise { + if (isTauri()) { + try { + return await dbRecoveryList() + } catch { + return [] + } + } + return readWebRecovery() +} + +export async function saveRecovery(entry: RecoveryEntry): Promise { + if (isTauri()) { + await dbRecoverySave(entry).catch(() => undefined) + return + } + const list = readWebRecovery().filter((r) => r.docKey !== entry.docKey) + list.unshift(entry) + localStorage.setItem(LS_RECOVERY, JSON.stringify(list.slice(0, 20))) +} + +export async function deleteRecovery(docKey: string): Promise { + if (isTauri()) { + await dbRecoveryDelete(docKey).catch(() => undefined) + return + } + localStorage.setItem(LS_RECOVERY, JSON.stringify(readWebRecovery().filter((r) => r.docKey !== docKey))) +} diff --git a/src/stores/tabs.ts b/src/stores/tabs.ts index a94f904..56f6a9d 100644 --- a/src/stores/tabs.ts +++ b/src/stores/tabs.ts @@ -47,6 +47,8 @@ export type TabsAction = | { type: 'activate'; id: string } | { type: 'updateContent'; id: string; content: string } | { type: 'saved'; id: string; path?: string; title?: string } + | { type: 'loadContent'; id: string; content: string; encoding?: string; lineEnding?: string; hadBom?: boolean } + | { type: 'setPath'; id: string; path: string; title: string } | { type: 'close'; id: string } | { type: 'closeOthers'; id: string } | { type: 'closeRight'; id: string } @@ -78,6 +80,28 @@ export function tabsReducer(state: TabsState, action: TabsAction): TabsState { : t, ), } + case 'loadContent': + // 外部修改后整表重载:内容与已保存内容一起替换(重载即与磁盘对齐,标签变干净) + return { + ...state, + tabs: state.tabs.map((t) => + t.id === action.id + ? { + ...t, + content: action.content, + savedContent: action.content, + encoding: action.encoding ?? t.encoding, + lineEnding: action.lineEnding ?? t.lineEnding, + hadBom: action.hadBom ?? t.hadBom, + } + : t, + ), + } + case 'setPath': + return { + ...state, + tabs: state.tabs.map((t) => (t.id === action.id ? { ...t, path: action.path, title: action.title } : t)), + } case 'close': { const idx = state.tabs.findIndex((t) => t.id === action.id) if (idx < 0) return state diff --git a/src/styles.css b/src/styles.css index c3e216d..344f0bf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -147,3 +147,16 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius .mermaid-preview svg { max-width: 100%; height: auto; } .mermaid-preview.mermaid-error { color: #b3261e; font-size: 13px; text-align: left; white-space: pre-wrap; } .mermaid-block pre { margin: 0 !important; border-radius: 0 !important; } +.app-notice { position: fixed; right: 14px; bottom: 40px; z-index: 60; background: var(--bg-editor); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px; padding: 8px 12px; font-size: 13px; box-shadow: 0 8px 22px rgba(0,0,0,0.14); max-width: 420px; } +.modal-note { color: var(--text-dim); font-size: 13px; } +.recovery-list { list-style: none; margin: 10px 0; padding: 0; display: flex; flex-direction: column; gap: 8px; max-height: 300px; overflow: auto; } +.recovery-list li { display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; } +.recovery-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.recovery-meta span { color: var(--text-dim); font-size: 12px; } +.recovery-actions { display: flex; gap: 6px; flex: none; } +.modal-actions.column { flex-direction: column; align-items: stretch; } +.modal-field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; margin: 8px 0 4px; } +.tree-context-menu { position: fixed; z-index: 90; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 10px 26px rgba(0,0,0,0.16); padding: 4px; min-width: 150px; } +.tree-context-menu button { display: block; width: 100%; text-align: left; border: 0; background: transparent; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; } +.tree-context-menu button:hover { background: var(--accent-weak); } +.tree-context-menu button.danger { color: #b3261e; } diff --git a/src/utils/recovery.ts b/src/utils/recovery.ts new file mode 100644 index 0000000..6486623 --- /dev/null +++ b/src/utils/recovery.ts @@ -0,0 +1,40 @@ +// 崩溃恢复的纯逻辑(阶段 4):恢复记录的键、启动时该提供/该清理的划分。 +import type { RecoveryEntry } from '../services/platform' + +export interface RecoveryTabLike { + id: string + path: string | null +} + +/** 恢复记录的稳定键:文件文档按路径(跨会话一致),未命名文档按标签 id(仅本机延续)。 */ +export function recoveryKey(tab: RecoveryTabLike): string { + return tab.path ? `path:${tab.path}` : `untitled:${tab.id}` +} + +export interface RecoveryPlan { + /** 应向用户提供的恢复项 */ + offer: RecoveryEntry[] + /** 可静默清理的记录键(内容与磁盘一致,说明已正常保存) */ + purgeKeys: string[] +} + +/** + * 按磁盘现状划分恢复记录: + * - 文件记录:磁盘内容与记录一致 → 已保存,清掉;不一致或读不到(记录了路径但文件已删)→ 提供恢复。 + * - 未命名记录:总是提供恢复。 + * diskContent 取不到(文件不存在/读取失败)时传 null。 + */ +export function planRecovery(records: RecoveryEntry[], diskContent: (path: string) => string | null): RecoveryPlan { + const offer: RecoveryEntry[] = [] + const purgeKeys: string[] = [] + for (const record of records) { + if (!record.path) { + offer.push(record) + continue + } + const disk = diskContent(record.path) + if (disk !== null && disk === record.content) purgeKeys.push(record.docKey) + else offer.push(record) + } + return { offer, purgeKeys } +} diff --git a/tests/stage4.test.ts b/tests/stage4.test.ts new file mode 100644 index 0000000..51d9072 --- /dev/null +++ b/tests/stage4.test.ts @@ -0,0 +1,52 @@ +// 阶段 4 验收:恢复记录纯逻辑(键/提供与清理划分)与标签 reducer 的重载/改路径动作。 +import { describe, it, expect } from 'vitest' +import { recoveryKey, planRecovery } from '../src/utils/recovery' +import { tabsReducer, createTab, isDirty, type TabsState } from '../src/stores/tabs' +import type { RecoveryEntry } from '../src/services/platform' + +describe('recoveryKey', () => { + it('文件文档按路径键,未命名按标签 id', () => { + expect(recoveryKey({ id: 't1', path: 'D:\\a.md' })).toBe('path:D:\\a.md') + expect(recoveryKey({ id: 't2', path: null })).toBe('untitled:t2') + }) +}) + +describe('planRecovery', () => { + const fileRecord: RecoveryEntry = { + docKey: 'path:D:\\a.md', path: 'D:\\a.md', title: 'a', content: '新草稿', updatedAt: 2, + } + const savedRecord: RecoveryEntry = { + docKey: 'path:D:\\b.md', path: 'D:\\b.md', title: 'b', content: '已存内容', updatedAt: 1, + } + const untitledRecord: RecoveryEntry = { + docKey: 'untitled:t9', path: null, title: '未命名', content: '临时', updatedAt: 3, + } + + it('与磁盘一致的记录清理、不一致与未命名的提供恢复、文件已删的也提供', () => { + const plan = planRecovery([fileRecord, savedRecord, untitledRecord], (path) => + path === 'D:\\b.md' ? '已存内容' : '磁盘旧内容', + ) + expect(plan.purgeKeys).toEqual(['path:D:\\b.md']) + expect(plan.offer.map((r) => r.docKey)).toEqual(['path:D:\\a.md', 'untitled:t9']) + + const gonePlan = planRecovery([fileRecord], () => null) + expect(gonePlan.offer).toHaveLength(1) + expect(gonePlan.purgeKeys).toEqual([]) + }) +}) + +describe('tabs reducer 阶段 4 动作', () => { + it('loadContent 整表替换并变干净,setPath 改路径与标题', () => { + const tab = createTab({ path: 'D:\\a.md', title: 'a', content: '旧' }) + let state: TabsState = { tabs: [tab], activeId: tab.id } + state = tabsReducer(state, { type: 'updateContent', id: tab.id, content: '我的修改' }) + expect(isDirty(state.tabs[0])).toBe(true) + state = tabsReducer(state, { type: 'loadContent', id: tab.id, content: '磁盘新内容', encoding: 'GBK' }) + expect(state.tabs[0].content).toBe('磁盘新内容') + expect(isDirty(state.tabs[0])).toBe(false) + expect(state.tabs[0].encoding).toBe('GBK') + state = tabsReducer(state, { type: 'setPath', id: tab.id, path: 'D:\\b.md', title: 'b' }) + expect(state.tabs[0].path).toBe('D:\\b.md') + expect(state.tabs[0].title).toBe('b') + }) +})