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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user