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(
|
||||
|
||||
Reference in New Issue
Block a user