Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 150 additions & 32 deletions packages/app/src/components/dialog-select-directory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,59 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import fuzzysort from "fuzzysort"
import { createMemo } from "solid-js"
import { createMemo, createResource, createSignal } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import type { ListRef } from "@opencode-ai/ui/list"

interface DialogSelectDirectoryProps {
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
}

type Row = {
absolute: string
search: string
}

export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useGlobalSync()
const sdk = useGlobalSDK()
const dialog = useDialog()
const language = useLanguage()

const home = createMemo(() => sync.data.path.home)
const [filter, setFilter] = createSignal("")

let list: ListRef | undefined

const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))

const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async () => {
return sdk.client.path
.get()
.then((x) => x.data)
.catch(() => undefined)
},
{ initialValue: undefined },
)

const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")

const start = createMemo(() => sync.data.path.home || sync.data.path.directory)
const start = createMemo(
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)

const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()

const clean = (value: string) => {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}

function normalize(input: string) {
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
Expand Down Expand Up @@ -64,24 +94,67 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return ""
}

function display(path: string) {
function parentOf(input: string) {
const v = trimTrailing(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v

const i = v.lastIndexOf("/")
if (i <= 0) return "/"
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
return v.slice(0, i)
}

function modeOf(input: string) {
const raw = normalizeDriveRoot(input.trim())
if (!raw) return "relative" as const
if (raw.startsWith("~")) return "tilde" as const
if (rootOf(raw)) return "absolute" as const
return "relative" as const
}

function display(path: string, input: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full

return tildeOf(full) || full
}

function tildeOf(absolute: string) {
const full = trimTrailing(absolute)
const h = home()
if (!h) return full
if (!h) return ""

const hn = trimTrailing(h)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
return full
return ""
}

function row(absolute: string): Row {
const full = trimTrailing(absolute)
const tilde = tildeOf(full)

const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
}

const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n")
return { absolute: full, search }
}

function scoped(filter: string) {
function scoped(value: string) {
const base = start()
if (!base) return

const raw = normalizeDriveRoot(filter.trim())
const raw = normalizeDriveRoot(value)
if (!raw) return { directory: trimTrailing(base), path: "" }

const h = home()
Expand Down Expand Up @@ -122,21 +195,25 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}

const directories = async (filter: string) => {
const input = scoped(filter)
if (!input) return [] as string[]
const value = clean(filter)
const scopedInput = scoped(value)
if (!scopedInput) return [] as string[]

const raw = normalizeDriveRoot(filter.trim())
const raw = normalizeDriveRoot(value)
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")

const query = normalizeDriveRoot(input.path)
const query = normalizeDriveRoot(scopedInput.path)

if (!isPath) {
const results = await sdk.client.find
.files({ directory: input.directory, query, type: "directory", limit: 50 })
const find = () =>
sdk.client.find
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
.then((x) => x.data ?? [])
.catch(() => [])

return results.map((rel) => join(input.directory, rel)).slice(0, 50)
if (!isPath) {
const results = await find()

return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
}

const segments = query.replace(/^\/+/, "").split("/")
Expand All @@ -145,17 +222,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {

const cap = 12
const branch = 4
let paths = [input.directory]
let paths = [scopedInput.directory]
for (const part of head) {
if (part === "..") {
paths = paths.map((p) => {
const v = trimTrailing(p)
if (v === "/") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
return v.slice(0, i)
})
paths = paths.map(parentOf)
continue
}

Expand All @@ -165,7 +235,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}

const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
return Array.from(new Set(out)).slice(0, 50)
const deduped = Array.from(new Set(out))
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
const expand = !raw.endsWith("/")
if (!expand || !tail) {
const items = base ? Array.from(new Set([base, ...deduped])) : deduped
return items.slice(0, 50)
}

const needle = tail.toLowerCase()
const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
const target = exact[0]
if (!target) return deduped.slice(0, 50)

const children = await match(target, "", 30)
const items = Array.from(new Set([...deduped, ...children]))
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
}

const items = async (value: string) => {
const results = await directories(value)
return results.map(row)
}

function resolve(absolute: string) {
Expand All @@ -179,24 +269,52 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.directory.empty")}
loadingMessage={language.t("common.loading")}
items={directories}
key={(x) => x}
items={items}
key={(x) => x.absolute}
filterKeys={["search"]}
ref={(r) => (list = r)}
onFilter={(value) => setFilter(clean(value))}
onKeyEvent={(e, item) => {
if (e.key !== "Tab") return
if (e.shiftKey) return
if (!item) return

e.preventDefault()
e.stopPropagation()

const value = display(item.absolute, filter())
list?.setFilter(value.endsWith("/") ? value : value + "/")
}}
onSelect={(path) => {
if (!path) return
resolve(path)
resolve(path.absolute)
}}
>
{(absolute) => {
const path = display(absolute)
{(item) => {
const path = display(item.absolute, filter())
if (path === "~") {
return (
<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.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-strong whitespace-nowrap">~</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>
)
}
return (
<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: absolute, type: "directory" }} class="shrink-0 size-4" />
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(path)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>
Expand Down
45 changes: 29 additions & 16 deletions packages/ui/src/components/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface ListProps<T> extends FilteredListProps<T> {
export interface ListRef {
onKeyDown: (e: KeyboardEvent) => void
setScrollRef: (el: HTMLDivElement | undefined) => void
setFilter: (value: string) => void
}

export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
Expand Down Expand Up @@ -80,7 +81,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
container.scrollTop = Math.max(0, Math.min(target, max))
}

const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList<T>(props)
const { filter, grouped, flat, active, setActive, onKeyDown, onInput, refetch } = useFilteredList<T>(props)

const searchProps = () => (typeof props.search === "object" ? props.search : {})
const searchAction = () => searchProps().action
Expand All @@ -89,21 +90,29 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })

const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0

createEffect(() => {
if (props.filter !== undefined) {
onInput(props.filter)
}
})
const applyFilter = (value: string, options?: { ref?: boolean }) => {
const prev = filter()
setInternalFilter(value)
onInput(value)
props.onFilter?.(value)

createEffect((prev) => {
if (!props.search) return
const current = internalFilter()
if (prev !== current) {
onInput(current)
props.onFilter?.(current)
if (!options?.ref) return

// Force a refetch even if the value is unchanged.
// This is important for programmatic changes like Tab completion.
if (prev === value) {
refetch()
return
}
return current
}, "")
queueMicrotask(() => refetch())
}

createEffect(() => {
if (props.filter === undefined) return
if (props.filter === internalFilter()) return
setInternalFilter(props.filter)
onInput(props.filter)
})

createEffect(
on(
Expand Down Expand Up @@ -163,6 +172,8 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
const index = selected ? all.indexOf(selected) : -1
props.onKeyEvent?.(e, selected)

if (e.defaultPrevented) return

if (e.key === "Enter" && !e.isComposing) {
e.preventDefault()
if (selected) handleSelect(selected, index)
Expand All @@ -174,6 +185,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
props.ref?.({
onKeyDown: handleKey,
setScrollRef,
setFilter: (value) => applyFilter(value, { ref: true }),
})

const renderAdd = () => {
Expand Down Expand Up @@ -247,7 +259,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
data-slot="list-search-input"
type="text"
value={internalFilter()}
onChange={setInternalFilter}
onChange={(value) => applyFilter(value)}
onKeyDown={handleKey}
placeholder={searchProps().placeholder}
spellcheck={false}
Expand All @@ -260,7 +272,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
<IconButton
icon="circle-x"
variant="ghost"
onClick={() => setInternalFilter("")}
onClick={() => applyFilter("")}
aria-label={i18n.t("ui.list.clearFilter")}
/>
</Show>
Expand Down Expand Up @@ -295,6 +307,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
data-active={props.key(item) === active()}
data-selected={item === props.current}
onClick={() => handleSelect(item, i())}
onKeyDown={handleKey}
type="button"
onMouseMove={(event) => {
if (!moved(event)) return
Expand Down
Loading