Files
paseo/packages/server/src/utils/directory-suggestions.ts
T
paseo-bot[bot]andX b3693b79d1 Find hidden agent plan files in workspace suggestions (#6641)
* fix(server): find hidden agent plan files in workspace suggestions

Workspace autocomplete skipped the editor's plan directory while traversing
other known agent directories. Include it in the existing discovery policy
and cover basename and partial-path queries through the daemon client.

Co-authored-by: X <X@16x.org>

* test(server): give plan suggestion queries explicit expectations

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: X <X@16x.org>
2026-10-11 21:54:46 +02:00

895 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 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 entries = await readChildren(parent);
return entries.flatMap((entry) => {
if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, discoveryInput))
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,
start: WalkStart = { visiblePath: input.root, resolvedPath: input.root },
): Promise<RankedEntry[]> {
if (!(input.maxEntriesScanned > 0)) return [];
const roots = (await readChildren(start.resolvedPath)).filter((entry) =>
staysInsideRoot(entry, input.root),
);
const visited = new Set<string>([start.resolvedPath]);
const branches = roots.flatMap((entry) =>
shouldDiscover(entry, input)
? [
walkBranch(
{ ...entry, visiblePath: path.join(start.visiblePath, 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<string>,
): AsyncGenerator<TraversedEntry> {
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) =>
staysInsideRoot(child, input.root),
);
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<T>(branches: Array<AsyncGenerator<T>>): AsyncGenerator<T> {
let active = branches;
while (active.length) {
const nextRound: Array<AsyncGenerator<T>> = [];
for (const branch of active) {
const next = await branch.next();
if (!next.done) {
nextRound.push(branch);
yield next.value;
}
}
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): Promise<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();
}
}
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<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;
}