From 6740d41fab9e7a0343b018b70b3e3502a59675bf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Nov 2026 07:19:13 +0700 Subject: [PATCH] Open plugin settings from a cog, with a tab per settings screen (#8675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): open plugin settings from a cog with a tab per screen The plugin row shows a settings button next to its switch when the plugin registers settings screens. The actions menu keeps Logs, Reload and Remove. The settings page shows one tab per registered screen; a plugin with one screen shows no tabs. Tabs swap screenId in place, so existing plugins/[pluginId]/[screenId] links and openSettings keep working. * 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. * feat(app): name only the plugin in its settings page header The header shows the plugin's manifest name, or its id when it has none, for single- and multi-screen plugins alike. The selected tab names the screen. The Plugins page and the header share one plugin list query. * feat(app): show plugin display names and a Settings tooltip on the cog Plugin rows on Settings → Plugins show the manifest name, falling back to the id; when a name is set, the id stays visible beneath it. The row's controls, logs sheet, confirmations and feedback use the same name. The cog is a ToolbarButton labeled Settings, with a matching tooltip. --- docs/expo-router.md | 3 +- .../app/e2e/browser/plugin-settings.spec.ts | 34 ++++ .../e2e/support/helpers/plugin-settings.ts | 150 +++++++++++++++++- packages/app/src/i18n/resources/en.ts | 19 +-- .../app/src/i18n/resources/plugin-settings.ts | 138 ++++++++-------- packages/app/src/plugins/plugin-list.ts | 36 +++++ packages/app/src/plugins/settings/index.tsx | 140 ++++++++++------ packages/app/src/screens/settings-screen.tsx | 13 +- .../app/src/screens/settings/plugins-page.tsx | 71 ++++----- plugin-examples/settings/README.md | 2 +- public-docs/plugins/reference.md | 27 ++-- public-docs/plugins/reference.zh-CN.md | 6 +- 12 files changed, 444 insertions(+), 195 deletions(-) create mode 100644 packages/app/src/plugins/plugin-list.ts 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)` 可以打开你自己的某个界面。即使多台主机安装了同一个插件,每次安装也有各自的值和路由。