mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 07:33:18 +00:00
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.
This commit is contained in:
@@ -13,6 +13,7 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
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";
|
||||
@@ -49,7 +50,11 @@ 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 }),
|
||||
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 }) };
|
||||
@@ -99,8 +104,9 @@ async function openPluginScreen(page: 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.
|
||||
// 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);
|
||||
@@ -142,6 +148,8 @@ export async function expectSettingsTab(page: Page, title: "General" | "Advanced
|
||||
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()}`,
|
||||
@@ -216,6 +224,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 }),
|
||||
|
||||
@@ -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<PluginListItem, "id" | "name">): 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;
|
||||
}
|
||||
@@ -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,17 +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,
|
||||
);
|
||||
// Several screens show tabs, which already name the open screen.
|
||||
if (plugin && plugin.settingsScreens.length > 1) return 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;
|
||||
|
||||
@@ -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 { 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";
|
||||
|
||||
@@ -273,7 +273,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,
|
||||
@@ -294,16 +293,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
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;
|
||||
|
||||
@@ -76,7 +76,7 @@ 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. |
|
||||
| `name` | No | Non-empty display name for the registry, the website, and your plugin settings header; 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**. |
|
||||
@@ -1305,7 +1305,8 @@ Register a component with `client.addSettingsScreen({ id, title, icon, Component
|
||||
`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. `id` is unique within the installation; `icon` is any [icon](#icons) form. Registration returns an
|
||||
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
|
||||
|
||||
@@ -73,7 +73,7 @@ my-plugin/
|
||||
| 字段 | 是否必填 | 行为 |
|
||||
| -------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | 是 | 默认的安装 ID。 |
|
||||
| `name` | 否 | 注册表和网站使用的非空显示名称;与安装 ID 无关。 |
|
||||
| `name` | 否 | 注册表、网站和插件设置页标题使用的非空显示名称;与安装 ID 无关。 |
|
||||
| `icon` | 否 | 插件包内 PNG 文件的相对路径。 |
|
||||
| `media` | 否 | 包内图片或视频路径的数组,或 HTTPS URL。空数组是有效的。 |
|
||||
| `description` | 否 | 在 **设置 → 插件** 中显示在插件 ID 下方的非空摘要。 |
|
||||
@@ -1087,7 +1087,7 @@ export default function contribute(client: PluginClientContext) {
|
||||
|
||||
## 设置界面 {#settings-screens}
|
||||
|
||||
在 `index.client.tsx` 中用 `client.addSettingsScreen({ id, title, icon, Component })` 注册一个组件。之后,该主机 **设置 → 插件** 中你的插件行会显示一个设置按钮(齿轮),点击后打开你的第一个界面。注册多个界面时,设置页面会按注册顺序为每个界面显示一个标签页,使用其 `title` 和 `icon`;只有一个界面时不显示标签页。`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)` 可以打开你自己的某个界面。即使多台主机安装了同一个插件,每次安装也有各自的值和路由。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user