Преглед на файлове

refactor(app): extract keybind settings controller (#39226)

Brendan Allan преди 1 месец
родител
ревизия
9c8060d96d
променени са 2 файла, в които са добавени 396 реда и са изтрити 61 реда
  1. 289 61
      packages/app/src/components/settings-keybinds.tsx
  2. 107 0
      packages/app/test-browser/settings-keybinds.test.ts

+ 289 - 61
packages/app/src/components/settings-keybinds.tsx

@@ -30,6 +30,8 @@ type KeybindMeta = {
 
 type KeybindMap = Record<string, string | undefined>
 type CommandContext = ReturnType<typeof useCommand>
+type LanguageContext = ReturnType<typeof useLanguage>
+type SettingsContext = ReturnType<typeof useSettings>
 
 const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
 
@@ -122,7 +124,7 @@ function keybinds(value: unknown): KeybindMap {
   return value as KeybindMap
 }
 
-function listFor(command: CommandContext, map: KeybindMap, palette: string) {
+function listFor(command: Pick<CommandContext, "catalog" | "options">, map: KeybindMap, palette: string) {
   const out = new Map<string, KeybindMeta>()
   out.set(PALETTE_ID, { title: palette, group: "General" })
 
@@ -262,7 +264,274 @@ function useKeyCapture(input: {
   })
 }
 
+export function createKeybindSettingsController(
+  input: {
+    command: Pick<CommandContext, "catalog" | "options" | "keybinds">
+    settings: {
+      current: { keybinds: unknown }
+      keybinds: Pick<SettingsContext["keybinds"], "get" | "set" | "resetAll">
+    }
+    target?: Document
+    notify?: (toast: { title: string; description: string }) => void
+  },
+  language: Pick<LanguageContext, "locale" | "t"> = useLanguage(),
+) {
+  const [store, setStore] = createStore({ active: null as string | null })
+  const overrides = createMemo(() => keybinds(input.settings.current.keybinds))
+  const list = createMemo(() => {
+    language.locale()
+    return listFor(input.command, overrides(), language.t("command.palette"))
+  })
+  const grouped = createMemo(() => groupedFor(list()))
+  const title = (id: string) => list().get(id)?.title ?? ""
+  const effective = (id: string) => {
+    if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND
+
+    const custom = input.settings.keybinds.get(id)
+    if (typeof custom === "string") return custom
+
+    const live = input.command.options.find((item) => item.id === id)
+    if (live?.keybind) return live.keybind
+    return input.command.catalog.find((item) => item.id === id)?.keybind
+  }
+  const used = createMemo(() => {
+    const value = new Map<string, { id: string; title: string }[]>()
+
+    for (const id of list().keys()) {
+      for (const signature of signatures(effective(id))) {
+        const items = value.get(signature)
+        if (items) {
+          items.push({ id, title: title(id) })
+          continue
+        }
+        value.set(signature, [{ id, title: title(id) }])
+      }
+    }
+
+    return value
+  })
+  const stop = () => {
+    if (!store.active) return
+    setStore("active", null)
+    input.command.keybinds(true)
+  }
+  const toggle = (id: string) => {
+    if (store.active === id) {
+      stop()
+      return
+    }
+    if (store.active) stop()
+    setStore("active", id)
+    input.command.keybinds(false)
+  }
+  const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast))
+
+  const handle = (event: KeyboardEvent) => {
+    const id = store.active
+    if (!id) return
+
+    event.preventDefault()
+    event.stopPropagation()
+    event.stopImmediatePropagation()
+
+    if (event.key === "Escape") {
+      stop()
+      return
+    }
+
+    const clear =
+      (event.key === "Backspace" || event.key === "Delete") &&
+      !event.ctrlKey &&
+      !event.metaKey &&
+      !event.altKey &&
+      !event.shiftKey
+    if (clear) {
+      input.settings.keybinds.set(id, "none")
+      stop()
+      return
+    }
+
+    const next = recordKeybind(event)
+    if (!next) return
+
+    const conflicts = new Map<string, string>()
+    for (const signature of signatures(next)) {
+      for (const item of used().get(signature) ?? []) {
+        if (item.id === id) continue
+        conflicts.set(item.id, item.title)
+      }
+    }
+
+    if (conflicts.size > 0) {
+      notify({
+        title: language.t("settings.shortcuts.conflict.title"),
+        description: language.t("settings.shortcuts.conflict.description", {
+          keybind: formatKeybind(next, language.t),
+          titles: [...conflicts.values()].join(", "),
+        }),
+      })
+      return
+    }
+
+    input.settings.keybinds.set(id, next)
+    stop()
+  }
+
+  const target = input.target ?? (typeof document === "object" ? document : undefined)
+  if (target) makeEventListener(target, "keydown", handle, { capture: true })
+
+  onCleanup(() => {
+    if (store.active) input.command.keybinds(true)
+  })
+
+  return {
+    catalog: {
+      groups: GROUPS,
+      filtered: (query: string) =>
+        filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)),
+      title,
+      keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t),
+    },
+    capture: {
+      active: () => store.active,
+      toggle,
+    },
+    settings: {
+      hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"),
+      reset: () => {
+        stop()
+        input.settings.keybinds.resetAll()
+        notify({
+          title: language.t("settings.shortcuts.reset.toast.title"),
+          description: language.t("settings.shortcuts.reset.toast.description"),
+        })
+      },
+    },
+  }
+}
+
+function SettingsKeybindsV2() {
+  const command = useCommand()
+  const settings = useSettings()
+  const controller = createKeybindSettingsController({
+    command,
+    settings,
+  })
+
+  return (
+    <SettingsKeybindsV2View
+      groups={controller.catalog.groups}
+      filtered={controller.catalog.filtered}
+      title={controller.catalog.title}
+      keybind={controller.catalog.keybind}
+      active={controller.capture.active}
+      onCapture={controller.capture.toggle}
+      hasOverrides={controller.settings.hasOverrides}
+      onReset={controller.settings.reset}
+    />
+  )
+}
+
+function SettingsKeybindsV2View(props: {
+  groups: KeybindGroup[]
+  filtered: (query: string) => Map<KeybindGroup, string[]>
+  title: (id: string) => string
+  keybind: (id: string) => string
+  active: () => string | null
+  onCapture: (id: string) => void
+  hasOverrides: () => boolean
+  onReset: () => void
+}) {
+  const language = useLanguage()
+  const [store, setStore] = createStore({ filter: "" })
+  const filtered = createMemo(() => props.filtered(store.filter))
+  const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
+
+  return (
+    <>
+      <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={props.onReset} disabled={!props.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">
+        <div class="settings-v2-shortcuts flex flex-col gap-8">
+          <For each={props.groups}>
+            {(group) => (
+              <Show when={(filtered().get(group) ?? []).length > 0}>
+                <div class="settings-v2-section">
+                  <h3 class="settings-v2-section-title">{language.t(groupKey[group])}</h3>
+                  <SettingsListV2>
+                    <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>{props.title(id)}</span>
+                          <button
+                            type="button"
+                            data-keybind-id={id}
+                            classList={{
+                              "settings-v2-keybind-button": true,
+                              "settings-v2-keybind-button--active": props.active() === id,
+                            }}
+                            onClick={() => props.onCapture(id)}
+                          >
+                            <Show
+                              when={props.active() === id}
+                              fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
+                            >
+                              {language.t("settings.shortcuts.pressKeys")}
+                            </Show>
+                          </button>
+                        </div>
+                      )}
+                    </For>
+                  </SettingsListV2>
+                </div>
+              </Show>
+            )}
+          </For>
+          <Show when={store.filter && !hasResults()}>
+            <div class="settings-v2-shortcuts-status">
+              <span>{language.t("settings.shortcuts.search.empty")}</span>
+              <span class="settings-v2-shortcuts-status-filter">&quot;{store.filter}&quot;</span>
+            </div>
+          </Show>
+        </div>
+      </div>
+    </>
+  )
+}
+
 export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
+  if (props.v2) return <SettingsKeybindsV2 />
+
   const command = useCommand()
   const language = useLanguage()
   const settings = useSettings()
@@ -476,78 +745,37 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
   )
 
   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()}>
+    <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")}
-            </ButtonV2>
+            </Button>
           </div>
-          <div class="settings-v2-tab-search">
-            <TextInputV2
-              type="search"
-              appearance="base"
+
+          <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}
-              onInput={(event) => setStore("filter", event.currentTarget.value)}
+              onChange={(v) => setStore("filter", v)}
               placeholder={language.t("settings.shortcuts.search.placeholder")}
               spellcheck={false}
               autocorrect="off"
               autocomplete="off"
               autocapitalize="off"
-              aria-label={language.t("settings.shortcuts.search.placeholder")}
+              class="flex-1"
             />
             <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", "")}
-              />
+              <IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
             </Show>
           </div>
         </div>
-        <div class="settings-v2-tab-body">{groups}</div>
-      </>
-    </Show>
+      </div>
+      {groups}
+    </div>
   )
 }

+ 107 - 0
packages/app/test-browser/settings-keybinds.test.ts

@@ -0,0 +1,107 @@
+import { describe, expect, test } from "bun:test"
+import { createRoot } from "solid-js"
+import { createKeybindSettingsController } from "../src/components/settings-keybinds"
+
+function setup(overrides: Record<string, string> = {}) {
+  const changes: [string, string][] = []
+  const suppression: boolean[] = []
+  const notifications: { title: string; description?: string }[] = []
+  let resets = 0
+  let controller: ReturnType<typeof createKeybindSettingsController>
+
+  const dispose = createRoot((dispose) => {
+    controller = createKeybindSettingsController(
+      {
+        command: {
+          catalog: [
+            { id: "session.alpha", title: "Alpha", keybind: "mod+a" },
+            { id: "session.beta", title: "Beta", keybind: "mod+b" },
+          ],
+          options: [],
+          keybinds: (enabled) => suppression.push(enabled),
+        },
+        settings: {
+          current: { keybinds: overrides },
+          keybinds: {
+            get: (id) => overrides[id],
+            set: (id, value) => {
+              overrides[id] = value
+              changes.push([id, value])
+            },
+            resetAll: () => {
+              resets++
+            },
+          },
+        },
+        notify: (toast) => notifications.push(toast),
+      },
+      {
+        locale: () => "en",
+        t: (key, params) => {
+          if (params) return `${key}:${Object.values(params).join("|")}`
+          if (key === "common.key.alt") return "Alt"
+          return String(key)
+        },
+      },
+    )
+    return dispose
+  })
+
+  return {
+    controller: controller!,
+    changes,
+    suppression,
+    notifications,
+    resets: () => resets,
+    dispose,
+  }
+}
+
+function modKey(key: string) {
+  const mac = /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
+  return new KeyboardEvent("keydown", { key, ctrlKey: !mac, metaKey: mac, bubbles: true, cancelable: true })
+}
+
+describe("keybind settings controller", () => {
+  test("derives the catalog, effective bindings, and filtered groups", () => {
+    const state = setup({ "session.beta": "alt+k" })
+
+    expect(state.controller.catalog.title("session.alpha")).toBe("Alpha")
+    expect(state.controller.catalog.keybind("session.beta")).toBe("Alt+K")
+    expect(state.controller.catalog.filtered("alt k").get("Session")).toEqual(["session.beta"])
+    expect(state.controller.settings.hasOverrides()).toBe(true)
+
+    state.dispose()
+  })
+
+  test("captures bindings, rejects conflicts, and restores command handling", () => {
+    const state = setup()
+
+    state.controller.capture.toggle("session.beta")
+    document.dispatchEvent(modKey("a"))
+    expect(state.changes).toEqual([])
+    expect(state.notifications).toHaveLength(1)
+    expect(state.controller.capture.active()).toBe("session.beta")
+
+    document.dispatchEvent(modKey("x"))
+    expect(state.changes).toEqual([["session.beta", "mod+x"]])
+    expect(state.suppression).toEqual([false, true])
+    expect(state.controller.capture.active()).toBeNull()
+
+    state.controller.capture.toggle("session.alpha")
+    state.dispose()
+    expect(state.suppression).toEqual([false, true, false, true])
+    document.dispatchEvent(modKey("z"))
+    expect(state.changes).toEqual([["session.beta", "mod+x"]])
+  })
+
+  test("resets persisted overrides and reports success", () => {
+    const state = setup({ "session.alpha": "none" })
+
+    state.controller.settings.reset()
+    expect(state.resets()).toBe(1)
+    expect(state.notifications[0]?.title).toBe("settings.shortcuts.reset.toast.title")
+
+    state.dispose()
+  })
+})