Files
paseo/packages/server/src/utils/directory-suggestions.ts
T
paseo-bot[bot] a7a21d91c2 Keep overlapping directory searches from exhausting the heap (#7265)
* fix(server): keep overlapping directory searches within their scan budget

Construct child paths and branch iterators as the round-robin walk consumes them. Eager sibling expansion retained hundreds of thousands of unvisited children per search and exhausted the heap when requests overlapped.

* test(server): stop directory-search children before fixture cleanup

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 03:52:15 +02:00

892 lines
30 KiB
TypeScript

import type { Dirent, Stats } from "node:fs";
import * as fs from "node:fs/promises";
import path from "node:path";
import pLimit from "p-limit";
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;
// Absolute paths that discovery never walks into. A path the caller types explicitly still
// resolves, and browsing inside one lists a single level at a time.
excludedDiscoveryPaths?: readonly string[];
}
interface QueryPlan {
isPathQuery: boolean;
parentPart: string;
searchTerm: string;
normalizedQuery: string;
browseExactPath?: boolean;
}
interface ChildEntry {
name: string;
resolvedPath: string;
kind: DirectorySuggestionKind;
viaSymlink: boolean;
}
interface RawChildEntry {
name: string;
kind: DirectorySuggestionKind | "symlink";
}
interface WalkStart {
visiblePath: string;
resolvedPath: string;
}
interface WalkChildrenOptions {
parent: WalkStart;
depth: number;
input: SearchInput;
visited: Set<string>;
}
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<Set<string>>;
}
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;
// Each pending fs call holds one of libuv's shared threadpool threads (4 by default), and a read on
// a hung mount or a protected folder never returns. Searches pile up while the user types, so they
// share two threads and leave the rest of the pool to the daemon. Searches over one tree walk it
// in the same order and stop at the same stuck folder, so they share its pending call and hold one
// thread between them instead of both.
const filesystemCalls = pLimit(2);
const pendingFilesystemCalls = new Map<string, Promise<unknown>>();
function sharedFilesystemCall<T>(key: string, call: () => Promise<T>): Promise<T> {
const pending = pendingFilesystemCalls.get(key);
if (pending) return pending as Promise<T>;
const started = filesystemCalls(call).finally(() => pendingFilesystemCalls.delete(key));
pendingFilesystemCalls.set(key, started);
return started;
}
function readdir(directory: string): Promise<Dirent[]> {
return sharedFilesystemCall(`readdir:${directory}`, () =>
fs.readdir(directory, { withFileTypes: true }),
);
}
function realpath(target: string): Promise<string> {
return sharedFilesystemCall(`realpath:${target}`, () => fs.realpath(target));
}
function stat(target: string): Promise<Stats> {
return sharedFilesystemCall(`stat:${target}`, () => fs.stat(target));
}
export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [
".agents",
".claude",
".codex",
".cursor",
".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<string, DirectoryListCacheEntry>();
const gitIgnoredPathsCache = new Map<string, GitIgnoredPathsCacheEntry>();
// 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<DirectorySuggestionEntry[]> {
const root = await resolveDirectory(options.root);
if (!root) return [];
const gitIgnoredPaths = options.respectGitIgnore
? await loadGitIgnoredPaths(root)
: new Set<string>();
const excludedDiscoveryPaths = await resolveExistingPaths(options.excludedDiscoveryPaths ?? []);
const input = buildSearchInput(options, root, gitIgnoredPaths, excludedDiscoveryPaths);
if (!input) return [];
const exact =
input.plan.browseExactPath ||
(input.plan.isPathQuery && (input.matchMode === "suffix" || resolvesTypedPath(input)))
? await findExactEntry(input)
: null;
if (exact && input.limit === 1) return [exact];
const ranked = await searchCandidates(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;
}
async function searchCandidates(input: SearchInput): Promise<RankedEntry[]> {
const browsesRoot = input.plan.isPathQuery && !input.plan.normalizedQuery;
const browsesAbsoluteParent = input.plan.browseExactPath === true;
if (browsesRoot || browsesAbsoluteParent) return searchChildren(input);
if (!resolvesTypedPath(input) || !input.plan.parentPart) return searchTree(input);
// A typed parent such as `~/Developer/pas` anchors the walk under `~/Developer`. A parent that
// does not exist falls back to matching the typed path against the whole tree.
const typedParent = await resolveTypedParent(input);
if (!typedParent) return searchTree(input);
if (isInsideExcludedPath(typedParent.resolvedPath, input)) return searchChildren(input);
return searchTree(input, typedParent);
}
// Only a rooted query under the rooted policy names a location. Under the slashes policy a query
// with a slash is still a fragment that may match anywhere in the tree.
function resolvesTypedPath(input: SearchInput): boolean {
return input.pathQueryPolicy === "rooted" && input.plan.isPathQuery;
}
async function resolveTypedParent(input: SearchInput): Promise<WalkStart | null> {
const visiblePath = path.resolve(input.root, input.plan.parentPart);
const resolvedPath = await realpath(visiblePath).catch(() => null);
if (!resolvedPath || !isPathInsideRoot(input.root, resolvedPath)) return null;
const info = await stat(resolvedPath).catch(() => null);
return info?.isDirectory() ? { visiblePath, resolvedPath } : null;
}
async function resolveExistingPaths(paths: readonly string[]): Promise<Set<string>> {
const resolved = await Promise.all(
paths.map((candidate) => realpath(path.resolve(candidate)).catch(() => null)),
);
return new Set(resolved.filter((candidate): candidate is string => candidate !== null));
}
function buildSearchInput(
options: SearchDirectoryEntriesOptions,
root: string,
gitIgnoredPaths: Set<string>,
excludedDiscoveryPaths: Set<string>,
): 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",
pathQueryPolicy: options.pathQueryPolicy ?? "slashes",
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,
excludedDiscoveryPaths,
};
}
async function findExactEntry(input: SearchInput): Promise<DirectorySuggestionEntry | null> {
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;
pathQueryPolicy: PathQueryPolicy;
pathFormat: DirectorySuggestionPathFormat;
hiddenDirectoryNames: Set<string>;
limit: number;
maxDepth: number;
maxEntriesScanned: number;
confidentResultScanThreshold: number | undefined;
gitIgnoredPaths: Set<string>;
excludedDiscoveryPaths: Set<string>;
}
async function searchChildren(input: SearchInput): Promise<RankedEntry[]> {
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 [];
// Browsing inside an excluded path is explicit: the caller typed it, so list what is there.
const discoveryInput = isInsideExcludedPath(parent, input)
? { ...input, excludedDiscoveryPaths: new Set<string>() }
: input;
const ranked: RankedEntry[] = [];
for await (const entry of readChildren(parent)) {
if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, discoveryInput))
continue;
const candidate: TraversedEntry = {
...entry,
visiblePath: path.join(visibleParent, entry.name),
depth: 1,
};
if (shouldSuggest(candidate, input)) ranked.push(rank(candidate, input));
}
return ranked;
}
async function searchTree(
input: SearchInput,
start: WalkStart = { visiblePath: input.root, resolvedPath: input.root },
): Promise<RankedEntry[]> {
if (!(input.maxEntriesScanned > 0)) return [];
const visited = new Set<string>([start.resolvedPath]);
const branches = walkChildren({ parent: start, 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<string>,
): AsyncGenerator<TraversedEntry> {
yield entry;
if (
entry.kind !== "directory" ||
visited.has(entry.resolvedPath) ||
entry.depth >= input.maxDepth
)
return;
visited.add(entry.resolvedPath);
yield* roundRobin(walkChildren({ parent: entry, depth: entry.depth + 1, input, visited }));
}
// Create a child's path and iterator only when its branch gets a turn. Eagerly constructing every
// sibling's subtree retained hundreds of thousands of unvisited children beyond the scan budget.
async function* walkChildren({
parent,
depth,
input,
visited,
}: WalkChildrenOptions): AsyncGenerator<AsyncGenerator<TraversedEntry>> {
for await (const child of readChildren(parent.resolvedPath)) {
if (!staysInsideRoot(child, input.root) || !shouldDiscover(child, input)) continue;
yield walkBranch(
{ ...child, visiblePath: path.join(parent.visiblePath, child.name), depth },
input,
visited,
);
}
}
async function* roundRobin<T>(branches: AsyncIterable<AsyncGenerator<T>>): AsyncGenerator<T> {
let active: AsyncIterable<AsyncGenerator<T>> | Array<AsyncGenerator<T>> = branches;
while (true) {
const nextRound: Array<AsyncGenerator<T>> = [];
for await (const branch of active) {
const next = await branch.next();
if (!next.done) {
nextRound.push(branch);
yield next.value;
}
}
if (!nextRound.length) return;
active = nextRound;
}
}
// Only a symlink can resolve outside the tree being walked. Every other child is its parent's
// path plus a name, and the parent was already proved inside the root.
function staysInsideRoot(entry: ChildEntry, root: string): boolean {
return !entry.viaSymlink || isPathInsideRoot(root, entry.resolvedPath);
}
function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean {
if (isNewlyGitIgnored(entry, input)) return false;
if (isNewlyExcluded(entry, 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);
}
// Traversal only descends through entries that already passed this check, so every ancestor of
// a named child is known discoverable and only the entry itself can be newly ignored. A symlink
// resolves to a path with different ancestors, so it still needs the full walk.
function isNewlyGitIgnored(entry: ChildEntry, input: SearchInput): boolean {
if (entry.viaSymlink) return isGitIgnoredPath(entry.resolvedPath, input);
return input.gitIgnoredPaths.has(entry.resolvedPath);
}
// Same reasoning as Git-ignored paths: only a symlink can land inside an excluded path without
// first passing through it by name.
function isNewlyExcluded(entry: ChildEntry, input: SearchInput): boolean {
if (input.excludedDiscoveryPaths.size === 0) return false;
if (entry.viaSymlink) return isInsideExcludedPath(entry.resolvedPath, input);
return input.excludedDiscoveryPaths.has(entry.resolvedPath);
}
function isInsideExcludedPath(absolutePath: string, input: SearchInput): boolean {
for (const excludedPath of input.excludedDiscoveryPaths) {
if (isPathInsideRoot(excludedPath, absolutePath)) return true;
}
return false;
}
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<string, RankedEntry>();
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<string | null> {
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): AsyncGenerator<ChildEntry> {
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).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();
}
}
for (const rawEntry of rawEntries) {
const entry = await resolveChild(directory, rawEntry);
if (entry) yield entry;
}
}
async function loadGitIgnoredPaths(root: string): Promise<Set<string>> {
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<string>());
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<ChildEntry | null> {
const visiblePath = path.join(directory, entry.name);
if (entry.kind !== "symlink") {
return { name: entry.name, resolvedPath: visiblePath, kind: entry.kind, viaSymlink: false };
}
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, viaSymlink: true } : null;
}
function getEntryKind(info: Stats | null): DirectorySuggestionKind | null {
if (info?.isDirectory()) return "directory";
if (info?.isFile()) return "file";
return null;
}
// Reads already reject stale entries by expiry and by directory metadata, so this only has to
// bound the map. Sweeping it for expired keys would cost a full pass on every cache miss.
function pruneCache(): void {
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;
}