/** * Client side of the first-party UI extension points: how each validated * declaration is projected onto the workbench, and how its items appear on * the plugin details page. One `defineUiPoint` per contribution type. */ import { registerEndpointProvider, type EndpointEntry } from "@/workbench/contributions"; import type { PluginInfo, ThemeDef } from "@/bindings"; import { registerAction } from "@/platform/actions"; import { tx } from "@/platform/i18n"; import { addContributedTheme, pluginThemeRef } from "@/platform/themes"; import { currentThemeChoice, setTheme, useThemeOverride } from "@/workbench/theme"; import { useProject } from "@/platform/project"; import { Button, Kbd, T } from "@/ui"; import { addIconTheme, iconThemeKey, selectIconTheme, useSelectedIconTheme } from "./iconThemes"; import { defineUiPoint, type UiItem } from "./projection"; import { SecretsDetails, type SecretDecl } from "./secrets"; import { defineViewUiPoints, pluginSettingsPageId } from "./uiPoints"; import { pluginsBackend } from "./backend"; import { isLive, runPluginCommand } from "./store"; import styles from "./PluginsPage.module.css"; /** Unpacks an action argument: a bare path, or a menu's `{ path, paths }`. */ export function commandArgs(arg: unknown): [string | null, string[]] { if (typeof arg === "string") return [arg, []]; const { path, paths } = (arg ?? {}) as { path?: unknown; paths?: unknown }; if (typeof path !== "string" || !Array.isArray(paths)) return [null, []]; return [path, paths.filter((p): p is string => typeof p === "string")]; } export const pluginActionId = (pluginId: string, commandId: string) => `plugin.${pluginId}.${commandId}`; interface CommandDecl { id: string; title: string; category?: string | null; when?: string | null; } function CommandsDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem[] }) { const runnable = plugin.status === "active" || plugin.status === "inactive"; return ( ); } function ThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem[] }) { // The selection is the Appearance setting; these buttons write it as the theme pickers do. useProject((s) => s.settings?.appearance); useThemeOverride((s) => s.theme); const selected = currentThemeChoice(); const live = isLive(plugin.status); return ( ); } interface IconThemeDecl { id: string; label: string; icons: number; } function IconThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem[] }) { const selected = useSelectedIconTheme(); return (
    {items.map(({ decl: t }) => { const key = iconThemeKey(plugin.id, t.id); return (
  • {t.label} {tx`${t.icons} icons`} {selected === key ? ( ) : ( )}
  • ); })}
); } const endpointPlugins = new Map void }>(); /** One endpoint provider per plugin, however many `[[contributes.endpoints]]` it declares. */ function providePluginEndpoints(pluginId: string): () => void { let entry = endpointPlugins.get(pluginId); if (!entry) { const dispose = registerEndpointProvider({ id: `plugin:${pluginId}`, endpoints: async (root): Promise => (await pluginsBackend().endpoints(pluginId)).map((e) => ({ method: e.method ?? "*", url: e.path, framework: e.framework, handler: e.handler ?? undefined, controller: e.framework, methodName: e.handler ?? "", pluginId, path: e.location.path, rel: e.location.path.startsWith(`${root}/`) ? e.location.path.slice(root.length + 1) : e.location.path, line: e.location.range.start.line, col: e.location.range.start.col, })), }); entry = { declarations: 0, dispose }; endpointPlugins.set(pluginId, entry); } const held = entry; held.declarations++; return () => { if (--held.declarations > 0) return; held.dispose(); endpointPlugins.delete(pluginId); }; } /** Registers the client side of every first-party UI point. */ export function defineBuiltinUiPoints(): () => void { const disposers = [ defineUiPoint("endpoints", { project: ({ pluginId }) => providePluginEndpoints(pluginId) }), defineUiPoint("commands", { title: "Commands", project: ({ pluginId, decl }, { plugin }) => registerAction({ id: pluginActionId(pluginId, decl.id), title: decl.title, category: decl.category ?? plugin.name, icon: "package", when: decl.when ?? undefined, verbatim: true, keywords: ["plugin", plugin.name, pluginId], description: tx`From plugin ${plugin.name}`, run: async ({ arg }) => { const [path, paths] = commandArgs(arg); await runPluginCommand(pluginId, decl.id, path, paths); }, }), Details: CommandsDetails, }), // Offered by every theme picker while the plugin is live; `followThemeSetting` applies the selected one. defineUiPoint("themes", { title: "Themes", project: ({ pluginId, decl }, { plugin }) => addContributedTheme({ id: pluginThemeRef(pluginId, decl.id), label: decl.name, kind: decl.kind === "light" ? "light" : "dark", origin: plugin.name, }), Details: ThemesDetails, }), defineUiPoint("iconThemes", { title: "File Icon Themes", project: ({ pluginId, decl }, { plugin }) => addIconTheme({ pluginId, id: decl.id, label: decl.label, origin: plugin.name, settingsPage: `${pluginSettingsPageId(pluginId)}.${decl.id.replace(/\./g, "-")}`, }), Details: IconThemesDetails, }), // Stored by the backend; listed here with a field per secret. defineUiPoint("secrets", { title: "Secrets", Details: SecretsDetails }), defineViewUiPoints(), ]; return () => disposers.reverse().forEach((d) => d()); }