fix(app): render settings tab icons inside the plugin's runtime

Component icons now mount under the installation provider, so plugin SDK
hooks such as useTranslation work. Each icon has its own error boundary;
an icon that throws leaves its tab with the label alone.
This commit is contained in:
Mohamed Boudra
2026-11-03 13:23:13 +01:00
parent c8376d9c67
commit 51fd60d4c6
2 changed files with 60 additions and 17 deletions
@@ -15,7 +15,10 @@ type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>
const TABBED_PLUGIN_ID = "settings-tabs";
/** Two settings screens, so the settings page shows a tab per screen. */
const TABBED_SOURCE = `import React from "react";
import { Text } from "react-native";
import { Text, View } from "react-native";
import { defineTranslations, useTranslation } from "@getpaseo/plugin/client";
const messages = defineTranslations({ resources: { en: { icon: "advanced-tab-icon" } } });
function General({ theme }) {
return <Text style={{ color: theme.colors.foreground }}>General settings body</Text>;
@@ -25,9 +28,19 @@ function Advanced({ theme }) {
return <Text style={{ color: theme.colors.foreground }}>Advanced settings body</Text>;
}
// Component icons run with the plugin's runtime; one that throws costs its tab only the icon.
function TranslatedIcon({ color, size }) {
const { t } = useTranslation(messages);
return <View testID={t("icon")} aria-hidden style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
}
function BrokenIcon() {
throw new Error("Settings tab icon exploded");
}
export default function contribute(client) {
client.addSettingsScreen({ id: "general", title: "General", icon: "Settings", Component: General });
client.addSettingsScreen({ id: "advanced", title: "Advanced", icon: "Wrench", Component: Advanced });
client.addSettingsScreen({ id: "general", title: "General", icon: BrokenIcon, Component: General });
client.addSettingsScreen({ id: "advanced", title: "Advanced", icon: TranslatedIcon, Component: Advanced });
return () => {};
}
`;
@@ -114,6 +127,8 @@ export async function expectNoSettingsCogWithoutScreens(page: Page) {
export async function openTabbedSettings(page: Page) {
await page.getByRole("button", { name: `Settings for ${TABBED_PLUGIN_ID}`, exact: true }).click();
await expectSettingsTab(page, "General");
// The Advanced icon reads the plugin's translations; the General icon throws and is dropped.
await expect(page.getByTestId("advanced-tab-icon")).toBeVisible();
}
function settingsTab(page: Page, title: string) {
return page.getByTestId("plugin-settings-tabs").getByRole("button", { name: title, exact: true });
+42 -14
View File
@@ -50,14 +50,42 @@ export function PluginSettingsButton({ serverId, pluginId }: Omit<SettingsIdenti
);
}
/** One tab per registered settings screen. A plugin with a single screen shows no tabs. */
function renderNoIcon() {
return null;
}
/** A plugin-supplied icon component may throw; its tab then shows the label alone. */
function SettingsTabIcon({
plugin,
icon,
size,
color,
}: {
plugin: InstalledPlugin;
icon: InstalledPlugin["settingsScreens"][number]["icon"];
size: number;
color: string;
}) {
return (
<SurfaceErrorBoundary installation={plugin} Surface={icon} renderError={renderNoIcon}>
<PluginIconView icon={icon} size={size} color={color} />
</SurfaceErrorBoundary>
);
}
/**
* One tab per registered settings screen. A plugin with a single screen shows no tabs.
* Render inside the installation's provider: tab icons may be plugin components.
*/
function SettingsTabs({ plugin, screenId }: { plugin: InstalledPlugin; screenId: string }) {
const options = useMemo<SegmentedControlOption<string>[]>(
() =>
plugin.settingsScreens.map((screen) => ({
value: screen.id,
label: plugin.i18n.resolve(screen.title, plugin.locale),
icon: ({ color, size }) => <PluginIconView icon={screen.icon} size={size} color={color} />,
icon: ({ color, size }) => (
<SettingsTabIcon plugin={plugin} icon={screen.icon} size={size} color={color} />
),
testID: `plugin-settings-tab-${screen.id}`,
})),
[plugin],
@@ -124,19 +152,19 @@ function SettingsContent({
return <Text style={styles.message}>{t("settings.plugins.screens.unavailable")}</Text>;
const Component = screen.Component;
return (
<View>
<SettingsTabs plugin={plugin} screenId={screen.id} />
<SurfaceErrorBoundary
installation={plugin}
Surface={Component}
resetKey={attempt}
renderError={renderError}
>
<PluginInstallationProvider plugin={plugin}>
<PluginInstallationProvider plugin={plugin}>
<View>
<SettingsTabs plugin={plugin} screenId={screen.id} />
<SurfaceErrorBoundary
installation={plugin}
Surface={Component}
resetKey={attempt}
renderError={renderError}
>
<Component theme={theme} layout={layout} host={host} />
</PluginInstallationProvider>
</SurfaceErrorBoundary>
</View>
</SurfaceErrorBoundary>
</View>
</PluginInstallationProvider>
);
}
const ThemedSettingsContent = withUnistyles(SettingsContent);