Browse Source

feat(app): unified search for commands and files

Adam 2 months ago
parent
commit
657f3d5089

+ 153 - 25
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 { 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 }}
-        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>
               </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>

+ 12 - 69
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 { 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 = () => {
-      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) => {
     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()) {
-          if (option.id === id || option.id === "suggested." + id) {
-            option.onSelect?.(source)
-            return
-          }
-        }
+        run(id, source)
       },
       },
       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)

+ 10 - 3
packages/app/src/context/global-sync.tsx

@@ -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(
-        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] })
       vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] })
 
 
       children[directory] = createStore<State>({
       children[directory] = createStore<State>({

+ 1 - 1
packages/app/src/pages/session.tsx

@@ -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",