/
Alex_Ural
/
opencode
Обзор
Документация
Войти
/
Alex_Ural
/
opencode
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
dev
packages/app/src/components/settings-keybinds.tsx
553 строки
17 KB
Aarav Sareen
feat(app): cmd+k menu shows sessions on home page (#36686)
16 июл 2026, 12:51
Не верифицирован
16 июл 2026, 12:51
958e08e
Код
Авторство
О чём код?
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { showToast } from "@/utils/toast" import fuzzysort from "fuzzysort" import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { SettingsList } from "./settings-list" import { SettingsListV2 } from "./settings-v2/parts/list" const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon }))) const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const PALETTE_ID = "command.palette" type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt" type KeybindMeta = { title: string group: KeybindGroup } type KeybindMap = Record<string, string | undefined> type CommandContext = ReturnType<typeof useCommand> const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"] type GroupKey = | "settings.shortcuts.group.general" | "settings.shortcuts.group.session" | "settings.shortcuts.group.navigation" | "settings.shortcuts.group.modelAndAgent" | "settings.shortcuts.group.terminal" | "settings.shortcuts.group.prompt" const groupKey: Record<KeybindGroup, GroupKey> = { General: "settings.shortcuts.group.general", Session: "settings.shortcuts.group.session", Navigation: "settings.shortcuts.group.navigation", "Model and agent": "settings.shortcuts.group.modelAndAgent", Terminal: "settings.shortcuts.group.terminal", Prompt: "settings.shortcuts.group.prompt", } function groupFor(id: string): KeybindGroup { if (id === PALETTE_ID) return "General" if (id.startsWith("terminal.")) return "Terminal" if (id.startsWith("model.") || id.startsWith("agent.") || id.startsWith("mcp.")) return "Model and agent" if (id.startsWith("file.") || id.startsWith("fileTree.")) return "Navigation" if (id.startsWith("prompt.")) return "Prompt" if ( id.startsWith("session.") || id.startsWith("message.") || id.startsWith("permissions.") || id.startsWith("steps.") || id.startsWith("review.") ) return "Session" return "General" } function isModifier(key: string) { return key === "Shift" || key === "Control" || key === "Alt" || key === "Meta" } function normalizeKey(key: string) { if (key === ",") return "comma" if (key === "+") return "plus" if (key === " ") return "space" return key.toLowerCase() } function recordKeybind(event: KeyboardEvent) { if (isModifier(event.key)) return const parts: string[] = [] const mod = IS_MAC ? event.metaKey : event.ctrlKey if (mod) parts.push("mod") if (IS_MAC && event.ctrlKey) parts.push("ctrl") if (!IS_MAC && event.metaKey) parts.push("meta") if (event.altKey) parts.push("alt") if (event.shiftKey) parts.push("shift") const key = normalizeKey(event.key) if (!key) return parts.push(key) return parts.join("+") } function signatures(config: string | undefined) { if (!config) return [] const sigs: string[] = [] for (const kb of parseKeybind(config)) { const parts: string[] = [] if (kb.ctrl) parts.push("ctrl") if (kb.alt) parts.push("alt") if (kb.shift) parts.push("shift") if (kb.meta) parts.push("meta") if (kb.key) parts.push(kb.key) if (parts.length === 0) continue sigs.push(parts.join("+")) } return sigs } function keybinds(value: unknown): KeybindMap { if (!value || typeof value !== "object" || Array.isArray(value)) return {} return value as KeybindMap } function listFor(command: CommandContext, map: KeybindMap, palette: string) { const out = new Map<string, KeybindMeta>() out.set(PALETTE_ID, { title: palette, group: "General" }) for (const opt of command.catalog) { if (opt.id.startsWith("suggested.")) continue if (opt.hidden) continue out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) } for (const opt of command.options) { if (opt.id.startsWith("suggested.")) continue if (opt.hidden) continue out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) } for (const [id, value] of Object.entries(map)) { if (typeof value !== "string") continue if (out.has(id)) continue out.set(id, { title: id, group: groupFor(id) }) } return out } function groupedFor(list: Map<string, KeybindMeta>) { const out = new Map<KeybindGroup, string[]>() for (const group of GROUPS) out.set(group, []) for (const [id, item] of list) { const ids = out.get(item.group) if (!ids) continue ids.push(id) } for (const group of GROUPS) { const ids = out.get(group) if (!ids) continue ids.sort((a, b) => (list.get(a)?.title ?? "").localeCompare(list.get(b)?.title ?? "")) } return out } function filteredFor( query: string, list: Map<string, KeybindMeta>, grouped: Map<KeybindGroup, string[]>, keybind: (id: string) => string, ) { const value = query.toLowerCase().trim() if (!value) return grouped const out = new Map<KeybindGroup, string[]>() for (const group of GROUPS) out.set(group, []) const items = Array.from(list.entries()).map(([id, meta]) => ({ id, title: meta.title, group: meta.group, keybind: keybind(id), })) const results = fuzzysort.go(value, items, { keys: ["title", "keybind"], threshold: -10000, }) for (const result of results) { const ids = out.get(result.obj.group) if (!ids) continue ids.push(result.obj.id) } return out } function useKeyCapture(input: { active: () => string | null stop: () => void set: (id: string, keybind: string) => void used: () => Map<string, { id: string; title: string }[]> language: ReturnType<typeof useLanguage> }) { onMount(() => { const handle = (event: KeyboardEvent) => { const id = input.active() if (!id) return event.preventDefault() event.stopPropagation() event.stopImmediatePropagation() if (event.key === "Escape") { input.stop() return } const clear = (event.key === "Backspace" || event.key === "Delete") && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey if (clear) { input.set(id, "none") input.stop() return } const next = recordKeybind(event) if (!next) return const conflicts = new Map<string, string>() for (const sig of signatures(next)) { for (const item of input.used().get(sig) ?? []) { if (item.id === id) continue conflicts.set(item.id, item.title) } } if (conflicts.size > 0) { showToast({ title: input.language.t("settings.shortcuts.conflict.title"), description: input.language.t("settings.shortcuts.conflict.description", { keybind: formatKeybind(next, input.language.t), titles: [...conflicts.values()].join(", "), }), }) return } input.set(id, next) input.stop() } makeEventListener(document, "keydown", handle, { capture: true }) }) } export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { const command = useCommand() const language = useLanguage() const settings = useSettings() const [store, setStore] = createStore({ active: null as string | null, filter: "", }) const stop = () => { if (!store.active) return setStore("active", null) command.keybinds(true) } const start = (id: string) => { if (store.active === id) { stop() return } if (store.active) stop() setStore("active", id) command.keybinds(false) } const map = createMemo(() => keybinds(settings.current.keybinds)) const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string")) const resetAll = () => { stop() settings.keybinds.resetAll() showToast({ title: language.t("settings.shortcuts.reset.toast.title"), description: language.t("settings.shortcuts.reset.toast.description"), }) } const list = createMemo(() => { language.locale() return listFor(command, map(), language.t("command.palette")) }) const title = (id: string) => list().get(id)?.title ?? "" const grouped = createMemo(() => groupedFor(list())) const filtered = createMemo(() => { return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "") }) const hasResults = createMemo(() => { for (const group of GROUPS) { const ids = filtered().get(group) ?? [] if (ids.length > 0) return true } return false }) const used = createMemo(() => { const map = new Map<string, { id: string; title: string }[]>() const add = (key: string, value: { id: string; title: string }) => { const list = map.get(key) if (!list) { map.set(key, [value]) return } list.push(value) } const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND for (const sig of signatures(palette)) { add(sig, { id: PALETTE_ID, title: title(PALETTE_ID) }) } const valueFor = (id: string) => { const custom = settings.keybinds.get(id) if (typeof custom === "string") return custom const live = command.options.find((x) => x.id === id) if (live?.keybind) return live.keybind const meta = command.catalog.find((x) => x.id === id) return meta?.keybind } for (const id of list().keys()) { if (id === PALETTE_ID) continue for (const sig of signatures(valueFor(id))) { add(sig, { id, title: title(id) }) } } return map }) const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind) useKeyCapture({ active: () => store.active, stop, set: setKeybind, used, language, }) onCleanup(() => { if (store.active) command.keybinds(true) }) const emptyResults = ( <Show when={store.filter && !hasResults()}> <div classList={{ "flex flex-col items-center justify-center py-12 text-center": !props.v2, "settings-v2-shortcuts-status": props.v2, }} > <span classList={{ "text-14-regular text-text-weak": !props.v2, }} > {language.t("settings.shortcuts.search.empty")} </span> <Show when={store.filter}> <span classList={{ "text-14-regular text-text-strong mt-1": !props.v2, "settings-v2-shortcuts-status-filter": props.v2, }} > "{store.filter}" </span> </Show> </div> </Show> ) const List = props.v2 ? SettingsListV2 : SettingsList const groups = ( <div classList={{ "settings-v2-shortcuts flex flex-col gap-8": props.v2, "flex flex-col gap-8 max-w-[720px]": !props.v2, }} > <For each={GROUPS}> {(group) => ( <Show when={(filtered().get(group) ?? []).length > 0}> <div classList={{ "settings-v2-section": props.v2, "flex flex-col gap-1": !props.v2, }} > <h3 classList={{ "settings-v2-section-title": props.v2, "text-14-medium text-text-strong pb-2": !props.v2, }} > {language.t(groupKey[group])} </h3> <List> <For each={filtered().get(group) ?? []}> {(id) => ( <div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none"> <span classList={{ "text-14-regular text-text-strong": !props.v2, }} > {title(id)} </span> <button type="button" data-keybind-id={id} classList={{ "settings-v2-keybind-button": props.v2, "settings-v2-keybind-button--active": props.v2 && store.active === id, "h-8 px-3 rounded-md text-12-regular": !props.v2, "bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active": !props.v2 && store.active !== id, "border border-border-weak-base bg-surface-inset-base text-text-weak": !props.v2 && store.active === id, }} onClick={() => start(id)} > <Show when={store.active === id} fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")} > {language.t("settings.shortcuts.pressKeys")} </Show> </button> </div> )} </For> </List> </div> </Show> )} </For> {emptyResults} </div> ) return ( <Show when={props.v2} fallback={ <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]"> <div class="flex items-center justify-between gap-4"> <h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2> <Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}> {language.t("settings.shortcuts.reset.button")} </Button> </div> <div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base"> <Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" /> <TextField variant="ghost" type="text" value={store.filter} onChange={(v) => setStore("filter", v)} placeholder={language.t("settings.shortcuts.search.placeholder")} spellcheck={false} autocorrect="off" autocomplete="off" autocapitalize="off" class="flex-1" /> <Show when={store.filter}> <IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} /> </Show> </div> </div> </div> {groups} </div> } > <> <div class="settings-v2-tab-header settings-v2-tab-header--stacked"> <div class="settings-v2-tab-header-row"> <h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2> <ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}> {language.t("settings.shortcuts.reset.button")} </ButtonV2> </div> <div class="settings-v2-tab-search"> <TextInputV2 type="search" appearance="base" value={store.filter} onInput={(event) => setStore("filter", event.currentTarget.value)} placeholder={language.t("settings.shortcuts.search.placeholder")} spellcheck={false} autocorrect="off" autocomplete="off" autocapitalize="off" aria-label={language.t("settings.shortcuts.search.placeholder")} /> <Show when={store.filter}> <IconButtonV2 type="button" variant="ghost-muted" size="small" class="settings-v2-tab-search-clear" icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />} onClick={() => setStore("filter", "")} /> </Show> </div> </div> <div class="settings-v2-tab-body">{groups}</div> </> </Show> ) }