From 657f3d5089cc20315ead234367ee8f1f18efd754 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:58:17 -0600 Subject: [PATCH] feat(app): unified search for commands and files --- .../app/src/components/dialog-select-file.tsx | 178 +++++++++++++++--- packages/app/src/context/command.tsx | 81 ++------ packages/app/src/context/global-sync.tsx | 13 +- packages/app/src/pages/session.tsx | 2 +- 4 files changed, 176 insertions(+), 98 deletions(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 461f8a0c0..3b80c2687 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -4,11 +4,26 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/util/path" 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 { 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() { + const command = useCommand() const layout = useLayout() const file = useFile() const dialog = useDialog() @@ -16,35 +31,148 @@ export function DialogSelectFile() { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(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() + 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 ( - + x} - onSelect={(path) => { - if (path) { - const value = file.tab(path) - tabs().open(value) - file.load(path) - view().reviewPanel.open() - } - dialog.close() - }} + search={{ placeholder: "Search files and commands", autofocus: true }} + emptyMessage="No results found" + items={items} + key={(item) => item.id} + filterKeys={["title", "description", "category"]} + groupBy={(item) => (grouped() ? item.category : "")} + onMove={handleMove} + onSelect={handleSelect} > - {(i) => ( -
-
- -
- - {getDirectory(i)} - - {getFilename(i)} + {(item) => ( + +
+ +
+ + {getDirectory(item.path ?? "")} + + {getFilename(item.path ?? "")} +
+
+ } + > +
+
+ {item.title} + + {item.description} + +
+ + {formatKeybind(item.keybind ?? "")} +
-
+ )}
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 7f88b74c8..3c640d8e9 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -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 { 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) @@ -114,67 +111,11 @@ export function formatKeybind(config: string): string { 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 ( - - 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) => ( -
-
- {option.title} - - {option.description} - -
- - {formatKeybind(option.keybind!)} - -
- )} -
-
- ) -} - export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ name: "Command", init: () => { const [registrations, setRegistrations] = createSignal[]>([]) const [suspendCount, setSuspendCount] = createSignal(0) - const dialog = useDialog() const options = createMemo(() => { const seen = new Set() @@ -202,12 +143,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const suspended = () => suspendCount() > 0 - const showPalette = () => { - if (!dialog.active) { - dialog.show(() => !x.disabled)} />) + const run = (id: string, source?: "palette" | "keybind" | "slash") => { + for (const option of options()) { + if (option.id === id || option.id === "suggested." + id) { + option.onSelect?.(source) + return + } } } + const showPalette = () => { + run("file.open", "palette") + } + const handleKeyDown = (event: KeyboardEvent) => { if (suspended()) return @@ -248,12 +196,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex }) }, trigger(id: string, source?: "palette" | "keybind" | "slash") { - for (const option of options()) { - if (option.id === id || option.id === "suggested." + id) { - option.onSelect?.(source) - return - } - } + run(id, source) }, keybind(id: string) { const option = options().find((x) => x.id === id || x.id === "suggested." + id) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 94c39d2f0..82452ed48 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -28,6 +28,8 @@ import { batch, createContext, createEffect, + getOwner, + runWithOwner, useContext, onCleanup, onMount, @@ -89,6 +91,8 @@ type VcsCache = { function createGlobalSync() { const globalSDK = useGlobalSDK() const platform = usePlatform() + const owner = getOwner() + if (!owner) throw new Error("GlobalSync must be created within owner") const vcsCache = new Map() const [globalStore, setGlobalStore] = createStore<{ ready: boolean @@ -109,10 +113,13 @@ function createGlobalSync() { function child(directory: string) { if (!directory) console.error("No directory provided") if (!children[directory]) { - const cache = persisted( - Persist.workspace(directory, "vcs", ["vcs.v1"]), - createStore({ value: undefined as VcsInfo | undefined }), + const cache = runWithOwner(owner, () => + persisted( + 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] }) children[directory] = createStore({ diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 143150b92..ca5e73a9b 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -428,7 +428,7 @@ export default function Page() { { id: "file.open", title: "Open file", - description: "Search and open a file", + description: "Search files and commands", category: "File", keybind: "mod+p", slash: "open",