diff --git a/src/App.tsx b/src/App.tsx index b18f8fe..3bed343 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 e6d8a3e..60fcada 100644 --- a/src/components/TabBar.test.tsx +++ b/src/components/TabBar.test.tsx @@ -114,4 +114,48 @@ 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(); + }); + + 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 5e3a233..554f62d 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); @@ -37,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) => { @@ -175,13 +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.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 5f1dd8b..3c02090 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 223c1ed..fa31aae 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", @@ -208,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[] = []; @@ -221,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("+"); } diff --git a/src/store.test.ts b/src/store.test.ts index 5116fff..73e7080 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 f775575..4997691 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) => {