Skip to content
Open
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
87 changes: 54 additions & 33 deletions packages/app/src/components/dialog-select-directory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(/[\\/]+$/, "")
Expand All @@ -29,61 +39,72 @@ 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
.files({ directory, query, type: "directory", limit: 50 })
.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 (
<Dialog title={props.title ?? "Open project"}>
<List
search={{ placeholder: "Search folders", autofocus: true }}
search={{ placeholder: "Search folders (use / for absolute paths)", autofocus: true }}
emptyMessage="No folders found"
items={directories}
key={(x) => x}
Expand All @@ -92,17 +113,17 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
resolve(path)
}}
>
{(rel) => {
const path = display(rel)
{(absolutePath) => {
const displayPath = display(absolutePath)
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: rel, type: "directory" }} class="shrink-0 size-4" />
<FileIcon node={{ path: absolutePath, 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)}
{getDirectory(displayPath)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(displayPath)}</span>
</div>
</div>
</div>
Expand Down
55 changes: 51 additions & 4 deletions packages/opencode/src/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/\/+$/, "")
Expand All @@ -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<string>()
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)
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/src/server/routes/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,22 @@ 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(),
}),
),
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,
Expand Down