Skip to content

04 · 文件与工作区 ​

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

1. 工作区模型 ​

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

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

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

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

1.1 文件树是工作区的入口(issue #36) ​

这一节的前半段被推翻过一次。 原文写的是「文件树是只读的导航视图, 不提供重命名、拖拽移动、新建、删除 —— 那些该是一批单独的工作」。 判断没错,只是那批工作后来做了:

能力说明
主标签显示文档标题第一个标题;没有标题才回退到文件名,文件名退成副标签
新建 Markdown 文件 / 子目录在文件夹上右键
重命名原地输入,只改名不移动
移到废纸篓确认一次,走系统废纸篓
复制路径 / 在文件夹中显示由 main 就地做掉,不回渲染进程
过滤与排序按名称或修改时间;过滤同时看文件名和标题

仍然不做拖拽移动。 它要处理「拖进自己的子目录」这类环,以及一次移动多个 条目时的部分失败,跟上面几条不是一个量级。

四条写操作的取舍,每一条都是为了不悄悄吃掉用户的东西:

  • 绝不覆盖已有的东西。 新建用 wx(独占创建),重命名先查目标是否存在。 fs.rename 在多数平台上会静默覆盖目标 —— 用户把 a.md 改名成 b.md 时 若 b.md 已存在,他丢的是一份自己都不知道存在的文档。同理 mkdir 不带 recursive:带上之后目标已存在不报错,「新建文件夹」撞名就悄无声息。
  • 删除一律走系统废纸篓(shell.trashItem),不用 unlink。废纸篓可撤销, unlink 不可。拿不到废纸篓时报错而不是降级成真删 —— 降级会让 「移到废纸篓」这句承诺变成谎话。
  • 名字里挡住路径分隔符与 ..。 守卫那一层能挡住跑出工作区的,但挡不住 「在工作区内乱跑」,而用户敲名字时并没有在表达移动的意思。
  • 改名之后开着的标签只换路径,不重新读盘。 重读会把未保存的修改冲掉; 而放着不管的话,监听还盯着一个不存在的路径,下一次保存还会写回旧名字 —— 磁盘上凭空多出一份旧文件。

标题是异步补上的。 一层可能有几十个条目,每个都要读文件;所以先用文件名把 树画出来,再批量问一次标题,回来了就地替换。等标题的话展开目录会先白一下, 而那是用户最没耐心的一刻。读也只读文件开头 64 KB —— 一个仓库里随便一份 几 MB 的数据文件就够把展开目录拖成肉眼可见的卡顿。

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

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

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

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

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

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

文件树的写操作是第三条许可(assertWritableInWorkspace,issue #36), 比前两条都窄:

  • 只认工作区根的子树 —— 用户显式选过「打开文件夹」的那些目录;
  • 工作区根自身被明确拒绝。允许的话,「移到废纸篓」的第一个目标就是用户刚 打开的整个文件夹,一次误点删掉一整个项目。想删工作区去文件管理器删, 那里至少还有一次系统级确认。

不要图省事复用「读文件」那条。 它接受任何落在 allowedDirs 里面的路径, 而 allowedDirs 装着「用户打开过的每个文件的所在目录」—— 那份授权的本意只是 让相对路径的图片能加载。拿它当写权限用,等于说「你打开过 ~/Documents/a.md, 所以渲染进程可以把 ~/Documents 里任何东西扔进废纸篓」。读一张图和删一个文件 不是一个量级的事。

三条许可的边界由 apps/desktop/test/path-guard.test.ts 逐条钉着。那一组里 每一个「应当拒绝」的目标都真实存在 —— 不存在的话守卫走的是「路径不可访问」 那条分支,白名单整个失效也照样绿(这个坑已经踩过三次,见 07 §1)。

2. 保存:原子写入 ​

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

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

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

3. 外部修改与冲突 ​

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

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

3.1 「文件消失了」是一个中间状态,不是终点 ​

上面那张图默认监听一直在。实际上文件被删掉的那一刻监听就断了, 而这条路径原本是单向的(issue #6):

  • attach() 挂不上时把 watcher 置空就完事 —— 没有定时器、没有轮询, 文件重新出现也永远收不到通知;
  • watcher 的 'error' 事件同样只是 dispose;
  • 而 watchFor() 里「已经在表里」被当成「还在监听」直接 continue —— 于是渲染进程每次上报会话都在重新武装,但一次都不生效。

三条加起来的效果是:文件一旦离开过磁盘,这个标签页此后就是聋的。

而「离开过」远不是罕见事件。git checkout 切分支、从废纸篓恢复、 OneDrive / Dropbox 这类先删后建的同步客户端,走的都是这条路径 —— §8 早就把它们列成了要处理的场景。

修法是给条目加一个退避重挂:500ms 起、每次翻倍、封顶 5s,挂上就重置。 快的那一头是给上面那些场景准备的(文件通常几十毫秒就回来),封顶是给真被 永久删掉的文件——每秒 stat 一次纯属浪费,5s 的延迟没人在意。

重挂成功后要主动比对一次,不能干等下一个事件:文件是在我们聋着的那段 时间里回来的,没有任何事件会宣布这件事。

3.2 保存时的「我以为它不在了」 ​

文件被外部删除后基线被置为 null,而 null 会跳过保存前的冲突检测 —— 这本身是对的(新建文件没有基线)。但两件事凑在一起就成了静默数据丢失: 文件又回来了,而编辑器还停在「它已经不在了」的认知上,于是第一次保存 拿陈旧的缓冲区盖掉了刚回来的内容。

所以写入接口上多一个 expectMissing:调用方在「我认为这个文件已被删除」 时置位,writeTextFile 在这条路径上单独确认一次 —— 真不在就照常新建, 已经回来了就抛冲突,跟正常冲突走同一个对话框。

这是「基线为 null」这个状态的两种含义(从没有过文件 / 曾经有但没了) 第一次需要被区分开。

4. 崩溃恢复 ​

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

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

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

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

4.1 恢复出来的基线要用草稿记的那个 ​

meta.json 里存基线 hash 不是为了好看。恢复时若拿磁盘此刻的 hash 当基线 (openPath 的默认行为),停机期间在别处改过的文件就会被认成「没变过」, 于是后续保存跳过冲突检测直接覆盖,一句提示都没有 —— 这就是 issue #5。

刺眼的地方在于:完全相同的冲突在应用正常运行时是会弹框的,差别只在 中间有没有崩溃。而崩溃恢复恰恰是磁盘最可能已经变了的时刻 —— 重启前顺手 git pull 是很自然的动作。

草稿从一开始就正确记录了崩溃前的基线,只是从来没人读过它:写了,没读。

草稿没有基线时(未命名文档,或旧版本写的草稿)退回原来的行为,以磁盘为基线。 不能退化成「拒绝保存」——用户的内容还在编辑器里,保住它比守住一次 冲突检测重要。

5. 附件与图片 ​

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

策略行为
复制到附件目录(默认)存到 assets/(可按文档名建子目录),插入相对路径
保持原位置插入相对路径引用原文件
转 Base64 内嵌适合要求单文件的场景,大图警告
上传到图床❌ 不做。它需要一条「渲染进程能往任意域名发请求」的通路,而 CSP 是 connect-src 'self'(01 §6)—— 那条通路正是整个安全模型不给的东西。插件系统推后之后(ADR-0006)也没有别的提供方

配套能力:

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

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. 全文搜索(Level 1 ✅ 已实现,issue #39) ​

⌘F 是当前文档里的查找替换(CodeMirror 自带的那一套,不动); ⌘⇧F 是整个工作区。两件事的结果形态完全不同 —— 前者在正文里高亮, 后者是一张跨文件的结果列表 —— 所以不共用面板。

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

Level 1 · 无索引扫描(默认)· ✅ 已实现

遍历 + 逐行匹配,分批推回渲染进程,边搜边显示。支持正则、大小写、全词、 限定子目录。实现见 main/workspace-scan.ts。

实际偏差与取舍:

  • 跑在 main 而不是 utility 进程。 遍历要碰真实文件系统,而渲染进程只有 fs.list 这一条通路 —— 在那边递归意味着几百次 IPC 往返,每次都过一遍路径 守卫。utility 进程的价值要等到「搜索期间 main 明显卡顿」真的出现才兑现, 现在搬属于凭想象优化(跟导出管线当初的判断同一条理由,见 06 §1)。
  • 结果是推回来的,不是等回来的。 几百份文档搜完要几百毫秒到几秒,等它一次 返回的话用户按下回车后面对的是一段没有任何反馈的空白。
  • 每一批都带搜索 id,作废由 main 保证。 用户改查询词时上一次可能还在跑, 而它的结果会晚一步到达 —— 没有 id 的话旧结果会涌进新列表,而且看起来完全 合理。让调用方自己去取消是一条必然会漏的路径(面板关掉、窗口关掉、连敲五个 字符,每一条都要记得取消),所以 id 递增即作废,跑着的那次每批自查一下。
  • 三条上限,撞到了必须说出来。 深度 12 层、两万个文件、单文件 4 MB。 用户会往「打开文件夹」里丢任何东西(home 目录、整个磁盘),没有上限的话第一次 误操作就是一次几分钟的无响应。而**「搜不到」和「没搜完」对用户是完全不同的 两件事**,所以 truncated 会一路带到界面上。
  • 只搜 Markdown,且跟文件树共用同一份忽略清单。 两处不一致的话, 「树里看不见但搜得到」会让人以为文件重复了。
  • 非正则模式下必须转义元字符。 用户搜 a.md 时那个点是字面点。少了这一步, 结果里会莫名其妙多出一堆看不出为什么命中的行。正则写坏了(( 没闭合是敲到 一半的常态)返回空而不是抛 —— 边敲边搜时每个中间状态都是非法的。
  • 整词(\b)对中文没有意义(CJK 不是 \w)。不为此自造一套断言, 那会得到一个「有时生效」的开关。
  • 跨文件替换没做。 它要先展示全部匹配与预览 diff,是单独一批工作。

Level 2 · 增量索引(大工作区,用户可开启)· ⏸ 未实现

SQLite + FTS5,文件监听驱动增量更新。索引是纯派生数据,.mosu/index.sqlite 损坏或版本不符时直接删掉重建,绝不因索引问题阻塞使用。

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

6.1 Quick Open:找内容,不是找动作(issue #37) ​

命令面板只搜命令。实测「输入 03-mock → 没有匹配的命令」正是这个缺口 —— 两件事挤在一个入口里,用户每次都要先想「我要找的是个命令还是个文件」, 而那从来不是他脑子里的问题。

所以是两个面板(⌘P 找文件,⌘⇧P 找命令),不是给命令面板加一类结果。 共用的只有交互约定(上下键循环、Enter 执行、Esc 与点遮罩都能关、关掉后焦点 回编辑器)和模糊匹配函数。

  • 三类候选:已打开的标签排最前(十次里有七次是想回到刚才那篇)、工作区文件、 没打开工作区时只剩第一类且面板照样能用。
  • 按路径去重。 同一个文件既是打开的标签又在工作区里时只出现一次 —— 列表里出现两行一模一样的东西,用户会以为自己看错了。
  • 文件名 / 标题的命中比路径的命中值钱。 路径也参与匹配是必要的 (docs/design/02 这种输入很自然),但不压低权重的话,搜 design 会把那个 目录下的每一份文档都以同样的分数列出来。
  • 清单每次打开面板时现取,不常驻缓存。 工作区不监听目录(§1.1), 常驻缓存意味着「刚在别处新建的文件搜不到」,而用户不会知道要去按刷新。
  • 标题最后补。 列目录快、读文件慢,而没有标题时用文件名已经完全可用。

6.2 大纲的长文导航(issue #41) ​

「能把标题列出来」在几十个标题之后就不够用了。补的四件事:过滤、折叠 (单条 / 全部 / 按层级)、可调宽度、跳转后把当前节滚进视野。

这四件事一个字都不碰文档,也不碰光标。 折叠与过滤只影响画哪几个 <li>, 拖宽度只改面板的 CSS 宽度。这是 #41 的验收条件,也是这个面板「只读导航视图」 定位的延续 —— 一旦它开始动文本,它就成了一个会悄悄改稿子的东西。

几处取舍:

  • 折叠状态按指纹记(level:text 加同名出现的序号),不按下标也不按文档 偏移:下标会因为上面插入一个标题就整体错位,偏移每敲一个字都在变。代价是 「改了标题文字,那一条的折叠状态丢失」—— 可以接受,而且诚实。 标题集合变化时还会把不存在的键清掉,否则改名再改回来会让一个早就忘了的 折叠状态突然复活。
  • 过滤时匹配项的祖先也留着。 不留的话,深层标题会以一堆没有上下文的孤条 出现,用户不知道它属于哪一节。
  • 过滤生效时忽略折叠。 用户正在找东西,此刻还让折叠挡着结果毫无道理。
  • 层级不假设连续。 # 一级 后面直接跟 ### 三级 很常见且完全合法, 所以认父是「往回找第一个层级更小的」,不是查 level - 1;编号里缺的那一层 记 0(1.0.1),不把编号算塌成 1.1。
  • 层级按钮只列文档里真有的层级。 固定列 H1–H6 的话,一篇只有 H2/H3 的文档 会有四个点了没反应的按钮。
  • 宽度只在松手时写进偏好,不是每一帧都写:拖一次会产生几百个中间值。 范围由偏好层夹住(160–600),夹完再画回界面 —— 不画回来的话拖过头时 界面和存下来的值对不上。

没做「复制章节链接」。issue 里那一条是「可考虑」,而本地文档之间的章节链接 还没有一个定下来的格式(用行号会随编辑失效,用标题文字会随改名失效), 定不下格式就先不做。

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

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

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

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

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

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

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.md → A.md)→ 走两步重命名。

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