summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-15 17:58:17 -0600
committerAdam <[email protected]>2026-01-15 17:59:26 -0600
commit657f3d5089cc20315ead234367ee8f1f18efd754 (patch)
tree23d25ef7dfce2ff3d9fad802d8727e20f4cd777b
parent49939c4d8d4b7112273ab03779d4ec0c9ad2a175 (diff)
downloadopencode-657f3d5089cc20315ead234367ee8f1f18efd754.tar.gz
opencode-657f3d5089cc20315ead234367ee8f1f18efd754.zip
feat(app): unified search for commands and files
-rw-r--r--packages/app/src/components/dialog-select-file.tsx178
-rw-r--r--packages/app/src/context/command.tsx81
-rw-r--r--packages/app/src/context/global-sync.tsx13
-rw-r--r--packages/app/src/pages/session.tsx2
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<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 (
- <Dialog title="Select file">
+ <Dialog title="Search">
<List
- search={{ placeholder: "Search files", autofocus: true }}
- emptyMessage="No files found"
- items={file.searchFiles}
- key={(x) => 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) => (
- <div class="w-full flex items-center justify-between rounded-md">
- <div class="flex items-center gap-x-3 grow min-w-0">
- <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
- <div class="flex items-center text-14-regular">
- <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
- {getDirectory(i)}
- </span>
- <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
+ {(item) => (
+ <Show
+ when={item.type === "command"}
+ fallback={
+ <div class="w-full flex items-center justify-between rounded-md">
+ <div class="flex items-center gap-x-3 grow min-w-0">
+ <FileIcon node={{ path: item.path ?? "", type: "file" }} class="shrink-0 size-4" />
+ <div class="flex items-center text-14-regular">
+ <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 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>
+ </Show>
)}
</List>
</Dialog>
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 (
- <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({
name: "Command",
init: () => {
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
- const dialog = useDialog()
const options = createMemo(() => {
const seen = new Set<string>()
@@ -202,12 +143,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const suspended = () => suspendCount() > 0
- const showPalette = () => {
- if (!dialog.active) {
- dialog.show(() => <DialogCommand options={options().filter((x) => !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<string, VcsCache>()
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<State>({
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",