diff --git a/docs/29-全项目审计.md b/docs/29-全项目审计.md index 6fbb10e..671cda9 100644 --- a/docs/29-全项目审计.md +++ b/docs/29-全项目审计.md @@ -132,6 +132,13 @@ src-tauri/src/ 全部(1289 行)。先说结论:发现 **1 个高危数据 - D1(中)非 UTF-8 文件首次保存被静默转码为 UTF-8(GBK/UTF-16 文档存一次即变 UTF-8):编辑器内部统一 UTF-8 是既定设计,但 落盘编码变更应至少首次提示一次。需前端交互拍板,未擅自加。 + → **已于当晚按用户拍板实现**:saveTab 在落盘前拦截,首次保存 + 弹 EncodingConvertModal 说明将转 UTF-8(按路径记已确认、本次 + 运行只提示一次);自动保存不弹窗也不静默转码,跳过该文档等 + 手动保存时提示;确认后以冻结快照完成落盘,标签 encoding 如实 + 更新为 UTF-8。弹窗渲染置于所有弹窗之后(避免被关窗确认框 + 盖住)。vitest 123+2(含弹窗契约测试)。真机验证点:拿一个 + GBK 文档改字后 Ctrl+S,应先弹提示、确认后才落盘。 - D2(中)专注模式跨块时全量重建装饰集:大文档每次回车重建与 顶层块数等量的装饰对象。改增量方案需重构插件,单独立项。 - D3(低)源码模式每次击键物化全文回写 App(无渲染模式的 200ms diff --git a/src/App.tsx b/src/App.tsx index 8361dfe..dbc5a36 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,7 @@ const SourceEditor = lazy(() => import('./components/SourceEditor')) import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync' import { insertPlainText, insertLinkText } from './editor/paste' import { isImeComposing } from './utils/ime' -import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal } from './components/Modals' +import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal } from './components/Modals' import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs' import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text' import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor' @@ -472,11 +472,32 @@ export default function App() { const handleSidebarOpenFolder = useCallback(() => { void openFolder() }, [openFolder]) - const saveTab = useCallback(async (target: DocTab, saveAs: boolean): Promise => { + // 非 UTF-8 文档保存转码提示(docs/29 D1,用户拍板加):按路径记已确认, + // 本次运行内同一文件只提示一次;确认后走 finishSave 完成落盘收尾。 + const encodingAckedRef = useRef>(new Set()) + const [encodingPrompt, setEncodingPrompt] = useState<{ target: DocTab; path: string; snapshot: string } | null>(null) + + /** 保存落盘后的收尾:标已保存(编码如实记为 UTF-8)、刷树、更新基线、清恢复记录。 */ + const finishSave = useCallback(async (target: DocTab, path: string, snapshot: string) => { + const oldKey = recoveryKey(target) + dispatch({ type: 'saved', id: target.id, savedContent: snapshot, path, title: baseName(path), encoding: 'UTF-8' }) + // 落点变化(未命名首次保存、另存为)意味着磁盘上多/改了一个文件,刷新左侧文件树 + if (target.path !== path) setTreeRefresh((n) => n + 1) + const stat = await statFile(path) + if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size }) + // 恢复记录可能按旧键(未命名/旧路径)写入,新旧键都清,避免下次启动误报草稿 + const newKey = recoveryKey({ id: target.id, path }) + for (const key of new Set([oldKey, newKey])) { + recoveryWrittenRef.current.delete(key) + void store.deleteRecovery(key) + } + pushRecent(path) + }, [pushRecent]) + + const saveTab = useCallback(async (target: DocTab, saveAs: boolean, fromAutosave = false): Promise => { // 审计修复:保存用调用时刻的快照;saved 只把这份快照标为已保存, // 写盘期间新输入的内容保持「脏」,不会被误标、不会丢恢复记录。 const snapshot = target.content - const oldKey = recoveryKey(target) try { if (!tauriMode) { downloadTextFile(`${target.title}.md`, snapshot) @@ -498,25 +519,35 @@ export default function App() { path = await pickSaveFile(path ?? (workspaceRoot ? joinPath(workspaceRoot, `${target.title}.md`) : `${target.title}.md`)) if (!path) return false } - await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom) - dispatch({ type: 'saved', id: target.id, savedContent: snapshot, path, title: baseName(path) }) - // 落点变化(未命名首次保存、另存为)意味着磁盘上多/改了一个文件,刷新左侧文件树 - if (target.path !== path) setTreeRefresh((n) => n + 1) - const stat = await statFile(path) - if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size }) - // 恢复记录可能按旧键(未命名/旧路径)写入,新旧键都清,避免下次启动误报草稿 - const newKey = recoveryKey({ id: target.id, path }) - for (const key of new Set([oldKey, newKey])) { - recoveryWrittenRef.current.delete(key) - void store.deleteRecovery(key) + // 非 UTF-8 文档保存会转码为 UTF-8:首次保存前提示一次(按路径记已确认)。 + // 自动保存不弹窗、也不静默转码——跳过本轮,等手动保存/关窗保存时提示。 + if (target.encoding !== 'UTF-8' && !encodingAckedRef.current.has(path)) { + if (fromAutosave) return false + setEncodingPrompt({ target, path, snapshot }) + return false } - pushRecent(path) + await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom) + await finishSave(target, path, snapshot) return true } catch (e) { setError(String(e)) return false } - }, [tauriMode, pushRecent, workspaceRoot]) + }, [tauriMode, finishSave, workspaceRoot]) + + /** 编码提示确认:记下该路径已确认,用弹窗时冻结的快照完成落盘。 */ + const confirmEncodingSave = useCallback(async () => { + const job = encodingPrompt + if (!job) return + setEncodingPrompt(null) + encodingAckedRef.current.add(job.path) + try { + await writeTextFile(job.path, toDiskContent(job.snapshot, job.target.lineEnding), job.target.hadBom) + await finishSave(job.target, job.path, job.snapshot) + } catch (e) { + setError(String(e)) + } + }, [encodingPrompt, finishSave]) const requestClose = useCallback((ids: string[], after: () => void) => { const dirtyOnes = state.tabs.filter((t) => ids.includes(t.id) && isDirty(t)) @@ -731,7 +762,7 @@ export default function App() { const timer = window.setInterval(() => { for (const t of stateRef.current.tabs) { // 冲突弹窗正等用户裁决的标签不自动保存,否则会抢先覆盖外部修改 - if (t.path && isDirty(t) && conflictRef.current !== t.id) void saveTabRef.current(t, false) + if (t.path && isDirty(t) && conflictRef.current !== t.id) void saveTabRef.current(t, false, true) } }, Math.max(10, ui.autosaveSeconds) * 1000) return () => window.clearInterval(timer) @@ -1363,7 +1394,7 @@ export default function App() { }, [tauriMode, flash]) anyModalRef.current = Boolean( - modal || appearanceOpen || exportOpen || pendingClose || conflict + modal || appearanceOpen || exportOpen || pendingClose || conflict || encodingPrompt || promptSpec || confirmSpec || quitOpen || gitSettingsOpen || (recoveryOffer.length > 0 && !recoveryDismissed), ) findOpenRef.current = findUi.open @@ -1857,6 +1888,15 @@ export default function App() { /> )} {error && setError(null)} />} + {encodingPrompt && ( + { void confirmEncodingSave() }} + onCancel={() => setEncodingPrompt(null)} + /> + )} + ) } diff --git a/src/components/Modals.tsx b/src/components/Modals.tsx index 8bda8e1..c537a0e 100644 --- a/src/components/Modals.tsx +++ b/src/components/Modals.tsx @@ -28,6 +28,24 @@ export function ConfirmSaveModal({ title, onSave, onDiscard, onCancel }: { ) } +export function EncodingConvertModal({ encoding, fileName, onConfirm, onCancel }: { + encoding: string + fileName: string + onConfirm: () => void + onCancel: () => void +}) { + return ( + +

文档编码将转为 UTF-8

+

「{fileName}」原是 {encoding} 编码。本编辑器统一以 UTF-8 保存,保存后文件编码将转为 UTF-8(内容不变)。本次运行内,同一文件只提示这一次。

+
+ + +
+
+ ) +} + export function AboutModal({ onClose }: { onClose: () => void }) { return ( diff --git a/src/stores/tabs.ts b/src/stores/tabs.ts index 04050d3..f39667a 100644 --- a/src/stores/tabs.ts +++ b/src/stores/tabs.ts @@ -46,7 +46,7 @@ export type TabsAction = | { type: 'open'; tab: DocTab } | { type: 'activate'; id: string } | { type: 'updateContent'; id: string; content: string } - | { type: 'saved'; id: string; savedContent: string; path?: string; title?: string } + | { type: 'saved'; id: string; savedContent: string; path?: string; title?: string; encoding?: string } | { type: 'syncEditorContent'; id: string; content: string } | { type: 'loadContent'; id: string; content: string; encoding?: string; lineEnding?: string; hadBom?: boolean } | { type: 'setPath'; id: string; path: string; title: string } @@ -93,7 +93,7 @@ export function tabsReducer(state: TabsState, action: TabsAction): TabsState { ...state, tabs: state.tabs.map((t) => t.id === action.id - ? { ...t, savedContent: action.savedContent, path: action.path ?? t.path, title: action.title ?? t.title } + ? { ...t, savedContent: action.savedContent, path: action.path ?? t.path, title: action.title ?? t.title, encoding: action.encoding ?? t.encoding } : t, ), } diff --git a/tests/encoding-modal.test.tsx b/tests/encoding-modal.test.tsx new file mode 100644 index 0000000..66cfa7a --- /dev/null +++ b/tests/encoding-modal.test.tsx @@ -0,0 +1,27 @@ +// docs/29 D1:非 UTF-8 文档保存转码提示弹窗的渲染与回调契约。 +import { describe, it, expect, vi } from 'vitest' +import { createRoot } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { EncodingConvertModal } from '../src/components/Modals' + +describe('EncodingConvertModal', () => { + it('显示原编码与文件名,确认/取消回调正确', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onConfirm = vi.fn() + const onCancel = vi.fn() + await act(async () => { + root.render() + }) + expect(container.textContent).toContain('GBK') + expect(container.textContent).toContain('老文档.md') + expect(container.textContent).toContain('转为 UTF-8') + const buttons = [...container.querySelectorAll('button')] + buttons.find((b) => b.textContent?.includes('转为 UTF-8'))?.click() + expect(onConfirm).toHaveBeenCalledTimes(1) + buttons.find((b) => b.textContent?.includes('取消'))?.click() + expect(onCancel).toHaveBeenCalledTimes(1) + await act(async () => { root.unmount() }) + }) +})