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

Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Aarav Sareen 2026-07-03 16:01:37 +05:30 committed by GitHub
parent 41a3cfcdd9
commit a4fed69a82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 162 additions and 12 deletions

View file

@ -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}>

View file

@ -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()}

View file

@ -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
}