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