| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554 |
- 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 "@kirincode-ai/ui/button"
- import { Icon } from "@kirincode-ai/ui/icon"
- import { IconButton } from "@kirincode-ai/ui/icon-button"
- import { TextField } from "@kirincode-ai/ui/text-field"
- import { ButtonV2 } from "@kirincode-ai/ui/v2/button-v2"
- import { IconButtonV2 } from "@kirincode-ai/ui/v2/icon-button-v2"
- import { TextInputV2 } from "@kirincode-ai/ui/v2/text-input-v2"
- import { showToast } from "@/utils/toast"
- import fuzzysort from "fuzzysort"
- import { 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("@kirincode-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"
- const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
- 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>
- )
- }
|