鸿蒙 PC Markdown 编辑器三方冲突处理:本地缓冲区、磁盘版本与共同基线 鸿蒙 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:Mapstring,OpenedDocumentnewMap();privateregisterExternalConflict(diskDocument:OpenedDocument):void{this.externalConflicts.set(this.activeDocumentSessionId,diskDocument);this.externalConflictVisibletrue;this.cancelScheduledAutoSave();this.operationStatusExternal changes need attention;}如果只有一个全局diskDocument用户在标签 A 发生冲突后切到标签 BB 的变化可能覆盖 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():Promisevoid{constdiskDocumentthis.externalConflicts.get(this.activeDocumentSessionId);if(!diskDocument){return;}awaitthis.captureActiveDocumentSession();constbaselinethis.persistedDocumentContent??;constcomparisonCharacterCountbaseline.lengththis.documentContent.lengthdiskDocument.content.length;if(comparisonCharacterCountMAX_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 注入。渲染函数创建行元素并写入textContentfunctioncreateConflictLine(lineNumber:number|undefined,text:string,kind:ConflictLineKind):HTMLElement{constlinedocument.createElement(div);line.classNameconflict-comparison__line conflict-comparison__line--${kind};constnumberdocument.createElement(span);number.classNameconflict-comparison__line-number;number.textContentlineNumberundefined?:String(lineNumber);constcontentdocument.createElement(span);content.classNameconflict-comparison__line-content;content.textContenttext.length0? :text;line.append(number,content);returnline;}textContent使script、HTML 标签和 Markdown 内嵌内容只按文本显示。空行使用视觉占位但不改变真实内容。样式类只来自内部枚举不由用户文本生成。CSP 继续禁止外部脚本和网络资源。这条安全规则与预览不同。预览会解析 Markdown 并经过 DOMPurify冲突视图的目标是忠实比较源码根本不需要解析。选择正确的展示模型比给所有内容套同一净化流程更直接。三列同步滚动需要递归抑制用户拖动任意一列时另外两列按滚动比例同步。程序设置目标scrollTop会再次触发滚动事件如果没有抑制会形成递归抖动。实现使用synchronizingConflictScroll锁并在下一动画帧释放container.addEventListener(scroll,(){if(synchronizingConflictScroll){return;}synchronizingConflictScrolltrue;constverticalRangeMath.max(0,container.scrollHeight-container.clientHeight);constverticalRatioverticalRange0?container.scrollTop/verticalRange:0;conflictLineContainers.forEach((target){if(target!container){target.scrollTopverticalRatio*Math.max(0,target.scrollHeight-target.clientHeight);}});window.requestAnimationFrame((){synchronizingConflictScrollfalse;});},{passive:true});比例同步不是像素同步。三列内容高度可能因换行、占位和字体产生细微差异使用各自可滚动范围比例更稳定。被动监听避免阻塞滚动主线程动画帧锁覆盖同一帧内的目标事件。同步并不等于语义行永远完美对齐因此差异渲染还应尽量产生相同数量的对齐行。性能测试需要覆盖长行、CJK、代码块和窄窗口换行而不能只用短英文行。大文档必须有降级而不是假装能算三份正文总字符数超过MAX_THREE_WAY_COMPARISON_CHARACTERS时原生层不会把所有内容送入差异 DOM而是计算有界摘要三方行数、第一处差异和局部预览。这样用户仍知道冲突存在可以 Save As 或选择版本同时编辑器避免冻结。降级提示明确说明大文档使用摘要不伪装成完整差异。用户做数据决策时必须知道当前信息是否完整。未来若引入 Worker/TaskPool 流式 diff也应在真机性能达到阈值后再扩大上限而不是只把计算移到后台却让巨大 DOM 卡住渲染。大文档策略同样保护 Bridge。三个大字符串跨运行时复制会产生显著内存峰值限制在原生调用前判断可以避免不必要传输。阈值需要通过 G3-10 的真机压力数据校准。Keep Local 的语义是推进基线真实实现从冲突映射取出磁盘快照将其正文、格式和指纹设为新的比较基线然后关闭冲突privatekeepLocalAfterExternalChange():void{constdiskDocumentthis.externalConflicts.get(this.activeDocumentSessionId);if(!diskDocument){return;}this.persistedDocumentContentdiskDocument.content;this.documentFormatdiskDocument.format;this.documentFingerprintdiskDocument.fingerprint;this.clearExternalConflict();this.operationStatusLocal 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 仍 dirtyUse 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 编辑器从“覆盖还是取消”的二选一升级为可解释的三方工作流。它没有冒充完整版本控制系统也没有用不成熟的自动合并替用户下注。保留每一份事实、让不可逆动作延后、为大文档提供诚实降级这些原则比差异界面的视觉复杂度更能决定本地编辑器是否值得信任。