From d3fe21804315e030a04fed09ea1220fe0c454f45 Mon Sep 17 00:00:00 2001 From: oratis Date: Mon, 24 Aug 2026 00:14:22 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(tabs):=20=E8=A1=A5=E4=B8=8A=20Close=20?= =?UTF-8?q?to=20the=20Left=EF=BC=8C=E2=8C=98=E2=8C=A5W=20=E5=85=B3?= =?UTF-8?q?=E6=8E=89=E5=85=B6=E4=BB=96=E6=A0=87=E7=AD=BE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 有「Close to the Right」没有「Close to the Left」:看长文顺着 wikilink 一路点进去,最后停在最右边,想关掉左边那一串——没有这个动词。 closeTabsToRight 和新的 closeTabsToLeft 收口成一个 closeSide(), 固定页照旧不动,轴心页留下并在激活页被关掉时接管。 键盘上原本一条关闭类命令都没有(唯一的 ⌘W 烧在原生菜单里,不能改键、 不进速查表)。加 Close Other Tabs = ⌘⌥W,走 shortcuts.ts,所以可改键、 出现在 ⌘⇧/ 速查表和设置里的快捷键编辑器。其余批量动作只上命令面板 + 右键菜单:它们要么天然需要一个轴心,要么本来就是指针场景,占全局键位 不划算。见 docs/design/10-close-many-tabs.md §4.6。 Co-Authored-By: Claude Opus 5 --- src/App.tsx | 31 +++++++++++++++++++++++ src/components/TabBar.test.tsx | 27 ++++++++++++++++++++ src/components/TabBar.tsx | 6 +++++ src/lib/shortcuts.ts | 3 +++ src/store.test.ts | 45 ++++++++++++++++++++++++++++++++++ src/store.ts | 34 +++++++++++++++---------- 6 files changed, 133 insertions(+), 13 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b18f8fec..3bed3439 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1404,6 +1404,12 @@ export function App() { else useAppStore.getState().activateNextTab(); return; } + if (matchesShortcut(e, "closeOtherTabs")) { + e.preventDefault(); + const cur = useAppStore.getState().activeTabId; + if (cur) useAppStore.getState().closeOtherTabs(cur); + return; + } if (matchesShortcut(e, "reopenClosed")) { e.preventDefault(); reopenLastClosed(); @@ -1586,6 +1592,31 @@ export function App() { label: "Close All Tabs", run: () => useAppStore.getState().closeAllTabs(), }, + { + id: "close_other_tabs", + label: "Close Other Tabs", + shortcut: "⌘⌥W", + run: () => { + const cur = useAppStore.getState().activeTabId; + if (cur) useAppStore.getState().closeOtherTabs(cur); + }, + }, + { + id: "close_tabs_left", + label: "Close Tabs to the Left", + run: () => { + const cur = useAppStore.getState().activeTabId; + if (cur) useAppStore.getState().closeTabsToLeft(cur); + }, + }, + { + id: "close_tabs_right", + label: "Close Tabs to the Right", + run: () => { + const cur = useAppStore.getState().activeTabId; + if (cur) useAppStore.getState().closeTabsToRight(cur); + }, + }, { id: "next_tab", label: "Next Tab", diff --git a/src/components/TabBar.test.tsx b/src/components/TabBar.test.tsx index e6d8a3e6..9cf25c98 100644 --- a/src/components/TabBar.test.tsx +++ b/src/components/TabBar.test.tsx @@ -114,4 +114,31 @@ describe("TabBar", () => { const ids = useAppStore.getState().tabs.map((t) => t.id); expect(ids).toEqual(["/b.md", "/c.md", "/a.md"]); }); + + it("the context menu can close everything to the left", () => { + useAppStore.setState({ + tabs: [ + makeTab("/a.md", "a.md"), + makeTab("/b.md", "b.md"), + makeTab("/c.md", "c.md"), + ], + activeTabId: "/a.md", + }); + render(); + const cRow = screen.getByText("c.md").parentElement!; + fireEvent.contextMenu(cRow, { clientX: 10, clientY: 10 }); + fireEvent.click(screen.getByText("Close to the Left")); + expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/c.md"]); + }); + + it("Close to the Left is disabled on the first tab", () => { + useAppStore.setState({ + tabs: [makeTab("/a.md", "a.md"), makeTab("/b.md", "b.md")], + activeTabId: "/a.md", + }); + render(); + const aRow = screen.getByText("a.md").parentElement!; + fireEvent.contextMenu(aRow, { clientX: 10, clientY: 10 }); + expect(screen.getByText("Close to the Left")).toBeDisabled(); + }); }); diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 5e3a2338..ed5f216c 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -22,6 +22,7 @@ export function TabBar() { const closeTab = useAppStore((s) => s.closeTab); const closeOtherTabs = useAppStore((s) => s.closeOtherTabs); const closeTabsToRight = useAppStore((s) => s.closeTabsToRight); + const closeTabsToLeft = useAppStore((s) => s.closeTabsToLeft); const closeAllTabs = useAppStore((s) => s.closeAllTabs); const toggleTabPinned = useAppStore((s) => s.toggleTabPinned); const reorderTab = useAppStore((s) => s.reorderTab); @@ -176,6 +177,11 @@ export function TabBar() { }, { label: "Close", run: () => closeTab(ctx.id) }, { label: "Close Others", run: () => closeOtherTabs(ctx.id) }, + { + label: "Close to the Left", + run: () => closeTabsToLeft(ctx.id), + disabled: tabs.findIndex((t) => t.id === ctx.id) === 0, + }, { label: "Close to the Right", run: () => closeTabsToRight(ctx.id), diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 223c1ede..c980bf88 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -28,6 +28,7 @@ export type ShortcutId = | "settings" | "nextTab" | "prevTab" + | "closeOtherTabs" | "reopenClosed" | "fmtBold" | "fmtItalic" @@ -70,6 +71,7 @@ export const defaults: Record = { settings: "Mod+,", nextTab: "Mod+Alt+]", prevTab: "Mod+Alt+[", + closeOtherTabs: "Mod+Alt+W", reopenClosed: "Mod+Shift+T", fmtBold: "Mod+B", fmtItalic: "Mod+I", @@ -113,6 +115,7 @@ export const labels: Record = { settings: "Settings", nextTab: "Next Tab", prevTab: "Previous Tab", + closeOtherTabs: "Close Other Tabs", reopenClosed: "Reopen Last Closed Tab", fmtBold: "Bold", fmtItalic: "Italic", diff --git a/src/store.test.ts b/src/store.test.ts index 5116fffb..73e70807 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -417,6 +417,42 @@ describe("app store", () => { }); }); + describe("closeTabsToLeft", () => { + // 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 everything before the pivot, pivot included", () => { + openN(4); + useAppStore.getState().closeTabsToLeft("/3.md"); + expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/3.md", "/4.md"]); + }); + + it("keeps pinned tabs on the left", () => { + openN(3); + useAppStore.getState().toggleTabPinned("/1.md"); // pinned tabs sort to the front + useAppStore.getState().closeTabsToLeft("/3.md"); + expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/1.md", "/3.md"]); + }); + + it("on the first tab is a no-op", () => { + openN(2); + useAppStore.getState().closeTabsToLeft("/1.md"); + expect(useAppStore.getState().tabs).toHaveLength(2); + }); + + it("activates the pivot when the active tab went with the batch", () => { + openN(3); + useAppStore.getState().setActiveTab("/1.md"); + useAppStore.getState().closeTabsToLeft("/3.md"); + expect(useAppStore.getState().activeTabId).toBe("/3.md"); + }); + }); + describe("batch close asks before discarding unsaved work", () => { // Opens every path in `paths` and leaves `dirtyPaths` with unsaved edits. function openDirty(paths: string[], dirtyPaths: string[]) { @@ -507,6 +543,15 @@ describe("app store", () => { expect(s.activeTabId).toBe("/b.md"); }); + it("closeTabsToLeft asks too, and cancelling keeps every tab", () => { + openDirty(["/a.md", "/b.md"], ["/a.md"]); + const spy = vi.spyOn(window, "confirm").mockReturnValue(false); + useAppStore.getState().closeTabsToLeft("/b.md"); + expect(spy).toHaveBeenCalledOnce(); + expect(useAppStore.getState().tabs).toHaveLength(2); + spy.mockRestore(); + }); + it("closeTabsToRight lands the active tab on the pivot when it went with the batch", () => { openDirty(["/a.md", "/b.md", "/c.md"], []); useAppStore.getState().setActiveTab("/c.md"); diff --git a/src/store.ts b/src/store.ts index f775575f..4997691b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -114,6 +114,7 @@ interface AppState { reorderTab: (fromId: string, toId: string) => void; closeOtherTabs: (id: string) => void; closeTabsToRight: (id: string) => void; + closeTabsToLeft: (id: string) => void; closeAllTabs: () => void; toggleTabPinned: (id: string) => void; activateNextTab: () => void; @@ -323,6 +324,23 @@ function removeTabs(state: AppState, victimIds: Set) { return { tabs, activeTabId, recentlyClosed }; } +/** + * Close everything on one side of `id`. Pinned tabs on that side survive — + * this gesture shouldn't take an anchored doc with it. The pivot always + * stays, and takes over as active if the previous active tab went along. + */ +function closeSide(state: AppState, id: string, side: "left" | "right") { + const idx = state.tabs.findIndex((x) => x.id === id); + if (idx < 0) return state; + const range = side === "left" ? state.tabs.slice(0, idx) : state.tabs.slice(idx + 1); + const victims = range.filter((x) => !x.pinned); + if (victims.length === 0) return state; + if (!confirmDiscard(victims)) return state; + const next = removeTabs(state, new Set(victims.map((x) => x.id))); + const activeSurvives = next.tabs.some((x) => x.id === state.activeTabId); + return { ...next, activeTabId: activeSurvives ? state.activeTabId : id }; +} + function welcomeTab(): Tab { return { id: `${SCRATCH_PREFIX}welcome`, @@ -478,19 +496,9 @@ export const useAppStore = create((set) => ({ }; }), - closeTabsToRight: (id) => - set((state) => { - const idx = state.tabs.findIndex((t) => t.id === id); - if (idx < 0) return state; - // Keep pinned tabs that lived to the right of `id` so the user - // doesn't lose their anchored ones via this gesture. - const victims = state.tabs.slice(idx + 1).filter((t) => !t.pinned); - if (victims.length === 0) return state; - if (!confirmDiscard(victims)) return state; - const next = removeTabs(state, new Set(victims.map((x) => x.id))); - const activeSurvives = next.tabs.some((t) => t.id === state.activeTabId); - return { ...next, activeTabId: activeSurvives ? state.activeTabId : id }; - }), + closeTabsToRight: (id) => set((state) => closeSide(state, id, "right")), + + closeTabsToLeft: (id) => set((state) => closeSide(state, id, "left")), closeAllTabs: () => set((state) => { From 8cec189e55b4a00a719eafa7963b9392e23048dd Mon Sep 17 00:00:00 2001 From: oratis Date: Mon, 24 Aug 2026 00:39:58 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(shortcuts):=20=E2=8C=A5=20=E7=BB=84?= =?UTF-8?q?=E5=90=88=E6=8C=89=E7=89=A9=E7=90=86=E9=94=AE=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E2=80=94=E2=80=94macOS=20=E6=8A=8A=20=E2=8C=A5W=20=E5=90=88?= =?UTF-8?q?=E6=88=90=E6=88=90=E4=BA=86=20=E2=88=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review 指出:eventToShortcut 只看 e.key,而 macOS 的 Option 层会先把 字母合成成符号(⌥W → "∑",⌥] → "‘",⌥E → 死键),于是 "Mod+Alt+W" 这类绑定在 macOS 上可能永远匹配不上。这不是本 PR 新引入的——既有的 ⌘⌥S / ⌘⌥T / ⌘⌥B / ⌘⌥] / ⌘⌥[ 走的是同一条路,仓库里没有任何测试或 e2e 用真实的合成字符验过它们。 修法:Alt 按下且 e.key 是合成字符(非 ASCII 或 "Dead")时,键名改从 e.code 取(KeyW → W,BracketRight → ],…)。纯 ASCII 的 Alt 组合不动, AltGr 布局(德语 Ctrl+Alt+Q = @)照旧打字。 没能在真机上验证合成行为(本机未授权驱动应用),所以用合成事件写了 单元测试:∑/KeyW、‘/BracketRight、†/KeyT、Dead/KeyE 都落到预期绑定, @/KeyQ 保持原样。如果真机上 ⌘⌥T 本来就能切主题,这条补丁是惰性的。 另:右键菜单里 Close Others / Close to the Left / Right / All 按"是否 真有非固定页会被关"来置灰,而不是按"那一侧有没有标签页"——固定页永远 排在最前,"左边全是固定页"是常态不是边角。 Co-Authored-By: Claude Opus 5 --- src/components/TabBar.test.tsx | 17 ++++++++++++ src/components/TabBar.tsx | 21 ++++++++++++--- src/lib/shortcuts.test.ts | 49 ++++++++++++++++++++++++++++++++++ src/lib/shortcuts.ts | 33 ++++++++++++++++++++++- 4 files changed, 115 insertions(+), 5 deletions(-) diff --git a/src/components/TabBar.test.tsx b/src/components/TabBar.test.tsx index 9cf25c98..60fcada8 100644 --- a/src/components/TabBar.test.tsx +++ b/src/components/TabBar.test.tsx @@ -141,4 +141,21 @@ describe("TabBar", () => { fireEvent.contextMenu(aRow, { clientX: 10, clientY: 10 }); expect(screen.getByText("Close to the Left")).toBeDisabled(); }); + + it("side / others / all closes are disabled when only pinned tabs would be hit", () => { + // Pinned tabs always sort to the front, so "everything to my left is + // pinned" is the common case, not a corner. + useAppStore.setState({ + tabs: [{ ...makeTab("/a.md", "a.md"), pinned: true }, makeTab("/b.md", "b.md")], + activeTabId: "/b.md", + }); + render(); + const bRow = screen.getByText("b.md").parentElement!; + fireEvent.contextMenu(bRow, { clientX: 10, clientY: 10 }); + expect(screen.getByText("Close to the Left")).toBeDisabled(); + expect(screen.getByText("Close Others")).toBeDisabled(); + expect(screen.getByText("Close to the Right")).toBeDisabled(); + // "Close All" still has b.md to close. + expect(screen.getByText("Close All")).toBeEnabled(); + }); }); diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index ed5f216c..554f62d5 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -38,6 +38,8 @@ export function TabBar() { if (tabs.length <= 1) return null; + const ctxIdx = ctx ? tabs.findIndex((t) => t.id === ctx.id) : -1; + return (
{tabs.map((tab) => { @@ -176,18 +178,29 @@ export function TabBar() { disabled: !tabs.find((t) => t.id === ctx.id)?.path, }, { label: "Close", run: () => closeTab(ctx.id) }, - { label: "Close Others", run: () => closeOtherTabs(ctx.id) }, + { + label: "Close Others", + run: () => closeOtherTabs(ctx.id), + disabled: !tabs.some((t) => t.id !== ctx.id && !t.pinned), + }, + // Pinned tabs sit out of every bulk close, so an item is greyed + // out when nothing on that side would actually go — not merely + // when there is nothing on that side. { label: "Close to the Left", run: () => closeTabsToLeft(ctx.id), - disabled: tabs.findIndex((t) => t.id === ctx.id) === 0, + disabled: !tabs.slice(0, ctxIdx).some((t) => !t.pinned), }, { label: "Close to the Right", run: () => closeTabsToRight(ctx.id), - disabled: tabs.findIndex((t) => t.id === ctx.id) === tabs.length - 1, + disabled: !tabs.slice(ctxIdx + 1).some((t) => !t.pinned), + }, + { + label: "Close All", + run: () => closeAllTabs(), + disabled: !tabs.some((t) => !t.pinned), }, - { label: "Close All", run: () => closeAllTabs() }, ]} /> )} diff --git a/src/lib/shortcuts.test.ts b/src/lib/shortcuts.test.ts index 5f1dd8b9..3c020900 100644 --- a/src/lib/shortcuts.test.ts +++ b/src/lib/shortcuts.test.ts @@ -45,6 +45,55 @@ describe("eventToShortcut", () => { expect(eventToShortcut(ke({ key: "/", metaKey: true }))).toBe("Mod+/"); expect(eventToShortcut(ke({ key: ",", metaKey: true }))).toBe("Mod+,"); }); + + it("names the physical key when macOS composes an Alt combo", () => { + // ⌥W arrives as "∑", ⌥] as "‘", ⌥T as "†" — the physical key still says + // which key it was. + expect( + eventToShortcut(ke({ key: "∑", code: "KeyW", metaKey: true, altKey: true })), + ).toBe("Mod+Alt+W"); + expect( + eventToShortcut( + ke({ key: "‘", code: "BracketRight", metaKey: true, altKey: true }), + ), + ).toBe("Mod+Alt+]"); + expect( + eventToShortcut(ke({ key: "†", code: "KeyT", metaKey: true, altKey: true })), + ).toBe("Mod+Alt+T"); + expect( + eventToShortcut( + ke({ key: "Ω", code: "KeyZ", metaKey: true, shiftKey: true, altKey: true }), + ), + ).toBe("Mod+Shift+Alt+Z"); + }); + + it("names the physical key when an Alt combo is a dead key", () => { + expect( + eventToShortcut(ke({ key: "Dead", code: "KeyE", metaKey: true, altKey: true })), + ).toBe("Mod+Alt+E"); + }); + + it("leaves ASCII Alt combos alone so AltGr layouts keep typing", () => { + // Ctrl+Alt+Q is "@" on a German layout; the typed character must win. + expect( + eventToShortcut(ke({ key: "@", code: "KeyQ", ctrlKey: true, altKey: true })), + ).toBe("Mod+Alt+@"); + }); + + it("matches a composed ⌘⌥W against the Close Other Tabs default", () => { + expect( + matches( + ke({ key: "∑", code: "KeyW", metaKey: true, altKey: true }), + "closeOtherTabs", + ), + ).toBe(true); + expect( + matches( + ke({ key: "w", code: "KeyW", metaKey: true, altKey: true }), + "closeOtherTabs", + ), + ).toBe(true); + }); }); describe("override + matches", () => { diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index c980bf88..fa31aae3 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -211,10 +211,39 @@ export function subscribe(cb: () => void): () => void { }; } +const CODE_PUNCTUATION: Record = { + BracketLeft: "[", + BracketRight: "]", + Minus: "-", + Equal: "=", + Slash: "/", + Backslash: "\\", + Semicolon: ";", + Quote: "'", + Comma: ",", + Period: ".", + Backquote: "`", +}; + +/** The key name for a physical key (`KeyboardEvent.code`), or null if it + * isn't one a shortcut binds. */ +function keyNameFromCode(code: string): string | null { + if (/^Key[A-Z]$/.test(code)) return code.slice(3); + if (/^Digit[0-9]$/.test(code)) return code.slice(5); + return CODE_PUNCTUATION[code] ?? null; +} + /** * Convert a KeyboardEvent → "Mod+Shift+X"-style string. * Returns null for events that aren't valid shortcuts (no modifiers, or * just a modifier). + * + * With Alt held, macOS composes the key through the Option layer before the + * event reaches us: ⌥W arrives as `key: "∑"`, ⌥] as `"‘"`, ⌥E as a dead key. + * None of those can ever equal a binding like "Mod+Alt+W", so for composed + * (non-ASCII or dead) keys the name comes from the physical key instead. + * Plain ASCII keys are left alone on purpose — AltGr layouts type real + * characters through Ctrl+Alt and must keep doing so. */ export function eventToShortcut(e: KeyboardEvent): string | null { const parts: string[] = []; @@ -224,8 +253,10 @@ export function eventToShortcut(e: KeyboardEvent): string | null { const k = e.key; if (k === "Meta" || k === "Control" || k === "Shift" || k === "Alt") return null; if (parts.length === 0) return null; + const composed = k === "Dead" || (k.length === 1 && k.charCodeAt(0) > 0x7f); + const physical = e.altKey && composed ? keyNameFromCode(e.code ?? "") : null; // Normalise letters to uppercase, keep punctuation as-is. - parts.push(k.length === 1 ? k.toUpperCase() : k); + parts.push(physical ?? (k.length === 1 ? k.toUpperCase() : k)); return parts.join("+"); }