Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
97 changes: 97 additions & 0 deletions packages/app/component-tests/command-keybinds.fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// 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: "decline",
title: "Decline",
keybind: "none",
onSelect: () => {
selected.push("decline")

return false
},
},
{ 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()
},
}
}
105 changes: 105 additions & 0 deletions packages/app/component-tests/command-keybinds.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// 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,
])
},
)

story("a declining literal or physical command leaves the key to its target", 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 {
host.set("decline", "ctrl+а")
const literal = host.send({ key: "а", code: "KeyF", ctrlKey: true })
host.set("decline", "ctrl+f")
const physical = host.send({ key: "а", code: "KeyF", ctrlKey: true })

return { selected: host.selected, prevented: [literal, physical] }
} finally {
host.dispose()
}
}, fixture)

expect(result.selected).toEqual(["decline", "decline"])
expect(result.prevented).toEqual([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 @@ -120,8 +121,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
Loading
Loading