summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
authorFilip <[email protected]>2025-09-24 18:05:15 +0200
committerGitHub <[email protected]>2025-09-24 11:05:15 -0500
commitd3b6545e7c6069c9db031634b7890e6b8eb4de2a (patch)
tree5457b28378cf453e2c138337fc1abee3b3423ed2 /packages/app/src/components
parent3f911b22b011af74426699dac079b33badf862b1 (diff)
downloadopencode-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.tsx2
-rw-r--r--packages/app/src/components/select-dialog.tsx216
-rw-r--r--packages/app/src/components/select.tsx99
-rw-r--r--packages/app/src/components/session-list.tsx2
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