mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(desktop): ⌘1–9 and ⇧⌘[ ] switch side-panel tabs (#57301)
This commit is contained in:
committed by
GitHub
Unverified
parent
1817f310ec
commit
f75e1f72c7
@@ -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<string>) => 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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<KeyboardEvent, "key" | "code">) {
|
||||
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)
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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<Electron.Input, "key" | "code">) {
|
||||
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,
|
||||
|
||||
@@ -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<string>) => 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)}
|
||||
/>
|
||||
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Restored side strip</h1>
|
||||
@@ -1091,6 +1099,7 @@ export function mountBrowserRegion(input: RegionHost) {
|
||||
<p data-testid="new-sessions">{store.sessions}</p>
|
||||
<p data-testid="browser-opens">{store.opens}</p>
|
||||
<p data-testid="session-reopens">{store.reopens}</p>
|
||||
<p data-testid="session-switches">{store.switches}</p>
|
||||
<p data-testid="add-shortcut">{shortcut()() || "none"}</p>
|
||||
<p data-testid="browser-closes">{JSON.stringify(store.closes)}</p>
|
||||
<p data-testid="browser-inspects">{JSON.stringify(store.inspects)}</p>
|
||||
|
||||
Reference in New Issue
Block a user