diff options
Diffstat (limited to 'packages/app/src/components/dialog-select-directory.tsx')
| -rw-r--r-- | packages/app/src/components/dialog-select-directory.tsx | 277 |
1 files changed, 145 insertions, 132 deletions
diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 6e7af3d90..515e640c9 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -2,13 +2,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" +import type { ListRef } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/util/path" import fuzzysort from "fuzzysort" 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 @@ -21,157 +21,131 @@ type Row = { search: string } -export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { - const sync = useGlobalSync() - const sdk = useGlobalSDK() - const dialog = useDialog() - const language = useLanguage() - - 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 || fallbackPath()?.home || fallbackPath()?.directory, - ) - - const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>() +function cleanInput(value: string) { + const first = (value ?? "").split(/\r?\n/)[0] ?? "" + return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() +} - const clean = (value: string) => { - const first = (value ?? "").split(/\r?\n/)[0] ?? "" - return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() - } +function normalizePath(input: string) { + const v = input.replaceAll("\\", "/") + if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") + return v.replace(/\/+/g, "/") +} - function normalize(input: string) { - const v = input.replaceAll("\\", "/") - if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") - return v.replace(/\/+/g, "/") - } +function normalizeDriveRoot(input: string) { + const v = normalizePath(input) + if (/^[A-Za-z]:$/.test(v)) return v + "/" + return v +} - function normalizeDriveRoot(input: string) { - const v = normalize(input) - if (/^[A-Za-z]:$/.test(v)) return v + "/" - return v - } +function trimTrailing(input: string) { + const v = normalizeDriveRoot(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v + return v.replace(/\/+$/, "") +} - function trimTrailing(input: string) { - const v = normalizeDriveRoot(input) - if (v === "/") return v - if (v === "//") return v - if (/^[A-Za-z]:\/$/.test(v)) return v - return v.replace(/\/+$/, "") - } +function joinPath(base: string | undefined, rel: string) { + const b = trimTrailing(base ?? "") + const r = trimTrailing(rel).replace(/^\/+/, "") + if (!b) return r + if (!r) return b + if (b.endsWith("/")) return b + r + return b + "/" + r +} - function join(base: string | undefined, rel: string) { - const b = trimTrailing(base ?? "") - const r = trimTrailing(rel).replace(/^\/+/, "") - if (!b) return r - if (!r) return b - if (b.endsWith("/")) return b + r - return b + "/" + r - } +function rootOf(input: string) { + const v = normalizeDriveRoot(input) + if (v.startsWith("//")) return "//" + if (v.startsWith("/")) return "/" + if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3) + return "" +} - function rootOf(input: string) { - const v = normalizeDriveRoot(input) - if (v.startsWith("//")) return "//" - if (v.startsWith("/")) return "/" - if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3) - return "" - } +function parentOf(input: string) { + const v = trimTrailing(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v - 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) +} - 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 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 tildeOf(absolute: string, home: string) { + const full = trimTrailing(absolute) + if (!home) return "" - function display(path: string, input: string) { - const full = trimTrailing(path) - if (modeOf(input) === "absolute") return full + const hn = trimTrailing(home) + const lc = full.toLowerCase() + const hc = hn.toLowerCase() + if (lc === hc) return "~" + if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) + return "" +} - return tildeOf(full) || full - } +function displayPath(path: string, input: string, home: string) { + const full = trimTrailing(path) + if (modeOf(input) === "absolute") return full + return tildeOf(full, home) || full +} - function tildeOf(absolute: string) { - const full = trimTrailing(absolute) - const h = home() - 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 "" +function toRow(absolute: string, home: string): Row { + const full = trimTrailing(absolute) + const tilde = tildeOf(full, home) + const withSlash = (value: string) => { + if (!value) return "" + if (value.endsWith("/")) return value + return value + "/" } - 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 } +} - const search = Array.from( - new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), - ).join("\n") - return { absolute: full, search } - } +function useDirectorySearch(args: { + sdk: ReturnType<typeof useGlobalSDK> + start: () => string | undefined + home: () => string +}) { + const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>() + let current = 0 - function scoped(value: string) { - const base = start() + const scoped = (value: string) => { + const base = args.start() if (!base) return const raw = normalizeDriveRoot(value) if (!raw) return { directory: trimTrailing(base), path: "" } - const h = home() - if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" } - if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) } + const h = args.home() + if (raw === "~") return { directory: trimTrailing(h || base), path: "" } + if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) } const root = rootOf(raw) if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) } return { directory: trimTrailing(base), path: raw } } - async function dirs(dir: string) { + const dirs = async (dir: string) => { const key = trimTrailing(dir) const existing = cache.get(key) if (existing) return existing - const request = sdk.client.file + const request = args.sdk.client.file .list({ directory: key, path: "" }) .then((x) => x.data ?? []) .catch(() => []) @@ -188,32 +162,34 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return request } - async function match(dir: string, query: string, limit: number) { + const match = async (dir: string, query: string, limit: number) => { const items = await dirs(dir) if (!query) return items.slice(0, limit).map((x) => x.absolute) return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute) } - const directories = async (filter: string) => { - const value = clean(filter) + return async (filter: string) => { + const token = ++current + const active = () => token === current + + const value = cleanInput(filter) const scopedInput = scoped(value) if (!scopedInput) return [] as string[] const raw = normalizeDriveRoot(value) const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/") - const query = normalizeDriveRoot(scopedInput.path) const find = () => - sdk.client.find + args.sdk.client.find .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 }) .then((x) => x.data ?? []) .catch(() => []) if (!isPath) { const results = await find() - - return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50) + if (!active()) return [] + return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50) } const segments = query.replace(/^\/+/, "").split("/") @@ -224,17 +200,20 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const branch = 4 let paths = [scopedInput.directory] for (const part of head) { + if (!active()) return [] if (part === "..") { paths = paths.map(parentOf) continue } const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat() + if (!active()) return [] paths = Array.from(new Set(next)).slice(0, cap) if (paths.length === 0) return [] as string[] } const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat() + if (!active()) return [] const deduped = Array.from(new Set(out)) const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : "" const expand = !raw.endsWith("/") @@ -249,13 +228,47 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { if (!target) return deduped.slice(0, 50) const children = await match(target, "", 30) + if (!active()) return [] const items = Array.from(new Set([...deduped, ...children])) return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50) } +} + +export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { + const sync = useGlobalSync() + const sdk = useGlobalSDK() + const dialog = useDialog() + const language = useLanguage() + + 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 || fallbackPath()?.home || fallbackPath()?.directory, + ) + + const directories = useDirectorySearch({ + sdk, + home, + start, + }) const items = async (value: string) => { const results = await directories(value) - return results.map(row) + return results.map((absolute) => toRow(absolute, home())) } function resolve(absolute: string) { @@ -273,7 +286,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { key={(x) => x.absolute} filterKeys={["search"]} ref={(r) => (list = r)} - onFilter={(value) => setFilter(clean(value))} + onFilter={(value) => setFilter(cleanInput(value))} onKeyEvent={(e, item) => { if (e.key !== "Tab") return if (e.shiftKey) return @@ -282,7 +295,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { e.preventDefault() e.stopPropagation() - const value = display(item.absolute, filter()) + const value = displayPath(item.absolute, filter(), home()) list?.setFilter(value.endsWith("/") ? value : value + "/") }} onSelect={(path) => { @@ -291,7 +304,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { }} > {(item) => { - const path = display(item.absolute, filter()) + const path = displayPath(item.absolute, filter(), home()) if (path === "~") { return ( <div class="w-full flex items-center justify-between rounded-md"> |
