Skip to content

04 · 文件与工作区

编辑器丢用户的字,一次就足以毁掉信任。这一章的所有设计都围绕「不丢字」。

1. 工作区模型

工作区 = 一个普通文件夹。 没有数据库、没有 .typo 私有格式绑架用户数据。

my-notes/                    ← 工作区根
├── .typo/                   ← 可选,工作区级配置(可加进 .gitignore)
│   ├── workspace.json       #   打开的标签页、侧边栏宽度等会话状态
│   └── index.sqlite         #   搜索索引(派生数据,删了能重建)
├── assets/                  ← 默认附件目录(可配置)
└── **/*.md

.typo/ 里的一切都是可重建的派生数据。删掉它,除了丢失会话状态,不损失任何内容。

也支持「单文件模式」:直接双击一个 .md 打开,不需要工作区。

1.1 文件树是只读的导航视图

点一下在标签里打开,仅此而已。不提供重命名、拖拽移动、新建、删除 —— 那些是文件系统的写操作,每一条都要处理「目标已存在」「正开着的文件被移走」 「权限不足」「操作到一半失败」,而且每一条都可能不可逆。它们该是一批单独的 工作,不是文件树顺手带出来的赠品。

懒展开,且不监听目录。 一次性递归整棵树在大仓库上要几秒钟,而用户通常 只看两三层;递归 inotify 则会直接爆句柄上限,换来的只是「别人在别处新建了 文件、树里自动多一行」。所以给了刷新按钮,没给监听。

node_modules.gitdist 与点文件不进树:前者是量级问题(展开一个前端 仓库的 node_modules 会有几万条),后者是工具的东西不是用户的正文。

1.2 「列目录」是一条独立的许可

白名单(01 §5)里,「能不能读这个文件」和「能不能看这个目录的清单」分开

  • 用户显式打开的工作区目录 → 连同子树,两条许可都给;
  • 用户显式打开的单个文件 → 给读文件的许可,连同它所在目录(相对路径的 图片要能加载),但不给列目录的许可

合并成一条会顺带放开一件不该放开的事:用户只打开过某个文件时, 「那个目录里还有什么别的文件」不该因此被交出去。

2. 保存:原子写入

1. 写入同目录临时文件  ./.foo.md.tmp-<rand>
2. fsync
3. rename(tmp, target)      ← POSIX 上原子;Windows 用 ReplaceFileW
4. 更新内存中的 mtime / hash 基线

同目录写临时文件是必须的:跨文件系统 rename 不是原子操作。 若目标文件是符号链接,解析到真实路径后再走上述流程(否则会把符号链接替换成普通文件)。 若目录不可写(例如只读挂载),回退为直接覆盖写并明确告知用户风险。

保留原文件的权限位与所有者(尽力而为)。

3. 外部修改与冲突

文件监听(chokidarfs.watch + 去抖 100ms)跑在 main 进程。收到变更事件时:

                    磁盘 mtime/hash 变了?

              ┌─────────────┴─────────────┐
             否                           是
              │                            │
           忽略                    编辑器有未保存改动?

                              ┌─────────────┴─────────────┐
                             否                           是
                              │                            │
              直接重载(保留光标位置与滚动位置)      弹冲突对话框:
                                              [保留我的] [用磁盘的] [并排 diff]
  • 比对用 mtime + 大小 + 内容 hash 三重,避免 mtime 精度问题误判。
  • 保存前再校验一次基线:如果磁盘上的内容自打开以来变过而编辑器不知道, 不允许静默覆盖,走同一个冲突对话框。
  • 自己保存触发的变更事件要能识别并忽略(记录刚写入的 hash)。

4. 崩溃恢复

用户输入后 500ms 防抖 把未保存内容写入草稿目录(应用数据目录,非工作区):

<userData>/drafts/<hash(path)>/
├── meta.json      # 原文件路径、基线 mtime、基线 hash、时间戳
└── content.md     # 当前缓冲区内容

启动时扫描草稿目录:若存在草稿且其内容与原文件不同,提示「上次未正常退出, 是否恢复未保存的修改?」并提供 diff 预览。正常保存后删除对应草稿。

草稿写入用同样的原子写流程;写失败只记日志,不打断用户输入。

5. 附件与图片

粘贴 / 拖入图片时的处理策略(可配置,默认第一项):

策略行为
复制到附件目录(默认)存到 assets/(可按文档名建子目录),插入相对路径
保持原位置插入相对路径引用原文件
转 Base64 内嵌适合要求单文件的场景,大图警告
上传(插件)由插件提供图床能力

配套能力:

  • 文件重命名 / 移动时,同步更新指向它的相对路径引用(询问后执行)。
  • 「清理未引用附件」命令:扫描工作区,列出无人引用的资源,让用户确认后删除。
  • 图片路径解析优先级:相对当前文件 → 相对工作区根 → 绝对路径。

5.1 已实现的部分

粘贴 / 拖入图片 → 存进当前文件旁边的 assets/ → 插入相对路径。 只做了「复制到附件目录」这一种策略,其余三种连同策略开关留给后续里程碑。

三条安全约束,每一条都因为字节和文件名都来自渲染进程

  1. 扩展名由 MIME 决定,绝不采信调用方给的文件名。 否则一个被 XSS 的渲染进程就能往用户目录里写 x.sh / x.desktop
  2. MIME 必须在图片白名单里。 这个 IPC 通道的用途只有「粘贴/拖入图片」, 不是通用的写文件能力。体积上限 32MB。
  3. 校验的是附件目录本身而不是父目录。 assets 若是一个指向白名单之外的 符号链接,在建目录之前就当场拒绝 —— 先写出去再发现越权就晚了。

文件名用内容哈希sha256 前 16 位 + 扩展名)。同一张图重复粘贴命中 同一个文件,天然去重,不会在 assets/ 里堆出十几份一模一样的截图。

未保存的新文档粘贴图片会明确报错,不找临时目录糊弄:图片进了临时目录、 Markdown 里却写着相对路径,用户一保存就得到一个永远加载不出来的引用。 宁可当场说清楚(原则 P2:失败要响,不能静默)。

异步插入的位置用一个 StateField 装锚点,每个 transaction 都 mapPos 一次。 「记下位置、回来再插」是经典错误 —— 用户完全可能在存图的那几毫秒里继续打字。 文档被整体换掉(切换文件)时锚点消失,那就放弃这次插入, 总好过把图片插进另一篇文档。

6. 全文搜索(⏸ 未实现,M5 / M6)

现在只有单文件内的查找替换(CodeMirror 自带的 search 面板,⌘F)。 跨文件搜索一行代码都还没写。下面是设计,不是现状。

分两级,避免为小工作区付出索引成本:

Level 1 · 无索引扫描(默认,< 2000 个文件) 在 utility 进程里遍历 + 逐行匹配,流式返回结果,边搜边显示。支持正则、大小写、全词。 搜索期间可取消。

Level 2 · 增量索引(大工作区,用户可开启) SQLite + FTS5,文件监听驱动增量更新。索引是纯派生数据,.typo/index.sqlite 损坏或版本不符时直接删掉重建,绝不因索引问题阻塞使用。

跨文件替换:先展示全部匹配与预览 diff,用户确认后逐文件走原子写; 任一文件失败则停止并报告已完成的部分(不做假的「事务回滚」承诺)。

7. 标签页与会话(✅ 已实现)

  • 多标签页,状态(选区、滚动位置、撤销栈)随标签页保持 —— 每个标签一整套 编辑器与文档控制器,模型见 ADR-0005
  • 未保存的标签页关闭时弹确认;关窗口时汇总成一个对话框而不是弹十次。
  • 会话(工作区 + 标签列表 + 活动标签)在下次启动时恢复。

7.1 会话为什么写在 userData 而不是 .typo/workspace.json

本文档 §1 里画的是后者。实现时改了,两条理由:

  1. 会话是跨工作区的。 窗口可以各自开着不同目录,也可以一个目录都没开 —— 放进某个工作区目录就没地方安置那些没有工作区的窗口。
  2. 往用户的项目目录里写状态文件是要被 git 记一笔的。 除非用户主动要求, 否则不该发生。

.typo/ 仍然是工作区级配置(而不是会话)的去处,这一条没变。

7.2 会话里只存路径

绝不放文档正文。正文有它自己的持久化通路(磁盘上的文件、以及 §4 的崩溃草稿), 再存一份就有了第三个真相来源,三者不一致时谁也说不清该信谁。

因此未命名标签不进会话 —— 它没有可恢复的落点,它的内容由草稿机制负责。 那条路走的是「上次没正常退出」的语义,跟会话恢复是两件事。

7.3 会话文件也走原子写入

跟文档保存(§2)同一个道理,而且这里更容易踩:退出时会立刻刷一次盘, 而进程随时可能在 writeFile 已经把文件截断、还没写完内容的那一瞬间结束 —— 留下一个长度为零或者半截的 JSON,下次启动解析失败,会话就这么没了。

而且是时有时无地没,最难查的那一类:本地跑十次能中一次, CI 上换台慢一点的机器就变成三次里挂两次。

两件事一起做才算数:写入用「临时文件 + rename」(rename 是原子的), 退出时等它真的落盘再退before-quit 里先拦下这次退出,写完重新 quit)。

7.4 恢复时必须重新授权

白名单(01 §5)是每个进程的,新进程一片空白。会话里的路径当初都是用户在 系统对话框里亲手选过的,恢复时要把那份授权一并带回来 —— 漏掉这一步的表现是 「文件树列不出、标签一个也打不开」,而且失败得悄无声息。

8. 需要显式处理的边界情况

清单式记录,实现时逐条落测试:

  • 文件在编辑过程中被外部删除 → 标记为「已删除」(状态栏显示),保存时就地重新创建。 按下保存的意图就是「把我这份存下来」,在原地重建比弹一个文件选择框更符合预期。
  • 只读文件 → 编辑器进入只读模式并提示,提供「强制可写」入口。
  • 超大文件(> 50MB)→ 拒绝以编辑器打开,提供「以只读源码查看」。
  • 二进制内容伪装成 .md → 嗅探到不可解码字节,拒绝并提示。
  • 路径含 emoji / RTL 字符 / 超长路径(Windows MAX_PATH)→ 用长路径 API。
  • 网络盘 / 云盘目录(OneDrive、Dropbox)→ 文件监听不可靠,缩短基线校验间隔。
  • 大小写不敏感文件系统上的重命名(a.mdA.md)→ 走两步重命名。

MIT 许可发布。与 Typora 无关联,是一个独立实现。