Timeline Diff — 版本一致性与脏文本保护

文档发表:2026-07-20 · 阅读:143 · 更新:2026-08-02

基于原创 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 显示同一文件,用户看到的始终是同一个版本

两个层次共同实现了最终目标:用户永远不需要理解、参与或仲裁文件系统的版本冲突。 编辑器的唯一义务是让用户的每一次键盘敲击都安全、连贯、无感知。

这就是我们对于「文件在外部被更改」弹窗问题的解决方案。