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:
@@ -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
|
||||
|
||||
76
src/App.tsx
76
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<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
27
tests/encoding-modal.test.tsx
Normal file
27
tests/encoding-modal.test.tsx
Normal 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() })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user