Skip to content

ADR-0005 · 窗口与标签页的关系

  • 状态:已接受
  • 日期:M1 之后(用户反馈:新建文件只能在当前窗口打开)

背景

M1 的实现是单窗口单文档main/index.tsmainWindow 是一个模块级单例, 打开新文件会替换当前窗口的内容。用户提出要能开多个窗口。

同时 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 个标签时把非活跃标签的编辑器实例卸载, 只留文本与选区,重新激活时重建。这条留到真出现问题再做,先记着。

实施顺序

多窗口与标签不必同时做,而且多窗口可以先行:

  1. 多窗口先做,因为它基本只动 main 进程(窗口注册表、IPC 带身份、 菜单转向聚焦窗口),跟编辑器内核完全解耦,可以和 M2 的编辑器工作并行。
  2. 标签跟文件树、会话恢复是同一批 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 个标签就卸载非活跃标签的编辑器实例) 仍然没做 —— 按当时说的,留到真出现问题再做。

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