perf(tui): optimize searchable select list filtering

- Add regex caching to avoid creating new RegExp objects on each render
- Optimize smartFilter to use single array with tier-based scoring
- Replace non-existent fuzzyFilter import with local fuzzyFilterLower
- Reduces from 4 array allocations and 4 sorts to 1 array and 1 sort

Fixes pre-existing bug where fuzzyFilter was imported from pi-tui but not exported.
This commit is contained in:
Tobias Bischoff
2026-01-22 10:29:37 +01:00
committed by Peter Steinberger
parent 565944ec71
commit 81e78dced5

View File

@@ -1,6 +1,5 @@
import { import {
type Component, type Component,
fuzzyFilter,
getEditorKeybindings, getEditorKeybindings,
Input, Input,
isKeyRelease, isKeyRelease,
@@ -10,7 +9,7 @@ import {
truncateToWidth, truncateToWidth,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import { visibleWidth } from "../../terminal/ansi.js"; import { visibleWidth } from "../../terminal/ansi.js";
import { findWordBoundaryIndex } from "./fuzzy-filter.js"; import { findWordBoundaryIndex, fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js";
export interface SearchableSelectListTheme extends SelectListTheme { export interface SearchableSelectListTheme extends SelectListTheme {
searchPrompt: (text: string) => string; searchPrompt: (text: string) => string;
@@ -28,6 +27,7 @@ export class SearchableSelectList implements Component {
private maxVisible: number; private maxVisible: number;
private theme: SearchableSelectListTheme; private theme: SearchableSelectListTheme;
private searchInput: Input; private searchInput: Input;
private regexCache = new Map<string, RegExp>();
onSelect?: (item: SelectItem) => void; onSelect?: (item: SelectItem) => void;
onCancel?: () => void; onCancel?: () => void;
@@ -41,6 +41,15 @@ export class SearchableSelectList implements Component {
this.searchInput = new Input(); this.searchInput = new Input();
} }
private getCachedRegex(pattern: string): RegExp {
let regex = this.regexCache.get(pattern);
if (!regex) {
regex = new RegExp(this.escapeRegex(pattern), "gi");
this.regexCache.set(pattern, regex);
}
return regex;
}
private updateFilter() { private updateFilter() {
const query = this.searchInput.getValue().trim(); const query = this.searchInput.getValue().trim();
@@ -59,15 +68,13 @@ export class SearchableSelectList implements Component {
* Smart filtering that prioritizes: * Smart filtering that prioritizes:
* 1. Exact substring match in label (highest priority) * 1. Exact substring match in label (highest priority)
* 2. Word-boundary prefix match in label * 2. Word-boundary prefix match in label
* 3. Exact substring match in description * 3. Exact substring in description
* 4. Fuzzy match (lowest priority) * 4. Fuzzy match (lowest priority)
*/ */
private smartFilter(query: string): SelectItem[] { private smartFilter(query: string): SelectItem[] {
const q = query.toLowerCase(); const q = query.toLowerCase();
type ScoredItem = { item: SelectItem; score: number }; type ScoredItem = { item: SelectItem; score: number };
const exactLabel: ScoredItem[] = []; const scoredItems: ScoredItem[] = [];
const wordBoundary: ScoredItem[] = [];
const descriptionMatches: ScoredItem[] = [];
const fuzzyCandidates: SelectItem[] = []; const fuzzyCandidates: SelectItem[] = [];
for (const item of this.items) { for (const item of this.items) {
@@ -77,38 +84,32 @@ export class SearchableSelectList implements Component {
// Tier 1: Exact substring in label (score 0-99) // Tier 1: Exact substring in label (score 0-99)
const labelIndex = label.indexOf(q); const labelIndex = label.indexOf(q);
if (labelIndex !== -1) { if (labelIndex !== -1) {
// Earlier match = better score scoredItems.push({ item, score: labelIndex });
exactLabel.push({ item, score: labelIndex });
continue; continue;
} }
// Tier 2: Word-boundary prefix in label (score 100-199) // Tier 2: Word-boundary prefix in label (score 100-199)
const wordBoundaryIndex = findWordBoundaryIndex(label, q); const wordBoundaryIndex = findWordBoundaryIndex(label, q);
if (wordBoundaryIndex !== null) { if (wordBoundaryIndex !== null) {
wordBoundary.push({ item, score: wordBoundaryIndex }); scoredItems.push({ item, score: 100 + wordBoundaryIndex });
continue; continue;
} }
// Tier 3: Exact substring in description (score 200-299) // Tier 3: Exact substring in description (score 200-299)
const descIndex = desc.indexOf(q); const descIndex = desc.indexOf(q);
if (descIndex !== -1) { if (descIndex !== -1) {
descriptionMatches.push({ item, score: descIndex }); scoredItems.push({ item, score: 200 + descIndex });
continue; continue;
} }
// Tier 4: Fuzzy match (score 300+) // Tier 4: Fuzzy match (score 300+)
fuzzyCandidates.push(item); fuzzyCandidates.push(item);
} }
exactLabel.sort(this.compareByScore); scoredItems.sort(this.compareByScore);
wordBoundary.sort(this.compareByScore);
descriptionMatches.sort(this.compareByScore); const preparedCandidates = prepareSearchItems(fuzzyCandidates);
const fuzzyMatches = fuzzyFilter( const fuzzyMatches = fuzzyFilterLower(preparedCandidates, q);
fuzzyCandidates,
query,
(i) => `${i.label} ${i.description ?? ""}`,
);
return [ return [
...exactLabel.map((s) => s.item), ...scoredItems.map((s) => s.item),
...wordBoundary.map((s) => s.item),
...descriptionMatches.map((s) => s.item),
...fuzzyMatches, ...fuzzyMatches,
]; ];
} }
@@ -140,7 +141,7 @@ export class SearchableSelectList implements Component {
const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length); const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length);
let result = text; let result = text;
for (const token of uniqueTokens) { for (const token of uniqueTokens) {
const regex = new RegExp(this.escapeRegex(token), "gi"); const regex = this.getCachedRegex(token);
result = result.replace(regex, (match) => this.theme.matchHighlight(match)); result = result.replace(regex, (match) => this.theme.matchHighlight(match));
} }
return result; return result;