diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index bf4a1f9edd4..85196f90a80 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -30,8 +30,18 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { } function display(rel: string) { - const full = join(root(), rel) + // If already in display format (~/...), return as-is + if (rel.startsWith("~/")) return rel + // If absolute path, convert to ~/ format if under home const h = home() + if (rel.startsWith("/")) { + if (h && (rel === h || rel.startsWith(h + "/") || rel.startsWith(h + "\\"))) { + return "~" + rel.slice(h.length) + } + return rel + } + // Relative path - convert to display format + const full = join(root(), rel) if (!h) return full if (full === h) return "~" if (full.startsWith(h + "/") || full.startsWith(h + "\\")) { @@ -70,12 +80,34 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { } const directories = async (filter: string) => { - const query = normalizeQuery(filter.trim()) - return fetchDirs(query) + const trimmed = filter.trim() + const query = normalizeQuery(trimmed) + const results = await fetchDirs(query) + + // Transform results to match user's input format + const h = home() + if (trimmed.startsWith("~/")) { + // User typed ~/... so prefix results with ~/ + return results.map((r) => "~/" + r) + } + if (h && trimmed.toLowerCase().startsWith(h.toLowerCase())) { + // User typed absolute path like /Users/huy/... so prefix with home + return results.map((r) => h + "/" + r) + } + return results } function resolve(rel: string) { - const absolute = join(root(), rel) + // Handle paths that are already absolute or start with ~/ + const h = home() + let absolute: string + if (rel.startsWith("~/") && h) { + absolute = h + rel.slice(1) // Replace ~ with home path + } else if (rel.startsWith("/")) { + absolute = rel // Already absolute + } else { + absolute = join(root(), rel) + } props.onSelect(props.multiple ? [absolute] : absolute) dialog.close() } @@ -87,6 +119,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { emptyMessage="No folders found" items={directories} key={(x) => x} + skipClientFilter onSelect={(path) => { if (!path) return resolve(path) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index d2ff1d0b14c..a0198ac5128 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -369,11 +369,18 @@ export namespace File { }) } - export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { + export async function search(input: { + directory?: string + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) { + const directory = input.directory ?? Instance.directory const query = input.query.trim() const limit = input.limit ?? 100 const kind = input.type ?? (input.dirs === false ? "file" : "all") - log.info("search", { query, kind }) + log.info("search", { directory, query, kind }) const result = await state().then((x) => x.files()) @@ -405,6 +412,34 @@ export namespace File { const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target) const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted + // If searching for directories and query looks like a path, + // check if it actually exists on disk and list its subdirectories + if (kind === "directory" && query.includes("/")) { + const normalizedQuery = query.replace(/\/+$/, "") + const fullPath = path.join(directory, normalizedQuery) + try { + const stat = await fs.promises.stat(fullPath) + if (stat.isDirectory()) { + // Add the directory itself if not already in results + if (!output.includes(normalizedQuery + "/")) { + output.unshift(normalizedQuery + "/") + } + // Also list immediate subdirectories + const children = await fs.promises.readdir(fullPath, { withFileTypes: true }).catch(() => []) + for (const child of children) { + if (!child.isDirectory()) continue + if (child.name.startsWith(".")) continue // Skip hidden dirs + const childPath = normalizedQuery + "/" + child.name + "/" + if (!output.includes(childPath)) { + output.push(childPath) + } + } + } + } catch { + // Path doesn't exist, ignore + } + } + log.info("search", { query, kind, results: output.length }) return output } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 04ec4673ec4..a3ccb170585 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1936,6 +1936,7 @@ export namespace Server { validator( "query", z.object({ + directory: z.string().optional(), query: z.string(), dirs: z.enum(["true", "false"]).optional(), type: z.enum(["file", "directory"]).optional(), @@ -1943,11 +1944,13 @@ export namespace Server { }), ), async (c) => { + const directory = c.req.valid("query").directory const query = c.req.valid("query").query const dirs = c.req.valid("query").dirs const type = c.req.valid("query").type const limit = c.req.valid("query").limit const results = await File.search({ + directory, query, limit: limit ?? 10, dirs: dirs !== "false", diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index 416f030ef49..52660ccd7b6 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -13,6 +13,7 @@ export interface FilteredListProps { sortBy?: (a: T, b: T) => number sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number onSelect?: (value: T | undefined, index: number) => void + skipClientFilter?: boolean } export function useFilteredList(props: FilteredListProps) { @@ -25,11 +26,14 @@ export function useFilteredList(props: FilteredListProps) { }), async ({ filter, items }) => { const needle = filter?.toLowerCase() + const isAsync = typeof props.items === "function" const all = (items ?? (await (props.items as (filter: string) => T[] | Promise)(needle))) || [] const result = pipe( all, (x) => { - if (!needle) return x + // Skip client-side filtering for async items when skipClientFilter is true + // (server already filtered the results) + if (!needle || (isAsync && props.skipClientFilter)) return x if (!props.filterKeys && Array.isArray(x) && x.every((e) => typeof e === "string")) { return fuzzysort.go(needle, x).map((x) => x.target) as T[] }