mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 00:14:54 +00:00
Each pending fs call holds one of libuv's four shared threadpool threads, and a read on a hung mount, a protected macOS folder, or a huge network directory does not return. Add Project starts a new search per keystroke and nothing cancels the old ones, so four stuck searches took every thread: logging stopped and `paseo project create` timed out until the read returned or the daemon was killed. Directory search now runs its filesystem calls through one shared limit of two, leaving the rest of the pool to the daemon. Refs #4879 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Leandro Ardissone <59826+lardissone@users.noreply.github.com>
1082 lines
35 KiB
TypeScript
1082 lines
35 KiB
TypeScript
import { createHook } from "node:async_hooks";
|
|
import { execFileSync } from "node:child_process";
|
|
import {
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
realpathSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { isPlatform } from "../test-utils/platform.js";
|
|
import { startPathContainmentMetrics, stopPathContainmentMetrics } from "./path.js";
|
|
import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js";
|
|
import {
|
|
searchDirectoryEntries,
|
|
WORKSPACE_SEARCH_HIDDEN_DIRECTORIES,
|
|
} from "./directory-suggestions.js";
|
|
|
|
const isWindows = isPlatform("win32");
|
|
const filesystemRootDirectoryName = isWindows ? "Windows" : "usr";
|
|
async function searchAbsoluteDirectoryPaths(options: {
|
|
homeDir: string;
|
|
query: string;
|
|
limit?: number;
|
|
maxDepth?: number;
|
|
maxDirectoriesScanned?: number;
|
|
}): Promise<string[]> {
|
|
const entries = await searchDirectoryEntries({
|
|
root: options.homeDir,
|
|
query: options.query,
|
|
pathFormat: "absolute",
|
|
includeDirectories: true,
|
|
includeFiles: false,
|
|
pathQueryPolicy: "rooted",
|
|
rootAliases: ["~"],
|
|
blankQueryBehavior: "none",
|
|
limit: options.limit,
|
|
maxDepth: options.maxDepth,
|
|
maxEntriesScanned: options.maxDirectoriesScanned,
|
|
confidentResultScanThreshold: 5_000,
|
|
});
|
|
return entries.map((entry) => entry.path);
|
|
}
|
|
|
|
async function searchRelativeDirectoryEntries(options: {
|
|
cwd: string;
|
|
query: string;
|
|
limit?: number;
|
|
includeFiles?: boolean;
|
|
includeDirectories?: boolean;
|
|
matchMode?: "fuzzy" | "suffix";
|
|
maxDepth?: number;
|
|
maxEntriesScanned?: number;
|
|
respectGitIgnore?: boolean;
|
|
}) {
|
|
return searchDirectoryEntries({
|
|
root: options.cwd,
|
|
query: options.query,
|
|
pathFormat: "relative",
|
|
includeFiles: options.includeFiles,
|
|
includeDirectories: options.includeDirectories,
|
|
matchMode: options.matchMode,
|
|
pathQueryPolicy: "slashes",
|
|
blankQueryBehavior: "children",
|
|
traversableHiddenDirectoryNames: WORKSPACE_SEARCH_HIDDEN_DIRECTORIES,
|
|
limit: options.limit,
|
|
maxDepth: options.maxDepth,
|
|
maxEntriesScanned: options.maxEntriesScanned,
|
|
respectGitIgnore: options.respectGitIgnore,
|
|
});
|
|
}
|
|
|
|
function initGitRepo(directory: string, ignorePatterns: string): void {
|
|
execFileSync("git", ["init", "-q"], { cwd: directory });
|
|
writeFileSync(path.join(directory, ".gitignore"), ignorePatterns);
|
|
}
|
|
|
|
describe("searchDirectoryEntries", () => {
|
|
let configuredSearchRoot: string;
|
|
let searchRoot: string;
|
|
|
|
beforeEach(() => {
|
|
configuredSearchRoot = mkdtempSync(path.join(tmpdir(), "directory-search-"));
|
|
searchRoot = realpathSync.native(configuredSearchRoot);
|
|
mkdirSync(path.join(searchRoot, "projects", "paseo-desktop"), { recursive: true });
|
|
mkdirSync(path.join(searchRoot, "src", "components"), { recursive: true });
|
|
mkdirSync(path.join(searchRoot, ".hidden", "secret"), { recursive: true });
|
|
writeFileSync(path.join(searchRoot, "src", "components", "message-renderer.tsx"), "");
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(searchRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("applies result paths and entry kinds as parameters of one search", async () => {
|
|
const directories = await searchDirectoryEntries({
|
|
root: configuredSearchRoot,
|
|
query: "pso",
|
|
pathFormat: "absolute",
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
});
|
|
const files = await searchDirectoryEntries({
|
|
root: searchRoot,
|
|
query: "msgrndr",
|
|
pathFormat: "relative",
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
});
|
|
|
|
expect({ directories, files }).toEqual({
|
|
directories: [
|
|
{
|
|
path: path.join(searchRoot, "projects", "paseo-desktop"),
|
|
kind: "directory",
|
|
},
|
|
],
|
|
files: [
|
|
{
|
|
path: "src/components/message-renderer.tsx",
|
|
kind: "file",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("prunes directories ignored by Git and caches concurrent lookups", async () => {
|
|
execFileSync("git", ["init", "-q"], { cwd: searchRoot });
|
|
writeFileSync(path.join(searchRoot, ".gitignore"), "data/\n");
|
|
mkdirSync(path.join(searchRoot, "data", "matching-ignored-directory"), {
|
|
recursive: true,
|
|
});
|
|
mkdirSync(path.join(searchRoot, "visible", "matching-visible-directory"), {
|
|
recursive: true,
|
|
});
|
|
|
|
startGitCommandMetrics();
|
|
const results = await Promise.all([
|
|
searchDirectoryEntries({
|
|
root: searchRoot,
|
|
query: "matching",
|
|
pathFormat: "relative",
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
respectGitIgnore: true,
|
|
}),
|
|
searchDirectoryEntries({
|
|
root: searchRoot,
|
|
query: "visible",
|
|
pathFormat: "relative",
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
respectGitIgnore: true,
|
|
}),
|
|
]);
|
|
const gitMetrics = stopGitCommandMetrics();
|
|
|
|
expect(results).toEqual([
|
|
[{ path: "visible/matching-visible-directory", kind: "directory" }],
|
|
[
|
|
{ path: "visible", kind: "directory" },
|
|
{ path: "visible/matching-visible-directory", kind: "directory" },
|
|
],
|
|
]);
|
|
expect(gitMetrics.commands.filter((command) => command.args.includes("ls-files"))).toHaveLength(
|
|
1,
|
|
);
|
|
});
|
|
|
|
it.skipIf(isWindows)(
|
|
"prunes a symlink whose target is inside a Git-ignored directory",
|
|
async () => {
|
|
initGitRepo(searchRoot, "generated/\n");
|
|
mkdirSync(path.join(searchRoot, "generated", "output"), { recursive: true });
|
|
symlinkSync(path.join(searchRoot, "generated", "output"), path.join(searchRoot, "linked"));
|
|
|
|
await expect(
|
|
searchDirectoryEntries({
|
|
root: searchRoot,
|
|
query: "linked",
|
|
pathFormat: "relative",
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
respectGitIgnore: true,
|
|
}),
|
|
).resolves.toEqual([]);
|
|
},
|
|
);
|
|
|
|
it("configures raw blank queries independently from explicit root aliases", async () => {
|
|
const rootEntries = [
|
|
{ path: "projects", kind: "directory" as const },
|
|
{ path: "src", kind: "directory" as const },
|
|
];
|
|
const common = {
|
|
root: searchRoot,
|
|
pathFormat: "relative" as const,
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
rootAliases: ["~"],
|
|
};
|
|
|
|
await expect(
|
|
searchDirectoryEntries({
|
|
...common,
|
|
query: "",
|
|
blankQueryBehavior: "none",
|
|
}),
|
|
).resolves.toEqual([]);
|
|
|
|
await expect(
|
|
searchDirectoryEntries({
|
|
...common,
|
|
query: "~",
|
|
blankQueryBehavior: "none",
|
|
}),
|
|
).resolves.toEqual(rootEntries);
|
|
|
|
await expect(
|
|
searchDirectoryEntries({
|
|
...common,
|
|
query: "",
|
|
blankQueryBehavior: "children",
|
|
}),
|
|
).resolves.toEqual(rootEntries);
|
|
|
|
const suffixRootBrowses = await Promise.all([
|
|
searchDirectoryEntries({
|
|
...common,
|
|
query: "",
|
|
matchMode: "suffix",
|
|
blankQueryBehavior: "children",
|
|
}),
|
|
searchDirectoryEntries({
|
|
...common,
|
|
query: "~",
|
|
matchMode: "suffix",
|
|
blankQueryBehavior: "none",
|
|
}),
|
|
]);
|
|
expect(suffixRootBrowses).toEqual([rootEntries, rootEntries]);
|
|
});
|
|
|
|
it("matches rooted queries against the full path at any depth", async () => {
|
|
mkdirSync(path.join(searchRoot, "nested", "pso-global"), { recursive: true });
|
|
mkdirSync(path.join(searchRoot, "pso-root"), { recursive: true });
|
|
const absoluteQuery = path.join(configuredSearchRoot, "pso");
|
|
|
|
const common = {
|
|
root: configuredSearchRoot,
|
|
pathFormat: "relative" as const,
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
pathQueryPolicy: "rooted" as const,
|
|
rootAliases: ["~"],
|
|
};
|
|
const expected = [
|
|
{ path: "pso-root", kind: "directory" },
|
|
{ path: "nested/pso-global", kind: "directory" },
|
|
{ path: "projects/paseo-desktop", kind: "directory" },
|
|
];
|
|
|
|
await expect(searchDirectoryEntries({ ...common, query: "~/pso" })).resolves.toEqual(expected);
|
|
await expect(searchDirectoryEntries({ ...common, query: "./pso" })).resolves.toEqual(expected);
|
|
await expect(searchDirectoryEntries({ ...common, query: absoluteQuery })).resolves.toEqual(
|
|
expected,
|
|
);
|
|
});
|
|
|
|
it("browses an absolute root and an absolute directory ending in a separator", async () => {
|
|
const common = {
|
|
root: configuredSearchRoot,
|
|
pathFormat: "relative" as const,
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
pathQueryPolicy: "rooted" as const,
|
|
};
|
|
const rootEntries = await searchDirectoryEntries({ ...common, query: configuredSearchRoot });
|
|
const projectEntries = await searchDirectoryEntries({
|
|
...common,
|
|
query: `${path.join(configuredSearchRoot, "projects")}${path.sep}`,
|
|
});
|
|
|
|
expect({ rootEntries, projectEntries }).toEqual({
|
|
rootEntries: [
|
|
{ path: "projects", kind: "directory" },
|
|
{ path: "src", kind: "directory" },
|
|
],
|
|
projectEntries: [
|
|
{ path: "projects", kind: "directory" },
|
|
{ path: "projects/paseo-desktop", kind: "directory" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("anchors single-segment absolute queries when the search root is a filesystem root", async () => {
|
|
const filesystemRoot = path.parse(searchRoot).root;
|
|
const exactPath = path.join(filesystemRoot, filesystemRootDirectoryName);
|
|
const incompletePath = exactPath.slice(0, -1);
|
|
const common = {
|
|
root: filesystemRoot,
|
|
pathFormat: "absolute" as const,
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
pathQueryPolicy: "rooted" as const,
|
|
limit: 5,
|
|
};
|
|
|
|
await expect(searchDirectoryEntries({ ...common, query: incompletePath })).resolves.toEqual([]);
|
|
|
|
const exactEntries = await searchDirectoryEntries({ ...common, query: exactPath });
|
|
expect(exactEntries[0]).toEqual({ path: exactPath, kind: "directory" });
|
|
expect(
|
|
exactEntries.every(
|
|
(entry) => entry.path === exactPath || entry.path.startsWith(`${exactPath}${path.sep}`),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does not return entries below the configured traversal depth", async () => {
|
|
await expect(
|
|
searchDirectoryEntries({
|
|
root: searchRoot,
|
|
query: "message-renderer",
|
|
pathFormat: "relative",
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
maxDepth: 2,
|
|
}),
|
|
).resolves.toEqual([]);
|
|
});
|
|
|
|
it("does not spend the scan budget on excluded entry kinds", async () => {
|
|
const budgetRoot = path.join(searchRoot, "kind-budget");
|
|
const target = path.join(budgetRoot, "z-projects", "paseo-target");
|
|
mkdirSync(target, { recursive: true });
|
|
for (let index = 0; index < 10; index += 1) {
|
|
writeFileSync(path.join(budgetRoot, `a-noise-${index}.txt`), "");
|
|
}
|
|
|
|
await expect(
|
|
searchDirectoryEntries({
|
|
root: budgetRoot,
|
|
query: "paseo-target",
|
|
pathFormat: "relative",
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
maxEntriesScanned: 2,
|
|
}),
|
|
).resolves.toEqual([{ path: "z-projects/paseo-target", kind: "directory" }]);
|
|
});
|
|
|
|
it("applies ignored-directory policy to parent-scoped queries", async () => {
|
|
mkdirSync(path.join(searchRoot, "node_modules"));
|
|
|
|
await expect(
|
|
searchDirectoryEntries({
|
|
root: searchRoot,
|
|
query: "./node",
|
|
pathFormat: "relative",
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
}),
|
|
).resolves.toEqual([]);
|
|
});
|
|
|
|
it("does not traverse dependency, environment, or build-output directories", async () => {
|
|
const ignoredNames = [
|
|
"node_modules",
|
|
"venv",
|
|
"env",
|
|
"virtualenv",
|
|
"dist",
|
|
"build",
|
|
"target",
|
|
"out",
|
|
"coverage",
|
|
"vendor",
|
|
"__pycache__",
|
|
".git",
|
|
];
|
|
for (const name of ignoredNames) {
|
|
const ignoredTarget = path.join(searchRoot, name, "search-target.ts");
|
|
mkdirSync(path.dirname(ignoredTarget), { recursive: true });
|
|
writeFileSync(ignoredTarget, "");
|
|
}
|
|
const visibleTarget = path.join(searchRoot, "src", "search-target.ts");
|
|
writeFileSync(visibleTarget, "");
|
|
|
|
await expect(
|
|
searchDirectoryEntries({
|
|
root: searchRoot,
|
|
query: "search-target.ts",
|
|
pathFormat: "relative",
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
}),
|
|
).resolves.toEqual([{ path: "src/search-target.ts", kind: "file" }]);
|
|
});
|
|
|
|
it.skipIf(isWindows)("rechecks cached symlink children against each search root", async () => {
|
|
const narrowRoot = path.join(searchRoot, "narrow");
|
|
const outsideNarrowRoot = path.join(searchRoot, "outside");
|
|
mkdirSync(narrowRoot, { recursive: true });
|
|
mkdirSync(path.join(outsideNarrowRoot, "leaked-child"), { recursive: true });
|
|
symlinkSync(outsideNarrowRoot, path.join(narrowRoot, "outside-link"));
|
|
|
|
await searchDirectoryEntries({
|
|
root: searchRoot,
|
|
query: "leaked-child",
|
|
pathFormat: "absolute",
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
});
|
|
|
|
await expect(
|
|
searchDirectoryEntries({
|
|
root: narrowRoot,
|
|
query: "outside",
|
|
pathFormat: "relative",
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
}),
|
|
).resolves.toEqual([]);
|
|
});
|
|
|
|
it.skipIf(isWindows)("rechecks a cached symlink after its target changes", async () => {
|
|
const narrowRoot = path.join(searchRoot, "retargeted-link-root");
|
|
const insideTarget = path.join(narrowRoot, "inside");
|
|
const outsideTarget = path.join(searchRoot, "retargeted-link-outside");
|
|
const link = path.join(narrowRoot, "project-link");
|
|
mkdirSync(insideTarget, { recursive: true });
|
|
mkdirSync(outsideTarget, { recursive: true });
|
|
symlinkSync(insideTarget, link);
|
|
|
|
await expect(
|
|
searchDirectoryEntries({
|
|
root: narrowRoot,
|
|
query: "project-link",
|
|
pathFormat: "relative",
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
}),
|
|
).resolves.toEqual([{ path: "project-link", kind: "directory" }]);
|
|
|
|
unlinkSync(link);
|
|
symlinkSync(outsideTarget, link);
|
|
|
|
await expect(
|
|
searchDirectoryEntries({
|
|
root: narrowRoot,
|
|
query: "project-link",
|
|
pathFormat: "relative",
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
}),
|
|
).resolves.toEqual([]);
|
|
});
|
|
|
|
it("refreshes a cached directory after a child is created", async () => {
|
|
const dynamicRoot = path.join(searchRoot, "dynamic-cache-root");
|
|
mkdirSync(dynamicRoot);
|
|
|
|
await expect(
|
|
searchDirectoryEntries({
|
|
root: dynamicRoot,
|
|
query: "fresh-project",
|
|
pathFormat: "relative",
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
}),
|
|
).resolves.toEqual([]);
|
|
|
|
mkdirSync(path.join(dynamicRoot, "fresh-project"));
|
|
|
|
await expect(
|
|
searchDirectoryEntries({
|
|
root: dynamicRoot,
|
|
query: "fresh-project",
|
|
pathFormat: "relative",
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
}),
|
|
).resolves.toEqual([{ path: "fresh-project", kind: "directory" }]);
|
|
});
|
|
});
|
|
|
|
describe("absolute directory-path configuration", () => {
|
|
let tempRoot: string;
|
|
let homeDir: string;
|
|
let outsideDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-suggestions-")));
|
|
homeDir = path.join(tempRoot, "home");
|
|
outsideDir = path.join(tempRoot, "outside");
|
|
|
|
mkdirSync(homeDir, { recursive: true });
|
|
mkdirSync(outsideDir, { recursive: true });
|
|
homeDir = realpathSync.native(homeDir);
|
|
outsideDir = realpathSync.native(outsideDir);
|
|
|
|
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true });
|
|
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true });
|
|
mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true });
|
|
mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true });
|
|
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
|
|
|
|
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
|
|
if (!isWindows) {
|
|
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("does not inspect directories when the scan budget is zero", async () => {
|
|
await expect(
|
|
searchAbsoluteDirectoryPaths({
|
|
homeDir,
|
|
query: "documents",
|
|
limit: 10,
|
|
maxDirectoriesScanned: 0,
|
|
}),
|
|
).resolves.toEqual([]);
|
|
});
|
|
|
|
it("shares the scan budget fairly between nested sibling branches", async () => {
|
|
const budgetHome = path.join(tempRoot, "nested-budget-home");
|
|
const projectPath = path.join(budgetHome, "work", "client", "team", "paseo-desktop");
|
|
mkdirSync(projectPath, { recursive: true });
|
|
for (let index = 0; index < 10; index += 1) {
|
|
mkdirSync(
|
|
path.join(budgetHome, "work", "archive", `noise-${index.toString().padStart(2, "0")}`),
|
|
{ recursive: true },
|
|
);
|
|
}
|
|
|
|
const results = await searchAbsoluteDirectoryPaths({
|
|
homeDir: budgetHome,
|
|
query: "paseo-desktop",
|
|
limit: 10,
|
|
maxDirectoriesScanned: 8,
|
|
});
|
|
|
|
expect(results.map((result) => realpathSync.native(result))).toEqual([
|
|
realpathSync.native(projectPath),
|
|
]);
|
|
});
|
|
|
|
it.skipIf(isWindows)("does not let a queued symlink hide the direct project branch", async () => {
|
|
const symlinkHome = path.join(tempRoot, "symlink-budget-home");
|
|
const projectRoot = path.join(symlinkHome, "b-projects", "project-root");
|
|
const projectPath = path.join(projectRoot, "paseo-desktop");
|
|
const noisyBranch = path.join(symlinkHome, "a-noisy");
|
|
mkdirSync(projectPath, { recursive: true });
|
|
for (let index = 0; index < 10; index += 1) {
|
|
mkdirSync(path.join(noisyBranch, `noise-${index.toString().padStart(2, "0")}`), {
|
|
recursive: true,
|
|
});
|
|
}
|
|
// The alias is intentionally queued behind the noise. Discovery must not
|
|
// reserve its target before the direct b-projects branch gets a turn.
|
|
symlinkSync(projectRoot, path.join(noisyBranch, "zz-target-link"));
|
|
|
|
const results = await searchAbsoluteDirectoryPaths({
|
|
homeDir: symlinkHome,
|
|
query: "paseo-desktop",
|
|
limit: 10,
|
|
maxDirectoriesScanned: 6,
|
|
});
|
|
|
|
expect(results.map((result) => realpathSync.native(result))).toEqual([
|
|
realpathSync.native(projectPath),
|
|
]);
|
|
});
|
|
|
|
it.skipIf(isWindows)("follows visible directory symlinks that stay inside home", async () => {
|
|
const symlinkHome = path.join(tempRoot, "internal-symlink-home");
|
|
const projectPath = path.join(symlinkHome, ".linked", "project-root", "paseo-desktop");
|
|
mkdirSync(projectPath, { recursive: true });
|
|
symlinkSync(path.dirname(projectPath), path.join(symlinkHome, "linked-project"));
|
|
|
|
const results = await searchAbsoluteDirectoryPaths({
|
|
homeDir: symlinkHome,
|
|
query: "pso",
|
|
limit: 10,
|
|
});
|
|
|
|
expect(results.map((result) => realpathSync.native(result))).toEqual([
|
|
realpathSync.native(projectPath),
|
|
]);
|
|
});
|
|
|
|
it.skipIf(isWindows)("matches the visible name of a directory symlink", async () => {
|
|
const symlinkHome = path.join(tempRoot, "visible-symlink-home");
|
|
const projectsPath = path.join(symlinkHome, "projects");
|
|
const targetPath = path.join(symlinkHome, "work", "current");
|
|
const visibleProjectPath = path.join(projectsPath, "paseo");
|
|
mkdirSync(projectsPath, { recursive: true });
|
|
mkdirSync(targetPath, { recursive: true });
|
|
symlinkSync(targetPath, visibleProjectPath);
|
|
|
|
const results = await searchAbsoluteDirectoryPaths({
|
|
homeDir: symlinkHome,
|
|
query: "paseo",
|
|
limit: 10,
|
|
});
|
|
|
|
expect(results).toContain(visibleProjectPath);
|
|
});
|
|
|
|
it("keeps scanning past weak fuzzy matches for a stronger late result", async () => {
|
|
const largeHome = path.join(tempRoot, "large-home");
|
|
const exactMatchPath = path.join(largeHome, "pso");
|
|
for (let index = 0; index < 8; index += 1) {
|
|
mkdirSync(
|
|
path.join(largeHome, `a-${index.toString().padStart(2, "0")}-project-search-output`),
|
|
{ recursive: true },
|
|
);
|
|
}
|
|
mkdirSync(exactMatchPath);
|
|
|
|
const results = await searchDirectoryEntries({
|
|
root: largeHome,
|
|
query: "pso",
|
|
pathFormat: "absolute",
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
limit: 1,
|
|
maxEntriesScanned: 20,
|
|
confidentResultScanThreshold: 5,
|
|
});
|
|
|
|
expect(results).toEqual([{ path: exactMatchPath, kind: "directory" }]);
|
|
});
|
|
|
|
it("supports home-relative path query syntax", async () => {
|
|
const result = await searchAbsoluteDirectoryPaths({
|
|
homeDir,
|
|
query: "~/projects/pa",
|
|
limit: 10,
|
|
});
|
|
|
|
expect(result.map((entry) => realpathSync.native(entry))).toEqual([
|
|
realpathSync.native(path.join(homeDir, "projects", "paseo")),
|
|
realpathSync.native(path.join(homeDir, "projects", "playground")),
|
|
]);
|
|
});
|
|
|
|
it("prioritizes partial matches that appear earlier in the path", async () => {
|
|
const earlierPath = path.join(homeDir, "farofoo");
|
|
const laterPath = path.join(homeDir, "x", "y", "farofoo");
|
|
mkdirSync(earlierPath, { recursive: true });
|
|
mkdirSync(laterPath, { recursive: true });
|
|
|
|
const results = await searchAbsoluteDirectoryPaths({
|
|
homeDir,
|
|
query: "arofo",
|
|
limit: 30,
|
|
});
|
|
|
|
const resolvedResults = results.map((result) => realpathSync.native(result));
|
|
const earlierIndex = resolvedResults.indexOf(realpathSync.native(earlierPath));
|
|
const laterIndex = resolvedResults.indexOf(realpathSync.native(laterPath));
|
|
expect(earlierIndex).toBeGreaterThanOrEqual(0);
|
|
expect(laterIndex).toBeGreaterThanOrEqual(0);
|
|
expect(earlierIndex).toBeLessThan(laterIndex);
|
|
});
|
|
|
|
// POSIX-only: creates and follows a symlink escape fixture.
|
|
it.skipIf(isWindows)("does not return paths that escape home through symlinks", async () => {
|
|
const results = await searchAbsoluteDirectoryPaths({
|
|
homeDir,
|
|
query: "outside",
|
|
limit: 20,
|
|
});
|
|
|
|
expect(results).not.toContain(path.join(homeDir, "outside-link"));
|
|
expect(results).not.toContain(path.join(outsideDir, "outside-match"));
|
|
});
|
|
|
|
it("respects the result limit", async () => {
|
|
const results = await searchAbsoluteDirectoryPaths({
|
|
homeDir,
|
|
query: "p",
|
|
limit: 1,
|
|
});
|
|
|
|
expect(results).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("relative typed-entry configuration", () => {
|
|
let tempRoot: string;
|
|
let workspaceDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-suggestions-")));
|
|
workspaceDir = path.join(tempRoot, "workspace");
|
|
|
|
mkdirSync(path.join(workspaceDir, "src", "components"), {
|
|
recursive: true,
|
|
});
|
|
mkdirSync(path.join(workspaceDir, "docs"), { recursive: true });
|
|
|
|
writeFileSync(path.join(workspaceDir, "README.md"), "# paseo\n");
|
|
writeFileSync(
|
|
path.join(workspaceDir, "src", "components", "chat-input.tsx"),
|
|
"export const ChatInput = null;\n",
|
|
);
|
|
writeFileSync(path.join(workspaceDir, "docs", "search-notes.md"), "notes\n");
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("ranks fuzzy basename matches after exact, prefix, and substring matches", async () => {
|
|
writeFileSync(path.join(workspaceDir, "src", "components", "msgrndr"), "");
|
|
writeFileSync(path.join(workspaceDir, "src", "components", "msgrndr-panel.tsx"), "");
|
|
writeFileSync(path.join(workspaceDir, "src", "components", "use-msgrndr.ts"), "");
|
|
writeFileSync(path.join(workspaceDir, "src", "components", "message-renderer.tsx"), "");
|
|
|
|
const results = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "msgrndr",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
});
|
|
|
|
expect(results.map((entry) => entry.path)).toEqual([
|
|
"src/components/msgrndr",
|
|
"src/components/msgrndr-panel.tsx",
|
|
"src/components/use-msgrndr.ts",
|
|
"src/components/message-renderer.tsx",
|
|
]);
|
|
});
|
|
|
|
it("suffix mode matches whole path segment suffixes without fuzzy matches", async () => {
|
|
mkdirSync(path.join(workspaceDir, "packages", "app", "src"), { recursive: true });
|
|
writeFileSync(path.join(workspaceDir, "src", "file.ts"), "");
|
|
writeFileSync(path.join(workspaceDir, "packages", "app", "src", "file.ts"), "");
|
|
writeFileSync(path.join(workspaceDir, "src", "paseo-config-file.ts"), "");
|
|
|
|
const basenameResults = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "file.ts",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
matchMode: "suffix",
|
|
});
|
|
const suffixResults = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "src/file.ts",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
matchMode: "suffix",
|
|
});
|
|
|
|
expect(basenameResults).toEqual([
|
|
{ path: "src/file.ts", kind: "file" },
|
|
{ path: "packages/app/src/file.ts", kind: "file" },
|
|
]);
|
|
expect(suffixResults).toEqual([
|
|
{ path: "src/file.ts", kind: "file" },
|
|
{ path: "packages/app/src/file.ts", kind: "file" },
|
|
]);
|
|
});
|
|
|
|
it("suffix mode resolves exact workspace file paths before broad traversal", async () => {
|
|
const targetPath = path.join(workspaceDir, "plugins", "usage-sources", "providers", "local.ts");
|
|
mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
writeFileSync(targetPath, "");
|
|
|
|
const results = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "plugins/usage-sources/providers/local.ts",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
matchMode: "suffix",
|
|
maxEntriesScanned: 1,
|
|
});
|
|
|
|
expect(results).toEqual([
|
|
{
|
|
path: "plugins/usage-sources/providers/local.ts",
|
|
kind: "file",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("suffix mode resolves explicit hidden file paths without broad hidden traversal", async () => {
|
|
const targetPath = path.join(workspaceDir, ".dev", "paseo-home", "daemon.log");
|
|
mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
writeFileSync(targetPath, "daemon log\n");
|
|
|
|
const results = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: ".dev/paseo-home/daemon.log",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
matchMode: "suffix",
|
|
maxEntriesScanned: 1,
|
|
});
|
|
|
|
expect(results).toEqual([{ path: ".dev/paseo-home/daemon.log", kind: "file" }]);
|
|
});
|
|
|
|
it("resolves an exact gitignored path while keeping it out of discovery results", async () => {
|
|
initGitRepo(workspaceDir, "generated/\n");
|
|
mkdirSync(path.join(workspaceDir, "generated"), { recursive: true });
|
|
writeFileSync(path.join(workspaceDir, "generated", "search-notes.md"), "generated notes\n");
|
|
|
|
const exactResults = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "generated/search-notes.md",
|
|
limit: 1,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
matchMode: "suffix",
|
|
respectGitIgnore: true,
|
|
});
|
|
const fuzzyResults = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "search-notes",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
respectGitIgnore: true,
|
|
});
|
|
|
|
expect({ exactResults, fuzzyResults }).toEqual({
|
|
exactResults: [{ path: "generated/search-notes.md", kind: "file" }],
|
|
fuzzyResults: [{ path: "docs/search-notes.md", kind: "file" }],
|
|
});
|
|
});
|
|
|
|
it.skipIf(isWindows)(
|
|
"refuses an exact path that escapes the root through a symlink",
|
|
async () => {
|
|
const outsideDir = path.join(tempRoot, "outside-workspace");
|
|
mkdirSync(outsideDir, { recursive: true });
|
|
writeFileSync(path.join(outsideDir, "secret.md"), "outside\n");
|
|
symlinkSync(outsideDir, path.join(workspaceDir, "escape-link"));
|
|
symlinkSync(path.join(workspaceDir, "docs"), path.join(workspaceDir, "inside-link"));
|
|
|
|
const escaping = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "escape-link/secret.md",
|
|
limit: 1,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
matchMode: "suffix",
|
|
});
|
|
const staysInside = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "inside-link/search-notes.md",
|
|
limit: 1,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
matchMode: "suffix",
|
|
});
|
|
|
|
expect({ escaping, staysInside }).toEqual({
|
|
escaping: [],
|
|
staysInside: [{ path: "inside-link/search-notes.md", kind: "file" }],
|
|
});
|
|
},
|
|
);
|
|
|
|
it("traverses only allowlisted hidden directories without suggesting the directories", async () => {
|
|
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
|
|
mkdirSync(path.join(workspaceDir, ".dev", "cache"), { recursive: true });
|
|
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
|
|
writeFileSync(path.join(workspaceDir, ".dev", "cache", "settings.local.json"), "{}");
|
|
|
|
const suffixResults = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "settings.local.json",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
matchMode: "suffix",
|
|
});
|
|
const fuzzyResults = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "claude",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
matchMode: "fuzzy",
|
|
});
|
|
|
|
expect({ suffixResults, fuzzyResults }).toEqual({
|
|
suffixResults: [{ path: ".claude/settings.local.json", kind: "file" }],
|
|
fuzzyResults: [{ path: ".claude/settings.local.json", kind: "file" }],
|
|
});
|
|
});
|
|
|
|
it("supports slash-style path queries", async () => {
|
|
const results = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "src/co",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
});
|
|
|
|
expect(results).toEqual([
|
|
{ path: "src/components", kind: "directory" },
|
|
{ path: "src/components/chat-input.tsx", kind: "file" },
|
|
]);
|
|
});
|
|
|
|
it("matches a path fragment anywhere in the full file path", async () => {
|
|
const targetPath = path.join(
|
|
workspaceDir,
|
|
"something",
|
|
"something-else",
|
|
"skills",
|
|
"paseo-advisor",
|
|
"SKILL.md",
|
|
);
|
|
mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
writeFileSync(targetPath, "skill\n");
|
|
|
|
const results = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "skills/",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
});
|
|
|
|
expect(results).toEqual([
|
|
{
|
|
path: "something/something-else/skills/paseo-advisor/SKILL.md",
|
|
kind: "file",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("fuzzy-matches natural language against the full path", async () => {
|
|
mkdirSync(path.join(workspaceDir, "blankpage", "editor"), { recursive: true });
|
|
|
|
const results = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "blank page editor",
|
|
limit: 20,
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
});
|
|
|
|
expect(results).toEqual([{ path: "blankpage/editor", kind: "directory" }]);
|
|
});
|
|
});
|
|
|
|
// Reading a tree is cheap; re-deriving each entry's path state is not. A scan that asks whether
|
|
// every ancestor of every entry is inside the root, or is Git-ignored, costs multiples of the
|
|
// read and is what made large home directories exceed the client request timeout.
|
|
describe("home-tree scan cost", () => {
|
|
const DEPTH = 8;
|
|
const CHAINS = 40;
|
|
let scanRoot: string;
|
|
|
|
beforeEach(() => {
|
|
scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-cost-")));
|
|
for (let chain = 0; chain < CHAINS; chain += 1) {
|
|
const segments: string[] = [];
|
|
for (let level = 0; level < DEPTH; level += 1) segments.push(`c${chain}l${level}`);
|
|
mkdirSync(path.join(scanRoot, ...segments), { recursive: true });
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(scanRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
// Nothing matches, so the scan walks the whole tree instead of stopping at a confident result.
|
|
function scanWholeTree() {
|
|
return searchAbsoluteDirectoryPaths({
|
|
homeDir: scanRoot,
|
|
query: "nomatch",
|
|
maxDepth: DEPTH + 4,
|
|
});
|
|
}
|
|
|
|
async function countContainmentChecks(run: () => Promise<unknown>): Promise<number> {
|
|
startPathContainmentMetrics();
|
|
await run();
|
|
return stopPathContainmentMetrics();
|
|
}
|
|
|
|
it("derives no containment for a tree that cannot leave the root", async () => {
|
|
const checks = await countContainmentChecks(scanWholeTree);
|
|
|
|
expect({ checks, scanned: CHAINS * DEPTH }).toEqual({ checks: 0, scanned: 320 });
|
|
});
|
|
|
|
it.skipIf(isWindows)(
|
|
"derives containment for symlinked entries, which can leave the root",
|
|
async () => {
|
|
const outside = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-away-")));
|
|
symlinkSync(outside, path.join(scanRoot, "escape"), "dir");
|
|
|
|
const checks = await countContainmentChecks(scanWholeTree);
|
|
|
|
expect({ checked: checks > 0, escaped: (await scanWholeTree()).includes(outside) }).toEqual({
|
|
checked: true,
|
|
escaped: false,
|
|
});
|
|
rmSync(outside, { recursive: true, force: true });
|
|
},
|
|
);
|
|
});
|
|
|
|
// Every pending fs call holds one of libuv's shared threadpool threads (4 by default). A read that
|
|
// never returns, such as one on a hung network mount, keeps its thread, so searches that pile up
|
|
// while the user types must not be able to take every thread from the rest of the daemon.
|
|
describe("filesystem threads held by concurrent searches", () => {
|
|
const SEARCHES = 8;
|
|
let scanRoot: string;
|
|
|
|
beforeEach(() => {
|
|
scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-threads-")));
|
|
for (let branch = 0; branch < 20; branch += 1) {
|
|
mkdirSync(path.join(scanRoot, `b${branch}`, "inner", "leaf"), { recursive: true });
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(scanRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
async function peakPendingFilesystemRequests(run: () => Promise<unknown>): Promise<number> {
|
|
const pending = new Set<number>();
|
|
let peak = 0;
|
|
const hook = createHook({
|
|
init(asyncId, type) {
|
|
if (type !== "FSREQPROMISE" && type !== "FSREQCALLBACK") return;
|
|
pending.add(asyncId);
|
|
peak = Math.max(peak, pending.size);
|
|
},
|
|
before(asyncId) {
|
|
pending.delete(asyncId);
|
|
},
|
|
});
|
|
hook.enable();
|
|
try {
|
|
await run();
|
|
} finally {
|
|
hook.disable();
|
|
}
|
|
return peak;
|
|
}
|
|
|
|
function searchWhileTyping() {
|
|
const queries = Array.from({ length: SEARCHES }, (_, index) => `nomatch${index}`);
|
|
return Promise.all(
|
|
queries.map((query) => searchAbsoluteDirectoryPaths({ homeDir: scanRoot, query })),
|
|
);
|
|
}
|
|
|
|
it("leaves threadpool threads free for the rest of the daemon", async () => {
|
|
const peak = await peakPendingFilesystemRequests(searchWhileTyping);
|
|
|
|
expect(peak).toBeLessThanOrEqual(2);
|
|
});
|
|
});
|