import type { Dirent, Stats } from "node:fs"; import { readdir, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { scorePathMatch, type MatchScore } from "@getpaseo/protocol/search/text-match"; import { isPathInsideRoot } from "./path.js"; import { runGitCommand } from "./run-git-command.js"; export type DirectorySuggestionKind = "file" | "directory"; export type DirectorySuggestionPathFormat = "absolute" | "relative"; export type DirectorySuggestionMatchMode = "fuzzy" | "suffix"; export type PathQueryPolicy = "rooted" | "slashes"; export type BlankQueryBehavior = "none" | "children"; export interface DirectorySuggestionEntry { path: string; kind: DirectorySuggestionKind; } export interface SearchDirectoryEntriesOptions { root: string; query: string; pathFormat: DirectorySuggestionPathFormat; includeFiles?: boolean; includeDirectories?: boolean; matchMode?: DirectorySuggestionMatchMode; pathQueryPolicy?: PathQueryPolicy; rootAliases?: string[]; blankQueryBehavior?: BlankQueryBehavior; traversableHiddenDirectoryNames?: readonly string[]; limit?: number; maxDepth?: number; maxEntriesScanned?: number; confidentResultScanThreshold?: number; respectGitIgnore?: boolean; } interface QueryPlan { isPathQuery: boolean; parentPart: string; searchTerm: string; normalizedQuery: string; browseExactPath?: boolean; } interface ChildEntry { name: string; resolvedPath: string; kind: DirectorySuggestionKind; } interface RawChildEntry { name: string; kind: DirectorySuggestionKind | "symlink"; } interface TraversedEntry extends ChildEntry { visiblePath: string; depth: number; } interface RankedEntry extends DirectorySuggestionEntry { matchTier: number; segmentIndex: number; matchOffset: number; fuzzyScore: number; depth: number; } interface RankFields { matchTier: number; segmentIndex: number; matchOffset: number; fuzzyScore: number; } interface DirectoryListCacheEntry { expiresAt: number; modifiedAtMs: number; changedAtMs: number; entries: RawChildEntry[]; } interface GitIgnoredPathsCacheEntry { expiresAt: number; paths: Promise>; } const DEFAULT_LIMIT = 30; const MAX_LIMIT = 100; const DEFAULT_MAX_DEPTH = 12; const DEFAULT_MAX_ENTRIES_SCANNED = 20_000; const DIRECTORY_LIST_CACHE_TTL_MS = 8_000; const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000; const GIT_IGNORED_PATHS_CACHE_TTL_MS = 8_000; const GIT_IGNORED_PATHS_CACHE_MAX_ENTRIES = 256; // Windows does not reliably update directory mtime/ctime when children change, // so metadata cannot safely validate a cross-request listing cache there. const CAN_VALIDATE_DIRECTORY_CACHE_FROM_METADATA = process.platform !== "win32"; const MAX_CONFIDENT_FUZZY_SKIPS_PER_CHARACTER = 2; const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER; const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER; const NO_FUZZY_SCORE = Number.MAX_SAFE_INTEGER; const NO_MATCH_TIER = 5; export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [ ".agents", ".claude", ".codex", ".github", ".opencode", ".paseo", ".vscode", ] as const; const IGNORED_DIRECTORY_NAMES = new Set([ "node_modules", "venv", "env", "virtualenv", "dist", "build", "target", "out", "coverage", "vendor", "__pycache__", ".git", ]); const directoryListCache = new Map(); const gitIgnoredPathsCache = new Map(); // Discovery and retrieval filter differently, on purpose. Discovery — anything that ranks or // browses candidates the caller has not named — drops gitignored and hidden entries, so pickers // do not offer build output. Retrieval of a path the caller named exactly applies no ignore or // hidden filtering; the only question is whether the path stays inside the root. Clicking a file // reference an agent wrote must open it whether or not Git tracks it. export async function searchDirectoryEntries( options: SearchDirectoryEntriesOptions, ): Promise { const root = await resolveDirectory(options.root); if (!root) return []; const gitIgnoredPaths = options.respectGitIgnore ? await loadGitIgnoredPaths(root) : new Set(); const input = buildSearchInput(options, root, gitIgnoredPaths); if (!input) return []; const exact = input.plan.browseExactPath || (input.matchMode === "suffix" && input.plan.isPathQuery) ? await findExactEntry(input) : null; if (exact && input.limit === 1) return [exact]; const browsesRoot = input.plan.isPathQuery && !input.plan.normalizedQuery; const browsesAbsoluteParent = input.plan.browseExactPath === true; const ranked = browsesRoot || browsesAbsoluteParent ? await searchChildren(input) : await searchTree(input); const results = sortAndFormat(ranked, input.root, input.pathFormat).slice(0, input.limit); return exact ? [exact, ...results.filter((entry) => !sameEntry(entry, exact))].slice(0, input.limit) : results; } function buildSearchInput( options: SearchDirectoryEntriesOptions, root: string, gitIgnoredPaths: Set, ): SearchInput | null { const includeDirectories = options.includeDirectories ?? true; const includeFiles = options.includeFiles ?? false; if (!includeDirectories && !includeFiles) return null; const plan = parseQuery({ query: options.query, root, configuredRoot: path.resolve(options.root), policy: options.pathQueryPolicy ?? "slashes", aliases: options.rootAliases ?? [], blankBehavior: options.blankQueryBehavior ?? "none", }); if (!plan) return null; return { root, plan, includeDirectories, includeFiles, matchMode: options.matchMode ?? "fuzzy", pathFormat: options.pathFormat, hiddenDirectoryNames: new Set(options.traversableHiddenDirectoryNames ?? []), limit: normalizeLimit(options.limit), maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH, maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_ENTRIES_SCANNED, confidentResultScanThreshold: options.confidentResultScanThreshold, gitIgnoredPaths, }; } async function findExactEntry(input: SearchInput): Promise { if (!input.plan.normalizedQuery) return null; const visiblePath = path.resolve(input.root, input.plan.normalizedQuery); const resolvedPath = await realpath(visiblePath).catch(() => null); if (!resolvedPath || !isPathInsideRoot(input.root, resolvedPath)) return null; // No ignore filtering here: the caller named this exact path, so containment above is the // only question left to answer. Filtering belongs to discovery, not retrieval. const info = await stat(resolvedPath).catch(() => null); const kind = getEntryKind(info); if ( !kind || (kind === "directory" && !input.includeDirectories) || (kind === "file" && !input.includeFiles) ) return null; return formatEntry({ path: visiblePath, kind }, input.root, input.pathFormat); } interface SearchInput { root: string; plan: QueryPlan; includeDirectories: boolean; includeFiles: boolean; matchMode: DirectorySuggestionMatchMode; pathFormat: DirectorySuggestionPathFormat; hiddenDirectoryNames: Set; limit: number; maxDepth: number; maxEntriesScanned: number; confidentResultScanThreshold: number | undefined; gitIgnoredPaths: Set; } async function searchChildren(input: SearchInput): Promise { const visibleParent = path.resolve(input.root, input.plan.parentPart || "."); const parent = await realpath(visibleParent).catch(() => null); if (!parent || !isPathInsideRoot(input.root, parent)) return []; if (isGitIgnoredPath(parent, input)) return []; const entries = await readChildren(parent); return entries.flatMap((entry) => { if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, input)) return []; const candidate: TraversedEntry = { ...entry, visiblePath: path.join(visibleParent, entry.name), depth: 1, }; return shouldSuggest(candidate, input) ? [rank(candidate, input)] : []; }); } async function searchTree(input: SearchInput): Promise { if (!(input.maxEntriesScanned > 0)) return []; const roots = (await readChildren(input.root)).filter((entry) => isPathInsideRoot(input.root, entry.resolvedPath), ); const visited = new Set([input.root]); const branches = roots.flatMap((entry) => shouldDiscover(entry, input) ? [ walkBranch( { ...entry, visiblePath: path.join(input.root, entry.name), depth: 1 }, input, visited, ), ] : [], ); const ranked: RankedEntry[] = []; let scanned = 0; const threshold = input.confidentResultScanThreshold; for await (const entry of roundRobin(branches)) { scanned += 1; if (shouldSuggest(entry, input)) ranked.push(rank(entry, input)); if ( scanned >= input.maxEntriesScanned || (threshold && scanned >= threshold && hasConfidentResult(ranked, input.plan.normalizedQuery || input.plan.searchTerm)) ) break; } return ranked; } async function* walkBranch( entry: TraversedEntry, input: SearchInput, visited: Set, ): AsyncGenerator { yield entry; if ( entry.kind !== "directory" || visited.has(entry.resolvedPath) || entry.depth >= input.maxDepth ) return; visited.add(entry.resolvedPath); const children = (await readChildren(entry.resolvedPath)).filter((child) => isPathInsideRoot(input.root, child.resolvedPath), ); const branches = children.flatMap((child) => shouldDiscover(child, input) ? [ walkBranch( { ...child, visiblePath: path.join(entry.visiblePath, child.name), depth: entry.depth + 1, }, input, visited, ), ] : [], ); yield* roundRobin(branches); } async function* roundRobin(branches: Array>): AsyncGenerator { let active = branches; while (active.length) { const nextRound: Array> = []; for (const branch of active) { const next = await branch.next(); if (!next.done) { nextRound.push(branch); yield next.value; } } active = nextRound; } } function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean { if (isGitIgnoredPath(entry.resolvedPath, input)) return false; if (entry.kind === "file") { return input.includeFiles && !entry.name.startsWith("."); } if (IGNORED_DIRECTORY_NAMES.has(entry.name)) return false; if (!entry.name.startsWith(".")) return true; return input.hiddenDirectoryNames.has(entry.name); } function isGitIgnoredPath(absolutePath: string, input: SearchInput): boolean { let candidate = absolutePath; while (candidate !== input.root && isPathInsideRoot(input.root, candidate)) { if (input.gitIgnoredPaths.has(candidate)) return true; candidate = path.dirname(candidate); } return false; } function shouldSuggest(entry: TraversedEntry, input: SearchInput): boolean { if (entry.name.startsWith(".")) return false; if (entry.kind === "directory" && !input.includeDirectories) return false; if (entry.kind === "file" && !input.includeFiles) return false; if (!input.plan.normalizedQuery) return true; if (input.matchMode === "suffix") return suffixMatches(entry.visiblePath, input.root, input.plan.normalizedQuery); return rank(entry, input).matchTier !== NO_MATCH_TIER; } function rank(entry: TraversedEntry, input: SearchInput): RankedEntry { const relativePath = normalizeRelativePath(input.root, entry.visiblePath); const lowerPath = relativePath.toLowerCase(); const query = getRankQuery(input); const segments = lowerPath === "." ? [] : lowerPath.split("/"); const pathScore = query ? scorePathMatch(query, relativePath) : null; const rankFields = input.plan.isPathQuery ? rankPathMatch(pathScore) : rankTextMatch({ query, lowerPath, pathFormat: input.pathFormat, pathScore, segments }); return { path: entry.visiblePath, kind: entry.kind, ...rankFields, depth: relativePath === "." ? 0 : segments.length, }; } function getRankQuery(input: SearchInput): string { return ( input.plan.isPathQuery ? input.plan.normalizedQuery : input.plan.searchTerm ).toLowerCase(); } function rankPathMatch(pathScore: MatchScore | null): RankFields { return { matchTier: pathScore ? Math.min(pathScore.tier, 4) : NO_MATCH_TIER, segmentIndex: NO_SEGMENT_INDEX, matchOffset: pathScore?.offset ?? NO_MATCH_OFFSET, fuzzyScore: pathScore?.spread ?? NO_FUZZY_SCORE, }; } function rankTextMatch(input: { query: string; lowerPath: string; pathFormat: DirectorySuggestionPathFormat; pathScore: MatchScore | null; segments: string[]; }): RankFields { const { query, lowerPath, pathFormat, pathScore, segments } = input; const offset = lowerPath.indexOf(query); const fuzzyScore = scoreFuzzySubsequence(query, segments.at(-1) ?? ""); if (!query) { return { matchTier: 3, segmentIndex: NO_SEGMENT_INDEX, matchOffset: NO_MATCH_OFFSET, fuzzyScore: NO_FUZZY_SCORE, }; } const segmentRank = rankSegmentMatch({ query, segments, offset, fuzzyScore }); if (segmentRank) return segmentRank; if (pathFormat === "relative" ? lowerPath.startsWith(query) : offset >= 0) { return { matchTier: 3, segmentIndex: NO_SEGMENT_INDEX, matchOffset: offset, fuzzyScore: fuzzyScore ?? NO_FUZZY_SCORE, }; } if (fuzzyScore !== null) { return { matchTier: 4, segmentIndex: NO_SEGMENT_INDEX, matchOffset: offset >= 0 ? offset : NO_MATCH_OFFSET, fuzzyScore, }; } return { matchTier: pathScore ? Math.min(pathScore.tier, 4) : NO_MATCH_TIER, segmentIndex: NO_SEGMENT_INDEX, matchOffset: pathScore?.offset ?? NO_MATCH_OFFSET, fuzzyScore: pathScore?.spread ?? NO_FUZZY_SCORE, }; } function rankSegmentMatch(input: { query: string; segments: string[]; offset: number; fuzzyScore: number | null; }): RankFields | null { const { query, segments, offset, fuzzyScore } = input; const matchOffset = offset >= 0 ? offset : NO_MATCH_OFFSET; const exact = findSegmentMatchIndex(segments, (segment) => segment === query); if (exact >= 0) { return { matchTier: 0, segmentIndex: exact, matchOffset, fuzzyScore: fuzzyScore ?? NO_FUZZY_SCORE, }; } const prefix = findSegmentMatchIndex(segments, (segment) => segment.startsWith(query)); if (prefix >= 0) { return { matchTier: 1, segmentIndex: prefix, matchOffset, fuzzyScore: fuzzyScore ?? NO_FUZZY_SCORE, }; } const substring = findSegmentMatchIndex(segments, (segment) => segment.includes(query)); if (substring >= 0) { return { matchTier: 2, segmentIndex: substring, matchOffset, fuzzyScore: fuzzyScore ?? NO_FUZZY_SCORE, }; } return null; } function sortAndFormat( entries: RankedEntry[], root: string, format: DirectorySuggestionPathFormat, ): DirectorySuggestionEntry[] { const unique = new Map(); for (const entry of entries) { const key = `${entry.kind}:${entry.path}`; const existing = unique.get(key); if (!existing || compareRank(entry, existing) < 0) unique.set(key, entry); } return [...unique.values()].sort(compareRank).map((entry) => formatEntry(entry, root, format)); } function formatEntry( entry: DirectorySuggestionEntry, root: string, format: DirectorySuggestionPathFormat, ): DirectorySuggestionEntry { return { path: format === "absolute" ? entry.path : normalizeRelativePath(root, entry.path), kind: entry.kind, }; } function compareRank(left: RankedEntry, right: RankedEntry): number { return ( left.matchTier - right.matchTier || left.segmentIndex - right.segmentIndex || left.matchOffset - right.matchOffset || left.fuzzyScore - right.fuzzyScore || left.depth - right.depth || compareKinds(left.kind, right.kind) || left.path.localeCompare(right.path) ); } function compareKinds(left: DirectorySuggestionKind, right: DirectorySuggestionKind): number { if (left === right) return 0; return left === "directory" ? -1 : 1; } function hasConfidentResult(entries: RankedEntry[], query: string): boolean { const maxFuzzyScore = query.length * MAX_CONFIDENT_FUZZY_SKIPS_PER_CHARACTER; return entries.some( (entry) => entry.matchTier < 4 || (entry.matchTier === 4 && entry.fuzzyScore <= maxFuzzyScore), ); } function suffixMatches(visiblePath: string, root: string, query: string): boolean { const querySegments = query.toLowerCase().split("/").filter(Boolean); if (querySegments.length === 0) return false; const pathSegments = normalizeRelativePath(root, visiblePath) .toLowerCase() .split("/") .filter(Boolean); const offset = pathSegments.length - querySegments.length; return ( offset >= 0 && querySegments.every((segment, index) => pathSegments[offset + index] === segment) ); } function parseQuery(input: { query: string; root: string; configuredRoot: string; policy: PathQueryPolicy; aliases: string[]; blankBehavior: BlankQueryBehavior; }): QueryPlan | null { const normalizedInput = normalizeQueryInput(input); if (!normalizedInput) return null; const { typed, rooted } = normalizedInput; const normalized = normalizedInput.normalized; if (!normalized) { const explicitlyBrowseRoot = rooted || typed === "."; if (!explicitlyBrowseRoot && input.blankBehavior !== "children") return null; return { isPathQuery: true, parentPart: "", searchTerm: "", normalizedQuery: "" }; } if (normalizedInput.isAbsolute && isFilesystemRoot(input.root) && !normalized.includes("/")) { return { isPathQuery: true, parentPart: normalized, searchTerm: "", normalizedQuery: normalized, browseExactPath: true, }; } const isPathQuery = rooted || (input.policy === "slashes" && normalized.includes("/")); const slash = normalized.lastIndexOf("/"); return { isPathQuery, parentPart: isPathQuery && slash >= 0 ? normalized.slice(0, slash) : "", searchTerm: isPathQuery && slash >= 0 ? normalized.slice(slash + 1) : normalized, normalizedQuery: normalized, }; } function normalizeQueryInput(input: { query: string; root: string; configuredRoot: string; aliases: string[]; }): { typed: string; normalized: string; rooted: boolean; isAbsolute: boolean } | null { const typed = input.query.trim().replace(/\\/g, "/"); let normalized = typed; let rooted = false; let isAbsolute = false; for (const alias of input.aliases) { if (normalized === alias || normalized.startsWith(`${alias}/`)) { rooted = true; normalized = normalized.slice(alias.length).replace(/^\/+/, ""); break; } } if (path.isAbsolute(normalized)) { isAbsolute = true; const browseAbsoluteDirectory = normalized.endsWith("/"); const absolutePath = path.resolve(normalized); let queryRoot: string | null = null; if (isPathInsideRoot(input.root, absolutePath)) { queryRoot = input.root; } else if (isPathInsideRoot(input.configuredRoot, absolutePath)) { queryRoot = input.configuredRoot; } if (!queryRoot) return null; rooted = true; normalized = normalizeRelativePath(queryRoot, absolutePath); if (browseAbsoluteDirectory && normalized !== ".") { normalized = `${normalized}/`; } } if (normalized.startsWith("./")) rooted = true; normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/"); if (normalized === "." && (rooted || typed === ".")) { normalized = ""; } return { typed, normalized, rooted, isAbsolute }; } function isFilesystemRoot(inputPath: string): boolean { return path.relative(path.parse(inputPath).root, inputPath) === ""; } async function resolveDirectory(inputPath: string): Promise { const resolved = await realpath(path.resolve(inputPath)).catch(() => null); if (!resolved) return null; const info = await stat(resolved).catch(() => null); return info?.isDirectory() ? resolved : null; } async function readChildren(directory: string): Promise { const directoryInfo = await stat(directory).catch(() => null); if (!directoryInfo?.isDirectory()) return []; const cached = CAN_VALIDATE_DIRECTORY_CACHE_FROM_METADATA ? directoryListCache.get(directory) : undefined; let rawEntries: RawChildEntry[]; if ( cached && cached.expiresAt > Date.now() && cached.modifiedAtMs === directoryInfo.mtimeMs && cached.changedAtMs === directoryInfo.ctimeMs ) { rawEntries = cached.entries; } else { const dirents = await readdir(directory, { withFileTypes: true }).catch(() => [] as Dirent[]); rawEntries = dirents .map(toRawChildEntry) .filter((entry): entry is RawChildEntry => entry !== null) .sort((left, right) => left.name.localeCompare(right.name)); if (CAN_VALIDATE_DIRECTORY_CACHE_FROM_METADATA) { directoryListCache.set(directory, { expiresAt: Date.now() + DIRECTORY_LIST_CACHE_TTL_MS, modifiedAtMs: directoryInfo.mtimeMs, changedAtMs: directoryInfo.ctimeMs, entries: rawEntries, }); pruneCache(); } } return (await Promise.all(rawEntries.map((entry) => resolveChild(directory, entry)))) .filter((entry): entry is ChildEntry => entry !== null) .sort((left, right) => left.name.localeCompare(right.name)); } async function loadGitIgnoredPaths(root: string): Promise> { const now = Date.now(); const cached = gitIgnoredPathsCache.get(root); if (cached && cached.expiresAt > now) return cached.paths; const paths = runGitCommand(["ls-files", "-o", "-i", "--directory", "--exclude-standard", "-z"], { cwd: root, envOverlay: { GIT_OPTIONAL_LOCKS: "0" }, timeout: 10_000, }) .then( (result) => new Set( result.stdout .split("\0") .filter(Boolean) .map((relativePath) => path.resolve(root, relativePath.replace(/\/$/, ""))), ), ) .catch(() => new Set()); gitIgnoredPathsCache.set(root, { expiresAt: now + GIT_IGNORED_PATHS_CACHE_TTL_MS, paths, }); pruneGitIgnoredPathsCache(now); return paths; } function pruneGitIgnoredPathsCache(now: number): void { if (gitIgnoredPathsCache.size <= GIT_IGNORED_PATHS_CACHE_MAX_ENTRIES) return; for (const [key, entry] of gitIgnoredPathsCache) { if (entry.expiresAt <= now) gitIgnoredPathsCache.delete(key); } while (gitIgnoredPathsCache.size > GIT_IGNORED_PATHS_CACHE_MAX_ENTRIES) { const key = gitIgnoredPathsCache.keys().next().value; if (!key) return; gitIgnoredPathsCache.delete(key); } } function toRawChildEntry(dirent: Dirent): RawChildEntry | null { if (dirent.isDirectory()) return { name: dirent.name, kind: "directory" }; if (dirent.isFile()) return { name: dirent.name, kind: "file" }; if (dirent.isSymbolicLink()) return { name: dirent.name, kind: "symlink" }; return null; } async function resolveChild(directory: string, entry: RawChildEntry): Promise { const visiblePath = path.join(directory, entry.name); if (entry.kind !== "symlink") { return { name: entry.name, resolvedPath: visiblePath, kind: entry.kind }; } const resolvedPath = await realpath(visiblePath).catch(() => null); if (!resolvedPath) return null; const info = await stat(resolvedPath).catch(() => null); const kind = getEntryKind(info); return kind ? { name: entry.name, resolvedPath, kind } : null; } function getEntryKind(info: Stats | null): DirectorySuggestionKind | null { if (info?.isDirectory()) return "directory"; if (info?.isFile()) return "file"; return null; } function pruneCache(): void { if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) return; for (const [key, entry] of directoryListCache) if (entry.expiresAt <= Date.now()) directoryListCache.delete(key); while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) { const key = directoryListCache.keys().next().value; if (!key) return; directoryListCache.delete(key); } } function normalizeLimit(limit: number | undefined): number { const candidate = typeof limit === "number" && Number.isFinite(limit) ? Math.trunc(limit) : DEFAULT_LIMIT; return Math.max(1, Math.min(MAX_LIMIT, candidate)); } function normalizeRelativePath(root: string, absolutePath: string): string { const relative = path.relative(root, absolutePath); return relative ? relative.split(path.sep).join("/") : "."; } function scoreFuzzySubsequence(query: string, candidate: string): number | null { let queryIndex = 0; let first = -1; let previous = -1; let gaps = 0; for (let index = 0; index < candidate.length && queryIndex < query.length; index += 1) { if (candidate[index] !== query[queryIndex]) continue; if (first < 0) first = index; if (previous >= 0) gaps += index - previous - 1; previous = index; queryIndex += 1; } return queryIndex === query.length && first >= 0 ? first + gaps : null; } function findSegmentMatchIndex( segments: string[], predicate: (segment: string) => boolean, ): number { return segments.findIndex((segment) => predicate(segment)); } function sameEntry(left: DirectorySuggestionEntry, right: DirectorySuggestionEntry): boolean { return left.path === right.path && left.kind === right.kind; }