feat(save): 非 UTF-8 文档保存前转码提示(docs/29 D1,用户拍板)

GBK/UTF-16 文档保存会落盘为 UTF-8,原先无任何提示。saveTab 在
落盘前拦截:首次保存弹 EncodingConvertModal 说明将转 UTF-8,
按路径记已确认、本次运行只提示一次;自动保存不弹窗也不静默
转码,跳过等手动保存时提示;确认后以冻结快照 finishSave 收尾,
标签 encoding 如实更新为 UTF-8。弹窗渲染移到弹窗栈最后,避免
被关窗确认框盖住。vitest 123+2、构建通过。
This commit is contained in:
Muse
2026-11-14 18:06:02 +08:00
parent fb2315a2f3
commit c602573d8e
5 changed files with 112 additions and 20 deletions

View File

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

View File

@@ -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<boolean> => {
// 非 UTF-8 文档保存转码提示(docs/29 D1,用户拍板加):按路径记已确认,
// 本次运行内同一文件只提示一次;确认后走 finishSave 完成落盘收尾。
const encodingAckedRef = useRef<Set<string>>(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<boolean> => {
// 审计修复:保存用调用时刻的快照;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 && <ErrorModal message={error} onClose={() => setError(null)} />}
{encodingPrompt && (
<EncodingConvertModal
encoding={encodingPrompt.target.encoding}
fileName={baseName(encodingPrompt.path)}
onConfirm={() => { void confirmEncodingSave() }}
onCancel={() => setEncodingPrompt(null)}
/>
)}
</div>
)
}

View File

@@ -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 (
<Shell onCancel={onCancel}>
<h3>文档编码将转为 UTF-8</h3>
<p>「{fileName}」原是 {encoding} 编码。本编辑器统一以 UTF-8 保存,保存后文件编码将转为 UTF-8(内容不变)。本次运行内,同一文件只提示这一次。</p>
<div className="modal-actions">
<button onClick={onCancel}>取消保存</button>
<button className="primary" onClick={onConfirm}>转为 UTF-8 保存</button>
</div>
</Shell>
)
}
export function AboutModal({ onClose }: { onClose: () => void }) {
return (
<Shell onCancel={onClose}>

View File

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

View File

@@ -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(<EncodingConvertModal encoding="GBK" fileName="老文档.md" onConfirm={onConfirm} onCancel={onCancel} />)
})
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() })
})
})