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

桌面编辑器最不能犯的错误,是把“用户点击关闭”理解成“可以立即删除文档状态”。一个标签可能从未保存过,可能对应磁盘文件,可能在保存期间继续输入,可能遇到系统选择器取消、外部文件变化或写入失败。关闭动作如果只绑定一个布尔确认框,很容易在异步边界上丢失内容。

本文基于鸿蒙 PC Markdown 编辑器 OhMarkdown,拆解脏标签关闭的取消、放弃、保存三条路径,说明为什么保存后关闭必须等待真正持久化成功,为什么后台标签要先激活再保存,以及如何在关闭最后一个标签时保持工作台可用。完整代码位于 https://gitcode.com/VON-/codex_md_oh,本文对应提交 3a9146e

先区分关闭意图和关闭提交

用户点击标签关闭图标只表达一个意图。应用要先读取目标会话的 dirty 状态:

private requestCloseDocumentSession(sessionId: string): void {
  const session = this.documentSessions.find(
    (candidate: DocumentSession): boolean =>
      candidate.id === sessionId
  );
  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.index === 1) {
    this.closeDocumentSession(sessionId);
  } else if (result.index === 2) {
    this.saveAndCloseDocumentSession(sessionId);
  }
}).catch(() => {
  this.operationStatus = 'Unable to show confirmation';
});

索引零是取消,因此没有分支,状态保持不变。取消不是失败,也不需要把标签重新创建,因为它从未被删除。索引一是放弃,明确跳过保存并提交关闭。索引二是保存,进入一条异步链,不能直接调用关闭。

放弃按钮使用危险色,保存使用产品强调色,取消保持中性。颜色不是安全机制,真正安全性来自行为;视觉层只帮助用户在高频操作中区分后果。对话框按钮顺序也要与鸿蒙桌面交互习惯和键盘默认焦点一起验证,避免回车意外触发放弃。

showDialog 返回 Promise,显示失败会落到 catch,只更新状态栏,不会关闭标签。确认 UI 自身异常时,保守策略必须是保留内容。所有错误路径都应倾向“不丢数据”,而不是为了结束流程强行删除状态。

保存按钮不等于保存成功

最危险的错误实现是:用户点击保存,程序发出写入请求,然后立刻关闭标签。如果这是未命名文档,系统保存选择器还没有返回;用户可能取消选择。如果目标文件无权限、磁盘已变化或写入失败,标签已经消失,编辑缓冲区也可能被清理。

OhMarkdown 使用 pendingCloseSessionId 表示“保存成功后需要关闭的会话”:

private pendingCloseSessionId: string = '';

private async saveAndCloseDocumentSession(
  sessionId: string
): Promise<void> {
  if (sessionId !== this.activeDocumentSessionId) {
    await this.activateDocumentSession(sessionId);
  }
  if (sessionId !== this.activeDocumentSessionId) {
    return;
  }
  this.pendingCloseSessionId = sessionId;
  this.requestEditorCommand('save');
}

字段不是“关闭计时器”,而是保存事务结束后的条件动作。保存流程最终检查会话是否仍活动、是否已经干净;只有满足条件才真正关闭。

为什么后台标签需要先激活?当前文件保存管线读取页面级活动文档字段,包括 URI、正文、格式、revision 和持久化基线。直接保存后台会话会把当前页面字段与目标 sessionId 混用。先执行标准标签切换,让原生状态和 CodeMirror 状态都指向目标,再发出保存命令,可以复用经过验证的单活动会话保存路径。

切换是异步的,所以激活后再次比较 sessionId。ArkWeb 激活可能失败,或者文件操作互斥阻止切换;此时函数直接返回,既不设置 pending,也不关闭。重复检查看似保守,却封住了“以为切换成功”的竞态。

保存内容必须来自编辑器命令快照

requestEditorCommand('save') 让 Web 编辑器先刷新原生状态和恢复快照,再通过 Bridge 发送保存命令。原生 onEditorCommand 接收正文:

private onEditorCommand(
  command: string,
  content: string
): void {
  if (command === 'save') {
    this.documentContent = content;
    this.documentRevision += 1;
    this.syncActiveDocumentSession(this.documentContent);
    this.saveDocument(this.documentRevision);
  }
  // 省略其他命令
}

这样保存使用的是用户点击时的编辑器快照,而不是原生层上一次节流同步的旧正文。保存调用同时携带 snapshotRevision,后续可判断写入期间是否发生新编辑。

Bridge 协议在这里承担事务起点:编辑器先把当前内容固定为一个版本,原生层再写入。若直接由工具栏调用 saveDocument,按键事件和 Bridge 节流之间可能有时间差,最后几个字符不一定进入保存内容。

保存期间设置 operationInProgress,阻止另一个打开、保存或会话切换流程并发修改同一组活动字段。这个互斥让 pendingClose 只对应一笔保存。后续若支持多个后台会话并行保存,就需要把保存上下文从页面字段改成显式对象,包含 sessionId、URI、正文、格式和 revision,而不能仅去掉互斥。

系统选择器取消必须保留标签

未命名文档没有 URI,保存时先打开系统选择器:

let saveUri = this.documentUri;
const existingDocumentUri = this.documentUri;
if (saveUri.length === 0) {
  saveUri = await pickMarkdownSaveUri(
    context,
    this.documentName
  ) ?? '';
}

if (saveUri.length === 0) {
  this.operationStatus = this.hasUnsavedChanges()
    ? 'Modified'
    : 'Ready';
  return;
}

用户取消选择后 saveUri 为空,保存函数正常返回,documentDirty 仍为 true。finally 会看到 pendingClose,但因为文档仍脏,不执行关闭。标签、正文和星号全部保留。

这一行为需要独立测试,因为从用户视角,他先在关闭确认框点“保存”,又在系统选择器点“取消”。第二次取消应当覆盖第一次保存意图,而不是被解释为“取消另存为但继续关闭”。多层对话框中,最内层未完成的持久化必须阻止外层破坏性动作。

混合换行文档还可能出现第二个策略对话框,让用户选择 LF、CRLF 或取消:

const saveFormat = await this.resolveSaveFormat();
if (!saveFormat) {
  this.operationStatus = this.hasUnsavedChanges()
    ? 'Modified'
    : 'Ready';
  return;
}

取消格式选择同样保留 dirty,从而阻止关闭。状态机不是只围绕一个确认框,而是贯穿保存管线中所有可能终止事务的交互。

外部修改冲突也不能关闭

已有文件保存前会重新读取磁盘版本,与打开时记录的 persistedDocumentContent 和格式比较:

const diskDocument = await readUtf8Document(saveUri);
if (existingDocumentUri.length > 0 &&
  this.persistedDocumentContent !== undefined &&
  (diskDocument.content !== this.persistedDocumentContent ||
    !this.isSameDocumentFormat(
      diskDocument.format,
      this.documentFormat
    ))) {
  throw new Error(
    'The file changed on disk. Reopen it or use Save As ' +
    'to avoid overwriting external changes.'
  );
}

如果其他应用修改了同一文件,当前编辑器不能静默覆盖。异常进入 catch,文档继续保持 dirty,finally 不关闭。用户仍能从标签中复制内容、另存为或重新打开处理冲突。

这里再次说明关闭状态不能只依赖“保存函数有没有返回”。保存函数可能因为取消正常返回,也可能因为冲突抛错;最终判断应读取持久化后的事实,即目标会话是否干净。

写入失败时先恢复旧文件

保存前,程序把目标文件旧内容和格式写入应用沙箱备份:

pendingBackup = {
  version: 1,
  documentUri: saveUri,
  documentName: diskDocument.name,
  previousContent: diskDocument.content,
  hasUtf8Bom: diskDocument.format.hasUtf8Bom,
  lineEnding: diskDocument.format.lineEnding,
  updatedAt: Date.now()
};
await savePendingSaveBackup(context.filesDir, pendingBackup);

真正写入失败后,catch 尝试把旧版本写回;恢复成功则清理备份,恢复也失败则保留沙箱记录供下次启动处理。无论哪种情况,当前编辑缓冲区都不应被删除。

} catch (error) {
  const failureMessage = error instanceof Error
    ? error.message
    : String(error);
  if (pendingBackup) {
    try {
      await writeUtf8Document(
        pendingBackup.documentUri,
        pendingBackup.previousContent,
        {
          hasUtf8Bom: pendingBackup.hasUtf8Bom,
          lineEnding: this.parseLineEnding(
            pendingBackup.lineEnding
          )
        }
      );
      await clearPendingSaveBackup(context.filesDir);
      this.operationStatus =
        `Save failed; previous file restored: ${failureMessage}`;
    } catch (_) {
      this.operationStatus =
        `Save failed; backup retained: ${failureMessage}`;
    }
  }
}

关闭安全与文件安全在这里汇合:只要写入没有建立新的干净基线,pendingClose 就不能提交。即使旧磁盘文件已恢复,用户的新编辑仍只存在缓冲区,关闭会造成损失。

revision 防止保存期间的新输入被误判为已保存

保存开始时记录 snapshotRevision。写入成功后,只有当前 revision 仍等于快照版本,才能清除 dirty:

if (this.documentRevision === snapshotRevision) {
  this.documentDirty = false;
  this.operationStatus = backupCleared
    ? 'Saved'
    : 'Saved; backup cleanup pending';
  this.clearRecoveryDraft();
} else {
  this.operationStatus = 'Modified';
}

假设写入耗时较长,用户在保存过程中又输入一行。磁盘只包含保存开始时的快照,新一行尚未持久化。若无条件设为干净,标签星号消失,finally 随后关闭,最后一行就会丢失。revision 比较让它继续保持 Modified,pendingClose 因此不执行。

这是一种乐观版本控制:写入操作可以异步完成,但提交“已保存”状态前确认数据没有变化。它比简单禁用编辑器更符合桌面体验,用户不必等待磁盘;代价是需要正确维护每次编辑的 revision。

markEditorSaved() 会把 CodeMirror 内的保存基线更新到实际快照,原生层随后同步活动会话。保存基线、revision 和 dirty 三者必须一起迁移,不能只改标签星号。

finally 中的关闭条件

保存流程无论成功、取消还是失败,都会进入 finally:

} finally {
  const closeSessionId = this.pendingCloseSessionId;
  this.pendingCloseSessionId = '';
  if (closeSessionId.length > 0 &&
    closeSessionId === this.activeDocumentSessionId &&
    !this.documentDirty) {
    await this.closeDocumentSession(closeSessionId);
  }
  this.operationInProgress = false;
}

先把 pending 字段复制到局部变量并清空,保证这笔意图只消费一次。随后检查三个条件:确实有待关闭会话;它仍是当前活动会话;文档已经干净。任何条件不满足都保留标签。

检查活动身份防止异步期间关闭错标签。当前互斥已大幅减少切换,但身份检查仍是必要防线。检查 dirty 则统一覆盖选择器取消、格式取消、外部冲突、写入失败、恢复失败和保存期间继续输入。

最后才把 operationInProgress 设回 false。closeDocumentSession 内部还要切换后继会话并操作 ArkWeb,如果提前释放互斥,用户可能在关闭尚未完成时发出新命令。当前顺序让整个“保存并关闭”成为一个完整事务。

放弃关闭与恢复记录

用户选择放弃时,程序不写磁盘,直接删除目标会话。这是用户明确授权的数据丢弃路径。关闭活动会话后还会清理当前恢复草稿,避免下次启动又弹出已被用户放弃的内容。

但多标签崩溃恢复目前主要保证活动标签,后台标签的恢复记录尚未完整持久化。因此关闭后台脏标签时,放弃语义只作用于内存会话;未来扩展为多会话恢复集合后,必须按 sessionId 或 URI 删除对应记录,而不是清空全部草稿。

取消关闭则绝不能清理恢复记录。保存成功后可以清理,因为磁盘建立了新基线;放弃后可以清理,因为用户明确不要;显示对话框失败、系统选择器取消、写入失败都必须保留。

最后一个标签关闭后仍保留工作台

关闭提交从会话数组删除目标。如果删除后数组为空,程序创建一个新的干净未命名会话:

if (remainingSessions.length === 0) {
  remainingSessions = [
    createUntitledSession(this.createDocumentSessionId())
  ];
}

这样 Web 编辑器始终有活动 sessionId,工具栏的打开、新建、保存和快捷键不需要处理“零会话”特例。用户看到的是一个空白工作台,而不是编辑区域突然消失。

关闭中间标签时,后继选择原索引对应的右侧邻居;关闭末尾标签时选择新数组最后一项:

const nextIndex = Math.min(
  sessionIndex,
  remainingSessions.length - 1
);
const nextSession = remainingSessions[nextIndex];
this.applyDocumentSession(nextSession);
await this.activateEditorSession(nextSession);
this.closeEditorSession(sessionId);

先激活后继,再清理旧 Web 会话,避免编辑器出现没有状态的中间帧。关闭非活动标签则无需切换,只删除数组项和对应快照。

鸿蒙 PC 模拟器中的脏标签

下图来自 MateBook Pro 2in1 模拟器。标签栏同时存在两个未命名文档,活动标签正文为 Session-B,标签状态与底部 Modified 一致。这样的独立脏状态是关闭确认能够正确作用到目标标签的前提。

在这里插入图片描述

模拟器验证完成了取消和放弃分支:点击脏标签关闭图标,选择取消后标签和正文保留;再次关闭并选择放弃,只删除目标标签,邻近标签恢复且内容不串页。保存分支还要覆盖已有文件直接保存、未命名文件选择 URI、选择器取消、写入失败和保存期间继续输入。

测试报告不能把“调用保存方法”写成保存分支通过。真正通过标准是目标 URI 写入成功、dirty 清除后标签关闭;任何取消和失败都必须保留。状态机的验收围绕数据结果,而不是按钮路径。

应用退出与窗口关闭仍需单独设计

标签关闭状态机解决单个会话,不自动等于应用退出保护。用户点击窗口关闭时,可能有多个脏标签,需要决定逐个询问、汇总列表、全部保存或取消退出。若简单调用当前标签的关闭对话框,后台脏会话仍可能丢失。

完整退出流程可以收集所有 dirty 会话,显示可勾选列表,再按顺序保存。未命名文档会依次弹出系统选择器,任何取消都应中止退出。多个保存的错误汇总、部分成功后的状态、恢复记录清理也要定义。应用被系统强杀则不能弹框,只能依赖周期恢复快照。

因此,“关闭标签”“关闭窗口”“进程终止”是三种不同生命周期。它们可以复用会话 dirty 和保存事务,但不能共享一个简单确认函数后假设问题已经解决。

结语

未保存标签关闭是一台小型事务状态机:点击关闭只建立意图;干净标签直接提交;脏标签等待取消、放弃或保存;保存目标若在后台先完成会话切换;系统选择器、格式选择、外部冲突和写入异常都可以中止;revision 确认保存期间没有新输入;最终只在同一会话已经干净时关闭。

这套设计的核心标准很简单:所有不确定路径都保留用户内容,只有持久化成功或用户明确放弃才能删除会话。鸿蒙 PC 编辑器要进入长期真实写作场景,防误操作不能依赖一句“是否保存”,而要贯穿文件系统、Bridge、编辑器状态和多标签生命周期的完整调用链。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐