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:
committed by
Peter Steinberger
parent
565944ec71
commit
81e78dced5
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user