基于原创 timeline diff 系统解决「文档在外部被更改」导致的版本不一致和脏文本问题
1. 要解决什么问题
一个文件被多处打开——同一个 IDE 的两个窗口、IDE 和外部编辑器、甚至同一个窗口的两个 tab——用户在 A 处编辑、切到 B 处看到的是旧内容,大脑瞬间分裂:我刚刚改的内容去哪了?是谁覆盖了谁?
传统 IDE(VS Code、JetBrains、Sublime)给出的答案是弹窗:「文件在外部被修改了——你要覆盖还是重载?」这个弹窗把文件系统的一致性仲裁推给了用户。用户不知道「外部」是谁,不知道选「是」会丢什么,不知道选「否」的后果。
本质问题是:用户在编辑器里的注意力应该放在「我要写什么」上,而不是「文件系统此刻是什么状态」。
2. 历史方案的失败点
VS Code 模式:「弹窗二选一」
- 文件被外部改了 → 弹出
File has been modified externally. Reload? - 用户必须理解「覆盖」和「重载」的含义
- 选错了就丢数据
- 当有 3 个窗口同时打开同一文件,弹窗乱飞
JetBrains 模式:「底部通知 + 非阻塞」
- 底部出现「File changed externally. Show Diff」
- 比 VS Code 好——不打断,但用户仍需处理冲突
- 本质上仍需要用户理解文件版本语义
Apple 版本系统
- OS 级版本历史,每次保存是一个新版
- 不存在「覆盖」——但需要整个 OS 栈支持,跨平台不可行
共同缺陷
所有方案都假设:用户应该关心文件系统状态。 这是一个错误的假设。用户只想编辑文本。文件系统是编辑器的领域,编辑器应该自己兜底。
3. 第一层:Timeline Diff — 永不丢失数据的安全网
我们的底层设施是自建的 timeline 版本系统(文件位于 _qqq/timeline/):
存储: blobs/{sha256}.gz (内容不可变,SHA256 行尾归一化去重)
索引: timeline.db (SQLite 全量快照)
增量: timeline.wal (NDJSON ≤99 行)
工作方式
每次编辑器 blur 或 Ctrl+S,触发保存管线:
① _captureExternalBefore(filePath)
→ 读磁盘当前内容
→ 查 timeline 数据库中最新的 blob_hash
→ 解压对比
→ 如果不相等 → 外部修改发生
→ 捕获当前磁盘版本到 timeline(source=editx-before,不走 100s 冷却)
② 写入我们自己的编辑内容到磁盘
③ _xHookRecord / _maybeRecordTimeline
→ 正常保存快照(source=editx,走 100s 冷却 + SHA256 去重)
结果
| 场景 | 磁盘 | timeline 里的记录 |
|---|---|---|
| 正常编辑 | 我们的版本 | editx (单条) |
| 外部先改了 | 外部版本 → 我们的版本 | editx-before + editx (两条) |
| 两个窗口先后保存 | 后者覆盖前者 | editx-before(前者的内容) + editx(后者的内容) |
任何版本都不会丢失。 任何时候打开 diff 窗口(独立 BrowserWindow,Monaco side-by-side),都能看到完整的版本链。外部修改被静默捕获为一个 before 快照,用户事后可以审查,但从不需要在编辑时做决策。文件系统的噪音被完全隔离在用户注意力之外。
4. 第二层:跨窗口脏文件追踪 — IDE 领域内视觉一致
Timeline diff 解决了保存后的数据安全。但还有另一个问题:保存前的脏文本不一致。
场景
窗口 A: 编辑 foo.py,内存中有 "abcXYZ"(未保存)
窗口 B: 也打开 foo.py,显示的是磁盘上的旧版本 "abc"
用户切换到窗口 B 的 foo.py tab,看到的是过时的 "abc",虽然窗口 A 里已经有最新的 "abcXYZ"。这是我们 IDE 自己领域内的不一致——磁盘上确实还是旧版本(A 还没保存),但用户期望在不同窗口/不同 tab 里看到同一个版本。
设计
维护一个跨窗口共享的脏文件快照(主进程内存 Map),键是文件路径,值是当前未保存的最新文本。
┌─────────────────────────────────────┐
│ Main Process (共享内存) │
│ __dirtySnapshots: Map<path,text> │
└──┬──────────────┬───────────────────┘
│ IPC │ IPC
▼ ▼
┌──────┐ ┌──────┐
│窗口 A │ │窗口 B │
│编辑中 │ │显示中 │
└──────┘ └──────┘
数据流
① 窗口 A 编辑 foo.py,内容变化
→ debounced (500ms) → bridge.dirty.set("foo.py", content)
→ 主进程存储最新脏文本
② 窗口 B 获得焦点 / 切换到 foo.py 的 tab
→ bridge.dirty.get("foo.py") → 有脏文本
→ 窗口 B 的 Monaco model 更新为脏文本
→ 用户看到的是窗口 A 正在编辑的最新版本
③ 窗口 A 保存(blur 或 Ctrl+S)
→ 写盘 → timeline 捕获 → bridge.dirty.remove("foo.py")
→ 此时磁盘已是最新版本,后续读取天然一致
触发时机(只需两个事件)
- 窗口获得焦点 (
window focus):遍历当前活跃窗口中所有打开的 file tab,逐一检查是否有脏快照 - tab 切换 (
activateTab):切换到的 tab 对应的文件检查是否有脏快照
性能
- 键盘输入时:仅更新渲染层 DOM(零 IPC),500ms 后才推送一次脏快照到主进程
- 脏快照存储:主进程内存 Map,零序列化开销
- 窗口聚焦 / tab 切换:一次 IPC 调用取回文本,零额外开销
5. 两层协同:完整闭环
用户在 IDE 内编辑文件
│
▼
[Layer 2: 脏文件追踪]
编辑 → debounced push 到主进程
切窗口/tab → pull 最新脏文本
保存 → 从主进程删除
│ (保存触发)
▼
[Layer 1: Timeline Diff]
_captureExternalBefore → 外部修改?捕获 before 快照
写盘
_xHookRecord / _maybeRecordTimeline → 正常 after 快照
两层职责完全不同,互不耦合:
- Layer 2 管未保存的脏文本在 IDE 内部的一致性(我编辑的同一个东西,到处看起来都一样)
- Layer 1 管保存后的版本永久安全(任何版本都能在 diff 窗口里找回来,不用在弹窗中做决策)
6. 结论
我们给出的方案是一个有两个独立层次的架构:
- 下层:Timeline diff 提供零数据损失的安全网——捕获所有变化,静默归档,事后可查
- 上层:跨窗口脏文件追踪提供 IDE 领域内的视觉一致性——无论打开了多少个窗口、多少个 tab 显示同一文件,用户看到的始终是同一个版本
两个层次共同实现了最终目标:用户永远不需要理解、参与或仲裁文件系统的版本冲突。 编辑器的唯一义务是让用户的每一次键盘敲击都安全、连贯、无感知。
这就是我们对于「文件在外部被更改」弹窗问题的解决方案。