diff --git a/src/tui/components/filterable-select-list.ts b/src/tui/components/filterable-select-list.ts index a6d507ed2..c611c6319 100644 --- a/src/tui/components/filterable-select-list.ts +++ b/src/tui/components/filterable-select-list.ts @@ -8,72 +8,7 @@ import { } from "@mariozechner/pi-tui"; import type { Component } from "@mariozechner/pi-tui"; import chalk from "chalk"; - -/** - * Fuzzy match with pre-lowercased inputs (avoids toLowerCase on every keystroke). - * Returns score (lower = better) or null if no match. - */ -function fuzzyMatchLower(queryLower: string, textLower: string): number | null { - if (queryLower.length === 0) return 0; - if (queryLower.length > textLower.length) return null; - - let queryIndex = 0; - let score = 0; - let lastMatchIndex = -1; - let consecutiveMatches = 0; - - for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { - if (textLower[i] === queryLower[queryIndex]) { - const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]); - if (lastMatchIndex === i - 1) { - consecutiveMatches++; - score -= consecutiveMatches * 5; - } else { - consecutiveMatches = 0; - if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; - } - if (isWordBoundary) score -= 10; - score += i * 0.1; - lastMatchIndex = i; - queryIndex++; - } - } - return queryIndex < queryLower.length ? null : score; -} - -/** - * Filter items using pre-lowercased searchTextLower field. - * Supports space-separated tokens (all must match). - */ -function fuzzyFilterLower( - items: T[], - queryLower: string, -): T[] { - const trimmed = queryLower.trim(); - if (!trimmed) return items; - - const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0); - if (tokens.length === 0) return items; - - const results: { item: T; score: number }[] = []; - for (const item of items) { - const text = item.searchTextLower ?? ""; - let totalScore = 0; - let allMatch = true; - for (const token of tokens) { - const score = fuzzyMatchLower(token, text); - if (score !== null) { - totalScore += score; - } else { - allMatch = false; - break; - } - } - if (allMatch) results.push({ item, score: totalScore }); - } - results.sort((a, b) => a.score - b.score); - return results.map((r) => r.item); -} +import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js"; export interface FilterableSelectItem extends SelectItem { /** Additional searchable fields beyond label */ @@ -102,17 +37,9 @@ export class FilterableSelectList implements Component { onCancel?: () => void; constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) { - // Pre-compute searchTextLower for each item once - this.allItems = items.map((item) => { - if (item.searchTextLower) return item; - const parts = [item.label]; - if (item.description) parts.push(item.description); - if (item.searchText) parts.push(item.searchText); - return { ...item, searchTextLower: parts.join(" ").toLowerCase() }; - }); + this.allItems = prepareSearchItems(items); this.maxVisible = maxVisible; this.theme = theme; - this.input = new Input(); this.selectList = new SelectList(this.allItems, maxVisible, theme); } diff --git a/src/tui/components/fuzzy-filter.ts b/src/tui/components/fuzzy-filter.ts new file mode 100644 index 000000000..38694fb91 --- /dev/null +++ b/src/tui/components/fuzzy-filter.ts @@ -0,0 +1,114 @@ +/** + * Shared fuzzy filtering utilities for select list components. + */ + +/** + * Word boundary characters for matching. + */ +const WORD_BOUNDARY_CHARS = /[\s\-_./:#@]/; + +/** + * Check if position is at a word boundary. + */ +export function isWordBoundary(text: string, index: number): boolean { + return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? ""); +} + +/** + * Find index where query matches at a word boundary in text. + * Returns null if no match. + */ +export function findWordBoundaryIndex(text: string, query: string): number | null { + if (!query) return null; + const textLower = text.toLowerCase(); + const queryLower = query.toLowerCase(); + const maxIndex = textLower.length - queryLower.length; + if (maxIndex < 0) return null; + for (let i = 0; i <= maxIndex; i++) { + if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) { + return i; + } + } + return null; +} + +/** + * Fuzzy match with pre-lowercased inputs (avoids toLowerCase on every keystroke). + * Returns score (lower = better) or null if no match. + */ +export function fuzzyMatchLower(queryLower: string, textLower: string): number | null { + if (queryLower.length === 0) return 0; + if (queryLower.length > textLower.length) return null; + + let queryIndex = 0; + let score = 0; + let lastMatchIndex = -1; + let consecutiveMatches = 0; + + for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { + if (textLower[i] === queryLower[queryIndex]) { + const isAtWordBoundary = isWordBoundary(textLower, i); + if (lastMatchIndex === i - 1) { + consecutiveMatches++; + score -= consecutiveMatches * 5; // Reward consecutive matches + } else { + consecutiveMatches = 0; + if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps + } + if (isAtWordBoundary) score -= 10; // Reward word boundary matches + score += i * 0.1; // Slight penalty for later matches + lastMatchIndex = i; + queryIndex++; + } + } + return queryIndex < queryLower.length ? null : score; +} + +/** + * Filter items using pre-lowercased searchTextLower field. + * Supports space-separated tokens (all must match). + */ +export function fuzzyFilterLower( + items: T[], + queryLower: string, +): T[] { + const trimmed = queryLower.trim(); + if (!trimmed) return items; + + const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0); + if (tokens.length === 0) return items; + + const results: { item: T; score: number }[] = []; + for (const item of items) { + const text = item.searchTextLower ?? ""; + let totalScore = 0; + let allMatch = true; + for (const token of tokens) { + const score = fuzzyMatchLower(token, text); + if (score !== null) { + totalScore += score; + } else { + allMatch = false; + break; + } + } + if (allMatch) results.push({ item, score: totalScore }); + } + results.sort((a, b) => a.score - b.score); + return results.map((r) => r.item); +} + +/** + * Prepare items for fuzzy filtering by pre-computing lowercase search text. + */ +export function prepareSearchItems< + T extends { label?: string; description?: string; searchText?: string }, +>(items: T[]): (T & { searchTextLower: string })[] { + return items.map((item) => { + const parts: string[] = []; + if (item.label) parts.push(item.label); + if (item.description) parts.push(item.description); + if (item.searchText) parts.push(item.searchText); + return { ...item, searchTextLower: parts.join(" ").toLowerCase() }; + }); +} diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index 6f1d15865..ec08282c1 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -10,6 +10,7 @@ import { truncateToWidth, } from "@mariozechner/pi-tui"; import { visibleWidth } from "../../terminal/ansi.js"; +import { findWordBoundaryIndex } from "./fuzzy-filter.js"; export interface SearchableSelectListTheme extends SelectListTheme { searchPrompt: (text: string) => string; @@ -81,7 +82,7 @@ export class SearchableSelectList implements Component { continue; } // Tier 2: Word-boundary prefix in label (score 100-199) - const wordBoundaryIndex = this.findWordBoundaryIndex(label, q); + const wordBoundaryIndex = findWordBoundaryIndex(label, q); if (wordBoundaryIndex !== null) { wordBoundary.push({ item, score: wordBoundaryIndex }); continue; @@ -112,20 +113,6 @@ export class SearchableSelectList implements Component { ]; } - private findWordBoundaryIndex(text: string, query: string): number | null { - if (!query) return null; - const maxIndex = text.length - query.length; - if (maxIndex < 0) return null; - for (let i = 0; i <= maxIndex; i++) { - if (text.startsWith(query, i)) { - if (i === 0 || /[\s\-_./:]/.test(text[i - 1] ?? "")) { - return i; - } - } - } - return null; - } - private escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }