feat(app): rank combobox options by word-boundary match quality
Introduce a shared scoreMatch utility that tiers matches by word-boundary vs mid-word offset, and use it to rank combobox options so e.g. "py" prefers "a/py" over "happy".
This commit is contained in:
@@ -88,6 +88,39 @@ describe("filterAndRankComboboxOptions", () => {
|
||||
it("returns empty when nothing matches", () => {
|
||||
expect(filterAndRankComboboxOptions(options, "zzz")).toEqual([]);
|
||||
});
|
||||
|
||||
it("ranks word-boundary matches above mid-word substring matches", () => {
|
||||
const items = [
|
||||
{ id: "happy", label: "happy" },
|
||||
{ id: "a/py", label: "a/py" },
|
||||
];
|
||||
const result = filterAndRankComboboxOptions(items, "py");
|
||||
expect(result.map((o) => o.id)).toEqual(["a/py", "happy"]);
|
||||
});
|
||||
|
||||
it("interleaves mixed branches and PRs by match quality", () => {
|
||||
const items = [
|
||||
{ id: "branch:feat/api-login", label: "feat/api-login" },
|
||||
{ id: "branch:feat/pi-direct-sdk", label: "feat/pi-direct-sdk" },
|
||||
{ id: "github-pr:202", label: "#202 feat(server): replace Pi ACP with direct SDK provider" },
|
||||
{ id: "github-pr:355", label: "#355 feat: add LaTeX math formula rendering" },
|
||||
];
|
||||
const result = filterAndRankComboboxOptions(items, "pi");
|
||||
expect(result.map((o) => o.id)).toEqual([
|
||||
"branch:feat/pi-direct-sdk",
|
||||
"github-pr:202",
|
||||
"branch:feat/api-login",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ranks PR-number matches via word-boundary on #", () => {
|
||||
const items = [
|
||||
{ id: "github-pr:202", label: "#202 some title" },
|
||||
{ id: "github-pr:1202", label: "#1202 another title" },
|
||||
];
|
||||
const result = filterAndRankComboboxOptions(items, "202");
|
||||
expect(result.map((o) => o.id)).toEqual(["github-pr:202", "github-pr:1202"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combobox above-search ordering", () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { compareMatchScores, type MatchScore, scoreMatch } from "../../utils/score-match";
|
||||
|
||||
export type ComboboxOptionKind = "directory" | "file";
|
||||
|
||||
export interface ComboboxOptionModel {
|
||||
@@ -7,6 +9,23 @@ export interface ComboboxOptionModel {
|
||||
kind?: ComboboxOptionKind;
|
||||
}
|
||||
|
||||
const DESCRIPTION_FALLBACK_TIER = 99;
|
||||
|
||||
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
|
||||
const labelScore = scoreMatch(search, opt.label);
|
||||
const idScore = scoreMatch(search, opt.id);
|
||||
let best: MatchScore | null = null;
|
||||
if (labelScore) best = labelScore;
|
||||
if (idScore && (!best || compareMatchScores(idScore, best) < 0)) {
|
||||
best = idScore;
|
||||
}
|
||||
if (best) return best;
|
||||
if (opt.description && opt.description.toLowerCase().includes(search)) {
|
||||
return { tier: DESCRIPTION_FALLBACK_TIER, offset: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface BuildVisibleComboboxOptionsInput {
|
||||
options: ComboboxOptionModel[];
|
||||
searchQuery: string;
|
||||
@@ -40,21 +59,17 @@ export function filterAndRankComboboxOptions(
|
||||
search: string,
|
||||
): ComboboxOptionModel[] {
|
||||
if (!search) return options;
|
||||
return options
|
||||
.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(search) ||
|
||||
opt.id.toLowerCase().includes(search) ||
|
||||
opt.description?.toLowerCase().includes(search),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aPrefix =
|
||||
a.label.toLowerCase().startsWith(search) || a.id.toLowerCase().startsWith(search);
|
||||
const bPrefix =
|
||||
b.label.toLowerCase().startsWith(search) || b.id.toLowerCase().startsWith(search);
|
||||
if (aPrefix !== bPrefix) return aPrefix ? -1 : 1;
|
||||
return 0;
|
||||
});
|
||||
const scored: { opt: ComboboxOptionModel; score: MatchScore }[] = [];
|
||||
for (const opt of options) {
|
||||
const score = scoreOption(opt, search);
|
||||
if (score) scored.push({ opt, score });
|
||||
}
|
||||
scored.sort((a, b) => {
|
||||
const cmp = compareMatchScores(a.score, b.score);
|
||||
if (cmp !== 0) return cmp;
|
||||
return a.opt.label.localeCompare(b.opt.label);
|
||||
});
|
||||
return scored.map((entry) => entry.opt);
|
||||
}
|
||||
|
||||
export function buildVisibleComboboxOptions(
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { compareMatchScores, scoreMatch } from "./score-match";
|
||||
|
||||
describe("scoreMatch", () => {
|
||||
it("returns tier 0 for empty query", () => {
|
||||
expect(scoreMatch("", "anything")).toEqual({ tier: 0, offset: 0 });
|
||||
});
|
||||
|
||||
it("returns tier 0 for exact match ignoring case", () => {
|
||||
expect(scoreMatch("pi", "pi")).toEqual({ tier: 0, offset: 0 });
|
||||
expect(scoreMatch("PI", "pi")).toEqual({ tier: 0, offset: 0 });
|
||||
expect(scoreMatch("Pi", "pI")).toEqual({ tier: 0, offset: 0 });
|
||||
});
|
||||
|
||||
it("returns tier 1 for whole-word match at start", () => {
|
||||
expect(scoreMatch("feat", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 0 });
|
||||
});
|
||||
|
||||
it("returns tier 1 for whole-word match in middle (word boundaries on both sides)", () => {
|
||||
expect(scoreMatch("pi", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 5 });
|
||||
expect(scoreMatch("pi", "a b pi c d")).toEqual({ tier: 1, offset: 4 });
|
||||
});
|
||||
|
||||
it("returns tier 2 for string prefix that does not complete a word", () => {
|
||||
expect(scoreMatch("par", "party")).toEqual({ tier: 2, offset: 0 });
|
||||
});
|
||||
|
||||
it("returns tier 3 for word-boundary start that does not complete a word", () => {
|
||||
expect(scoreMatch("par", "a/party")).toEqual({ tier: 3, offset: 2 });
|
||||
});
|
||||
|
||||
it("returns tier 4 for substring inside a word", () => {
|
||||
expect(scoreMatch("art", "party")).toEqual({ tier: 4, offset: 1 });
|
||||
});
|
||||
|
||||
it("returns null when query is not found", () => {
|
||||
expect(scoreMatch("xyz", "feat/pi-direct-sdk")).toBeNull();
|
||||
});
|
||||
|
||||
it("picks the best tier across multiple occurrences", () => {
|
||||
expect(scoreMatch("ab", "xabxab-yy")).toEqual({ tier: 4, offset: 1 });
|
||||
expect(scoreMatch("ab", "xxab x-ab-y")).toEqual({ tier: 1, offset: 7 });
|
||||
});
|
||||
|
||||
it("prefers earlier offset when tiers are equal", () => {
|
||||
expect(scoreMatch("pi", "pi-a pi-b")).toEqual({ tier: 1, offset: 0 });
|
||||
});
|
||||
|
||||
it("treats common separators as word boundaries", () => {
|
||||
expect(scoreMatch("pi", "x/pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x-pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x_pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x.pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x:pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("202", "#202 feat")).toEqual({ tier: 1, offset: 1 });
|
||||
});
|
||||
|
||||
it("scores PR-title-shaped text realistically", () => {
|
||||
const pr202 = "#202 feat(server): replace Pi ACP with direct SDK provider";
|
||||
expect(scoreMatch("pi", pr202)?.tier).toBe(1);
|
||||
expect(scoreMatch("202", pr202)).toEqual({ tier: 1, offset: 1 });
|
||||
expect(scoreMatch("replace", pr202)?.tier).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareMatchScores", () => {
|
||||
it("sorts by tier ascending", () => {
|
||||
expect(compareMatchScores({ tier: 1, offset: 10 }, { tier: 2, offset: 0 })).toBeLessThan(0);
|
||||
expect(compareMatchScores({ tier: 3, offset: 0 }, { tier: 1, offset: 99 })).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("tie-breaks by offset ascending at the same tier", () => {
|
||||
expect(compareMatchScores({ tier: 1, offset: 0 }, { tier: 1, offset: 5 })).toBeLessThan(0);
|
||||
expect(compareMatchScores({ tier: 1, offset: 5 }, { tier: 1, offset: 5 })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface MatchScore {
|
||||
tier: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
function isWordBoundaryChar(ch: string | undefined): boolean {
|
||||
if (ch === undefined) return true;
|
||||
return !/[a-z0-9]/.test(ch);
|
||||
}
|
||||
|
||||
export function scoreMatch(query: string, text: string): MatchScore | null {
|
||||
if (!query) return { tier: 0, offset: 0 };
|
||||
const q = query.toLowerCase();
|
||||
const t = text.toLowerCase();
|
||||
if (t === q) return { tier: 0, offset: 0 };
|
||||
|
||||
let best: MatchScore | null = null;
|
||||
let pos = 0;
|
||||
while (pos <= t.length - q.length) {
|
||||
const found = t.indexOf(q, pos);
|
||||
if (found === -1) break;
|
||||
const before = found > 0 ? t[found - 1] : undefined;
|
||||
const after = t[found + q.length];
|
||||
const startsAtBoundary = found === 0 || isWordBoundaryChar(before);
|
||||
const endsAtBoundary = after === undefined || isWordBoundaryChar(after);
|
||||
let tier: number;
|
||||
if (startsAtBoundary && endsAtBoundary) {
|
||||
tier = 1;
|
||||
} else if (found === 0) {
|
||||
tier = 2;
|
||||
} else if (startsAtBoundary) {
|
||||
tier = 3;
|
||||
} else {
|
||||
tier = 4;
|
||||
}
|
||||
if (!best || tier < best.tier || (tier === best.tier && found < best.offset)) {
|
||||
best = { tier, offset: found };
|
||||
}
|
||||
pos = found + 1;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function compareMatchScores(a: MatchScore, b: MatchScore): number {
|
||||
if (a.tier !== b.tier) return a.tier - b.tier;
|
||||
return a.offset - b.offset;
|
||||
}
|
||||
Reference in New Issue
Block a user