mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
feat(plugin): add host-rendered Icon component (#3903)
This commit is contained in:
+7
-5
@@ -121,14 +121,16 @@ code lives behind filename boundaries:
|
||||
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
|
||||
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
|
||||
|
||||
Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks from
|
||||
`@getpaseo/plugin`. Plugin UI runs on desktop and mobile across multiple themes: color every
|
||||
`Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from
|
||||
`layout.compact`. See `public-docs/plugins/reference.md`.
|
||||
Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks and the
|
||||
host-provided `Icon` component from `@getpaseo/plugin`. `Icon` resolves a Lucide name using the
|
||||
client's installed icon set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
|
||||
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
|
||||
See `public-docs/plugins/reference.md`.
|
||||
|
||||
| Module | Use it for |
|
||||
| ------------------------- | -------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | hooks and UI types |
|
||||
| `@getpaseo/plugin` | host UI components, hooks, and UI types |
|
||||
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
|
||||
|
||||
The compiler removes client registrations and imports from the server entry point, and server
|
||||
|
||||
@@ -333,6 +333,28 @@ describe("evaluatePluginClientBundle", () => {
|
||||
).toThrow("must return a cleanup function");
|
||||
});
|
||||
|
||||
it("provides the host Icon component through @getpaseo/plugin", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
`(function(require) {
|
||||
const { Icon } = require("@getpaseo/plugin");
|
||||
const module = { exports: {} };
|
||||
module.exports.default = function(plugin) {
|
||||
plugin.addSurface("main", function Surface() {
|
||||
return Icon({ name: "Settings", size: 18, color: "#123456" });
|
||||
});
|
||||
return function() {};
|
||||
};
|
||||
return module.exports;
|
||||
})`,
|
||||
);
|
||||
|
||||
const Component = plugin.surfaces[0]?.Component;
|
||||
expect(Component).toBeTypeOf("function");
|
||||
const element = (Component as (props: never) => { props: unknown })({} as never);
|
||||
expect(element).toMatchObject({ props: { size: 18, color: "#123456" } });
|
||||
});
|
||||
|
||||
it("resolves @getpaseo/plugin/server for shared RPC contracts", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { createPluginContext, type PluginRegistrationCollector } from "@getpaseo/plugin/host";
|
||||
import type { EvaluatedPlugin } from "./types";
|
||||
import type { ComponentType } from "react";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
import { Icon, resolvePluginIcon } from "./icons";
|
||||
import { parsePluginThemeContribution } from "./themes";
|
||||
|
||||
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
|
||||
@@ -247,6 +247,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
return {
|
||||
defineAttachmentSource,
|
||||
defineRpc,
|
||||
Icon,
|
||||
usePaseo,
|
||||
useAgent,
|
||||
useWorkspace,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Settings } from "lucide-react-native";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Icon } from "./icons";
|
||||
|
||||
describe("Icon", () => {
|
||||
it("renders a host Lucide icon with the requested presentation", () => {
|
||||
expect(Icon({ name: "Settings", size: 18, color: "#123456" })).toMatchObject({
|
||||
type: Settings,
|
||||
props: { size: 18, color: "#123456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing for an unknown icon name", () => {
|
||||
expect(Icon({ name: "NotALucideIcon" })).toBeNull();
|
||||
expect(Icon({ name: "Icon" })).toBeNull();
|
||||
expect(Icon({ name: "createLucideIcon" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,24 @@
|
||||
import { createElement, type ReactElement } from "react";
|
||||
import * as LucideIcons from "lucide-react-native";
|
||||
import type { PluginIconProps } from "@getpaseo/plugin";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
|
||||
export function resolvePluginIcon(name: string): LucideIcon {
|
||||
function findPluginIcon(name: string): LucideIcon | null {
|
||||
const candidate = Reflect.get(LucideIcons, name);
|
||||
if (candidate === LucideIcons.Icon || candidate === LucideIcons.createLucideIcon) return null;
|
||||
const isComponent =
|
||||
typeof candidate === "function" ||
|
||||
(typeof candidate === "object" && candidate !== null && "$$typeof" in candidate);
|
||||
if (!isComponent) throw new Error(`Unknown Lucide icon: ${name}`);
|
||||
return candidate as LucideIcon;
|
||||
return isComponent ? (candidate as LucideIcon) : null;
|
||||
}
|
||||
|
||||
export function resolvePluginIcon(name: string): LucideIcon {
|
||||
const icon = findPluginIcon(name);
|
||||
if (!icon) throw new Error(`Unknown Lucide icon: ${name}`);
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function Icon({ name, size, color }: PluginIconProps): ReactElement | null {
|
||||
const icon = findPluginIcon(name);
|
||||
return icon ? createElement(icon, { size, color }) : null;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ export async function inspectConfig(
|
||||
`import React from "react";
|
||||
import { Text } from "react-native";
|
||||
import {
|
||||
Icon,
|
||||
type PluginAgentPanelProps,
|
||||
useAgent,
|
||||
usePaseo,
|
||||
@@ -91,7 +92,7 @@ export function Surface() {
|
||||
source: { kind: "directory", path: "/repo" },
|
||||
});
|
||||
void createWorkspace;
|
||||
return <Text>Paseo API</Text>;
|
||||
return <><Icon name="Settings" size={18} color="#123456" /><Text>Paseo API</Text></>;
|
||||
}
|
||||
|
||||
export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
|
||||
|
||||
@@ -103,6 +103,12 @@ declare module "@getpaseo/plugin" {
|
||||
|
||||
export interface PluginSurfaceProps extends PluginHostProps {}
|
||||
|
||||
export interface PluginIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface PluginWorkspaceSnapshot {
|
||||
readonly id: string;
|
||||
readonly projectId: string;
|
||||
@@ -261,6 +267,8 @@ declare module "@getpaseo/plugin" {
|
||||
export type PluginCleanup = () => void | Promise<void>;
|
||||
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
|
||||
|
||||
export const Icon: ComponentType<PluginIconProps>;
|
||||
|
||||
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>>;
|
||||
|
||||
@@ -34,6 +34,12 @@ export interface PluginHostProps {
|
||||
|
||||
export interface PluginSurfaceProps extends PluginHostProps {}
|
||||
|
||||
export interface PluginIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface PluginWorkspaceSnapshot {
|
||||
readonly id: string;
|
||||
readonly projectId: string;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { PluginIconProps } from "./contracts.js";
|
||||
|
||||
export {
|
||||
PluginAttachmentItemSchema,
|
||||
PluginAttachmentSearchPayloadSchema,
|
||||
@@ -7,6 +10,8 @@ export {
|
||||
type PluginAttachmentSearchPayload,
|
||||
type PluginRpcContract,
|
||||
} from "./server.js";
|
||||
|
||||
export declare const Icon: ComponentType<PluginIconProps>;
|
||||
export type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginAgentCommandContext,
|
||||
@@ -21,6 +26,7 @@ export type {
|
||||
PluginHandlerContext,
|
||||
PluginHostProps,
|
||||
PluginOpenPanelOptions,
|
||||
PluginIconProps,
|
||||
PluginPanelLocation,
|
||||
PluginTheme,
|
||||
PluginSidebarContribution,
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("plugin author module externals", () => {
|
||||
const entryPath = path.join(directory, "index.ts");
|
||||
await writeFile(
|
||||
entryPath,
|
||||
`import type { PluginContext } from "${sdk}";
|
||||
`import { Icon, type PluginContext } from "${sdk}";
|
||||
import { defineRpc } from "${sdk}/server";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -93,8 +93,13 @@ const ping = defineRpc({
|
||||
output: z.object({ ok: z.boolean() }),
|
||||
});
|
||||
|
||||
function Surface() {
|
||||
return Icon({ name: "Settings", size: 18 });
|
||||
}
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(ping, async () => ({ ok: true }));
|
||||
plugin.addSurface("main", Surface);
|
||||
return () => undefined;
|
||||
}
|
||||
`,
|
||||
@@ -103,6 +108,8 @@ export default function contribute(plugin: PluginContext) {
|
||||
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
|
||||
expect(clientBundle).toContain(`${sdk}/server`);
|
||||
expect(serverBundle).toContain(`${sdk}/server`);
|
||||
expect(clientBundle).toContain("Settings");
|
||||
expect(serverBundle).not.toContain("Settings");
|
||||
expect(clientBundle).not.toContain("Invalid plugin RPC method");
|
||||
expect(serverBundle).not.toContain("Invalid plugin RPC method");
|
||||
},
|
||||
|
||||
@@ -62,7 +62,13 @@ function register(contract: PluginRpcContract, handler: RpcHandler): void {
|
||||
handlers.set(method, { contract: { ...contract, name: method }, handler });
|
||||
}
|
||||
|
||||
const pluginAuthorRuntime = { defineAttachmentSource, defineRpc };
|
||||
const pluginAuthorRuntime = {
|
||||
defineAttachmentSource,
|
||||
defineRpc,
|
||||
Icon() {
|
||||
throw new Error("Icon is available only in plugin client code");
|
||||
},
|
||||
};
|
||||
|
||||
function runtimeRequire(name: string): unknown {
|
||||
if (isPluginSdkSpecifier(name)) return pluginAuthorRuntime;
|
||||
|
||||
@@ -67,15 +67,15 @@ Paseo builds separate client and server bundles from `index.ts`. It rejects impo
|
||||
|
||||
Paseo provides these modules to client code:
|
||||
|
||||
| Module | Use it for |
|
||||
| ------------------------- | ----------------------------------- |
|
||||
| `@getpaseo/plugin` | Hooks and UI types |
|
||||
| `@getpaseo/plugin/server` | Shared RPC and attachment contracts |
|
||||
| `@tanstack/react-query` | Request state and caching |
|
||||
| `react` | Components and hooks |
|
||||
| `react/jsx-runtime` | Compiled JSX |
|
||||
| `react-native` | Cross-platform UI |
|
||||
| `zod` | Shared schemas |
|
||||
| Module | Use it for |
|
||||
| ------------------------- | --------------------------------------- |
|
||||
| `@getpaseo/plugin` | Host UI components, hooks, and UI types |
|
||||
| `@getpaseo/plugin/server` | Shared RPC and attachment contracts |
|
||||
| `@tanstack/react-query` | Request state and caching |
|
||||
| `react` | Components and hooks |
|
||||
| `react/jsx-runtime` | Compiled JSX |
|
||||
| `react-native` | Cross-platform UI |
|
||||
| `zod` | Shared schemas |
|
||||
|
||||
These exact module specifiers use the host's runtime instances. A client bundle that requests another host module fails with `Module "<name>" is not available in plugin client code`.
|
||||
|
||||
@@ -166,6 +166,16 @@ export default function contribute(plugin: PluginContext) {
|
||||
|
||||
Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the surface body.
|
||||
|
||||
Use the host-provided `Icon` component for Lucide icons inside client surfaces. It renders the icon from Paseo's installed Lucide version, so plugin bundles do not import `lucide-react-native` or `react-native-svg`. An unknown name renders nothing instead of failing the plugin surface.
|
||||
|
||||
```tsx
|
||||
import { Icon } from "@getpaseo/plugin";
|
||||
|
||||
<Icon name="Settings" size={18} color={theme.colors.foreground} />;
|
||||
```
|
||||
|
||||
Keep `Icon` in a `*.client.tsx` module.
|
||||
|
||||
## Timeline items
|
||||
|
||||
A plugin can replace a projected timeline entry with its own data and React Native renderer. Both
|
||||
|
||||
Reference in New Issue
Block a user