fix(acceptance): 首轮 Windows 验收 7 项缺陷回修(docs/14)

P0 快捷键让行判定补 recoveryDismissed,点过「稍后再说」不再全失效;
P1 冲突重载加 force 绕过防竞态守卫;路径分隔符 joinPath 原生化 +
samePath 归一化比较;.source-cm 补文档字色;
P2 turndown 启用 GFM 插件(HTML 导入不再丢表格);任务复选框去掉
click preventDefault(浏览器激活撤销覆盖视觉同步);
P3 新增 syncEditorContent:干净标签的挂载/卸载序列化回写同步基线,
切标签不再误标未保存。vitest 74 通过 + 2 跳过。
This commit is contained in:
Muse
2026-11-14 11:08:24 +08:00
parent 947154892c
commit 9bd3f3de97
12 changed files with 179 additions and 36 deletions

View File

@@ -247,7 +247,7 @@ export default function App() {
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)
const existing = stateRef.current.tabs.find((t) => samePath(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 })
@@ -352,7 +352,7 @@ export default function App() {
}, [requestClose])
// ——— 阶段 4:存储初始化与启动恢复 ———
const reloadTabFromDisk = useCallback(async (target: DocTab, expectedContent?: string) => {
const reloadTabFromDisk = useCallback(async (target: DocTab, expectedContent?: string, force = false) => {
if (!target.path) return
try {
const file = await readTextFile(target.path)
@@ -360,8 +360,12 @@ export default function App() {
// 交给下一轮轮询按冲突流程处理,绝不用磁盘内容整表覆盖新输入。
const current = stateRef.current.tabs.find((t) => t.id === target.id)
if (!current) return
if (expectedContent !== undefined && current.content !== expectedContent) return
if (isDirty(current)) return
// force=true 是冲突弹窗里用户主动选「重新加载磁盘版本」:本地是脏的正是前提,
// 不能被下面的防竞态守卫挡掉(验收 B3 实测踩中)。
if (!force) {
if (expectedContent !== undefined && current.content !== expectedContent) return
if (isDirty(current)) return
}
const isActive = stateRef.current.activeId === target.id
// 审计修复(卸载回写覆盖):活动标签重挂时,旧 EditorPane 的卸载回写会把旧正文
// 写回状态、盖掉刚载入的内容——重载前先给该标签的这次最终回写上闸门。
@@ -391,7 +395,7 @@ export default function App() {
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)
const existing = stateRef.current.tabs.find((t) => samePath(t.path, item.path))
dispatch({ type: 'open', tab: created })
const targetId = existing?.id ?? created.id
dispatch({ type: 'updateContent', id: targetId, content: item.content })
@@ -563,7 +567,13 @@ export default function App() {
}, [])
// ——— 阶段 4:工作区文件管理 ———
const joinPath = (dir: string, name: string) => `${dir.replace(/[\\/]+$/, '')}/${name}`
const joinPath = (dir: string, name: string) => {
const sep = dir.includes('\\') ? '\\' : '/'
return `${dir.replace(/[\\/]+$/, '')}${sep}${name}`
}
/** 路径比较归一化:不同来源(对话框/Rust/手拼)的分隔符混用时也能对上 */
const samePath = (a?: string | null, b?: string | null) =>
Boolean(a && b && a.replace(/\\/g, '/') === b.replace(/\\/g, '/'))
const dirOf = (path: string) => path.replace(/[\\/][^\\/]*$/, '')
const createFileInWorkspace = useCallback(() => {
@@ -595,7 +605,7 @@ export default function App() {
void (async () => {
try {
await renamePath(entry.path, target)
const open = stateRef.current.tabs.find((t) => t.path === entry.path)
const open = stateRef.current.tabs.find((t) => samePath(t.path, entry.path))
if (open) {
dispatch({ type: 'setPath', id: open.id, path: target, title: baseName(target) })
store.removeRecent(entry.path)
@@ -622,7 +632,9 @@ export default function App() {
try {
const dest = 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}\\`))) {
const tabPath = t.path?.replace(/\\/g, '/')
const entryPath = entry.path.replace(/\\/g, '/')
if (tabPath && (tabPath === entryPath || tabPath.startsWith(`${entryPath}/`))) {
dispatch({ type: 'close', id: t.id })
const key = recoveryKey(t)
if (recoveryWrittenRef.current.delete(key)) void store.deleteRecovery(key)
@@ -884,7 +896,7 @@ export default function App() {
anyModalRef.current = Boolean(
modal || appearanceOpen || exportOpen || pendingClose || conflict
|| promptSpec || confirmSpec || quitOpen || recoveryOffer.length > 0,
|| promptSpec || confirmSpec || quitOpen || (recoveryOffer.length > 0 && !recoveryDismissed),
)
// 大纲与状态栏计数只随正文变化重算(原先每次光标移动/击键都对全文重算);
@@ -1078,8 +1090,9 @@ export default function App() {
onMarkdown={(id, md) => dispatch({ type: 'updateContent', id, content: md })}
onFinalMarkdown={(id, md) => {
if (suppressFinalWriteRef.current.delete(id)) return // 外部重载的卸载回写,丢弃
dispatch({ type: 'updateContent', id, content: md })
dispatch({ type: 'syncEditorContent', id, content: md })
}}
onSyncInitial={(id, md) => dispatch({ type: 'syncEditorContent', id, content: md })}
onCursor={setCursor}
focusMode={ui.focusMode}
typewriter={ui.typewriter}
@@ -1193,7 +1206,7 @@ export default function App() {
return (
<ConflictModal
title={target.title}
onReload={() => { setConflict(null); void reloadTabFromDisk(target) }}
onReload={() => { setConflict(null); void reloadTabFromDisk(target, undefined, true) }}
onKeepMine={() => {
setConflict(null)
if (target.path) {

View File

@@ -26,6 +26,8 @@ interface Props {
onMarkdown: (tabId: string, markdown: string) => void
/** 卸载前的最终回写(与实时 onMarkdown 分开,供外部重载时拦截旧内容回写) */
onFinalMarkdown?: (tabId: string, markdown: string) => void
/** 挂载后首次序列化与初始文本不一致时的一次性对齐(规范化差异不标脏) */
onSyncInitial?: (tabId: string, markdown: string) => void
onCursor: (cursor: CursorInfo) => void
onReady: (api: EditorPaneApi) => void
onImageFile?: (file: File) => Promise<string>
@@ -47,7 +49,7 @@ const EMPTY_API: EditorPaneApi = {
}
/** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */
export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError, focusMode, typewriter }: Props) {
export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinalMarkdown, onSyncInitial, onCursor, onReady, onImageFile, onImageError, focusMode, typewriter }: Props) {
const wrapRef = useRef<HTMLDivElement>(null)
const findRef = useRef<{ matches: MatchRange[]; current: number; query: string; opts: FindOptions | null }>({
matches: [], current: -1, query: '', opts: null,
@@ -68,8 +70,8 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
}, [])
const hostRef = useRef<HTMLDivElement>(null)
const initialRef = useRef(initialMarkdown)
const callbacksRef = useRef({ onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError })
callbacksRef.current = { onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError }
const callbacksRef = useRef({ onMarkdown, onFinalMarkdown, onSyncInitial, onCursor, onReady, onImageFile, onImageError })
callbacksRef.current = { onMarkdown, onFinalMarkdown, onSyncInitial, onCursor, onReady, onImageFile, onImageError }
useEffect(() => {
if (!hostRef.current) return

View File

@@ -28,12 +28,18 @@ const listItemView: NodeViewConstructor = (node, view, getPos) => {
checkbox.checked = Boolean(item.attrs.checked)
checkbox.contentEditable = 'false'
checkbox.addEventListener('mousedown', (e) => e.preventDefault())
checkbox.addEventListener('click', (e) => {
e.preventDefault()
checkbox.addEventListener('click', () => {
// 不要在 click 里 preventDefault:浏览器对 checkbox 的激活行为是「先翻转、
// 事件被取消则退回原值」,会把 update() 同步好的 checked 又退回去(验收 C3)。
// 原生翻转与文档翻转方向一致,update() 再按文档值对齐即可;取不到节点时手工回退。
const el = checkbox
if (!el) return
const pos = getPos()
if (pos == null) return
const current = view.state.doc.nodeAt(pos)
if (!current) return
const current = pos == null ? null : view.state.doc.nodeAt(pos)
if (pos == null || !current) {
el.checked = Boolean(item.attrs.checked)
return
}
const next = !current.attrs.checked
view.dispatch(view.state.tr.setNodeMarkup(pos, undefined, { ...current.attrs, checked: next }))
})

View File

@@ -221,6 +221,9 @@ function escapeHtml(text: string): string {
export async function htmlToMarkdown(html: string): Promise<string> {
const TurndownService = (await import('turndown')).default
const service = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-' })
// GFM 扩展(表格/删除线/任务列表):原先没启用,导入 HTML 时表格整段丢失(验收 E6)
const { gfm } = await import('turndown-plugin-gfm')
service.use(gfm)
return service.turndown(html)
}

View File

@@ -47,6 +47,7 @@ export type TabsAction =
| { type: 'activate'; id: string }
| { type: 'updateContent'; id: string; content: string }
| { type: 'saved'; id: string; savedContent: string; path?: string; title?: 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 }
| { type: 'close'; id: string }
@@ -75,6 +76,18 @@ export function tabsReducer(state: TabsState, action: TabsAction): TabsState {
tabs: state.tabs.map((t) => (t.id === action.id ? { ...t, content: action.content } : t)),
}
}
case 'syncEditorContent':
// 编辑器挂载/卸载时的序列化回写:标签若原本干净,这份差异只是解析→序列化的
// 规范化(表格对齐冒号等),不是用户编辑——正文与基线一起对齐,不标未保存。
// 标签若已脏,则与普通 updateContent 无异。(验收 P3:切标签后旧文档误标脏)
return {
...state,
tabs: state.tabs.map((t) => {
if (t.id !== action.id || t.content === action.content) return t
const wasClean = t.content === t.savedContent
return { ...t, content: action.content, savedContent: wasClean ? action.content : t.savedContent }
}),
}
case 'saved':
return {
...state,

View File

@@ -91,7 +91,7 @@ button { font: inherit; color: inherit; }
.editor-scroll { flex: 1; overflow-y: auto; background: var(--bg-editor); min-width: 0; position: relative; }
.editor-host { max-width: 880px; margin: 0 auto; padding: 34px 48px 140px; }
.source-cm { height: 100%; max-width: 980px; margin: 0 auto; }
.source-cm { height: 100%; max-width: 980px; margin: 0 auto; color: var(--doc-ink); caret-color: var(--doc-ink); }
.source-cm .cm-editor { height: 100%; outline: none; }
.md-marker { color: var(--text-dim); opacity: 0.55; font-size: 0.9em; pointer-events: none; user-select: none; white-space: pre; }
.empty-state { max-width: 520px; margin: 18vh auto 0; text-align: center; color: var(--text-dim); }

9
src/types/turndown-plugin-gfm.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
// turndown-plugin-gfm 无官方类型声明(运行时为标准 turndown 插件对象)。
declare module 'turndown-plugin-gfm' {
import type TurndownService from 'turndown'
export const gfm: TurndownService.Plugin
export const tables: TurndownService.Plugin
export const strikethrough: TurndownService.Plugin
export const taskListItems: TurndownService.Plugin
export const highlightedCodeBlock: TurndownService.Plugin
}