diff options
| author | Filip <[email protected]> | 2025-09-24 18:05:15 +0200 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-09-24 11:05:15 -0500 |
| commit | d3b6545e7c6069c9db031634b7890e6b8eb4de2a (patch) | |
| tree | 5457b28378cf453e2c138337fc1abee3b3423ed2 /packages/app/src/components | |
| parent | 3f911b22b011af74426699dac079b33badf862b1 (diff) | |
| download | opencode-d3b6545e7c6069c9db031634b7890e6b8eb4de2a.tar.gz opencode-d3b6545e7c6069c9db031634b7890e6b8eb4de2a.zip | |
feat(app): added command palette (#2630)
Co-authored-by: Adam <[email protected]>
Diffstat (limited to 'packages/app/src/components')
| -rw-r--r-- | packages/app/src/components/code.tsx | 2 | ||||
| -rw-r--r-- | packages/app/src/components/select-dialog.tsx | 216 | ||||
| -rw-r--r-- | packages/app/src/components/select.tsx | 99 | ||||
| -rw-r--r-- | packages/app/src/components/session-list.tsx | 2 |
4 files changed, 227 insertions, 92 deletions
diff --git a/packages/app/src/components/code.tsx b/packages/app/src/components/code.tsx index e6d5ba80d..e4121d121 100644 --- a/packages/app/src/components/code.tsx +++ b/packages/app/src/components/code.tsx @@ -279,7 +279,7 @@ export function Code(props: Props) { }} innerHTML={html()} class=" - font-mono text-xs tracking-wide overflow-y-auto no-scrollbar h-full + font-mono text-xs tracking-wide overflow-y-auto h-full [&]:[counter-reset:line] [&_pre]:focus-visible:outline-none [&_pre]:overflow-x-auto [&_pre]:no-scrollbar diff --git a/packages/app/src/components/select-dialog.tsx b/packages/app/src/components/select-dialog.tsx new file mode 100644 index 000000000..2ff64f5da --- /dev/null +++ b/packages/app/src/components/select-dialog.tsx @@ -0,0 +1,216 @@ +import { createEffect, Show, For, createMemo, type JSX } from "solid-js" +import { Dialog } from "@kobalte/core/dialog" +import { Icon, IconButton } from "@/ui" +import { createStore } from "solid-js/store" +import { entries, flatMap, groupBy, map, mapValues, pipe } from "remeda" +import { createList } from "solid-list" +import fuzzysort from "fuzzysort" + +interface SelectDialogProps<T> { + items: T[] + key: (item: T) => string + render: (item: T) => JSX.Element + current?: T + placeholder?: string + filter?: + | false + | { + keys: string[] + } + groupBy?: (x: T) => string + onSelect?: (value: T | undefined) => void + onClose?: () => void +} + +export function SelectDialog<T>(props: SelectDialogProps<T>) { + let scrollRef: HTMLDivElement | undefined + const [store, setStore] = createStore({ + filter: "", + mouseActive: false, + }) + + const grouped = createMemo(() => { + const needle = store.filter.toLowerCase() + const result = pipe( + props.items, + (x) => + !needle || !props.filter + ? x + : fuzzysort.go(needle, x, { keys: props.filter && props.filter.keys }).map((x) => x.obj), + groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), + mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))), + entries(), + map(([k, v]) => ({ category: k, items: v })), + ) + return result + }) + const flat = createMemo(() => { + return pipe( + grouped(), + flatMap((x) => x.items), + ) + }) + const list = createList({ + items: () => flat().map(props.key), + initialActive: props.current ? props.key(props.current) : undefined, + loop: true, + }) + const resetSelection = () => list.setActive(props.key(flat()[0])) + + createEffect(() => { + store.filter + scrollRef?.scrollTo(0, 0) + resetSelection() + }) + + createEffect(() => { + if (store.mouseActive) return + if (list.active() === props.key(flat()[0])) { + scrollRef?.scrollTo(0, 0) + return + } + const element = scrollRef?.querySelector(`[data-key="${list.active()}"]`) + element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + }) + + const handleInput = (value: string) => { + setStore("filter", value) + resetSelection() + } + + const handleSelect = (item: T) => { + props.onSelect?.(item) + props.onClose?.() + } + + const handleKey = (e: KeyboardEvent) => { + setStore("mouseActive", false) + + if (e.key === "Enter") { + e.preventDefault() + const selected = flat().find((x) => props.key(x) === list.active()) + if (selected) handleSelect(selected) + } else if (e.key === "Escape") { + e.preventDefault() + props.onClose?.() + } else { + list.onKeyDown(e) + } + } + + return ( + <Dialog defaultOpen modal onOpenChange={(open) => open || props.onClose?.()}> + <Dialog.Portal> + <Dialog.Overlay class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100]" /> + <Dialog.Content + class="fixed top-[20%] left-1/2 -translate-x-1/2 w-[90vw] max-w-2xl + shadow-[0_0_33px_rgba(0,0,0,0.8)] + bg-background border border-border-subtle/30 rounded-lg z-[101] + max-h-[60vh] flex flex-col" + > + <div class="border-b border-border-subtle/30"> + <div class="relative"> + <Icon name="command" size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted/80" /> + <input + type="text" + value={store.filter} + onInput={(e) => handleInput(e.currentTarget.value)} + onKeyDown={handleKey} + placeholder={props.placeholder} + class="w-full pl-10 pr-4 py-2 rounded-t-md + text-sm text-text placeholder-text-muted/70 + focus:outline-none" + autofocus + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> + <div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2"> + {/* <Show when={fileResults.loading && mode() === "files"}> + <div class="text-text-muted"> + <Icon name="refresh" size={14} class="animate-spin" /> + </div> + </Show> */} + <Show when={store.filter}> + <IconButton + size="xs" + variant="ghost" + class="text-text-muted hover:text-text" + onClick={() => { + setStore("filter", "") + resetSelection() + }} + > + <Icon name="close" size={14} /> + </IconButton> + </Show> + </div> + </div> + </div> + <div ref={(el) => (scrollRef = el)} class="relative flex-1 overflow-y-auto"> + <Show + when={flat().length > 0} + fallback={<div class="text-center py-8 text-text-muted text-sm">No results</div>} + > + <For each={grouped()}> + {(group) => ( + <> + <div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase"> + {group.category} + </div> + <div class="p-2"> + <For each={group.items}> + {(item) => ( + <button + data-key={props.key(item)} + onClick={() => handleSelect(item)} + onMouseMove={() => { + setStore("mouseActive", true) + list.setActive(props.key(item)) + }} + classList={{ + "w-full px-3 py-2 flex items-center gap-3": true, + "rounded-md text-left transition-colors group": true, + "bg-background-element": props.key(item) === list.active(), + "hover:bg-background-element": true, + }} + > + {props.render(item)} + </button> + )} + </For> + </div> + </> + )} + </For> + </Show> + </div> + <div class="p-3 border-t border-border-subtle/30 flex items-center justify-between text-xs text-text-muted"> + <div class="flex items-center gap-5"> + <span class="flex items-center gap-1.5"> + <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> + ↑↓ + </kbd> + Navigate + </span> + <span class="flex items-center gap-1.5"> + <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> + ↵ + </kbd> + Select + </span> + <span class="flex items-center gap-1.5"> + <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> + ESC + </kbd> + Close + </span> + </div> + <span>{`${flat().length} results`}</span> + </div> + </Dialog.Content> + </Dialog.Portal> + </Dialog> + ) +} diff --git a/packages/app/src/components/select.tsx b/packages/app/src/components/select.tsx index a99eccbd8..3df8c9999 100644 --- a/packages/app/src/components/select.tsx +++ b/packages/app/src/components/select.tsx @@ -1,46 +1,26 @@ import { Select as KobalteSelect } from "@kobalte/core/select" -import { createEffect, createMemo, Show } from "solid-js" +import { createMemo } from "solid-js" import type { ComponentProps } from "solid-js" import { Icon } from "@/ui/icon" -import fuzzysort from "fuzzysort" import { pipe, groupBy, entries, map } from "remeda" -import { createStore } from "solid-js/store" +import { Button, type ButtonProps } from "@/ui" export interface SelectProps<T> { - variant?: "default" | "outline" - size?: "sm" | "md" | "lg" placeholder?: string - filter?: - | false - | { - placeholder?: string - keys: string[] - } options: T[] current?: T value?: (x: T) => string label?: (x: T) => string groupBy?: (x: T) => string - onFilter?: (query: string) => void onSelect?: (value: T | undefined) => void class?: ComponentProps<"div">["class"] classList?: ComponentProps<"div">["classList"] } -export function Select<T>(props: SelectProps<T>) { - let inputRef: HTMLInputElement | undefined = undefined - let listboxRef: HTMLUListElement | undefined = undefined - const [store, setStore] = createStore({ - filter: "", - }) +export function Select<T>(props: SelectProps<T> & ButtonProps) { const grouped = createMemo(() => { - const needle = store.filter.toLowerCase() const result = pipe( props.options, - (x) => - !needle || !props.filter - ? x - : fuzzysort.go(needle, x, { keys: props.filter && props.filter.keys }).map((x) => x.obj), groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))), entries(), @@ -48,19 +28,6 @@ export function Select<T>(props: SelectProps<T>) { ) return result }) - // const flat = createMemo(() => { - // return pipe( - // grouped(), - // flatMap(({ options }) => options), - // ) - // }) - - createEffect(() => { - store.filter - listboxRef?.scrollTo(0, 0) - // setStore("selected", 0) - // scroll.scrollTo(0) - }) return ( <KobalteSelect<T, { category: string; options: T[] }> @@ -89,36 +56,21 @@ export function Select<T>(props: SelectProps<T>) { <KobalteSelect.ItemLabel> {props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)} </KobalteSelect.ItemLabel> - <KobalteSelect.ItemIndicator - classList={{ - "ml-auto": true, - }} - > + <KobalteSelect.ItemIndicator class="ml-auto"> <Icon name="checkmark" size={16} /> </KobalteSelect.ItemIndicator> </KobalteSelect.Item> )} onChange={(v) => { - if (props.onSelect) props.onSelect(v ?? undefined) - if (v !== null) { - // close the select - } + props.onSelect?.(v ?? undefined) }} - onOpenChange={(v) => v || setStore("filter", "")} > <KobalteSelect.Trigger + as={Button} + size={props.size || "sm"} + variant={props.variant || "secondary"} classList={{ ...(props.classList ?? {}), - "flex w-full items-center justify-between rounded-md transition-colors": true, - "focus-visible:outline-none focus-visible:ring focus-visible:ring-border-active/30": true, - "disabled:cursor-not-allowed disabled:opacity-50": true, - "data-[placeholder-shown]:text-text-muted cursor-pointer": true, - "hover:bg-background-element focus-visible:ring-border-active": true, - "bg-background-element text-text": props.variant === "default" || !props.variant, - "border-2 border-border bg-transparent text-text": props.variant === "outline", - "h-6 pl-2 text-xs": props.size === "sm", - "h-8 pl-3 text-sm": props.size === "md" || !props.size, - "h-10 pl-4 text-base": props.size === "lg", [props.class ?? ""]: !!props.class, }} > @@ -140,13 +92,6 @@ export function Select<T>(props: SelectProps<T>) { </KobalteSelect.Trigger> <KobalteSelect.Portal> <KobalteSelect.Content - onKeyDown={(e) => { - if (!props.filter) return - if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") { - return - } - inputRef?.focus() - }} classList={{ "min-w-32 overflow-hidden rounded-md border border-border-subtle/40": true, "bg-background-panel p-1 shadow-md z-50": true, @@ -154,33 +99,7 @@ export function Select<T>(props: SelectProps<T>) { "data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true, }} > - <Show when={props.filter}> - <input - ref={(el) => (inputRef = el)} - id="select-filter" - type="text" - placeholder={props.filter ? props.filter.placeholder : "Filter items"} - value={store.filter} - onInput={(e) => setStore("filter", e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") { - e.preventDefault() - e.stopPropagation() - listboxRef?.focus() - } - }} - classList={{ - "w-full": true, - "px-2 pb-2 text-text font-light placeholder-text-muted/70 text-xs focus:outline-none": true, - }} - /> - </Show> - <KobalteSelect.Listbox - ref={(el) => (listboxRef = el)} - classList={{ - "overflow-y-auto max-h-48 no-scrollbar": true, - }} - /> + <KobalteSelect.Listbox class="overflow-y-auto max-h-48" /> </KobalteSelect.Content> </KobalteSelect.Portal> </KobalteSelect> diff --git a/packages/app/src/components/session-list.tsx b/packages/app/src/components/session-list.tsx index e57562586..e0819780d 100644 --- a/packages/app/src/components/session-list.tsx +++ b/packages/app/src/components/session-list.tsx @@ -7,7 +7,7 @@ export default function SessionList() { const local = useLocal() return ( - <VList data={sync.data.session} class="p-2 no-scrollbar"> + <VList data={sync.data.session} class="p-2"> {(session) => ( <Tooltip placement="right" value={session.title} class="w-full min-w-0"> <Button |
