feat(app): unified search for commands and files

This commit is contained in:
Adam
2026-01-15 17:58:17 -06:00
parent 49939c4d8d
commit 657f3d5089
4 changed files with 176 additions and 98 deletions

View File

@@ -4,11 +4,26 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js" import { createMemo, createSignal, onCleanup, Show } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
type EntryType = "command" | "file"
type Entry = {
id: string
type: EntryType
title: string
description?: string
keybind?: string
category: "Commands" | "Files"
option?: CommandOption
path?: string
}
export function DialogSelectFile() { export function DialogSelectFile() {
const command = useCommand()
const layout = useLayout() const layout = useLayout()
const file = useFile() const file = useFile()
const dialog = useDialog() const dialog = useDialog()
@@ -16,35 +31,148 @@ export function DialogSelectFile() {
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey())) const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey())) const view = createMemo(() => layout.view(sessionKey()))
const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false)
const common = ["session.new", "session.previous", "session.next", "terminal.toggle", "review.toggle"]
const limit = 5
const allowed = createMemo(() =>
command.options.filter(
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
),
)
const commandItem = (option: CommandOption): Entry => ({
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category: "Commands",
option,
})
const fileItem = (path: string): Entry => ({
id: "file:" + path,
type: "file",
title: path,
category: "Files",
path,
})
const list = createMemo(() => allowed().map(commandItem))
const picks = createMemo(() => {
const all = allowed()
const order = new Map(common.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, limit)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
return sorted.map(commandItem)
})
const recent = createMemo(() => {
const all = tabs().all()
const active = tabs().active()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const items: Entry[] = []
for (const item of order) {
const path = file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(fileItem(path))
}
return items.slice(0, limit)
})
const items = async (filter: string) => {
const query = filter.trim()
setGrouped(query.length > 0)
if (!query) return [...picks(), ...recent()]
const files = await file.searchFiles(query)
const entries = files.map(fileItem)
return [...list(), ...entries]
}
const handleMove = (item: Entry | undefined) => {
state.cleanup?.()
if (!item) return
if (item.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const open = (path: string) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
view().reviewPanel.open()
}
const handleSelect = (item: Entry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (!item.path) return
open(item.path)
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return ( return (
<Dialog title="Select file"> <Dialog title="Search">
<List <List
search={{ placeholder: "Search files", autofocus: true }} search={{ placeholder: "Search files and commands", autofocus: true }}
emptyMessage="No files found" emptyMessage="No results found"
items={file.searchFiles} items={items}
key={(x) => x} key={(item) => item.id}
onSelect={(path) => { filterKeys={["title", "description", "category"]}
if (path) { groupBy={(item) => (grouped() ? item.category : "")}
const value = file.tab(path) onMove={handleMove}
tabs().open(value) onSelect={handleSelect}
file.load(path)
view().reviewPanel.open()
}
dialog.close()
}}
> >
{(i) => ( {(item) => (
<div class="w-full flex items-center justify-between rounded-md"> <Show
<div class="flex items-center gap-x-3 grow min-w-0"> when={item.type === "command"}
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> fallback={
<div class="flex items-center text-14-regular"> <div class="w-full flex items-center justify-between rounded-md">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> <div class="flex items-center gap-x-3 grow min-w-0">
{getDirectory(i)} <FileIcon node={{ path: item.path ?? "", type: "file" }} class="shrink-0 size-4" />
</span> <div class="flex items-center text-14-regular">
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(item.path ?? "")}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path ?? "")}</span>
</div>
</div>
</div> </div>
}
>
<div class="w-full flex items-center justify-between gap-4">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
<Show when={item.description}>
<span class="text-14-regular text-text-weak truncate">{item.description}</span>
</Show>
</div>
<Show when={item.keybind}>
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(item.keybind ?? "")}</span>
</Show>
</div> </div>
</div> </Show>
)} )}
</List> </List>
</Dialog> </Dialog>

View File

@@ -1,8 +1,5 @@
import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js" import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -114,67 +111,11 @@ export function formatKeybind(config: string): string {
return IS_MAC ? parts.join("") : parts.join("+") return IS_MAC ? parts.join("") : parts.join("+")
} }
function DialogCommand(props: { options: CommandOption[] }) {
const dialog = useDialog()
let cleanup: (() => void) | void
let committed = false
const handleMove = (option: CommandOption | undefined) => {
cleanup?.()
cleanup = option?.onHighlight?.()
}
const handleSelect = (option: CommandOption | undefined) => {
if (option) {
committed = true
cleanup = undefined
dialog.close()
option.onSelect?.("palette")
}
}
onCleanup(() => {
if (!committed) {
cleanup?.()
}
})
return (
<Dialog title="Commands">
<List
search={{ placeholder: "Search commands", autofocus: true }}
emptyMessage="No commands found"
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
key={(x) => x?.id}
filterKeys={["title", "description", "category"]}
groupBy={(x) => x.category ?? ""}
onMove={handleMove}
onSelect={handleSelect}
>
{(option) => (
<div class="w-full flex items-center justify-between gap-4">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
<Show when={option.description}>
<span class="text-14-regular text-text-weak truncate">{option.description}</span>
</Show>
</div>
<Show when={option.keybind}>
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
</Show>
</div>
)}
</List>
</Dialog>
)
}
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command", name: "Command",
init: () => { init: () => {
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([]) const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0) const [suspendCount, setSuspendCount] = createSignal(0)
const dialog = useDialog()
const options = createMemo(() => { const options = createMemo(() => {
const seen = new Set<string>() const seen = new Set<string>()
@@ -202,12 +143,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const suspended = () => suspendCount() > 0 const suspended = () => suspendCount() > 0
const showPalette = () => { const run = (id: string, source?: "palette" | "keybind" | "slash") => {
if (!dialog.active) { for (const option of options()) {
dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />) if (option.id === id || option.id === "suggested." + id) {
option.onSelect?.(source)
return
}
} }
} }
const showPalette = () => {
run("file.open", "palette")
}
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (suspended()) return if (suspended()) return
@@ -248,12 +196,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}) })
}, },
trigger(id: string, source?: "palette" | "keybind" | "slash") { trigger(id: string, source?: "palette" | "keybind" | "slash") {
for (const option of options()) { run(id, source)
if (option.id === id || option.id === "suggested." + id) {
option.onSelect?.(source)
return
}
}
}, },
keybind(id: string) { keybind(id: string) {
const option = options().find((x) => x.id === id || x.id === "suggested." + id) const option = options().find((x) => x.id === id || x.id === "suggested." + id)

View File

@@ -28,6 +28,8 @@ import {
batch, batch,
createContext, createContext,
createEffect, createEffect,
getOwner,
runWithOwner,
useContext, useContext,
onCleanup, onCleanup,
onMount, onMount,
@@ -89,6 +91,8 @@ type VcsCache = {
function createGlobalSync() { function createGlobalSync() {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const platform = usePlatform() const platform = usePlatform()
const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner")
const vcsCache = new Map<string, VcsCache>() const vcsCache = new Map<string, VcsCache>()
const [globalStore, setGlobalStore] = createStore<{ const [globalStore, setGlobalStore] = createStore<{
ready: boolean ready: boolean
@@ -109,10 +113,13 @@ function createGlobalSync() {
function child(directory: string) { function child(directory: string) {
if (!directory) console.error("No directory provided") if (!directory) console.error("No directory provided")
if (!children[directory]) { if (!children[directory]) {
const cache = persisted( const cache = runWithOwner(owner, () =>
Persist.workspace(directory, "vcs", ["vcs.v1"]), persisted(
createStore({ value: undefined as VcsInfo | undefined }), Persist.workspace(directory, "vcs", ["vcs.v1"]),
createStore({ value: undefined as VcsInfo | undefined }),
),
) )
if (!cache) throw new Error("Failed to create persisted cache")
vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] }) vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] })
children[directory] = createStore<State>({ children[directory] = createStore<State>({

View File

@@ -428,7 +428,7 @@ export default function Page() {
{ {
id: "file.open", id: "file.open",
title: "Open file", title: "Open file",
description: "Search and open a file", description: "Search files and commands",
category: "File", category: "File",
keybind: "mod+p", keybind: "mod+p",
slash: "open", slash: "open",