Files
paseo/packages/app/src/components/provider-catalog-list.tsx
T

289 lines
8.2 KiB
TypeScript

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> | 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 (
<View style={styles.row}>
<View style={styles.iconFrame}>
{entry.iconSvg ? (
<ThemedSvgXml
xml={entry.iconSvg}
width={PROVIDER_REMOTE_ICON_SIZE}
height={PROVIDER_REMOTE_ICON_SIZE}
uniProps={foregroundColorMapping}
/>
) : (
<ThemedPackagePlus size={PROVIDER_FALLBACK_ICON_SIZE} uniProps={foregroundColorMapping} />
)}
</View>
<View style={styles.textColumn}>
<View style={styles.titleRow}>
<Text style={styles.name} numberOfLines={1}>
{entry.title}
</Text>
<Text style={styles.version} numberOfLines={1}>
{entry.version}
</Text>
</View>
<Text style={styles.description} numberOfLines={1}>
{entry.description || entry.id}
</Text>
<Pressable
accessibilityRole="link"
accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
provider: entry.title,
})}
onPress={handleOpenInstallLink}
style={styles.installLink}
>
<Text style={styles.installLinkText} numberOfLines={1}>
{t("providerCatalog.actions.installInstructions")}
</Text>
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
</Pressable>
</View>
<Button
size="sm"
variant="default"
disabled={installing}
loading={installing}
onPress={handleInstall}
style={styles.actionButton}
testID={`install-provider-${entry.id}`}
>
{actionLabel}
</Button>
</View>
);
}
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 (
<View>
<View style={styles.searchField}>
<View style={styles.searchIcon}>
<ThemedSearch size={SEARCH_ICON_SIZE} uniProps={foregroundMutedColorMapping} />
</View>
<ThemedTextInput
testID="provider-catalog-search"
value={search}
onChangeText={setSearch}
accessibilityLabel={t("providerCatalog.search")}
placeholder={t("providerCatalog.search")}
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{availableEntries.length === 0 ? (
<View style={styles.stateBox}>
<Text style={styles.stateText}>{t("providerCatalog.noProviders")}</Text>
</View>
) : (
<View style={styles.list}>
{availableEntries.map((entry) => (
<CatalogRow
key={entry.id}
entry={entry}
installing={installingProviderId === entry.id}
onInstall={onInstall}
/>
))}
</View>
)}
</View>
);
}
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" }];