diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index ba4793305b..f026f371ea 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/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 ( diff --git a/packages/app/src/components/prompt-project-selector.tsx b/packages/app/src/components/prompt-project-selector.tsx index 4573d0d552..312e02df12 100644 --- a/packages/app/src/components/prompt-project-selector.tsx +++ b/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 ( 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 +}