Files
paseo/packages/app/src/plugins/surface-screen.tsx
T
Mohamed Boudra 01f9bc8291 Modernize compact headers and sidebar affordances (#6431)
* feat(app): modernize compact headers and sidebar affordances

Use a shared island header with quick tab creation and keep sheet footers inside their visible layout. Opt sidebar rows into touch sizing without changing global appearance settings.

* test(app): match darker light-theme workspace selection

* test(app): align plugin journeys with compact header
2026-10-10 20:53:09 +01:00

269 lines
8.9 KiB
TypeScript

import { router, useLocalSearchParams } from "expo-router";
import type { 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 { resolvePluginIcon } 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: ReturnType<typeof resolvePluginIcon>;
color?: string;
}) {
return <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 ? resolvePluginIcon(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],
},
}));