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

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