ADR-0005 · 窗口与标签页的关系
- 状态:已接受
- 日期:M1 之后(用户反馈:新建文件只能在当前窗口打开)
背景
M1 的实现是单窗口单文档:main/index.ts 里 mainWindow 是一个模块级单例, 打开新文件会替换当前窗口的内容。用户提出要能开多个窗口。
同时 M3 原本就计划了窗口内多标签页。两件事如果各做各的,会撞出一堆 说不清的问题:标签能不能拖出来变成窗口?菜单的「保存」保存哪个? 关窗口时要确认几次?会话恢复要还原成什么形状?
所以在动手之前先把两者的关系定下来。
决策
采用「多窗口 + 窗口内多标签」的两层模型,并明确一条主线:
窗口是文档的容器,标签是窗口内的文档列表。所有状态挂在标签上, 窗口只负责承载与聚焦。
具体:
| 概念 | 归属 |
|---|---|
| 文档内容、撤销栈、脏标记、保真元数据 | 标签 |
| 编辑器实例(TypoEditor) | 标签(每标签一个,切换时只切显示) |
| 文件路径白名单 | 进程全局(main 侧的 allowlist,不随窗口) |
| 工作区(M3 的文件夹) | 窗口(一个窗口对应一个工作区) |
| 设置 | 进程全局 |
关键推论
1. 命令必须作用于「当前聚焦的窗口」,而不是某个全局单例
M1 里菜单直接往 mainWindow 发命令。多窗口下这是错的 —— 必须走 BrowserWindow.getFocusedWindow()。
2. IPC 消息必须带窗口身份
M1 的 respondClose 通道没有任何窗口标识(ipcMain.on('respond-close', …) 直接操作 mainWindow)。两个窗口同时在问「要不要保存」时, 回复会串台、关错窗口。这是 M1 遗留的实打实的缺陷,多窗口的第一步就得修: 所有 renderer → main 的消息一律用 event.sender 反查窗口。
3. 「打开文件」的落点需要一条明确规则
否则用户每次都得猜。规则:
当前窗口是「空的未命名文档」(无路径、无修改)?
├── 是 → 在当前标签打开(新开一个空窗口再让它闲着很蠢)
└── 否 → 在当前窗口新开一个标签
「在新窗口打开」是一条独立的命令(⌘⇧O),不抢默认行为从 Finder / 命令行双击文件:优先送给已聚焦的窗口新开标签;没有窗口则新建窗口。
4. 退出确认要按标签汇总,不能逐个弹窗
一个窗口三个脏标签,关窗口时应当弹一个对话框列出三个文件, 而不是连弹三次。⌘Q 退出时同理,按窗口汇总。
5. 会话恢复的粒度是「窗口 + 其标签列表」
.typo/workspace.json 里存的是窗口数组,每个窗口带自己的标签列表、 几何尺寸、工作区路径。
为什么不是别的形态
只做多窗口、不做标签(每个文件一个窗口,Typora 的默认形态) 写作场景下文档少、窗口少,这个模型确实更简单。但一旦用户在一个项目里 来回跳十几个文件(技术文档场景),满屏窗口就失控了。而且 M3 的文件树 天然需要「在同一个窗口里换文件」。
只做标签、不做窗口(VS Code 的单窗口默认形态) 挡住了两个真实需求:把两篇文档并排放在两个显示器上;一边写一边参照另一篇。 用户提这个需求正是因为这个。
标签可以拖出来变成独立窗口 方向是对的,但实现成本高(要在窗口间迁移编辑器状态与撤销栈), 且不影响上面的模型 —— 属于后续增强,不进 v1。
代价
- main 进程要引入窗口注册表,所有单例假设都得清理一遍。
- 渲染层从「一个编辑器实例」变成「标签 ↔ 编辑器实例」的映射,
DocumentController要从单例改成每标签一个。 - 内存随标签数线性增长(每个标签一份 CodeMirror 状态)。 需要一条上限策略:超过 N 个标签时把非活跃标签的编辑器实例卸载, 只留文本与选区,重新激活时重建。这条留到真出现问题再做,先记着。
实施顺序
多窗口与标签不必同时做,而且多窗口可以先行:
- 多窗口先做,因为它基本只动 main 进程(窗口注册表、IPC 带身份、 菜单转向聚焦窗口),跟编辑器内核完全解耦,可以和 M2 的编辑器工作并行。
- 标签跟文件树、会话恢复是同一批 UI 工作,要一起做。
第 2 条的前提是第 1 条已经把「状态挂在文档而不是窗口」这件事做对了 —— 否则加标签时会发现状态全长在窗口上,得返工。
排期现状(2026-08):第 1 条已随 M1.5 完成,第 2 条连同文件树、 会话恢复已随 M4.5 的部分解封完成。本文档描述的模型现在是实际形态, 不再是目标设计。
两处实现与本文档的偏差,记在这里:
- 没有立
@typo/ui这层 React 外壳。 上面写「它是本 ADR 里成本最高的一块」 时,默认了标签条与文件树需要一个组件框架。真做下来发现不需要:这两样都是 列表,裸 DOM 写出来跟既有的大纲、命令面板是同一种东西,而引入 React 要连带 重写那两个已经好用的面板。真正的成本从来不在渲染技术上,而在 「把文档状态从窗口里拆出来」 —— 那一块照本文档做完了。 真需要一个框架的是设置界面(表单引擎),等那一档解封时再一起决定。 - 会话写在 userData 而不是
.typo/workspace.json,理由见 04 §7.1。
「代价」里那条内存上限策略(超过 N 个标签就卸载非活跃标签的编辑器实例) 仍然没做 —— 按当时说的,留到真出现问题再做。