feat(stage4): 自动保存/崩溃恢复/外部修改检测/工作区管理(含合并验收清单)
This commit is contained in:
@@ -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
37
docs/08-stage4-report.md
Normal 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、打印与打印预览、导出时资源处理(图片转相对路径/内嵌选项)。
|
||||
62
docs/08-windows-checklist.md
Normal file
62
docs/08-windows-checklist.md
Normal 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
|
||||
386
src/App.tsx
386
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<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}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
141
src/components/Stage4Modals.tsx
Normal file
141
src/components/Stage4Modals.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
137
src/services/store.ts
Normal 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)))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
40
src/utils/recovery.ts
Normal 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
52
tests/stage4.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user