Explorar o código

feat(app): dropdown search fix (#34961)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Aarav Sareen hai 2 meses
pai
achega
a4fed69a82

+ 23 - 1
packages/app/src/components/dialog-select-model.tsx

@@ -1,5 +1,15 @@
 import { Popover as Kobalte } from "@kobalte/core/popover"
-import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js"
+import {
+  Component,
+  ComponentProps,
+  createEffect,
+  createMemo,
+  For,
+  JSX,
+  onCleanup,
+  Show,
+  ValidComponent,
+} from "solid-js"
 import { createStore } from "solid-js/store"
 import { useLocal } from "@/context/local"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -17,6 +27,8 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
 import { ModelTooltip } from "./model-tooltip"
 import { useLanguage } from "@/context/language"
 import { decode64 } from "@/utils/base64"
+import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
+import { createEventListener } from "@solid-primitives/event-listener"
 import { matchesModelSearch } from "./dialog-select-model-search"
 
 const isFree = (provider: string, cost: { input: number } | undefined) =>
@@ -336,6 +348,16 @@ export function ModelSelectorPopoverV2(props: {
     setStore({ search: value, active: first ? modelKey(first) : manageKey })
   }
 
+  createEffect(() => {
+    if (!store.open) return
+    createEventListener(
+      document,
+      "keydown",
+      (event: KeyboardEvent) => handleDocumentSearchKeydown(searchRef, event, store.search, setSearch),
+      true,
+    )
+  })
+
   return (
     <MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
       <MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>

+ 23 - 11
packages/app/src/components/prompt-project-selector.tsx

@@ -1,4 +1,4 @@
-import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
+import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
 import { createStore } from "solid-js/store"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -8,6 +8,7 @@ import { getProjectAvatarVariant } from "@/context/layout"
 import { useLanguage } from "@/context/language"
 import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
 import { pathKey } from "@/utils/path-key"
+import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
 
 export type PromptProject = {
   name?: string
@@ -101,6 +102,16 @@ export function createPromptProjectController(input: {
     setStore({ open: false, search: "", active: "" })
     input.controls().add(language.t("command.project.open"), server)
   }
+  const setSearch = (value: string) => {
+    const search = value.trim().toLowerCase()
+    const first = input
+      .controls()
+      .available.find((project) => !search || displayName(project).toLowerCase().includes(search))
+    setStore({
+      search: value,
+      active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
+    })
+  }
 
   return {
     selected,
@@ -127,16 +138,7 @@ export function createPromptProjectController(input: {
       }
       setStore({ open: false, search: "", active: "" })
     },
-    setSearch(value: string) {
-      const search = value.trim().toLowerCase()
-      const first = input
-        .controls()
-        .available.find((project) => !search || displayName(project).toLowerCase().includes(search))
-      setStore({
-        search: value,
-        active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
-      })
-    },
+    setSearch,
     clearSearch() {
       setStore({ search: "", active: initialActive() })
       setTimeout(() => searchRef?.focus())
@@ -170,6 +172,9 @@ export function createPromptProjectController(input: {
     focusSearch() {
       setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
     },
+    handleSearchKeydown(event: KeyboardEvent) {
+      return handleDocumentSearchKeydown(searchRef, event, store.search, setSearch)
+    },
   }
 }
 
@@ -243,6 +248,13 @@ export function PromptProjectSelector(props: {
     return project ? props.controller.projectKey(project) : undefined
   }
 
+  createEffect(() => {
+    if (!props.controller.open()) return
+    const handler = (event: KeyboardEvent) => props.controller.handleSearchKeydown(event)
+    document.addEventListener("keydown", handler, true)
+    onCleanup(() => document.removeEventListener("keydown", handler, true))
+  })
+
   return (
     <DropdownMenu
       open={props.controller.open()}

+ 116 - 0
packages/app/src/utils/search-keydown.ts

@@ -0,0 +1,116 @@
+const editableSelector = "input, textarea, select, [contenteditable=''], [contenteditable='true']"
+
+export function handleDocumentSearchKeydown(
+  input: HTMLInputElement | undefined,
+  event: KeyboardEvent,
+  inputValue: string,
+  setInputValue: (value: string) => void,
+) {
+  if (!input) return false
+  if (event.defaultPrevented || event.isComposing) return false
+  if (event.target === input) return false
+  if (event.target instanceof Element && event.target.closest(editableSelector)) return false
+
+  const action = searchKeyAction(event)
+  if (!action) return false
+
+  event.preventDefault()
+  event.stopPropagation()
+  input.focus()
+
+  const start = input.selectionStart ?? inputValue.length
+  const end = input.selectionEnd ?? inputValue.length
+
+  if (action.type === "selectAll") {
+    input.setSelectionRange(0, inputValue.length)
+    return true
+  }
+
+  if (action.type === "move") {
+    moveSelection(input, inputValue, action.delta, event.shiftKey)
+    return true
+  }
+
+  if (action.type === "home") {
+    setBoundarySelection(input, start, 0, event.shiftKey)
+    return true
+  }
+
+  if (action.type === "end") {
+    setBoundarySelection(input, start, inputValue.length, event.shiftKey)
+    return true
+  }
+
+  if (action.type === "deleteBackward") {
+    if (start !== end)
+      return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end), start, setInputValue)
+    if (start === 0) return true
+    return updateValue(input, inputValue.slice(0, start - 1) + inputValue.slice(end), start - 1, setInputValue)
+  }
+
+  if (action.type === "deleteForward") {
+    if (start !== end)
+      return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end), start, setInputValue)
+    if (end === inputValue.length) return true
+    return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end + 1), start, setInputValue)
+  }
+
+  return updateValue(
+    input,
+    inputValue.slice(0, start) + action.value + inputValue.slice(end),
+    start + action.value.length,
+    setInputValue,
+  )
+}
+
+function searchKeyAction(event: KeyboardEvent) {
+  if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "a") {
+    return { type: "selectAll" } as const
+  }
+  if (event.ctrlKey || event.metaKey || event.altKey) return undefined
+  if (event.key.length === 1) return { type: "insert", value: event.key } as const
+  if (event.key === "Backspace") return { type: "deleteBackward" } as const
+  if (event.key === "Delete") return { type: "deleteForward" } as const
+  if (event.key === "ArrowLeft") return { type: "move", delta: -1 } as const
+  if (event.key === "ArrowRight") return { type: "move", delta: 1 } as const
+  if (event.key === "Home") return { type: "home" } as const
+  if (event.key === "End") return { type: "end" } as const
+  return undefined
+}
+
+function moveSelection(input: HTMLInputElement, inputValue: string, delta: -1 | 1, extend: boolean) {
+  const start = input.selectionStart ?? inputValue.length
+  const end = input.selectionEnd ?? inputValue.length
+  if (!extend && start !== end) {
+    const caret = delta < 0 ? start : end
+    input.setSelectionRange(caret, caret)
+    return
+  }
+
+  if (!extend) {
+    const caret = Math.max(0, Math.min(inputValue.length, start + delta))
+    input.setSelectionRange(caret, caret)
+    return
+  }
+
+  const backward = input.selectionDirection === "backward"
+  const anchor = backward ? end : start
+  const focus = backward ? start : end
+  const next = Math.max(0, Math.min(inputValue.length, focus + delta))
+  input.setSelectionRange(Math.min(anchor, next), Math.max(anchor, next), next < anchor ? "backward" : "forward")
+}
+
+function setBoundarySelection(input: HTMLInputElement, anchor: number, focus: number, extend: boolean) {
+  if (!extend) {
+    input.setSelectionRange(focus, focus)
+    return
+  }
+  input.setSelectionRange(Math.min(anchor, focus), Math.max(anchor, focus), focus < anchor ? "backward" : "forward")
+}
+
+function updateValue(input: HTMLInputElement, value: string, caret: number, setInputValue: (value: string) => void) {
+  input.value = value
+  setInputValue(value)
+  input.setSelectionRange(caret, caret)
+  return true
+}