鸿蒙 PC Markdown 编辑器三方冲突处理:本地缓冲区、磁盘版本与共同基线
“文件已被外部修改,是否覆盖?”是很多编辑器处理冲突时唯一给用户的问题。这个问题缺少最关键的信息:本地改了什么、磁盘改了什么、双方从哪个共同版本出发。用户只能凭记忆猜测,任何一个按钮都可能丢数据。对于强调本地优先的 Markdown 编辑器,冲突处理不能只是一道确认框,而应该是一套保留事实、解释差异并延迟不可逆决定的工作流。
OhMarkdown 的三方冲突能力在公开仓库 https://gitcode.com/VON-/codex_md_oh 中实现。文档可靠性纵切始于提交57aea97,完整三方差异和决策收口于5cb4aed。本文基于已进入主分支的 ArkTS、TypeScript、Playwright 和模拟器证据,讨论当前能力及其边界;尚未实现的自动合并、版本树和远程协作不会被包装为现有功能。
二方比较无法解释修改来源
只比较“编辑器正文”和“磁盘正文”可以显示不同,却无法区分每一行来自谁。如果一行在两边相同,可能是共同基线,也可能是双方恰好做了同样修改;如果一边缺少一行,无法判断是本地删除还是磁盘新增。共同基线提供因果参照,使用户能看到两条修改路径。
项目把上次成功打开或保存的正文作为persistedDocumentContent。CodeMirror 当前内容是 local,外部检测重新读取的文件是 disk。三者必须属于同一文档会话和同一冲突时刻。若在用户查看差异时持续用最新磁盘内容覆盖 disk 列,界面会漂移,决策也不再可重复。
因此检测到冲突时会捕获OpenedDocument并按activeDocumentSessionId存入映射。冲突比较读取这个快照,不在每次打开视图时重新随意读盘。用户做出选择后,再由相应动作更新基线或重新读取。
冲突状态不是一个全局布尔值
界面有externalConflictVisible用于活动标签展示,但实际磁盘快照保存在按会话键控的映射里:
privateexternalConflicts:Map<string,OpenedDocument>=newMap();privateregisterExternalConflict(diskDocument:OpenedDocument):void{this.externalConflicts.set(this.activeDocumentSessionId,diskDocument);this.externalConflictVisible=true;this.cancelScheduledAutoSave();this.operationStatus='External changes need attention';}如果只有一个全局diskDocument,用户在标签 A 发生冲突后切到标签 B,B 的变化可能覆盖 A 的待决版本。映射让每个文档保持自己的冲突事实,活动布尔值只是当前窗口投影。标签切换时要同步对应记录是否存在。
冲突注册立即取消延迟自动保存。失焦保存也会检查externalConflictVisible。用户尚未决策前,任何自动写入都有覆盖磁盘版本的风险。这个暂停不是错误恢复的附加动作,而是冲突状态进入时的原子条件。
冲突栏提供动作而不是一句警告
当前工作台展示 Compare、Keep Local、Use Disk 和 Save As。四个动作分别对应不同意图:Compare 获取信息,不改变正文;Keep Local 保留当前缓冲区并承认已看到磁盘版本;Use Disk 放弃本地缓冲区,采用冲突快照;Save As 把本地内容保存到新文件,避免覆盖原路径。
不能把 Keep Local 直接实现为立即覆盖磁盘。用户可能只是关闭警告并准备继续编辑,也可能先复制内容。当前实现将冲突磁盘版本设为新的持久化基线和指纹,关闭冲突后重新安排自动保存;真正写入仍走标准保存事务。这使自动策略可继续,但不会在点击瞬间执行隐藏覆盖。
Use Disk 是不可逆动作,因为它替换当前缓冲区,所以需要二次确认。Save As 也不能丢失原会话状态:只有新文件写入成功,活动 URI 和基线才切换;用户取消选择器时仍保持冲突与 dirty。
三方视图的数据入口保持结构化
原生层在打开比较前捕获活动编辑器正文,读取基线和冲突快照,然后调用 Web 暴露方法:
privateasyncshowExternalConflictComparison():Promise<void>{constdiskDocument=this.externalConflicts.get(this.activeDocumentSessionId);if(!diskDocument){return;}awaitthis.captureActiveDocumentSession();constbaseline=this.persistedDocumentContent??'';constcomparisonCharacterCount=baseline.length+this.documentContent.length+diskDocument.content.length;if(comparisonCharacterCount>MAX_THREE_WAY_COMPARISON_CHARACTERS){awaitthis.showExternalConflictSummary(baseline,diskDocument,true);return;}this.runEditorScript(`window.OhMarkdownEditor?.showThreeWayDiff(${JSON.stringify(baseline)},`+`${JSON.stringify(this.documentContent)},${JSON.stringify(diskDocument.content)})`);}三份正文使用JSON.stringify编码为 JavaScript 字符串,不直接拼接原始 Markdown。文档可能包含引号、反斜杠、换行甚至</script>,结构化编码是防止脚本语法破坏和注入的必要步骤。
进入比较前重新捕获 local,确保展示的是用户刚刚编辑的缓冲区,而不是上一次 Bridge 快照。disk 使用冲突发生时保存的版本,baseline 使用最后确认版本。三份数据的采样时刻有意不同,因为它们代表不同事实。
差异算法从最长公共子序列开始
Web 层将文本按行切分,对 baseline-local 与 baseline-disk 分别计算差异。当前实现使用有界的行级算法,产生相同、增加和删除段,再把两边与基线对齐。行级而非字符级能在 Markdown 文档中保持可扫描性,代码块和段落改动也更容易理解。
最长公共子序列的时间和空间复杂度会随行数乘积增长,因此实现设置总字符上限,并对极大文档降级摘要。算法不是为了宣称“智能合并”,它只负责解释差异。没有自动把 local 和 disk 合成第四份正文,避免把同一段的语义冲突悄悄处理错。
差异段需要稳定行号。baseline 列作为共同坐标,本地和磁盘插入会产生空占位。滚动同步依赖三列总高度近似一致,不能简单让每列独立渲染原始文本,否则大量插入会让相同行在不同垂直位置。
DOM 渲染坚持文本节点
三方视图显示用户 Markdown 原文,绝不能把正文当 HTML 注入。渲染函数创建行元素并写入textContent:
functioncreateConflictLine(lineNumber:number|undefined,text:string,kind:ConflictLineKind):HTMLElement{constline=document.createElement('div');line.className=`conflict-comparison__line conflict-comparison__line--${kind}`;constnumber=document.createElement('span');number.className='conflict-comparison__line-number';number.textContent=lineNumber===undefined?'':String(lineNumber);constcontent=document.createElement('span');content.className='conflict-comparison__line-content';content.textContent=text.length===0?' ':text;line.append(number,content);returnline;}textContent使<script>、HTML 标签和 Markdown 内嵌内容只按文本显示。空行使用视觉占位但不改变真实内容。样式类只来自内部枚举,不由用户文本生成。CSP 继续禁止外部脚本和网络资源。
这条安全规则与预览不同。预览会解析 Markdown 并经过 DOMPurify;冲突视图的目标是忠实比较源码,根本不需要解析。选择正确的展示模型比给所有内容套同一净化流程更直接。
三列同步滚动需要递归抑制
用户拖动任意一列时,另外两列按滚动比例同步。程序设置目标scrollTop会再次触发滚动事件,如果没有抑制,会形成递归抖动。实现使用synchronizingConflictScroll锁,并在下一动画帧释放:
container.addEventListener('scroll',()=>{if(synchronizingConflictScroll){return;}synchronizingConflictScroll=true;constverticalRange=Math.max(0,container.scrollHeight-container.clientHeight);constverticalRatio=verticalRange>0?container.scrollTop/verticalRange:0;conflictLineContainers.forEach((target)=>{if(target!==container){target.scrollTop=verticalRatio*Math.max(0,target.scrollHeight-target.clientHeight);}});window.requestAnimationFrame(()=>{synchronizingConflictScroll=false;});},{passive:true});比例同步不是像素同步。三列内容高度可能因换行、占位和字体产生细微差异,使用各自可滚动范围比例更稳定。被动监听避免阻塞滚动主线程,动画帧锁覆盖同一帧内的目标事件。
同步并不等于语义行永远完美对齐,因此差异渲染还应尽量产生相同数量的对齐行。性能测试需要覆盖长行、CJK、代码块和窄窗口换行,而不能只用短英文行。
大文档必须有降级而不是假装能算
三份正文总字符数超过MAX_THREE_WAY_COMPARISON_CHARACTERS时,原生层不会把所有内容送入差异 DOM,而是计算有界摘要:三方行数、第一处差异和局部预览。这样用户仍知道冲突存在,可以 Save As 或选择版本,同时编辑器避免冻结。
降级提示明确说明大文档使用摘要,不伪装成完整差异。用户做数据决策时必须知道当前信息是否完整。未来若引入 Worker/TaskPool 流式 diff,也应在真机性能达到阈值后再扩大上限,而不是只把计算移到后台却让巨大 DOM 卡住渲染。
大文档策略同样保护 Bridge。三个大字符串跨运行时复制会产生显著内存峰值,限制在原生调用前判断可以避免不必要传输。阈值需要通过 G3-10 的真机压力数据校准。
Keep Local 的语义是推进基线
真实实现从冲突映射取出磁盘快照,将其正文、格式和指纹设为新的比较基线,然后关闭冲突:
privatekeepLocalAfterExternalChange():void{constdiskDocument=this.externalConflicts.get(this.activeDocumentSessionId);if(!diskDocument){return;}this.persistedDocumentContent=diskDocument.content;this.documentFormat=diskDocument.format;this.documentFingerprint=diskDocument.fingerprint;this.clearExternalConflict();this.operationStatus='Local changes kept';this.syncActiveDocumentSession();this.scheduleDelayedAutoSave();}这个动作没有清除documentDirty,因为 local 仍不同于新 baseline。它也没有清恢复快照。若自动保存策略开启,稍后标准保存会把 local 写入;若关闭,用户仍需手动保存。状态栏使用“Local changes kept”而不是“Saved”,避免语言误导。
推进基线意味着用户已经接受磁盘版本作为已知外部起点。如果磁盘随后再次变化,指纹检测会建立新冲突。若不推进,轮询会立刻对同一版本重复报警。
Use Disk 必须二次确认
Use Disk 会丢弃本地未保存内容,因此显示确认对话框。确认后调用applyExternalDiskDocument,更新正文、基线、格式、指纹、修订和 dirty,并清除恢复草稿。取消则不改变任何冲突事实。
为什么有三方比较仍需要二次确认?因为比较只是信息展示,用户可能误点操作。不可逆按钮应该在最后时刻再次明确后果。按钮颜色、位置和文字需要在宽窄窗口、深浅主题以及键盘焦点下检查,不能仅依赖红色表达危险。
未来可考虑在确认框显示本地修改行数或提供“先保存副本”快捷动作,但这些都不能自动发生。当前 Save As 已是保留本地版本的安全路径。
Save As 是冲突中的逃生通道
Save As 复用正常保存为新文件的命令。它保持当前缓冲区,把用户带到系统文件选择器,成功后创建新文档事实。原冲突文件仍保留磁盘版本,不被覆盖。用户取消时返回冲突状态。
这条路径对不确定如何合并的用户非常重要:先保存本地副本,再用其他工具处理。应用不要求用户必须在当前界面完成合并,也不锁死编辑。开放标准 Markdown 文件使两个版本都能被 Git、diff 工具或其他编辑器继续处理。
保存新文件仍遵守 UTF-8、BOM、换行和原子提交规则,不能因为是“逃生”就降级成普通字符串写入。新 URI 的授权、名称冲突和写入失败都由原生服务处理。
真实设备证据
在 MateBook Pro 2in1 模拟器中,本地缓冲区先产生修改,再从应用外改写同一文件。应用检测后暂停自动保存并展示冲突栏:
选择 Compare 后,应用展示完整基线、本地和磁盘三列,并保持同步滚动:
证据报告位于docs/test/ohmarkdown/2026-07-18-g3-03-document-reliability/。截图证明当前语料和模拟器闭环,不代表自动合并或所有文件系统情形已经通过。
测试应该验证没有丢失哪一份内容
冲突测试不能只断言面板可见。应记录 baseline、local 和 disk 的不同标记,打开比较后分别确认三列文本;Keep Local 后磁盘暂时不变、local 仍 dirty;Use Disk 确认后 local 被替换;取消确认不改变状态;Save As 成功后原文件保持 disk,新文件等于 local。
Playwright 覆盖 Web 三方渲染、行标记、同步滚动、Escape 和危险 HTML 作为文本。ArkTS 测试覆盖冲突摘要与格式。模拟器覆盖真实外部写入和界面动作。当前代码基线统一 Playwright29/29、ohosTest7/7,三方功能的直接完成提交为5cb4aed。
还需要补充故障注入:比较期间磁盘再次变化、Save As 写入失败、极长单行、混合 EOL、UTF-8 BOM、CJK 与 emoji、多个标签同时冲突、应用在冲突状态退出并恢复。这些项目应进入后续设备验收,而不是仅凭代码审查判定通过。
安全与隐私
三方正文只在本地 ArkUI 与应用自有 ArkWeb 之间传输,不上传服务器。应用无网络权限。Web 页面受 CSP 约束,冲突文本使用textContent,反向脚本参数用JSON.stringify。Markdown 中的脚本标签不会在比较视图执行。
差异算法需要处理不可信大输入,因此有字符上限。行数与预览错误信息不应把完整文档写入日志。当前测试报告保留专用语料截图,不使用用户私人文档。技术文章图片也已清除无关窗口和个人信息。
冲突快照的生命周期在决策后结束。长期保留所有 disk 版本会变成隐形版本库,增加隐私和存储风险。当前只保留处理当前冲突所需内容,历史版本能力如果要做,应以显式产品功能、配额和清理策略另行设计。
没有采用自动合并的原因
行级三方合并可以识别一部分不重叠修改,但 Markdown 语义比纯文本复杂:引用定义、脚注、标题锚点、表格分隔和代码围栏可能在远距离关联。机械合并即使无文本冲突,也可能生成语义错误文档。项目当前没有成熟解析合并引擎和足够测试,因此只展示差异,不生成看似成功的合并结果。
也没有把冲突交给系统对话框用两按钮解决。系统对话框无法承载三方内容、滚动和大文档降级,而且容易阻塞用户先保存副本。原生冲突栏加 Web 差异面板更适合复杂决策,同时仍由 ArkUI 掌握文件动作。
没有自动创建.conflict文件,因为这会污染用户目录并引入命名冲突。Save As 把位置和名称交还用户。后续若用户研究表明自动备份有价值,可以作为明确可配置项,而不是默认副作用。
性能与可访问性边界
差异计算和 DOM 行数是主要成本。当前字符阈值提供硬保护,但仍需在真机 Release 测量中型文档的打开耗时、滚动帧率和峰值内存。同步滚动使用被动事件和动画帧抑制,不能保证在超长换行行上完全无抖动。
三列布局在宽屏 PC 上适合比较,窄窗口需要保持每列最小宽度并允许水平滚动,不能把文字压到无法阅读。标题、行号和增删标记还需屏幕阅读器语义测试。颜色不是唯一差异信号,当前通过样式类和文本列标题补充,但真机无障碍验收尚未完成。
键盘用户需要 Escape 关闭、Tab 顺序稳定、危险动作可聚焦且有确认。打开比较前保存当前编辑器焦点,关闭后返回冲突决策区域,比一律聚焦正文更符合任务连续性。这部分会与 G3-09 完整 PC 交互一起复测。
验收清单与结论
三方冲突完成验收至少要确认:三份内容来自正确时刻;多个标签冲突隔离;自动保存暂停;Compare 不修改正文;危险文本不执行;滚动不会递归抖动;大文档明确降级;Keep Local 不假称已保存;Use Disk 二次确认且取消无副作用;Save As 保留原磁盘;决策后指纹、格式、基线、dirty 和恢复记录一致。
当前实现已经让鸿蒙 PC Markdown 编辑器从“覆盖还是取消”的二选一升级为可解释的三方工作流。它没有冒充完整版本控制系统,也没有用不成熟的自动合并替用户下注。保留每一份事实、让不可逆动作延后、为大文档提供诚实降级,这些原则比差异界面的视觉复杂度更能决定本地编辑器是否值得信任。