summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-09-24 12:40:54 -0500
committerAdam <[email protected]>2025-09-24 12:40:54 -0500
commitf589fc2327dd807a82ce6f756231fdb8eb43dd59 (patch)
tree0adc4c7fb38e5b41bba95e3de6e84f2c46cbc7f6
parentd3b6545e7c6069c9db031634b7890e6b8eb4de2a (diff)
downloadopencode-f589fc2327dd807a82ce6f756231fdb8eb43dd59.tar.gz
opencode-f589fc2327dd807a82ce6f756231fdb8eb43dd59.zip
feat: fuzzy file open
-rw-r--r--packages/app/src/components/select-dialog.tsx70
-rw-r--r--packages/app/src/context/local.tsx6
-rw-r--r--packages/app/src/pages/index.tsx30
-rw-r--r--packages/app/src/utils/path.ts5
4 files changed, 75 insertions, 36 deletions
diff --git a/packages/app/src/components/select-dialog.tsx b/packages/app/src/components/select-dialog.tsx
index 2ff64f5da..ade7489d1 100644
--- a/packages/app/src/components/select-dialog.tsx
+++ b/packages/app/src/components/select-dialog.tsx
@@ -1,22 +1,18 @@
-import { createEffect, Show, For, createMemo, type JSX } from "solid-js"
+import { createEffect, Show, For, createMemo, type JSX, createResource } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { Icon, IconButton } from "@/ui"
import { createStore } from "solid-js/store"
-import { entries, flatMap, groupBy, map, mapValues, pipe } from "remeda"
+import { entries, flatMap, groupBy, map, pipe } from "remeda"
import { createList } from "solid-list"
import fuzzysort from "fuzzysort"
interface SelectDialogProps<T> {
- items: T[]
+ items: T[] | ((filter: string) => Promise<T[]>)
key: (item: T) => string
render: (item: T) => JSX.Element
+ filter?: string[]
current?: T
placeholder?: string
- filter?:
- | false
- | {
- keys: string[]
- }
groupBy?: (x: T) => string
onSelect?: (value: T | undefined) => void
onClose?: () => void
@@ -29,24 +25,31 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) {
mouseActive: false,
})
- const grouped = createMemo(() => {
- const needle = store.filter.toLowerCase()
- const result = pipe(
- props.items,
- (x) =>
- !needle || !props.filter
- ? x
- : fuzzysort.go(needle, x, { keys: props.filter && props.filter.keys }).map((x) => x.obj),
- groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
- mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))),
- entries(),
- map(([k, v]) => ({ category: k, items: v })),
- )
- return result
- })
+ const [grouped] = createResource(
+ () => store.filter,
+ async (filter) => {
+ const needle = filter.toLowerCase()
+ const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
+ const result = pipe(
+ all,
+ (x) => {
+ if (!needle) return x
+ if (!props.filter && Array.isArray(x) && x.every((e) => typeof e === "string")) {
+ return fuzzysort.go(needle, x).map((x) => x.target) as T[]
+ }
+ return fuzzysort.go(needle, x, { keys: props.filter! }).map((x) => x.obj)
+ },
+ groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
+ // mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))),
+ entries(),
+ map(([k, v]) => ({ category: k, items: v })),
+ )
+ return result
+ },
+ )
const flat = createMemo(() => {
return pipe(
- grouped(),
+ grouped() || [],
flatMap((x) => x.items),
)
})
@@ -55,7 +58,11 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) {
initialActive: props.current ? props.key(props.current) : undefined,
loop: true,
})
- const resetSelection = () => list.setActive(props.key(flat()[0]))
+ const resetSelection = () => {
+ const all = flat()
+ if (all.length === 0) return
+ list.setActive(props.key(all[0]))
+ }
createEffect(() => {
store.filter
@@ -64,8 +71,9 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) {
})
createEffect(() => {
- if (store.mouseActive) return
- if (list.active() === props.key(flat()[0])) {
+ const all = flat()
+ if (store.mouseActive || all.length === 0) return
+ if (list.active() === props.key(all[0])) {
scrollRef?.scrollTo(0, 0)
return
}
@@ -156,9 +164,11 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) {
<For each={grouped()}>
{(group) => (
<>
- <div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase">
- {group.category}
- </div>
+ <Show when={group.category}>
+ <div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase">
+ {group.category}
+ </div>
+ </Show>
<div class="p-2">
<For each={group.items}>
{(item) => (
diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx
index c52fe0db4..c74ae21ab 100644
--- a/packages/app/src/context/local.tsx
+++ b/packages/app/src/context/local.tsx
@@ -163,7 +163,7 @@ function init() {
})
}
- const open = async (path: string) => {
+ const open = async (path: string, options?: { pin?: boolean }) => {
const relative = path.replace(sync.data.path.directory + "/", "")
if (!store.node[relative]) {
const parent = relative.split("/").slice(0, -1).join("/")
@@ -181,6 +181,7 @@ function init() {
]
})
setStore("active", relative)
+ if (options?.pin) setStore("node", path, "pinned", true)
if (store.node[relative].loaded) return
return load(relative)
}
@@ -199,6 +200,8 @@ function init() {
})
}
+ const search = (query: string) => sdk.find.files({ query: { query } }).then((x) => x.data!)
+
const bus = useEvent()
bus.listen((event) => {
switch (event.type) {
@@ -303,6 +306,7 @@ function init() {
!x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
)
},
+ search,
}
})()
diff --git a/packages/app/src/pages/index.tsx b/packages/app/src/pages/index.tsx
index 9133f40d8..acb756621 100644
--- a/packages/app/src/pages/index.tsx
+++ b/packages/app/src/pages/index.tsx
@@ -20,6 +20,7 @@ import type { LocalFile } from "@/context/local"
import SessionList from "@/components/session-list"
import SessionTimeline from "@/components/session-timeline"
import { createStore } from "solid-js/store"
+import { getDirectory, getFilename } from "@/utils"
export default function Page() {
const sdk = useSDK()
@@ -30,6 +31,7 @@ export default function Page() {
prompt: "",
dragging: undefined as "left" | "right" | undefined,
modelSelectOpen: false,
+ fileSelectOpen: false,
})
let inputRef: HTMLInputElement | undefined = undefined
@@ -47,12 +49,12 @@ export default function Page() {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.getModifierState(MOD) && e.shiftKey && e.key.toLowerCase() === "p") {
e.preventDefault()
- setStore("modelSelectOpen", true)
+ // TODO: command palette
return
}
if (e.getModifierState(MOD) && e.key.toLowerCase() === "p") {
e.preventDefault()
- setStore("modelSelectOpen", true)
+ setStore("fileSelectOpen", true)
return
}
@@ -554,14 +556,32 @@ export default function Page() {
</div>
</div>
)}
- filter={{
- keys: ["provider.name", "name", "id"],
- }}
+ filter={["provider.name", "name", "id"]}
groupBy={(x) => x.provider.name}
onClose={() => setStore("modelSelectOpen", false)}
onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
/>
</Show>
+ <Show when={store.fileSelectOpen}>
+ <SelectDialog
+ items={local.file.search}
+ key={(x) => x}
+ render={(i) => (
+ <div class="w-full flex items-center justify-between">
+ <div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
+ <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
+ <span class="text-xs text-text whitespace-nowrap">{getFilename(i)}</span>
+ <span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
+ {getDirectory(i)}
+ </span>
+ </div>
+ <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
+ </div>
+ )}
+ onClose={() => setStore("fileSelectOpen", false)}
+ onSelect={(x) => (x ? local.file.open(x, { pin: true }) : undefined)}
+ />
+ </Show>
</div>
)
}
diff --git a/packages/app/src/utils/path.ts b/packages/app/src/utils/path.ts
index d6a066e80..9c026ca41 100644
--- a/packages/app/src/utils/path.ts
+++ b/packages/app/src/utils/path.ts
@@ -3,6 +3,11 @@ export function getFilename(path: string) {
return parts[parts.length - 1]
}
+export function getDirectory(path: string) {
+ const parts = path.split("/")
+ return parts.slice(0, parts.length - 1).join("/")
+}
+
export function getFileExtension(path: string) {
const parts = path.split(".")
return parts[parts.length - 1]