diff --git a/docs/USAGE.md b/docs/USAGE.md index 0126391..82e75db 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -23,6 +23,29 @@ the toolbar — search syntax, navigation, and Canvas. - **Open a single file** — `⌘O`, drag a `.md` / `.markdown` / `.html` / `.canvas` file onto the window, or double-click it in Finder. +## Closing tabs + +One at a time: the `×` on the tab, middle-click, or `⌘W`. + +Several at once — right-click any tab: + +| Item | Closes | +|---|---| +| **Close Others** (`⌘⌥W`) | everything except that tab | +| **Close to the Left / Right** | everything on that side | +| **Close All** | everything | +| **Close N Tabs** | the tabs you marked (see below) | + +To mark a set, **⌘-click** tabs one by one, or **⇧-click** to extend a range +from the last tab you clicked. Marked tabs get a blue outline; `⌘W` then closes +the whole marked set. A plain click or `Esc` clears the marking. + +Pinned tabs (right-click → **Pin**) sit out of all of this — no bulk gesture +closes them, and they can't be marked. Unpin first. + +Anything with unsaved changes asks once for the whole batch, and `⌘⇧T` reopens +closed tabs one at a time. + ## Search Two complementary tools: diff --git a/docs/design/10-close-many-tabs.md b/docs/design/10-close-many-tabs.md index b6962cf..2d67943 100644 --- a/docs/design/10-close-many-tabs.md +++ b/docs/design/10-close-many-tabs.md @@ -182,11 +182,12 @@ removeTabs(state, victimIds) → { tabs, activeTabId, recentlyClosed, selectedTa |---|---| | 任何关闭动作 | 清空 | | Save As(`setActivePathAndName`,标签页 id 从 `scratch:*` 变成路径) | 跟着改 id,不清空 | -| 拖拽换序、固定/取消固定 | id 不变,无需处理 | +| 拖拽换序、取消固定 | id 不变,无需处理 | +| **固定**一个已选中的标签页 | 从选择集里剔掉——固定页进不了选择集,已经在里面的也不能留,否则「Close N Tabs」的 N 会多数一个、⌘W 会变死键(见 §9) | | 普通点击标签页 | 清空并激活 | -| Esc | 清空 | +| Esc(焦点不在输入框里) | 清空;编辑态下 App 的"Esc 回阅读"先让路,下一次 Esc 才回阅读。输入框(查找栏、命令面板)里的 Esc 归输入框 | | 把已经固定的标签页选中 | 进不来(辩题五) | -| 标签条被设置整个关掉(`showTabBar: false`) | 选择集留着,但 ⌘W 不认它——看不见的选择不许指挥破坏性操作 | +| 标签条被设置整个关掉(`showTabBar: false`) | 选择集留着,但 ⌘W 不认它——看不见的选择不许指挥破坏性操作。**Esc 也不认它**:清选择集的监听挂在 TabBar 上,strip 隐藏时它不在场,App 的 Esc 守卫只在 strip 可见时才让位,否则 Esc 会变成死键 | ⇧ 连选的锚点(anchor)留在 TabBar 的局部状态里——它纯粹是指针交互的概念,不该进全局 store。锚点失效时退回当前激活页。 @@ -196,13 +197,13 @@ removeTabs(state, victimIds) → { tabs, activeTabId, recentlyClosed, selectedTa |---|---| | 点击 | 激活,清空选择集(不变) | | ⌘/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」。 +右键菜单:**点在选中的标签页上**时,顶部多两条——「Close N Tabs」(N 取自 `liveSelection()`:在 strip 上且非固定的,所以是真实会关掉的数量)和「Clear Selection」;**点在没选中的标签页上**时,先清空选择集再弹常规菜单(Finder 的行为,避免菜单说的和用户看的不是一回事)。常规菜单里补上「Close to the Left」。 ### 4.4 一次批量,一个诚实的确认 @@ -230,7 +231,9 @@ confirmDiscard(victims): boolean 其余批量动作只上命令面板 + 右键菜单:「Close Tabs to the Left」「Close Tabs to the Right」「Close Selected Tabs」,加上原有的「Close All Tabs」。理由是它们要么天然需要一个轴心(先得有激活页/选择集),要么本来就是指针场景——占一个全局键位不划算。 -⌘W(原生菜单 File ▸ Close Tab)改成走 `closeSelectedOrActive()`:有选择集关选择集,没有就关当前页(辩题二)。 +⌘W(原生菜单 File ▸ Close Tab)改成走 `closeSelectedOrActive()`:有选择集关选择集,没有就关当前页(辩题二)。"有"的定义同样是 `liveSelection()` 非空——只剩固定页或悬空 id 的选择集不算,退回关当前页,⌘W 永远不会变成死键。 + +`⌘⌥W` 还牵出一个既有问题:`eventToShortcut` 只看 `e.key`,而 macOS 的 Option 层会先把字母合成成符号(⌥W → `∑`,⌥] → `‘`,⌥E → 死键),所以 `Mod+Alt+*` 这一整类绑定——包括既有的 ⌘⌥S / ⌘⌥T / ⌘⌥B / ⌘⌥] / ⌘⌥[——在 macOS 上可能从来没匹配上过;仓库里没有任何测试用真实的合成字符验过它们。修法是 Alt 按下且 `e.key` 是合成字符(非 ASCII 或 `Dead`)时改从 `e.code` 取键名;纯 ASCII 的 Alt 组合不动,AltGr 布局照旧打字。**没能在真机上验证**(本轮没有授权驱动应用),所以这条以合成事件的单元测试为准——真机上按一次 ⌘⌥T 切主题就能一锤定音:改前就能切,补丁是惰性的;改前不能切,补丁顺手把五个老绑定也救活了。 --- @@ -267,7 +270,7 @@ confirmDiscard(victims): boolean ## 8. 验证 -单元 / 组件测试(Vitest,本轮新增 36 条,全套 1047 条通过): +单元 / 组件测试(Vitest,三个 PR 合计新增 53 条,全套 1064 条通过): **store(`src/store.test.ts`)** - `closeTabs` 关掉给定的一批,跳过固定页,激活页落到第一个被关的左邻; @@ -277,8 +280,11 @@ confirmDiscard(victims): boolean - **回归**:`closeAllTabs` 有多个脏文档时,确认文案里出现的是**全部**名字,不是第一个; - 一个批次只问一次;批次里没有脏文档时一声不吭; - 批量关闭按 strip 顺序压 `recentlyClosed`,连续 `popRecentlyClosed()` 从左到右; -- `closeSelectedOrActive()` 有选择集时关选择集、为空时关当前页;关掉标签条(`showTabBar: false`)时无视选择集; -- 选择集:`toggleTabSelection` 拒绝固定页、`selectTabRange` 跳过固定页、任何关闭后清空、Save As 后 id 跟着迁移。 +- `closeSelectedOrActive()` 有选择集时关选择集、为空时关当前页;关掉标签条(`showTabBar: false`)时无视选择集;选择集里只剩固定页时退回关当前页; +- 选择集:`toggleTabSelection` 拒绝固定页、`selectTabRange` 跳过固定页、任何关闭后清空、Save As 后 id 跟着迁移、固定一个已选中的页会把它剔出选择集、`liveSelection()` 只数真会被关的; +- `closeOtherTabs` 其余全是固定页时仍把轴心页激活(改前行为);`closeAllTabs` 落到离被关位置最近的固定页; +- `t()` 不展开文件名里的 `$&` / `$'`(既有 bug,确认文案现在会列一串文件名,顺手修); +- `eventToShortcut`:∑/KeyW、‘/BracketRight、†/KeyT、Dead/KeyE 落到 `Mod+Alt+W/]/T/E`,@/KeyQ(AltGr)保持原样。 **TabBar(`src/components/TabBar.test.tsx`)** - ⌘ 点切换选中态且不改变激活页; @@ -287,8 +293,30 @@ confirmDiscard(victims): boolean - Esc 清空选择集; - 选中的标签页带 `data-selected="true"`; - 在选中的标签页上右键出现「Close N Tabs」,点它关掉的正是那 N 个; -- 在未选中的标签页上右键先清空选择集。 +- 在未选中的标签页上右键先清空选择集; +- 输入框里的 Esc 不碰选择集;锚点被关掉后 ⇧ 点从激活页起算; +- Close Others / to the Left / to the Right / All 在只会碰到固定页时置灰。 **真机 smoke**(dev server + 浏览器,非 Tauri 壳):⌘N 开到 4 个标签页 → ⌘点第 2、4 个,两者拿到 `data-selected="true"`,实测样式是 10% 蓝底 + `inset 0 0 0 1px` 蓝色描边、`opacity: 1`,未选中的仍是透明 + `opacity: .6` → 右键选中项,菜单首条是「Close 2 Tabs」→ 点它,4 个标签页变 2 个,激活页落到被关那个的左邻。§5 那个宽度 bug 也是在这一轮里量出来的。 没有加 e2e:这一轮的行为全部落在 DOM 事件和 store 上,Playwright 层面能多验的只有"⌘W 走原生菜单",而那条路径在 e2e 里也是 mock 的(`e2e/tauri-mock.ts` 不带真实菜单),加了也验不到真东西。 + +--- + +## 9. Review 之后改了什么 + +落地后三个独立的对抗式 review(每个 PR 一个,任务是"证明它不对")抓到的,按严重度: + +| 发现 | 严重度 | 处理 | +|---|---|---| +| ⌘点选中 b → Pin → ⌘W:`closeSelectedOrActive` 只查"还在不在"不查 pinned,`closeTabs` 过滤掉固定页后一个受害者都没有,`return state`——连当前页都不关,选择集也不清,**⌘W 从此是死键** | bug | `toggleTabPinned` 固定时剔出选择集;`closeSelectedOrActive` 改用 `liveSelection()`,空了退回关当前页 | +| 同一根因:「Close N Tabs」的 N 从 `selectedTabIds.length` 来,选 1 固定 + 2 非固定显示 3 实关 2——正是辩题五要防的"计数撒谎" | bug | 标签和 run 都改用 `liveSelection()` | +| `Mod+Alt+W` 在 macOS 上可能永远匹配不上(Option 合成),既有的五个 ⌘⌥ 绑定同病 | bug(未在真机证实) | `eventToShortcut` 对合成键改从 `e.code` 取名,见 §4.6 | +| Esc 双击:清选择集的同时把编辑器翻回阅读模式,削弱了辩题二的第 2 道闸 | 行为偏差 | App 那一支看到选择集非空先让路;TabBar 的监听跳过输入框 | +| `closeOtherTabs` 其余全是固定页时不再激活轴心页(改前会) | 行为回退 | 补回 | +| `closeAllTabs` 的落点从"第一个固定页"变成"最后一个固定页" | 行为变化 | **保留**——这是 §4.1 的统一规则(最左被关位置的左邻),补测试钉住 | +| `t()` 用字符串替换值,文件名里的 `$&` 会被展开成 `{1}` | 既有 nit | 换函数替换值 | +| ⇧ 连选的锚点被关掉后 `??` 不会退回激活页 | nit | 点击时核对锚点还在不在 | +| Close to the Left 只在第一个标签页上置灰,但固定页永远排最前,"左边全是固定页"是常态 | nit | 四条批量项都按"是否真有非固定页会被关"置灰 | + +§4.2 那张生命周期表原本写着"固定/取消固定 | id 不变,无需处理"——错了。危险的不是 id,是 `pinned` 这个标志:它决定一个 id 还算不算"会被关的"。表已改。 diff --git a/src/App.tsx b/src/App.tsx index 3bed343..fbb07ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -181,7 +181,6 @@ export function App() { const setVault = useAppStore((s) => s.setVault); const setVaultFiles = useAppStore((s) => s.setVaultFiles); const openLoadedFile = useAppStore((s) => s.openLoadedFile); - const closeTab = useAppStore((s) => s.closeTab); const newScratchTab = useAppStore((s) => s.newScratchTab); const setSourceMode = useAppStore((s) => s.setSourceMode); const toggleSourceMode = useAppStore((s) => s.toggleSourceMode); @@ -1140,11 +1139,11 @@ export function App() { case "open_recent": setShowRecentOpen(true); break; - case "close_tab": { - const a = useAppStore.getState().activeTabId; - if (a) closeTab(a); + case "close_tab": + // Closes the marked selection when there is one, else the active + // tab. See docs/design/10-close-many-tabs.md §3 (辩题二). + useAppStore.getState().closeSelectedOrActive(); break; - } case "save": { const a = useAppStore.getState().activeTabId; if (a) { @@ -1375,6 +1374,13 @@ export function App() { return; } if (e.key === "Escape" && !readMode && !sourceMode && !isEditableTarget) { + // A live tab selection is the more recent state — Esc drops that + // first (TabBar owns the clearing); the next Esc returns to Read. + // Only while the strip is visible, though: with showTabBar off the + // selection is retained but hidden (design 10 §4.2), TabBar isn't + // mounted to clear it, and deferring here would leave Esc dead. + const st = useAppStore.getState(); + if (st.showTabBar && st.selectedTabIds.length > 0) return; e.preventDefault(); setReadMode(true); return; @@ -1617,6 +1623,19 @@ export function App() { if (cur) useAppStore.getState().closeTabsToRight(cur); }, }, + { + id: "close_selected_tabs", + label: "Close Selected Tabs", + hint: "⌘-click / ⇧-click tabs to select", + run: () => { + const st = useAppStore.getState(); + if (st.selectedTabIds.length === 0) { + showToast(tr("toast.noTabSelection")); + return; + } + st.closeTabs(st.selectedTabIds); + }, + }, { id: "next_tab", label: "Next Tab", diff --git a/src/components/TabBar.test.tsx b/src/components/TabBar.test.tsx index 60fcada..b9e7d69 100644 --- a/src/components/TabBar.test.tsx +++ b/src/components/TabBar.test.tsx @@ -16,9 +16,22 @@ function makeTab(id: string, name: string, status: Tab["status"] = "saved"): Tab } beforeEach(() => { - useAppStore.setState({ tabs: [], activeTabId: null }); + useAppStore.setState({ tabs: [], activeTabId: null, selectedTabIds: [] }); }); +/** The clickable row for a tab, by its display name. */ +function row(name: string): HTMLElement { + return screen.getByText(name).parentElement as HTMLElement; +} + +function threeTabs() { + useAppStore.setState({ + tabs: [makeTab("/a.md", "a.md"), makeTab("/b.md", "b.md"), makeTab("/c.md", "c.md")], + activeTabId: "/a.md", + selectedTabIds: [], + }); +} + describe("TabBar", () => { it("renders nothing for a single-tab state (no tab strip needed)", () => { useAppStore.setState({ @@ -159,3 +172,107 @@ describe("TabBar", () => { expect(screen.getByText("Close All")).toBeEnabled(); }); }); + +describe("TabBar multi-select", () => { + it("⌘-click marks a tab without moving the active doc", () => { + threeTabs(); + render(); + fireEvent.click(row("b.md"), { metaKey: true }); + const s = useAppStore.getState(); + expect(s.selectedTabIds).toEqual(["/b.md"]); + expect(s.activeTabId).toBe("/a.md"); + }); + + it("⌘-click again unmarks it", () => { + threeTabs(); + render(); + fireEvent.click(row("b.md"), { metaKey: true }); + fireEvent.click(row("b.md"), { metaKey: true }); + expect(useAppStore.getState().selectedTabIds).toEqual([]); + }); + + it("⇧-click extends a range from the last plain-clicked tab", () => { + threeTabs(); + render(); + fireEvent.click(row("a.md")); + fireEvent.click(row("c.md"), { shiftKey: true }); + expect(useAppStore.getState().selectedTabIds).toEqual(["/a.md", "/b.md", "/c.md"]); + }); + + it("a plain click drops the selection", () => { + threeTabs(); + render(); + fireEvent.click(row("b.md"), { metaKey: true }); + fireEvent.click(row("c.md")); + const s = useAppStore.getState(); + expect(s.selectedTabIds).toEqual([]); + expect(s.activeTabId).toBe("/c.md"); + }); + + it("marks selected tabs in the DOM", () => { + threeTabs(); + render(); + fireEvent.click(row("b.md"), { metaKey: true }); + expect(row("b.md")).toHaveAttribute("data-selected", "true"); + expect(row("c.md")).not.toHaveAttribute("data-selected"); + }); + + it("Esc drops the selection", () => { + threeTabs(); + render(); + fireEvent.click(row("b.md"), { metaKey: true }); + fireEvent.keyDown(window, { key: "Escape" }); + expect(useAppStore.getState().selectedTabIds).toEqual([]); + }); + + it("right-clicking a selected tab offers to close the whole selection", () => { + threeTabs(); + render(); + fireEvent.click(row("b.md"), { metaKey: true }); + fireEvent.click(row("c.md"), { metaKey: true }); + fireEvent.contextMenu(row("b.md"), { clientX: 10, clientY: 10 }); + fireEvent.click(screen.getByText("Close 2 Tabs")); + expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/a.md"]); + }); + + it("Esc inside an input leaves the selection alone", () => { + threeTabs(); + render( + <> + + + , + ); + fireEvent.click(row("b.md"), { metaKey: true }); + fireEvent.keyDown(screen.getByLabelText("probe"), { key: "Escape" }); + expect(useAppStore.getState().selectedTabIds).toEqual(["/b.md"]); + }); + + it("⇧-click falls back to the active tab when the anchor was closed", () => { + useAppStore.setState({ + tabs: [ + makeTab("/a.md", "a.md"), + makeTab("/b.md", "b.md"), + makeTab("/c.md", "c.md"), + makeTab("/d.md", "d.md"), + ], + activeTabId: "/b.md", + selectedTabIds: [], + }); + render(); + fireEvent.click(row("a.md"), { metaKey: true }); // anchor = a + fireEvent.mouseDown(row("a.md"), { button: 1 }); // …and close it + fireEvent.click(row("d.md"), { shiftKey: true }); + // Anchor is gone → range runs from the active tab (b) to d. + expect(useAppStore.getState().selectedTabIds).toEqual(["/b.md", "/c.md", "/d.md"]); + }); + + it("right-clicking outside the selection clears it first", () => { + threeTabs(); + render(); + fireEvent.click(row("b.md"), { metaKey: true }); + fireEvent.contextMenu(row("c.md"), { clientX: 10, clientY: 10 }); + expect(useAppStore.getState().selectedTabIds).toEqual([]); + expect(screen.queryByText(/Close \d+ Tabs?/)).toBeNull(); + }); +}); diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 554f62d..cd08e16 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -1,11 +1,11 @@ -import { useState, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { getBookmarks, isBookmarked, subscribe as subscribeBookmarks, toggleBookmark, } from "../lib/bookmarks"; -import { useAppStore } from "../store"; +import { liveSelection, useAppStore } from "../store"; const DRAG_MIME = "application/x-markup-tab"; @@ -24,8 +24,13 @@ export function TabBar() { const closeTabsToRight = useAppStore((s) => s.closeTabsToRight); const closeTabsToLeft = useAppStore((s) => s.closeTabsToLeft); const closeAllTabs = useAppStore((s) => s.closeAllTabs); + const closeTabs = useAppStore((s) => s.closeTabs); const toggleTabPinned = useAppStore((s) => s.toggleTabPinned); const reorderTab = useAppStore((s) => s.reorderTab); + const selectedIds = useAppStore((s) => s.selectedTabIds); + const toggleTabSelection = useAppStore((s) => s.toggleTabSelection); + const selectTabRange = useAppStore((s) => s.selectTabRange); + const clearTabSelection = useAppStore((s) => s.clearTabSelection); // Subscribe so star indicators refresh on bookmark toggle. The // returned array reference is stable as the bookmarks store mutates @@ -35,6 +40,37 @@ export function TabBar() { const [draggingId, setDraggingId] = useState(null); const [overId, setOverId] = useState(null); const [ctx, setCtx] = useState(null); + // Anchor for ⇧-click ranges. Pure pointer state — it never needs to leave + // this component, unlike the selection itself (App reads that for ⌘W). + const [anchorId, setAnchorId] = useState(null); + + const selected = useMemo(() => new Set(selectedIds), [selectedIds]); + // What a "Close N Tabs" would really remove — never counts pinned tabs. + const live = useMemo( + () => liveSelection({ tabs, selectedTabIds: selectedIds }), + [tabs, selectedIds], + ); + + // Esc drops the selection. One of the three guards that make it safe for + // ⌘W to close a selection instead of the active tab — see + // docs/design/10-close-many-tabs.md §3 (辩题二). Esc inside an input (the + // find bar, the palette) belongs to that input, not to the strip. + useEffect(() => { + if (selectedIds.length === 0) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + const t = e.target as HTMLElement | null; + if ( + t && + (t.isContentEditable || t.tagName === "INPUT" || t.tagName === "TEXTAREA") + ) { + return; + } + clearTabSelection(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [selectedIds.length, clearTabSelection]); if (tabs.length <= 1) return null; @@ -44,9 +80,17 @@ export function TabBar() {
{tabs.map((tab) => { const isActive = tab.id === activeTabId; + const isSelected = selected.has(tab.id); const indicator = tab.status === "dirty" ? "●" : ""; const isDragging = draggingId === tab.id; const isOver = overId === tab.id && draggingId && draggingId !== tab.id; + // A selected tab stays at full opacity — the marking has to be + // obvious enough that nobody forgets a selection is live. + const tone = isActive + ? "bg-canvas-light dark:bg-canvas-dark text-ink-light dark:text-ink-dark" + : isSelected + ? "" + : "opacity-60 hover:opacity-90"; return (
{ e.preventDefault(); + // Right-clicking outside the selection moves focus to that tab, + // so the menu never describes tabs the user isn't pointing at. + if (!isSelected) clearTabSelection(); setCtx({ id: tab.id, x: e.clientX, y: e.clientY }); }} onMouseDown={(e) => { @@ -90,14 +137,32 @@ export function TabBar() { closeTab(tab.id); } }} - className={`group titlebar-no-drag relative flex items-center gap-2 pl-3 pr-1 py-1.5 text-[12px] cursor-pointer border-r border-black/5 dark:border-white/10 select-none ${ - isActive - ? "bg-canvas-light dark:bg-canvas-dark text-ink-light dark:text-ink-dark" - : "opacity-60 hover:opacity-90" + data-selected={isSelected ? "true" : undefined} + className={`group titlebar-no-drag relative flex items-center gap-2 pl-3 pr-1 py-1.5 text-[12px] cursor-pointer border-r border-black/5 dark:border-white/10 select-none ${tone} ${ + isSelected ? "bg-blue-500/10 ring-1 ring-inset ring-blue-500/60" : "" } ${isDragging ? "opacity-30" : ""} ${ isOver ? "ring-2 ring-blue-500/50 ring-inset" : "" }`} - onClick={() => setActiveTab(tab.id)} + onClick={(e) => { + // ⇧ extends a range from the anchor, ⌘/Ctrl toggles one tab + // (without moving the active doc), a plain click does what it + // always did — activate, and drop any selection. + if (e.shiftKey) { + // The anchor may have been closed since it was set; fall + // back to the active tab rather than selecting one tab. + const anchorLive = anchorId && tabs.some((t) => t.id === anchorId); + selectTabRange(anchorLive ? anchorId : activeTabId, tab.id); + return; + } + if (e.metaKey || e.ctrlKey) { + toggleTabSelection(tab.id); + setAnchorId(tab.id); + return; + } + clearTabSelection(); + setAnchorId(tab.id); + setActiveTab(tab.id); + }} > {tab.pinned && ( @@ -139,6 +204,16 @@ export function TabBar() { y={ctx.y} onClose={() => setCtx(null)} items={[ + ...(selected.has(ctx.id) && live.length > 0 + ? [ + { + label: + live.length === 1 ? "Close 1 Tab" : `Close ${live.length} Tabs`, + run: () => closeTabs(live), + }, + { label: "Clear Selection", run: () => clearTabSelection() }, + ] + : []), { label: tabs.find((t) => t.id === ctx.id)?.pinned ? "Unpin" : "Pin", run: () => toggleTabPinned(ctx.id), @@ -238,7 +313,7 @@ function ContextMenu({ style={{ left: x, top: y }} // flex-col so the panel's max-content width is the widest item, not // the sum of them — inline-block buttons made it grow with the item - // count (8 items measured 684px wide). + // count (~700px before this feature added two more entries). className="absolute flex flex-col min-w-[160px] py-1 rounded-md shadow-2xl bg-canvas-light dark:bg-canvas-dark border border-black/10 dark:border-white/15" onClick={(e) => e.stopPropagation()} > diff --git a/src/lib/locales/en.ts b/src/lib/locales/en.ts index ca28af8..baca3ac 100644 --- a/src/lib/locales/en.ts +++ b/src/lib/locales/en.ts @@ -112,6 +112,7 @@ export const en = { "toast.copiedPlainText": "Copied as plain text", "toast.copiedHtml": "Copied as HTML", "toast.savedAll": "Saved {0} files", + "toast.noTabSelection": "No tabs are selected", "toast.saveAllFailed": "Saved {0}, {1} failed", "toast.tableSizeBad": "Format: rows x cols (e.g. 3x4)", "toast.reloaded": "Reloaded from disk", diff --git a/src/lib/locales/zh.ts b/src/lib/locales/zh.ts index 7168417..d794a53 100644 --- a/src/lib/locales/zh.ts +++ b/src/lib/locales/zh.ts @@ -113,6 +113,7 @@ export const zh: Strings = { "toast.copiedPlainText": "已作为纯文本复制", "toast.copiedHtml": "已作为 HTML 复制", "toast.savedAll": "已保存 {0} 个文件", + "toast.noTabSelection": "没有选中的标签页", "toast.saveAllFailed": "已保存 {0},{1} 个失败", "toast.tableSizeBad": "格式:行 x 列(例如 3x4)", "toast.reloaded": "已从磁盘重新加载", diff --git a/src/store.test.ts b/src/store.test.ts index 73e7080..300f6f8 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useAppStore } from "./store"; +import { liveSelection, useAppStore } from "./store"; function reset() { // Re-create the store's initial state by closing all tabs except welcome @@ -18,6 +18,7 @@ function reset() { recentFiles: [], recentlyClosed: [], recentVaults: [], + selectedTabIds: [], }); } @@ -559,4 +560,191 @@ describe("app store", () => { expect(useAppStore.getState().activeTabId).toBe("/a.md"); }); }); + + describe("closeTabs (an explicit set)", () => { + // Opens n tabs named /1.md … /n.md, left to right. + function openN(n: number) { + const { openLoadedFile } = useAppStore.getState(); + for (let i = 1; i <= n; i++) { + openLoadedFile({ path: `/${i}.md`, content: "", mtime_ms: 1 }); + } + } + + it("removes exactly the listed tabs", () => { + openN(4); + useAppStore.getState().closeTabs(["/1.md", "/3.md"]); + expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/2.md", "/4.md"]); + }); + + it("skips pinned tabs in the batch", () => { + openN(3); + useAppStore.getState().toggleTabPinned("/2.md"); + useAppStore.getState().closeTabs(["/1.md", "/2.md", "/3.md"]); + expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/2.md"]); + }); + + it("lands the active tab left of the leftmost closed slot", () => { + openN(4); + useAppStore.getState().setActiveTab("/3.md"); + useAppStore.getState().closeTabs(["/2.md", "/3.md"]); + expect(useAppStore.getState().activeTabId).toBe("/1.md"); + }); + + it("keeps the active tab when it isn't in the batch", () => { + openN(3); + useAppStore.getState().setActiveTab("/1.md"); + useAppStore.getState().closeTabs(["/2.md", "/3.md"]); + expect(useAppStore.getState().activeTabId).toBe("/1.md"); + }); + + it("falls back to a fresh welcome scratch when it empties the strip", () => { + openN(2); + useAppStore.getState().closeTabs(["/1.md", "/2.md"]); + const s = useAppStore.getState(); + expect(s.tabs).toHaveLength(1); + expect(s.tabs[0].path).toBeNull(); + expect(s.activeTabId).toBe(s.tabs[0].id); + }); + + it("on an empty / unknown set is a no-op", () => { + openN(2); + useAppStore.getState().closeTabs([]); + useAppStore.getState().closeTabs(["/nope.md"]); + expect(useAppStore.getState().tabs).toHaveLength(2); + }); + + it("asks before discarding unsaved work in the set", () => { + openN(2); + useAppStore.getState().setActiveTab("/1.md"); + useAppStore.getState().updateActiveContent("edited"); + const spy = vi.spyOn(window, "confirm").mockReturnValue(false); + useAppStore.getState().closeTabs(["/1.md", "/2.md"]); + expect(spy).toHaveBeenCalledOnce(); + expect(useAppStore.getState().tabs).toHaveLength(2); + spy.mockRestore(); + }); + }); + + describe("tab selection", () => { + function openN(n: number) { + const { openLoadedFile } = useAppStore.getState(); + for (let i = 1; i <= n; i++) { + openLoadedFile({ path: `/${i}.md`, content: "", mtime_ms: 1 }); + } + } + + it("toggleTabSelection adds then removes", () => { + openN(2); + useAppStore.getState().toggleTabSelection("/1.md"); + expect(useAppStore.getState().selectedTabIds).toEqual(["/1.md"]); + useAppStore.getState().toggleTabSelection("/1.md"); + expect(useAppStore.getState().selectedTabIds).toEqual([]); + }); + + it("refuses pinned tabs — they're exempt from every bulk close", () => { + openN(2); + useAppStore.getState().toggleTabPinned("/1.md"); + useAppStore.getState().toggleTabSelection("/1.md"); + expect(useAppStore.getState().selectedTabIds).toEqual([]); + }); + + it("selectTabRange covers the span between anchor and target", () => { + openN(4); + useAppStore.getState().selectTabRange("/2.md", "/4.md"); + expect(useAppStore.getState().selectedTabIds).toEqual(["/2.md", "/3.md", "/4.md"]); + }); + + it("selectTabRange works backwards and skips pinned tabs", () => { + openN(4); + useAppStore.getState().toggleTabPinned("/3.md"); // pinned sorts to the front + // Order is now /3, /1, /2, /4 — this range walks all four backwards, + // and the pinned one must not join the selection. + useAppStore.getState().selectTabRange("/4.md", "/3.md"); + expect(useAppStore.getState().selectedTabIds).toEqual(["/1.md", "/2.md", "/4.md"]); + }); + + it("selectTabRange with no anchor selects just the target", () => { + openN(3); + useAppStore.getState().selectTabRange(null, "/2.md"); + expect(useAppStore.getState().selectedTabIds).toEqual(["/2.md"]); + }); + + it("any close clears the selection", () => { + openN(3); + const s = useAppStore.getState(); + s.toggleTabSelection("/1.md"); + s.toggleTabSelection("/2.md"); + expect(useAppStore.getState().selectedTabIds).toHaveLength(2); + useAppStore.getState().closeTab("/3.md"); + expect(useAppStore.getState().selectedTabIds).toEqual([]); + }); + + it("Save As carries the selection across the tab's id change", () => { + const { newScratchTab, toggleTabSelection, setActivePathAndName } = + useAppStore.getState(); + newScratchTab(); + const scratchId = useAppStore.getState().activeTabId as string; + toggleTabSelection(scratchId); + setActivePathAndName("/saved.md", "saved.md", 1); + expect(useAppStore.getState().selectedTabIds).toEqual(["/saved.md"]); + }); + + it("closeSelectedOrActive closes the selection when there is one", () => { + openN(3); + useAppStore.getState().setActiveTab("/3.md"); + useAppStore.getState().toggleTabSelection("/1.md"); + useAppStore.getState().closeSelectedOrActive(); + expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/2.md", "/3.md"]); + }); + + it("closeSelectedOrActive ignores a selection the strip isn't showing", () => { + openN(3); + useAppStore.getState().setActiveTab("/2.md"); + useAppStore.getState().toggleTabSelection("/1.md"); + useAppStore.setState({ showTabBar: false }); + useAppStore.getState().closeSelectedOrActive(); + // Closed the active tab, not the invisible selection. + expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/1.md", "/3.md"]); + useAppStore.setState({ showTabBar: true }); + }); + + it("closeSelectedOrActive falls back to the active tab", () => { + openN(3); + useAppStore.getState().setActiveTab("/2.md"); + useAppStore.getState().closeSelectedOrActive(); + expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/1.md", "/3.md"]); + }); + + it("pinning a selected tab drops it from the selection", () => { + openN(3); + useAppStore.getState().toggleTabSelection("/2.md"); + useAppStore.getState().toggleTabSelection("/3.md"); + useAppStore.getState().toggleTabPinned("/2.md"); + expect(useAppStore.getState().selectedTabIds).toEqual(["/3.md"]); + }); + + it("⌘W never goes dead: a selection of only pinned tabs falls back to the active tab", () => { + openN(3); + useAppStore.getState().setActiveTab("/3.md"); + // Force the bad state directly — the store itself no longer produces it. + useAppStore.getState().toggleTabPinned("/1.md"); + useAppStore.setState({ selectedTabIds: ["/1.md"] }); + useAppStore.getState().closeSelectedOrActive(); + expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/1.md", "/2.md"]); + }); + + it("liveSelection counts only tabs a bulk close would remove", () => { + openN(3); + useAppStore.getState().toggleTabPinned("/1.md"); + useAppStore.setState({ selectedTabIds: ["/1.md", "/2.md", "/gone.md"] }); + expect(liveSelection(useAppStore.getState())).toEqual(["/2.md"]); + }); + + it("clearTabSelection empties it", () => { + openN(2); + useAppStore.getState().toggleTabSelection("/1.md"); + useAppStore.getState().clearTabSelection(); + expect(useAppStore.getState().selectedTabIds).toEqual([]); + }); + }); }); diff --git a/src/store.ts b/src/store.ts index 4997691..5574c9d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -47,6 +47,12 @@ export interface VaultFile { interface AppState { tabs: Tab[]; activeTabId: string | null; + /** Ids of tabs marked for a multi-tab close (⌘-click / ⇧-click on the + * strip). Never holds pinned tabs — they're exempt from every bulk + * gesture, so letting them in would make the "Close N Tabs" count lie. + * Cleared by any close and by a plain click. See + * docs/design/10-close-many-tabs.md §4.2. */ + selectedTabIds: string[]; vaultRoot: string | null; vaultFiles: VaultFile[]; sourceMode: boolean; @@ -110,12 +116,25 @@ interface AppState { /** Open fetched text (e.g. a GitHub file) as a new unsaved buffer. */ openScratchWithContent: (name: string, content: string) => void; closeTab: (id: string) => void; + /** Close an explicit set of tabs in one transaction. Pinned tabs in the + * set are skipped, like every other bulk gesture. */ + closeTabs: (ids: string[]) => void; setActiveTab: (id: string) => void; reorderTab: (fromId: string, toId: string) => void; closeOtherTabs: (id: string) => void; closeTabsToRight: (id: string) => void; closeTabsToLeft: (id: string) => void; closeAllTabs: () => void; + /** What ⌘W / File ▸ Close Tab does: closes the selection when the user + * marked one, otherwise just the active tab. Lives here (not in App) so + * the dispatch is testable — it's the riskiest path in this feature. */ + closeSelectedOrActive: () => void; + /** Add/remove one tab from the selection. Pinned tabs are ignored. */ + toggleTabSelection: (id: string) => void; + /** Add every tab between `anchorId` and `toId` (inclusive) to the + * selection, skipping pinned. A null/unknown anchor selects `toId` alone. */ + selectTabRange: (anchorId: string | null, toId: string) => void; + clearTabSelection: () => void; toggleTabPinned: (id: string) => void; activateNextTab: () => void; activatePrevTab: () => void; @@ -274,6 +293,17 @@ function pushClosed(stack: string[], paths: Array): s return next.slice(0, RECENTLY_CLOSED_MAX); } +/** The selected tab ids that a bulk close would actually remove: present on + * the strip and not pinned. Exported for the strip, which labels its + * "Close N Tabs" item from it so the count can't lie. */ +export function liveSelection( + state: Pick, +): string[] { + return state.selectedTabIds.filter((id) => + state.tabs.some((x) => x.id === id && !x.pinned), + ); +} + /** How many filenames a batch confirmation spells out before summarising. */ const CONFIRM_NAME_MAX = 5; @@ -297,7 +327,8 @@ function confirmDiscard(victims: Tab[]): boolean { /** * Remove `victimIds` from the strip in one transaction: record the closed - * paths for ⌘⇧T and land the active tab somewhere sane. + * paths for ⌘⇧T, land the active tab somewhere sane, and drop the selection + * (it could only have referred to tabs that are now gone). * * Callers own the confirm gate and the pinned filter — this is the shared * mechanics, not the policy. @@ -312,6 +343,7 @@ function removeTabs(state: AppState, victimIds: Set) { tabs: [welcomeTab()], activeTabId: `${SCRATCH_PREFIX}welcome` as string | null, recentlyClosed, + selectedTabIds: [] as string[], }; } // If the active tab went with the batch, land on the survivor just left of @@ -321,7 +353,7 @@ function removeTabs(state: AppState, victimIds: Set) { state.activeTabId && victimIds.has(state.activeTabId) ? tabs[Math.min(Math.max(0, firstIdx - 1), tabs.length - 1)].id : state.activeTabId; - return { tabs, activeTabId, recentlyClosed }; + return { tabs, activeTabId, recentlyClosed, selectedTabIds: [] as string[] }; } /** @@ -353,9 +385,10 @@ function welcomeTab(): Tab { }; } -export const useAppStore = create((set) => ({ +export const useAppStore = create((set, get) => ({ tabs: [welcomeTab()], activeTabId: `${SCRATCH_PREFIX}welcome`, + selectedTabIds: [], vaultRoot: null, vaultFiles: [], sourceMode: false, @@ -459,6 +492,15 @@ export const useAppStore = create((set) => ({ return removeTabs(state, new Set([id])); }), + closeTabs: (ids) => + set((state) => { + const wanted = new Set(ids); + const victims = state.tabs.filter((x) => wanted.has(x.id) && !x.pinned); + if (victims.length === 0) return state; + if (!confirmDiscard(victims)) return state; + return removeTabs(state, new Set(victims.map((x) => x.id))); + }), + setActiveTab: (id) => set({ activeTabId: id }), reorderTab: (fromId, toId) => @@ -510,6 +552,51 @@ export const useAppStore = create((set) => ({ return removeTabs(state, new Set(victims.map((x) => x.id))); }), + closeSelectedOrActive: () => { + const s = get(); + // A selection the user can't see must not steer ⌘W — the strip can be + // switched off entirely. Only ids that would actually close count: + // pinned tabs never do, and a stale id (shouldn't happen — every close + // clears the selection) must fall through to the active tab rather + // than turn ⌘W into a dead key. + const live = s.showTabBar ? liveSelection(s) : []; + if (live.length > 0) { + s.closeTabs(live); + return; + } + if (s.activeTabId) s.closeTab(s.activeTabId); + }, + + toggleTabSelection: (id) => + set((state) => { + const target = state.tabs.find((x) => x.id === id); + // Pinned tabs are exempt from bulk closes, so they never join a + // selection — see docs/design/10-close-many-tabs.md §3 (辩题五). + if (!target || target.pinned) return state; + return { + selectedTabIds: state.selectedTabIds.includes(id) + ? state.selectedTabIds.filter((x) => x !== id) + : [...state.selectedTabIds, id], + }; + }), + + selectTabRange: (anchorId, toId) => + set((state) => { + const to = state.tabs.findIndex((x) => x.id === toId); + if (to < 0) return state; + const from = anchorId ? state.tabs.findIndex((x) => x.id === anchorId) : -1; + const start = from < 0 ? to : Math.min(from, to); + const end = from < 0 ? to : Math.max(from, to); + const merged = [...state.selectedTabIds]; + for (const x of state.tabs.slice(start, end + 1)) { + if (!x.pinned && !merged.includes(x.id)) merged.push(x.id); + } + return { selectedTabIds: merged }; + }), + + clearTabSelection: () => + set((state) => (state.selectedTabIds.length === 0 ? state : { selectedTabIds: [] })), + toggleTabPinned: (id) => set((state) => { const idx = state.tabs.findIndex((t) => t.id === id); @@ -521,7 +608,13 @@ export const useAppStore = create((set) => ({ const tabs = next.pinned ? [...others.filter((t) => t.pinned), next, ...others.filter((t) => !t.pinned)] : [...others.filter((t) => t.pinned), ...others.filter((t) => !t.pinned), next]; - return { tabs }; + // A pinned tab can't be selected (it's exempt from bulk closes), so + // pinning a selected one drops it from the selection — otherwise + // "Close N Tabs" would overcount and ⌘W would close nothing. + const selectedTabIds = next.pinned + ? state.selectedTabIds.filter((x) => x !== id) + : state.selectedTabIds; + return { tabs, selectedTabIds }; }), activateNextTab: () => @@ -642,6 +735,9 @@ export const useAppStore = create((set) => ({ t.id === id ? { ...t, id: path, path, name, mtimeMs, status: "saved" } : t, ), activeTabId: path, + // The tab's id just changed (scratch:* → its path); move any + // selection entry with it so it can't strand. + selectedTabIds: state.selectedTabIds.map((x) => (x === id ? path : x)), }; }),