import { useCallback, useEffect, useRef } from "react"; import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { File, Folder } from "lucide-react-native"; import { Theme } from "@/styles/theme"; import { getAutocompleteScrollOffset } from "./autocomplete-utils"; export interface AutocompleteOption { id: string; label: string; detail?: string; description?: string; kind?: "command" | "file" | "directory"; } interface AutocompleteProps { options: readonly AutocompleteOption[]; selectedIndex: number; onSelect: (option: AutocompleteOption) => void; isLoading?: boolean; errorMessage?: string; loadingText?: string; emptyText?: string; maxHeight?: number; } const BOLT_GLYPH_PATTERN = /[\u26A1\uFE0F]/g; function removeBoltGlyphs(value?: string): string | undefined { if (!value) { return value; } const cleaned = value.replace(BOLT_GLYPH_PATTERN, "").trim(); return cleaned.length > 0 ? cleaned : undefined; } export function Autocomplete({ options, selectedIndex, onSelect, isLoading = false, errorMessage, loadingText = "Loading...", emptyText = "No results found", maxHeight = 220, }: AutocompleteProps) { const { theme } = useUnistyles(); const scrollRef = useRef(null); const rowLayoutsRef = useRef>(new Map()); const viewportHeightRef = useRef(0); const scrollOffsetRef = useRef(0); const ensureActiveItemVisible = useCallback(() => { if (selectedIndex < 0) { return; } const layout = rowLayoutsRef.current.get(selectedIndex); if (!layout) { return; } const nextOffset = getAutocompleteScrollOffset({ currentOffset: scrollOffsetRef.current, viewportHeight: viewportHeightRef.current, itemTop: layout.top, itemHeight: layout.height, }); if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) { return; } scrollOffsetRef.current = nextOffset; scrollRef.current?.scrollTo({ y: nextOffset, animated: false }); }, [selectedIndex]); const pinToBottom = useCallback(() => { scrollRef.current?.scrollToEnd({ animated: false }); requestAnimationFrame(() => { scrollRef.current?.scrollToEnd({ animated: false }); }); }, []); useEffect(() => { rowLayoutsRef.current.clear(); scrollOffsetRef.current = 0; }, [options]); useEffect(() => { if (options.length === 0) { return; } pinToBottom(); }, [options, pinToBottom]); useEffect(() => { const raf = requestAnimationFrame(ensureActiveItemVisible); return () => { cancelAnimationFrame(raf); }; }, [ensureActiveItemVisible, options.length]); const handleScrollViewLayout = useCallback( (event: LayoutChangeEvent) => { viewportHeightRef.current = event.nativeEvent.layout.height; ensureActiveItemVisible(); }, [ensureActiveItemVisible], ); const handleRowLayout = useCallback( (index: number, event: LayoutChangeEvent) => { rowLayoutsRef.current.set(index, { top: event.nativeEvent.layout.y, height: event.nativeEvent.layout.height, }); ensureActiveItemVisible(); }, [ensureActiveItemVisible], ); if (isLoading) { return ( {loadingText} ); } if (errorMessage) { return ( Error: {errorMessage} ); } if (options.length === 0) { return ( {emptyText} ); } const selectedOption = options[selectedIndex]; return ( {selectedOption?.kind === "command" && selectedOption.description ? ( {removeBoltGlyphs(selectedOption.label) ?? selectedOption.label} {removeBoltGlyphs(selectedOption.description)} {selectedOption.detail ? ( {removeBoltGlyphs(selectedOption.detail)} ) : null} ) : null} { scrollOffsetRef.current = event.nativeEvent.contentOffset.y; }} scrollEventThrottle={16} style={styles.scrollView} contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="always" > {options.map((option, index) => { const isSelected = index === selectedIndex; const optionLabel = removeBoltGlyphs(option.label) ?? option.label; const optionDescription = removeBoltGlyphs(option.description); const isFileOrDir = option.kind === "directory" || option.kind === "file"; return ( handleRowLayout(index, event)} onPress={() => onSelect(option)} style={({ hovered = false, pressed }) => [ styles.item, (hovered || pressed || isSelected) && styles.itemActive, ]} > {isFileOrDir ? ( <> {option.kind === "directory" ? ( ) : ( )} {optionLabel} {removeBoltGlyphs(option.detail) ? ( {removeBoltGlyphs(option.detail)} ) : null} {optionDescription ? ( {optionDescription} ) : null} ) : ( {optionLabel} {optionDescription ? ( {optionDescription} ) : null} )} ); })} ); } const styles = StyleSheet.create(((theme: Theme) => ({ outerWrapper: { gap: theme.spacing[1], }, detailCard: { backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, borderRadius: theme.borderRadius.lg, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[3], ...theme.shadow.md, }, detailLabel: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, detailDescription: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, marginTop: theme.spacing[1], }, detailHint: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, marginTop: theme.spacing[1], }, container: { backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, borderRadius: theme.borderRadius.lg, overflow: "hidden", ...theme.shadow.md, }, scrollView: { flexGrow: 0, flexShrink: 1, }, scrollContent: { paddingVertical: theme.spacing[1], }, item: { flexDirection: "row", alignItems: "center", minHeight: 36, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], }, itemLeading: { width: 18, alignItems: "center", justifyContent: "center", marginRight: theme.spacing[1], }, itemActive: { backgroundColor: theme.colors.surface2, }, itemMain: { flex: 1, minWidth: 0, }, itemMainRow: { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, itemHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, itemLabel: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, itemDetail: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, itemDescription: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, marginTop: 2, }, itemDescriptionInline: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, emptyItem: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[3], }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, })) as any) as Record;