opencode/packages/app/src/components/shortcuts-panel.tsx
2026-01-19 14:18:50 -06:00

232 lines
6.9 KiB
TypeScript

import { For, createSignal, Show, onMount, onCleanup } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { parseKeybind, formatKeybind } from "@/context/command"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const SPECIAL_CHAR_NAMES: Record<string, string> = {
"^": "Control",
"⌥": "Option",
"⇧": "Shift",
"⌘": "Command",
"↑": "Arrow Up",
"↓": "Arrow Down",
"`": "Backtick",
"'": "Quote",
".": "Period",
",": "Comma",
"/": "Slash",
"\\": "Backslash",
"[": "Left Bracket",
"]": "Right Bracket",
"-": "Minus",
"=": "Equals",
";": "Semicolon",
}
const KEY_DISPLAY_MAP: Record<string, string> = {
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
backspace: "⌫",
}
interface Shortcut {
title: string
keybind: string
}
interface ShortcutCategory {
name: string
shortcuts: Shortcut[]
}
function isLetter(char: string): boolean {
return /^[A-Za-z]$/.test(char)
}
function getKeyChars(config: string): string[] {
const keybinds = parseKeybind(config)
if (keybinds.length === 0) return []
const kb = keybinds[0]
const chars: string[] = []
if (kb.ctrl) chars.push(IS_MAC ? "^" : "Ctrl")
if (kb.alt) chars.push(IS_MAC ? "⌥" : "Alt")
if (kb.shift) chars.push(IS_MAC ? "⇧" : "Shift")
if (kb.meta) chars.push(IS_MAC ? "⌘" : "Meta")
if (kb.key) {
const mapped = KEY_DISPLAY_MAP[kb.key.toLowerCase()]
if (mapped) {
chars.push(mapped)
} else {
const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
for (const char of displayKey) {
chars.push(char)
}
}
}
return chars
}
const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
{
name: "General",
shortcuts: [
{ title: "Command palette", keybind: "mod+shift+p" },
{ title: "Toggle sidebar", keybind: "mod+b" },
{ title: "Toggle shortcuts", keybind: "ctrl+/" },
{ title: "Open file", keybind: "mod+p" },
{ title: "Open project", keybind: "mod+o" },
],
},
{
name: "Session",
shortcuts: [
{ title: "New session", keybind: "mod+shift+s" },
{ title: "Previous session", keybind: "alt+arrowup" },
{ title: "Next session", keybind: "alt+arrowdown" },
{ title: "Archive session", keybind: "mod+shift+backspace" },
{ title: "Undo", keybind: "mod+z" },
{ title: "Redo", keybind: "mod+shift+z" },
],
},
{
name: "Navigation",
shortcuts: [
{ title: "Previous message", keybind: "mod+arrowup" },
{ title: "Next message", keybind: "mod+arrowdown" },
{ title: "Toggle steps", keybind: "mod+e" },
],
},
{
name: "Model and Agent",
shortcuts: [
{ title: "Choose model", keybind: "mod+'" },
{ title: "Cycle agent", keybind: "mod+." },
],
},
{
name: "Terminal",
shortcuts: [
{ title: "Toggle terminal", keybind: "ctrl+`" },
{ title: "New terminal", keybind: "ctrl+shift+`" },
],
},
]
const USED_SHORTCUTS_KEY = "opencode:used-shortcuts"
function getUsedShortcuts(): Set<string> {
const stored = localStorage.getItem(USED_SHORTCUTS_KEY)
return stored ? new Set(JSON.parse(stored)) : new Set()
}
const [usedShortcuts, setUsedShortcuts] = createSignal(getUsedShortcuts())
function formatKeybindForCopy(config: string): string {
const chars = getKeyChars(config)
return chars.join("")
}
function ShortcutItem(props: { shortcut: Shortcut }) {
const [copied, setCopied] = createSignal(false)
const used = () => usedShortcuts().has(props.shortcut.keybind)
function copyToClipboard() {
const text = formatKeybindForCopy(props.shortcut.keybind)
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<Tooltip value="Copy to clipboard" placement="top">
<button type="button" class="shortcut-item" classList={{ "shortcut-used": used() }} onClick={copyToClipboard}>
<span class="text-14-regular text-text-base">{props.shortcut.title}</span>
<Show
when={!copied()}
fallback={
<div class="shortcut-copied">
<Icon name="check" size="small" />
</div>
}
>
<div class="shortcut-keys">
<For each={getKeyChars(props.shortcut.keybind)}>
{(char) => {
const tooltip = SPECIAL_CHAR_NAMES[char]
const isSpecial = tooltip && !isLetter(char)
const isShift = char === "⇧"
return (
<Show when={isSpecial} fallback={<kbd class="shortcut-key">{char}</kbd>}>
<Tooltip value={tooltip} placement="top">
<kbd class="shortcut-key shortcut-key-special">
<span classList={{ "shortcut-key-shift": isShift }}>{char}</span>
</kbd>
</Tooltip>
</Show>
)
}}
</For>
</div>
</Show>
</button>
</Tooltip>
)
}
export function ShortcutsPanel(props: { onClose: () => void }) {
const [activeTab, setActiveTab] = createSignal(SHORTCUT_CATEGORIES[0].name)
let listRef: HTMLDivElement | undefined
onMount(() => {
const handler = () => setUsedShortcuts(getUsedShortcuts())
window.addEventListener("shortcut-used", handler)
onCleanup(() => window.removeEventListener("shortcut-used", handler))
// Focus the active tab trigger so arrow keys work immediately
const trigger = listRef?.querySelector<HTMLButtonElement>('[data-slot="tabs-trigger"][data-selected]')
trigger?.focus()
})
return (
<div class="shortcuts-panel" data-component="shortcuts-panel">
<Tabs value={activeTab()} onChange={setActiveTab}>
<div class="shortcuts-tabs-row">
<Tabs.List ref={listRef} class="shortcuts-tabs-list">
<For each={SHORTCUT_CATEGORIES}>
{(category) => <Tabs.Trigger value={category.name}>{category.name}</Tabs.Trigger>}
</For>
</Tabs.List>
<Tooltip
placement="top"
value={
<span>
Close <span class="text-text-weak">{formatKeybind("ctrl+/")}</span>
</span>
}
>
<IconButton icon="close" variant="ghost" onClick={props.onClose} />
</Tooltip>
</div>
<For each={SHORTCUT_CATEGORIES}>
{(category) => (
<Tabs.Content value={category.name} class="shortcuts-content">
<div class="shortcuts-grid">
<For each={category.shortcuts}>{(shortcut) => <ShortcutItem shortcut={shortcut} />}</For>
</div>
</Tabs.Content>
)}
</For>
</Tabs>
</div>
)
}