diff options
| author | Filip <[email protected]> | 2026-02-02 19:15:53 +0100 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-02 12:15:53 -0600 |
| commit | b9aad20be651050880bf2bc3b4c857f16a970402 (patch) | |
| tree | 3d7d609573fe50b3b1873355560feeea10bc96b6 | |
| parent | 965f32ad634d208bbb34c5a9bb12e501a009378b (diff) | |
| download | opencode-b9aad20be651050880bf2bc3b4c857f16a970402.tar.gz opencode-b9aad20be651050880bf2bc3b4c857f16a970402.zip | |
fix(app): open project search (#11783)
| -rw-r--r-- | packages/app/src/components/dialog-select-directory.tsx | 182 | ||||
| -rw-r--r-- | packages/ui/src/components/list.tsx | 45 |
2 files changed, 179 insertions, 48 deletions
diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index b9a7d6ed9..6e7af3d90 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -4,10 +4,11 @@ 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 @@ -15,18 +16,47 @@ interface DialogSelectDirectoryProps { 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, "/") @@ -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() @@ -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("/") @@ -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 } @@ -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) { @@ -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> diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 15854180e..886ac5e6c 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -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 }) { @@ -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 @@ -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( @@ -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) @@ -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 = () => { @@ -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} @@ -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> @@ -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 |
