Treat slash-separated fragments as searchable text and use the same full-path fuzzy matcher for daemon results and remembered project paths.
953 lines
30 KiB
TypeScript
953 lines
30 KiB
TypeScript
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 { 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("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", "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,
|
|
"packages",
|
|
"server",
|
|
"src",
|
|
"services",
|
|
"quota-fetcher",
|
|
"providers",
|
|
"local.ts",
|
|
);
|
|
mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
writeFileSync(targetPath, "");
|
|
|
|
const results = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "packages/server/src/services/quota-fetcher/providers/local.ts",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
matchMode: "suffix",
|
|
maxEntriesScanned: 1,
|
|
});
|
|
|
|
expect(results).toEqual([
|
|
{
|
|
path: "packages/server/src/services/quota-fetcher/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", "notes.md"), "generated notes\n");
|
|
|
|
const exactResults = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "generated/notes.md",
|
|
limit: 1,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
matchMode: "suffix",
|
|
respectGitIgnore: true,
|
|
});
|
|
const fuzzyResults = await searchRelativeDirectoryEntries({
|
|
cwd: workspaceDir,
|
|
query: "notes",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
respectGitIgnore: true,
|
|
});
|
|
|
|
expect({ exactResults, fuzzyResults }).toEqual({
|
|
exactResults: [{ path: "generated/notes.md", kind: "file" }],
|
|
fuzzyResults: [{ path: "docs/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/notes.md",
|
|
limit: 1,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
matchMode: "suffix",
|
|
});
|
|
|
|
expect({ escaping, staysInside }).toEqual({
|
|
escaping: [],
|
|
staysInside: [{ path: "inside-link/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" }]);
|
|
});
|
|
|
|
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" }]);
|
|
});
|
|
});
|