Files
paseo/packages/app/src/plugins/surface-screen.tsx
T
Tahir Khan b807c261ca Show progress while plugin screens load (#8123)
Plugin screens can report unavailable while a successful catalog request
is still pending. Track the host catalog state so these screens show
progress until their contributions register.

Keep missing surfaces, failed requests, and offline hosts distinct from
loading. Retain existing installed surfaces during catalog refreshes.

Cover pending-to-ready rendering with a real browser regression and
catalog lifecycle tests.

Related to getpaseo/paseo#7088
2026-11-03 06:05:00 +00:00

299 lines
10 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 { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
useHostRuntimeClient,
useHostRuntimeConnectionStatus,
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, usePluginCatalogStatus, 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,
resolvePluginSurfacePlaceholder,
type PluginSurfaceContributionIdentity,
type PluginSurfacePlaceholderStatus,
} 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);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const SURFACE_ERRORS = {
offline: "Plugin host is offline.",
error: "Could not load plugins. Reconnect to try again.",
unavailable: "This plugin surface is unavailable.",
} as const;
function PluginSurfacePlaceholder({ status }: { status: PluginSurfacePlaceholderStatus }) {
if (status === "loading") {
return (
<View
testID="plugin-surface-loading"
accessibilityRole="progressbar"
accessibilityLabel="Loading plugin"
style={styles.loading}
>
<ThemedLoadingSpinner size="large" uniProps={mutedColorMapping} />
</View>
);
}
return <Text style={styles.errorText}>{SURFACE_ERRORS[status]}</Text>;
}
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 connectionStatus = useHostRuntimeConnectionStatus(serverId);
const catalogStatus = usePluginCatalogStatus(serverId);
const placeholderStatus = resolvePluginSurfacePlaceholder({ connectionStatus, catalogStatus });
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(() => {
if (surface && plugin)
return resolvePluginScreenTitle(surface, sidebarItem, params, (text) =>
plugin.i18n.resolve(text, plugin.locale),
);
if (sidebarItem && plugin) return plugin.i18n.resolve(sidebarItem.title, plugin.locale);
return pluginId || "Plugin";
}, [params, plugin, 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>
) : (
<PluginSurfacePlaceholder status={placeholderStatus} />
)}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
screen: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
body: {
flex: 1,
},
loading: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
errorText: {
color: theme.colors.statusDanger,
padding: theme.spacing[4],
},
}));