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
41 changes: 37 additions & 4 deletions packages/app/src/components/dialog-select-directory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "\\")) {
Expand Down Expand Up @@ -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()
}
Expand All @@ -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)
Expand Down
39 changes: 37 additions & 2 deletions packages/opencode/src/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down Expand Up @@ -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
}
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1936,18 +1936,21 @@ 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(),
limit: z.coerce.number().int().min(1).max(200).optional(),
}),
),
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",
Expand Down
6 changes: 5 additions & 1 deletion packages/ui/src/hooks/use-filtered-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface FilteredListProps<T> {
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<T>(props: FilteredListProps<T>) {
Expand All @@ -25,11 +26,14 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
}),
async ({ filter, items }) => {
const needle = filter?.toLowerCase()
const isAsync = typeof props.items === "function"
const all = (items ?? (await (props.items as (filter: string) => T[] | Promise<T[]>)(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[]
}
Expand Down