diff --git a/docs/README.md b/docs/README.md index b1ae3f92..ccd238c3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ 8. [写作模式(长文 / 书籍)](./design/07-writing-mode.md) — 提案;调研见 [research/06](./research/06-longform-writing.md),首个真实项目是 [`book/`](../book/README.md) 9. [宽内容出血](./design/08-wide-tables.md) — 表格与代码块出血到窗格宽度、按内容分配列宽;实现见 `src/lib/milkdown/table-view.ts` 10. [应用内渲染 Mermaid](./design/09-mermaid-in-app.md) — 补上一直缺失的 diagram nodeView;实现见 `src/lib/milkdown/diagram-view.ts` +11. [一次关掉一批标签页](./design/10-close-many-tabs.md) — 标签页多选 + 批量关闭,并修掉批量关闭不问脏状态的旧缺陷;实现见 `src/store.ts`、`src/components/TabBar.tsx` ## 目录结构 @@ -35,7 +36,8 @@ docs/ ├── 02-mvp-features.md ├── 03-roadmap.md ├── 08-wide-tables.md - └── 09-mermaid-in-app.md + ├── 09-mermaid-in-app.md + └── 10-close-many-tabs.md ``` ## TL;DR 技术选型 diff --git a/docs/design/10-close-many-tabs.md b/docs/design/10-close-many-tabs.md new file mode 100644 index 00000000..b6962cf3 --- /dev/null +++ b/docs/design/10-close-many-tabs.md @@ -0,0 +1,294 @@ +# 设计 10 · 一次关掉一批标签页 + +- **日期**: 2026-08-22 +- **状态**: 已采纳,已实现(见 §8)。落地拆成三个叠放的 PR,按序合并: + 1. [#238](https://github.com/oratis/Markup/pull/238) 修哑巴闸门 + 右键菜单宽度(§1.2、§5) + 2. [#239](https://github.com/oratis/Markup/pull/239) Close to the Left + ⌘⌥W + 命令面板(§1.4、§4.6) + 3. [#240](https://github.com/oratis/Markup/pull/240) 多选 + Close N Tabs + ⌘W 认选择集(§4.1–4.3) +- **相关**: 标签条的视觉规范见 [设计 04 §5](./04-obsidian-redesign-plan.md)(组件清单里的 Tab Bar 一行) +- **一句话**: 标签页开到三十个之后,收拾场面只有三个动词(关别的 / 关右边 / 全关),全是"以某一个为轴心的全有全无";而且其中两个**不问一声就把未保存的改动丢掉**。这一轮补上"关掉我挑的这几个",顺手把那两个哑巴闸门修好。 + +--- + +## 1. 现状 review:能关,但不好关,而且有的关得不声不响 + +### 1.1 今天的全部关闭手势 + +| 手势 | 入口 | 作用域 | 脏文档确认 | +|---|---|---|---| +| `×` 按钮 | 标签页悬停时才显形([TabBar.tsx](../../src/components/TabBar.tsx)) | 1 个 | ✅ | +| 中键点击 | 标签页 | 1 个 | ✅ | +| ⌘W | 原生菜单 File ▸ Close Tab([menu.rs:77](../../src-tauri/src/menu.rs:77)) | 当前 1 个 | ✅ | +| Close | 右键菜单 | 1 个 | ✅ | +| Close Others | 右键菜单 | 除它以外的非固定 | ❌ **不问** | +| Close to the Right | 右键菜单 | 右侧非固定 | ❌ **不问** | +| Close All | 右键菜单 + 命令面板 | 全部非固定 | ⚠️ **问得不对** | + +### 1.2 三个真实缺陷 + +**缺陷一:`closeOtherTabs` 与 `closeTabsToRight` 完全不检查脏状态。** +改前的这两个 reducer([store.ts](../../src/store.ts))直接 `filter` 掉受害者就返回了。右键 → Close Others,其余十个标签页里没保存的改动一声不响地没了。`closeTab` 那条路径是问的,所以这不是"这个产品的风格是不问",而是漏了。 + +**缺陷二:`closeAllTabs` 问的是 A,丢的是 A、B、C。** +改前的写法: + +```ts +const dirty = state.tabs.find((x) => x.status === "dirty" && x.path && !x.pinned); +if (dirty) { + const ok = window.confirm(t("tab.confirmClose", dirty.name)); // ← 只报第一个的名字 + if (!ok) return state; +} +``` + +弹窗写着「「a.md」有未保存的修改,关闭后会丢失」,用户点确定,b.md 和 c.md 的改动跟着一起没了。这比不问更糟:它制造了一个"我知道我在丢什么"的错觉。 + +**缺陷三:无路径的草稿从头到尾不设防。** +`updateActiveContent` 里写的是 `status: t.path ? "dirty" : t.status`——没落盘的 buffer 永远显示 `saved`,所以:没有脏点、关闭不问、也进不了 `recentlyClosed`(`pushClosed` 会把 `null` 路径过滤掉)。⌘N 敲两百字、随手关掉,⌘⇧T 也救不回来。 + +这一条是既有设计(草稿是易失的),不在本轮改(见 §7),但**批量关闭会把它的杀伤半径从 1 放大到 N**,所以必须在这里记一笔。 + +### 1.3 三个"不好关" + +- **只有轴心式动词**。三个批量动作都要先选一个轴心标签页,然后关掉"它周围的全部"。"把这五个不相邻的关掉"没有任何路径,只能点五次 `×`——而 `×` 只在悬停时才显形,宽度 16px,标签页多了还要先横向滚过去。 +- **键盘上没有批量关闭**。[shortcuts.ts](../../src/lib/shortcuts.ts) 里一条关闭类命令都没有(唯一的 ⌘W 烧在原生菜单里)。结果是:不能改键、不出现在 ⌘⇧/ 速查表、不出现在设置里的快捷键编辑器。 +- **命令面板里只有 Close All Tabs 一条**([App.tsx](../../src/App.tsx))。Close Others / Close to the Right 只能右键,而右键要先瞄准一个标签页。 + +### 1.4 还差一个方向 + +有「Close to the **Right**」,没有「Close to the Left」。看长文时顺着 wikilink 一路点进去,最后停在最右边,想关掉左边那一串——没有这个动词。 + +--- + +## 2. 用户在什么时候会想"快速关掉多个" + +三个场景,各自要不同的动词: + +| 场景 | 状态 | 想要的动作 | +|---|---|---| +| 顺着 backlinks / wikilink 一路点,开了二十个 | 只想留住现在这个 | Close Others(已有) | +| 一路点进去,想退回到起点 | 留住左边一串 | Close to the Right(已有) | +| 从起点一路走到终点,前面的都读完了 | 留住右边一串 | **Close to the Left**(缺) | +| 搜索结果里打开了七个,其中三个不是要找的 | 关掉挑中的那几个 | **多选 + 关闭选中**(缺) | + +前两个已经有了。这一轮补后两个。 + +--- + +## 3. 正反方辩论 + +设计里每个有争议的岔路,把两边的话都写下来,再判。 + +### 辩题一:主力应该是"多选"还是"更多批量命令"? + +**正方(多选)**:strip 本身就是那份清单,不用再造第二套 UI。⌘ 点挑、⇧ 点连选,是 Finder / VS Code / 邮件客户端的通用手势,用户不用学新东西。而且它是**唯一**能表达"这五个不相邻"的手段——批量命令再加十条也表达不了。 + +**反方(批量命令)**:多选引入了一个新的隐藏状态(选择集),而隐藏状态是 bug 的温床:标签页被别的路径关掉了、Save As 改了 id、拖拽换了顺序——每一条都要想清楚选择集怎么办。批量命令零状态、零学习成本:右键,点一下,完事。而且实测里"关掉这几个"远不如"只留这一个"常见。 + +**裁决**:**都做,但分工写清楚**。批量命令是主力(覆盖"清到只剩这个/这一串",占绝大多数),多选是补集(覆盖"挑几个")。反方对隐藏状态的担心是对的,所以选择集的生命周期在 §4.2 逐条钉死:任何关闭动作后清空、Save As 时跟着改 id、普通点击和 Esc 清空。 + +### 辩题二:有选择集时,⌘W 关谁? + +**正方(关选择集)**:选中了就是意图。挑五个再去右键点菜单,比直接 ⌘W 慢一倍——"快速"的诉求正在这里。 + +**反方(永远关当前)**:⌘W 是肌肉记忆里最深的那一条,而且是破坏性的。一个用户可能几分钟前误 ⌘ 点了一下标签页,早忘了还有选择集在,然后 ⌘W 关掉的不是他眼前这篇。**肌肉记忆 + 破坏性 + 隐藏状态 = 事故。** + +**裁决**:**采纳正方,但加三道闸**—— +1. 选中态必须显眼(不是淡淡的高亮,是明确的描边 + 底色,见 §4.3); +2. 任何普通点击、Esc、以及任何一次关闭动作都会清空选择集,所以"忘了还选着"的窗口很短; +3. 批量确认会列出所有会丢改动的文件(§4.4),⌘⇧T 还能逐个找回来(§4.5)。 + +反方的顾虑没被驳倒,只是被这三道闸压到可接受。如果真有人报事故,退路是把它降级成"选择集为空才生效"。 + +### 辩题三:批量关闭前的确认,用 `window.confirm` 还是造一个真正的三选一对话框? + +**正方(真对话框)**:"保存并关闭 / 直接关闭 / 取消"才是完整语义。`window.confirm` 只有两个出口,等于逼用户在"丢掉"和"什么也不做"之间二选一——而他真正想要的第三个选项(保存了再关)根本不在桌上。 + +**反方(先修哑巴闸门)**:autosave 默认 300ms 开着(`autosaveMs: 300`),有路径的文档在真实使用里几乎不存在"脏"这个状态——脏状态只在 `autosaveMs = 0` 的用户那里才是常态。为一条罕见路径造一个新的受控对话框(焦点管理、i18n、键盘、测试、暗色主题)不划算。而且**今天的真实缺陷不是"对话框不够好",是"根本不问"**——先把 ❌ 修成 ✅,再谈 ✅ 怎么更好。 + +**裁决**:**本轮只修"根本不问"**。一次批量 = 一个诚实的确认,列出批次里所有会丢改动的文件名(缺陷一、缺陷二一起修掉)。三选一对话框记入 §7 不在本轮,触发条件写明:收到 `autosaveMs = 0` 用户的抱怨,或者哪天草稿也纳入脏状态统计。 + +### 辩题四:要不要加「Close Saved / 关闭未修改的」? + +**正方**:VS Code 有这条(Close Saved Editors),是最省心的清理动词——不用瞄准、不用选,一键把读完的都扫掉。 + +**反方**:在 Markup 里它跟 Close Others 几乎等价。autosave 300ms 意味着**所有东西永远是 saved**,所以"关闭未修改的"= "关闭全部"(或者,如果保留当前页,就是"关闭其余")。**一个在默认配置下永远等价于另一条命令的命令,是纯粹的菜单噪音**,还会让用户以为自己触发了某种更聪明的筛选。 + +**裁决**:**不做**。VS Code 的默认是不自动保存,所以那条命令在那里有意义;照抄一个前提不成立的功能,是把别人的约束当成自己的功能。 + +### 辩题五:固定(pinned)的标签页能进选择集吗? + +**正方**:用户明确 ⌘ 点了它,就该听他的。固定只是排序和默认保护,不该否决一次显式选择。 + +**反方**:pinned 的全部意义就是"批量手势碰不到我"——`×` 按钮都不给它画,Close All / Close Others / Close to the Right 全都跳过它。如果选择集能包含它,菜单上写「Close 3 Tabs」却只关掉 2 个:要么数字撒谎,要么 pinned 的契约破了。二选一都不好看。 + +**裁决**:**pinned 不进选择集**。⌘ 点固定标签页不改变选择集,⇧ 连选跨过它时也只收非固定的。这样计数永远说真话,pinned 的契约也完好。要关固定页,先取消固定——和今天一样。 + +### 辩题六:关掉一批之后,⌘⇧T 怎么恢复? + +**正方(一次全恢复)**:批量操作应该有批量撤销。关了八个,按一下 ⌘⇧T 应该八个一起回来。 + +**反方**:`recentlyClosed` 是一个**路径栈**,不记录"批次"这个概念。要做批量撤销就得引入批次边界、定义恢复后哪个是激活页、还要处理"关了八个之后又单独关了一个再撤销"这种交错。而浏览器的既有约定就是逐个回来,用户已经会了。 + +**裁决**:**保持逐个**。但保证批量关闭是按 strip 顺序压栈的,所以连按 ⌘⇧T 是从左到右依次回来——可预测,不是随机顺序。(`pushClosed` 已经是这个行为,本轮补一条测试钉住它。) + +### 辩题七:要不要一个"标签页管理面板"(可筛选的清单 + 勾选框)? + +**正方**:四十个标签页时 strip 要横向滚动,⌘ 点根本瞄不准;一个带搜索框的清单才是那个规模下的正确 UI。 + +**反方**:那是第二套要维护的 UI,而且**四十个标签页时用户真正想做的是"只留这个",不是"逐个挑"**——那个场景已经被 Close Others 一键覆盖了。面板解决的是一个假想中的中间地带。 + +**裁决**:**不做**,记入 §7。如果收到"标签页太多、strip 里选不中"的真实反馈,再做。 + +--- + +## 4. 设计细则 + +### 4.1 store 层:一个通用的批量移除 + +所有关闭动作最后都走同一个内部函数: + +``` +removeTabs(state, victimIds) → { tabs, activeTabId, recentlyClosed, selectedTabIds: [] } +``` + +规则: + +- **激活页落点**:如果当前激活的标签页在受害者里,落到"第一个被关掉的位置的左边那个幸存者"(`tabs[max(0, firstIdx - 1)]`)——和今天单个 `closeTab` 的规则一致,用户的视线不会跳。 +- **关空了就回 welcome**:和今天一致。 +- **`recentlyClosed` 按 strip 顺序压栈**(辩题六)。 +- **选择集清空**:每一次关闭都清(辩题二的第 2 道闸)。 + +公开的 API 变成: + +| 方法 | 作用域 | 跳过 pinned | 新增 | +|---|---|---|---| +| `closeTab(id)` | 显式的 1 个 | ❌(显式点名,连固定页也关) | | +| `closeTabs(ids)` | 显式的 N 个 | ✅ | ✅ | +| `closeOtherTabs(id)` | 除它以外 | ✅ | | +| `closeTabsToRight(id)` | 右侧 | ✅ | | +| `closeTabsToLeft(id)` | 左侧 | ✅ | ✅ | +| `closeAllTabs()` | 全部 | ✅ | | +| `closeSelectedOrActive()` | 选择集,空则当前页 | ✅ / ❌ | ✅ | + +`closeTab` 不跳过 pinned 是**故意**的,不是遗漏:右键菜单的「Close」就该关掉你点名的那一个。批量手势才是"扫过去",扫不到固定页。这条区别在代码注释里写死。 + +`closeSelectedOrActive()` 放在 store 而不是 App.tsx,是为了让 ⌘W 的分派逻辑可测——它是辩题二里风险最高的一条路径,不能只靠"在真机上点一下"来验。 + +### 4.2 选择集的生命周期 + +`selectedTabIds: string[]` 进 store(App.tsx 的 ⌘W 处理、TabBar 的渲染、右键菜单的计数都要读它)。悬空 id 只能从"标签页消失了"或"标签页换了 id"产生,逐条堵死: + +| 事件 | 处理 | +|---|---| +| 任何关闭动作 | 清空 | +| Save As(`setActivePathAndName`,标签页 id 从 `scratch:*` 变成路径) | 跟着改 id,不清空 | +| 拖拽换序、固定/取消固定 | id 不变,无需处理 | +| 普通点击标签页 | 清空并激活 | +| Esc | 清空 | +| 把已经固定的标签页选中 | 进不来(辩题五) | +| 标签条被设置整个关掉(`showTabBar: false`) | 选择集留着,但 ⌘W 不认它——看不见的选择不许指挥破坏性操作 | + +⇧ 连选的锚点(anchor)留在 TabBar 的局部状态里——它纯粹是指针交互的概念,不该进全局 store。锚点失效时退回当前激活页。 + +### 4.3 手势与显形 + +| 手势 | 行为 | +|---|---| +| 点击 | 激活,清空选择集(不变) | +| ⌘/Ctrl + 点击 | 在选择集里增删这一个;不改变激活页;固定页无反应 | +| ⇧ + 点击 | 从锚点到这里的整段加入选择集(跳过固定页) | +| 中键 | 关掉这一个(不变) | +| Esc | 清空选择集 | + +选中但非激活的标签页:内描边 + 淡蓝底(`ring-1 ring-inset ring-blue-500/60` + `bg-blue-500/10`),并挂 `data-selected="true"` 供测试断言。辩题二的第 1 道闸要求它一眼可辨,所以不用"稍微亮一点"这种表达。 + +右键菜单:**点在选中的标签页上**时,顶部多两条——「Close N Tabs」(N 是真实会关掉的数量)和「Clear Selection」;**点在没选中的标签页上**时,先清空选择集再弹常规菜单(Finder 的行为,避免菜单说的和用户看的不是一回事)。常规菜单里补上「Close to the Left」。 + +### 4.4 一次批量,一个诚实的确认 + +``` +confirmDiscard(victims): boolean +``` + +- 受害者里没有"有路径且脏"的 → 直接放行,不打扰; +- 恰好 1 个 → 沿用今天的 `tab.confirmClose`(单个关闭的体验一字不变); +- 2 个及以上 → 新的 `tab.confirmCloseMany`,**把名字都列出来**(超过 5 个截断并写明"还有 N 个")。 + +这一条同时修掉缺陷一(不问)和缺陷二(问得不对)。仍然用 `window.confirm`——理由见辩题三。 + +### 4.5 恢复 + +不新增机制:批量关闭把每个有路径的受害者按 strip 顺序压进 `recentlyClosed`,⌘⇧T 逐个取回。栈上限仍是 10(`RECENTLY_CLOSED_MAX`)——关掉 20 个只能找回最先关的 10 个,这是既有上限,本轮不动。 + +### 4.6 键盘与命令面板 + +新增一条可改键的快捷键(进 [shortcuts.ts](../../src/lib/shortcuts.ts),因此自动出现在 ⌘⇧/ 速查表和设置里的快捷键编辑器): + +| 命令 | 绑定 | 为什么是它 | +|---|---|---| +| Close Other Tabs | `⌘⌥W` | ⌘W 的近邻;本机菜单里没占用,也不撞任何现有绑定 | + +其余批量动作只上命令面板 + 右键菜单:「Close Tabs to the Left」「Close Tabs to the Right」「Close Selected Tabs」,加上原有的「Close All Tabs」。理由是它们要么天然需要一个轴心(先得有激活页/选择集),要么本来就是指针场景——占一个全局键位不划算。 + +⌘W(原生菜单 File ▸ Close Tab)改成走 `closeSelectedOrActive()`:有选择集关选择集,没有就关当前页(辩题二)。 + +--- + +## 5. 副作用:右键菜单一直有个宽度 bug + +`ContextMenu` 的面板是 `absolute` 的,宽度靠 shrink-to-fit;里面的按钮是 `inline-block`(`