diff --git a/packages/app/src/utils/working-directory-suggestions.test.ts b/packages/app/src/utils/working-directory-suggestions.test.ts index 480fc7738..4ef532227 100644 --- a/packages/app/src/utils/working-directory-suggestions.test.ts +++ b/packages/app/src/utils/working-directory-suggestions.test.ts @@ -32,17 +32,30 @@ describe("buildWorkingDirectorySuggestions", () => { expect(results).toEqual(["/Users/me/projects/paseo-desktop"]); }); - it("leaves path-query semantics to the daemon", () => { + it("matches recommended paths using the complete path text", () => { const results = buildWorkingDirectorySuggestions({ recommendedPaths: [ "/Users/me/archive/projects/paseo-desktop", "/Users/me/projects/paseo-desktop", ], serverPaths: [], - query: "~/projects/pso", + query: "projects/pso", }); - expect(results).toEqual([]); + expect(results).toEqual([ + "/Users/me/archive/projects/paseo-desktop", + "/Users/me/projects/paseo-desktop", + ]); + }); + + it("fuzzy-matches recommended paths using their full path", () => { + const results = buildWorkingDirectorySuggestions({ + recommendedPaths: ["/Users/me/projects/blankpage/editor"], + serverPaths: [], + query: "blank page editor", + }); + + expect(results).toEqual(["/Users/me/projects/blankpage/editor"]); }); it("treats '~' as an active query and includes daemon suggestions", () => { diff --git a/packages/app/src/utils/working-directory-suggestions.ts b/packages/app/src/utils/working-directory-suggestions.ts index 50510fd9b..6cb468a68 100644 --- a/packages/app/src/utils/working-directory-suggestions.ts +++ b/packages/app/src/utils/working-directory-suggestions.ts @@ -1,4 +1,4 @@ -import { scoreMatch } from "@getpaseo/protocol/search/text-match"; +import { scorePathMatch } from "@getpaseo/protocol/search/text-match"; export interface BuildWorkingDirectorySuggestionsInput { recommendedPaths: string[]; @@ -19,9 +19,8 @@ export function buildWorkingDirectorySuggestions( recommendedPathMatchesQuery(path, query), ); - // The request owner correlates these results with the current query. The - // daemon owns filesystem query parsing, filtering, and ranking; doing it - // again here creates a second search implementation that can disagree. + // Server paths are already ranked by the daemon. Recommended paths use the + // same shared matcher, then keep their existing recommendation order. return uniquePaths([...matchingRecommended, ...input.serverPaths]); } @@ -45,12 +44,8 @@ function recommendedPathMatchesQuery(path: string, query: string): boolean { if (["~", "~/"].includes(normalizedQuery)) { return true; } - if (normalizedQuery.includes("/") || normalizedQuery.startsWith("~")) { - return false; - } - const basename = candidate.split("/").at(-1) ?? ""; - return candidate.includes(normalizedQuery) || scoreMatch(normalizedQuery, basename) !== null; + return scorePathMatch(normalizedQuery, candidate) !== null; } function normalizePath(value: string): string { diff --git a/packages/protocol/src/search/text-match.test.ts b/packages/protocol/src/search/text-match.test.ts index b7c09ee30..d3454ed29 100644 --- a/packages/protocol/src/search/text-match.test.ts +++ b/packages/protocol/src/search/text-match.test.ts @@ -5,6 +5,7 @@ import { fuzzyPolicyForToken, matchRanges, scoreMatch, + scorePathMatch, scoreTextFields, } from "./text-match.js"; @@ -171,6 +172,28 @@ describe("scoreMatch typo tolerance", () => { }); }); +describe("scorePathMatch", () => { + it("matches a literal fragment anywhere in the complete path", () => { + expect( + scorePathMatch("skills/", "something/something-else/skills/paseo-advisor/SKILL.md"), + ).not.toBeNull(); + }); + + it("fuzzy-matches spaced text against a compact path", () => { + expect(scorePathMatch("blank page editor", "blankpage/editor")).toEqual({ + tier: 0, + offset: 0, + }); + }); + + it("keeps the original path offset for compact matches", () => { + expect(scorePathMatch("blank page editor", "projects/blankpage/editor")).toEqual({ + tier: 4, + offset: 9, + }); + }); +}); + describe("scoreTextFields", () => { it("requires every token to match some field", () => { expect(scoreTextFields("stripe billing", ["add stripe billing system"])).not.toBeNull(); diff --git a/packages/protocol/src/search/text-match.ts b/packages/protocol/src/search/text-match.ts index 63c2a36c7..b15789ff7 100644 --- a/packages/protocol/src/search/text-match.ts +++ b/packages/protocol/src/search/text-match.ts @@ -201,6 +201,39 @@ export function scoreMatch( return fuzzy ? scoreFuzzyMatch(q, t, fuzzy) : null; } +interface CompactText { + value: string; + offsets: number[]; +} + +function compactText(value: string): CompactText { + let compact = ""; + const offsets: number[] = []; + for (let index = 0; index < value.length; index += 1) { + if (!/[a-z0-9]/i.test(value[index])) continue; + compact += value[index].toLowerCase(); + offsets.push(index); + } + return { value: compact, offsets }; +} + +/** Match a query against a complete displayed path, including its separators. */ +export function scorePathMatch(query: string, path: string): MatchScore | null { + const direct = scoreMatch(query, path); + if (direct) return direct; + + const compactQuery = compactText(query); + const compactPath = compactText(path); + if (!compactQuery.value || !compactPath.value) return null; + + const compactScore = scoreMatch(compactQuery.value, compactPath.value); + if (!compactScore) return null; + return { + ...compactScore, + offset: compactPath.offsets[compactScore.offset] ?? compactScore.offset, + }; +} + export interface MatchRange { start: number; length: number; diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 0297d8ac4..8b0337928 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -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" }]); + }); }); diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 03031fbeb..aa24c4a6b 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -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 { 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,