feat(plugins): allow plugin workbenches to read and update tab title

This commit is contained in:
lxk955
2026-10-19 03:40:29 +08:00
committed by GitHub
parent 8e57d6f979
commit 8ac2711668
15 changed files with 602 additions and 269 deletions
+1
View File
@@ -5562,6 +5562,7 @@ onUnmounted(() => {
<div v-for="workbenchTab in mountedPluginWorkbenchTabs" :key="workbenchTab.id" v-show="activeTab && workbenchTab.id === activeTab.id" class="flex min-h-0 flex-1 flex-col">
<PluginWorkbenchTab
:ref="(element) => setPluginWorkbenchTabRef(workbenchTab.id, element)"
:tab-id="workbenchTab.id"
:plugin-id="workbenchTab.pluginWorkbench!.pluginId"
:contribution-id="workbenchTab.pluginWorkbench!.contributionId"
:context="workbenchTab.pluginWorkbench!.context"
@@ -330,6 +330,15 @@ function onPanelOpenWorkbench(entry: (typeof entries.value)[number], _contributi
activatePluginDockEntry(id);
}
function onSetEntryTitle(entry: PluginDockEntry, title: string | null) {
if (title && title.trim()) {
renamePluginDockEntry(entry.id, title.trim());
} else {
const contribution = workbenchContributionFor(entry);
renamePluginDockEntry(entry.id, contribution?.label || entry.workbenchContributionId);
}
}
// §8.3/§7.4 two-phase close: removing an entry first asks its panel webview to
// release the workbench scope (PTY sessions, subscriptions) and waits for the
// bridge-bounded handshake before the iframe is unmounted.
@@ -638,6 +647,8 @@ onScopeDispose(() => window.removeEventListener("blur", onPlusMenuWindowBlur));
:plugin="definitionFor(entry.pluginId)!"
:contribution="workbenchContributionFor(entry)!"
:context="entry.context"
:set-title="(title) => onSetEntryTitle(entry, title)"
:get-title="() => entry.title"
@close-tab="closeEntry(entry.id)"
@open-workbench="(_pluginId, contributionId, context, options) => onPanelOpenWorkbench(entry, contributionId, context, options)"
/>
@@ -40,6 +40,8 @@ const props = withDefaults(
plugin: InstalledPlugin;
contribution: PluginUiContribution;
context?: PluginWorkbenchContext;
setTitle?: (title: string | null) => Promise<void> | void;
getTitle?: () => Promise<string> | string;
}>(),
{ context: () => ({}) },
);
@@ -524,6 +526,28 @@ function createBridge() {
...(isTauriRuntime() ? aiCompletion : {}),
setAiRecommendations: (update) => emit("recommendations", update),
openWorkbench: async (pluginId, contributionId, context, options) => emit("openWorkbench", pluginId, contributionId, context, options),
// Only expose the title APIs on surfaces that implement them (workbench
// tab and dock hosts pass the props); the bridge derives the
// `workbenchTitle` capability from their presence, so floating windows
// and sidebar hosts must not advertise a silent no-op.
...(() => {
const setTitleProp = props.setTitle;
const getTitleProp = props.getTitle;
return {
...(setTitleProp
? {
setTitle: async (newTitle: string | null) => {
await setTitleProp(newTitle);
},
}
: {}),
...(getTitleProp
? {
getTitle: async () => await getTitleProp(),
}
: {}),
};
})(),
// §4/§5 bridge command execution, scoped to this plugin's own manifest:
// the single-plugin registry is equivalent here because findCommand /
// findWorkbench / enablement never cross plugins. Panel commands dock,
@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
repushPluginConnection: vi.fn(),
ensureConnected: vi.fn(),
openPluginWorkbench: vi.fn(),
setPluginWorkbenchTabTitle: vi.fn(),
getPluginWorkbenchTabTitle: vi.fn(),
connectedIds: new Set<string>(["conn-1"]),
}));
@@ -37,7 +39,13 @@ vi.mock("@/stores/connectionStore", () => ({
reopenPluginConnection: vi.fn(),
}),
}));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ openPluginWorkbench: mocks.openPluginWorkbench }) }));
vi.mock("@/stores/queryStore", () => ({
useQueryStore: () => ({
openPluginWorkbench: mocks.openPluginWorkbench,
setPluginWorkbenchTabTitle: mocks.setPluginWorkbenchTabTitle,
getPluginWorkbenchTabTitle: mocks.getPluginWorkbenchTabTitle,
}),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ locale: ref("en"), t: (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key) }),
}));
@@ -49,7 +57,7 @@ const plugin: InstalledPlugin = {
id: "com.example.graph",
name: "Graph plugin",
version: "1.0.0",
permissions: [],
permissions: ["host.workbench"],
drivers: [],
entrypoints: { ui: { root: "ui", entry: "ui/index.html" } },
contributions: [
@@ -154,6 +162,39 @@ describe("PluginWorkbenchTab contribution resolution", () => {
expect(root.querySelector("iframe")).toBeNull();
expect(root.textContent).toContain("pluginPlatform.workbenchUnavailable");
});
it("forwards setTitle and getTitle from the bridge to queryStore for the tab", async () => {
mocks.getPluginWorkbenchTabTitle.mockReturnValue("Tab Original Title");
app = createApp(PluginWorkbenchTab, { tabId: "tab-test-1", pluginId: "com.example.graph", contributionId: "graph.main", connectionId: "conn-1" });
app.mount(root);
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
const frame = root.querySelector("iframe");
expect(frame).toBeInstanceOf(HTMLIFrameElement);
const target = frame!.contentWindow!;
const postMessage = vi.spyOn(target, "postMessage").mockImplementation(() => {});
frame!.dispatchEvent(new Event("load"));
await vi.waitFor(() => expect(postMessage).toHaveBeenCalled());
// Send host.setTitle from the iframe
window.dispatchEvent(
new MessageEvent("message", {
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "t-set", method: "host.setTitle", params: { title: "Custom Title" } },
}),
);
await vi.waitFor(() => expect(mocks.setPluginWorkbenchTabTitle).toHaveBeenCalledWith("tab-test-1", "Custom Title"));
// Send host.getTitle from the iframe
window.dispatchEvent(
new MessageEvent("message", {
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "t-get", method: "host.getTitle" },
}),
);
await vi.waitFor(() => expect(mocks.getPluginWorkbenchTabTitle).toHaveBeenCalledWith("tab-test-1"));
});
});
// Restored-tab self-heal (dbx-plugin-ssh#144): a page refresh used to park the
@@ -12,6 +12,7 @@ import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
const props = defineProps<{
tabId?: string;
pluginId: string;
contributionId: string;
connectionId?: string;
@@ -144,6 +145,19 @@ function openFilesystem(pluginId: string, providerId: string, context?: PluginWo
});
}
function onSetTitle(title: string | null) {
if (props.tabId) {
queryStore.setPluginWorkbenchTabTitle(props.tabId, title);
}
}
function onGetTitle(): string {
if (props.tabId) {
return queryStore.getPluginWorkbenchTabTitle(props.tabId) ?? "";
}
return "";
}
onMounted(start);
watch(() => [props.pluginId, props.contributionId, props.connectionId, props.context?.connectionId], start);
@@ -158,6 +172,18 @@ defineExpose({ refresh });
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
<span>{{ error || t("pluginPlatform.workbenchUnavailableFallback") }}</span>
</div>
<PluginWorkbenchHost v-else class="min-h-0 flex-1" :plugin="entry.plugin" :contribution="entry.contribution" :context="hostContext" @open-workbench="openWorkbench" @open-filesystem="openFilesystem" @close-tab="emit('closeTab')" @recommendations="emit('recommendations', $event)" />
<PluginWorkbenchHost
v-else
class="min-h-0 flex-1"
:plugin="entry.plugin"
:contribution="entry.contribution"
:context="hostContext"
:set-title="onSetTitle"
:get-title="onGetTitle"
@open-workbench="openWorkbench"
@open-filesystem="openFilesystem"
@close-tab="emit('closeTab')"
@recommendations="emit('recommendations', $event)"
/>
</div>
</template>
+95
View File
@@ -528,6 +528,72 @@ describe("PluginHostBridge", () => {
expect(openWorkbench).toHaveBeenNthCalledWith(2, "sample", "sample.other", undefined, { forceNew: false });
});
it("rejects host.setTitle and host.getTitle without host.workbench permission", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
setTitle: vi.fn(),
getTitle: vi.fn(),
});
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "t1", method: "host.setTitle", params: { title: "Custom Title" } },
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(1));
expect(messages[0]).toMatchObject({ id: "t1", error: "Plugin has not declared permission 'host.workbench'" });
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "t2", method: "host.getTitle" },
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(2));
expect(messages[1]).toMatchObject({ id: "t2", error: "Plugin has not declared permission 'host.workbench'" });
});
it("handles host.setTitle and host.getTitle with host.workbench permission", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const setTitle = vi.fn();
const getTitle = vi.fn(() => "Current Title");
const bridge = new PluginHostBridge(plugin(["host.workbench"]), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
setTitle,
getTitle,
});
const request = (id: string, method: string, params?: unknown) =>
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id, method, params },
} as MessageEvent);
request("set-1", "host.setTitle", { title: " My Tab " });
await vi.waitFor(() => expect(messages).toHaveLength(1));
expect(setTitle).toHaveBeenLastCalledWith("My Tab");
expect(messages[0]).toMatchObject({ id: "set-1", result: null });
request("set-2", "host.setTitle", { title: null });
await vi.waitFor(() => expect(messages).toHaveLength(2));
expect(setTitle).toHaveBeenLastCalledWith(null);
request("set-3", "host.setTitle", { title: " " });
await vi.waitFor(() => expect(messages).toHaveLength(3));
expect(setTitle).toHaveBeenLastCalledWith(null);
request("get-1", "host.getTitle");
await vi.waitFor(() => expect(messages).toHaveLength(4));
expect(getTitle).toHaveBeenCalled();
expect(messages[3]).toMatchObject({ id: "get-1", result: "Current Title" });
});
it("routes host.executeCommand to the surface handler scoped to the calling plugin", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
@@ -1766,6 +1832,12 @@ describe("plugin SDK source", () => {
getPlanCapabilities: (connectionId: string) => Promise<unknown>;
explainPlan: (request: unknown) => Promise<unknown>;
getTableMetadata: (context: unknown) => Promise<unknown>;
setTitle: (title: string | null) => Promise<unknown>;
getTitle: () => Promise<unknown>;
workbench: {
setTitle: (title: string | null) => Promise<unknown>;
getTitle: () => Promise<unknown>;
};
};
receiveHostMessage: (message: unknown) => void;
}
@@ -1919,6 +1991,29 @@ describe("plugin SDK source", () => {
expect(request.params).toEqual({ connectionId: "c1", database: "app", table: "users" });
});
it("exposes setTitle and getTitle on window.dbxPlugin and window.dbxPlugin.workbench", () => {
const posted: unknown[] = [];
const { dbxPlugin } = loadSdk(posted);
void dbxPlugin!.setTitle("New Tab Name");
void dbxPlugin!.getTitle();
void dbxPlugin!.workbench.setTitle(null);
void dbxPlugin!.workbench.getTitle();
const requests = posted.filter((message) => (message as { type: string }).type === "request") as {
method: string;
params: Record<string, unknown>;
}[];
expect(requests).toHaveLength(4);
expect(requests[0].method).toBe("host.setTitle");
expect(requests[0].params).toEqual({ title: "New Tab Name" });
expect(requests[1].method).toBe("host.getTitle");
expect(requests[2].method).toBe("host.setTitle");
expect(requests[2].params).toEqual({ title: null });
expect(requests[3].method).toBe("host.getTitle");
});
it("forwards fileTransfer requests to the host api", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
@@ -178,6 +178,13 @@ export interface PluginHostBridgeApi {
openAiConversation?(request: AiPluginConversationRequest): Promise<void>;
setAiRecommendations?(update: PluginAiRecommendationHostUpdate): void;
openWorkbench?(pluginId: string, contributionId: string, context?: PluginWorkbenchContext, options?: { forceNew?: boolean; target?: "tab" }): Promise<void> | void;
/**
* Updates the calling workbench tab/dock title (or resets to default when title is null).
* Scoped to the single surface hosting the caller.
*/
setTitle?(title: string | null): Promise<void> | void;
/** Reads the calling workbench tab/dock title. */
getTitle?(): Promise<string> | string;
/**
* §4/§5 command execution asked from a plugin webview: the same registry
* path as menu execution — enablement (§5.4), §4.1 singleton reuse,
@@ -514,6 +521,7 @@ export class PluginHostBridge {
// Floating desktop widget windows: advertised only when the host can both
// open a window and drive its geometry, so a plugin sees one honest flag.
floating: !!this.api.openFloatingWindow && !!this.api.beginFloatingDrag && !!this.api.endFloatingDrag,
workbenchTitle: !!this.api.setTitle,
// The namespace exists on both hosts, but what it can DO differs:
// portable plugins gate on these flags instead of probing calls.
fileTransfer: {
@@ -783,6 +791,20 @@ export class PluginHostBridge {
await this.api.openWorkbench(this.plugin.manifest.id, requireProtocolName(input.contributionId, "workbench contribution"), isRecord(input.context) ? input.context : undefined, { forceNew: input.forceNew === true, target: input.target === "tab" ? "tab" : undefined });
return null;
}
if (method === "host.setTitle") {
this.requirePermission("host.workbench");
if (!this.api.setTitle) throw new Error("Host tab title update is unavailable");
const input = requireRecord(params, "setTitle params");
const rawTitle = input.title;
const title = typeof rawTitle === "string" ? rawTitle.trim().slice(0, 256) || null : null;
await this.api.setTitle(title);
return null;
}
if (method === "host.getTitle") {
this.requirePermission("host.workbench");
if (!this.api.getTitle) throw new Error("Host tab title read is unavailable");
return (await this.api.getTitle()) ?? "";
}
if (method === "host.executeCommand") {
this.requirePermission("host.workbench");
if (!this.api.executeCommand) throw new Error("Host command execution is unavailable");
@@ -1417,6 +1439,8 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
return URL.createObjectURL(new Blob([decode(asset.dataBase64)], { type: asset.contentType }));
},
openWorkbench: (contributionId, childContext, options) => request('host.openWorkbench', { contributionId, context: childContext, forceNew: !!(options && options.forceNew), target: options && options.target === "tab" ? "tab" : undefined }),
setTitle: (title) => request('host.setTitle', { title }),
getTitle: () => request('host.getTitle'),
// Closes whatever surface hosts this plugin UI — a workbench tab, a dock
// entry, or this plugin's floating window. Same path as the Ctrl+W
// shortcut, so a plugin can offer its own close/minimize affordance.
@@ -1531,6 +1555,8 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
// listener has settled. The host bounds the wait on its side.
workbench: Object.freeze({
onClose: (listener) => { listeners.close.add(listener); return () => listeners.close.delete(listener); },
setTitle: (title) => request('host.setTitle', { title }),
getTitle: () => request('host.getTitle'),
}),
decodeBase64: decode,
encodeBase64: encode,
@@ -275,4 +275,42 @@ describe("queryStore localizePluginTabTitles", () => {
expect(titles[freeId]).toBe("文件");
expect(titles[boundId]).toBe("prod-server · SFTP");
});
it("allows plugin workbench tabs to set and get custom titles independently and reset with null", () => {
const queryStore = useQueryStore();
const firstId = queryStore.openPluginWorkbench("io.dbx.viewer", "file-view", { title: "File View", forceNew: true });
const secondId = queryStore.openPluginWorkbench("io.dbx.viewer", "file-view", { title: "File View", forceNew: true });
expect(queryStore.getPluginWorkbenchTabTitle(firstId)).toBe("File View");
expect(queryStore.getPluginWorkbenchTabTitle(secondId)).toBe("File View (1)");
// Modify title on the first tab
const updatedFirst = queryStore.setPluginWorkbenchTabTitle(firstId, "config.yaml");
expect(updatedFirst).toBe(true);
expect(queryStore.getPluginWorkbenchTabTitle(firstId)).toBe("config.yaml");
// Second tab remains unchanged
expect(queryStore.getPluginWorkbenchTabTitle(secondId)).toBe("File View (1)");
const firstTab = queryStore.tabs.find((t) => t.id === firstId);
expect(firstTab?.customTitle).toBe(true);
// Modify title on the second tab
const updatedSecond = queryStore.setPluginWorkbenchTabTitle(secondId, "schema.json");
expect(updatedSecond).toBe(true);
expect(queryStore.getPluginWorkbenchTabTitle(secondId)).toBe("schema.json");
expect(queryStore.getPluginWorkbenchTabTitle(firstId)).toBe("config.yaml");
// Reset first tab to default by passing null
queryStore.setPluginWorkbenchTabTitle(firstId, null);
expect(queryStore.getPluginWorkbenchTabTitle(firstId)).toBe("File View");
expect(firstTab?.customTitle).toBe(false);
// Reset second tab with empty string
queryStore.setPluginWorkbenchTabTitle(secondId, " ");
expect(queryStore.getPluginWorkbenchTabTitle(secondId)).toBe("File View (1)");
// Non-existent or non-workbench tab returns false/undefined
expect(queryStore.setPluginWorkbenchTabTitle("non-existent", "title")).toBe(false);
expect(queryStore.getPluginWorkbenchTabTitle("non-existent")).toBeUndefined();
});
});
+28 -1
View File
@@ -4118,10 +4118,11 @@ export const useQueryStore = defineStore("query", () => {
return suffix ? Math.max(highest, Number(suffix[1])) : highest;
}, 0) + 1;
const baseTitle = options.title || contributionId;
const title = siblingTabs.length >= 1 ? `${baseTitle} (${nextSessionNumber})` : baseTitle;
const id = uuid();
const tab: QueryTab = {
id,
title: siblingTabs.length >= 1 ? `${baseTitle} (${nextSessionNumber})` : baseTitle,
title,
connectionId,
database: options.database || "",
sql: "",
@@ -4133,6 +4134,7 @@ export const useQueryStore = defineStore("query", () => {
commandId: options.commandId,
pluginId,
contributionId,
defaultTitle: title,
context: options.context ? snapshotPluginWorkbenchContext(options.context) : undefined,
},
};
@@ -4161,8 +4163,31 @@ export const useQueryStore = defineStore("query", () => {
if (!localizedTitle) continue;
const suffix = / \((\d+)\)$/.exec(tab.title)?.[0] || "";
tab.title = `${localizedTitle}${suffix}`;
if (tab.pluginWorkbench) {
tab.pluginWorkbench.defaultTitle = tab.title;
}
}
}
function setPluginWorkbenchTabTitle(id: string, title: string | null): boolean {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.mode !== "plugin-workbench" || !tab.pluginWorkbench) return false;
const trimmed = typeof title === "string" ? title.trim().slice(0, 256) : "";
if (trimmed) {
tab.title = trimmed;
tab.customTitle = true;
} else {
tab.title = tab.pluginWorkbench.defaultTitle || tab.pluginWorkbench.contributionId;
tab.customTitle = false;
}
return true;
}
function getPluginWorkbenchTabTitle(id: string): string | undefined {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.mode !== "plugin-workbench") return undefined;
return tab.title;
}
function openPluginFilesystem(pluginId: string, providerId: string, options: { title?: string; connectionId?: string; rootUri?: string; currentUri?: string; forceNew?: boolean } = {}) {
if (!options.forceNew) {
@@ -10722,6 +10747,8 @@ export const useQueryStore = defineStore("query", () => {
openMqttAdmin,
openNacosAdmin,
openPluginWorkbench,
setPluginWorkbenchTabTitle,
getPluginWorkbenchTabTitle,
pluginWorkbenchTitles,
localizePluginTabTitles,
openPluginFilesystem,
+1
View File
@@ -2309,6 +2309,7 @@ export interface QueryTab {
commandId?: string;
pluginId: string;
contributionId: string;
defaultTitle?: string;
context?: Record<string, unknown>;
};
pluginFilesystem?: {
+41 -28
View File
@@ -5,9 +5,7 @@ description: 从 CLI、Manifest、Workbench 和 Sidecar 开始开发 DBX 插件
DBX 插件是独立安装的 `.dbxp` 包,可以提供连接类型、工作台、文件系统和原生 Sidecar。插件源码、DBX 插件框架和官方商店目录由不同仓库负责。
<Callout type="info">
**推荐路径:** 使用 `dbx-plugin create` 创建项目,在自己的仓库开发并发布未签名候选包;`dbx-store` 会为已注册的插件自动创建或更新候选 PR,维护者审核并签名后才会进入官方目录。
</Callout>
<Callout type="info">**推荐路径:** 使用 `dbx-plugin create` 创建项目,在自己的仓库开发并发布未签名候选包;`dbx-store` 会为已注册的插件自动创建或更新候选 PR,维护者审核并签名后才会进入官方目录。</Callout>
<Callout type="warn">
**插件上架 Pull Request 提交到 [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store),不是 `t8y2/dbx`。** 插件自己的功能代码通常保留在开发者自己的源码仓库;只有修改 DBX Host、SDK、CLI、协议、Schema、文档或官方示例时,才向 [`t8y2/dbx`](https://github.com/t8y2/dbx) 提交 PR。
@@ -34,7 +32,7 @@ DBX 插件是独立安装的 `.dbxp` 包,可以提供连接类型、工作台
## 应该提交到哪个仓库
| 你要修改的内容 | 提交位置 | 是否向 DBX 官方提 PR |
| --- | --- | --- |
| -------------------------------------------------------------- | ----------------------------------------------------- | --------------------------------------- |
| 你的插件前端、Rust/Go 后端、测试和发布脚本 | 插件自己的源码仓库 | 不向 `dbx` 提交;在自己的仓库开发和发布 |
| 新插件上架、版本更新、商店图标和介绍 | [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store) | 是,目标分支为 `main` |
| DBX 插件 Host、Manifest/Marketplace Schema、SDK、CLI、Packager | [`t8y2/dbx`](https://github.com/t8y2/dbx) | 是,目标分支为 `main` |
@@ -84,16 +82,14 @@ my-plugin/
"engines": { "host_api": "1" },
"permissions": [],
"entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } },
"contributions": [
{ "type": "workbench", "id": "com.example.files.main", "label": "Files", "icon": "assets/plugin.svg" }
]
"contributions": [{ "type": "workbench", "id": "com.example.files.main", "label": "Files", "icon": "assets/plugin.svg" }]
}
```
Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含 `..`、反斜杠或重复斜杠。`ui.entry` 必须位于 `ui.root` 内:当 root 为 `ui` 时,入口写 `ui/index.html`,不是 `index.html`。`signingKeyId` 属于商店 artifact metadata,不是 Manifest 字段。
| 字段 | 必需 | 说明 |
| --- | --- | --- |
| ------------------ | ---- | ---------------------------------------------------------------------------------------------------- |
| `$schema` | 否 | 编辑器使用的 JSON Schema 地址,不参与身份校验。 |
| `manifest_version` | 是 | 当前必须为数字 `1`。 |
| `id` | 是 | 全局稳定 ID,只能使用小写字母、数字、`.`, `_`, `-`,例如 `io.github.example.files`;发布后不要更改。 |
@@ -168,14 +164,21 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含
{ "key": "display_name", "label": "Name", "type": "text", "binding": "name", "required": true },
{ "key": "endpoint", "label": "Endpoint", "type": "text", "binding": "host", "required": true },
{ "key": "port", "label": "Port", "type": "number", "binding": "port", "default": 443 },
{ "key": "region", "label": "Region", "type": "radio", "options": [{ "label": "US", "value": "us" }, { "label": "EU", "value": "eu" }], "binding": "config" },
{
"key": "region",
"label": "Region",
"type": "radio",
"options": [
{ "label": "US", "value": "us" },
{ "label": "EU", "value": "eu" }
],
"binding": "config"
},
{ "key": "token", "label": "Access token", "type": "password", "binding": "secret", "required": true }
],
"workbench": "com.example.files.main",
"capabilities": ["test", "connect", "disconnect"],
"actions": [
{ "id": "refresh", "label": "Refresh metadata", "variant": "outline", "when": "edit", "requires_valid_form": true, "timeout_ms": 30000 }
]
"actions": [{ "id": "refresh", "label": "Refresh metadata", "variant": "outline", "when": "edit", "requires_valid_form": true, "timeout_ms": 30000 }]
}
```
@@ -237,7 +240,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含
### 其他 Contributions
| 类型 | 必需字段 | 用途 |
| --- | --- | --- |
| --------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `workbench` | `type`, `id`, `label` | 注册可从侧边栏或插件入口打开的工作台。 |
| `filesystem-provider` | `type`, `id`, `label`, `schemes` | 注册虚拟文件系统;`root_uri` 例如 `s3://bucket`,能力可选 `read`、`write`、`delete`、`rename`、`mkdir`。 |
| `context-menu` | `type`、`id`、`label`、`menu: "connection"` 或 `"table"`、可选 `action` | 向 Sidebar Tree 的连接或 table 右键菜单增加后端动作,或打开已声明的 Workbench。 |
@@ -283,7 +286,7 @@ context-menu 也可以声明由 Host 处理的 Workbench action:
使用 DBX 通用文件管理器时,后端实现以下方法;自定义工作台也可以自行通过 RPC 使用这些方法,但不能把声明 Contribution 当成已经实现业务逻辑。
| 方法 | 参数与返回 |
| --- | --- |
| ---------------------------- | --------------------------------------------------------------------------------------------------- |
| `filesystem/list` | `providerId`、可选 `connectionId`、`uri`、可选 `cursor`、`limit`;返回 `{ entries, nextCursor? }`。 |
| `filesystem/read` | 同上身份字段、`uri`、`maxBytes`;返回 `{ dataBase64, contentType?, truncated, etag? }`。 |
| `filesystem/write` | `uri`、`dataBase64`、`create`、`overwrite`、可选 `etag`;需声明 `write`。 |
@@ -301,7 +304,7 @@ context-menu 也可以声明由 Host 处理的 Workbench action:
```json
{
"localizations": {
"localizations": {
"zh-CN": {
"name": "示例文件",
"contributions": {
@@ -311,7 +314,7 @@ context-menu 也可以声明由 Host 处理的 Workbench action:
}
}
}
}
}
}
```
@@ -353,7 +356,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)` | 读取或监听当前工作台上下文。 |
@@ -365,6 +368,8 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
| `sendBinary(channel, data)` / `onBinary(fn)` | 发送或接收二进制数据;需要 `stdio-framed` 和 `host.binary`。 |
| `readAsset(path)` / `readAssetUrl(path)` | 读取包内资源;路径不能越出插件资源根目录。 |
| `openWorkbench(id, context)` | 打开本插件的另一个工作台;需要 `host.workbench`。 |
| `setTitle(title)` | 修改当前工作台页签标题(传入 `null` 恢复默认);需要 `host.workbench`。 |
| `getTitle()` | 读取当前工作台页签标题;需要 `host.workbench`。 |
| `closeWorkbench()` | 关闭承载当前 UI 的表面——工作台标签、dock 面板条目,或本插件的浮动窗口。与 `Ctrl/Cmd+W` 快捷键走同一条路径,因此插件可以提供自己的关闭或最小化按钮。 |
| `executeCommand(commandId, context?)` | 执行本插件自己声明的命令,与菜单执行完全同径——重新检查 `enablement`、按单例复用,并遵循声明的 `presentation`(panel 停靠底部面板,tab 打开工作台标签页)。`context` 合并到命令上下文之上,并解析 `instance_key` 的 `{{path}}` 占位符(如 `instance_key: "logs:{{connectionId}}"` 即每个连接一个面板实例)。预期结果通过 `{ error }` 返回;需要 `host.workbench`。 |
| `openFilesystem(id, context)` | 打开本插件的文件系统入口;需要 `host.filesystem`。 |
@@ -372,7 +377,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | 返回 `sql` 的估算执行计划;需要 `host.plans:read`。 |
| `getTableMetadata({ connectionId, database?, schema?, table })` | 读取已打开连接的窄化 table schema metadata;需要 `host.schema:read`。 |
| `queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` | 在用户授权给插件的连接上执行一条只读 SQL;需要 `host.data:read`。 |
| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiRecommendations, clipboardWrite, clipboardRead, floating }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 |
| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiRecommendations, clipboardWrite, clipboardRead, floating, workbenchTitle }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 |
| `ai.openConversation({ title, prompt, context, send?, mode? })` | 在内置 AI 面板新建插件对话;`send` 默认 `false`,`mode` 默认 `ask`。`mode: "agent"` 会在 context 包含已打开插件连接时使用实时插件工具;需要 `host.ai`,并以 `capabilities.ai` 广播。 |
| `ai.setRecommendations({ context, items })` | 覆盖当前工作台的默认推荐问题并立即刷新全局 AI 面板;每次最多 5 条,支持 `{{path.to.value}}` 占位符;需要 `host.ai` 和 `capabilities.aiRecommendations`。 |
| `ai.clearRecommendations()` | 清空当前工作台的推荐问题;需要 `host.ai` 和 `capabilities.aiRecommendations`。 |
@@ -446,7 +451,7 @@ if (window.dbxPlugin.capabilities.schemaMetadataApi) {
connectionId,
database,
schema,
table: "users"
table: "users",
});
for (const column of metadata.columns) {
renderColumn(column.name, column.dataType, column.nullable);
@@ -500,7 +505,7 @@ if (window.dbxPlugin.capabilities.dataApi) {
connectionId,
database,
sql: "SELECT status, count(*) AS total FROM orders GROUP BY status",
maxRows: 200
maxRows: 200,
});
renderChart(result.columns, result.rows);
}
@@ -511,8 +516,14 @@ if (window.dbxPlugin.capabilities.dataApi) {
```json
{
"dbType": "postgres",
"columns": [{ "name": "status", "dataType": "text" }, { "name": "total", "dataType": "int8" }],
"rows": [["paid", 1204], ["refunded", 37]],
"columns": [
{ "name": "status", "dataType": "text" },
{ "name": "total", "dataType": "int8" }
],
"rows": [
["paid", 1204],
["refunded", 37]
],
"truncated": false,
"elapsedMs": 12
}
@@ -616,7 +627,7 @@ const offDrag = fileTransfer.onDragState((active) => showDropOverlay(active));
```
| API | 用途 |
| --- | --- |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `pick({ multiple })` | 原生打开对话框;返回已打开的读句柄 `{ handleId, name, size, contentType }`。 |
| `read(handleId, offset, length?)` | 从读句柄流式读取一个分块 `{ dataBase64, length, eof }`。 |
| `beginSave({ name, contentType?, size? })` | 原生保存对话框 + 写句柄;返回 `{ handleId, chunkBytes }`,用户取消时为 `null`。 |
@@ -641,7 +652,7 @@ if (window.dbxPlugin.capabilities.storage) {
```
| API | 用途 |
| --- | --- |
| ----------------- | --------------------------------------------------- |
| `get(key)` | 返回存储的 JSON 值;键未设置时返回 `null`。 |
| `set(key, value)` | 持久化任意 JSON 值(`undefined` 归一化为 `null`)。 |
| `delete(key)` | 删除一个键;删除不存在的键同样成功。 |
@@ -764,7 +775,7 @@ grip.addEventListener("pointerup", async () => {
```
| API | 作用 |
| --- | --- |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `floating.open(options)` | 为本插件的某个 `workbench` contribution 打开(或前置)浮动窗口。`options`:`contributionId`(必填)、`title`、逻辑像素的 `width`/`height`、物理屏幕像素的 `x`/`y`、`alwaysOnTop`(默认 `true`)、`skipTaskbar`(默认 `true`)、`resizable`(默认 `false`)、`context`。返回 `{ windowId, reused }`;需要 `host.workbench`。 |
| `floating.close(contributionId?)` | 关闭本插件的浮动窗口——指定某个 contribution,或省略 id 关闭本插件的全部浮动窗口。在小组件内部不带参数调用即关闭自己。 |
| `floating.beginDrag()` | 把窗口交给宿主的拖动循环。在 `pointermove` 里、自己的拖动阈值越过之后调用(此时按键还按住)。之后由宿主自己读原生光标,插件没有逐帧调用要做。 |
@@ -805,7 +816,7 @@ button {
env 消息的 `theme` 结构为 `{ appearance, tokens, editor? }`。`appearance` 是 `"light" | "dark"`;`tokens` 是按前缀扫取的根节点设计变量(`--color-*`、`--radius-*`、`--font-*`);`editor` 是无 CSS 令牌载体的 SQL 编辑器设置结构化快照。投递矩阵:
| DBX 设置 | 投递形式 |
| --- | --- |
| ----------------------------------- | ----------------------------------------------- |
| 浅色/深色/跟随系统 | `theme.appearance` |
| 调色板 + 自定义配色 | `--color-*` tokens |
| 圆角风格 | `--radius-*` tokens |
@@ -1075,12 +1086,14 @@ candidates/<plugin-id>.json
"name": "Example Files",
"source": "https://github.com/example/dbx-plugin-files/tree/v0.1.0",
"license": "Apache-2.0",
"targets": [{
"targets": [
{
"target": "universal",
"url": "https://github.com/example/dbx-plugin-files/releases/download/v0.1.0/com.example.files-0.1.0-universal.dbxp",
"sha256": "<64 位十六进制 SHA-256>",
"size": 123456
}]
}
]
}
```
+43 -27
View File
@@ -10,7 +10,8 @@ DBX plugins are independently installed `.dbxp` packages that can contribute con
</Callout>
<Callout type="warn">
**Marketplace listing pull requests go to [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store), not `t8y2/dbx`.** A plugin's feature code normally remains in its author's source repository. Open a PR against [`t8y2/dbx`](https://github.com/t8y2/dbx) only when changing the DBX host, SDK, CLI, protocol, schemas, documentation, or official examples.
**Marketplace listing pull requests go to [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store), not `t8y2/dbx`.** A plugin's feature code normally remains in its author's source repository. Open a PR against [`t8y2/dbx`](https://github.com/t8y2/dbx) only when changing the DBX host, SDK, CLI,
protocol, schemas, documentation, or official examples.
</Callout>
## Plugin Shortcuts
@@ -34,7 +35,7 @@ Position affects icons only: content retains its existing `panel/tab` presentati
## Which Repository Receives the Change
| Change | Destination | Official DBX PR? |
| --- | --- | --- |
| -------------------------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
| Your plugin frontend, Rust/Go backend, tests, and release scripts | The plugin's source repository | Do not submit it to `dbx`; develop and release it in its own repository |
| A new Marketplace listing, version update, icon, or description | [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store) | Yes, target `main` |
| DBX plugin host, Manifest/Marketplace schemas, SDK, CLI, or packager | [`t8y2/dbx`](https://github.com/t8y2/dbx) | Yes, target `main` |
@@ -84,16 +85,14 @@ my-plugin/
"engines": { "host_api": "1" },
"permissions": [],
"entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } },
"contributions": [
{ "type": "workbench", "id": "com.example.files.main", "label": "Files", "icon": "assets/plugin.svg" }
]
"contributions": [{ "type": "workbench", "id": "com.example.files.main", "label": "Files", "icon": "assets/plugin.svg" }]
}
```
Manifest paths are relative to the package root. They cannot start with `/`, contain `..`, backslashes, or duplicate slashes. `ui.entry` must be inside `ui.root`: with root `ui`, use `ui/index.html`, not `index.html`. `signingKeyId` belongs to Marketplace artifact metadata, not the Manifest.
| Field | Required | Meaning |
| --- | --- | --- |
| ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `$schema` | No | JSON Schema URL for editor support; it does not establish identity. |
| `manifest_version` | Yes | Must currently be the number `1`. |
| `id` | Yes | Stable global ID using lowercase letters, digits, `.`, `_`, and `-`, such as `io.github.example.files`; never change it after publishing. |
@@ -168,14 +167,21 @@ A connection provider declares a form; DBX renders the form and owns the connect
{ "key": "display_name", "label": "Name", "type": "text", "binding": "name", "required": true },
{ "key": "endpoint", "label": "Endpoint", "type": "text", "binding": "host", "required": true },
{ "key": "port", "label": "Port", "type": "number", "binding": "port", "default": 443 },
{ "key": "region", "label": "Region", "type": "radio", "options": [{ "label": "US", "value": "us" }, { "label": "EU", "value": "eu" }], "binding": "config" },
{
"key": "region",
"label": "Region",
"type": "radio",
"options": [
{ "label": "US", "value": "us" },
{ "label": "EU", "value": "eu" }
],
"binding": "config"
},
{ "key": "token", "label": "Access token", "type": "password", "binding": "secret", "required": true }
],
"workbench": "com.example.files.main",
"capabilities": ["test", "connect", "disconnect"],
"actions": [
{ "id": "refresh", "label": "Refresh metadata", "variant": "outline", "when": "edit", "requires_valid_form": true, "timeout_ms": 30000 }
]
"actions": [{ "id": "refresh", "label": "Refresh metadata", "variant": "outline", "when": "edit", "requires_valid_form": true, "timeout_ms": 30000 }]
}
```
@@ -237,7 +243,7 @@ Action `when` accepts `always`, `create`, or `edit`; `variant` accepts `default`
### Other Contributions
| Type | Required fields | Purpose |
| --- | --- | --- |
| --------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `workbench` | `type`, `id`, `label` | Registers a workbench opened from the sidebar or plugin entry. |
| `filesystem-provider` | `type`, `id`, `label`, `schemes` | Registers a virtual filesystem; `root_uri` can be `s3://bucket`, with optional `read`, `write`, `delete`, `rename`, and `mkdir` capabilities. |
| `context-menu` | `type`, `id`, `label`, `menu: "connection"` or `"table"`, optional `action` | Adds a backend action or opens a declared Workbench from a Sidebar Tree context menu. |
@@ -283,7 +289,7 @@ Choose `workbench` for a custom file browser: the plugin owns its list, virtuali
Implement these backend methods when using the DBX generic file manager. A custom workbench can use them through its own RPC calls as well; declaring a contribution does not implement business logic.
| Method | Parameters and return values |
| --- | --- |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `filesystem/list` | `providerId`, optional `connectionId`, `uri`, optional `cursor`, `limit`; returns `{ entries, nextCursor? }`. |
| `filesystem/read` | The same identity fields, `uri`, `maxBytes`; returns `{ dataBase64, contentType?, truncated, etag? }`. |
| `filesystem/write` | `uri`, `dataBase64`, `create`, `overwrite`, optional `etag`; requires `write`. |
@@ -301,7 +307,7 @@ Use locale keys such as `zh-CN` and `en` in `localizations`. You can override th
```json
{
"localizations": {
"localizations": {
"zh-CN": {
"name": "示例文件",
"contributions": {
@@ -311,7 +317,7 @@ Use locale keys such as `zh-CN` and `en` in `localizations`. You can override th
}
}
}
}
}
}
```
@@ -353,7 +359,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. |
@@ -365,6 +371,8 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
| `sendBinary(channel, data)` / `onBinary(fn)` | Sends or receives binary data; requires framed transport and `host.binary`. |
| `readAsset(path)` / `readAssetUrl(path)` | Reads a packaged asset within the plugin resource root. |
| `openWorkbench(id, context)` | Opens another workbench from this plugin; requires `host.workbench`. |
| `setTitle(title)` | Updates the current workbench tab title (pass `null` to reset to default); requires `host.workbench`. |
| `getTitle()` | Reads the current workbench tab title; requires `host.workbench`. |
| `closeWorkbench()` | Closes the surface hosting this UI — a workbench tab, a dock entry, or this plugin's floating window. It is the same path as the `Ctrl/Cmd+W` shortcut, so a plugin can offer its own close or minimize affordance. |
| `executeCommand(commandId, context?)` | Executes one of this plugin's own declared commands through the same path as a menu placement — re-checks `enablement`, applies singleton reuse, and follows the declared `presentation` (panel commands dock, tab commands open tabs). `context` merges over the command context and scopes `instance_key` `{{path}}` placeholders, so `instance_key: "logs:{{connectionId}}"` yields one panel instance per connection. Resolves `{ error }` for expected outcomes; requires `host.workbench`. |
| `openFilesystem(id, context)` | Opens a filesystem entry; requires `host.filesystem`. |
@@ -372,7 +380,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | Returns the estimated plan for `sql`; requires `host.plans:read`. |
| `getTableMetadata({ connectionId, database?, schema?, table })` | Reads narrow table schema metadata from an already-open connection; requires `host.schema:read`. |
| `queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` | Runs one read-only SQL statement on a connection the user granted to the plugin; requires `host.data:read`. |
| `capabilities` | `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiRecommendations, clipboardWrite, clipboardRead, floating }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. |
| `capabilities` | `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiRecommendations, clipboardWrite, clipboardRead, floating, workbenchTitle }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. |
| `ai.openConversation({ title, prompt, context, send?, mode? })` | Opens a new plugin conversation in the built-in AI panel; `send` defaults to `false` and `mode` defaults to `ask`. `mode: "agent"` uses live plugin tools when context includes an open plugin connection; requires `host.ai` and is advertised as `capabilities.ai`. |
| `ai.setRecommendations({ context, items })` | Replaces the current workbench's default recommendations and refreshes the global AI panel; up to five items are shown and `{{path.to.value}}` placeholders are supported; requires `host.ai` and `capabilities.aiRecommendations`. |
| `ai.clearRecommendations()` | Clears recommendations for the current workbench; requires `host.ai` and `capabilities.aiRecommendations`. |
@@ -446,7 +454,7 @@ if (window.dbxPlugin.capabilities.schemaMetadataApi) {
connectionId,
database,
schema,
table: "users"
table: "users",
});
for (const column of metadata.columns) {
renderColumn(column.name, column.dataType, column.nullable);
@@ -500,7 +508,7 @@ if (window.dbxPlugin.capabilities.dataApi) {
connectionId,
database,
sql: "SELECT status, count(*) AS total FROM orders GROUP BY status",
maxRows: 200
maxRows: 200,
});
renderChart(result.columns, result.rows);
}
@@ -511,8 +519,14 @@ if (window.dbxPlugin.capabilities.dataApi) {
```json
{
"dbType": "postgres",
"columns": [{ "name": "status", "dataType": "text" }, { "name": "total", "dataType": "int8" }],
"rows": [["paid", 1204], ["refunded", 37]],
"columns": [
{ "name": "status", "dataType": "text" },
{ "name": "total", "dataType": "int8" }
],
"rows": [
["paid", 1204],
["refunded", 37]
],
"truncated": false,
"elapsedMs": 12
}
@@ -616,7 +630,7 @@ const offDrag = fileTransfer.onDragState((active) => showDropOverlay(active));
```
| API | Purpose |
| --- | --- |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pick({ multiple })` | Native open dialog; resolves opened read handles `{ handleId, name, size, contentType }`. |
| `read(handleId, offset, length?)` | Streams a chunk `{ dataBase64, length, eof }` from a read handle. |
| `beginSave({ name, contentType?, size? })` | Native save dialog + write handle; resolves `{ handleId, chunkBytes }`, or `null` when the user cancels. |
@@ -641,7 +655,7 @@ if (window.dbxPlugin.capabilities.storage) {
```
| API | Purpose |
| --- | --- |
| ----------------- | ---------------------------------------------------------------- |
| `get(key)` | Resolves the stored JSON value, or `null` when the key is unset. |
| `set(key, value)` | Persists any JSON value (`undefined` normalizes to `null`). |
| `delete(key)` | Removes one key; deleting an unset key succeeds. |
@@ -764,14 +778,14 @@ grip.addEventListener("pointerup", async () => {
```
| API | Purpose |
| --- | --- |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `floating.open(options)` | Opens (or focuses) the floating window for one of this plugin's `workbench` contributions. `options`: `contributionId` (required), `title`, `width`/`height` in logical pixels, `x`/`y` in physical screen pixels, `alwaysOnTop` (default `true`), `skipTaskbar` (default `true`), `resizable` (default `false`), `context`. Resolves `{ windowId, reused }`; requires `host.workbench`. |
| `floating.close(contributionId?)` | Closes this plugin's floating window — one contribution, or every floating window of this plugin when the id is omitted. Inside a widget, calling it without an argument closes the widget itself. |
| `floating.beginDrag()` | Hands the window to the host's drag loop. Call it from `pointermove` once your drag threshold trips, while the button is still down. The host reads the native cursor itself from here on, so there is no per-move call to make. |
| `floating.endDrag({ snap? })` | Ends the drag. With `snap` (default `true`) the host pulls the window flush onto a work-area edge it reached or passed — docking it there, capsule first and tucked behind a thin strip once the cursor has been away — or clamps it fully on screen otherwise, and remembers the position (and the dock) for the next open. Resolves the final rect in physical pixels. Optional in practice: the host also ends a drag on its own once the cursor goes quiet, so a widget that never sees the pointerup cannot leave the window glued to it. |
| `floating.setSize(width, height)` | Resizes the hosting window in logical pixels; the host clamps to its own bounds. |
The widget decides *when* a drag starts (which element is a grip, how far the pointer must travel first); the host moves the window and owns the snap and the remembered position. There is deliberately no per-move bridge call: the host polls the native cursor, which keeps the widget under the pointer even when the webview is busy, and ends the drag on a quiet cursor if the pointerup never arrives. Geometry calls (`beginDrag`/`endDrag`/`setSize`) only work inside a floating window and reject elsewhere, so a tab can never move a window it is not in.
The widget decides _when_ a drag starts (which element is a grip, how far the pointer must travel first); the host moves the window and owns the snap and the remembered position. There is deliberately no per-move bridge call: the host polls the native cursor, which keeps the widget under the pointer even when the webview is busy, and ends the drag on a quiet cursor if the pointerup never arrives. Geometry calls (`beginDrag`/`endDrag`/`setSize`) only work inside a floating window and reject elsewhere, so a tab can never move a window it is not in.
A few rules make the surface predictable:
@@ -805,7 +819,7 @@ Listen with `window.addEventListener("dbx-plugin-env", handler)` for locale/them
The env message's `theme` is `{ appearance, tokens, editor? }`. `appearance` is `"light" | "dark"`; `tokens` are the resolved root design variables swept by prefix (`--color-*`, `--radius-*`, `--font-*`); `editor` is a structured snapshot of the SQL editor settings that have no CSS-token carrier. Delivered settings:
| DBX setting | Delivered as |
| --- | --- |
| ----------------------------------------------------- | ------------------------------------------------------------------------ |
| Light/dark/system mode | `theme.appearance` |
| Palette + custom color sets | `--color-*` tokens |
| Corner style | `--radius-*` tokens |
@@ -1073,12 +1087,14 @@ The candidate's essential shape is below. It references unsigned assets from the
"name": "Example Files",
"source": "https://github.com/example/dbx-plugin-files/tree/v0.1.0",
"license": "Apache-2.0",
"targets": [{
"targets": [
{
"target": "universal",
"url": "https://github.com/example/dbx-plugin-files/releases/download/v0.1.0/com.example.files-0.1.0-universal.dbxp",
"sha256": "<64 hexadecimal SHA-256 characters>",
"size": 123456
}]
}
]
}
```
+5 -7
View File
@@ -263,10 +263,7 @@ A field may declare `visible_when` and `required_when`. A leaf clause matches wh
]
},
"required_when": {
"all_of": [
{ "field": "sudo_source", "one_of": ["custom"] },
{ "not": { "field": "read_only", "one_of": [true] } }
]
"all_of": [{ "field": "sudo_source", "one_of": ["custom"] }, { "not": { "field": "read_only", "one_of": [true] } }]
}
}
```
@@ -369,6 +366,7 @@ A workbench opens in a normal persistent DBX tab. The iframe is loaded with `san
- `sendBinary(channel, data)` — requires `host.binary`
- `readAsset(path)` / `readAssetUrl(path)`
- `openWorkbench(contributionId, context)` — requires `host.workbench`
- `setTitle(title)` / `getTitle()` — updates (pass `null` to reset) or reads the calling tab title; requires `host.workbench`
- `executeCommand(commandId, context?)` — executes one of this plugin's own declared commands through the same path a menu placement takes: `enablement` is re-checked, `presentation: "panel"` commands dock in the bottom panel and `presentation: "tab"` (default) commands open a workbench tab, with §4.1 singleton reuse. `context` merges over the command-declared context (reserved identity fields stay host-owned) and scopes `instance_key` `{{path}}` placeholders — declare `instance_key: "logs:{{connectionId}}"` to get one panel instance per connection; a template that cannot resolve falls back to its literal. Resolves `{ error }` for expected outcomes (unknown command, enablement-blocked); requires `host.workbench`
- `openFilesystem(providerId, context)` — requires `host.filesystem`
- `getPlanCapabilities(connectionId)` / `explainPlan(request)` — reads an estimated execution plan for one connection; requires `host.plans:read`, see [Estimated execution plans](#estimated-execution-plans)
@@ -600,7 +598,7 @@ if (window.dbxPlugin.capabilities.schemaMetadataApi) {
connectionId,
database,
schema,
table: "users"
table: "users",
});
renderColumns(metadata.columns);
}
@@ -659,7 +657,7 @@ if (window.dbxPlugin.capabilities.dataApi) {
connectionId,
database,
sql: "SELECT status, count(*) AS total FROM orders GROUP BY status",
maxRows: 200
maxRows: 200,
});
}
```
@@ -684,7 +682,7 @@ A plugin can read the **estimated** execution plan of a query without touching a
```
| Request | Purpose |
| --- | --- |
| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `host.getPlanCapabilities({ connectionId })` | Reports what the host and this connection can plan. It only reads the stored connection config; it never connects or probes the server. The connection must already be open. |
| `host.explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | Returns the estimated plan for `sql`. The connection must already be open. |
+6
View File
@@ -131,6 +131,12 @@ function installBridge(channel) {
return URL.createObjectURL(new Blob([decode(asset.dataBase64)], { type: asset.contentType }));
},
openWorkbench: (contributionId, context, options) => request("host.openWorkbench", { contributionId, context, forceNew: !!(options && options.forceNew) }),
setTitle: (title) => request("host.setTitle", { title }),
getTitle: () => request("host.getTitle"),
workbench: Object.freeze({
setTitle: (title) => request("host.setTitle", { title }),
getTitle: () => request("host.getTitle"),
}),
openFilesystem: (providerId, context) => request("host.openFilesystem", { providerId, context }),
reopenConnection: (connectionId) => request("host.reopenConnection", { connectionId }),
getConnections: () => request("host.getConnections", {}),
+10
View File
@@ -314,6 +314,16 @@ export async function createMockHost(options) {
requirePermission(manifest, "host.workbench");
return { mockHostOpenFrame: await serialize(() => openFrame(session, p.context?.connectionId || frame.connectionId, p.contributionId, p.context)) };
}
case "host.setTitle": {
requirePermission(manifest, "host.workbench");
const title = p.title === null || p.title === undefined ? null : typeof p.title === "string" ? p.title.trim() || null : null;
frame.title = title;
return { ok: true, title };
}
case "host.getTitle": {
requirePermission(manifest, "host.workbench");
return frame.title ?? "";
}
case "host.reopenConnection": {
if (typeof p.connectionId !== "string" || !p.connectionId) throw new Error("connectionId is invalid");
return { ok: true, mockReopenConnection: p.connectionId };