Skip to content

[Bug]外部程序修改已打开文件后,编辑器仍显示「已修改」标记(用户未手动编辑) #3165

Closed

Description

@bao1979

问题描述

当外部程序(AI 代理 / 脚本 / git checkout)修改了当前已在编辑器中打开的文件时,编辑器会正确地把可见缓冲区刷新为新磁盘内容,但标签页/编辑器仍显示「已修改 / 未保存更改」标记——即便用户从未手动输入任何内容,且此时缓冲区内容已与磁盘一致。

复现步骤

  1. 在编辑器中打开一个文件,保持标签页打开。
  2. 用外部程序修改磁盘上的同一文件(例如 AI 代理写文件,或 git checkout)。
  3. 编辑器检测到变更,并把可见内容刷新为最新磁盘内容。
  4. 观察:标签页/编辑器仍显示「已修改」标记。

期望行为

外部变更被同步、缓冲区与磁盘一致后,「已修改 / 脏」标记应被清除。

实际行为

脏标记残留(或短暂亮起),导致用户即使没有任何未保存编辑,也不得不「关闭而不保存」。

环境

  • 操作系统:Windows
  • OpenBitFun 版本:最新版(反馈时刚完成升级)
  • 触发条件:外部程序写入已打开的文件

根因分析(代码级)

1. 全局脏标记监听器没有「程序化同步」抑制

src/web-ui/src/tools/editor/services/MonacoModelManager.ts — setupContentChangeListener():

const listener = model.onDidChangeContent(() => {
  const metadata = this.modelMetadata.get(uriString);
  if (metadata) {
    const currentVersionId = model.getAlternativeVersionId();
    metadata.isDirty = this.documentModels.has(uriString)
      ? model.getValue() !== metadata.originalContent
      : currentVersionId !== metadata.savedVersionId;
    // 同时广播 monaco-model-dirty-changed 和 ModelContentChanged
  }
});

这里没有任何「正在同步」的防护。每一次 model.setValue()——包括外部磁盘同步——都会把 isDirty 重新计算为 true。

相比之下,src/web-ui/src/tools/editor/components/CodeEditor.tsx(约第 913 行)的组件级监听器是有防护的:

model.onDidChangeContent(() => {
  if (isLoadingContentRef.current) {
    return;  // 加载期间被抑制
  }
  // ...
  setHasChanges(changed);
});

因此同一次外部同步下,两套脏状态(hasChanges 状态 vs metadata.isDirty)会得出不一致的结果。

2. 脏标记同步置位、异步清除

CodeEditor.tsx — applyDiskSnapshotToEditor() 调用 applyExternalContentToModel(),后者执行 model.setValue(fileContent)(经全局监听器同步置脏),随后才在 queueMicrotask 里通过 monacoModelManager.markAsSaved(modelKey) 清除。

3. updateModelContent 默认 markAsSaved = false

MonacoModelManager.updateModelContent(filePath, content, markAsSaved = false):

model.setValue(content);        // 全局监听器把 isDirty 置为 true
if (metadata) {
  if (markAsSaved) {            // 默认 false → 永远不会清除
    metadata.savedVersionId = /* ... */;
    metadata.originalContent = content;
    metadata.isDirty = false;
  }
  // ...
}

任何把磁盘内容同步进已打开模型、但没有传 markAsSaved=true 的调用方,都会让模型永久处于脏状态。

4. 事件不对称

setValue 会广播 ModelContentChanged(标签页 / 文件列表的消费方据此把条目标脏),但 markAsSaved() 只派发 monaco-model-dirty-changed,不会发出对应的「内容已同步」事件。因此消费方会保留过期的脏标记。

修复建议

  1. 在 manager 层增加「正在同步」标志,让程序化 setValue 不标脏(或当内容是磁盘真值时,让 updateModelContent 默认采用「已保存」语义)。
  2. 让脏状态单一来源化——UI 读取 metadata.isDirty,而不是维护一套平行的 hasChanges。
  3. 外部同步时同步地清除脏标记,并广播一个对应的「内容已同步 / 已保存」事件给标签页 / 文件列表消费方。

Activity

  1. changed the title [-]外部程序修改已打开文件后,编辑器仍显示「已修改」标记(用户未手动编辑)[/-] [+][Bug]外部程序修改已打开文件后,编辑器仍显示「已修改」标记(用户未手动编辑)[/+] on Sep 21, 2026
  2. xiechimon commented on Sep 21, 2026

    @xiechimon
    Contributor

    我来修这个。方案走建议 1 和 3:在 MonacoModelManager 加一个外部同步 bracket(beginExternalSync / endExternalSync),bracket 期间的 setValue 不再重算 isDirty、不广播瞬时脏事件,同步结束时同步落 saved 状态并广播 dirty-changed(false);CodeEditor 的磁盘同步路径(applyDiskSnapshotToEditor、编码切换重载)接进 bracket,markAsSaved 从 queueMicrotask 提前到同步执行,消除「组件先卸载导致清脏被跳过」的残留窗口;updateModelContent 的 markAsSaved=true 分支补上 dirty-changed(false) 广播,与 setValue 的广播对称。建议 2(脏状态单一来源化)是跨组件重构,改动面大,这个 PR 不碰,可以后续单独讨论。PR 稍后发。

  3. added a commit that references this issue on Sep 22, 2026
    cdf7acf
  4. added a commit that references this issue on Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions