mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-29 05:34:19 +01:00
feat: fuzzy file open
This commit is contained in:
@@ -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) => (
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})()
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user