feat(app): unified search for commands and files
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>({
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user