鸿蒙 PC Markdown 编辑器防误操作设计:未保存标签关闭状态机

发布时间:2026/7/23 13:50:38
鸿蒙 PC Markdown 编辑器防误操作设计:未保存标签关闭状态机 鸿蒙 PC Markdown 编辑器防误操作设计未保存标签关闭状态机桌面编辑器最不能犯的错误是把“用户点击关闭”理解成“可以立即删除文档状态”。一个标签可能从未保存过可能对应磁盘文件可能在保存期间继续输入可能遇到系统选择器取消、外部文件变化或写入失败。关闭动作如果只绑定一个布尔确认框很容易在异步边界上丢失内容。本文基于鸿蒙 PC Markdown 编辑器 OhMarkdown拆解脏标签关闭的取消、放弃、保存三条路径说明为什么保存后关闭必须等待真正持久化成功为什么后台标签要先激活再保存以及如何在关闭最后一个标签时保持工作台可用。完整代码位于 https://gitcode.com/VON-/codex_md_oh本文对应提交3a9146e。先区分关闭意图和关闭提交用户点击标签关闭图标只表达一个意图。应用要先读取目标会话的dirty状态privaterequestCloseDocumentSession(sessionId:string):void{constsessionthis.documentSessions.find((candidate:DocumentSession):booleancandidate.idsessionId);if(!session){return;}if(!session.dirty){this.closeDocumentSession(sessionId);return;}// 脏标签进入确认流程}这个入口名使用requestClose而真正删除会话的方法叫closeDocumentSession命名直接体现两阶段语义。干净标签没有数据风险可以进入关闭提交脏标签必须等待用户选择。查询目标会话而不是读取页面级documentDirty非常重要。用户可能点击一个后台标签的关闭按钮当前活动标签是干净的后台标签却有星号。如果用全局活动状态判断应用会无提示删除后台内容。多标签产品的任何命令都要明确作用对象不能默认目标永远是活动会话。如果 sessionId 已经过期或会话已被其他操作删除函数直接返回。声明式 UI 点击事件与异步操作可能交错业务层不应因为找不到对象而访问空值或误关当前标签。三个按钮代表三种不同事务脏标签确认对话框提供取消、放弃和保存this.getUIContext().getPromptAction().showDialog({title:$r(app.string.unsaved_changes_title),message:$r(app.string.unsaved_changes_message),buttons:[{text:$r(app.string.cancel_action),color:#34404B},{text:$r(app.string.discard_action),color:#B42318},{text:$r(app.string.save_action),color:#087A63}]}).then((result){if(result.index1){this.closeDocumentSession(sessionId);}elseif(result.index2){this.saveAndCloseDocumentSession(sessionId);}}).catch((){this.operationStatusUnable to show confirmation;});索引零是取消因此没有分支状态保持不变。取消不是失败也不需要把标签重新创建因为它从未被删除。索引一是放弃明确跳过保存并提交关闭。索引二是保存进入一条异步链不能直接调用关闭。放弃按钮使用危险色保存使用产品强调色取消保持中性。颜色不是安全机制真正安全性来自行为视觉层只帮助用户在高频操作中区分后果。对话框按钮顺序也要与鸿蒙桌面交互习惯和键盘默认焦点一起验证避免回车意外触发放弃。showDialog返回 Promise显示失败会落到 catch只更新状态栏不会关闭标签。确认 UI 自身异常时保守策略必须是保留内容。所有错误路径都应倾向“不丢数据”而不是为了结束流程强行删除状态。保存按钮不等于保存成功最危险的错误实现是用户点击保存程序发出写入请求然后立刻关闭标签。如果这是未命名文档系统保存选择器还没有返回用户可能取消选择。如果目标文件无权限、磁盘已变化或写入失败标签已经消失编辑缓冲区也可能被清理。OhMarkdown 使用pendingCloseSessionId表示“保存成功后需要关闭的会话”privatependingCloseSessionId:string;privateasyncsaveAndCloseDocumentSession(sessionId:string):Promisevoid{if(sessionId!this.activeDocumentSessionId){awaitthis.activateDocumentSession(sessionId);}if(sessionId!this.activeDocumentSessionId){return;}this.pendingCloseSessionIdsessionId;this.requestEditorCommand(save);}字段不是“关闭计时器”而是保存事务结束后的条件动作。保存流程最终检查会话是否仍活动、是否已经干净只有满足条件才真正关闭。为什么后台标签需要先激活当前文件保存管线读取页面级活动文档字段包括 URI、正文、格式、revision 和持久化基线。直接保存后台会话会把当前页面字段与目标 sessionId 混用。先执行标准标签切换让原生状态和 CodeMirror 状态都指向目标再发出保存命令可以复用经过验证的单活动会话保存路径。切换是异步的所以激活后再次比较 sessionId。ArkWeb 激活可能失败或者文件操作互斥阻止切换此时函数直接返回既不设置 pending也不关闭。重复检查看似保守却封住了“以为切换成功”的竞态。保存内容必须来自编辑器命令快照requestEditorCommand(save)让 Web 编辑器先刷新原生状态和恢复快照再通过 Bridge 发送保存命令。原生onEditorCommand接收正文privateonEditorCommand(command:string,content:string):void{if(commandsave){this.documentContentcontent;this.documentRevision1;this.syncActiveDocumentSession(this.documentContent);this.saveDocument(this.documentRevision);}// 省略其他命令}这样保存使用的是用户点击时的编辑器快照而不是原生层上一次节流同步的旧正文。保存调用同时携带snapshotRevision后续可判断写入期间是否发生新编辑。Bridge 协议在这里承担事务起点编辑器先把当前内容固定为一个版本原生层再写入。若直接由工具栏调用saveDocument按键事件和 Bridge 节流之间可能有时间差最后几个字符不一定进入保存内容。保存期间设置operationInProgress阻止另一个打开、保存或会话切换流程并发修改同一组活动字段。这个互斥让 pendingClose 只对应一笔保存。后续若支持多个后台会话并行保存就需要把保存上下文从页面字段改成显式对象包含 sessionId、URI、正文、格式和 revision而不能仅去掉互斥。系统选择器取消必须保留标签未命名文档没有 URI保存时先打开系统选择器letsaveUrithis.documentUri;constexistingDocumentUrithis.documentUri;if(saveUri.length0){saveUriawaitpickMarkdownSaveUri(context,this.documentName)??;}if(saveUri.length0){this.operationStatusthis.hasUnsavedChanges()?Modified:Ready;return;}用户取消选择后saveUri为空保存函数正常返回documentDirty仍为 true。finally 会看到 pendingClose但因为文档仍脏不执行关闭。标签、正文和星号全部保留。这一行为需要独立测试因为从用户视角他先在关闭确认框点“保存”又在系统选择器点“取消”。第二次取消应当覆盖第一次保存意图而不是被解释为“取消另存为但继续关闭”。多层对话框中最内层未完成的持久化必须阻止外层破坏性动作。混合换行文档还可能出现第二个策略对话框让用户选择 LF、CRLF 或取消constsaveFormatawaitthis.resolveSaveFormat();if(!saveFormat){this.operationStatusthis.hasUnsavedChanges()?Modified:Ready;return;}取消格式选择同样保留 dirty从而阻止关闭。状态机不是只围绕一个确认框而是贯穿保存管线中所有可能终止事务的交互。外部修改冲突也不能关闭已有文件保存前会重新读取磁盘版本与打开时记录的persistedDocumentContent和格式比较constdiskDocumentawaitreadUtf8Document(saveUri);if(existingDocumentUri.length0this.persistedDocumentContent!undefined(diskDocument.content!this.persistedDocumentContent||!this.isSameDocumentFormat(diskDocument.format,this.documentFormat))){thrownewError(The file changed on disk. Reopen it or use Save As to avoid overwriting external changes.);}如果其他应用修改了同一文件当前编辑器不能静默覆盖。异常进入 catch文档继续保持 dirtyfinally 不关闭。用户仍能从标签中复制内容、另存为或重新打开处理冲突。这里再次说明关闭状态不能只依赖“保存函数有没有返回”。保存函数可能因为取消正常返回也可能因为冲突抛错最终判断应读取持久化后的事实即目标会话是否干净。写入失败时先恢复旧文件保存前程序把目标文件旧内容和格式写入应用沙箱备份pendingBackup{version:1,documentUri:saveUri,documentName:diskDocument.name,previousContent:diskDocument.content,hasUtf8Bom:diskDocument.format.hasUtf8Bom,lineEnding:diskDocument.format.lineEnding,updatedAt:Date.now()};awaitsavePendingSaveBackup(context.filesDir,pendingBackup);真正写入失败后catch 尝试把旧版本写回恢复成功则清理备份恢复也失败则保留沙箱记录供下次启动处理。无论哪种情况当前编辑缓冲区都不应被删除。}catch(error){constfailureMessageerrorinstanceofError?error.message:String(error);if(pendingBackup){try{awaitwriteUtf8Document(pendingBackup.documentUri,pendingBackup.previousContent,{hasUtf8Bom:pendingBackup.hasUtf8Bom,lineEnding:this.parseLineEnding(pendingBackup.lineEnding)});awaitclearPendingSaveBackup(context.filesDir);this.operationStatusSave failed; previous file restored:${failureMessage};}catch(_){this.operationStatusSave failed; backup retained:${failureMessage};}}}关闭安全与文件安全在这里汇合只要写入没有建立新的干净基线pendingClose 就不能提交。即使旧磁盘文件已恢复用户的新编辑仍只存在缓冲区关闭会造成损失。revision 防止保存期间的新输入被误判为已保存保存开始时记录snapshotRevision。写入成功后只有当前 revision 仍等于快照版本才能清除 dirtyif(this.documentRevisionsnapshotRevision){this.documentDirtyfalse;this.operationStatusbackupCleared?Saved:Saved; backup cleanup pending;this.clearRecoveryDraft();}else{this.operationStatusModified;}假设写入耗时较长用户在保存过程中又输入一行。磁盘只包含保存开始时的快照新一行尚未持久化。若无条件设为干净标签星号消失finally 随后关闭最后一行就会丢失。revision 比较让它继续保持ModifiedpendingClose 因此不执行。这是一种乐观版本控制写入操作可以异步完成但提交“已保存”状态前确认数据没有变化。它比简单禁用编辑器更符合桌面体验用户不必等待磁盘代价是需要正确维护每次编辑的 revision。markEditorSaved()会把 CodeMirror 内的保存基线更新到实际快照原生层随后同步活动会话。保存基线、revision 和 dirty 三者必须一起迁移不能只改标签星号。finally 中的关闭条件保存流程无论成功、取消还是失败都会进入 finally}finally{constcloseSessionIdthis.pendingCloseSessionId;this.pendingCloseSessionId;if(closeSessionId.length0closeSessionIdthis.activeDocumentSessionId!this.documentDirty){awaitthis.closeDocumentSession(closeSessionId);}this.operationInProgressfalse;}先把 pending 字段复制到局部变量并清空保证这笔意图只消费一次。随后检查三个条件确实有待关闭会话它仍是当前活动会话文档已经干净。任何条件不满足都保留标签。检查活动身份防止异步期间关闭错标签。当前互斥已大幅减少切换但身份检查仍是必要防线。检查 dirty 则统一覆盖选择器取消、格式取消、外部冲突、写入失败、恢复失败和保存期间继续输入。最后才把operationInProgress设回 false。closeDocumentSession内部还要切换后继会话并操作 ArkWeb如果提前释放互斥用户可能在关闭尚未完成时发出新命令。当前顺序让整个“保存并关闭”成为一个完整事务。放弃关闭与恢复记录用户选择放弃时程序不写磁盘直接删除目标会话。这是用户明确授权的数据丢弃路径。关闭活动会话后还会清理当前恢复草稿避免下次启动又弹出已被用户放弃的内容。但多标签崩溃恢复目前主要保证活动标签后台标签的恢复记录尚未完整持久化。因此关闭后台脏标签时放弃语义只作用于内存会话未来扩展为多会话恢复集合后必须按 sessionId 或 URI 删除对应记录而不是清空全部草稿。取消关闭则绝不能清理恢复记录。保存成功后可以清理因为磁盘建立了新基线放弃后可以清理因为用户明确不要显示对话框失败、系统选择器取消、写入失败都必须保留。最后一个标签关闭后仍保留工作台关闭提交从会话数组删除目标。如果删除后数组为空程序创建一个新的干净未命名会话if(remainingSessions.length0){remainingSessions[createUntitledSession(this.createDocumentSessionId())];}这样 Web 编辑器始终有活动 sessionId工具栏的打开、新建、保存和快捷键不需要处理“零会话”特例。用户看到的是一个空白工作台而不是编辑区域突然消失。关闭中间标签时后继选择原索引对应的右侧邻居关闭末尾标签时选择新数组最后一项constnextIndexMath.min(sessionIndex,remainingSessions.length-1);constnextSessionremainingSessions[nextIndex];this.applyDocumentSession(nextSession);awaitthis.activateEditorSession(nextSession);this.closeEditorSession(sessionId);先激活后继再清理旧 Web 会话避免编辑器出现没有状态的中间帧。关闭非活动标签则无需切换只删除数组项和对应快照。鸿蒙 PC 模拟器中的脏标签下图来自 MateBook Pro 2in1 模拟器。标签栏同时存在两个未命名文档活动标签正文为Session-B标签状态与底部Modified一致。这样的独立脏状态是关闭确认能够正确作用到目标标签的前提。模拟器验证完成了取消和放弃分支点击脏标签关闭图标选择取消后标签和正文保留再次关闭并选择放弃只删除目标标签邻近标签恢复且内容不串页。保存分支还要覆盖已有文件直接保存、未命名文件选择 URI、选择器取消、写入失败和保存期间继续输入。测试报告不能把“调用保存方法”写成保存分支通过。真正通过标准是目标 URI 写入成功、dirty 清除后标签关闭任何取消和失败都必须保留。状态机的验收围绕数据结果而不是按钮路径。应用退出与窗口关闭仍需单独设计标签关闭状态机解决单个会话不自动等于应用退出保护。用户点击窗口关闭时可能有多个脏标签需要决定逐个询问、汇总列表、全部保存或取消退出。若简单调用当前标签的关闭对话框后台脏会话仍可能丢失。完整退出流程可以收集所有 dirty 会话显示可勾选列表再按顺序保存。未命名文档会依次弹出系统选择器任何取消都应中止退出。多个保存的错误汇总、部分成功后的状态、恢复记录清理也要定义。应用被系统强杀则不能弹框只能依赖周期恢复快照。因此“关闭标签”“关闭窗口”“进程终止”是三种不同生命周期。它们可以复用会话 dirty 和保存事务但不能共享一个简单确认函数后假设问题已经解决。结语未保存标签关闭是一台小型事务状态机点击关闭只建立意图干净标签直接提交脏标签等待取消、放弃或保存保存目标若在后台先完成会话切换系统选择器、格式选择、外部冲突和写入异常都可以中止revision 确认保存期间没有新输入最终只在同一会话已经干净时关闭。这套设计的核心标准很简单所有不确定路径都保留用户内容只有持久化成功或用户明确放弃才能删除会话。鸿蒙 PC 编辑器要进入长期真实写作场景防误操作不能依赖一句“是否保存”而要贯穿文件系统、Bridge、编辑器状态和多标签生命周期的完整调用链。