394 lines
12 KiB
TypeScript
394 lines
12 KiB
TypeScript
import {
|
|
type Component,
|
|
getEditorKeybindings,
|
|
Input,
|
|
isKeyRelease,
|
|
matchesKey,
|
|
type SelectItem,
|
|
type SelectListTheme,
|
|
truncateToWidth,
|
|
} from "@mariozechner/pi-tui";
|
|
import { stripAnsi, visibleWidth } from "../../terminal/ansi.js";
|
|
import { findWordBoundaryIndex, fuzzyFilterLower } from "./fuzzy-filter.js";
|
|
|
|
const ANSI_ESCAPE = String.fromCharCode(27);
|
|
const ANSI_SGR_REGEX = new RegExp(`${ANSI_ESCAPE}\\[[0-9;]*m`, "g");
|
|
|
|
export interface SearchableSelectListTheme extends SelectListTheme {
|
|
searchPrompt: (text: string) => string;
|
|
searchInput: (text: string) => string;
|
|
matchHighlight: (text: string) => string;
|
|
}
|
|
|
|
/**
|
|
* A select list with a search input at the top for fuzzy filtering.
|
|
*/
|
|
export class SearchableSelectList implements Component {
|
|
private items: SelectItem[];
|
|
private filteredItems: SelectItem[];
|
|
private selectedIndex = 0;
|
|
private maxVisible: number;
|
|
private theme: SearchableSelectListTheme;
|
|
private searchInput: Input;
|
|
private regexCache = new Map<string, RegExp>();
|
|
|
|
onSelect?: (item: SelectItem) => void;
|
|
onCancel?: () => void;
|
|
onSelectionChange?: (item: SelectItem) => void;
|
|
|
|
private static readonly DESCRIPTION_LAYOUT_MIN_WIDTH = 40;
|
|
private static readonly DESCRIPTION_MIN_WIDTH = 12;
|
|
private static readonly DESCRIPTION_SPACING_WIDTH = 2;
|
|
// Keep a small right margin so we don't risk wrapping due to styling/terminal quirks.
|
|
private static readonly RIGHT_MARGIN_WIDTH = 2;
|
|
|
|
constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) {
|
|
this.items = items;
|
|
this.filteredItems = items;
|
|
this.maxVisible = maxVisible;
|
|
this.theme = theme;
|
|
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() {
|
|
const query = this.searchInput.getValue().trim();
|
|
|
|
if (!query) {
|
|
this.filteredItems = this.items;
|
|
} else {
|
|
this.filteredItems = this.smartFilter(query);
|
|
}
|
|
|
|
// Reset selection when filter changes
|
|
this.selectedIndex = 0;
|
|
this.notifySelectionChange();
|
|
}
|
|
|
|
/**
|
|
* Smart filtering that prioritizes:
|
|
* 1. Exact substring match in label (highest priority)
|
|
* 2. Word-boundary prefix match in label
|
|
* 3. Exact substring in description
|
|
* 4. Fuzzy match (lowest priority)
|
|
*/
|
|
private smartFilter(query: string): SelectItem[] {
|
|
const q = query.toLowerCase();
|
|
type ScoredItem = { item: SelectItem; tier: number; score: number };
|
|
type FuzzyCandidate = { item: SelectItem; searchTextLower: string };
|
|
const scoredItems: ScoredItem[] = [];
|
|
const fuzzyCandidates: FuzzyCandidate[] = [];
|
|
|
|
for (const item of this.items) {
|
|
const rawLabel = this.getItemLabel(item);
|
|
const rawDesc = item.description ?? "";
|
|
const label = stripAnsi(rawLabel).toLowerCase();
|
|
const desc = stripAnsi(rawDesc).toLowerCase();
|
|
|
|
// Tier 1: Exact substring in label
|
|
const labelIndex = label.indexOf(q);
|
|
if (labelIndex !== -1) {
|
|
scoredItems.push({ item, tier: 0, score: labelIndex });
|
|
continue;
|
|
}
|
|
// Tier 2: Word-boundary prefix in label
|
|
const wordBoundaryIndex = findWordBoundaryIndex(label, q);
|
|
if (wordBoundaryIndex !== null) {
|
|
scoredItems.push({ item, tier: 1, score: wordBoundaryIndex });
|
|
continue;
|
|
}
|
|
// Tier 3: Exact substring in description
|
|
const descIndex = desc.indexOf(q);
|
|
if (descIndex !== -1) {
|
|
scoredItems.push({ item, tier: 2, score: descIndex });
|
|
continue;
|
|
}
|
|
// Tier 4: Fuzzy match (score 300+)
|
|
const searchText = (item as { searchText?: string }).searchText ?? "";
|
|
fuzzyCandidates.push({
|
|
item,
|
|
searchTextLower: [rawLabel, rawDesc, searchText]
|
|
.map((value) => stripAnsi(value))
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase(),
|
|
});
|
|
}
|
|
|
|
scoredItems.sort(this.compareByScore);
|
|
const fuzzyMatches = fuzzyFilterLower(fuzzyCandidates, q);
|
|
return [...scoredItems.map((s) => s.item), ...fuzzyMatches.map((entry) => entry.item)];
|
|
}
|
|
|
|
private escapeRegex(str: string): string {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
private compareByScore = (
|
|
a: { item: SelectItem; tier: number; score: number },
|
|
b: { item: SelectItem; tier: number; score: number },
|
|
) => {
|
|
if (a.tier !== b.tier) {
|
|
return a.tier - b.tier;
|
|
}
|
|
if (a.score !== b.score) {
|
|
return a.score - b.score;
|
|
}
|
|
return this.getItemLabel(a.item).localeCompare(this.getItemLabel(b.item));
|
|
};
|
|
|
|
private getItemLabel(item: SelectItem): string {
|
|
return item.label || item.value;
|
|
}
|
|
|
|
private splitAnsiParts(text: string): Array<{ text: string; isAnsi: boolean }> {
|
|
const parts: Array<{ text: string; isAnsi: boolean }> = [];
|
|
ANSI_SGR_REGEX.lastIndex = 0;
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = ANSI_SGR_REGEX.exec(text)) !== null) {
|
|
if (match.index > lastIndex) {
|
|
parts.push({ text: text.slice(lastIndex, match.index), isAnsi: false });
|
|
}
|
|
parts.push({ text: match[0], isAnsi: true });
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
if (lastIndex < text.length) {
|
|
parts.push({ text: text.slice(lastIndex), isAnsi: false });
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
private highlightMatch(text: string, query: string): string {
|
|
const tokens = query
|
|
.trim()
|
|
.split(/\s+/)
|
|
.map((token) => token.toLowerCase())
|
|
.filter((token) => token.length > 0);
|
|
if (tokens.length === 0) {
|
|
return text;
|
|
}
|
|
|
|
const uniqueTokens = Array.from(new Set(tokens)).toSorted((a, b) => b.length - a.length);
|
|
let parts = this.splitAnsiParts(text);
|
|
for (const token of uniqueTokens) {
|
|
const regex = this.getCachedRegex(token);
|
|
const nextParts: Array<{ text: string; isAnsi: boolean }> = [];
|
|
for (const part of parts) {
|
|
if (part.isAnsi) {
|
|
nextParts.push(part);
|
|
continue;
|
|
}
|
|
regex.lastIndex = 0;
|
|
const replaced = part.text.replace(regex, (match) => this.theme.matchHighlight(match));
|
|
if (replaced === part.text) {
|
|
nextParts.push(part);
|
|
continue;
|
|
}
|
|
nextParts.push(...this.splitAnsiParts(replaced));
|
|
}
|
|
parts = nextParts;
|
|
}
|
|
return parts.map((part) => part.text).join("");
|
|
}
|
|
|
|
setSelectedIndex(index: number) {
|
|
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
|
|
}
|
|
|
|
invalidate() {
|
|
this.searchInput.invalidate();
|
|
}
|
|
|
|
render(width: number): string[] {
|
|
const lines: string[] = [];
|
|
|
|
// Search input line
|
|
const promptText = "search: ";
|
|
const prompt = this.theme.searchPrompt(promptText);
|
|
const inputWidth = Math.max(1, width - visibleWidth(prompt));
|
|
const inputLines = this.searchInput.render(inputWidth);
|
|
const inputText = inputLines[0] ?? "";
|
|
lines.push(`${prompt}${this.theme.searchInput(inputText)}`);
|
|
lines.push(""); // Spacer
|
|
|
|
const query = this.searchInput.getValue().trim();
|
|
|
|
// If no items match filter, show message
|
|
if (this.filteredItems.length === 0) {
|
|
lines.push(this.theme.noMatch(" No matches"));
|
|
return lines;
|
|
}
|
|
|
|
// Calculate visible range with scrolling
|
|
const startIndex = Math.max(
|
|
0,
|
|
Math.min(
|
|
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
|
this.filteredItems.length - this.maxVisible,
|
|
),
|
|
);
|
|
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
|
|
|
|
// Render visible items
|
|
for (let i = startIndex; i < endIndex; i++) {
|
|
const item = this.filteredItems[i];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
const isSelected = i === this.selectedIndex;
|
|
lines.push(this.renderItemLine(item, isSelected, width, query));
|
|
}
|
|
|
|
// Show scroll indicator if needed
|
|
if (this.filteredItems.length > this.maxVisible) {
|
|
const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`;
|
|
lines.push(this.theme.scrollInfo(` ${scrollInfo}`));
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
private renderItemLine(
|
|
item: SelectItem,
|
|
isSelected: boolean,
|
|
width: number,
|
|
query: string,
|
|
): string {
|
|
const prefix = isSelected ? "→ " : " ";
|
|
const prefixWidth = prefix.length;
|
|
const displayValue = this.getItemLabel(item);
|
|
|
|
const description = item.description;
|
|
if (description) {
|
|
const descriptionLayout = this.getDescriptionLayout(width, prefixWidth);
|
|
if (descriptionLayout) {
|
|
const truncatedValue = truncateToWidth(displayValue, descriptionLayout.maxValueWidth, "");
|
|
const valueText = this.highlightMatch(truncatedValue, query);
|
|
|
|
const usedByValue = visibleWidth(valueText);
|
|
const remainingWidth = descriptionLayout.availableWidth - usedByValue;
|
|
const descriptionWidth = remainingWidth - descriptionLayout.spacingWidth;
|
|
|
|
if (descriptionWidth >= SearchableSelectList.DESCRIPTION_MIN_WIDTH) {
|
|
const spacing = " ".repeat(descriptionLayout.spacingWidth);
|
|
const truncatedDesc = truncateToWidth(description, descriptionWidth, "");
|
|
// Highlight plain text first, then apply theme styling to avoid corrupting ANSI codes
|
|
const highlightedDesc = this.highlightMatch(truncatedDesc, query);
|
|
const descText = isSelected ? highlightedDesc : this.theme.description(highlightedDesc);
|
|
const line = `${prefix}${valueText}${spacing}${descText}`;
|
|
return isSelected ? this.theme.selectedText(line) : line;
|
|
}
|
|
}
|
|
}
|
|
|
|
const maxWidth = width - prefixWidth - 2;
|
|
const truncatedValue = truncateToWidth(displayValue, maxWidth, "");
|
|
const valueText = this.highlightMatch(truncatedValue, query);
|
|
const line = `${prefix}${valueText}`;
|
|
return isSelected ? this.theme.selectedText(line) : line;
|
|
}
|
|
|
|
private getDescriptionLayout(
|
|
width: number,
|
|
prefixWidth: number,
|
|
): { availableWidth: number; maxValueWidth: number; spacingWidth: number } | null {
|
|
if (width <= SearchableSelectList.DESCRIPTION_LAYOUT_MIN_WIDTH) {
|
|
return null;
|
|
}
|
|
|
|
const availableWidth = Math.max(
|
|
1,
|
|
width - prefixWidth - SearchableSelectList.RIGHT_MARGIN_WIDTH,
|
|
);
|
|
const maxValueWidth =
|
|
availableWidth -
|
|
SearchableSelectList.DESCRIPTION_MIN_WIDTH -
|
|
SearchableSelectList.DESCRIPTION_SPACING_WIDTH;
|
|
|
|
if (maxValueWidth < 1) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
availableWidth,
|
|
maxValueWidth,
|
|
spacingWidth: SearchableSelectList.DESCRIPTION_SPACING_WIDTH,
|
|
};
|
|
}
|
|
|
|
handleInput(keyData: string): void {
|
|
if (isKeyRelease(keyData)) {
|
|
return;
|
|
}
|
|
|
|
const allowVimNav = !this.searchInput.getValue().trim();
|
|
|
|
// Navigation keys
|
|
if (
|
|
matchesKey(keyData, "up") ||
|
|
matchesKey(keyData, "ctrl+p") ||
|
|
(allowVimNav && keyData === "k")
|
|
) {
|
|
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
this.notifySelectionChange();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
matchesKey(keyData, "down") ||
|
|
matchesKey(keyData, "ctrl+n") ||
|
|
(allowVimNav && keyData === "j")
|
|
) {
|
|
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
|
|
this.notifySelectionChange();
|
|
return;
|
|
}
|
|
|
|
if (matchesKey(keyData, "enter")) {
|
|
const item = this.filteredItems[this.selectedIndex];
|
|
if (item && this.onSelect) {
|
|
this.onSelect(item);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const kb = getEditorKeybindings();
|
|
if (kb.matches(keyData, "selectCancel")) {
|
|
if (this.onCancel) {
|
|
this.onCancel();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Pass other keys to search input
|
|
const prevValue = this.searchInput.getValue();
|
|
this.searchInput.handleInput(keyData);
|
|
const newValue = this.searchInput.getValue();
|
|
|
|
if (prevValue !== newValue) {
|
|
this.updateFilter();
|
|
}
|
|
}
|
|
|
|
private notifySelectionChange() {
|
|
const item = this.filteredItems[this.selectedIndex];
|
|
if (item && this.onSelectionChange) {
|
|
this.onSelectionChange(item);
|
|
}
|
|
}
|
|
|
|
getSelectedItem(): SelectItem | null {
|
|
return this.filteredItems[this.selectedIndex] ?? null;
|
|
}
|
|
}
|