Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions packages/app/component-tests/command-keybinds.fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Relative command import is deliberate: the stories harness aliases @/shell/commands/command to a no-op.
import { DialogProvider } from "@opencode/ui/context/dialog"
import { render } from "solid-js/web"
import { PlatformProvider } from "../src/runtime/platform/platform"
import { LanguageProvider } from "../src/runtime/i18n/language"
import { SettingsProvider, useSettings } from "../src/settings/model"
import { CommandProvider, useCommand } from "../src/shell/commands/command"

export async function mountCommands() {
const element = document.createElement("div")
document.body.append(element)
const selected: string[] = []
let settings: ReturnType<typeof useSettings> | undefined
let contextual = false

function Probe() {
const commands = useCommand()
settings = useSettings()
settings.keybinds.set("command.palette", "ctrl+k")
commands.register("layout-fixture", () => [
{ id: "find", title: "Find", keybind: "ctrl+f", onSelect: () => void selected.push("find") },
{ id: "bracket", title: "Bracket", keybind: "ctrl+[", onSelect: () => void selected.push("bracket") },
{
id: "alt-bracket",
title: "Alt bracket",
keybind: "meta+alt+[",
onSelect: () => void selected.push("alt-bracket"),
},
{
id: "ctrl-alt-bracket",
title: "Ctrl Alt bracket",
keybind: "ctrl+alt+[",
onSelect: () => void selected.push("ctrl-alt-bracket"),
},
{
id: "context",
title: "Context",
keybind: "ctrl+f",
when: () => contextual,
onSelect: () => void selected.push("context"),
},
{ id: "custom", title: "Custom", keybind: "none", onSelect: () => void selected.push("custom") },
{ id: "command.palette", title: "Palette", onSelect: () => void selected.push("palette") },
])

return null
}

const dispose = render(
() => (
<PlatformProvider value={{ platform: "web", openExternal() {}, restart: async () => {}, notify: async () => {} }}>
<LanguageProvider locale="en">
<SettingsProvider>
<DialogProvider>
<CommandProvider>
<Probe />
</CommandProvider>
</DialogProvider>
</SettingsProvider>
</LanguageProvider>
</PlatformProvider>
),
element,
)

await Promise.resolve()

return {
selected,
set(action: string, bind: string) {
settings!.keybinds.set(action, bind)
},
context(active: boolean) {
contextual = active
},
send(input: KeyboardEventInit) {
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...input })
element.dispatchEvent(event)

return event.defaultPrevented
},
dispose() {
dispose()
element.remove()
},
}
}
82 changes: 82 additions & 0 deletions packages/app/component-tests/command-keybinds.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Uses the real CommandProvider, global document listener and Settings keybind overrides.
import { expect, sourceURL, story } from "../../stories/playwright/story"

const fixture = sourceURL(new URL("./command-keybinds.fixture.tsx", import.meta.url))

story(
"non-Latin fallback respects command context, exact custom bindings and palette priority",
async ({ mount, page }) => {
await mount("ui-line-comment--editor")

const result = await page.evaluate(async (fixture) => {
const { mountCommands } = await import(fixture)
const host = await mountCommands()

try {
const prevented = [host.send({ key: "а", code: "KeyF", ctrlKey: true })]
host.context(true)
prevented.push(host.send({ key: "а", code: "KeyF", ctrlKey: true }))
host.context(false)
prevented.push(host.send({ key: "а", code: "KeyF", ctrlKey: true }))
host.set("custom", "ctrl+а")
prevented.push(host.send({ key: "а", code: "KeyF", ctrlKey: true }))
host.set("custom", "ctrl+л")
prevented.push(host.send({ key: "л", code: "KeyK", ctrlKey: true }))
host.set("custom", "none")
prevented.push(host.send({ key: "л", code: "KeyK", ctrlKey: true }))
host.set("custom", "ctrl+k")
host.set("command.palette", "ctrl+л")
prevented.push(host.send({ key: "л", code: "KeyK", ctrlKey: true }))
host.set("custom", "ctrl+х")
prevented.push(host.send({ key: "х", code: "BracketLeft", ctrlKey: true }))
host.set("custom", "none")
prevented.push(host.send({ key: "х", code: "BracketLeft", ctrlKey: true }))
prevented.push(host.send({ key: "х", code: "BracketLeft", ctrlKey: true, isComposing: true }))
prevented.push(host.send({ key: "“", code: "BracketLeft", metaKey: true, altKey: true }))
prevented.push(host.send({ key: "х", code: "BracketLeft", ctrlKey: true, altKey: true }))
prevented.push(
host.send({ key: "х", code: "BracketLeft", ctrlKey: true, altKey: true, modifierAltGraph: true }),
)
prevented.push(host.send({ key: "а", code: "KeyF", ctrlKey: true, isComposing: true }))
prevented.push(host.send({ key: "а", code: "KeyF", ctrlKey: true, modifierAltGraph: true }))

return { selected: host.selected, prevented }
} finally {
host.dispose()
}
}, fixture)

expect(result.selected).toEqual([
"find",
"context",
"find",
"custom",
"custom",
"palette",
"palette",
"custom",
"bracket",
"bracket",
"alt-bracket",
"ctrl-alt-bracket",
"ctrl-alt-bracket",
])
expect(result.prevented).toEqual([
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
false,
])
},
)
87 changes: 86 additions & 1 deletion packages/app/src/shell/commands/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
CommandCatalog,
commandKeymap,
formatKeybind,
keyFromKeyboardEvent,
matchKeybind,
parseKeybind,
resolveKeybindOption,
Expand Down Expand Up @@ -118,8 +119,92 @@ describe("command keybinds", () => {
{ 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 },
{ config: "ctrl+f", event: { key: "а", code: "KeyF", ctrlKey: true }, match: true },
{ config: "meta+f", event: { key: "а", code: "KeyF", metaKey: true }, match: true },
{ config: "ctrl+shift+f", event: { key: "А", code: "KeyF", ctrlKey: true, shiftKey: true }, match: true },
{ config: "ctrl+g", event: { key: "γ", code: "KeyG", ctrlKey: true }, match: true },
{ config: "ctrl+e", event: { key: "ק", code: "KeyE", ctrlKey: true }, match: true },
{ config: "ctrl+comma", event: { key: "б", code: "Comma", ctrlKey: true }, match: true },
{ config: "ctrl+.", event: { key: "ю", code: "Period", ctrlKey: true }, match: true },
{ config: "ctrl+;", event: { key: "ж", code: "Semicolon", ctrlKey: true }, match: true },
{ config: "ctrl+'", event: { key: "э", code: "Quote", ctrlKey: true }, match: true },
{ config: "ctrl+[", event: { key: "х", code: "BracketLeft", ctrlKey: true }, match: true },
{ config: "ctrl+]", event: { key: "ъ", code: "BracketRight", ctrlKey: true }, match: true },
{ config: "ctrl+\\", event: { key: "む", code: "Backslash", ctrlKey: true }, match: true },
{ config: "ctrl+/", event: { key: "め", code: "Slash", ctrlKey: true }, match: true },
{ config: "ctrl+`", event: { key: "ё", code: "Backquote", ctrlKey: true }, match: true },
{ config: "ctrl+а", event: { key: "а", code: "KeyF", ctrlKey: true }, match: true },
{ config: "ctrl+х", event: { key: "х", code: "BracketLeft", ctrlKey: true }, match: true },
{ config: "а", event: { key: "а", code: "KeyF" }, match: true },
{ config: "f", event: { key: "а", code: "KeyF" }, match: false },
{ config: "shift+f", event: { key: "А", code: "KeyF", shiftKey: true }, match: false },
{ config: "ctrl+a", event: { key: "a", code: "KeyQ", ctrlKey: true }, match: true },
{ config: "ctrl+q", event: { key: "a", code: "KeyQ", ctrlKey: true }, match: false },
{ config: "ctrl+e", event: { key: "é", code: "KeyE", ctrlKey: true }, match: false },
{ config: "ctrl+f", event: { key: "а", code: "KeyF", metaKey: true }, match: false },
{ config: "ctrl+f", event: { key: "а", code: "KeyF", ctrlKey: true, shiftKey: true }, match: false },
{ config: "ctrl+f", event: { key: "а", code: "KeyF", ctrlKey: true, isComposing: true }, match: false },
{ config: "ctrl+[", event: { key: "х", code: "BracketLeft", ctrlKey: true, isComposing: true }, match: true },
{ config: "ctrl+f", event: { key: "Dead", code: "KeyF", ctrlKey: true }, match: false },
{ config: "ctrl+f", event: { key: "Unidentified", code: "KeyF", ctrlKey: true }, match: false },
// Preserve the bracket fallback that already supports macOS Option and explicit Ctrl+Alt bindings.
{ config: "meta+alt+[", event: { key: "“", code: "BracketLeft", metaKey: true, altKey: true }, match: true },
{ config: "meta+alt+]", event: { key: "‘", code: "BracketRight", metaKey: true, altKey: true }, match: true },
{ config: "ctrl+alt+[", event: { key: "х", code: "BracketLeft", ctrlKey: true, altKey: true }, match: true },
{
config: "ctrl+[",
event: { key: "х", code: "BracketLeft", ctrlKey: true, modifierAltGraph: true },
match: true,
},
// Guard only the new fallback; existing Alt/bracket normalization still applies.
{
config: "alt+f",
event: { key: "а", code: "KeyF", altKey: true, modifierAltGraph: true },
match: true,
},
{
config: "ctrl+alt+f",
event: { key: "а", code: "KeyF", ctrlKey: true, altKey: true, modifierAltGraph: true },
match: true,
},
{
config: "meta+alt+[",
event: { key: "“", code: "BracketLeft", metaKey: true, altKey: true, modifierAltGraph: true },
match: true,
},
{
config: "ctrl+alt+[",
event: { key: "х", code: "BracketLeft", ctrlKey: true, altKey: true, modifierAltGraph: true },
match: true,
},
{
config: "meta+alt+l",
event: { key: "¬", code: "KeyL", metaKey: true, altKey: true, isComposing: true },
match: true,
},
{
config: "ctrl+f",
event: { key: "а", code: "KeyF", ctrlKey: true, modifierAltGraph: true },
match: false,
},
])("matchKeybind $config with $event.key is $match", (row) => {
expect(matchKeybind(parseKeybind(row.config), new KeyboardEvent("keydown", row.event))).toBe(row.match)
const event = new KeyboardEvent("keydown", row.event)

// happy-dom treats every Alt key as AltGraph and ignores modifierAltGraph.
const getModifierState = event.getModifierState.bind(event)
event.getModifierState = (key) =>
key === "AltGraph"
? "modifierAltGraph" in row.event && row.event.modifierAltGraph === true
: getModifierState(key)
expect(matchKeybind(parseKeybind(row.config), event)).toBe(row.match)
})

test.each([
{ event: { key: "х", code: "BracketLeft", ctrlKey: true }, key: "[" },
{ event: { key: "“", code: "BracketLeft", metaKey: true, altKey: true }, key: "[" },
{ event: { key: "‘", code: "BracketRight", metaKey: true, altKey: true }, key: "]" },
])("shortcut recording retains the upstream bracket key for $event.key", (row) => {
expect(keyFromKeyboardEvent(new KeyboardEvent("keydown", row.event))).toBe(row.key)
})

// IS_MAC is fixed when the module loads, so each assertion accepts either platform's symbols.
Expand Down
71 changes: 54 additions & 17 deletions packages/app/src/shell/commands/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,38 @@ export function keyFromKeyboardEvent(event: KeyboardEvent) {
return event.code.slice(3).toLowerCase()
}

function keybindKeysFromKeyboardEvent(event: KeyboardEvent) {
const key = keyFromKeyboardEvent(event)

if (event.isComposing || event.getModifierState("AltGraph")) return [key]

if ((!event.ctrlKey && !event.metaKey) || event.altKey || !/^[^\p{ASCII}\p{Script=Latin}]$/u.test(event.key))
return [key]

// Keep literal bindings first, then try the physical position on non-Latin layouts.
const literal = normalizeKey(event.key)

if (/^Key[A-Z]$/.test(event.code)) return [literal, event.code.slice(3).toLowerCase()]

const punctuation = new Map([
["Comma", "comma"],
["Period", "."],
["Semicolon", ";"],
["Quote", "'"],
["BracketLeft", "["],
["BracketRight", "]"],
["Backslash", "\\"],
["Slash", "/"],
["Backquote", "`"],
["Minus", "-"],
["Equal", "="],
])

const physical = punctuation.get(event.code)

return physical ? [literal, physical] : [key]
}

// ⇧[ 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 === "{" ? "[" : "]"
Expand All @@ -84,8 +116,10 @@ function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, al
return `${key}:${mask}`
}

function signatureFromEvent(event: KeyboardEvent) {
return signature(keyFromKeyboardEvent(event), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
function signaturesFromEvent(event: KeyboardEvent) {
return keybindKeysFromKeyboardEvent(event).map((key) =>
signature(key, event.ctrlKey, event.metaKey, event.shiftKey, event.altKey),
)
}

type KeybindConfig = string
Expand Down Expand Up @@ -276,10 +310,10 @@ export function parseKeybind(config: string): Keybind[] {
}

export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
const eventKey = keyFromKeyboardEvent(event)
const eventKeys = keybindKeysFromKeyboardEvent(event)

for (const kb of keybinds) {
const keyMatch = kb.key === eventKey
const keyMatch = eventKeys.includes(kb.key)
const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
const metaMatch = kb.meta === (event.metaKey || false)
const shiftMatch = kb.shift === (event.shiftKey || false)
Expand Down Expand Up @@ -504,26 +538,29 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const handleKeyDown = (event: KeyboardEvent) => {
if (suspended() || dialog.active) return

const sig = signatureFromEvent(event)
const isPalette = palette().has(sig)
const option = resolveKeybindOption(keymap().get(sig), event)
const modified = event.ctrlKey || event.metaKey || event.altKey
const isTab = event.key === "Tab"
for (const sig of signaturesFromEvent(event)) {
const isPalette = palette().has(sig)
const option = resolveKeybindOption(keymap().get(sig), event)
const modified = event.ctrlKey || event.metaKey || event.altKey
const isTab = event.key === "Tab"

if (isEditableTarget(event.target) && !isPalette && !option?.editable && !modified && !isTab) return

if (isEditableTarget(event.target) && !isPalette && !option?.editable && !modified && !isTab) return
if (isPalette) {
event.preventDefault()
event.stopPropagation()
showPalette()

if (isPalette) {
return
}

if (!option) continue
event.preventDefault()
event.stopPropagation()
showPalette()
void option.onSelect?.("keybind")

return
}

if (!option) return
event.preventDefault()
event.stopPropagation()
void option.onSelect?.("keybind")
}

onMount(() => {
Expand Down
Loading