From f75e1f72c7a20428548a47a2d92b0f60eb0fc00b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 20 Oct 2026 10:40:41 -0700 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20=E2=8C=981=E2=80=939=20and=20?= =?UTF-8?q?=E2=87=A7=E2=8C=98[=20]=20switch=20side-panel=20tabs=20(#57301)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../browser-pane-restore.spec.ts | 44 +++++++++++++++++++ .../app/src/runtime/extension/closed-tabs.ts | 32 ++++++++++++++ .../session/commands/use-session-commands.tsx | 14 +++++- .../app/src/shell/commands/command.test.ts | 5 +++ packages/app/src/shell/commands/command.tsx | 11 ++++- .../gui-extensions/src/browser/keys.test.ts | 7 +++ packages/gui-extensions/src/browser/keys.ts | 12 +++-- .../src/browser/panel.fixture.tsx | 9 ++++ 8 files changed, 128 insertions(+), 6 deletions(-) diff --git a/packages/app/component-tests/browser-pane-restore.spec.ts b/packages/app/component-tests/browser-pane-restore.spec.ts index 9adebceff13..26f16aa0d8d 100644 --- a/packages/app/component-tests/browser-pane-restore.spec.ts +++ b/packages/app/component-tests/browser-pane-restore.spec.ts @@ -92,6 +92,8 @@ story.beforeEach(async ({ mount, page }) => { newSession: () => void reopenSession: () => void reopenSide: () => void + switchSession: () => void + side: () => { keys: () => readonly string[]; active: () => string | undefined; select: (key: string) => void } | undefined shortcut: (shortcut: Accessor) => void }) => { const command = commands.useCommand() @@ -127,6 +129,13 @@ story.beforeEach(async ({ mount, page }) => { when: (event: Event) => inSide(event), onSelect: props.reopenSide, }, + { id: "tab.2", title: "", keybind: "mod+2", hidden: true, onSelect: props.switchSession }, + ...closed.sideTabShortcuts({ + keys: () => props.side()?.keys() ?? [], + active: () => props.side()?.active(), + select: (key: string) => props.side()?.select(key), + when: (event: Event) => inSide(event), + }), { id: "review.toggle", title: "Toggle review", @@ -376,6 +385,41 @@ story("reopens a closed browser tab with ⌘⇧T after the side panel and a sess await expect(opens).toHaveText("1") }) +story("selects side tabs with ⌘1–9 and ⇧⌘[ ] inside the side panel and session tabs elsewhere", async ({ page }) => { + const root = page.getByTestId("browser-region-fixture") + const mod = (await page.evaluate(() => /Mac|iPhone|iPad|iPod/.test(navigator.platform))) ? "Meta" : "Control" + const outside = root.getByRole("button", { name: "Beta", exact: true }) + const panel = page.locator("#review-panel") + const editor = page.frameLocator('iframe[title="Browser page"]').getByRole("textbox", { name: "Editor page input" }) + const selected = root.getByTestId("selected") + await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible() + await outside.click() + await root.getByRole("button", { name: "First inventory", exact: true }).click() + await expect(selected).toHaveText(/^browser:/) + + // A native page takes focus without a DOM event; the key it forwards still lands in the side panel. + await outside.click() + await editor.press(`${mod}+1`) + await expect(selected).toHaveText("file://beta.ts") + + await panel.press(`${mod}+2`) + await expect(selected).toHaveText(/^browser:/) + await panel.press(`${mod}+1`) + await expect(selected).toHaveText("file://beta.ts") + await panel.press(`${mod}+9`) + await expect(selected).toHaveText(/^browser:/) + await editor.press(`${mod}+Shift+BracketLeft`) + await expect(selected).toHaveText("file://beta.ts") + await panel.press(`${mod}+Shift+BracketRight`) + await expect(selected).toHaveText(/^browser:/) + await expect(root.getByTestId("session-switches")).toHaveText("0") + + await outside.focus() + await outside.press(`${mod}+2`) + await expect(root.getByTestId("session-switches")).toHaveText("1") + await expect(selected).toHaveText(/^browser:/) +}) + story("keeps a restored browser tab selected and undrawn until the desktop's first inventory", async ({ page }) => { const root = page.getByTestId("browser-region-fixture") const tabs = root.getByRole("tab") diff --git a/packages/app/src/runtime/extension/closed-tabs.ts b/packages/app/src/runtime/extension/closed-tabs.ts index 9d2facd7860..4e821f2c3b0 100644 --- a/packages/app/src/runtime/extension/closed-tabs.ts +++ b/packages/app/src/runtime/extension/closed-tabs.ts @@ -1,4 +1,5 @@ import { createEventListener } from "@solid-primitives/event-listener" +import { adjacentTabKey } from "@/shell/titlebar/tab-order" /** Side tabs the user closed in this window, newest last, each with how its panel brings it back. */ export function createClosedTabs(limit = 25) { @@ -31,6 +32,37 @@ export function createSideFocus() { return (event: Event) => inSide(event, last) } +/** As in a browser: inside the side panel, ⌘1–8 select that side tab, ⌘9 the last one, and ⇧⌘[ and ⇧⌘] the previous and next. */ +export function sideTabShortcuts(input: { + keys: () => readonly string[] + active: () => string | undefined + select: (key: string) => void + when: (event: KeyboardEvent) => boolean +}) { + const shortcut = (id: string, keybind: string, target: () => string | undefined) => ({ + id, + title: "", + keybind, + hidden: true, + when: input.when, + onSelect: () => { + const key = target() + + if (key) input.select(key) + }, + }) + + return [ + ...Array.from({ length: 9 }, (_, index) => + shortcut(`file.tab.${index + 1}`, `mod+${index + 1}`, () => + index === 8 ? input.keys().at(-1) : input.keys()[index], + ), + ), + shortcut("file.tab.left", "mod+shift+[", () => adjacentTabKey([...input.keys()], input.active(), -1)), + shortcut("file.tab.right", "mod+shift+]", () => adjacentTabKey([...input.keys()], input.active(), 1)), + ] +} + function inSide(event: Event, fallback: boolean) { const target = event.target diff --git a/packages/app/src/session/commands/use-session-commands.tsx b/packages/app/src/session/commands/use-session-commands.tsx index 8a420a04c3d..bde022d2398 100644 --- a/packages/app/src/session/commands/use-session-commands.tsx +++ b/packages/app/src/session/commands/use-session-commands.tsx @@ -17,7 +17,7 @@ import type { SessionModel } from "@/session/model" import type { SessionRevert } from "@/session/revert" import { useExtensionScoped } from "@/runtime/extension/commands" import type { Region } from "@/runtime/extension/panels" -import { createSideFocus } from "@/runtime/extension/closed-tabs" +import { createSideFocus, sideTabShortcuts } from "@/runtime/extension/closed-tabs" type SessionCommandSource = { identity: SessionModel["identity"] @@ -214,8 +214,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const selectAdjacentSideTab = (offset: -1 | 1) => { const key = adjacentTabKey(actions.region.keys(), actions.region.active(), offset) - if (!key) return + if (key) selectSideTab(key) + } + const selectSideTab = (key: string) => { actions.region.select(key) const panel = document.getElementById("review-panel") @@ -428,6 +430,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => { when: (event) => sideFocused(event.target), onSelect: () => selectAdjacentSideTab(1), }), + ...(actions.session.layout.view().side.opened() + ? sideTabShortcuts({ + keys: actions.region.keys, + active: actions.region.active, + select: selectSideTab, + when: inSide, + }).map(fileCommand) + : []), ].filter((v) => !!v) } diff --git a/packages/app/src/shell/commands/command.test.ts b/packages/app/src/shell/commands/command.test.ts index 8ffad058060..dc3ea3b4f2d 100644 --- a/packages/app/src/shell/commands/command.test.ts +++ b/packages/app/src/shell/commands/command.test.ts @@ -111,6 +111,11 @@ describe("command keybinds", () => { { config: "mod+alt+[", event: { key: "[", ctrlKey: true, altKey: true }, match: true }, { config: "mod+alt+]", event: { key: "]", ctrlKey: true, altKey: true }, match: true }, { config: "mod+alt+[", event: { key: "[", metaKey: true, altKey: true }, match: false }, + // ⇧[ types { on US layouts; Dvorak's slash on the physical bracket stays a slash; Russian's х falls back to it. + { config: "mod+shift+[", event: { key: "{", code: "BracketLeft", ctrlKey: true, shiftKey: true }, match: true }, + { config: "mod+shift+]", event: { key: "}", code: "Equal", ctrlKey: true, shiftKey: true }, match: true }, + { config: "mod+shift+[", event: { key: "?", code: "BracketLeft", ctrlKey: true, shiftKey: true }, match: false }, + { config: "mod+shift+[", event: { key: "Х", code: "BracketLeft", ctrlKey: true, shiftKey: true }, match: true }, // macOS Option turns L into ¬; the physical key still matches. { config: "meta+alt+l", event: { key: "¬", code: "KeyL", metaKey: true, altKey: true }, match: true }, ])("matchKeybind $config with $event.key is $match", (row) => { diff --git a/packages/app/src/shell/commands/command.tsx b/packages/app/src/shell/commands/command.tsx index 0625e73df34..93707392a65 100644 --- a/packages/app/src/shell/commands/command.tsx +++ b/packages/app/src/shell/commands/command.tsx @@ -57,7 +57,7 @@ function normalizeKey(key: string) { } export function keyFromKeyboardEvent(event: KeyboardEvent) { - const key = normalizeKey(event.key) + const key = normalizeKey(bracketKey(event)) if (!event.altKey || /^[a-z0-9]$/.test(key)) return key @@ -66,6 +66,15 @@ export function keyFromKeyboardEvent(event: KeyboardEvent) { return event.code.slice(3).toLowerCase() } +// ⇧[ types { on US layouts, so ⇧⌘[ binds as written; a layout that types no ASCII there falls back to the physical key. +function bracketKey(event: Pick) { + if (event.key === "{" || event.key === "}") return event.key === "{" ? "[" : "]" + + if (!/^[^\x00-\x7f]$/u.test(event.key) || !/^Bracket(Left|Right)$/.test(event.code)) return event.key + + return event.code === "BracketLeft" ? "[" : "]" +} + function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) { const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0) diff --git a/packages/gui-extensions/src/browser/keys.test.ts b/packages/gui-extensions/src/browser/keys.test.ts index fd1427c8807..35338ddb235 100644 --- a/packages/gui-extensions/src/browser/keys.test.ts +++ b/packages/gui-extensions/src/browser/keys.test.ts @@ -25,6 +25,11 @@ test.each([ { layout: "Dvorak", input: { key: "J", code: "KeyC" }, letter: "j" }, { layout: "Russian", input: { key: "\u0421", code: "KeyC" }, letter: "c" }, { layout: "QWERTY", input: { key: "=", code: "Equal" }, letter: "=" }, + { layout: "QWERTY", input: { key: "{", code: "BracketLeft" }, letter: "[" }, + // Dvorak types brackets on the physical minus and equal keys, and slash on the physical left bracket. + { layout: "Dvorak", input: { key: "}", code: "Equal" }, letter: "]" }, + { layout: "Dvorak", input: { key: "/", code: "BracketLeft" }, letter: "/" }, + { layout: "Russian", input: { key: "\u0445", code: "BracketLeft" }, letter: "[" }, ])("$layout $input.key on $input.code is $letter", (row) => { expect(pageLetter(row.input)).toBe(row.letter) }) @@ -52,6 +57,8 @@ test("forwards browser and shell shortcuts while leaving page editing, find, rel ["mod+shift", "P", true, "p"], ["mod+shift", "L", true, "l"], ["mod+shift", "E", true, "e"], + ["mod+shift", "{", true, "[", "BracketLeft"], + ["mod+shift", "}", false, "]", "BracketRight"], ["mod+alt", "ArrowLeft", true, "ArrowLeft"], ["mod+alt", "ArrowRight", false, "ArrowRight"], ["ctrl", "Tab", true, "Tab"], diff --git a/packages/gui-extensions/src/browser/keys.ts b/packages/gui-extensions/src/browser/keys.ts index 555b839690a..572c8be9473 100644 --- a/packages/gui-extensions/src/browser/keys.ts +++ b/packages/gui-extensions/src/browser/keys.ts @@ -15,9 +15,9 @@ export type PageShortcut = { const shellKeys = ["t", "w", "n", "1", "2", "3", "4", "5", "6", "7", "8", "9", ",", "\\"] // Shifted chords with the platform's modifier that belong to the app shell rather than the page: reopening a tab, -// toggling the side panel, opening the command palette, or running session commands. New Window and Close Window +// switching side-panel tabs, toggling the side panel, opening the command palette, or running session commands. New Window and Close Window // (⇧⌘N, ⇧⌘W) stay unclaimed: only their menu accelerators handle them, and claiming a chord also blocks the menu. -const shellShiftKeys = ["t", "r", "p", "l", "e", "o", "s"] +const shellShiftKeys = ["t", "[", "]", "r", "p", "l", "e", "o", "s"] /** * The letter a page key press names, as the app's keybinds read it: the layout's character, so Dvorak's C is "c" @@ -26,6 +26,12 @@ const shellShiftKeys = ["t", "r", "p", "l", "e", "o", "s"] export function pageLetter(input: Pick) { const key = input.key.toLowerCase() + // ⇧[ types { on US layouts; a layout that types no ASCII on the bracket keys falls back to them. + if (key === "{" || key === "}") return key === "{" ? "[" : "]" + + if (/^[^\x00-\x7f]$/u.test(key) && /^Bracket(Left|Right)$/.test(input.code)) + return input.code === "BracketLeft" ? "[" : "]" + if (/^[a-z]$/.test(key) || !/^Key[A-Z]$/.test(input.code)) return key return input.code.slice(3).toLowerCase() @@ -52,7 +58,7 @@ export function pageShortcut(input: PageKey, mac: boolean): PageShortcut | undef if (!matched) return undefined return { - key: /^[a-z]$/.test(key) ? key : input.key, + key: /^[a-z[\]]$/.test(key) ? key : input.key, code: input.code, ctrl: input.control, meta: input.meta, diff --git a/packages/gui-extensions/src/browser/panel.fixture.tsx b/packages/gui-extensions/src/browser/panel.fixture.tsx index 7b3e626fc80..43ae9f51943 100644 --- a/packages/gui-extensions/src/browser/panel.fixture.tsx +++ b/packages/gui-extensions/src/browser/panel.fixture.tsx @@ -599,6 +599,7 @@ type RegionHost = { active(): string | undefined toggle(): void entry(key: string): { readonly tab: PanelTab; readonly extension: string; readonly provider: Panel } | undefined + select(key: string): void close(key: string): void reopen(): void } @@ -613,6 +614,9 @@ type RegionHost = { newSession: () => void reopenSession: () => void reopenSide: () => void + switchSession: () => void + /** The side region, for its tab shortcuts. */ + side: () => { keys: () => readonly string[]; active: () => string | undefined; select: (key: string) => void } | undefined /** The shortcut the browser's + menu item shows, as the host formats it; empty when it shows none. */ shortcut: (shortcut: Accessor) => void }> @@ -638,6 +642,7 @@ type RegionFixtureState = { /** How many browser tabs the pane asked main to open. */ opens: number reopens: number + switches: number /** The page main reports, blank or loaded. */ blank: boolean /** Native browser tab close requests, by tab id. */ @@ -674,6 +679,7 @@ export function mountBrowserRegion(input: RegionHost) { sessions: 0, opens: 0, reopens: 0, + switches: 0, blank: false, closes: [], inspects: [], @@ -1051,6 +1057,8 @@ export function mountBrowserRegion(input: RegionHost) { newSession={() => setStore("sessions", (count) => count + 1)} reopenSession={() => setStore("reopens", (count) => count + 1)} reopenSide={() => sideRegion()?.reopen()} + switchSession={() => setStore("switches", (count) => count + 1)} + side={sideRegion} shortcut={(next) => setShortcut(() => next)} />

Restored side strip

@@ -1091,6 +1099,7 @@ export function mountBrowserRegion(input: RegionHost) {

{store.sessions}

{store.opens}

{store.reopens}

+

{store.switches}

{shortcut()() || "none"}

{JSON.stringify(store.closes)}

{JSON.stringify(store.inspects)}