Skip to content
Merged
37 changes: 37 additions & 0 deletions packages/shared/src/paletteSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,43 @@ describe("scoreThreadSearch", () => {
thread("Share auth across worktrees"),
]),
).toEqual(["Share auth across worktrees", "Authentication cleanup", "Fix oauth redirect"]);
expect(
rankTitles("share auth", [
thread("Share authentication cleanup", {
activityAt: NOW,
opens: { openedAt: NOW, score: 12, picks: [] },
}),
thread("Auth across worktrees to share", { activityAt: NOW - 90 * DAY }),
]),
).toEqual(["Auth across worktrees to share", "Share authentication cleanup"]);
expect(
rankTitles("ui", [
thread("Parse ui tokens", { activityAt: NOW - 200 * DAY }),
thread("Polish UIs", { activityAt: NOW, opens: { openedAt: NOW, score: 12, picks: [] } }),
]),
).toEqual(["Polish UIs", "Parse ui tokens"]);
expect(
rankTitles("fix in sidebar", [
thread("Fix sidebar", { activityAt: NOW - 200 * DAY }),
thread("Fix crash in sidebar", {
activityAt: NOW,
opens: { openedAt: NOW, score: 12, picks: [] },
}),
thread("Fix sidebar input focus", {
activityAt: NOW,
opens: { openedAt: NOW, score: 6, picks: [] },
}),
]),
).toEqual(["Fix crash in sidebar", "Fix sidebar input focus", "Fix sidebar"]);
expect(
rankTitles("and", [
thread("Fix auth and sync", { activityAt: NOW - 200 * DAY }),
thread("Android build", {
activityAt: NOW,
opens: { openedAt: NOW, score: 12, picks: [] },
}),
]),
).toEqual(["Android build", "Fix auth and sync"]);
});

it("treats camelCase, kebab, path, and digit boundaries as word starts", () => {
Expand Down
18 changes: 17 additions & 1 deletion packages/shared/src/paletteSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,11 @@ interface WeightedSearchField {
interface SearchFieldsMatch {
/** Roughly 0.3 (inside a word) to 1.5 (exact title). */
readonly quality: number;
/**
* Every non-filler token has three or more characters and matched a whole word at full
* weight, before phrase bonuses. Optional filler words don't count either way.
*/
readonly wholeWords: boolean;
/** Every token matched at a word start, without a typo. */
readonly strong: boolean;
/** Source ranges matched in the first field, for highlighting. */
Expand Down Expand Up @@ -407,6 +412,7 @@ function matchSearchFields(
const primaryRanges: SearchRange[] = [];
let total = 0;
let counted = 0;
let notWhole = false;
let strong = true;
for (const token of tokens) {
let best = 0;
Expand All @@ -433,6 +439,9 @@ function matchSearchFields(
if (bestPrimary) primaryRanges.push(...(bestPrimary.letters ?? [bestPrimary]));
total += best;
counted += 1;
// Optional filler never decides the whole-word tier; a query of only filler never earns it.
if (FILLER_WORDS.has(token)) notWhole ||= !fillerOptional;
else notWhole ||= token.length < PICK_PREFIX_MIN_LENGTH || best < WHOLE_WORD;
}
let quality = total / counted;
if (primary) {
Expand All @@ -441,6 +450,7 @@ function matchSearchFields(
}
return {
quality,
wholeWords: !notWhole,
strong,
primaryRanges: mergeRanges(primaryRanges),
};
Expand Down Expand Up @@ -851,7 +861,7 @@ function matchReference(
};
return (thread.id && matches(thread.id)) ||
thread.pullRequests?.some((pullRequest) => pullRequest.references?.some(matches))
? { quality: 0.6, strong: true, primaryRanges: [] }
? { quality: 0.6, wholeWords: false, strong: true, primaryRanges: [] }
: null;
}

Expand Down Expand Up @@ -888,6 +898,7 @@ export interface ThreadSearchMatch {
readonly score: number;
/** Match quality alone; see `rankSearchGroups`. */
readonly quality: number;
readonly wholeWords: boolean;
/** Every token matched at a word start; see `rankSearchGroups`. */
readonly strong: boolean;
/** Matched ranges in the title. */
Expand Down Expand Up @@ -1043,6 +1054,7 @@ export function scoreThreadSearch(
return {
score: match.quality * threadUsageBoost(thread, now),
quality: match.quality,
wholeWords: match.wholeWords,
// A thread's whole title never lifts its group over a command or project
// of that name: every new thread is titled "New thread" for a while.
groupQuality: Math.min(match.quality, WHOLE_NAME - 0.01),
Expand Down Expand Up @@ -1146,6 +1158,7 @@ export function rankSearchGroups<
readonly pickedAt?: number;
readonly pickedExactly?: boolean;
readonly groupQuality?: number;
readonly wholeWords?: boolean;
},
>(
groups: ReadonlyArray<{ readonly group: G; readonly items: ReadonlyArray<T> }>,
Expand Down Expand Up @@ -1173,12 +1186,15 @@ export function rankSearchGroups<
// A longer-query pick must not pass a whole-name match for the current query.
const wordMatch = (match: M) => Math.min(matchClass(match.quality), 2);
const exactPickAt = (match: M) => (match.pickedExactly ? (match.pickedAt ?? 0) : 0);
// Use reorders whole words or prefixes among themselves, never a prefix above a whole word.
const wholeWord = (match: M) => Number(match.wholeWords ?? match.quality >= WHOLE_WORD);
const sorted = [...items].sort(
(left, right) =>
wordMatch(right.match) - wordMatch(left.match) ||
exactPickAt(right.match) - exactPickAt(left.match) ||
matchClass(right.match.quality) - matchClass(left.match.quality) ||
(right.match.pickedAt ?? 0) - (left.match.pickedAt ?? 0) ||
wholeWord(right.match) - wholeWord(left.match) ||
right.match.score - left.match.score,
);
const best = Math.max(
Expand Down
Loading