mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
Typing @ in the chat input to reference a file froze the app on huge workspaces. Two compounding causes: 1. The autocomplete hook fed fileFilterQuery straight into the React Query key, so every keystroke fired a new directory_suggestions request and a fresh BFS scan on the daemon. Added a 180 ms debounce that mirrors the existing pattern in new-workspace-screen so rapid typing coalesces into one request. 2. searchWorkspaceEntries only ignored node_modules, so dist, build, target, out, coverage, vendor and __pycache__ each consumed the 5,000-entry scan budget before any real source file was reached. Extended WORKSPACE_IGNORED_DIRECTORY_NAMES with those names and added a regression test that proves a deep source file is still reachable when those directories are full of bait entries. Refs #599 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
299 lines
9.8 KiB
TypeScript
299 lines
9.8 KiB
TypeScript
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js";
|
|
|
|
const isWindows = process.platform === "win32";
|
|
|
|
describe("searchHomeDirectories", () => {
|
|
let tempRoot: string;
|
|
let homeDir: string;
|
|
let outsideDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-")));
|
|
homeDir = path.join(tempRoot, "home");
|
|
outsideDir = path.join(tempRoot, "outside");
|
|
|
|
mkdirSync(homeDir, { recursive: true });
|
|
mkdirSync(outsideDir, { recursive: true });
|
|
|
|
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 });
|
|
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns an empty list for blank queries", async () => {
|
|
await expect(
|
|
searchHomeDirectories({
|
|
homeDir,
|
|
query: " ",
|
|
limit: 10,
|
|
}),
|
|
).resolves.toEqual([]);
|
|
});
|
|
|
|
it("returns only existing directories", async () => {
|
|
const results = await searchHomeDirectories({
|
|
homeDir,
|
|
query: "proj",
|
|
limit: 10,
|
|
});
|
|
|
|
expect(results).toContain(path.join(homeDir, "projects"));
|
|
expect(results).toContain(path.join(homeDir, "projects", "paseo"));
|
|
expect(results).not.toContain(path.join(homeDir, "projects", "README.md"));
|
|
});
|
|
|
|
it("supports home-relative path query syntax", async () => {
|
|
const results = await searchHomeDirectories({
|
|
homeDir,
|
|
query: "~/projects/pa",
|
|
limit: 10,
|
|
});
|
|
|
|
expect(results).toEqual([path.join(homeDir, "projects", "paseo")]);
|
|
});
|
|
|
|
it("prioritizes exact segment matches before segment-prefix matches", async () => {
|
|
const exactSegmentPath = path.join(homeDir, "something", "faro", "something-else");
|
|
const prefixSegmentPath = path.join(homeDir, "something", "somethingelse", "faro-bla");
|
|
mkdirSync(exactSegmentPath, { recursive: true });
|
|
mkdirSync(prefixSegmentPath, { recursive: true });
|
|
|
|
const results = await searchHomeDirectories({
|
|
homeDir,
|
|
query: "faro",
|
|
limit: 30,
|
|
});
|
|
|
|
const exactIndex = results.indexOf(exactSegmentPath);
|
|
const prefixIndex = results.indexOf(prefixSegmentPath);
|
|
expect(exactIndex).toBeGreaterThanOrEqual(0);
|
|
expect(prefixIndex).toBeGreaterThanOrEqual(0);
|
|
expect(exactIndex).toBeLessThan(prefixIndex);
|
|
});
|
|
|
|
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 searchHomeDirectories({
|
|
homeDir,
|
|
query: "arofo",
|
|
limit: 30,
|
|
});
|
|
|
|
const earlierIndex = results.indexOf(earlierPath);
|
|
const laterIndex = results.indexOf(laterPath);
|
|
expect(earlierIndex).toBeGreaterThanOrEqual(0);
|
|
expect(laterIndex).toBeGreaterThanOrEqual(0);
|
|
expect(earlierIndex).toBeLessThan(laterIndex);
|
|
});
|
|
|
|
it.skipIf(isWindows)("returns home-root suggestions when query is '~'", async () => {
|
|
const results = await searchHomeDirectories({
|
|
homeDir,
|
|
query: "~",
|
|
limit: 20,
|
|
});
|
|
|
|
expect(results).toContain(path.join(homeDir, "projects"));
|
|
expect(results).toContain(path.join(homeDir, "documents"));
|
|
expect(results).not.toContain(path.join(homeDir, ".hidden"));
|
|
});
|
|
|
|
it("does not return hidden directories during tree search", async () => {
|
|
const results = await searchHomeDirectories({
|
|
homeDir,
|
|
query: "cache",
|
|
limit: 20,
|
|
});
|
|
|
|
expect(results).not.toContain(path.join(homeDir, ".hidden", "cache"));
|
|
});
|
|
|
|
it("does not return paths that escape home through symlinks", async () => {
|
|
const results = await searchHomeDirectories({
|
|
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 searchHomeDirectories({
|
|
homeDir,
|
|
query: "p",
|
|
limit: 1,
|
|
});
|
|
|
|
expect(results).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("searchWorkspaceEntries", () => {
|
|
let tempRoot: string;
|
|
let workspaceDir: string;
|
|
let outsideDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-suggestions-")));
|
|
workspaceDir = path.join(tempRoot, "workspace");
|
|
outsideDir = path.join(tempRoot, "outside");
|
|
|
|
mkdirSync(path.join(workspaceDir, "src", "components"), {
|
|
recursive: true,
|
|
});
|
|
mkdirSync(path.join(workspaceDir, "docs"), { recursive: true });
|
|
mkdirSync(path.join(outsideDir, "escaped"), { 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");
|
|
|
|
symlinkSync(path.join(outsideDir, "escaped"), path.join(workspaceDir, "escaped-link"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns relative file and directory suggestions for workspace queries", async () => {
|
|
const results = await searchWorkspaceEntries({
|
|
cwd: workspaceDir,
|
|
query: "chat",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
});
|
|
|
|
expect(results).toContainEqual({
|
|
path: "src/components/chat-input.tsx",
|
|
kind: "file",
|
|
});
|
|
expect(results.some((entry) => entry.path === path.join(workspaceDir, "src"))).toBe(false);
|
|
});
|
|
|
|
it("filters entries by kind", async () => {
|
|
const dirsOnly = await searchWorkspaceEntries({
|
|
cwd: workspaceDir,
|
|
query: "src",
|
|
limit: 20,
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
});
|
|
expect(dirsOnly.some((entry) => entry.kind === "file")).toBe(false);
|
|
expect(dirsOnly.some((entry) => entry.path === "src")).toBe(true);
|
|
|
|
const filesOnly = await searchWorkspaceEntries({
|
|
cwd: workspaceDir,
|
|
query: "readme",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
});
|
|
expect(filesOnly).toEqual([{ path: "README.md", kind: "file" }]);
|
|
});
|
|
|
|
it("supports path-style queries and does not escape cwd through symlinks", async () => {
|
|
const pathResults = await searchWorkspaceEntries({
|
|
cwd: workspaceDir,
|
|
query: "src/co",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
});
|
|
expect(pathResults).toContainEqual({
|
|
path: "src/components",
|
|
kind: "directory",
|
|
});
|
|
|
|
const escapedResults = await searchWorkspaceEntries({
|
|
cwd: workspaceDir,
|
|
query: "escaped",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
});
|
|
expect(escapedResults.some((entry) => entry.path.includes("escaped-link"))).toBe(false);
|
|
});
|
|
|
|
it("ignores node_modules entries so deep workspace files still resolve under scan limits", async () => {
|
|
mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true });
|
|
writeFileSync(path.join(workspaceDir, "packages", "app", "src", "app", "_layout.tsx"), "");
|
|
|
|
for (let index = 0; index < 120; index += 1) {
|
|
mkdirSync(path.join(workspaceDir, "node_modules", `pkg-${index}`), { recursive: true });
|
|
writeFileSync(
|
|
path.join(workspaceDir, "node_modules", `pkg-${index}`, "index.js"),
|
|
"module.exports = {};\n",
|
|
);
|
|
}
|
|
writeFileSync(path.join(workspaceDir, "node_modules", "_layout.tsx"), "");
|
|
|
|
const results = await searchWorkspaceEntries({
|
|
cwd: workspaceDir,
|
|
query: "_layout.tsx",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
maxEntriesScanned: 60,
|
|
});
|
|
|
|
expect(results).toContainEqual({
|
|
path: "packages/app/src/app/_layout.tsx",
|
|
kind: "file",
|
|
});
|
|
expect(results.some((entry) => entry.path.startsWith("node_modules/"))).toBe(false);
|
|
});
|
|
|
|
it("ignores common build/cache directories so large generated trees do not exhaust scan budget", async () => {
|
|
mkdirSync(path.join(workspaceDir, "packages", "app", "src"), { recursive: true });
|
|
writeFileSync(path.join(workspaceDir, "packages", "app", "src", "needle.ts"), "");
|
|
|
|
const heavyDirs = ["dist", "build", "target", "out", "coverage", "vendor", "__pycache__"];
|
|
for (const heavyDir of heavyDirs) {
|
|
for (let index = 0; index < 30; index += 1) {
|
|
mkdirSync(path.join(workspaceDir, heavyDir, `bundle-${index}`), { recursive: true });
|
|
writeFileSync(path.join(workspaceDir, heavyDir, `bundle-${index}`, "needle.ts"), "");
|
|
}
|
|
}
|
|
|
|
const results = await searchWorkspaceEntries({
|
|
cwd: workspaceDir,
|
|
query: "needle.ts",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
maxEntriesScanned: 80,
|
|
});
|
|
|
|
expect(results).toContainEqual({
|
|
path: "packages/app/src/needle.ts",
|
|
kind: "file",
|
|
});
|
|
for (const heavyDir of heavyDirs) {
|
|
expect(results.some((entry) => entry.path.startsWith(`${heavyDir}/`))).toBe(false);
|
|
}
|
|
});
|
|
});
|