Files
paseo/packages/app/src/plugins/surface-screen.tsx
T
Mohamed Boudra b6dde61a7f feat(plugins): accept inline SVG and component icons (#7368)
Every contribution `icon` now takes a Lucide name, `{ svg }`, or a
component. Attachment sources take only the first two because composer
drafts persist the source icon.

The daemon's provider-icon check and the app's inline-SVG check are the
same function in the shared SDK root, so a plugin sees the same reasons
in both places. The app parses each icon once at registration and fails
the plugin with that reason. Rendering goes through one module that
turns any form into a component, so surfaces never branch on the form.
2026-10-19 15:36:04 +07:00

263 lines
8.9 KiB
TypeScript

import { router, useLocalSearchParams } from "expo-router";
import type { PluginIcon, PluginScreenParams, PluginScreenProps } from "@getpaseo/plugin/client";
import type { PluginTheme } from "@getpaseo/plugin";
import { X } from "lucide-react-native";
import { useCallback, useMemo, type ComponentType } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { CompactHeader } from "@/components/headers/compact-header";
import { ScreenHeader } from "@/components/headers/screen-header";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HostFilter } from "@/components/hosts/host-filter";
import type { HostPickerHost } from "@/components/hosts/host-picker";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { usePluginHostNavigation } from "./host-navigation";
import { PluginIconView } from "./icons";
import { toPluginTheme } from "./theme";
import { useInstalledPlugin, usePluginInstallations } from "./registry";
import { buildPluginSurfaceRoute, pluginScreenParamsFromRoute } from "./routes";
import {
legacySidebarItemHostKey,
pluginScreensHostKey,
rememberPluginContributionHost,
} from "./contribution-host";
import { SurfaceErrorBoundary } from "./surface-error-boundary";
import { PluginInstallationProvider } from "./installation-provider";
import {
getPluginSurfaceContributionServerIds,
resolvePluginScreenTitle,
resolvePluginSurfaceContribution,
type PluginSurfaceContributionIdentity,
} from "./surface-contribution";
import { resolvePluginPlatform } from "./platform";
const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = [];
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const pluginThemeMapping = (theme: Theme) => ({
theme: toPluginTheme(theme),
});
const ThemedX = withUnistyles(X);
function routeParam(value: string | string[] | undefined): string {
return typeof value === "string" ? value : "";
}
function PluginHeaderIcon({ icon, color = "" }: { icon: PluginIcon; color?: string }) {
return <PluginIconView icon={icon} size={16} color={color} />;
}
const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon);
function SurfaceRenderer({
Surface,
plugin,
layout,
host,
params,
theme,
}: {
Surface: ComponentType<PluginScreenProps>;
plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>;
layout: PluginScreenProps["layout"];
host: PluginScreenProps["host"];
params: PluginScreenParams;
theme: PluginTheme;
}) {
const navigation = usePluginHostNavigation(host.id);
return (
<PluginInstallationProvider plugin={plugin}>
<Surface theme={theme} host={host} layout={layout} navigation={navigation} params={params} />
</PluginInstallationProvider>
);
}
const ThemedSurfaceRenderer = withUnistyles(SurfaceRenderer);
function PluginHostFilter({
serverId,
pluginId,
identity,
params,
hosts,
}: {
serverId: string;
pluginId: string;
identity: PluginSurfaceContributionIdentity;
params: PluginScreenParams;
hosts: HostPickerHost[];
}) {
const selectHost = useCallback(
(nextServerId: string) => {
// A legacy row remembers its own host; a screen's host carries to all the plugin's items.
rememberPluginContributionHost(
identity.kind === "sidebar"
? legacySidebarItemHostKey(pluginId, identity.id)
: pluginScreensHostKey(pluginId),
nextServerId,
);
router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, identity, params));
},
[identity, params, pluginId],
);
return (
<HostFilter
hosts={hosts}
selectedHost={serverId}
onSelectHost={selectHost}
includeAllHost={false}
triggerTestID="plugin-host-filter-trigger"
/>
);
}
export function PluginSurfaceScreen() {
const routeParams = useLocalSearchParams<{
serverId?: string | string[];
pluginId?: string | string[];
contributionKind?: string | string[];
contributionId?: string | string[];
}>();
const serverId = routeParam(routeParams.serverId);
const pluginId = routeParam(routeParams.pluginId);
const contributionKind = routeParam(routeParams.contributionKind);
const contributionId = routeParam(routeParams.contributionId);
const paramsKey = JSON.stringify(pluginScreenParamsFromRoute(routeParams));
// Keyed by content: the route hands back a new object on every render.
const params = useMemo<PluginScreenParams>(() => JSON.parse(paramsKey), [paramsKey]);
const identity = useMemo<PluginSurfaceContributionIdentity | null>(() => {
if (contributionKind !== "sidebar" && contributionKind !== "surface") return null;
return { kind: contributionKind, id: contributionId };
}, [contributionId, contributionKind]);
const plugin = useInstalledPlugin(serverId, pluginId);
const installations = usePluginInstallations(pluginId);
const hosts = useHosts();
const client = useHostRuntimeClient(serverId);
const compact = useIsCompactFormFactor();
const { sidebarItem, surface } = useMemo(
() => resolvePluginSurfaceContribution(plugin, identity),
[identity, plugin],
);
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
// The hosts this contribution is installed on; the filter shows when there is a choice.
const filterHosts = useMemo(() => {
if (!identity) return [];
const serverIds = getPluginSurfaceContributionServerIds(installations, pluginId, identity);
return hosts.filter((candidate) => serverIds.includes(candidate.serverId));
}, [hosts, identity, installations, pluginId]);
const title = useMemo(
() =>
surface
? resolvePluginScreenTitle(surface, sidebarItem, params)
: (sidebarItem?.title ?? (pluginId || "Plugin")),
[params, pluginId, sidebarItem, surface],
);
const icon = sidebarItem?.icon ?? null;
const close = useCallback(() => {
if (router.canGoBack()) router.back();
else router.replace(`/h/${encodeURIComponent(serverId)}`);
}, [serverId]);
const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]);
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]);
const headerLeft = useMemo(
() => (
<>
{icon ? (
<HeaderIconBadge>
<ThemedPluginHeaderIcon icon={icon} uniProps={mutedColorMapping} />
</HeaderIconBadge>
) : null}
<ScreenTitle testID="plugin-surface-title">{title}</ScreenTitle>
</>
),
[icon, title],
);
const hostFilter = useMemo(
() =>
identity && filterHosts.length > 1 ? (
<PluginHostFilter
serverId={serverId}
pluginId={pluginId}
identity={identity}
params={params}
hosts={filterHosts}
/>
) : null,
[filterHosts, identity, params, pluginId, serverId],
);
const headerRight = useMemo(
() => (
<>
{hostFilter}
<HeaderToggleButton
accessibilityLabel="Close plugin"
onPress={close}
testID="plugin-surface-close"
tooltipKeys={EMPTY_SHORTCUT_KEYS}
tooltipLabel="Close"
tooltipSide="bottom"
>
<ThemedX size={18} uniProps={mutedColorMapping} />
</HeaderToggleButton>
</>
),
[close, hostFilter],
);
return (
<View style={styles.screen}>
{compact ? (
<CompactHeader
navigation="back"
onBack={close}
title={title}
titleTestID="plugin-surface-title"
actions={hostFilter}
/>
) : (
<ScreenHeader left={headerLeft} right={headerRight} />
)}
<View style={styles.body}>
{plugin && surface && client ? (
<SurfaceErrorBoundary
key={`${serverId}/${pluginId}/${identity?.kind}/${contributionId}`}
installation={plugin}
Surface={surface.Component}
>
<ThemedSurfaceRenderer
Surface={surface.Component}
plugin={plugin}
host={host}
layout={layout}
params={params}
uniProps={pluginThemeMapping}
/>
</SurfaceErrorBoundary>
) : (
<Text style={styles.errorText}>
{plugin && surface ? "Plugin host is offline." : "This plugin surface is unavailable."}
</Text>
)}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
screen: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
body: {
flex: 1,
},
errorText: {
color: theme.colors.statusDanger,
padding: theme.spacing[4],
},
}));