鸿蒙 PC Markdown 编辑器多文档架构DocumentSession 会话模型桌面 Markdown 编辑器从单文档升级到多标签表面上只是工具栏多出一排标签真正困难的部分却是状态归属发生了变化。单文档程序可以把documentUri、正文、修改标记和撤销历史都放在页面级变量中多标签程序必须回答更严格的问题切走时保存什么切回时恢复什么后台标签是否还能被保存两个内容相同但来源不同的文件如何区分关闭活动标签后焦点落在哪里最后一个标签关闭后工作台是否仍然可用。本文基于鸿蒙 PC 原生 Markdown 编辑器 OhMarkdown 的实际实现拆解 ArkUI 原生外壳与 ArkWeb CodeMirror 内核如何共同维护多文档会话。示例代码来自公开仓库https://gitcode.com/VON-/codex_md_oh多标签不是字符串数组最容易实现、也最容易出问题的方案是只在原生层保存每个标签的id、标题和正文字符串。切换标签时把当前编辑器内容读出来再把目标正文整段写进去。这个方案能让两个标签显示不同文字却会丢失编辑器内部状态选区、滚动位置、折叠区、组合输入状态、撤销栈和重做栈都不再属于文档。用户在标签甲输入内容切到标签乙后按撤销如果编辑内核仍使用同一条历史链就可能撤销甲的内容甚至退回上一份文件。因此多文档会话需要两层状态原生层负责文件身份、持久化和业务状态Web 编辑器层负责编辑器原生状态。两层通过稳定的sessionId对齐而不是通过文件名或正文内容猜测身份。原生层的会话模型定义在entry/src/main/ets/shared/services/DocumentSessionService.etsexportconstMAX_OPEN_DOCUMENT_SESSIONS:number12;exportinterfaceDocumentSession{id:string;uri:string;name:string;content:string;persistedContent:string|undefined;format:DocumentFormat;revision:number;dirty:boolean;wordCount:number;largeDocumentMode:boolean;}这些字段不是为了让对象显得完整而是分别承担不同的约束。id是进程内稳定身份。一个未命名文档没有 URI两个新建文档的名称又都可能是Untitled.md所以名称不能作为键。uri是系统文件身份用于打开、保存和判断同一文件是否已经打开。content是原生侧最近一次捕获的编辑内容它让工作区在 ArkWeb 临时不可用时仍能保有业务状态。persistedContent表示最近一次确认写入外部文件的正文用来区分编辑缓冲区和磁盘基线。format保存 UTF-8 BOM 与换行格式避免多标签切换时把甲文档的 CRLF 策略错误应用到乙文档。revision、dirty和wordCount分别承担恢复快照排序、未保存提示和状态栏显示。largeDocumentMode则把大文档保护边界也变成会话属性避免切换后错误开启预览或全文快照。会话上限固定为十二个。上限不是随意的 UI 限制而是当前内存模型下的保护措施。CodeMirror 的EditorState会保留文档树、选择区和历史扩展标签越多、文档越大常驻内存越高。Alpha 阶段先给出明确边界比允许无限打开后被系统低内存终止更可靠。未来如果要提高上限应先引入后台会话冻结、历史压缩和内存观测而不是只修改常量。新建会话必须从完整默认值开始未命名文档由一个纯函数创建exportfunctioncreateUntitledSession(id:string):DocumentSession{return{id:id,uri:,name:Untitled.md,content:,persistedContent:,format:createDefaultDocumentFormat(),revision:0,dirty:false,wordCount:0,largeDocumentMode:false};}集中创建默认值可以阻止一种常见错误页面在不同入口手工拼接会话对象某个入口忘记初始化format或persistedContent问题直到保存时才出现。纯函数也让“关闭最后一个标签后创建空白会话”与“点击加号新建会话”使用完全相同的基线。ArkUI 的状态更新采用替换对象而不是原地修改exportfunctionreplaceDocumentSession(sessions:ArrayDocumentSession,updated:DocumentSession):ArrayDocumentSession{returnsessions.map((session:DocumentSession):DocumentSessionsession.idupdated.id?updated:session);}这是为了配合声明式 UI 的变更检测。对数组中的对象直接赋值可能让视图层无法稳定观察到变化返回新数组能让标签标题、星号和活动状态及时重绘。对于最多十二项的小数组线性映射的成本远低于引入复杂可变状态后产生的一致性风险。切换前先捕获切换后再应用多标签切换的核心顺序是“捕获当前会话、应用目标会话、激活目标编辑状态”。如果先切换activeSessionId再读取编辑器读出的正文就会被错误记到目标会话。OhMarkdown 在WorkspaceShell.ets中把顺序写得很明确privateasyncactivateDocumentSession(sessionId:string):Promisevoid{if(sessionIdthis.activeDocumentSessionId||this.operationInProgress){return;}consttargetSessionthis.documentSessions.find((session:DocumentSession):booleansession.idsessionId);if(!targetSession){return;}awaitthis.captureActiveDocumentSession();this.applyDocumentSession(targetSession);awaitthis.activateEditorSession(targetSession);}operationInProgress会阻止文件选择或保存过程中发生标签切换。原因是这类操作存在异步窗口系统选择器打开后用户看到的仍是原窗口如果此时活动会话改变选择器返回的 URI 可能被写入另一个标签。最稳妥的做法是把文件操作和会话切换做互斥等操作闭环后再接受下一次切换。捕获正文通过 ArkWeb 的runJavaScript完成privateasynccaptureActiveDocumentSession():Promisevoid{letcontentthis.documentContent;if(this.editorReady){try{constresultawaitthis.editorController.runJavaScript(window.OhMarkdownEditor?.getDocument() ?? );contentthis.decodeJavaScriptString(result);}catch(_){}}this.documentContentcontent;this.syncActiveDocumentSession(content);}这里保留documentContent作为失败回退。如果 ArkWeb 正在重载或脚本调用失败原生层不会把会话覆盖为空字符串。另一个容易忽略的细节是runJavaScript返回的字符串可能经过 JSON 编码例如正文中的换行和引号会以转义形式出现。实现通过JSON.parse解码在解析失败时才回退原结果privatedecodeJavaScriptString(result:string):string{try{returnJSON.parse(result)asstring;}catch(_){returnresult;}}如果省略这一步普通单行文本看起来正常包含换行、反斜杠或引号的 Markdown 在标签切换后却可能变成带引号的 JSON 字面量。这类问题非常适合通过真实多行文档验证不能只测hello。Web 层保存完整 EditorState原生层会话对象不能替代 CodeMirror 状态。Web 内核为每个sessionId保存一个EditorSessionSnapshotinterfaceEditorSessionSnapshot{state:EditorState;baselineDocument:Text;forcedDirty:boolean;pendingDirty:boolean;documentRevision:number;lastRecoveryRevision:number;}consteditorSessionsnewMapstring,EditorSessionSnapshot();functionpersistActiveSession():void{editorSessions.set(activeSessionId,{state:editor.state,baselineDocument,forcedDirty,pendingDirty,documentRevision,lastRecoveryRevision});}直接保存EditorState有三个重要效果。首先正文和选区天然绑定不需要自己计算光标偏移。其次撤销历史是状态的一部分标签之间不会串线。最后CodeMirror 的文档是持久化数据结构保存状态快照并不等于每次都复制整篇字符串切换成本比手工序列化全部内部字段更可控。切换时当前状态先进入 Map再查找目标快照functionactivateSession(sessionId:string,content:string,recovered:booleanfalse):void{if(sessionIdactiveSessionId){return;}persistActiveSession();window.clearTimeout(bridgeTimer);window.clearTimeout(recoveryTimer);recoveryTimerundefined;pendingNativeChangefalse;activeSessionIdsessionId;conststoredSessioneditorSessions.get(sessionId);if(!storedSession){resetEditorDocument(content,recovered);return;}isApplyingNativeDocumenttrue;editor.setState(storedSession.state);isApplyingNativeDocumentfalse;baselineDocumentstoredSession.baselineDocument;forcedDirtystoredSession.forcedDirty;pendingDirtystoredSession.pendingDirty;documentRevisionstoredSession.documentRevision;lastRecoveryRevisionstoredSession.lastRecoveryRevision;}切换前必须清除旧会话的 Bridge 定时器和恢复定时器。否则用户刚从甲切到乙甲延迟发送的onChange或onSnapshot可能在乙已成为活动会话后到达原生层造成乙被标记为修改甚至把甲的正文写进乙的恢复记录。清除定时器后目标会话如果本身为脏状态再为它重新安排恢复快照这样异步任务始终跟随当前身份。isApplyingNativeDocument用来区分“用户编辑”与“程序切换状态”。设置EditorState可能触发更新监听如果不做抑制单纯切换标签也会被当成一次输入revision 增加、标签出现星号、恢复快照被重新写入。任何双运行时编辑器都需要这类来源标记否则状态回环迟早出现。打开文件时先去重再决定是否复用空白标签桌面用户经常从文件树重复点击同一个文档。正确行为是激活已打开标签而不是创建第二个指向同一 URI 的会话。OhMarkdown 先按 URI 查重constopenedSessionthis.documentSessions.find((session:DocumentSession):booleansession.uriopenedDocument.uri);if(openedSession){awaitthis.captureActiveDocumentSession();this.applyDocumentSession(openedSession);awaitthis.activateEditorSession(openedSession);return;}若当前标签是未命名、未修改且内容为空则复用它避免用户启动应用后第一次打开文件就留下一个无意义的空标签。复用条件必须同时检查 URI、脏状态和正文只检查正文为空会误伤一个“保存后为空”的真实文件只检查未修改则可能把已经命名的空文件覆盖。新文件会话保留读取阶段识别出的格式信息并根据内容长度初始化大文档模式。文件打开完成不代表可以把所有字段设成默认值BOM、换行、URI 和磁盘基线都必须从OpenedDocument一起迁移否则第一次切换标签后再保存就可能破坏字节语义。关闭活动标签是一个状态迁移关闭非活动标签相对简单从数组删除并清理 Web 层快照。关闭活动标签需要选择后继、恢复其原生状态、恢复其 CodeMirror 状态最后才删除旧快照。privateasynccloseDocumentSession(sessionId:string):Promisevoid{constsessionIndexthis.documentSessions.findIndex((session:DocumentSession):booleansession.idsessionId);if(sessionIndex0){return;}constwasActivesessionIdthis.activeDocumentSessionId;letremainingSessionsthis.documentSessions.filter((session:DocumentSession):booleansession.id!sessionId);if(!wasActive){this.documentSessionsremainingSessions;this.closeEditorSession(sessionId);return;}if(remainingSessions.length0){remainingSessions[createUntitledSession(this.createDocumentSessionId())];}this.documentSessionsremainingSessions;constnextIndexMath.min(sessionIndex,remainingSessions.length-1);constnextSessionremainingSessions[nextIndex];this.applyDocumentSession(nextSession);awaitthis.activateEditorSession(nextSession);this.closeEditorSession(sessionId);this.clearRecoveryDraft();}后继索引使用Math.min。关闭中间标签时新数组相同索引正好是右侧邻居关闭最后一个标签时索引收缩到新数组末尾。这个规则比固定跳到第一个标签更符合桌面编辑器的空间记忆。最后一个标签关闭后自动创建空白会话是为了保持工作台结构稳定。编辑器区域不会消失快捷键、新建和打开命令仍有明确目标也避免 ArkWeb 进入“没有活动 sessionId”的额外状态。空工作台并不一定要等于没有会话内部保留一个干净会话反而能减少大量分支。未保存状态不能只看当前标签标签上的星号来自每个DocumentSession.dirty而不是全局documentDirty。活动标签编辑时Bridge 回调更新页面状态再同步回活动会话对象切换后applyDocumentSession把目标会话的dirty、revision和格式恢复到页面字段。这样原生 UI 仍可以使用简洁的活动文档属性同时标签栏能读取整个会话数组展示各自状态。关闭脏标签时程序给出取消、放弃和保存三个分支。保存分支不能在发出保存命令后立即关闭因为系统文件选择器可能被取消外部 URI 写入也可能失败。实现设置pendingCloseSessionId等待保存成功并且当前会话已变为干净状态后才关闭。这个等待条件把“用户点击保存”与“数据已经持久化”分开是防止丢文档的关键。多标签恢复还有一个现阶段边界当前崩溃恢复记录主要围绕活动会话建立。若要保证所有后台脏标签在进程终止后都恢复需要把单记录扩展为按 sessionId 或 URI 索引的恢复集合并设计容量上限、加密与淘汰策略。在完成这套存储模型前不应该仅凭标签星号宣称多文档恢复已经完整。鸿蒙 PC 模拟器中的实际状态下图来自 MateBook Pro 2in1 模拟器。两个Untitled.md标签分别持有独立正文当前标签显示Session-B另一个标签仍保留未保存标记。切换回前一标签后正文和撤销历史均保持独立。截图验证的重点不是“出现两个标签”而是至少完成以下闭环甲输入、乙输入、甲撤销、乙内容不变两个标签独立显示脏状态关闭脏标签选择取消后内容保留选择放弃后只关闭目标选择保存时只有成功写入才关闭连续新建达到十二个后拒绝继续增长。Web 自动化测试直接验证了会话隔离awaitpage.evaluate((){consthostwindowasunknownasEditorTestWindow;host.OhMarkdownEditor.setSessionDocument(session-a,文档甲);});awaitpage.locator(.cm-content).click();awaitpage.keyboard.insertText(修改);awaitpage.evaluate((){consthostwindowasunknownasEditorTestWindow;host.OhMarkdownEditor.activateSession(session-b,文档乙);});awaitpage.locator(.cm-content).click();awaitpage.keyboard.insertText(新增);测试随后切回甲并撤销期望正文退回“文档甲”再切到乙期望仍为“文档乙新增”。这比只断言标签数量更接近真正风险因为多标签最严重的缺陷不是 UI 少一个标签而是用户编辑落入错误文档。设计结论鸿蒙 PC Markdown 编辑器的多文档能力本质上是跨 ArkUI 与 ArkWeb 的分布式状态管理。原生层掌握文件、格式、持久化和标签生命周期Web 层掌握 CodeMirror 的正文结构、选区和撤销历史二者以稳定 sessionId 对齐。切换必须遵循先捕获再应用异步定时器必须在身份变化时清理关闭必须等持久化结果而不是等待按钮点击。这套模型仍然保持了可控复杂度十二个会话使用小数组EditorState 使用 Map 保存页面只暴露活动文档字段没有引入通用状态管理框架。它解决的是实际的数据归属问题而不是为标签栏制造抽象。后续要扩展固定标签、会话重启恢复、标签拖动或跨窗口编辑都可以围绕同一个会话身份继续演进而不需要推翻文件保存与编辑内核。
鸿蒙 PC Markdown 编辑器多文档架构:DocumentSession 会话模型
鸿蒙 PC Markdown 编辑器多文档架构DocumentSession 会话模型桌面 Markdown 编辑器从单文档升级到多标签表面上只是工具栏多出一排标签真正困难的部分却是状态归属发生了变化。单文档程序可以把documentUri、正文、修改标记和撤销历史都放在页面级变量中多标签程序必须回答更严格的问题切走时保存什么切回时恢复什么后台标签是否还能被保存两个内容相同但来源不同的文件如何区分关闭活动标签后焦点落在哪里最后一个标签关闭后工作台是否仍然可用。本文基于鸿蒙 PC 原生 Markdown 编辑器 OhMarkdown 的实际实现拆解 ArkUI 原生外壳与 ArkWeb CodeMirror 内核如何共同维护多文档会话。示例代码来自公开仓库https://gitcode.com/VON-/codex_md_oh多标签不是字符串数组最容易实现、也最容易出问题的方案是只在原生层保存每个标签的id、标题和正文字符串。切换标签时把当前编辑器内容读出来再把目标正文整段写进去。这个方案能让两个标签显示不同文字却会丢失编辑器内部状态选区、滚动位置、折叠区、组合输入状态、撤销栈和重做栈都不再属于文档。用户在标签甲输入内容切到标签乙后按撤销如果编辑内核仍使用同一条历史链就可能撤销甲的内容甚至退回上一份文件。因此多文档会话需要两层状态原生层负责文件身份、持久化和业务状态Web 编辑器层负责编辑器原生状态。两层通过稳定的sessionId对齐而不是通过文件名或正文内容猜测身份。原生层的会话模型定义在entry/src/main/ets/shared/services/DocumentSessionService.etsexportconstMAX_OPEN_DOCUMENT_SESSIONS:number12;exportinterfaceDocumentSession{id:string;uri:string;name:string;content:string;persistedContent:string|undefined;format:DocumentFormat;revision:number;dirty:boolean;wordCount:number;largeDocumentMode:boolean;}这些字段不是为了让对象显得完整而是分别承担不同的约束。id是进程内稳定身份。一个未命名文档没有 URI两个新建文档的名称又都可能是Untitled.md所以名称不能作为键。uri是系统文件身份用于打开、保存和判断同一文件是否已经打开。content是原生侧最近一次捕获的编辑内容它让工作区在 ArkWeb 临时不可用时仍能保有业务状态。persistedContent表示最近一次确认写入外部文件的正文用来区分编辑缓冲区和磁盘基线。format保存 UTF-8 BOM 与换行格式避免多标签切换时把甲文档的 CRLF 策略错误应用到乙文档。revision、dirty和wordCount分别承担恢复快照排序、未保存提示和状态栏显示。largeDocumentMode则把大文档保护边界也变成会话属性避免切换后错误开启预览或全文快照。会话上限固定为十二个。上限不是随意的 UI 限制而是当前内存模型下的保护措施。CodeMirror 的EditorState会保留文档树、选择区和历史扩展标签越多、文档越大常驻内存越高。Alpha 阶段先给出明确边界比允许无限打开后被系统低内存终止更可靠。未来如果要提高上限应先引入后台会话冻结、历史压缩和内存观测而不是只修改常量。新建会话必须从完整默认值开始未命名文档由一个纯函数创建exportfunctioncreateUntitledSession(id:string):DocumentSession{return{id:id,uri:,name:Untitled.md,content:,persistedContent:,format:createDefaultDocumentFormat(),revision:0,dirty:false,wordCount:0,largeDocumentMode:false};}集中创建默认值可以阻止一种常见错误页面在不同入口手工拼接会话对象某个入口忘记初始化format或persistedContent问题直到保存时才出现。纯函数也让“关闭最后一个标签后创建空白会话”与“点击加号新建会话”使用完全相同的基线。ArkUI 的状态更新采用替换对象而不是原地修改exportfunctionreplaceDocumentSession(sessions:ArrayDocumentSession,updated:DocumentSession):ArrayDocumentSession{returnsessions.map((session:DocumentSession):DocumentSessionsession.idupdated.id?updated:session);}这是为了配合声明式 UI 的变更检测。对数组中的对象直接赋值可能让视图层无法稳定观察到变化返回新数组能让标签标题、星号和活动状态及时重绘。对于最多十二项的小数组线性映射的成本远低于引入复杂可变状态后产生的一致性风险。切换前先捕获切换后再应用多标签切换的核心顺序是“捕获当前会话、应用目标会话、激活目标编辑状态”。如果先切换activeSessionId再读取编辑器读出的正文就会被错误记到目标会话。OhMarkdown 在WorkspaceShell.ets中把顺序写得很明确privateasyncactivateDocumentSession(sessionId:string):Promisevoid{if(sessionIdthis.activeDocumentSessionId||this.operationInProgress){return;}consttargetSessionthis.documentSessions.find((session:DocumentSession):booleansession.idsessionId);if(!targetSession){return;}awaitthis.captureActiveDocumentSession();this.applyDocumentSession(targetSession);awaitthis.activateEditorSession(targetSession);}operationInProgress会阻止文件选择或保存过程中发生标签切换。原因是这类操作存在异步窗口系统选择器打开后用户看到的仍是原窗口如果此时活动会话改变选择器返回的 URI 可能被写入另一个标签。最稳妥的做法是把文件操作和会话切换做互斥等操作闭环后再接受下一次切换。捕获正文通过 ArkWeb 的runJavaScript完成privateasynccaptureActiveDocumentSession():Promisevoid{letcontentthis.documentContent;if(this.editorReady){try{constresultawaitthis.editorController.runJavaScript(window.OhMarkdownEditor?.getDocument() ?? );contentthis.decodeJavaScriptString(result);}catch(_){}}this.documentContentcontent;this.syncActiveDocumentSession(content);}这里保留documentContent作为失败回退。如果 ArkWeb 正在重载或脚本调用失败原生层不会把会话覆盖为空字符串。另一个容易忽略的细节是runJavaScript返回的字符串可能经过 JSON 编码例如正文中的换行和引号会以转义形式出现。实现通过JSON.parse解码在解析失败时才回退原结果privatedecodeJavaScriptString(result:string):string{try{returnJSON.parse(result)asstring;}catch(_){returnresult;}}如果省略这一步普通单行文本看起来正常包含换行、反斜杠或引号的 Markdown 在标签切换后却可能变成带引号的 JSON 字面量。这类问题非常适合通过真实多行文档验证不能只测hello。Web 层保存完整 EditorState原生层会话对象不能替代 CodeMirror 状态。Web 内核为每个sessionId保存一个EditorSessionSnapshotinterfaceEditorSessionSnapshot{state:EditorState;baselineDocument:Text;forcedDirty:boolean;pendingDirty:boolean;documentRevision:number;lastRecoveryRevision:number;}consteditorSessionsnewMapstring,EditorSessionSnapshot();functionpersistActiveSession():void{editorSessions.set(activeSessionId,{state:editor.state,baselineDocument,forcedDirty,pendingDirty,documentRevision,lastRecoveryRevision});}直接保存EditorState有三个重要效果。首先正文和选区天然绑定不需要自己计算光标偏移。其次撤销历史是状态的一部分标签之间不会串线。最后CodeMirror 的文档是持久化数据结构保存状态快照并不等于每次都复制整篇字符串切换成本比手工序列化全部内部字段更可控。切换时当前状态先进入 Map再查找目标快照functionactivateSession(sessionId:string,content:string,recovered:booleanfalse):void{if(sessionIdactiveSessionId){return;}persistActiveSession();window.clearTimeout(bridgeTimer);window.clearTimeout(recoveryTimer);recoveryTimerundefined;pendingNativeChangefalse;activeSessionIdsessionId;conststoredSessioneditorSessions.get(sessionId);if(!storedSession){resetEditorDocument(content,recovered);return;}isApplyingNativeDocumenttrue;editor.setState(storedSession.state);isApplyingNativeDocumentfalse;baselineDocumentstoredSession.baselineDocument;forcedDirtystoredSession.forcedDirty;pendingDirtystoredSession.pendingDirty;documentRevisionstoredSession.documentRevision;lastRecoveryRevisionstoredSession.lastRecoveryRevision;}切换前必须清除旧会话的 Bridge 定时器和恢复定时器。否则用户刚从甲切到乙甲延迟发送的onChange或onSnapshot可能在乙已成为活动会话后到达原生层造成乙被标记为修改甚至把甲的正文写进乙的恢复记录。清除定时器后目标会话如果本身为脏状态再为它重新安排恢复快照这样异步任务始终跟随当前身份。isApplyingNativeDocument用来区分“用户编辑”与“程序切换状态”。设置EditorState可能触发更新监听如果不做抑制单纯切换标签也会被当成一次输入revision 增加、标签出现星号、恢复快照被重新写入。任何双运行时编辑器都需要这类来源标记否则状态回环迟早出现。打开文件时先去重再决定是否复用空白标签桌面用户经常从文件树重复点击同一个文档。正确行为是激活已打开标签而不是创建第二个指向同一 URI 的会话。OhMarkdown 先按 URI 查重constopenedSessionthis.documentSessions.find((session:DocumentSession):booleansession.uriopenedDocument.uri);if(openedSession){awaitthis.captureActiveDocumentSession();this.applyDocumentSession(openedSession);awaitthis.activateEditorSession(openedSession);return;}若当前标签是未命名、未修改且内容为空则复用它避免用户启动应用后第一次打开文件就留下一个无意义的空标签。复用条件必须同时检查 URI、脏状态和正文只检查正文为空会误伤一个“保存后为空”的真实文件只检查未修改则可能把已经命名的空文件覆盖。新文件会话保留读取阶段识别出的格式信息并根据内容长度初始化大文档模式。文件打开完成不代表可以把所有字段设成默认值BOM、换行、URI 和磁盘基线都必须从OpenedDocument一起迁移否则第一次切换标签后再保存就可能破坏字节语义。关闭活动标签是一个状态迁移关闭非活动标签相对简单从数组删除并清理 Web 层快照。关闭活动标签需要选择后继、恢复其原生状态、恢复其 CodeMirror 状态最后才删除旧快照。privateasynccloseDocumentSession(sessionId:string):Promisevoid{constsessionIndexthis.documentSessions.findIndex((session:DocumentSession):booleansession.idsessionId);if(sessionIndex0){return;}constwasActivesessionIdthis.activeDocumentSessionId;letremainingSessionsthis.documentSessions.filter((session:DocumentSession):booleansession.id!sessionId);if(!wasActive){this.documentSessionsremainingSessions;this.closeEditorSession(sessionId);return;}if(remainingSessions.length0){remainingSessions[createUntitledSession(this.createDocumentSessionId())];}this.documentSessionsremainingSessions;constnextIndexMath.min(sessionIndex,remainingSessions.length-1);constnextSessionremainingSessions[nextIndex];this.applyDocumentSession(nextSession);awaitthis.activateEditorSession(nextSession);this.closeEditorSession(sessionId);this.clearRecoveryDraft();}后继索引使用Math.min。关闭中间标签时新数组相同索引正好是右侧邻居关闭最后一个标签时索引收缩到新数组末尾。这个规则比固定跳到第一个标签更符合桌面编辑器的空间记忆。最后一个标签关闭后自动创建空白会话是为了保持工作台结构稳定。编辑器区域不会消失快捷键、新建和打开命令仍有明确目标也避免 ArkWeb 进入“没有活动 sessionId”的额外状态。空工作台并不一定要等于没有会话内部保留一个干净会话反而能减少大量分支。未保存状态不能只看当前标签标签上的星号来自每个DocumentSession.dirty而不是全局documentDirty。活动标签编辑时Bridge 回调更新页面状态再同步回活动会话对象切换后applyDocumentSession把目标会话的dirty、revision和格式恢复到页面字段。这样原生 UI 仍可以使用简洁的活动文档属性同时标签栏能读取整个会话数组展示各自状态。关闭脏标签时程序给出取消、放弃和保存三个分支。保存分支不能在发出保存命令后立即关闭因为系统文件选择器可能被取消外部 URI 写入也可能失败。实现设置pendingCloseSessionId等待保存成功并且当前会话已变为干净状态后才关闭。这个等待条件把“用户点击保存”与“数据已经持久化”分开是防止丢文档的关键。多标签恢复还有一个现阶段边界当前崩溃恢复记录主要围绕活动会话建立。若要保证所有后台脏标签在进程终止后都恢复需要把单记录扩展为按 sessionId 或 URI 索引的恢复集合并设计容量上限、加密与淘汰策略。在完成这套存储模型前不应该仅凭标签星号宣称多文档恢复已经完整。鸿蒙 PC 模拟器中的实际状态下图来自 MateBook Pro 2in1 模拟器。两个Untitled.md标签分别持有独立正文当前标签显示Session-B另一个标签仍保留未保存标记。切换回前一标签后正文和撤销历史均保持独立。截图验证的重点不是“出现两个标签”而是至少完成以下闭环甲输入、乙输入、甲撤销、乙内容不变两个标签独立显示脏状态关闭脏标签选择取消后内容保留选择放弃后只关闭目标选择保存时只有成功写入才关闭连续新建达到十二个后拒绝继续增长。Web 自动化测试直接验证了会话隔离awaitpage.evaluate((){consthostwindowasunknownasEditorTestWindow;host.OhMarkdownEditor.setSessionDocument(session-a,文档甲);});awaitpage.locator(.cm-content).click();awaitpage.keyboard.insertText(修改);awaitpage.evaluate((){consthostwindowasunknownasEditorTestWindow;host.OhMarkdownEditor.activateSession(session-b,文档乙);});awaitpage.locator(.cm-content).click();awaitpage.keyboard.insertText(新增);测试随后切回甲并撤销期望正文退回“文档甲”再切到乙期望仍为“文档乙新增”。这比只断言标签数量更接近真正风险因为多标签最严重的缺陷不是 UI 少一个标签而是用户编辑落入错误文档。设计结论鸿蒙 PC Markdown 编辑器的多文档能力本质上是跨 ArkUI 与 ArkWeb 的分布式状态管理。原生层掌握文件、格式、持久化和标签生命周期Web 层掌握 CodeMirror 的正文结构、选区和撤销历史二者以稳定 sessionId 对齐。切换必须遵循先捕获再应用异步定时器必须在身份变化时清理关闭必须等持久化结果而不是等待按钮点击。这套模型仍然保持了可控复杂度十二个会话使用小数组EditorState 使用 Map 保存页面只暴露活动文档字段没有引入通用状态管理框架。它解决的是实际的数据归属问题而不是为标签栏制造抽象。后续要扩展固定标签、会话重启恢复、标签拖动或跨窗口编辑都可以围绕同一个会话身份继续演进而不需要推翻文件保存与编辑内核。