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..4a15063cd0 100644 --- a/packages/app/e2e/browser/plugin-settings.spec.ts +++ b/packages/app/e2e/browser/plugin-settings.spec.ts @@ -16,6 +16,14 @@ import { returnAndReopenSettings, returnToPluginsPage, expectSettingsUnavailable, + openPluginsPage, + expectActionsMenuWithoutSettings, + expectNoSettingsCogWithoutScreens, + openTabbedSettings, + selectSettingsTab, + openTabbedSettingsLink, + expectSettingsTooltip, + expectNamedPluginRow, } from "../support/helpers/plugin-settings"; test("display preferences survive a reload", async ({ page }) => { @@ -49,6 +57,32 @@ 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("plugin rows show the display name", async () => { + await expectNamedPluginRow(page); + }); + await test.step("the cog explains itself", async () => { + await expectSettingsTooltip(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..53048359f1 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,54 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace"; type SettingsClient = Awaited>; +const TABBED_PLUGIN_ID = "settings-tabs"; +const TABBED_PLUGIN_NAME = "Tabbed settings"; +/** Two settings screens, so the settings page shows a tab per screen. */ +const TABBED_SOURCE = `import React from "react"; +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 General settings body; +} + +function Advanced({ theme }) { + return Advanced settings body; +} + +// 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 ; +} + +function BrokenIcon() { + throw new Error("Settings tab icon exploded"); +} + +export default function contribute(client) { + client.addSettingsScreen({ id: "general", title: "General", icon: BrokenIcon, Component: General }); + client.addSettingsScreen({ id: "advanced", title: "Advanced", icon: TranslatedIcon, 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, + name: TABBED_PLUGIN_NAME, + 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 +67,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 +99,88 @@ 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); +} +/** The cog on a plugin's row. Its label is "Settings"; the row tells plugins apart. */ +function settingsButton(page: Page, pluginId: string) { + return page + .getByTestId(`plugin-row-${pluginId}`) + .filter({ visible: true }) + .getByRole("button", { name: "Settings", exact: true }); +} +async function openSettingsFromCog(page: Page) { + await settingsButton(page, "settings-example").click(); await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible(); + // A plugin with one settings screen shows no tabs, and the header names only the plugin. + await expect(page.getByTestId("plugin-settings-tabs")).toHaveCount(0); + await expectSettingsHeader(page, "settings-example"); +} +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(settingsButton(page, "settings-example")).toHaveCount(0); + await page.getByRole("switch", { name: "settings-example: Enable" }).click(); + await expect(settingsButton(page, "settings-example")).toBeVisible(); +} +export async function expectSettingsTooltip(page: Page) { + await settingsButton(page, "settings-example").hover(); + await expect(page.getByRole("tooltip")).toHaveText("Settings"); + await page.mouse.move(0, 0); +} +/** A manifest `name` titles the row and its controls; the id stays visible beneath it. */ +export async function expectNamedPluginRow(page: Page) { + const row = page.getByTestId(`plugin-row-${TABBED_PLUGIN_ID}`).filter({ visible: true }); + await expect(row.getByText(TABBED_PLUGIN_NAME, { exact: true })).toBeVisible(); + await expect(row.getByText(TABBED_PLUGIN_ID, { exact: true })).toBeVisible(); + await expect( + page.getByRole("switch", { name: `${TABBED_PLUGIN_NAME}: Disable`, exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: `Actions for ${TABBED_PLUGIN_NAME}`, exact: true }), + ).toBeVisible(); +} +export async function openTabbedSettings(page: Page) { + await settingsButton(page, TABBED_PLUGIN_ID).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 }); +} +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); + // The header names the plugin by its manifest name; the tab names the screen. + await expectSettingsHeader(page, TABBED_PLUGIN_NAME); + 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 +235,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(); @@ -114,6 +244,10 @@ export async function returnToPluginsPage(page: Page) { page.getByRole("button", { name: "Actions for settings-example", exact: true }), ).toBeVisible(); } +async function expectSettingsHeader(page: Page, title: string) { + // Settings keeps the Plugins page mounted beneath, so read the visible title only. + await expect(page.getByText(title, { exact: true }).filter({ visible: true })).toHaveCount(1); +} export async function expectSettingsUnavailable(page: Page) { await expect( page.getByText("This plugin settings screen is unavailable.", { exact: true }), diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index ae9e9347e5..16ba66d960 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2384,7 +2384,7 @@ export const en = { installing: "Installing…", logs: { action: "Logs", - title: "Logs: {{id}}", + title: "Logs: {{name}}", loading: "Loading logs…", refresh: "Refresh", refreshing: "Refreshing…", @@ -2393,7 +2393,8 @@ export const en = { }, status: { running: "running", disabled: "disabled", failed: "failed" }, actions: { - menu: "Actions for {{id}}", + menu: "Actions for {{name}}", + settings: "Settings", reload: "Reload", reloading: "Reloading…", enable: "Enable", @@ -2404,16 +2405,16 @@ export const en = { removing: "Removing…", }, feedback: { - installed: "Installed {{id}}", - reload: "Reloaded {{id}}", - enable: "Enabled {{id}}", - disable: "Disabled {{id}}", - remove: "Removed {{id}}", - kept: "Kept {{id}}", + installed: "Installed {{name}}", + reload: "Reloaded {{name}}", + enable: "Enabled {{name}}", + disable: "Disabled {{name}}", + remove: "Removed {{name}}", + kept: "Kept {{name}}", globalEnabled: "Plugins enabled", globalDisabled: "Plugins disabled", }, - removeConfirmTitle: "Remove {{id}}?", + removeConfirmTitle: "Remove {{name}}?", removeConfirmMessage: "This removes the configuration. The source directory is not deleted.", states: { offlineTitle: "Plugin host is offline", diff --git a/packages/app/src/i18n/resources/plugin-settings.ts b/packages/app/src/i18n/resources/plugin-settings.ts index 8146ad9747..a5d829bff1 100644 --- a/packages/app/src/i18n/resources/plugin-settings.ts +++ b/packages/app/src/i18n/resources/plugin-settings.ts @@ -1,7 +1,7 @@ const shared = { logs: { action: "Logs", - title: "Logs: {{id}}", + title: "Logs: {{name}}", loading: "Loading logs…", refresh: "Refresh", refreshing: "Refreshing…", @@ -33,7 +33,8 @@ export const pluginSettings = { installing: "جارٍ التثبيت…", status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" }, actions: { - menu: "إجراءات {{id}}", + menu: "إجراءات {{name}}", + settings: "الإعدادات", reload: "إعادة تحميل", reloading: "جارٍ إعادة التحميل…", enable: "تفعيل", @@ -44,16 +45,16 @@ export const pluginSettings = { removing: "جارٍ الإزالة…", }, feedback: { - installed: "تم تثبيت {{id}}", - reload: "تمت إعادة تحميل {{id}}", - enable: "تم تفعيل {{id}}", - disable: "تم تعطيل {{id}}", - remove: "تمت إزالة {{id}}", - kept: "تم الإبقاء على {{id}}", + installed: "تم تثبيت {{name}}", + reload: "تمت إعادة تحميل {{name}}", + enable: "تم تفعيل {{name}}", + disable: "تم تعطيل {{name}}", + remove: "تمت إزالة {{name}}", + kept: "تم الإبقاء على {{name}}", globalEnabled: "تم تفعيل الإضافات", globalDisabled: "تم تعطيل الإضافات", }, - removeConfirmTitle: "إزالة {{id}}؟", + removeConfirmTitle: "إزالة {{name}}؟", removeConfirmMessage: "يؤدي هذا إلى إزالة الإعداد فقط. لن يُحذف مجلد المصدر.", states: { offlineTitle: "مضيف الإضافات غير متصل", @@ -88,7 +89,8 @@ export const pluginSettings = { installing: "Instalando…", status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" }, actions: { - menu: "Acciones para {{id}}", + menu: "Acciones para {{name}}", + settings: "Configuración", reload: "Recargar", reloading: "Recargando…", enable: "Activar", @@ -99,16 +101,16 @@ export const pluginSettings = { removing: "Eliminando…", }, feedback: { - installed: "{{id}} instalado", - reload: "{{id}} recargado", - enable: "{{id}} activado", - disable: "{{id}} desactivado", - remove: "{{id}} eliminado", - kept: "Se conservó {{id}}", + installed: "{{name}} instalado", + reload: "{{name}} recargado", + enable: "{{name}} activado", + disable: "{{name}} desactivado", + remove: "{{name}} eliminado", + kept: "Se conservó {{name}}", globalEnabled: "Plugins activados", globalDisabled: "Plugins desactivados", }, - removeConfirmTitle: "¿Eliminar {{id}}?", + removeConfirmTitle: "¿Eliminar {{name}}?", removeConfirmMessage: "Esto elimina la configuración. El directorio de origen no se borra.", states: { offlineTitle: "El host de plugins está sin conexión", @@ -143,7 +145,8 @@ export const pluginSettings = { installing: "Installation…", status: { running: "actif", disabled: "désactivé", failed: "échec" }, actions: { - menu: "Actions pour {{id}}", + menu: "Actions pour {{name}}", + settings: "Paramètres", reload: "Recharger", reloading: "Rechargement…", enable: "Activer", @@ -154,16 +157,16 @@ export const pluginSettings = { removing: "Suppression…", }, feedback: { - installed: "{{id}} installé", - reload: "{{id}} rechargé", - enable: "{{id}} activé", - disable: "{{id}} désactivé", - remove: "{{id}} supprimé", - kept: "{{id}} conservé", + installed: "{{name}} installé", + reload: "{{name}} rechargé", + enable: "{{name}} activé", + disable: "{{name}} désactivé", + remove: "{{name}} supprimé", + kept: "{{name}} conservé", globalEnabled: "Plugins activés", globalDisabled: "Plugins désactivés", }, - removeConfirmTitle: "Supprimer {{id}} ?", + removeConfirmTitle: "Supprimer {{name}} ?", removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.", states: { offlineTitle: "L’hôte des plugins est hors ligne", @@ -198,7 +201,8 @@ export const pluginSettings = { installing: "インストール中…", status: { running: "実行中", disabled: "無効", failed: "失敗" }, actions: { - menu: "{{id}} のアクション", + menu: "{{name}} のアクション", + settings: "設定", reload: "再読み込み", reloading: "再読み込み中…", enable: "有効化", @@ -209,16 +213,16 @@ export const pluginSettings = { removing: "削除中…", }, feedback: { - installed: "{{id}} をインストールしました", - reload: "{{id}} を再読み込みしました", - enable: "{{id}} を有効にしました", - disable: "{{id}} を無効にしました", - remove: "{{id}} を削除しました", - kept: "{{id}} を保持しました", + installed: "{{name}} をインストールしました", + reload: "{{name}} を再読み込みしました", + enable: "{{name}} を有効にしました", + disable: "{{name}} を無効にしました", + remove: "{{name}} を削除しました", + kept: "{{name}} を保持しました", globalEnabled: "プラグインを有効にしました", globalDisabled: "プラグインを無効にしました", }, - removeConfirmTitle: "{{id}} を削除しますか?", + removeConfirmTitle: "{{name}} を削除しますか?", removeConfirmMessage: "設定のみを削除します。ソースディレクトリは削除されません。", states: { offlineTitle: "プラグインホストはオフラインです", @@ -252,7 +256,8 @@ export const pluginSettings = { installing: "설치 중…", status: { running: "실행 중", disabled: "비활성화", failed: "실패" }, actions: { - menu: "{{id}} 작업", + menu: "{{name}} 작업", + settings: "설정", reload: "다시 로드", reloading: "다시 로드 중…", enable: "활성화", @@ -263,16 +268,16 @@ export const pluginSettings = { removing: "제거 중…", }, feedback: { - installed: "{{id}} 설치됨", - reload: "{{id}} 다시 로드됨", - enable: "{{id}} 활성화됨", - disable: "{{id}} 비활성화됨", - remove: "{{id}} 제거됨", - kept: "{{id}} 유지됨", + installed: "{{name}} 설치됨", + reload: "{{name}} 다시 로드됨", + enable: "{{name}} 활성화됨", + disable: "{{name}} 비활성화됨", + remove: "{{name}} 제거됨", + kept: "{{name}} 유지됨", globalEnabled: "플러그인 활성화됨", globalDisabled: "플러그인 비활성화됨", }, - removeConfirmTitle: "{{id}}을(를) 제거할까요?", + removeConfirmTitle: "{{name}}을(를) 제거할까요?", removeConfirmMessage: "설정만 제거합니다. 소스 디렉터리는 삭제되지 않습니다.", states: { offlineTitle: "플러그인 호스트가 오프라인입니다", @@ -307,7 +312,8 @@ export const pluginSettings = { installing: "Instalando…", status: { running: "em execução", disabled: "desativado", failed: "falhou" }, actions: { - menu: "Ações para {{id}}", + menu: "Ações para {{name}}", + settings: "Configurações", reload: "Recarregar", reloading: "Recarregando…", enable: "Ativar", @@ -318,16 +324,16 @@ export const pluginSettings = { removing: "Removendo…", }, feedback: { - installed: "{{id}} instalado", - reload: "{{id}} recarregado", - enable: "{{id}} ativado", - disable: "{{id}} desativado", - remove: "{{id}} removido", - kept: "{{id}} mantido", + installed: "{{name}} instalado", + reload: "{{name}} recarregado", + enable: "{{name}} ativado", + disable: "{{name}} desativado", + remove: "{{name}} removido", + kept: "{{name}} mantido", globalEnabled: "Plugins ativados", globalDisabled: "Plugins desativados", }, - removeConfirmTitle: "Remover {{id}}?", + removeConfirmTitle: "Remover {{name}}?", removeConfirmMessage: "Isso remove a configuração. O diretório de origem não será excluído.", states: { offlineTitle: "O host de plugins está offline", @@ -362,7 +368,8 @@ export const pluginSettings = { installing: "Установка…", status: { running: "работает", disabled: "отключён", failed: "ошибка" }, actions: { - menu: "Действия для {{id}}", + menu: "Действия для {{name}}", + settings: "Настройки", reload: "Перезагрузить", reloading: "Перезагрузка…", enable: "Включить", @@ -373,16 +380,16 @@ export const pluginSettings = { removing: "Удаление…", }, feedback: { - installed: "{{id}} установлен", - reload: "{{id}} перезагружен", - enable: "{{id}} включён", - disable: "{{id}} отключён", - remove: "{{id}} удалён", - kept: "{{id}} сохранён", + installed: "{{name}} установлен", + reload: "{{name}} перезагружен", + enable: "{{name}} включён", + disable: "{{name}} отключён", + remove: "{{name}} удалён", + kept: "{{name}} сохранён", globalEnabled: "Плагины включены", globalDisabled: "Плагины отключены", }, - removeConfirmTitle: "Удалить {{id}}?", + removeConfirmTitle: "Удалить {{name}}?", removeConfirmMessage: "Конфигурация будет удалена. Исходный каталог останется на месте.", states: { offlineTitle: "Хост плагинов не в сети", @@ -416,7 +423,8 @@ export const pluginSettings = { installing: "正在安装…", status: { running: "运行中", disabled: "已禁用", failed: "失败" }, actions: { - menu: "{{id}} 的操作", + menu: "{{name}} 的操作", + settings: "设置", reload: "重新加载", reloading: "正在重新加载…", enable: "启用", @@ -427,16 +435,16 @@ export const pluginSettings = { removing: "正在移除…", }, feedback: { - installed: "已安装 {{id}}", - reload: "已重新加载 {{id}}", - enable: "已启用 {{id}}", - disable: "已禁用 {{id}}", - remove: "已移除 {{id}}", - kept: "已保留 {{id}}", + installed: "已安装 {{name}}", + reload: "已重新加载 {{name}}", + enable: "已启用 {{name}}", + disable: "已禁用 {{name}}", + remove: "已移除 {{name}}", + kept: "已保留 {{name}}", globalEnabled: "插件已启用", globalDisabled: "插件已禁用", }, - removeConfirmTitle: "移除 {{id}}?", + removeConfirmTitle: "移除 {{name}}?", removeConfirmMessage: "这会移除配置,但不会删除源目录。", states: { offlineTitle: "插件主机离线", diff --git a/packages/app/src/plugins/plugin-list.ts b/packages/app/src/plugins/plugin-list.ts new file mode 100644 index 0000000000..387e0c6b56 --- /dev/null +++ b/packages/app/src/plugins/plugin-list.ts @@ -0,0 +1,36 @@ +import type { PluginListItem } from "@getpaseo/protocol/messages"; +import { useFetchQuery } from "@/data/query"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; + +/** A host's configured plugins, as listed on Settings → Plugins. One cache entry per host. */ +export function usePluginList(serverId: string, options: { enabled?: boolean } = {}) { + const client = useHostRuntimeClient(serverId); + const connected = useHostRuntimeIsConnected(serverId); + const supported = useHostFeature(serverId, "pluginManagement"); + return useFetchQuery({ + queryKey: ["plugins", serverId] as const, + queryFn: async () => { + if (!client) throw new Error("Plugin host is offline"); + return client.listPlugins(); + }, + enabled: (options.enabled ?? true) && Boolean(client && connected && supported), + dataShape: "list", + staleTimeMs: 1_000, + }); +} + +/** The manifest's display name, or the plugin id when it has none. */ +export function pluginDisplayName(plugin: Pick): string { + return plugin.name ?? plugin.id; +} + +/** The display name of one configured plugin; its id until the list loads. */ +export function usePluginDisplayName( + target: { serverId: string; pluginId: string } | null, +): string | undefined { + const plugins = usePluginList(target?.serverId ?? "", { enabled: target !== null }); + if (!target) return undefined; + const plugin = plugins.data?.find((candidate) => candidate.id === target.pluginId); + return plugin ? pluginDisplayName(plugin) : target.pluginId; +} diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx index b5b4f20909..f2e510872a 100644 --- a/packages/app/src/plugins/settings/index.tsx +++ b/packages/app/src/plugins/settings/index.tsx @@ -1,17 +1,20 @@ 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 { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; +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 { 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 +26,84 @@ 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 }); + +/** 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]); + const compact = useIsCompactFormFactor(); + if (!supported || !firstScreenId) return null; return ( - - {title} - + + + ); } -export function PluginSettingsMenuItems({ - serverId, - pluginId, - disabled, -}: Omit & { disabled?: boolean }) { - const plugin = useInstalledPlugin(serverId, pluginId); - const supported = useHostFeature(serverId, "pluginSettings"); - if (!supported || !plugin) return null; +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 ( - <> - {plugin.settingsScreens.map((screen) => ( - - ))} - + + + + ); +} + +/** + * 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[]>( + () => + plugin.settingsScreens.map((screen) => ({ + value: screen.id, + label: plugin.i18n.resolve(screen.title, plugin.locale), + icon: ({ color, size }) => ( + + ), + testID: `plugin-settings-tab-${screen.id}`, + })), + [plugin], + ); + // Tabs swap the screen in place, so Back still returns to the Plugins page. + const select = useCallback((id: string) => router.setParams({ screenId: id }), []); + if (options.length < 2) return null; + return ( + + + ); } @@ -112,18 +154,19 @@ function SettingsContent({ return {t("settings.plugins.screens.unavailable")}; const Component = screen.Component; return ( - - - + + + + - - - + + + ); } const ThemedSettingsContent = withUnistyles(SettingsContent); @@ -153,5 +196,6 @@ export function PluginSettingsContent({ } const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted }, + tabs: { flexGrow: 0, marginBottom: theme.spacing[4] }, backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] }, })); diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 7b7e486654..8bce0c4dd1 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -114,7 +114,7 @@ import { HostTerminalsPage, } from "@/screens/settings/host-page"; import { PluginSettingsContent } from "@/plugins/settings"; -import { useInstalledPlugins } from "@/plugins/registry"; +import { usePluginDisplayName } from "@/plugins/plugin-list"; import { HostPluginsPage } from "@/screens/settings/plugins-page"; import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; import { HostVoicePage } from "@/screens/settings/voice/voice-page"; @@ -1267,15 +1267,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { returnFromSettings({ kind: "root" }); }, []); - const installedPlugins = useInstalledPlugins(); + // The header names the plugin; its tabs name the open settings screen. + const pluginDisplayName = usePluginDisplayName(view.kind === "plugin" ? view : null); const detailTitle = ((): string | undefined => { - if (view.kind === "plugin") { - const plugin = installedPlugins.find( - (candidate) => candidate.serverId === view.serverId && candidate.id === view.pluginId, - ); - const screen = plugin?.settingsScreens.find((candidate) => candidate.id === view.screenId); - return `${view.pluginId} · ${plugin && screen ? plugin.i18n.resolve(screen.title, plugin.locale) : t("settings.title")}`; - } + if (view.kind === "plugin") return pluginDisplayName; if (view.kind === "host") { const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section); return item ? t(item.labelKey) : undefined; diff --git a/packages/app/src/screens/settings/plugins-page.tsx b/packages/app/src/screens/settings/plugins-page.tsx index ae7a285fac..fdaf260f8d 100644 --- a/packages/app/src/screens/settings/plugins-page.tsx +++ b/packages/app/src/screens/settings/plugins-page.tsx @@ -1,5 +1,5 @@ import { formatPluginInstallation } from "@getpaseo/protocol/plugin-source-reference"; -import { PluginSettingsMenuItems } from "@/plugins/settings"; +import { PluginSettingsButton } from "@/plugins/settings"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; @@ -29,10 +29,10 @@ import { SettingsSection } from "@/components/settings/headings/settings-section import { resolvePluginPageState } from "@/screens/settings/plugins-page-state"; import { openPluginInstallForm } from "@/screens/settings/plugin-install-form-model"; import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry"; +import { pluginDisplayName, usePluginList } from "@/plugins/plugin-list"; import { settingsStyles } from "@/styles/settings"; import { confirmDialog } from "@/utils/confirm-dialog"; -const pluginQueryKey = (serverId: string) => ["plugins", serverId] as const; const PLUGIN_SOURCE_DOCS_URL = "https://paseo.sh/docs/plugins/reference#plugin-sources"; type PluginRowAction = "reload" | "enable" | "disable" | "remove"; @@ -49,7 +49,6 @@ function pluginRowAction(action: string | undefined): PluginRowAction | undefine function PluginActionsMenu({ plugin, - serverId, pending, pendingAction, onAction, @@ -57,19 +56,18 @@ function PluginActionsMenu({ supportsLogs, }: { plugin: PluginListItem; - serverId: string; pending: boolean; pendingAction?: PluginRowAction; onAction(action: PluginRowAction, plugin: PluginListItem): void; - onOpenLogs(pluginId: string): void; + onOpenLogs(plugin: PluginListItem): void; supportsLogs: boolean; }) { const { t } = useTranslation(); const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]); const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]); - const openLogs = useCallback(() => onOpenLogs(plugin.id), [onOpenLogs, plugin.id]); + const openLogs = useCallback(() => onOpenLogs(plugin), [onOpenLogs, plugin]); const removeIcon = useMemo(() => , []); - const menuLabel = t("settings.plugins.actions.menu", { id: plugin.id }); + const menuLabel = t("settings.plugins.actions.menu", { name: pluginDisplayName(plugin) }); return ( @@ -83,7 +81,6 @@ function PluginActionsMenu({ - {supportsLogs ? ( {t("settings.plugins.logs.action")} @@ -128,7 +125,7 @@ function PluginRow({ pending: boolean; pendingAction?: PluginRowAction; onAction(action: PluginRowAction, plugin: PluginListItem): void; - onOpenLogs(pluginId: string): void; + onOpenLogs(plugin: PluginListItem): void; supportsLogs: boolean; }) { const { t } = useTranslation(); @@ -145,10 +142,14 @@ function PluginRow({ () => , [badgeVariant, statusLabel], ); + const name = pluginDisplayName(plugin); + // A display name hides the id that the CLI and plugin routes use, so show it beneath. + const shownId = name === plugin.id ? null : plugin.id; const hint = useMemo( () => - plugin.description || plugin.installation ? ( + shownId || plugin.description || plugin.installation ? ( + {shownId ? {shownId} : null} {plugin.description ? ( {plugin.description} ) : null} @@ -157,20 +158,21 @@ function PluginRow({ ) : null} ) : undefined, - [plugin.description, plugin.installation], + [shownId, plugin.description, plugin.installation], ); - const toggleLabel = `${plugin.id}: ${t( + const toggleLabel = `${name}: ${t( plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable", )}`; return ( - + + >; - pluginId: string; + plugin: PluginListItem; serverId: string; onClose(): void; }) { + const pluginId = plugin.id; const { t } = useTranslation(); const logs = useFetchQuery({ queryKey: ["plugin-logs", serverId, pluginId], @@ -214,7 +216,7 @@ function PluginLogsSheet({ }, [logs]); const header = useMemo( () => ({ - title: t("settings.plugins.logs.title", { id: pluginId }), + title: t("settings.plugins.logs.title", { name: pluginDisplayName(plugin) }), actions: ( ), }), - [logs.isFetching, pluginId, refresh, t], + [logs.isFetching, plugin, refresh, t], ); let content = {t("settings.plugins.logs.loading")}; @@ -276,7 +278,6 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { const { config, patchConfig } = useDaemonConfig(serverId); const refreshQueue = useRef(Promise.resolve()); useInstalledPlugins(); - const queryKey = useMemo(() => pluginQueryKey(serverId), [serverId]); const [installForm] = useState(openPluginInstallForm); const installState = useSyncExternalStore( installForm.subscribe, @@ -293,20 +294,11 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { ), [t], ); - const [logsPluginId, setLogsPluginId] = useState(null); + const [logsPlugin, setLogsPlugin] = useState(null); const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>( null, ); - const plugins = useFetchQuery({ - queryKey, - queryFn: async () => { - if (!client) throw new Error("Plugin host is offline"); - return client.listPlugins(); - }, - enabled: Boolean(client && connected && supported), - dataShape: "list", - staleTimeMs: 1_000, - }); + const plugins = usePluginList(serverId); const refetchPlugins = plugins.refetch; const refresh = useCallback(async () => { if (!client) return; @@ -356,27 +348,28 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { run: async () => { const installed = await client.installPluginSource(input); installForm.reset(); - return t("settings.plugins.feedback.installed", { id: installed.id }); + return t("settings.plugins.feedback.installed", { name: pluginDisplayName(installed) }); }, }); }, [client, installForm, installState.canSubmit, mutation, sourceInstallSupported, t]); const action = useCallback( (name: PluginRowAction, plugin: PluginListItem) => { if (!client) return; + const pluginName = pluginDisplayName(plugin); const run = async () => { if (name === "remove") { const confirmed = await confirmDialog({ - title: t("settings.plugins.removeConfirmTitle", { id: plugin.id }), + title: t("settings.plugins.removeConfirmTitle", { name: pluginName }), message: t("settings.plugins.removeConfirmMessage"), confirmLabel: t("settings.plugins.actions.remove"), destructive: true, }); - if (!confirmed) return t("settings.plugins.feedback.kept", { id: plugin.id }); + if (!confirmed) return t("settings.plugins.feedback.kept", { name: pluginName }); await client.removePlugin(plugin.id); } else { await client[`${name}Plugin`](plugin.id); } - return t(`settings.plugins.feedback.${name}`, { id: plugin.id }); + return t(`settings.plugins.feedback.${name}`, { name: pluginName }); }; setFeedback(null); mutation.mutate({ action: name, pluginId: plugin.id, run }); @@ -408,7 +401,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { const retry = useCallback(() => { void refetchPlugins(); }, [refetchPlugins]); - const closeLogs = useCallback(() => setLogsPluginId(null), []); + const closeLogs = useCallback(() => setLogsPlugin(null), []); if (pageState === "offline") { return ( @@ -454,7 +447,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { pending={mutation.isPending} pendingAction={pending ? pluginRowAction(mutation.variables?.action) : undefined} onAction={action} - onOpenLogs={setLogsPluginId} + onOpenLogs={setLogsPlugin} supportsLogs={logsSupported} /> ); @@ -528,10 +521,10 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { ) : null} {catalogContent} - {client && logsPluginId ? ( + {client && logsPlugin ? ( diff --git a/plugin-examples/settings/README.md b/plugin-examples/settings/README.md index 45a896d63e..130ee94571 100644 --- a/plugin-examples/settings/README.md +++ b/plugin-examples/settings/README.md @@ -1,7 +1,7 @@ # Settings example Install this directory with `paseo plugin install /absolute/path/to/plugin-examples/settings`. -Open Settings → Plugins → settings-example → Display, or run **Configure agent monitor** in Command Center. +Open Settings → Plugins and press the settings button (cog) on the settings-example row, or run **Configure agent monitor** in Command Center. The select and switch save immediately. The title editor keeps a draft and its original revision, so another client's save produces a conflict instead of overwriting its changes. Close and reopen diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index ef25d93c35..33d38f4fc9 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -73,16 +73,16 @@ The required root manifest is `paseo-plugin.json`: } ``` -| Field | Required | Behavior | -| -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | Yes | Default installation ID. | -| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. | -| `icon` | No | Relative path to a PNG inside the plugin package. | -| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. | -| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | -| `translations` | No | Listing text in other languages, read by the registry and ignored by Paseo. See [other languages](/docs/plugins/publishing#other-languages). | -| `requirements` | No | Supported Paseo versions, described below. | -| `build` | No | Preparation commands, described in the CLI reference. | +| Field | Required | Behavior | +| -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Yes | Default installation ID. | +| `name` | No | Non-empty display name for the registry, the website, your row in **Settings → Plugins**, and your settings header. Without it, Paseo shows the ID. | +| `icon` | No | Relative path to a PNG inside the plugin package. | +| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. | +| `description` | No | Non-empty summary shown below the plugin name in **Settings → Plugins**. | +| `translations` | No | Listing text in other languages, read by the registry and ignored by Paseo. See [other languages](/docs/plugins/publishing#other-languages). | +| `requirements` | No | Supported Paseo versions, described below. | +| `build` | No | Preparation commands, described in the CLI reference. | Paths are relative to `paseo-plugin.json`, use forward slashes, and cannot contain `..` segments. Registry listings for visual plugins require an interface image even though `media` is @@ -1302,8 +1302,11 @@ Themes need a host that supports them. A client released before `addTheme` canno ## Settings screens Register a component with `client.addSettingsScreen({ id, title, icon, Component })` in -`index.client.tsx`. It appears under **Settings → Plugins → your plugin** on that host. -`id` is unique within the installation; `icon` is any [icon](#icons) form. Registration returns an +`index.client.tsx`. Your plugin's row in **Settings → Plugins** on that host then shows a settings +button (cog) that opens your first screen. When you register several screens, the settings page shows +one tab per screen, labeled with its `title` and `icon`, in registration order. With one screen there +are no tabs. The page header shows your manifest `name`, or your plugin ID when you leave `name` +out; it never includes a screen title. `id` is unique within the installation; `icon` is any [icon](#icons) form. Registration returns an idempotent remover, and plugin teardown removes remaining screens. Call `client.openSettings(id)` or a Command Center callback's `openSettings(id)` to open one diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 7862ff781f..a273a70aa7 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -73,10 +73,10 @@ my-plugin/ | 字段 | 是否必填 | 行为 | | -------------- | -------- | ---------------------------------------------------------------------------------------------------------- | | `id` | 是 | 默认的安装 ID。 | -| `name` | 否 | 注册表和网站使用的非空显示名称;与安装 ID 无关。 | +| `name` | 否 | 注册表、网站、**设置 → 插件** 中的插件行以及插件设置页标题使用的非空显示名称。没有 `name` 时显示插件 ID。 | | `icon` | 否 | 插件包内 PNG 文件的相对路径。 | | `media` | 否 | 包内图片或视频路径的数组,或 HTTPS URL。空数组是有效的。 | -| `description` | 否 | 在 **设置 → 插件** 中显示在插件 ID 下方的非空摘要。 | +| `description` | 否 | 在 **设置 → 插件** 中显示在插件名称下方的非空摘要。 | | `translations` | 否 | 其他语言的展示文本,由注册表读取,Paseo 会忽略它。见[其他语言](/docs/plugins/publishing#other-languages)。 | | `requirements` | 否 | 支持的 Paseo 版本,见下文。 | | `build` | 否 | 准备命令,见 CLI 参考。 | @@ -1087,7 +1087,7 @@ export default function contribute(client: PluginClientContext) { ## 设置界面 {#settings-screens} -在 `index.client.tsx` 中用 `client.addSettingsScreen({ id, title, icon, Component })` 注册一个组件。它会出现在该主机的 **设置 → 插件 → 你的插件** 下。`id` 在同一次安装中唯一;`icon` 可以是任意一种[图标](#icons)形式。注册返回一个幂等的移除函数,插件销毁时会移除剩余的界面。 +在 `index.client.tsx` 中用 `client.addSettingsScreen({ id, title, icon, Component })` 注册一个组件。之后,该主机 **设置 → 插件** 中你的插件行会显示一个设置按钮(齿轮),点击后打开你的第一个界面。注册多个界面时,设置页面会按注册顺序为每个界面显示一个标签页,使用其 `title` 和 `icon`;只有一个界面时不显示标签页。页面标题显示 manifest 中的 `name`;没有 `name` 时显示插件 ID,且从不包含界面标题。`id` 在同一次安装中唯一;`icon` 可以是任意一种[图标](#icons)形式。注册返回一个幂等的移除函数,插件销毁时会移除剩余的界面。 调用 `client.openSettings(id)` 或 Command Center 回调中的 `openSettings(id)` 可以打开你自己的某个界面。即使多台主机安装了同一个插件,每次安装也有各自的值和路由。