问题描述
当外部程序(AI 代理 / 脚本 / git checkout)修改了当前已在编辑器中打开的文件时,编辑器会正确地把可见缓冲区刷新为新磁盘内容,但标签页/编辑器仍显示「已修改 / 未保存更改」标记——即便用户从未手动输入任何内容,且此时缓冲区内容已与磁盘一致。
复现步骤
- 在编辑器中打开一个文件,保持标签页打开。
- 用外部程序修改磁盘上的同一文件(例如 AI 代理写文件,或
git checkout)。
- 编辑器检测到变更,并把可见内容刷新为最新磁盘内容。
- 观察:标签页/编辑器仍显示「已修改」标记。
期望行为
外部变更被同步、缓冲区与磁盘一致后,「已修改 / 脏」标记应被清除。
实际行为
脏标记残留(或短暂亮起),导致用户即使没有任何未保存编辑,也不得不「关闭而不保存」。
环境
- 操作系统: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,不会发出对应的「内容已同步」事件。因此消费方会保留过期的脏标记。
修复建议
- 在 manager 层增加「正在同步」标志,让程序化
setValue 不标脏(或当内容是磁盘真值时,让 updateModelContent 默认采用「已保存」语义)。
- 让脏状态单一来源化——UI 读取
metadata.isDirty,而不是维护一套平行的 hasChanges。
- 外部同步时同步地清除脏标记,并广播一个对应的「内容已同步 / 已保存」事件给标签页 / 文件列表消费方。
问题描述
当外部程序(AI 代理 / 脚本 /
git checkout)修改了当前已在编辑器中打开的文件时,编辑器会正确地把可见缓冲区刷新为新磁盘内容,但标签页/编辑器仍显示「已修改 / 未保存更改」标记——即便用户从未手动输入任何内容,且此时缓冲区内容已与磁盘一致。复现步骤
git checkout)。期望行为
外部变更被同步、缓冲区与磁盘一致后,「已修改 / 脏」标记应被清除。
实际行为
脏标记残留(或短暂亮起),导致用户即使没有任何未保存编辑,也不得不「关闭而不保存」。
环境
根因分析(代码级)
1. 全局脏标记监听器没有「程序化同步」抑制
src/web-ui/src/tools/editor/services/MonacoModelManager.ts—setupContentChangeListener():这里没有任何「正在同步」的防护。每一次
model.setValue()——包括外部磁盘同步——都会把isDirty重新计算为true。相比之下,
src/web-ui/src/tools/editor/components/CodeEditor.tsx(约第 913 行)的组件级监听器是有防护的:因此同一次外部同步下,两套脏状态(
hasChanges状态 vsmetadata.isDirty)会得出不一致的结果。2. 脏标记同步置位、异步清除
CodeEditor.tsx—applyDiskSnapshotToEditor()调用applyExternalContentToModel(),后者执行model.setValue(fileContent)(经全局监听器同步置脏),随后才在queueMicrotask里通过monacoModelManager.markAsSaved(modelKey)清除。3.
updateModelContent默认markAsSaved = falseMonacoModelManager.updateModelContent(filePath, content, markAsSaved = false):任何把磁盘内容同步进已打开模型、但没有传
markAsSaved=true的调用方,都会让模型永久处于脏状态。4. 事件不对称
setValue会广播ModelContentChanged(标签页 / 文件列表的消费方据此把条目标脏),但markAsSaved()只派发monaco-model-dirty-changed,不会发出对应的「内容已同步」事件。因此消费方会保留过期的脏标记。修复建议
setValue不标脏(或当内容是磁盘真值时,让updateModelContent默认采用「已保存」语义)。metadata.isDirty,而不是维护一套平行的hasChanges。