fix(search): match complete paths in file and project search

Treat slash-separated fragments as searchable text and use the same full-path fuzzy matcher for daemon results and remembered project paths.
This commit is contained in:
Mohamed Boudra
2026-08-19 12:18:23 +02:00
parent 91024bc029
commit e55dd5a212
6 changed files with 245 additions and 45 deletions
@@ -223,7 +223,7 @@ describe("searchDirectoryEntries", () => {
expect(suffixRootBrowses).toEqual([rootEntries, rootEntries]);
});
it("anchors rooted one-segment queries to their root parent", async () => {
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");
@@ -236,7 +236,11 @@ describe("searchDirectoryEntries", () => {
pathQueryPolicy: "rooted" as const,
rootAliases: ["~"],
};
const expected = [{ path: "pso-root", kind: "directory" }];
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);
@@ -264,7 +268,10 @@ describe("searchDirectoryEntries", () => {
{ path: "projects", kind: "directory" },
{ path: "src", kind: "directory" },
],
projectEntries: [{ path: "projects/paseo-desktop", kind: "directory" }],
projectEntries: [
{ path: "projects", kind: "directory" },
{ path: "projects/paseo-desktop", kind: "directory" },
],
});
});
@@ -900,4 +907,46 @@ describe("relative typed-entry configuration", () => {
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" }]);
});
});
@@ -1,6 +1,7 @@
import type { Dirent, Stats } from "node:fs";
import { readdir, realpath, stat } from "node:fs/promises";
import path from "node:path";
import { scorePathMatch, type MatchScore } from "@getpaseo/protocol/search/text-match";
import { isPathInsideRoot } from "./path.js";
import { runGitCommand } from "./run-git-command.js";
@@ -65,6 +66,13 @@ interface RankedEntry extends DirectorySuggestionEntry {
depth: number;
}
interface RankFields {
matchTier: number;
segmentIndex: number;
matchOffset: number;
fuzzyScore: number;
}
interface DirectoryListCacheEntry {
expiresAt: number;
modifiedAtMs: number;
@@ -143,10 +151,9 @@ export async function searchDirectoryEntries(
if (exact && input.limit === 1) return [exact];
const browsesRoot = input.plan.isPathQuery && !input.plan.normalizedQuery;
const browsesAbsoluteParent = input.plan.browseExactPath === true;
const ranked =
input.plan.isPathQuery && (input.matchMode === "fuzzy" || browsesRoot)
? await searchChildren(input)
: await searchTree(input);
browsesRoot || browsesAbsoluteParent ? await searchChildren(input) : await searchTree(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)
@@ -264,7 +271,9 @@ async function searchTree(input: SearchInput): Promise<RankedEntry[]> {
if (shouldSuggest(entry, input)) ranked.push(rank(entry, input));
if (
scanned >= input.maxEntriesScanned ||
(threshold && scanned >= threshold && hasConfidentResult(ranked, input.plan.searchTerm))
(threshold &&
scanned >= threshold &&
hasConfidentResult(ranked, input.plan.normalizedQuery || input.plan.searchTerm))
)
break;
}
@@ -346,45 +355,123 @@ function shouldSuggest(entry: TraversedEntry, input: SearchInput): boolean {
if (!input.plan.normalizedQuery) return true;
if (input.matchMode === "suffix")
return suffixMatches(entry.visiblePath, input.root, input.plan.normalizedQuery);
return !input.plan.searchTerm || rank(entry, input).matchTier !== NO_MATCH_TIER;
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 = input.plan.searchTerm.toLowerCase();
const query = getRankQuery(input);
const segments = lowerPath === "." ? [] : lowerPath.split("/");
const exact = findSegmentMatchIndex(segments, (segment) => segment === query);
const prefix = findSegmentMatchIndex(segments, (segment) => segment.startsWith(query));
const substring = findSegmentMatchIndex(segments, (segment) => segment.includes(query));
const offset = lowerPath.indexOf(query);
const fuzzyScore = scoreFuzzySubsequence(query, segments.at(-1) ?? "");
let matchTier = NO_MATCH_TIER;
let segmentIndex = NO_SEGMENT_INDEX;
if (!query) matchTier = 3;
else if (exact >= 0) {
matchTier = 0;
segmentIndex = exact;
} else if (prefix >= 0) {
matchTier = 1;
segmentIndex = prefix;
} else if (substring >= 0) {
matchTier = 2;
segmentIndex = substring;
} else if (input.pathFormat === "relative" ? lowerPath.startsWith(query) : offset >= 0)
matchTier = 3;
else if (fuzzyScore !== null) matchTier = 4;
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,
matchTier,
segmentIndex,
matchOffset: offset >= 0 ? offset : NO_MATCH_OFFSET,
fuzzyScore: fuzzyScore ?? NO_FUZZY_SCORE,
...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,