diff --git a/docs/expo-router.md b/docs/expo-router.md index 7b328cc86e..9a9e5e3634 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -96,7 +96,8 @@ hidden workspace can overwrite the remembered workspace before Settings or History returns. Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf; -Back returns to that host's Plugins page. +Back returns to that host's Plugins page. Its screen tabs change `screenId` with `router.setParams`, +so switching tabs adds no stack entries. Settings detail routes are separate siblings on purpose. Keep `settings/[section]`, the host routes, the projects index, and project detail as diff --git a/packages/app/e2e/browser/plugin-settings.spec.ts b/packages/app/e2e/browser/plugin-settings.spec.ts index 6235cd647f..aa435dadee 100644 --- a/packages/app/e2e/browser/plugin-settings.spec.ts +++ b/packages/app/e2e/browser/plugin-settings.spec.ts @@ -16,6 +16,12 @@ import { returnAndReopenSettings, returnToPluginsPage, expectSettingsUnavailable, + openPluginsPage, + expectActionsMenuWithoutSettings, + expectNoSettingsCogWithoutScreens, + openTabbedSettings, + selectSettingsTab, + openTabbedSettingsLink, } from "../support/helpers/plugin-settings"; test("display preferences survive a reload", async ({ page }) => { @@ -49,6 +55,26 @@ test("Back to plugins returns from a plugin settings screen", async ({ page }) = await returnToPluginsPage(page); }); +test("plugin settings open from the cog and show a tab per screen", async ({ page }) => { + await openPluginsPage(page); + await test.step("the actions menu keeps only plugin actions", async () => { + await expectActionsMenuWithoutSettings(page); + }); + await test.step("tabs switch between the plugin's settings screens", async () => { + await openTabbedSettings(page); + await selectSettingsTab(page, "Advanced"); + await selectSettingsTab(page, "General"); + await returnToPluginsPage(page); + }); + await test.step("a plugin without settings screens shows no cog", async () => { + await expectNoSettingsCogWithoutScreens(page); + }); +}); + +test("a settings screen link opens its tab", async ({ page }) => { + await openTabbedSettingsLink(page, "Advanced"); +}); + test.describe("compact plugin settings", () => { test.use({ viewport: { width: 390, height: 844 } }); diff --git a/packages/app/e2e/support/helpers/plugin-settings.ts b/packages/app/e2e/support/helpers/plugin-settings.ts index 32f07fcdfd..942356bf3d 100644 --- a/packages/app/e2e/support/helpers/plugin-settings.ts +++ b/packages/app/e2e/support/helpers/plugin-settings.ts @@ -1,4 +1,7 @@ -import { copyPluginExample } from "./plugin-fixture"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { copyPluginExample, pluginRequirements } from "./plugin-fixture"; import { expect, test as base, type Page } from "../fixtures"; import { gotoAppShell, openSettings } from "./app"; import { goBackInSettings, openCompactSettings, openHostSection } from "./settings"; @@ -9,6 +12,36 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace"; type SettingsClient = Awaited>; +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"; + +function General({ theme }) { + return General settings body; +} + +function Advanced({ theme }) { + return Advanced settings body; +} + +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 }); + return () => {}; +} +`; + +async function writeTabbedPlugin() { + const directory = await mkdtemp(path.join(tmpdir(), `paseo-${TABBED_PLUGIN_ID}-`)); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: TABBED_PLUGIN_ID, requirements: pluginRequirements }), + ); + await writeFile(path.join(directory, "index.client.tsx"), TABBED_SOURCE); + return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) }; +} + export const test = base.extend<{ settingsPlugin: SettingsClient }>({ settingsPlugin: [ async ({ e2eWorker, page }, provide, testInfo) => { @@ -16,16 +49,20 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({ const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previous = await client.getDaemonConfig(); const example = await copyPluginExample("settings"); + const tabbed = await writeTabbedPlugin(); try { await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(example.directory); + await client.installDirectoryPlugin(tabbed.directory); await provide(client); await page.screenshot({ path: testInfo.outputPath("settings.png") }); } finally { await client.removePlugin("settings-example"); + await client.removePlugin(TABBED_PLUGIN_ID); await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); await client.close(); await example.cleanup(); + await tabbed.cleanup(); } }, { auto: true }, @@ -44,9 +81,63 @@ export async function openCompactDisplaySettings(page: Page) { } async function openPluginScreen(page: Page) { await openHostSection(page, getServerId(), "plugins"); - await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click(); - await page.getByRole("menuitem", { name: "Display", exact: true }).click(); + await openSettingsFromCog(page); +} +async function openSettingsFromCog(page: Page) { + await page.getByRole("button", { name: "Settings for settings-example", exact: true }).click(); await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible(); + // A plugin with one settings screen shows no tabs. + await expect(page.getByTestId("plugin-settings-tabs")).toHaveCount(0); +} +export async function openPluginsPage(page: Page) { + await gotoAppShell(page); + await openSettings(page); + await openHostSection(page, getServerId(), "plugins"); +} +export async function expectActionsMenuWithoutSettings(page: Page) { + await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click(); + await expect(page.getByRole("menuitem", { name: "Reload", exact: true })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Display", exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); +} +export async function expectNoSettingsCogWithoutScreens(page: Page) { + await page.getByRole("switch", { name: "settings-example: Disable" }).click(); + await expect(page.getByRole("switch", { name: "settings-example: Enable" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Settings for settings-example", exact: true }), + ).toHaveCount(0); + await page.getByRole("switch", { name: "settings-example: Enable" }).click(); + await expect( + page.getByRole("button", { name: "Settings for settings-example", exact: true }), + ).toBeVisible(); +} +export async function openTabbedSettings(page: Page) { + await page.getByRole("button", { name: `Settings for ${TABBED_PLUGIN_ID}`, exact: true }).click(); + await expectSettingsTab(page, "General"); +} +function settingsTab(page: Page, title: string) { + return page.getByTestId("plugin-settings-tabs").getByRole("button", { name: title, exact: true }); +} +export async function selectSettingsTab(page: Page, title: "General" | "Advanced") { + await settingsTab(page, title).click(); + await expectSettingsTab(page, title); +} +export async function expectSettingsTab(page: Page, title: "General" | "Advanced") { + const other = title === "General" ? "Advanced" : "General"; + await expect(settingsTab(page, title)).toHaveAttribute("aria-selected", "true"); + await expect(page.getByText(`${title} settings body`, { exact: true })).toBeVisible(); + await expect(page.getByText(`${other} settings body`, { exact: true })).toHaveCount(0); + await expectAppRoute( + page, + `${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`, + ); +} +export async function openTabbedSettingsLink(page: Page, title: "General" | "Advanced") { + await gotoAppShell(page); + await page.goto( + `${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`, + ); + await expectSettingsTab(page, title); } export async function groupByWorkspace(page: Page) { await page.getByRole("button", { name: /^Group agents by:/ }).click(); @@ -101,11 +192,7 @@ export async function discardTitle(page: Page) { } export async function returnAndReopenSettings(page: Page) { await goBackInSettings(page); - const actions = page.getByRole("button", { name: "Actions for settings-example", exact: true }); - await expect(actions).toBeVisible(); - await actions.click(); - await page.getByRole("menuitem", { name: "Display", exact: true }).click(); - await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible(); + await openSettingsFromCog(page); } export async function returnToPluginsPage(page: Page) { await page.getByRole("button", { name: "Back to plugins", exact: true }).click(); diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index ae9e9347e5..2bdc822788 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2394,6 +2394,7 @@ export const en = { status: { running: "running", disabled: "disabled", failed: "failed" }, actions: { menu: "Actions for {{id}}", + settings: "Settings for {{id}}", reload: "Reload", reloading: "Reloading…", enable: "Enable", diff --git a/packages/app/src/i18n/resources/plugin-settings.ts b/packages/app/src/i18n/resources/plugin-settings.ts index 8146ad9747..9ca2e796ca 100644 --- a/packages/app/src/i18n/resources/plugin-settings.ts +++ b/packages/app/src/i18n/resources/plugin-settings.ts @@ -34,6 +34,7 @@ export const pluginSettings = { status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" }, actions: { menu: "إجراءات {{id}}", + settings: "إعدادات {{id}}", reload: "إعادة تحميل", reloading: "جارٍ إعادة التحميل…", enable: "تفعيل", @@ -89,6 +90,7 @@ export const pluginSettings = { status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" }, actions: { menu: "Acciones para {{id}}", + settings: "Configuración de {{id}}", reload: "Recargar", reloading: "Recargando…", enable: "Activar", @@ -144,6 +146,7 @@ export const pluginSettings = { status: { running: "actif", disabled: "désactivé", failed: "échec" }, actions: { menu: "Actions pour {{id}}", + settings: "Paramètres de {{id}}", reload: "Recharger", reloading: "Rechargement…", enable: "Activer", @@ -199,6 +202,7 @@ export const pluginSettings = { status: { running: "実行中", disabled: "無効", failed: "失敗" }, actions: { menu: "{{id}} のアクション", + settings: "{{id}} の設定", reload: "再読み込み", reloading: "再読み込み中…", enable: "有効化", @@ -253,6 +257,7 @@ export const pluginSettings = { status: { running: "실행 중", disabled: "비활성화", failed: "실패" }, actions: { menu: "{{id}} 작업", + settings: "{{id}} 설정", reload: "다시 로드", reloading: "다시 로드 중…", enable: "활성화", @@ -308,6 +313,7 @@ export const pluginSettings = { status: { running: "em execução", disabled: "desativado", failed: "falhou" }, actions: { menu: "Ações para {{id}}", + settings: "Configurações de {{id}}", reload: "Recarregar", reloading: "Recarregando…", enable: "Ativar", @@ -363,6 +369,7 @@ export const pluginSettings = { status: { running: "работает", disabled: "отключён", failed: "ошибка" }, actions: { menu: "Действия для {{id}}", + settings: "Настройки {{id}}", reload: "Перезагрузить", reloading: "Перезагрузка…", enable: "Включить", @@ -417,6 +424,7 @@ export const pluginSettings = { status: { running: "运行中", disabled: "已禁用", failed: "失败" }, actions: { menu: "{{id}} 的操作", + settings: "{{id}} 的设置", reload: "重新加载", reloading: "正在重新加载…", enable: "启用", diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx index b5b4f20909..69ebfbf341 100644 --- a/packages/app/src/plugins/settings/index.tsx +++ b/packages/app/src/plugins/settings/index.tsx @@ -1,17 +1,19 @@ import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Platform, Text, View } from "react-native"; +import { Platform, ScrollView, Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { router } from "expo-router"; -import { ArrowLeft } from "lucide-react-native"; +import { ArrowLeft, Settings } from "lucide-react-native"; import type { PluginHostProps } from "@getpaseo/plugin/client"; import { Button } from "@/components/ui/button"; -import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useHostFeature } from "@/runtime/host-features"; import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; -import type { Theme } from "@/styles/theme"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { PluginIconView } from "../icons"; import { useInstalledPlugin } from "../registry"; +import type { InstalledPlugin } from "../types"; import { PluginInstallationProvider } from "../installation-provider"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { toPluginTheme } from "../theme"; @@ -23,45 +25,55 @@ interface SettingsIdentity { screenId: string; } -function PluginSettingsMenuItem({ - serverId, - pluginId, - screenId, - title, - disabled, -}: SettingsIdentity & { title: string; disabled?: boolean }) { - const open = useCallback( - () => router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)), - [serverId, pluginId, screenId], - ); +const ThemedSettingsIcon = withUnistyles(Settings); +const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const settingsIcon = ; + +/** Opens the plugin's settings at its first screen. Renders nothing when it has none. */ +export function PluginSettingsButton({ serverId, pluginId }: Omit) { + const { t } = useTranslation(); + const plugin = useInstalledPlugin(serverId, pluginId); + const supported = useHostFeature(serverId, "pluginSettings"); + const firstScreenId = plugin?.settingsScreens[0]?.id; + const open = useCallback(() => { + if (firstScreenId) router.push(buildPluginSettingsRoute(serverId, pluginId, firstScreenId)); + }, [serverId, pluginId, firstScreenId]); + if (!supported || !firstScreenId) return null; return ( - - {title} - +