diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index bf4a1f9edd4..ea44cdcba0b 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -19,7 +19,17 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const dialog = useDialog() const home = createMemo(() => sync.data.path.home) - const root = createMemo(() => sync.data.path.home || sync.data.path.directory) + + function isAbsolutePath(p: string) { + return p.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(p) + } + + function getParentPath(p: string) { + const normalized = p.replace(/[\\/]+$/, "") + const lastSlash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")) + if (lastSlash <= 0) return "/" + return normalized.slice(0, lastSlash) + } function join(base: string | undefined, rel: string) { const b = (base ?? "").replace(/[\\/]+$/, "") @@ -29,36 +39,45 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return b + "/" + r } - function display(rel: string) { - const full = join(root(), rel) + function display(fullPath: string) { const h = home() - if (!h) return full - if (full === h) return "~" - if (full.startsWith(h + "/") || full.startsWith(h + "\\")) { - return "~" + full.slice(h.length) + if (!h) return fullPath + if (fullPath === h) return "~" + if (fullPath.startsWith(h + "/") || fullPath.startsWith(h + "\\")) { + return "~" + fullPath.slice(h.length) } - return full + return fullPath } - function normalizeQuery(query: string) { + async function fetchDirs(filter: string) { + const trimmed = filter.trim() const h = home() - if (!query) return query - if (query.startsWith("~/")) return query.slice(2) + // Determine search directory and query based on input + let directory: string + let query: string - if (h) { - const lc = query.toLowerCase() - const hc = h.toLowerCase() - if (lc === hc || lc.startsWith(hc + "/") || lc.startsWith(hc + "\\")) { - return query.slice(h.length).replace(/^[\\/]+/, "") + if (isAbsolutePath(trimmed)) { + // Check if path ends with / - user wants to see directory contents + if (trimmed.endsWith("/") || trimmed.endsWith("\\")) { + directory = trimmed.replace(/[\\/]+$/, "") + query = "" + } else { + // Absolute path without trailing slash: use parent directory, search for basename + directory = getParentPath(trimmed) + const basename = trimmed.slice(directory.length).replace(/^[\\/]+/, "") + query = basename } + } else if (trimmed.startsWith("~/")) { + // Home-relative path + directory = h || "/" + query = trimmed.slice(2) + } else { + // Relative query: search in home + directory = h || "/" + query = trimmed } - return query - } - - async function fetchDirs(query: string) { - const directory = root() if (!directory) return [] as string[] const results = await sdk.client.find @@ -66,24 +85,26 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { .then((x) => x.data ?? []) .catch(() => []) - return results.map((x) => x.replace(/[\\/]+$/, "")) + // Results are relative to directory - convert to absolute paths + return results.map((x) => { + const rel = x.replace(/[\\/]+$/, "") + return join(directory, rel) + }) } const directories = async (filter: string) => { - const query = normalizeQuery(filter.trim()) - return fetchDirs(query) + return fetchDirs(filter) } - function resolve(rel: string) { - const absolute = join(root(), rel) - props.onSelect(props.multiple ? [absolute] : absolute) + function resolve(absolutePath: string) { + props.onSelect(props.multiple ? [absolutePath] : absolutePath) dialog.close() } return ( x} @@ -92,17 +113,17 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { resolve(path) }} > - {(rel) => { - const path = display(rel) + {(absolutePath) => { + const displayPath = display(absolutePath) return (
- +
- {getDirectory(path)} + {getDirectory(displayPath)} - {getFilename(path)} + {getFilename(displayPath)}
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 76b7be4b72b..33de499dbf3 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -369,13 +369,18 @@ export namespace File { }) } - export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { + export async function search(input: { + query: string + directory?: string + limit?: number + dirs?: boolean + type?: "file" | "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 }) - - const result = await state().then((x) => x.files()) + const searchDir = input.directory + log.info("search", { query, kind, directory: searchDir }) const hidden = (item: string) => { const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") @@ -393,6 +398,48 @@ export namespace File { } return [...visible, ...hiddenItems] } + + // If a custom directory is provided, scan it directly instead of using cached results + if (searchDir && searchDir !== Instance.directory) { + const dirs = new Set() + const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) + const shouldIgnore = (name: string) => name.startsWith(".") && !preferHidden + const shouldIgnoreNested = (name: string) => (name.startsWith(".") && !preferHidden) || ignoreNested.has(name) + + try { + const top = await fs.promises.readdir(searchDir, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) + + for (const entry of top) { + if (!entry.isDirectory()) continue + if (shouldIgnore(entry.name)) continue + dirs.add(entry.name + "/") + + // Scan one level deeper + const base = path.join(searchDir, entry.name) + const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) + for (const child of children) { + if (!child.isDirectory()) continue + if (shouldIgnoreNested(child.name)) continue + dirs.add(entry.name + "/" + child.name + "/") + } + } + + const items = Array.from(dirs) + if (!query) { + return sortHiddenLast(items.toSorted()).slice(0, limit) + } + const searchLimit = !preferHidden ? limit * 20 : limit + const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target) + return sortHiddenLast(sorted).slice(0, limit) + } catch (err) { + log.error("search directory scan failed", { directory: searchDir, error: err }) + return [] + } + } + + // Use cached results for Instance.directory + const result = await state().then((x) => x.files()) + if (!query) { if (kind === "file") return result.files.slice(0, limit) return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) diff --git a/packages/opencode/src/server/routes/file.ts b/packages/opencode/src/server/routes/file.ts index 60789ef4b72..ccdf3a86ba3 100644 --- a/packages/opencode/src/server/routes/file.ts +++ b/packages/opencode/src/server/routes/file.ts @@ -62,7 +62,8 @@ export const FileRoutes = lazy(() => validator( "query", z.object({ - query: z.string(), + query: z.string().optional().default(""), + directory: z.string().optional(), dirs: z.enum(["true", "false"]).optional(), type: z.enum(["file", "directory"]).optional(), limit: z.coerce.number().int().min(1).max(200).optional(), @@ -70,11 +71,13 @@ export const FileRoutes = lazy(() => ), async (c) => { const query = c.req.valid("query").query + const directory = c.req.valid("query").directory 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({ query, + directory, limit: limit ?? 10, dirs: dirs !== "false", type,