mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix exact workspace file suggestions
Resolve exact suffix path queries before falling back to broad search, and keep broad hidden-directory traversal limited to known workspace config dirs.
This commit is contained in:
@@ -332,11 +332,63 @@ describe("searchWorkspaceEntries", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("suffix mode finds files under hidden workspace directories", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
|
||||
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 searchWorkspaceEntries({
|
||||
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 searchWorkspaceEntries({
|
||||
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("suffix mode finds files under allowlisted hidden workspace directories", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
|
||||
mkdirSync(path.join(workspaceDir, ".github", "workflows"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
|
||||
writeFileSync(path.join(workspaceDir, ".github", "workflows", "ci.yml"), "");
|
||||
|
||||
const claudeResults = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "settings.local.json",
|
||||
limit: 20,
|
||||
@@ -344,8 +396,34 @@ describe("searchWorkspaceEntries", () => {
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
const githubResults = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "ci.yml",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
|
||||
expect(results).toEqual([{ path: ".claude/settings.local.json", kind: "file" }]);
|
||||
expect(claudeResults).toEqual([{ path: ".claude/settings.local.json", kind: "file" }]);
|
||||
expect(githubResults).toEqual([{ path: ".github/workflows/ci.yml", kind: "file" }]);
|
||||
});
|
||||
|
||||
it("does not broadly traverse unlisted hidden workspace directories", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".dev", "cache"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, ".dev", "cache", "needle.ts"), "");
|
||||
writeFileSync(path.join(workspaceDir, "src", "needle.ts"), "");
|
||||
|
||||
const results = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "needle.ts",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
|
||||
expect(results).toEqual([{ path: "src/needle.ts", kind: "file" }]);
|
||||
});
|
||||
|
||||
it("does not suggest hidden directories even when includeDirectories is true", async () => {
|
||||
|
||||
@@ -92,6 +92,14 @@ const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([
|
||||
"__pycache__",
|
||||
".git",
|
||||
]);
|
||||
const TRAVERSABLE_HIDDEN_WORKSPACE_DIRECTORY_NAMES = new Set([
|
||||
".agents",
|
||||
".claude",
|
||||
".codex",
|
||||
".github",
|
||||
".paseo",
|
||||
".vscode",
|
||||
]);
|
||||
|
||||
export async function searchHomeDirectories(
|
||||
options: SearchHomeDirectoriesOptions,
|
||||
@@ -161,6 +169,19 @@ export async function searchWorkspaceEntries(
|
||||
}
|
||||
|
||||
const matchMode = options.matchMode ?? "fuzzy";
|
||||
const exactEntry =
|
||||
queryParts.isPathQuery && matchMode === "suffix"
|
||||
? await resolveWorkspaceExactEntry({
|
||||
workspaceRoot,
|
||||
query: options.query,
|
||||
includeDirectories,
|
||||
includeFiles,
|
||||
})
|
||||
: null;
|
||||
if (exactEntry && limit <= 1) {
|
||||
return [exactEntry];
|
||||
}
|
||||
|
||||
if (queryParts.isPathQuery && matchMode !== "suffix") {
|
||||
return searchWorkspaceWithinParentDirectory({
|
||||
workspaceRoot,
|
||||
@@ -176,7 +197,7 @@ export async function searchWorkspaceEntries(
|
||||
matchMode === "suffix"
|
||||
? [queryParts.parentPart, queryParts.searchTerm].filter(Boolean).join("/")
|
||||
: queryParts.searchTerm;
|
||||
return searchWorkspaceAcrossTree({
|
||||
const entries = await searchWorkspaceAcrossTree({
|
||||
workspaceRoot,
|
||||
searchTerm,
|
||||
limit,
|
||||
@@ -186,6 +207,66 @@ export async function searchWorkspaceEntries(
|
||||
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
|
||||
maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED,
|
||||
});
|
||||
return exactEntry ? prependWorkspaceEntry(exactEntry, entries).slice(0, limit) : entries;
|
||||
}
|
||||
|
||||
async function resolveWorkspaceExactEntry(input: {
|
||||
workspaceRoot: string;
|
||||
query: string;
|
||||
includeDirectories: boolean;
|
||||
includeFiles: boolean;
|
||||
}): Promise<WorkspaceSuggestionEntry | null> {
|
||||
const normalized = input.query
|
||||
.trim()
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\.\/+/, "")
|
||||
.replace(/\/{2,}/g, "/");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidatePath = path.isAbsolute(normalized)
|
||||
? path.resolve(normalized)
|
||||
: path.resolve(input.workspaceRoot, normalized);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await realpath(candidatePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isPathInsideRoot(input.workspaceRoot, resolvedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = await stat(resolvedPath).catch(() => null);
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
if (stats.isFile() && input.includeFiles) {
|
||||
return {
|
||||
path: normalizeRelativePath(input.workspaceRoot, resolvedPath),
|
||||
kind: "file",
|
||||
};
|
||||
}
|
||||
if (stats.isDirectory() && input.includeDirectories) {
|
||||
return {
|
||||
path: normalizeRelativePath(input.workspaceRoot, resolvedPath),
|
||||
kind: "directory",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function prependWorkspaceEntry(
|
||||
entry: WorkspaceSuggestionEntry,
|
||||
entries: WorkspaceSuggestionEntry[],
|
||||
): WorkspaceSuggestionEntry[] {
|
||||
return [
|
||||
entry,
|
||||
...entries.filter(
|
||||
(candidate) => candidate.kind !== entry.kind || candidate.path !== entry.path,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeLimit(limit: number | undefined): number {
|
||||
@@ -879,7 +960,14 @@ async function listWorkspaceChildEntries(input: {
|
||||
if (isIgnoredSuggestionDirectoryName(dirent.name)) {
|
||||
return false;
|
||||
}
|
||||
// Hidden directories must remain traversable so file links like
|
||||
if (
|
||||
isHiddenDirectoryName(dirent.name) &&
|
||||
!dirent.isFile() &&
|
||||
!isTraversableHiddenWorkspaceDirectoryName(dirent.name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Allowlisted hidden directories remain traversable so file links like
|
||||
// `.claude/settings.local.json` can resolve, but hidden files (e.g.
|
||||
// `.DS_Store`) should never be suggested.
|
||||
if (dirent.isFile() && isHiddenDirectoryName(dirent.name)) {
|
||||
@@ -985,6 +1073,10 @@ function isIgnoredSuggestionDirectoryName(name: string): boolean {
|
||||
return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name);
|
||||
}
|
||||
|
||||
function isTraversableHiddenWorkspaceDirectoryName(name: string): boolean {
|
||||
return TRAVERSABLE_HIDDEN_WORKSPACE_DIRECTORY_NAMES.has(name);
|
||||
}
|
||||
|
||||
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
|
||||
directoryListCache.set(cacheKey, entry);
|
||||
pruneDirectoryListCache();
|
||||
|
||||
Reference in New Issue
Block a user