Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
63 changes: 63 additions & 0 deletions packages/app/component-tests/command-keybinds.fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Relative command import is deliberate: Storybook 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

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: "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)
},
send(input: KeyboardEventInit) {
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...input })
element.dispatchEvent(event)

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

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

story(
"non-Latin fallback dispatches commands and palette while exact custom bindings take 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.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 }))

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

expect(result.selected).toEqual(["find", "custom", "custom", "palette", "palette"])
expect(result.prevented).toEqual([true, true, true, true, true])
},
)
38 changes: 37 additions & 1 deletion packages/app/src/shell/commands/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,44 @@ describe("command keybinds", () => {
{ config: "mod+alt+[", event: { key: "[", metaKey: true, altKey: true }, match: false },
// 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: "а", 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+f", event: { key: "Dead", code: "KeyF", ctrlKey: true }, match: false },
{ config: "ctrl+f", event: { key: "Unidentified", code: "KeyF", ctrlKey: true }, match: false },
{ config: "ctrl+alt+[", event: { key: "х", code: "BracketLeft", ctrlKey: true, altKey: true }, match: false },
{
config: "ctrl+[",
event: { key: "х", code: "BracketLeft", 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 does not implement modifierAltGraph.
if ("modifierAltGraph" in row.event) event.getModifierState = (key) => key === "AltGraph"
expect(matchKeybind(parseKeybind(row.config), event)).toBe(row.match)
})

// IS_MAC is fixed when the module loads, so each assertion accepts either platform's symbols.
Expand Down
79 changes: 62 additions & 17 deletions packages/app/src/shell/commands/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { keybindRenames } from "@/settings/keybinds/migration"
import en from "@/runtime/i18n/en"
import { Persist, persisted } from "@/runtime/persistence/storage"

// oxlint-disable-next-line anti-slop/no-runtime-typeof -- navigator is absent during server-side rendering.
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)

const PALETTE_ID = "command.palette"
Expand Down Expand Up @@ -66,14 +67,50 @@ export function keyFromKeyboardEvent(event: KeyboardEvent) {
return event.code.slice(3).toLowerCase()
}

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

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

// Keep literal bindings first, then try the physical position on non-Latin layouts.
if (/^Key[A-Z]$/.test(event.code)) return [key, 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 ? [key, physical] : [key]
}

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)

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),
)
}

export type KeybindConfig = string
Expand Down Expand Up @@ -232,10 +269,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 All @@ -262,6 +299,7 @@ function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) {

if (!kb.key) return parts

// oxlint-disable-next-line anti-slop/no-known-value-widening -- Parsed keybind strings index this display dictionary.
const keys: Record<string, string> = {
arrowup: "↑",
arrowdown: "↓",
Expand All @@ -271,6 +309,7 @@ function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) {
plus: "+",
}

// oxlint-disable-next-line anti-slop/no-known-value-widening -- Parsed keybind strings index this display dictionary.
const named: Record<string, KeyLabel> = {
backspace: "common.key.backspace",
delete: "common.key.delete",
Expand Down Expand Up @@ -335,6 +374,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const settings = useSettings()
const language = useLanguage()

// SAFETY: register populates this initially empty list exclusively with CommandRegistration entries.
const [store, setStore] = createStore({
registrations: [] as CommandRegistration[],
suspendCount: 0,
Expand Down Expand Up @@ -488,26 +528,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) {
if (isPalette) {
event.preventDefault()
event.stopPropagation()
showPalette()

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 All @@ -517,7 +560,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
function register(cb: () => CommandOption[]): void
function register(key: string, cb: () => CommandOption[]): void
function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- Discriminates the public register overloads.
const id = typeof key === "string" ? key : undefined
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- Discriminates the public register overloads.
const next = typeof key === "function" ? key : cb

if (!next) return
Expand Down
Loading