mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(plugin): expose stable contribution identity
This commit is contained in:
+60
-1
@@ -1661,11 +1661,16 @@ describe("PluginHostBridge", () => {
|
||||
describe("plugin SDK source", () => {
|
||||
interface SdkWindow {
|
||||
dbxPlugin?: {
|
||||
ready: Promise<unknown>;
|
||||
contributionId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
onInit: (listener: (context: unknown) => void) => () => void;
|
||||
invoke: (method: string, params?: unknown, options?: { timeoutMs?: number }) => Promise<unknown>;
|
||||
getPlanCapabilities: (connectionId: string) => Promise<unknown>;
|
||||
explainPlan: (request: unknown) => Promise<unknown>;
|
||||
getTableMetadata: (context: unknown) => Promise<unknown>;
|
||||
};
|
||||
receiveHostMessage: (message: unknown) => void;
|
||||
}
|
||||
|
||||
function loadSdk(posted: unknown[], initialTheme?: { appearance: "dark" | "light"; tokens: Record<string, string> }): SdkWindow {
|
||||
@@ -1677,10 +1682,19 @@ describe("plugin SDK source", () => {
|
||||
},
|
||||
dispatchEvent: vi.fn(),
|
||||
} as unknown as Document;
|
||||
let onMessage: ((event: MessageEvent) => void) | undefined;
|
||||
const parent = { postMessage: (message: unknown) => posted.push(message) };
|
||||
const addEventListener = (type: string, listener: (event: MessageEvent) => void) => {
|
||||
if (type === "message") onMessage = listener;
|
||||
};
|
||||
// The SDK IIFE only touches window and the bare-global addEventListener at
|
||||
// boot; parent.postMessage is captured for later request() calls, so a
|
||||
// stub window/parent is enough here.
|
||||
new Function("window", "parent", "addEventListener", "document", pluginSdkSource(initialTheme))(sandbox, { postMessage: (message: unknown) => posted.push(message) }, () => {}, document);
|
||||
new Function("window", "parent", "addEventListener", "document", pluginSdkSource(initialTheme))(sandbox, parent, addEventListener, document);
|
||||
sandbox.receiveHostMessage = (message) => {
|
||||
if (!onMessage) throw new Error("SDK did not register its host-message listener");
|
||||
onMessage({ source: parent, data: message } as unknown as MessageEvent);
|
||||
};
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
@@ -1720,6 +1734,51 @@ describe("plugin SDK source", () => {
|
||||
expect(posted[0]).toMatchObject({ source: "dbx-plugin", type: "ready" });
|
||||
});
|
||||
|
||||
it("exposes the opened contribution after ready when init was already delivered and context is empty", async () => {
|
||||
const sdkMessages: unknown[] = [];
|
||||
const hostMessages: unknown[] = [];
|
||||
const sdk = loadSdk(sdkMessages);
|
||||
const probe: PluginWorkbenchContribution = { type: "workbench", id: "sample.probe", label: "Probe" };
|
||||
const generation: PluginWorkbenchContribution = { type: "workbench", id: "sample.generation", label: "Generation" };
|
||||
const target = { postMessage: (message: unknown) => hostMessages.push(message) } as unknown as Window;
|
||||
const bridge = new PluginHostBridge(plugin([], [probe, generation]), generation, {}, () => target, { invoke: vi.fn(), notify: vi.fn(), sendBinary: vi.fn(), readAsset: vi.fn() });
|
||||
|
||||
bridge.sendInit();
|
||||
const init = hostMessages[0] as Record<string, unknown>;
|
||||
expect(init).toMatchObject({ type: "init", contributionId: "sample.generation", context: {} });
|
||||
sdk.receiveHostMessage(init);
|
||||
|
||||
await sdk.dbxPlugin!.ready;
|
||||
|
||||
expect(sdk.dbxPlugin!.contributionId).toBe("sample.generation");
|
||||
expect(sdk.dbxPlugin!.contributionId).not.toBe(probe.id);
|
||||
expect(sdk.dbxPlugin!.context).toEqual({});
|
||||
const lateInit = vi.fn();
|
||||
sdk.dbxPlugin!.onInit(lateInit);
|
||||
expect(lateInit).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("sets contribution identity independently of table context before ready and onInit", async () => {
|
||||
const sdk = loadSdk([]);
|
||||
const hostMessages: unknown[] = [];
|
||||
const tableContext = { connectionId: "connection-1", database: "app", schema: "public", table: "orders" };
|
||||
const generation: PluginWorkbenchContribution = { type: "workbench", id: "sample.generation", label: "Generation" };
|
||||
const target = { postMessage: (message: unknown) => hostMessages.push(message) } as unknown as Window;
|
||||
const bridge = new PluginHostBridge(plugin([], [generation]), generation, tableContext, () => target, { invoke: vi.fn(), notify: vi.fn(), sendBinary: vi.fn(), readAsset: vi.fn() });
|
||||
let observed: { context: unknown; contributionId?: string } | undefined;
|
||||
sdk.dbxPlugin!.onInit((context) => {
|
||||
observed = { context, contributionId: sdk.dbxPlugin!.contributionId };
|
||||
});
|
||||
|
||||
bridge.sendInit();
|
||||
sdk.receiveHostMessage(hostMessages[0]);
|
||||
await sdk.dbxPlugin!.ready;
|
||||
|
||||
expect(observed).toEqual({ context: tableContext, contributionId: "sample.generation" });
|
||||
expect(sdk.dbxPlugin!.context).toEqual(tableContext);
|
||||
expect(sdk.dbxPlugin!.contributionId).toBe("sample.generation");
|
||||
});
|
||||
|
||||
it("keeps cloneable values intact and plain scalars by reference", () => {
|
||||
const posted: unknown[] = [];
|
||||
const { dbxPlugin } = loadSdk(posted);
|
||||
|
||||
@@ -1252,6 +1252,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
|
||||
const pending = new Map();
|
||||
const listeners = { event: new Set(), binary: new Set(), init: new Set(), context: new Set(), filedrop: new Set(), dragstate: new Set(), close: new Set() };
|
||||
let sequence = 0;
|
||||
let contributionId;
|
||||
let context;
|
||||
let locale = 'en';
|
||||
let theme;
|
||||
@@ -1353,6 +1354,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
|
||||
};
|
||||
window.dbxPlugin = Object.freeze({
|
||||
ready,
|
||||
get contributionId() { return contributionId; },
|
||||
get context() { return context; },
|
||||
get locale() { return locale; },
|
||||
get theme() { return theme; },
|
||||
@@ -1511,6 +1513,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
|
||||
pending.delete(message.id);
|
||||
if (message.error) handler.reject(new Error(message.error)); else handler.resolve(message.result);
|
||||
} else if (message.type === 'init') {
|
||||
contributionId = message.contributionId;
|
||||
capabilities = message.capabilities || {};
|
||||
context = message.context;
|
||||
locale = typeof message.locale === 'string' ? message.locale : 'en';
|
||||
|
||||
@@ -274,7 +274,7 @@ context-menu 也可以声明由 Host 处理的 Workbench action:
|
||||
|
||||
同一插件内通过 `id` 互相引用:Connection Provider 的 `workbench` 必须指向一个已声明的工作台,`filesystem_provider` 必须指向一个已声明的文件系统 Provider。图标优先使用 Contribution 的 `icon`,缺失时回退到插件级 `icon`。
|
||||
|
||||
自定义文件浏览器应使用 `workbench`:列表、虚拟滚动、预览、右键菜单和分栏由插件前端实现。只有希望使用 DBX 通用文件管理器时才选择 `filesystem_provider`;同时声明两者时默认打开工作台。未声明 `action` 的旧式 `context-menu` 调用后端 `contextMenu/<contribution-id>`;声明式 `open-workbench` action 则由 Host 直接打开同插件 Workbench。`result-view` 在工作台 context 中接收结果快照,被打开的 contribution id 会通过 `dbx-plugin-init` 负载下发,因此单个 UI 入口可以为多个工作台和结果视图服务;具体负载见[完整贡献点协议](https://github.com/t8y2/dbx/blob/main/plugins/README.md)。
|
||||
自定义文件浏览器应使用 `workbench`:列表、虚拟滚动、预览、右键菜单和分栏由插件前端实现。只有希望使用 DBX 通用文件管理器时才选择 `filesystem_provider`;同时声明两者时默认打开工作台。未声明 `action` 的旧式 `context-menu` 调用后端 `contextMenu/<contribution-id>`;声明式 `open-workbench` action 则由 Host 直接打开同插件 Workbench。`result-view` 在工作台 context 中接收结果快照。SDK 会在初始化后通过 `dbxPlugin.contributionId` 暴露 Host 选中的 contribution id,因此单个 UI 入口可以为多个工作台和结果视图服务;具体负载见[完整贡献点协议](https://github.com/t8y2/dbx/blob/main/plugins/README.md)。
|
||||
|
||||
### 文件系统协议
|
||||
|
||||
@@ -327,11 +327,21 @@ Workbench 上下文跨越 DBX 与插件边界时是 JSON 数据快照。宿主
|
||||
|
||||
UI 在隔离的 iframe 中运行,DBX 通过 `window.dbxPlugin` 提供桥接对象。不要导入 DBX 的 Vue/Tauri 模块,也不要假设浏览器页面能直接访问 Node.js、文件系统或网络。
|
||||
|
||||
多个 Workbench 或 result-view contribution 共用同一个 UI 入口时,可通过 Host 选中的 identity 路由页面:
|
||||
|
||||
```js
|
||||
await window.dbxPlugin.ready;
|
||||
const contributionId = window.dbxPlugin.contributionId;
|
||||
```
|
||||
|
||||
`contributionId` 与 `context` 相互独立;例如从 Plugin Center 打开的 Workbench,其 context 可以是空对象 `{}`。SDK 会在 resolve `ready` 前记录 identity,因此 `await dbxPlugin.ready` 后可确定性地读取。为兼容旧插件,`dbx-plugin-init` 仍会携带完整 init payload 并作为通知派发;但该事件只派发一次,不应成为读取权威状态的唯一来源。
|
||||
|
||||
以下自定义 `objects/list` 和 `objects/changed` 需要由插件后端实现,资源示例假设包内存在 `ui/assets/empty-state.svg`:
|
||||
|
||||
```js
|
||||
await window.dbxPlugin.ready;
|
||||
|
||||
const contributionId = window.dbxPlugin.contributionId;
|
||||
const context = window.dbxPlugin.context;
|
||||
const result = await window.dbxPlugin.invoke("objects/list", { prefix: "docs/" }, { timeoutMs: 30000 });
|
||||
const off = window.dbxPlugin.onContext((nextContext) => render(nextContext));
|
||||
@@ -343,6 +353,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
|
||||
| API | 作用 |
|
||||
| --- | --- |
|
||||
| `ready` | 等待 Host 完成初始化;应用启动逻辑放在 `await dbxPlugin.ready` 之后。 |
|
||||
| `contributionId` | Host 打开的 contribution 的只读 identity;`await ready` 后可读取,且与 `context` 独立。 |
|
||||
| `context` / `onContext(fn)` | 读取或监听当前工作台上下文。 |
|
||||
| `locale` | 读取当前语言;插件 UI 需自行完成翻译。 |
|
||||
| `theme` | 读取 `appearance` 和 DBX 设计 tokens,适配明暗主题。 |
|
||||
|
||||
@@ -274,7 +274,7 @@ Only legacy context-menu contributions without `action` require a backend entryp
|
||||
|
||||
Reference contributions by ID: a connection provider's `workbench` must point to a declared workbench and `filesystem_provider` to a declared filesystem provider. A contribution icon takes precedence over the plugin-level icon.
|
||||
|
||||
Choose `workbench` for a custom file browser: the plugin owns its list, virtualization, previews, context menus, and split panes. Choose `filesystem_provider` only to use DBX's generic file manager; declaring both defaults to the workbench. A legacy `context-menu` invokes `contextMenu/<contribution-id>` on the backend, while a declarative `open-workbench` action opens its same-plugin Workbench directly; `result-view` receives a result snapshot in workbench context, and the opened contribution id arrives in the `dbx-plugin-init` payload so one UI entrypoint can serve several workbenches and result views. See the [full contribution protocol](https://github.com/t8y2/dbx/blob/main/plugins/README.md) for payloads.
|
||||
Choose `workbench` for a custom file browser: the plugin owns its list, virtualization, previews, context menus, and split panes. Choose `filesystem_provider` only to use DBX's generic file manager; declaring both defaults to the workbench. A legacy `context-menu` invokes `contextMenu/<contribution-id>` on the backend, while a declarative `open-workbench` action opens its same-plugin Workbench directly; `result-view` receives a result snapshot in workbench context. The SDK exposes the Host-selected contribution id as `dbxPlugin.contributionId` after initialization, so one UI entrypoint can serve several workbenches and result views. See the [full contribution protocol](https://github.com/t8y2/dbx/blob/main/plugins/README.md) for payloads.
|
||||
|
||||
### Filesystem Protocol
|
||||
|
||||
@@ -327,11 +327,21 @@ The host also authors three reserved fields on every workbench context: `workben
|
||||
|
||||
The UI runs in an isolated iframe. DBX exposes the bridge as `window.dbxPlugin`; do not import DBX Vue/Tauri modules or assume that Node.js, local files, or arbitrary network access are available.
|
||||
|
||||
When multiple Workbench or result-view contributions share one UI entrypoint, use the Host-selected identity to route the UI:
|
||||
|
||||
```js
|
||||
await window.dbxPlugin.ready;
|
||||
const contributionId = window.dbxPlugin.contributionId;
|
||||
```
|
||||
|
||||
`contributionId` is independent of `context`; for example, a Workbench opened from Plugin Center can have an empty `{}` context. The SDK records the identity before resolving `ready`, making it deterministic to read afterward. The legacy `dbx-plugin-init` event still carries the full init payload as a notification, but it is one-shot and must not be the only source of authoritative state.
|
||||
|
||||
The following custom `objects/list` and `objects/changed` methods must be implemented by your backend. The asset example assumes the package contains `ui/assets/empty-state.svg`:
|
||||
|
||||
```js
|
||||
await window.dbxPlugin.ready;
|
||||
|
||||
const contributionId = window.dbxPlugin.contributionId;
|
||||
const context = window.dbxPlugin.context;
|
||||
const result = await window.dbxPlugin.invoke("objects/list", { prefix: "docs/" }, { timeoutMs: 30000 });
|
||||
const off = window.dbxPlugin.onContext((nextContext) => render(nextContext));
|
||||
@@ -343,6 +353,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
|
||||
| API | Purpose |
|
||||
| --- | --- |
|
||||
| `ready` | Waits for Host initialization; start application logic after `await dbxPlugin.ready`. |
|
||||
| `contributionId` | Read-only identity of the contribution opened by the Host; available after `await ready` and independent of `context`. |
|
||||
| `context` / `onContext(fn)` | Reads or observes the current workbench context. |
|
||||
| `locale` | Reads the current DBX locale; the plugin translates its own UI. |
|
||||
| `theme` | Reads `appearance` and DBX design tokens for light/dark support. |
|
||||
|
||||
@@ -66,7 +66,7 @@ Field names come from the plugin manifest, not the runtime. Imported records get
|
||||
|
||||
## Supported boundary
|
||||
|
||||
- Host API 1.0 subset: `ready`, `context`, `locale`, `theme`, `request`, `invoke`, `notify`, `onInit`, `onContext`, `onEvent`, `onBinary`, `sendBinary`, resource reads and `openWorkbench`.
|
||||
- Host API 1.0 subset: `ready`, `contributionId` (readable after `ready`), `context`, `locale`, `theme`, `request`, `invoke`, `notify`, `onInit`, `onContext`, `onEvent`, `onBinary`, `sendBinary`, resource reads and `openWorkbench`.
|
||||
- Backend transports: default `stdio-jsonl` and explicit `stdio-framed`, protocol version 1. Initialization verifies plugin identity and version. Binary channels require framed transport.
|
||||
- Permissions: event, binary and workbench navigation permissions are enforced. Unimplemented methods, such as `host.openFilesystem`, return errors. Native connection actions, query-result contributions and the DBX component kit are not emulated.
|
||||
- JSON bridge parameters: 2 MiB. UI binary messages: 8 MiB. Sidecar JSON: 8 MiB. Sidecar binary: 64 MiB. Explicit bridge timeouts are clamped to 1–120000 ms, matching the host baseline.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// This function is serialized into the sandbox. It must not capture module state.
|
||||
function installBridge(channel) {
|
||||
let sequence = 0,
|
||||
contributionId,
|
||||
context = {},
|
||||
locale = "zh-CN",
|
||||
theme;
|
||||
@@ -102,6 +103,9 @@ function installBridge(channel) {
|
||||
}
|
||||
window.dbxPlugin = Object.freeze({
|
||||
ready,
|
||||
get contributionId() {
|
||||
return contributionId;
|
||||
},
|
||||
get context() {
|
||||
return context;
|
||||
},
|
||||
@@ -150,6 +154,7 @@ function installBridge(channel) {
|
||||
const m = event.data;
|
||||
if (event.source !== parent || m?.source !== "dbx-host" || m.channel !== channel || m.version !== 1) return;
|
||||
if (m.type === "init") {
|
||||
contributionId = m.contributionId;
|
||||
initialized = true;
|
||||
clearInterval(readyTimer);
|
||||
context = m.context || {};
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
||||
import { reactive } from "vue";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { sandboxDocument } from "../browser-bridge.mjs";
|
||||
import { hostMessage } from "../ui/messages.js";
|
||||
import { hostMessage, pluginInitMessage } from "../ui/messages.js";
|
||||
|
||||
test("bridge delivers host-compatible environment, lifecycle and binary events", async (t) => {
|
||||
const dom = new JSDOM(sandboxDocument("<head></head>", "test"), { runScripts: "dangerously" });
|
||||
@@ -20,7 +20,7 @@ test("bridge delivers host-compatible environment, lifecycle and binary events",
|
||||
w.dbxPlugin.onBinary((value) => {
|
||||
binary = value;
|
||||
});
|
||||
send({ type: "init", context: { value: 1 } });
|
||||
send({ type: "init", contributionId: "example.main", context: { value: 1 } });
|
||||
await w.dbxPlugin.ready;
|
||||
assert.equal(initialized, 1);
|
||||
w.dbxPlugin.onInit(() => initialized++);
|
||||
@@ -34,9 +34,47 @@ test("bridge delivers host-compatible environment, lifecycle and binary events",
|
||||
assert.deepEqual([...binary.data], [0, 255]);
|
||||
assert.equal(binary.channel, "bytes");
|
||||
assert.equal(events.length, 5);
|
||||
assert.equal(events[0].detail.contributionId, "example.main");
|
||||
assert.equal(events[1].detail.value, 2);
|
||||
});
|
||||
|
||||
test("dev-host exposes the contribution identity after ready when init was delivered before consumption", async (t) => {
|
||||
const dom = new JSDOM(sandboxDocument("<html><head></head><body></body></html>", "generation"), { runScripts: "dangerously" });
|
||||
t.after(() => dom.window.close());
|
||||
const w = dom.window;
|
||||
const frame = { contributionId: "example.generation", context: {} };
|
||||
const init = hostMessage("generation", pluginInitMessage(frame, "zh-CN", { appearance: "light" }, []));
|
||||
|
||||
w.dispatchEvent(new w.MessageEvent("message", { source: w, data: init }));
|
||||
await w.dbxPlugin.ready;
|
||||
|
||||
assert.equal(w.dbxPlugin.contributionId, "example.generation");
|
||||
assert.deepEqual(w.dbxPlugin.context, {});
|
||||
let lateContext;
|
||||
w.dbxPlugin.onInit((context) => (lateContext = context));
|
||||
assert.deepEqual(lateContext, {});
|
||||
});
|
||||
|
||||
test("dev-host exposes contribution identity independently of table context before onInit", async (t) => {
|
||||
const dom = new JSDOM(sandboxDocument("<html><head></head><body></body></html>", "generation"), { runScripts: "dangerously" });
|
||||
t.after(() => dom.window.close());
|
||||
const w = dom.window;
|
||||
const context = { connectionId: "connection-1", database: "app", schema: "public", table: "orders" };
|
||||
const frame = { contributionId: "example.generation", context };
|
||||
const init = hostMessage("generation", pluginInitMessage(frame, "zh-CN", { appearance: "light" }, []));
|
||||
let observed;
|
||||
w.dbxPlugin.onInit((value) => {
|
||||
observed = { context: value, contributionId: w.dbxPlugin.contributionId };
|
||||
});
|
||||
|
||||
w.dispatchEvent(new w.MessageEvent("message", { source: w, data: init }));
|
||||
await w.dbxPlugin.ready;
|
||||
|
||||
assert.deepEqual(observed, { context, contributionId: "example.generation" });
|
||||
assert.deepEqual(w.dbxPlugin.context, context);
|
||||
assert.equal(w.dbxPlugin.contributionId, "example.generation");
|
||||
});
|
||||
|
||||
test("host messages snapshot nested reactive context and permissions before postMessage", () => {
|
||||
const source = reactive({ context: { connectionId: "one" }, permissions: ["host.events"] });
|
||||
const message = hostMessage("generation", { type: "init", ...source });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { Plus, Upload, Plug, Unplug, Pencil, Trash2, X, RefreshCw, Sun, Moon, Terminal, Settings2, Languages } from "lucide-vue-next";
|
||||
import { hostMessage } from "./messages.js";
|
||||
import { hostMessage, pluginInitMessage } from "./messages.js";
|
||||
import DebugPanel from "./DebugPanel.vue";
|
||||
import PluginIcon from "./PluginIcon.vue";
|
||||
import { localizeManifest, translate } from "./i18n.js";
|
||||
@@ -134,7 +134,7 @@ function post(frame, message) {
|
||||
windows.get(frame.id)?.contentWindow?.postMessage(snapshot, "*");
|
||||
}
|
||||
function init(frame) {
|
||||
post(frame, { type: "init", context: JSON.parse(JSON.stringify(frame.context)), locale: locale.value, theme: themes(), permissions: manifest.value.permissions || [] });
|
||||
post(frame, pluginInitMessage(frame, locale.value, themes(), manifest.value.permissions || []));
|
||||
}
|
||||
function theme() {
|
||||
dark.value = !dark.value;
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
export function hostMessage(channel, message) {
|
||||
return JSON.parse(JSON.stringify({ ...message, source: "dbx-host", version: 1, channel }));
|
||||
}
|
||||
|
||||
export function pluginInitMessage(frame, locale, theme, permissions) {
|
||||
return {
|
||||
type: "init",
|
||||
contributionId: frame.contributionId,
|
||||
context: JSON.parse(JSON.stringify(frame.context)),
|
||||
locale,
|
||||
theme,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user