feat(stage4): 自动保存/崩溃恢复/外部修改检测/工作区管理(含合并验收清单)

This commit is contained in:
Muse
2026-11-13 22:43:29 +08:00
parent 0774b70d29
commit e5ad58eb93
12 changed files with 1013 additions and 21 deletions

View File

@@ -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 上运行桌面版:

37
docs/08-stage4-report.md Normal file
View File

@@ -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、打印与打印预览、导出时资源处理(图片转相对路径/内嵌选项)。

View File

@@ -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**:脚注定义与引用、行内 `<u>`、块级 `<div>` 往返稳定
## 5. 已知不做 / 留待阶段 6 的项(验到也不算问题)
- 图片暂无缩放 / 对齐操作条;表格无行列拖动排序、无多格拖拽选区
- 列表标记 `-` / `1.` 由浏览器样式天然呈现,不做显隐
- 表格对齐冒号首轮会被规范化(` :--- ` → ` :- `),语义无损、次轮稳定
## 6. 验完之后
- 清单全绿 → 可拍板进**阶段 4**(SQLite 落库、自动保存与崩溃恢复、外部修改检测、工作区新建/重命名/删除)
- 有问题 → 把未通过项贴回对话,按项修,不急着进阶段 4

View File

@@ -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<string | null>(null)
const [pendingClose, setPendingClose] = useState<{ ids: string[]; after: () => void } | null>(null)
// ——— 阶段 4 状态 ———
const [notice, setNotice] = useState<string | null>(null)
const [recoveryOffer, setRecoveryOffer] = useState<RecoveryEntry[]>([])
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<string, { mtimeMs: number; size: number }>())
const conflictSeenRef = useRef(new Set<string>())
const recoveryWrittenRef = useRef(new Map<string, string>())
const lastTabsRef = useRef(new Map<string, { path: string | null; title: string }>())
const flash = useCallback((msg: string) => {
setNotice(msg)
window.setTimeout(() => setNotice((cur) => (cur === msg ? null : cur)), 6000)
}, [])
const editorRef = useRef<Editor | null>(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<string, string | null>()
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<string | null>(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 && <div className="app-notice">{notice}</div>}
{recoveryOffer.length > 0 && !recoveryDismissed && (
<RecoveryModal
items={recoveryOffer}
onRestore={(item) => 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 (
<ConflictModal
title={target.title}
onReload={() => { 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 && (
<QuitModal
titles={state.tabs.filter((t) => 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 && (
<PromptModal
title={promptSpec.title}
label={promptSpec.label}
initial={promptSpec.initial}
onSubmit={promptSpec.submit}
onCancel={() => setPromptSpec(null)}
/>
)}
{confirmSpec && (
<ConfirmModal
title={confirmSpec.title}
message={confirmSpec.message}
confirmLabel={confirmSpec.confirmLabel}
onConfirm={confirmSpec.onOk}
onCancel={() => setConfirmSpec(null)}
/>
)}
{pendingClose && (
<ConfirmSaveModal
title={pendingTitles}

View File

@@ -10,6 +10,10 @@ interface Props {
activePath: string | null
onOpenFile: (path: string) => 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) {
))}
</div>
) : p.workspaceRoot ? (
<FileTree root={p.workspaceRoot} activePath={p.activePath} onOpenFile={p.onOpenFile} />
<FileTree
root={p.workspaceRoot}
activePath={p.activePath}
onOpenFile={p.onOpenFile}
onCreateFile={p.onCreateFile}
onRenameEntry={p.onRenameEntry}
onDeleteEntry={p.onDeleteEntry}
refreshSignal={p.refreshSignal ?? 0}
/>
) : (
<div className="sidebar-empty">
<p>尚未打开文件夹。</p>
@@ -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 (
<div className="filetree">
<div className="filetree-head">
<span className="filetree-root" title={root}>{baseName(root)}</span>
{onCreateFile && <button aria-label="新建文档" title="在工作区新建文档" onClick={onCreateFile}>+</button>}
<button aria-label="刷新目录" onClick={() => setRefreshKey((k) => k + 1)}>⟳</button>
</div>
<TreeLevel dir={root} depth={0} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey} />
<TreeLevel
dir={root} depth={0} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey}
onEntryMenu={(entry, x, y) => setMenu({ entry, x, y })}
/>
{menu && (
<div className="tree-context-menu" style={{ left: menu.x, top: menu.y }} onClick={(e) => e.stopPropagation()}>
{!menu.entry.isDir && <button onClick={() => { onOpenFile(menu.entry.path); setMenu(null) }}>打开</button>}
{onRenameEntry && <button onClick={() => { onRenameEntry(menu.entry); setMenu(null) }}>重命名…</button>}
{onDeleteEntry && <button className="danger" onClick={() => { onDeleteEntry(menu.entry); setMenu(null) }}>删除到回收站</button>}
</div>
)}
</div>
)
}
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<DirEntry[] | null>(null)
const [error, setError] = useState<string | null>(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}
>
<span className="tree-icon">{entry.isDir ? (expanded.has(entry.path) ? '▾' : '▸') : '·'}</span>
{entry.name}
</button>
{entry.isDir && expanded.has(entry.path) && (
<TreeLevel dir={entry.path} depth={depth + 1} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey} />
<TreeLevel dir={entry.path} depth={depth + 1} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey} onEntryMenu={onEntryMenu} />
)}
</div>
))}

View File

@@ -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 (
<div className="modal-backdrop">
<div className="modal" style={wide ? { width: 520 } : undefined}>{children}</div>
</div>
)
}
/** 启动恢复:列出上次遗留的未保存草稿,逐项恢复或丢弃。 */
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 (
<Shell wide>
<h3>发现未保存的草稿</h3>
<p className="modal-note">以下内容在上次退出时尚未保存(已自动留存),可逐项恢复或丢弃:</p>
<ul className="recovery-list">
{items.map((item) => (
<li key={item.docKey}>
<div className="recovery-meta">
<strong>{item.title}</strong>
<span>{new Date(item.updatedAt).toLocaleString()} · {item.content.length} 字</span>
</div>
<div className="recovery-actions">
<button className="primary" onClick={() => onRestore(item)}>恢复</button>
<button onClick={() => onDiscard(item)}>丢弃</button>
</div>
</li>
))}
</ul>
<div className="modal-actions">
<button onClick={onClose}>稍后再说</button>
</div>
</Shell>
)
}
/** 外部修改冲突:磁盘上的文件变了,而当前标签有未保存修改。 */
export function ConflictModal({ title, onReload, onKeepMine, onSaveAs }: {
title: string
onReload: () => void
onKeepMine: () => void
onSaveAs: () => void
}) {
return (
<Shell>
<h3>文件已被外部修改</h3>
<p>《{title}》在磁盘上已被其他程序修改,而你有未保存的更改。请选择如何处理:</p>
<div className="modal-actions column">
<button className="primary" onClick={onReload}>重新加载磁盘版本(丢弃我的修改)</button>
<button onClick={onKeepMine}>保留我的内容(下次保存时覆盖磁盘)</button>
<button onClick={onSaveAs}>把我的内容另存为…</button>
</div>
</Shell>
)
}
/** 退出前仍有已保存路径的脏文档时的确认。 */
export function QuitModal({ titles, onSaveAndQuit, onDiscardAndQuit, onCancel }: {
titles: string[]
onSaveAndQuit: () => void
onDiscardAndQuit: () => void
onCancel: () => void
}) {
return (
<Shell>
<h3>退出前保存?</h3>
<p>以下文档有未保存的修改:{titles.join('、')}。未命名草稿已自动留存,下次启动可恢复。</p>
<div className="modal-actions">
<button onClick={onCancel}>取消</button>
<button onClick={onDiscardAndQuit}>不保存退出</button>
<button className="primary" onClick={onSaveAndQuit}>保存并退出</button>
</div>
</Shell>
)
}
/** 通用输入弹窗(新建/重命名)。 */
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<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [])
return (
<Shell>
<h3>{title}</h3>
<label className="modal-field">
<span>{label}</span>
<input
ref={inputRef}
className="input"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && value.trim()) onSubmit(value.trim())
if (e.key === 'Escape') onCancel()
}}
/>
</label>
<div className="modal-actions">
<button onClick={onCancel}>取消</button>
<button className="primary" disabled={!value.trim()} onClick={() => onSubmit(value.trim())}>确定</button>
</div>
</Shell>
)
}
/** 通用确认弹窗(删除等危险操作)。 */
export function ConfirmModal({ title, message, confirmLabel, onConfirm, onCancel }: {
title: string
message: string
confirmLabel: string
onConfirm: () => void
onCancel: () => void
}) {
return (
<Shell>
<h3>{title}</h3>
<p>{message}</p>
<div className="modal-actions">
<button onClick={onCancel}>取消</button>
<button className="danger" onClick={onConfirm}>{confirmLabel}</button>
</div>
</Shell>
)
}

View File

@@ -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<FileStat> {
if (!isTauri()) return { exists: true, mtimeMs: 0, size: 0 }
return invoke<FileStat>('stat_file', { path })
}
export async function createTextFile(path: string): Promise<void> {
return invoke<void>('create_text_file', { path })
}
export async function createDirPath(path: string): Promise<void> {
return invoke<void>('create_dir', { path })
}
export async function renamePath(from: string, to: string): Promise<void> {
return invoke<void>('rename_path', { from, to })
}
export async function deleteToTrash(path: string): Promise<void> {
return invoke<void>('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<RecentEntry[]> {
return invoke<RecentEntry[]>('db_recent_list')
}
export async function dbRecentAdd(path: string, name: string, openedAt: number): Promise<void> {
return invoke<void>('db_recent_add', { path, name, openedAt })
}
export async function dbRecentRemove(path: string): Promise<void> {
return invoke<void>('db_recent_remove', { path })
}
export async function dbWorkspaceAdd(path: string, openedAt: number): Promise<void> {
return invoke<void>('db_workspace_add', { path, openedAt })
}
export async function dbPrefGet(key: string): Promise<string | null> {
return invoke<string | null>('db_pref_get', { key })
}
export async function dbPrefSet(key: string, value: string): Promise<void> {
return invoke<void>('db_pref_set', { key, value })
}
export async function dbRecoveryList(): Promise<RecoveryEntry[]> {
return invoke<RecoveryEntry[]>('db_recovery_list')
}
export async function dbRecoverySave(entry: RecoveryEntry): Promise<void> {
return invoke<void>('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<void> {
return invoke<void>('db_recovery_delete', { docKey })
}

137
src/services/store.ts Normal file
View File

@@ -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<RecentFile[] | null> {
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<string | null> {
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<RecoveryEntry[]> {
if (isTauri()) {
try {
return await dbRecoveryList()
} catch {
return []
}
}
return readWebRecovery()
}
export async function saveRecovery(entry: RecoveryEntry): Promise<void> {
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<void> {
if (isTauri()) {
await dbRecoveryDelete(docKey).catch(() => undefined)
return
}
localStorage.setItem(LS_RECOVERY, JSON.stringify(readWebRecovery().filter((r) => r.docKey !== docKey)))
}

View File

@@ -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

View File

@@ -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; }

40
src/utils/recovery.ts Normal file
View File

@@ -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 }
}

52
tests/stage4.test.ts Normal file
View File

@@ -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')
})
})