refactor(search): unify dropdown filtering on the shared ranked matcher

Branch, project, agent, model, provider, stash, SSH-host, skill-catalog
and archive filters each had their own toLowerCase().includes (or no
ordering at all); the git branch and gitmoji pickers also let cmdk
re-filter and reorder on top of the manual filter, silently dropping
rows. All of them now go through rankByQuery/matchesRankQuery: results
are relevance-ordered, multi-word queries match in any order, matching
ignores punctuation, and cmdk filtering is disabled where the ranked
list is already final. rankBranchesForQuery keeps relevance order
instead of re-sorting matches alphabetically; the model picker now also
matches model ids.
This commit is contained in:
Bohdan Triapitsyn
2026-08-24 15:00:52 +03:00
parent 081056e6e5
commit b8716fe808
19 changed files with 145 additions and 262 deletions
-68
View File
@@ -41,54 +41,6 @@ export function matchesFuzzyQuery(
return fuse.search(query).length > 0;
}
function getFuzzyMatchMask<T>(
items: T[],
query: string,
getText: (item: T) => string,
options?: FuzzySearchOptions
): boolean[] {
if (!query) {
return items.map(() => true);
}
const mergedOptions = { ...DEFAULT_FUZZY_OPTIONS, ...options };
const queryLower = query.toLowerCase();
const matches = new Array(items.length).fill(false);
const fuzzyCandidateTexts: string[] = [];
const fuzzyCandidateIndices: number[] = [];
for (let i = 0; i < items.length; i++) {
const target = getText(items[i]);
if (!target) {
continue;
}
if (mergedOptions.preferSubstring && target.toLowerCase().includes(queryLower)) {
matches[i] = true;
continue;
}
fuzzyCandidateTexts.push(target);
fuzzyCandidateIndices.push(i);
}
if (fuzzyCandidateTexts.length === 0) {
return matches;
}
const fuse = new Fuse(fuzzyCandidateTexts, {
threshold: mergedOptions.threshold,
distance: mergedOptions.distance,
ignoreLocation: mergedOptions.ignoreLocation,
});
for (const result of fuse.search(query)) {
matches[fuzzyCandidateIndices[result.refIndex]] = true;
}
return matches;
}
/**
* Score-sorted fuzzy ranking. Strict (low threshold), prioritizes substring
* matches (especially prefix matches), and returns the top N.
@@ -271,23 +223,3 @@ export function matchesRankQuery(
return tokens.every((token) => scoreRankToken(token, rankFields) !== RANK_TOKEN_MISS);
}
export function partitionByFuzzyQuery<T>(
items: T[],
query: string,
getText: (item: T) => string,
options?: FuzzySearchOptions
): { matching: T[]; other: T[] } {
const matches = getFuzzyMatchMask(items, query, getText, options);
const matching: T[] = [];
const other: T[] = [];
for (let i = 0; i < items.length; i++) {
if (matches[i]) {
matching.push(items[i]);
continue;
}
other.push(items[i]);
}
return { matching, other };
}
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'bun:test';
import { rankBranchesForQuery } from './branchSearch';
describe('rankBranchesForQuery', () => {
test('empty query keeps everything in the other groups', () => {
const result = rankBranchesForQuery({ localBranches: ['main'], remoteBranches: ['origin/dev'], query: ' ' });
expect(result.matching).toEqual([]);
expect(result.otherLocal).toEqual(['main']);
expect(result.otherRemote).toEqual(['origin/dev']);
});
test('orders matches by relevance, not alphabetically', () => {
const result = rankBranchesForQuery({
localBranches: ['aaa-fix-scroll', 'fix/scroll', 'main'],
remoteBranches: ['origin/fix/scroll-old'],
query: 'fix',
});
expect(result.matching[0]).toEqual({ label: 'fix/scroll', value: 'fix/scroll', source: 'local' });
expect(result.matching.map((entry) => entry.label)).toEqual([
'fix/scroll',
'aaa-fix-scroll',
'origin/fix/scroll-old',
]);
expect(result.otherLocal).toEqual(['main']);
expect(result.otherRemote).toEqual([]);
});
test('remote matches carry the remotes/ checkout value', () => {
const result = rankBranchesForQuery({ localBranches: [], remoteBranches: ['origin/feat/x'], query: 'feat' });
expect(result.matching[0].value).toBe('remotes/origin/feat/x');
});
});
+12 -35
View File
@@ -1,4 +1,4 @@
import { partitionByFuzzyQuery } from "@/lib/search/fuzzySearch";
import { rankByQuery } from "@/lib/search/fuzzySearch";
export interface RankedBranchGroups {
matching: Array<{
@@ -26,42 +26,19 @@ export function rankBranchesForQuery(args: {
};
}
const localPartition = partitionByFuzzyQuery(localBranches, normalizedQuery, (branch) => branch);
const remotePartition = partitionByFuzzyQuery(remoteBranches, normalizedQuery, (branch) => branch);
const matching: RankedBranchGroups['matching'] = [];
const otherLocal = localPartition.other;
const otherRemote = remotePartition.other;
for (const branch of localPartition.matching) {
matching.push({
label: branch,
value: branch,
source: 'local',
});
}
for (const branch of remotePartition.matching) {
matching.push({
label: branch,
value: `remotes/${branch}`,
source: 'remote',
});
}
matching.sort((a, b) => {
const byLabel = a.label.localeCompare(b.label, undefined, { sensitivity: 'accent' });
if (byLabel !== 0) {
return byLabel;
}
if (a.source !== b.source) {
return a.source.localeCompare(b.source);
}
return a.value.localeCompare(b.value);
});
// Rank local and remote branches together so the order reflects match
// quality (an exact or prefix match lands first), not the source group or
// the alphabet.
const candidates: RankedBranchGroups['matching'] = [
...localBranches.map((branch) => ({ label: branch, value: branch, source: 'local' as const })),
...remoteBranches.map((branch) => ({ label: branch, value: `remotes/${branch}`, source: 'remote' as const })),
];
const matching = rankByQuery(candidates, normalizedQuery, (branch) => [branch.label]);
const matched = new Set(matching);
return {
matching,
otherLocal,
otherRemote,
otherLocal: candidates.filter((entry) => entry.source === 'local' && !matched.has(entry)).map((entry) => entry.label),
otherRemote: candidates.filter((entry) => entry.source === 'remote' && !matched.has(entry)).map((entry) => entry.label),
};
}