feat(plugins): add host-rendered panel chrome

Let plugins describe header actions, search, tabs, selects, and empty states through mount context shell.set(), and render them with the host UI.
This commit is contained in:
tianma-if committed 2026-09-09 23:51:00 +08:00
1 parent cb58b3c568
commit e953faad87
12 files changed
+564 -9

No files matched your search

@@ -0,0 +1,109 @@
import type { PluginPanelAction, PluginPanelActionVariant, PluginPanelChrome, PluginPanelToolbarItem } from "@edgeever/plugin-api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const actionVariant = (variant?: PluginPanelActionVariant): "outline" | "solid" | "ghost" => {
if (variant === "primary") return "solid";
if (variant === "ghost") return "ghost";
return "outline";
};
const ActionButton = ({ action, onAction }: { action: PluginPanelAction; onAction?: (id: string) => void }) => (
<Button
type="button"
size="sm"
variant={actionVariant(action.variant)}
disabled={action.disabled}
onClick={() => onAction?.(action.id)}
>
{action.label}
</Button>
);
const ToolbarItem = ({ item, onAction, onChange }: {
item: PluginPanelToolbarItem;
onAction?: (id: string) => void;
onChange?: (key: string, value: string) => void;
}) => {
if (item.type === "search") {
return (
<Input
type="search"
value={item.value ?? ""}
placeholder={item.placeholder}
aria-label={item.placeholder ?? item.key}
className="h-9 min-w-[12rem] flex-1"
onChange={(event) => onChange?.(item.key, event.target.value)}
/>
);
}
if (item.type === "tabs") {
return (
<ToggleGroup
type="single"
value={item.value}
className="flex-wrap gap-1"
onValueChange={(value) => { if (value) onChange?.(item.key, value); }}
>
{item.options.map((option) => (
<ToggleGroupItem key={option.value} value={option.value} variant="outline" size="sm">
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
);
}
if (item.type === "select") {
return (
<label className="flex min-w-[9rem] items-center gap-2 text-xs text-slate-500">
{item.label ? <span className="shrink-0">{item.label}</span> : null}
<Select value={item.value} onValueChange={(value) => onChange?.(item.key, value)}>
<SelectTrigger className="h-9 w-[9.5rem]" aria-label={item.label ?? item.key}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{item.options.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</label>
);
}
return <ActionButton action={{ id: item.key, label: item.label, variant: item.variant, disabled: item.disabled }} onAction={onAction} />;
};
export const PluginPanelHeaderActions = ({ chrome }: { chrome: PluginPanelChrome }) => {
const actions = chrome.header?.actions ?? [];
if (!actions.length) return null;
return (
<div className="flex flex-wrap justify-end gap-2">
{actions.map((action) => <ActionButton key={action.id} action={action} onAction={chrome.onAction} />)}
</div>
);
};
export const PluginPanelToolbar = ({ chrome }: { chrome: PluginPanelChrome }) => {
if (!chrome.toolbar?.length) return null;
return (
<div className="flex flex-wrap items-center gap-2">
{chrome.toolbar.map((item) => (
<ToolbarItem key={`${item.type}:${item.key}`} item={item} onAction={chrome.onAction} onChange={chrome.onChange} />
))}
</div>
);
};
export const PluginPanelEmpty = ({ chrome }: { chrome: PluginPanelChrome }) => {
if (!chrome.empty) return null;
return (
<div className="flex min-h-40 flex-1 flex-col items-center justify-center gap-2 px-6 py-16 text-center">
<p className="text-sm font-medium text-slate-800">{chrome.empty.title}</p>
{chrome.empty.description ? <p className="max-w-md text-sm text-slate-500">{chrome.empty.description}</p> : null}
{chrome.empty.action ? <ActionButton action={chrome.empty.action} onAction={chrome.onAction} /> : null}
</div>
);
};
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import type { PluginPanelCloseDecision, PluginPanelOpenOptions } from "@edgeever/plugin-api";
import type { PluginPanelChrome, PluginPanelCloseDecision, PluginPanelOpenOptions } from "@edgeever/plugin-api";
import { useTranslation } from "react-i18next";
import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs";
import { PluginPanelEmpty, PluginPanelHeaderActions, PluginPanelToolbar } from "@/components/plugins/PluginPanelChrome";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { EdgeEverPluginHost, RegisteredPluginPanel } from "@/lib/plugins/plugin-host";
import { cn } from "@/lib/utils";
@@ -14,12 +15,19 @@ export const PluginPanelDialog = ({ host, panel, options, onClose }: {
}) => {
const { t } = useTranslation();
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [chrome, setChrome] = useState<PluginPanelChrome>({});
const [chromeEnabled, setChromeEnabled] = useState(false);
const [mountError, setMountError] = useState<string | null>(null);
const [closeConfirmation, setCloseConfirmation] = useState<Exclude<PluginPanelCloseDecision, boolean> | null>(null);
const [checkingClose, setCheckingClose] = useState(false);
const panelPluginId = panel?.pluginId ?? null;
const panelId = panel?.id ?? null;
useEffect(() => {
setChrome({});
setChromeEnabled(false);
}, [panelPluginId, panelId]);
useEffect(() => {
// Container is reactive so delayed portal attachment mounts the plugin.
if (!container || !panelPluginId || !panelId) return;
@@ -27,7 +35,13 @@ export const PluginPanelDialog = ({ host, panel, options, onClose }: {
let disposePanel: (() => void) | null = null;
setMountError(null);
container.replaceChildren();
void host.mountPanel(panelPluginId, panelId, container, options, requestClose).then((dispose) => {
void host.mountPanel(panelPluginId, panelId, container, options, requestClose, {
set(next) {
if (disposed) return;
setChromeEnabled(true);
setChrome(next);
},
}).then((dispose) => {
if (disposed) dispose();
else disposePanel = dispose;
}).catch((error: unknown) => {
@@ -54,6 +68,10 @@ export const PluginPanelDialog = ({ host, panel, options, onClose }: {
}
};
const title = chrome.header?.title || panel?.title || t("plugins.panel");
const hideDescription = chromeEnabled && chrome.header?.description === null;
const description = chrome.header?.description || t("plugins.panelDescription");
return (
<>
<Dialog open={Boolean(panel)} onOpenChange={(open) => { if (!open) void requestClose(); }}>
@@ -62,14 +80,21 @@ export const PluginPanelDialog = ({ host, panel, options, onClose }: {
? "flex h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-none flex-col overflow-hidden p-5"
: "max-h-[85vh] max-w-3xl overflow-y-auto",
)}>
<DialogHeader>
<DialogTitle>{panel?.title ?? t("plugins.panel")}</DialogTitle>
<DialogDescription>{t("plugins.panelDescription")}</DialogDescription>
<DialogHeader className={cn(chromeEnabled && "flex flex-row items-start justify-between gap-3 space-y-0")}>
<div className="min-w-0 space-y-1.5">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className={hideDescription ? "sr-only" : undefined}>{description}</DialogDescription>
</div>
{chromeEnabled ? <PluginPanelHeaderActions chrome={chrome} /> : null}
</DialogHeader>
{chromeEnabled ? <PluginPanelToolbar chrome={chrome} /> : null}
{mountError ? <div role="alert" className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">{mountError}</div> : null}
<div ref={setContainer} className={cn(
"min-h-40 rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700",
panel?.presentation === "fullscreen" && "min-h-0 flex-1 overflow-hidden",
{chrome.empty ? <PluginPanelEmpty chrome={chrome} /> : null}
<div ref={setContainer} hidden={Boolean(chrome.empty)} className={cn(
chromeEnabled
? "min-h-40 text-sm text-slate-700"
: "min-h-40 rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700",
panel?.presentation === "fullscreen" && "min-h-0 flex-1 overflow-auto",
)} />
</DialogContent>
</Dialog>
@@ -53,9 +53,11 @@ export default {
mount(container, panelContext) {
container.panelState = panelContext.state;
container.requestPanelClose = panelContext.requestClose;
container.hasPanelShell = typeof panelContext.shell?.set === "function";
return () => {
delete container.panelState;
delete container.requestPanelClose;
delete container.hasPanelShell;
};
},
beforeClose() {
@@ -555,6 +555,7 @@ describe("EdgeEverPluginHost", () => {
() => { requestedPanelClose = true; },
);
expect(panelContainer.panelState).toEqual({ resourceId: "resource-1" });
expect(panelContainer.hasPanelShell).toBe(true);
await panelContainer.requestPanelClose();
expect(requestedPanelClose).toBe(true);
disposePanel();
+12
View File
@@ -17,8 +17,10 @@ import {
type PluginNoteSummary,
type PluginPanel,
type PluginOpenNoteOptions,
type PluginPanelChrome,
type PluginPanelCloseDecision,
type PluginPanelOpenOptions,
normalizePluginPanelChrome,
type PluginPermission,
type PluginApiErrorCode,
type PluginResource,
@@ -156,6 +158,10 @@ export interface PluginNavigationAdapter {
openNote(noteId: string, notebookId: string, options?: PluginOpenNoteOptions): void | Promise<void>;
}
export interface PluginPanelChromeAdapter {
set(chrome: PluginPanelChrome): void;
}
export interface PluginPanelAdapter {
openPanel(pluginId: string, panelId: string, options?: PluginPanelOpenOptions): void | Promise<void>;
}
@@ -722,6 +728,7 @@ export class EdgeEverPluginHost {
container: HTMLElement,
options?: PluginPanelOpenOptions,
onRequestClose?: () => void | Promise<void>,
chromeAdapter?: PluginPanelChromeAdapter,
) {
const key = `${pluginId}:${panelId}`;
const panel = this.panels.get(key);
@@ -733,6 +740,11 @@ export class EdgeEverPluginHost {
requestClose: async () => {
await onRequestClose?.();
},
shell: {
set(chrome) {
chromeAdapter?.set(normalizePluginPanelChrome(chrome));
},
},
});
if (this.panels.get(key) !== panel) {
if (typeof pluginDispose === "function") pluginDispose();
+39
View File
@@ -452,6 +452,45 @@ Every API v2 panel must declare one business purpose: `workflow`, `dashboard`, `
`presentation` accepts `dialog` (the default) or `fullscreen`. `panels.open()` can only open a panel registered by the calling plugin; its optional JSON state is limited to 64 KiB and is delivered through the mount context. `beforeClose()` may return `true` to close, `false` to stay open, or confirmation copy for a host-rendered dialog. The mount context's `requestClose()` follows the same guard.
### Panel chrome
Use `mount` context `shell.set()` for system chrome: title, description, header actions, search, tabs, selects, and empty states. EdgeEver renders those controls with the same components as the rest of the app. The `container` argument remains the plugin body — lists, canvases, and forms stay in plugin DOM.
```js
mount(container, { shell, requestClose }) {
const list = document.createElement("div");
container.append(list);
const render = (query) => {
const tasks = queryTasks(query);
shell.set({
header: {
title: "Tasks",
description: `${tasks.length} open`,
actions: [{ id: "refresh", label: "Refresh" }],
},
toolbar: [
{ type: "tabs", key: "view", value: query.view, options: [
{ value: "open", label: "Open" },
{ value: "done", label: "Done" },
] },
{ type: "search", key: "q", placeholder: "Search tasks", value: query.q },
{ type: "select", key: "priority", label: "Priority", value: query.priority, options: [
{ value: "all", label: "All" },
{ value: "high", label: "High" },
] },
],
empty: tasks.length ? null : { title: "No matching tasks" },
onAction(id) { if (id === "refresh") render(query); },
onChange(key, value) { render({ ...query, [key]: value }); },
});
list.replaceChildren(...tasks.map(renderRow));
};
render({ view: "open", q: "", priority: "all" });
}
```
Passing `header.description: null` hides the default “provided by a trusted plugin” line from the visible header (it remains available to assistive technology). Plugins that never call `shell.set()` keep the previous nested card layout. Chrome callbacks are in-memory only and are not stored in panel `state`.
## Desktop plugin entry
After a plugin is enabled, a unified puzzle button appears in the desktop workspace shortcuts on the left. Its menu groups commands and panels by plugin, keeps recently used actions at the top, and links directly to extension management. Individual plugins do not each consume a toolbar icon.
+39
View File
@@ -452,6 +452,45 @@ await context.ui.panels.open("dashboard", { state: { resourceId } });
`presentation` 可以使用 `dialog`(默认)或 `fullscreen`。`panels.open()` 只能打开调用插件自己注册的面板;可选 JSON 状态上限为 64 KiB,并通过挂载上下文传入。`beforeClose()` 可以返回 `true` 关闭、返回 `false` 保持打开,或返回由宿主显示确认框所需的文案。挂载上下文中的 `requestClose()` 同样会经过这项保护。
### 面板系统控件
用 `mount` 上下文里的 `shell.set()` 描述标题、说明、页头按钮、搜索、分段选项、下拉框和空状态。EdgeEver 用与应用其余部分相同的组件来渲染这些控件。`container` 仍是插件内容区——列表、画布和表单继续由插件自己的 DOM 负责。
```js
mount(container, { shell, requestClose }) {
const list = document.createElement("div");
container.append(list);
const render = (query) => {
const tasks = queryTasks(query);
shell.set({
header: {
title: "待办任务",
description: `${tasks.length} 项未完成`,
actions: [{ id: "refresh", label: "刷新" }],
},
toolbar: [
{ type: "tabs", key: "view", value: query.view, options: [
{ value: "open", label: "未完成" },
{ value: "done", label: "已完成" },
] },
{ type: "search", key: "q", placeholder: "搜索任务", value: query.q },
{ type: "select", key: "priority", label: "优先级", value: query.priority, options: [
{ value: "all", label: "全部" },
{ value: "high", label: "高" },
] },
],
empty: tasks.length ? null : { title: "没有符合条件的任务" },
onAction(id) { if (id === "refresh") render(query); },
onChange(key, value) { render({ ...query, [key]: value }); },
});
list.replaceChildren(...tasks.map(renderRow));
};
render({ view: "open", q: "", priority: "all" });
}
```
将 `header.description` 设为 `null` 会隐藏默认的「由受信任插件提供」说明(辅助技术仍可读取)。从不调用 `shell.set()` 的插件保持原来的内嵌卡片布局。控件回调只存在于内存中,不会写入面板 `state`。
## 桌面端插件入口
启用插件后,桌面端左侧工作区快捷栏会显示统一的拼图入口。菜单按插件分组展示命令和面板,并在顶部保留最近使用的操作;“管理插件与主题”会直接打开独立插件市场页面。插件不会各自在工具栏占用一个图标。
+2
View File
@@ -44,6 +44,8 @@ await context.ui.openNote(noteId, { search: "New task" }); // requires ui:naviga
Bulk-indexing plugins can page through `context.notes.queryContent()`. The API also exposes workspace templates, full live-editor reads and range edits, template mutation events, and programmatic opening of a plugin's own registered panels.
Dashboard and workflow panels can ask EdgeEver to render system chrome (header actions, search, tabs, selects, empty states) through `mount` context `shell.set()`, while the plugin keeps owning the body DOM.
Canvas-style plugins can read and conflict-safely replace resource bytes, open full-screen guarded panels with JSON state, and register constrained block embed renderers:
```ts
+62
View File
@@ -319,9 +319,69 @@ export type PluginPanelPurpose = "workflow" | "dashboard" | "preview" | "onboard
export interface PluginPanelOpenOptions {
state?: PluginJsonValue;
}
export type PluginPanelActionVariant = "default" | "primary" | "ghost";
export interface PluginPanelAction {
id: string;
label: string;
variant?: PluginPanelActionVariant;
disabled?: boolean;
}
export interface PluginPanelSelectOption {
value: string;
label: string;
}
export type PluginPanelToolbarItem = {
type: "search";
key: string;
placeholder?: string;
value?: string;
} | {
type: "tabs";
key: string;
value?: string;
options: PluginPanelSelectOption[];
} | {
type: "select";
key: string;
label?: string;
value?: string;
options: PluginPanelSelectOption[];
} | {
type: "button";
key: string;
label: string;
variant?: PluginPanelActionVariant;
disabled?: boolean;
};
export interface PluginPanelEmptyState {
title: string;
description?: string;
action?: PluginPanelAction;
}
/**
* Host-rendered panel chrome. Plugins describe intent; EdgeEver owns layout and controls.
* Callbacks stay in-memory and are not serialized with panel open state.
*/
export interface PluginPanelChrome {
header?: {
title?: string;
/** Pass `null` to hide the host's default panel description. */
description?: string | null;
actions?: PluginPanelAction[];
};
toolbar?: PluginPanelToolbarItem[];
empty?: PluginPanelEmptyState | null;
onAction?: (id: string) => void;
onChange?: (key: string, value: string) => void;
}
export interface PluginPanelShell {
set(chrome: PluginPanelChrome): void;
}
export interface PluginPanelMountContext {
state: PluginJsonValue | null;
requestClose(): Promise<void>;
/** Host-rendered header, toolbar, and empty state. `set` is a no-op when the host has no chrome adapter. */
shell: PluginPanelShell;
}
export type PluginPanelCloseDecision = boolean | {
title: string;
@@ -497,6 +557,8 @@ export interface EdgeEverPlugin {
}
export declare const definePlugin: <T extends EdgeEverPlugin>(plugin: T) => T;
export declare const defineTheme: <T extends ThemeManifest>(theme: T) => T;
/** Strips unknown fields and clamps sizes so host chrome rendering stays bounded. */
export declare const normalizePluginPanelChrome: (value: PluginPanelChrome | null | undefined) => PluginPanelChrome;
export declare const parseExtensionManifest: (value: unknown) => ExtensionManifest;
export declare const parseMarketplaceRegistry: (value: unknown) => MarketplaceRegistry;
export {};
+95
View File
@@ -52,6 +52,100 @@ var defineTheme = (theme) => theme;
var ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)+$/;
var VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
var PANEL_CHROME_ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
var PANEL_ACTION_VARIANTS = new Set(["default", "primary", "ghost"]);
var clipChromeText = (value, fallback = "", max = 200) => {
if (typeof value !== "string")
return fallback;
const trimmed = value.trim();
return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
};
var normalizePanelAction = (value) => {
if (!isRecord(value) || typeof value.id !== "string" || !PANEL_CHROME_ID.test(value.id))
return null;
const label = clipChromeText(value.label);
if (!label)
return null;
const variant = PANEL_ACTION_VARIANTS.has(value.variant) ? value.variant : undefined;
return { id: value.id, label, ...variant ? { variant } : {}, ...value.disabled === true ? { disabled: true } : {} };
};
var normalizePanelOptions = (value) => {
if (!Array.isArray(value))
return [];
const options = [];
for (const item of value.slice(0, 24)) {
if (!isRecord(item) || typeof item.value !== "string" || !item.value || item.value.length > 64)
continue;
const label = clipChromeText(item.label, item.value);
options.push({ value: item.value, label });
}
return options;
};
var normalizeToolbarItem = (value) => {
if (!isRecord(value) || typeof value.key !== "string" || !PANEL_CHROME_ID.test(value.key))
return null;
if (value.type === "search") {
return {
type: "search",
key: value.key,
...typeof value.placeholder === "string" ? { placeholder: clipChromeText(value.placeholder, "", 80) } : {},
...typeof value.value === "string" ? { value: value.value.slice(0, 200) } : {}
};
}
if (value.type === "tabs" || value.type === "select") {
const options = normalizePanelOptions(value.options);
if (!options.length)
return null;
const selected = typeof value.value === "string" && options.some((option) => option.value === value.value) ? value.value : options[0].value;
return {
type: value.type,
key: value.key,
value: selected,
options,
...value.type === "select" && typeof value.label === "string" ? { label: clipChromeText(value.label, "", 40) } : {}
};
}
if (value.type === "button") {
const action = normalizePanelAction({ ...value, id: value.key });
if (!action)
return null;
return { type: "button", key: value.key, label: action.label, ...action.variant ? { variant: action.variant } : {}, ...action.disabled ? { disabled: true } : {} };
}
return null;
};
var normalizePluginPanelChrome = (value) => {
if (!isRecord(value))
return {};
const chrome = {};
if (isRecord(value.header)) {
const actions = Array.isArray(value.header.actions) ? value.header.actions.map(normalizePanelAction).filter((action) => Boolean(action)).slice(0, 8) : [];
chrome.header = {
...typeof value.header.title === "string" ? { title: clipChromeText(value.header.title, "", 80) } : {},
...value.header.description === null ? { description: null } : typeof value.header.description === "string" ? { description: clipChromeText(value.header.description, "", 200) } : {},
...actions.length ? { actions } : {}
};
}
if (Array.isArray(value.toolbar)) {
chrome.toolbar = value.toolbar.map(normalizeToolbarItem).filter((item) => Boolean(item)).slice(0, 16);
}
if (value.empty === null)
chrome.empty = null;
else if (isRecord(value.empty)) {
const title = clipChromeText(value.empty.title, "", 80);
if (title) {
chrome.empty = {
title,
...typeof value.empty.description === "string" ? { description: clipChromeText(value.empty.description) } : {},
...normalizePanelAction(value.empty.action) ? { action: normalizePanelAction(value.empty.action) } : {}
};
}
}
if (typeof value.onAction === "function")
chrome.onAction = value.onAction;
if (typeof value.onChange === "function")
chrome.onChange = value.onChange;
return chrome;
};
var COLOR_THEME_TOKENS = new Set([
"color.background",
"color.surface",
@@ -340,6 +434,7 @@ var parseMarketplaceRegistry = (value) => {
export {
parseMarketplaceRegistry,
parseExtensionManifest,
normalizePluginPanelChrome,
defineTheme,
definePlugin,
THEME_TOKEN_NAMES,
+30 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { parseExtensionManifest, parseMarketplaceRegistry } from "./index.ts";
import { normalizePluginPanelChrome, parseExtensionManifest, parseMarketplaceRegistry } from "./index.ts";
describe("extension manifests", () => {
test("normalizes a plugin manifest", () => {
@@ -244,3 +244,32 @@ describe("marketplace registry", () => {
expect(() => parseMarketplaceRegistry({ registryVersion: "1", updatedAt: "2026-08-16T00:00:00Z", entries: [entry, entry] })).toThrow("Duplicate");
});
});
describe("panel chrome", () => {
test("keeps known chrome fields and drops unknown toolbar items", () => {
const onAction = () => {};
const chrome = normalizePluginPanelChrome({
header: { title: "Tasks", description: null, actions: [{ id: "refresh", label: "Refresh", variant: "primary" }] },
toolbar: [
{ type: "search", key: "q", placeholder: "Search", value: "ship" },
{ type: "tabs", key: "view", value: "open", options: [{ value: "open", label: "Open" }, { value: "done", label: "Done" }] },
{ type: "weird", key: "nope" },
],
empty: { title: "Nothing here", description: "Create a task" },
onAction,
});
expect(chrome.header).toEqual({ title: "Tasks", description: null, actions: [{ id: "refresh", label: "Refresh", variant: "primary" }] });
expect(chrome.toolbar).toEqual([
{ type: "search", key: "q", placeholder: "Search", value: "ship" },
{ type: "tabs", key: "view", value: "open", options: [{ value: "open", label: "Open" }, { value: "done", label: "Done" }] },
]);
expect(chrome.empty).toEqual({ title: "Nothing here", description: "Create a task" });
expect(chrome.onAction).toBe(onAction);
});
test("falls back to the first tab option when the value is unknown", () => {
expect(normalizePluginPanelChrome({
toolbar: [{ type: "tabs", key: "view", value: "missing", options: [{ value: "open", label: "Open" }] }],
}).toolbar).toEqual([{ type: "tabs", key: "view", value: "open", options: [{ value: "open", label: "Open" }] }]);
});
});
+140
View File
@@ -352,9 +352,58 @@ export interface PluginPanelOpenOptions {
state?: PluginJsonValue;
}
export type PluginPanelActionVariant = "default" | "primary" | "ghost";
export interface PluginPanelAction {
id: string;
label: string;
variant?: PluginPanelActionVariant;
disabled?: boolean;
}
export interface PluginPanelSelectOption {
value: string;
label: string;
}
export type PluginPanelToolbarItem =
| { type: "search"; key: string; placeholder?: string; value?: string }
| { type: "tabs"; key: string; value?: string; options: PluginPanelSelectOption[] }
| { type: "select"; key: string; label?: string; value?: string; options: PluginPanelSelectOption[] }
| { type: "button"; key: string; label: string; variant?: PluginPanelActionVariant; disabled?: boolean };
export interface PluginPanelEmptyState {
title: string;
description?: string;
action?: PluginPanelAction;
}
/**
* Host-rendered panel chrome. Plugins describe intent; EdgeEver owns layout and controls.
* Callbacks stay in-memory and are not serialized with panel open state.
*/
export interface PluginPanelChrome {
header?: {
title?: string;
/** Pass `null` to hide the host's default panel description. */
description?: string | null;
actions?: PluginPanelAction[];
};
toolbar?: PluginPanelToolbarItem[];
empty?: PluginPanelEmptyState | null;
onAction?: (id: string) => void;
onChange?: (key: string, value: string) => void;
}
export interface PluginPanelShell {
set(chrome: PluginPanelChrome): void;
}
export interface PluginPanelMountContext {
state: PluginJsonValue | null;
requestClose(): Promise<void>;
/** Host-rendered header, toolbar, and empty state. `set` is a no-op when the host has no chrome adapter. */
shell: PluginPanelShell;
}
export type PluginPanelCloseDecision = boolean | {
@@ -508,6 +557,97 @@ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const PANEL_CHROME_ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
const PANEL_ACTION_VARIANTS = new Set<PluginPanelActionVariant>(["default", "primary", "ghost"]);
const clipChromeText = (value: unknown, fallback = "", max = 200) => {
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
};
const normalizePanelAction = (value: unknown): PluginPanelAction | null => {
if (!isRecord(value) || typeof value.id !== "string" || !PANEL_CHROME_ID.test(value.id)) return null;
const label = clipChromeText(value.label);
if (!label) return null;
const variant = PANEL_ACTION_VARIANTS.has(value.variant as PluginPanelActionVariant) ? value.variant as PluginPanelActionVariant : undefined;
return { id: value.id, label, ...(variant ? { variant } : {}), ...(value.disabled === true ? { disabled: true } : {}) };
};
const normalizePanelOptions = (value: unknown): PluginPanelSelectOption[] => {
if (!Array.isArray(value)) return [];
const options: PluginPanelSelectOption[] = [];
for (const item of value.slice(0, 24)) {
if (!isRecord(item) || typeof item.value !== "string" || !item.value || item.value.length > 64) continue;
const label = clipChromeText(item.label, item.value);
options.push({ value: item.value, label });
}
return options;
};
const normalizeToolbarItem = (value: unknown): PluginPanelToolbarItem | null => {
if (!isRecord(value) || typeof value.key !== "string" || !PANEL_CHROME_ID.test(value.key)) return null;
if (value.type === "search") {
return {
type: "search",
key: value.key,
...(typeof value.placeholder === "string" ? { placeholder: clipChromeText(value.placeholder, "", 80) } : {}),
...(typeof value.value === "string" ? { value: value.value.slice(0, 200) } : {}),
};
}
if (value.type === "tabs" || value.type === "select") {
const options = normalizePanelOptions(value.options);
if (!options.length) return null;
const selected = typeof value.value === "string" && options.some((option) => option.value === value.value) ? value.value : options[0].value;
return {
type: value.type,
key: value.key,
value: selected,
options,
...(value.type === "select" && typeof value.label === "string" ? { label: clipChromeText(value.label, "", 40) } : {}),
};
}
if (value.type === "button") {
const action = normalizePanelAction({ ...value, id: value.key });
if (!action) return null;
return { type: "button", key: value.key, label: action.label, ...(action.variant ? { variant: action.variant } : {}), ...(action.disabled ? { disabled: true } : {}) };
}
return null;
};
/** Strips unknown fields and clamps sizes so host chrome rendering stays bounded. */
export const normalizePluginPanelChrome = (value: PluginPanelChrome | null | undefined): PluginPanelChrome => {
if (!isRecord(value)) return {};
const chrome: PluginPanelChrome = {};
if (isRecord(value.header)) {
const actions = Array.isArray(value.header.actions)
? value.header.actions.map(normalizePanelAction).filter((action): action is PluginPanelAction => Boolean(action)).slice(0, 8)
: [];
chrome.header = {
...(typeof value.header.title === "string" ? { title: clipChromeText(value.header.title, "", 80) } : {}),
...(value.header.description === null ? { description: null } : typeof value.header.description === "string" ? { description: clipChromeText(value.header.description, "", 200) } : {}),
...(actions.length ? { actions } : {}),
};
}
if (Array.isArray(value.toolbar)) {
chrome.toolbar = value.toolbar.map(normalizeToolbarItem).filter((item): item is PluginPanelToolbarItem => Boolean(item)).slice(0, 16);
}
if (value.empty === null) chrome.empty = null;
else if (isRecord(value.empty)) {
const title = clipChromeText(value.empty.title, "", 80);
if (title) {
chrome.empty = {
title,
...(typeof value.empty.description === "string" ? { description: clipChromeText(value.empty.description) } : {}),
...(normalizePanelAction(value.empty.action) ? { action: normalizePanelAction(value.empty.action)! } : {}),
};
}
}
if (typeof value.onAction === "function") chrome.onAction = value.onAction as PluginPanelChrome["onAction"];
if (typeof value.onChange === "function") chrome.onChange = value.onChange as PluginPanelChrome["onChange"];
return chrome;
};
const COLOR_THEME_TOKENS = new Set<ThemeTokenName>([
"color.background", "color.surface", "color.surfaceMuted", "color.text", "color.textMuted",
"color.border", "color.accent", "color.accentForeground", "color.success", "color.warning", "color.danger",