import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, TextInput, View } from "react-native"; import { SvgXml } from "react-native-svg"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { ExternalLink, PackagePlus, Search } from "lucide-react-native"; import { Button } from "@/components/ui/button"; import { isWeb } from "@/constants/platform"; import { useAcpProviderCatalog, type AcpProviderCatalogItem, } from "@/hooks/use-acp-provider-catalog"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import type { Theme } from "@/styles/theme"; import { openExternalUrl } from "@/utils/open-external-url"; interface ProviderCatalogListProps { serverId: string; installingProviderId: string | null; onInstall: (entry: AcpProviderCatalogItem) => Promise | void; } const SEARCH_ICON_SIZE = 16; const PROVIDER_FALLBACK_ICON_SIZE = 20; const PROVIDER_REMOTE_ICON_SIZE = 24; const ThemedPackagePlus = withUnistyles(PackagePlus); const ThemedSvgXml = withUnistyles(SvgXml); const ThemedSearch = withUnistyles(Search); const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedTextInput = withUnistyles(TextInput, (theme) => ({ placeholderTextColor: theme.colors.foregroundMuted, })); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean { const normalized = query.trim().toLowerCase(); if (!normalized) return true; return [entry.title, entry.id, entry.description].some((value) => value.toLowerCase().includes(normalized), ); } interface CatalogRowProps { entry: AcpProviderCatalogItem; installing: boolean; onInstall: (entry: AcpProviderCatalogItem) => void; } function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) { const { t } = useTranslation(); const actionLabel = installing ? t("providerCatalog.actions.adding") : t("providerCatalog.actions.add"); const handleInstall = useCallback(() => { onInstall(entry); }, [entry, onInstall]); const handleOpenInstallLink = useCallback(() => { void openExternalUrl(entry.installLink); }, [entry.installLink]); return ( {entry.iconSvg ? ( ) : ( )} {entry.title} {entry.version} {entry.description || entry.id} {t("providerCatalog.actions.installInstructions")} ); } export function ProviderCatalogList({ serverId, installingProviderId, onInstall, }: ProviderCatalogListProps) { const { t } = useTranslation(); const { entries: catalogEntries } = useAcpProviderCatalog(); const { entries: providerEntries } = useProvidersSnapshot(serverId); const [search, setSearch] = useState(""); const installedIds = useMemo( () => new Set(providerEntries?.map((entry) => entry.provider) ?? []), [providerEntries], ); const availableEntries = useMemo( () => catalogEntries .filter((entry) => !installedIds.has(entry.id)) .filter((entry) => matchesSearch(entry, search)), [catalogEntries, installedIds, search], ); return ( {availableEntries.length === 0 ? ( {t("providerCatalog.noProviders")} ) : ( {availableEntries.map((entry) => ( ))} )} ); } const styles = StyleSheet.create((theme) => ({ searchField: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, paddingHorizontal: theme.spacing[3], marginBottom: theme.spacing[3], }, searchIcon: { width: 18, alignItems: "center", }, searchInput: { flex: 1, minWidth: 0, paddingVertical: theme.spacing[3], color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, list: { borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, overflow: "hidden", }, row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], padding: theme.spacing[3], borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, iconFrame: { width: 36, height: 36, borderRadius: theme.borderRadius.md, backgroundColor: theme.colors.surface2, alignItems: "center", justifyContent: "center", flexShrink: 0, }, textColumn: { flex: 1, minWidth: 0, gap: theme.spacing[1], }, titleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], minWidth: 0, }, name: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, flexShrink: 1, }, version: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, flexShrink: 0, }, description: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, installLink: { alignSelf: "flex-start", flexDirection: "row", alignItems: "center", gap: theme.spacing[1], maxWidth: "100%", }, installLinkText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, actionButton: { width: 92, flexShrink: 0, }, stateBox: { minHeight: 96, borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface1, alignItems: "center", justifyContent: "center", gap: theme.spacing[3], padding: theme.spacing[4], }, stateText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, })); const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];