mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 08:15:46 +00:00
feat(plugins): allow plugin workbenches to read and update tab title
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,9 +4163,32 @@ 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) {
|
||||
const existing = tabs.value.find((tab) => tab.mode === "plugin-filesystem" && tab.pluginFilesystem?.pluginId === pluginId && tab.pluginFilesystem?.providerId === providerId && tab.connectionId === (options.connectionId || ""));
|
||||
@@ -10722,6 +10747,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
openMqttAdmin,
|
||||
openNacosAdmin,
|
||||
openPluginWorkbench,
|
||||
setPluginWorkbenchTabTitle,
|
||||
getPluginWorkbenchTabTitle,
|
||||
pluginWorkbenchTitles,
|
||||
localizePluginTabTitles,
|
||||
openPluginFilesystem,
|
||||
|
||||
@@ -2309,6 +2309,7 @@ export interface QueryTab {
|
||||
commandId?: string;
|
||||
pluginId: string;
|
||||
contributionId: string;
|
||||
defaultTitle?: string;
|
||||
context?: Record<string, unknown>;
|
||||
};
|
||||
pluginFilesystem?: {
|
||||
|
||||
@@ -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。
|
||||
@@ -33,15 +31,15 @@ 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` |
|
||||
| DBX 官方示例或插件开发文档 | [`t8y2/dbx`](https://github.com/t8y2/dbx) | 是 |
|
||||
| 某个第三方插件自身的 Bug | 该插件的源码仓库 | 不要提交到 `dbx-store` |
|
||||
| 自定义或企业私有仓库 | 你的私有仓库 | 不需要向官方商店提交 |
|
||||
| 数据库厂商 JDBC Driver JAR | 不提交到 Git 仓库 | 用户在本机或服务器中导入 |
|
||||
| 你要修改的内容 | 提交位置 | 是否向 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` |
|
||||
| DBX 官方示例或插件开发文档 | [`t8y2/dbx`](https://github.com/t8y2/dbx) | 是 |
|
||||
| 某个第三方插件自身的 Bug | 该插件的源码仓库 | 不要提交到 `dbx-store` |
|
||||
| 自定义或企业私有仓库 | 你的私有仓库 | 不需要向官方商店提交 |
|
||||
| 数据库厂商 JDBC Driver JAR | 不提交到 Git 仓库 | 用户在本机或服务器中导入 |
|
||||
|
||||
DBX 官方维护但需要独立发布节奏的插件,通常也应使用独立源码仓库。只有与 Host 强耦合的最小示例、SDK 验证项目或维护者明确同意的组件才放进 `t8y2/dbx`。
|
||||
|
||||
@@ -84,31 +82,29 @@ 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`;发布后不要更改。 |
|
||||
| `name` | 是 | 默认显示名称。 |
|
||||
| `icon` | 否 | 插件默认图标的包内相对路径;推荐 SVG 或 PNG。 |
|
||||
| `version` | 是 | `major.minor.patch` 语义化版本,可带 prerelease/build 后缀。相同 ID 和版本的正式包不能覆盖。 |
|
||||
| `publisher` | 是 | 发布者标识;首次上架后应保持不变。 |
|
||||
| `description` | 否 | 插件说明。 |
|
||||
| `source` | 否 | 源码仓库 HTTPS 地址,安装后可在插件信息中查看。 |
|
||||
| `homepage` | 否 | 项目主页、文档或支持地址。 |
|
||||
| `engines` | 是 | 运行时兼容范围,至少包含 `host_api`。 |
|
||||
| `permissions` | 否 | 最小权限集合,见下文。 |
|
||||
| `entrypoints` | 否 | UI 和原生 Sidecar 入口;贡献点存在时按需声明。 |
|
||||
| `contributions` | 否 | 连接、工作台、文件系统、连接右键菜单、结果视图、命令/菜单和 MCP 工具面声明。 |
|
||||
| `localizations` | 否 | 按 locale 覆盖名称、说明、贡献点、字段和动作文案。 |
|
||||
| 字段 | 必需 | 说明 |
|
||||
| ------------------ | ---- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `$schema` | 否 | 编辑器使用的 JSON Schema 地址,不参与身份校验。 |
|
||||
| `manifest_version` | 是 | 当前必须为数字 `1`。 |
|
||||
| `id` | 是 | 全局稳定 ID,只能使用小写字母、数字、`.`, `_`, `-`,例如 `io.github.example.files`;发布后不要更改。 |
|
||||
| `name` | 是 | 默认显示名称。 |
|
||||
| `icon` | 否 | 插件默认图标的包内相对路径;推荐 SVG 或 PNG。 |
|
||||
| `version` | 是 | `major.minor.patch` 语义化版本,可带 prerelease/build 后缀。相同 ID 和版本的正式包不能覆盖。 |
|
||||
| `publisher` | 是 | 发布者标识;首次上架后应保持不变。 |
|
||||
| `description` | 否 | 插件说明。 |
|
||||
| `source` | 否 | 源码仓库 HTTPS 地址,安装后可在插件信息中查看。 |
|
||||
| `homepage` | 否 | 项目主页、文档或支持地址。 |
|
||||
| `engines` | 是 | 运行时兼容范围,至少包含 `host_api`。 |
|
||||
| `permissions` | 否 | 最小权限集合,见下文。 |
|
||||
| `entrypoints` | 否 | UI 和原生 Sidecar 入口;贡献点存在时按需声明。 |
|
||||
| `contributions` | 否 | 连接、工作台、文件系统、连接右键菜单、结果视图、命令/菜单和 MCP 工具面声明。 |
|
||||
| `localizations` | 否 | 按 locale 覆盖名称、说明、贡献点、字段和动作文案。 |
|
||||
|
||||
### `engines`、权限和入口
|
||||
|
||||
@@ -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 }]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -236,12 +239,12 @@ 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。 |
|
||||
| `result-view` | `type`, `id`, `label` | 注册查询或任务结果视图。 |
|
||||
| 类型 | 必需字段 | 用途 |
|
||||
| --------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `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。 |
|
||||
| `result-view` | `type`, `id`, `label` | 注册查询或任务结果视图。 |
|
||||
|
||||
`menu: "table"` contribution 当前只在用户对 Sidebar Tree 中的具体 table 节点打开右键菜单时生效。未声明 `action` 的旧式 contribution 会通过必需的 backend entrypoint 调用 `contextMenu/<contribution-id>`,并传递 table identity:
|
||||
|
||||
@@ -282,14 +285,14 @@ 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`。 |
|
||||
| `filesystem/createDirectory` | `uri`;需声明 `mkdir`。 |
|
||||
| `filesystem/delete` | `uri`、`recursive`;需声明 `delete`。 |
|
||||
| `filesystem/rename` | `sourceUri`、`targetUri`、`overwrite`;需声明 `rename`。 |
|
||||
| 方法 | 参数与返回 |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `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`。 |
|
||||
| `filesystem/createDirectory` | `uri`;需声明 `mkdir`。 |
|
||||
| `filesystem/delete` | `uri`、`recursive`;需声明 `delete`。 |
|
||||
| `filesystem/rename` | `sourceUri`、`targetUri`、`overwrite`;需声明 `rename`。 |
|
||||
|
||||
所有方法都携带 `providerId` 和可选 `connectionId`。目录项包含单个文件名 `name`、完整 `uri`、`kind`(`file`、`directory`、`symlink`、`other`)以及可选 `size`、`modifiedAt`、`contentType`。`name` 不能包含 `/`、`\\` 或是 `.`/`..`;完整路径放在 `uri`。最后一页省略 `nextCursor`,不要返回已经使用的 cursor。图片预览应返回实际 MIME 和 base64 字节,不能把二进制当作 UTF-8 文本。
|
||||
|
||||
@@ -301,18 +304,18 @@ context-menu 也可以声明由 Host 处理的 Workbench action:
|
||||
|
||||
```json
|
||||
{
|
||||
"localizations": {
|
||||
"zh-CN": {
|
||||
"name": "示例文件",
|
||||
"contributions": {
|
||||
"com.example.files.connection": {
|
||||
"label": "示例服务",
|
||||
"fields": { "token": { "label": "访问令牌" } }
|
||||
"localizations": {
|
||||
"zh-CN": {
|
||||
"name": "示例文件",
|
||||
"contributions": {
|
||||
"com.example.files.connection": {
|
||||
"label": "示例服务",
|
||||
"fields": { "token": { "label": "访问令牌" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Workbench 上下文契约
|
||||
@@ -352,35 +355,37 @@ await window.dbxPlugin.notify("objects/changed", { count: result.items.length })
|
||||
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,适配明暗主题。 |
|
||||
| `request(method, params)` | 调用 Host API;常用内部方法包括 `host.getContext`、`ui.readAsset`。 |
|
||||
| `invoke(method, params, { timeoutMs })` | 向自己的 Sidecar 发送有响应的 RPC。 |
|
||||
| `notify(method, params)` | 向 Sidecar 发送无需返回业务结果的通知。 |
|
||||
| `sendBinary(channel, data)` / `onBinary(fn)` | 发送或接收二进制数据;需要 `stdio-framed` 和 `host.binary`。 |
|
||||
| `readAsset(path)` / `readAssetUrl(path)` | 读取包内资源;路径不能越出插件资源根目录。 |
|
||||
| `openWorkbench(id, context)` | 打开本插件的另一个工作台;需要 `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`。 |
|
||||
| `getPlanCapabilities(connectionId)` | 读取宿主与该连接能提供什么计划能力;需要 `host.plans:read`。 |
|
||||
| `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,应据此关闭功能而不是用真实请求试探。 |
|
||||
| `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`。 |
|
||||
| `onInit(fn)` / `onEvent(fn)` | 监听初始化、环境变化或后端事件;转发后端事件需要 `host.events`。 |
|
||||
| `storage` | 本插件独享的持久化键值状态;需要 `host.storage`;见下文。 |
|
||||
| `fileTransfer` | 本地文件流式传输、下载与(桌面端)系统拖放;见下文。 |
|
||||
| `clipboard` | 沙箱内的系统剪贴板访问;见下文。 |
|
||||
| `floating` | 打开并控制桌面浮动小组件窗口(仅桌面宿主);见[浮动窗口](#浮动窗口)。 |
|
||||
| API | 作用 |
|
||||
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ready` | 等待 Host 完成初始化;应用启动逻辑放在 `await dbxPlugin.ready` 之后。 |
|
||||
| `contributionId` | Host 打开的 contribution 的只读 identity;`await ready` 后可读取,且与 `context` 独立。 |
|
||||
| `context` / `onContext(fn)` | 读取或监听当前工作台上下文。 |
|
||||
| `locale` | 读取当前语言;插件 UI 需自行完成翻译。 |
|
||||
| `theme` | 读取 `appearance` 和 DBX 设计 tokens,适配明暗主题。 |
|
||||
| `request(method, params)` | 调用 Host API;常用内部方法包括 `host.getContext`、`ui.readAsset`。 |
|
||||
| `invoke(method, params, { timeoutMs })` | 向自己的 Sidecar 发送有响应的 RPC。 |
|
||||
| `notify(method, params)` | 向 Sidecar 发送无需返回业务结果的通知。 |
|
||||
| `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`。 |
|
||||
| `getPlanCapabilities(connectionId)` | 读取宿主与该连接能提供什么计划能力;需要 `host.plans:read`。 |
|
||||
| `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, 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`。 |
|
||||
| `onInit(fn)` / `onEvent(fn)` | 监听初始化、环境变化或后端事件;转发后端事件需要 `host.events`。 |
|
||||
| `storage` | 本插件独享的持久化键值状态;需要 `host.storage`;见下文。 |
|
||||
| `fileTransfer` | 本地文件流式传输、下载与(桌面端)系统拖放;见下文。 |
|
||||
| `clipboard` | 沙箱内的系统剪贴板访问;见下文。 |
|
||||
| `floating` | 打开并控制桌面浮动小组件窗口(仅桌面宿主);见[浮动窗口](#浮动窗口)。 |
|
||||
|
||||
调用失败会以 Promise rejection 返回,处理时应展示可理解的错误并允许重试。通过 `request` 调用的宿主方法、参数和返回值以当前 Host API 版本为准,不要调用未在协议中声明的 DBX 内部函数。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -615,15 +626,15 @@ const offDrop = fileTransfer.onDrop((files) => uploadAll(files));
|
||||
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`。 |
|
||||
| `write(handleId, offset, data)` | 写入一个分块(二进制 transfer 或 base64);返回 `{ written, nextOffset }`。 |
|
||||
| `finish(handleId)` / `cancel(handleId)` | 冲洗并关闭写句柄 / 丢弃任意句柄。 |
|
||||
| `onDrop(fn)` | 拖入本工作台的文件以读句柄送达;拖入文件夹会被展开为其中包含的文件(`.git` 等点开头文件与目录会被跳过),每个文件额外携带 `relativePath`(以 `/` 分隔、以拖入文件夹自身名字开头的路径,如 `folder/nested/a.csv`),可用于还原拖入的目录结构,也能区分不同拖入根目录下的同名文件。监听器签名为 `(files, { dropId, truncated })`:`dropId` 标识本次拖入的整组条目(取消与进度记账的分组键),`truncated` 为 `true` 表示展开因 2000 文件或 8 层深度上限被截断。 |
|
||||
| `onDragState(fn)` | 是否有系统拖拽悬在本工作台上方;用它驱动拖放遮罩。 |
|
||||
| API | 用途 |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `pick({ multiple })` | 原生打开对话框;返回已打开的读句柄 `{ handleId, name, size, contentType }`。 |
|
||||
| `read(handleId, offset, length?)` | 从读句柄流式读取一个分块 `{ dataBase64, length, eof }`。 |
|
||||
| `beginSave({ name, contentType?, size? })` | 原生保存对话框 + 写句柄;返回 `{ handleId, chunkBytes }`,用户取消时为 `null`。 |
|
||||
| `write(handleId, offset, data)` | 写入一个分块(二进制 transfer 或 base64);返回 `{ written, nextOffset }`。 |
|
||||
| `finish(handleId)` / `cancel(handleId)` | 冲洗并关闭写句柄 / 丢弃任意句柄。 |
|
||||
| `onDrop(fn)` | 拖入本工作台的文件以读句柄送达;拖入文件夹会被展开为其中包含的文件(`.git` 等点开头文件与目录会被跳过),每个文件额外携带 `relativePath`(以 `/` 分隔、以拖入文件夹自身名字开头的路径,如 `folder/nested/a.csv`),可用于还原拖入的目录结构,也能区分不同拖入根目录下的同名文件。监听器签名为 `(files, { dropId, truncated })`:`dropId` 标识本次拖入的整组条目(取消与进度记账的分组键),`truncated` 为 `true` 表示展开因 2000 文件或 8 层深度上限被截断。 |
|
||||
| `onDragState(fn)` | 是否有系统拖拽悬在本工作台上方;用它驱动拖放遮罩。 |
|
||||
|
||||
桌面端与网页端的行为差异:请以 init 消息里 additive 的 `capabilities.fileTransfer` 标志位(`pick`、`beginSave`、`read`、`drop`、`folderExpansion`)做能力探测,不要靠试调用。桌面端 `pick` / `beginSave` 走原生对话框、`onDrop` 接收系统拖放(句柄由宿主惰性打开,读时分块流式,因此超大文件夹与大文件都不会占满句柄;选取文件数超过共享句柄池时自动降级为惰性条目,不会静默丢弃)。网页端 `pick` 回退到浏览器文件选择器、`beginSave` 回退为内存缓冲加浏览器下载、`onDrop` / `onDragState` 不会触发(`drop`/`folderExpansion` 为 `false`)、句柄也没有 `relativePath`。`onDrop` 只在拖放落点位于本插件工作台区域内时触发;落点在宿主其他区域时保持宿主自身的行为。
|
||||
|
||||
@@ -640,11 +651,11 @@ if (window.dbxPlugin.capabilities.storage) {
|
||||
}
|
||||
```
|
||||
|
||||
| API | 用途 |
|
||||
| --- | --- |
|
||||
| `get(key)` | 返回存储的 JSON 值;键未设置时返回 `null`。 |
|
||||
| API | 用途 |
|
||||
| ----------------- | --------------------------------------------------- |
|
||||
| `get(key)` | 返回存储的 JSON 值;键未设置时返回 `null`。 |
|
||||
| `set(key, value)` | 持久化任意 JSON 值(`undefined` 归一化为 `null`)。 |
|
||||
| `delete(key)` | 删除一个键;删除不存在的键同样成功。 |
|
||||
| `delete(key)` | 删除一个键;删除不存在的键同样成功。 |
|
||||
|
||||
键为不超过 256 字符的字符串;单个值序列化后上限 256 KiB,整库上限 1 MiB——这是 UI 状态存储,不是数据落盘通道。数据保存在插件自己的 `plugin-data/<id>` 目录里,按插件隔离,版本升级和卸载后都保留。大批量数据、缓存以及 Sidecar 产生的任何文件请放进 Sidecar 的数据目录(见 [原生 Sidecar 和 RPC](#原生-sidecar-和-rpc))。Web 宿主上同一 API 由浏览器本地存储支撑,两端代码完全一致。
|
||||
|
||||
@@ -763,13 +774,13 @@ 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` 里、自己的拖动阈值越过之后调用(此时按键还按住)。之后由宿主自己读原生光标,插件没有逐帧调用要做。 |
|
||||
| `floating.endDrag({ snap? })` | 结束拖动。`snap`(默认 `true`)时宿主把窗口齐着它到达或越过的工作区边缘吸附并停靠在那(先保持整颗胶囊,光标离开一会儿才收起到屏外、只留一条悬停细边),落点在屏幕中间则完整夹回屏内;位置与停靠边都会记住,供下次打开使用。返回物理像素的最终矩形。实践中可不调:宿主在光标静止后也会自己收尾,收不到 pointerup 的小组件不会把窗口一直粘在光标上。 |
|
||||
| `floating.setSize(width, height)` | 以逻辑像素调整所在窗口大小;宿主会夹到自己的上下限内。 |
|
||||
| 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` 里、自己的拖动阈值越过之后调用(此时按键还按住)。之后由宿主自己读原生光标,插件没有逐帧调用要做。 |
|
||||
| `floating.endDrag({ snap? })` | 结束拖动。`snap`(默认 `true`)时宿主把窗口齐着它到达或越过的工作区边缘吸附并停靠在那(先保持整颗胶囊,光标离开一会儿才收起到屏外、只留一条悬停细边),落点在屏幕中间则完整夹回屏内;位置与停靠边都会记住,供下次打开使用。返回物理像素的最终矩形。实践中可不调:宿主在光标静止后也会自己收尾,收不到 pointerup 的小组件不会把窗口一直粘在光标上。 |
|
||||
| `floating.setSize(width, height)` | 以逻辑像素调整所在窗口大小;宿主会夹到自己的上下限内。 |
|
||||
|
||||
小组件决定拖动**何时**开始(哪个元素是拖拽把手、指针要先走多远);窗口由宿主移动,吸附与位置记忆也归宿主。刻意没有逐帧的桥调用:宿主轮询原生光标,webview 忙不过来时小组件也照样贴着指针走,光标静止即收尾,pointerup 丢了也不会一直拖下去。几何类调用(`beginDrag`/`endDrag`/`setSize`)只在浮动窗口内部有效,在其他表面调用会被拒绝,因此标签页永远无法移动它不在其中的窗口。
|
||||
|
||||
@@ -804,17 +815,17 @@ button {
|
||||
|
||||
env 消息的 `theme` 结构为 `{ appearance, tokens, editor? }`。`appearance` 是 `"light" | "dark"`;`tokens` 是按前缀扫取的根节点设计变量(`--color-*`、`--radius-*`、`--font-*`);`editor` 是无 CSS 令牌载体的 SQL 编辑器设置结构化快照。投递矩阵:
|
||||
|
||||
| DBX 设置 | 投递形式 |
|
||||
| --- | --- |
|
||||
| 浅色/深色/跟随系统 | `theme.appearance` |
|
||||
| 调色板 + 自定义配色 | `--color-*` tokens |
|
||||
| 圆角风格 | `--radius-*` tokens |
|
||||
| UI 字体 | `--font-sans` token |
|
||||
| 编辑器(等宽)字体 | `--font-mono` token + `theme.editor.fontFamily` |
|
||||
| 编辑器字号 | `theme.editor.fontSize` |
|
||||
| SQL 编辑器语法主题 | `theme.editor.theme` |
|
||||
| UI 缩放(宿主窗口级) | 不投递——插件自管缩放 |
|
||||
| 数据网格字体/类型配色、编辑器背景图 | 不投递——DB 域(`--dbx-*` 不在扫取范围) |
|
||||
| DBX 设置 | 投递形式 |
|
||||
| ----------------------------------- | ----------------------------------------------- |
|
||||
| 浅色/深色/跟随系统 | `theme.appearance` |
|
||||
| 调色板 + 自定义配色 | `--color-*` tokens |
|
||||
| 圆角风格 | `--radius-*` tokens |
|
||||
| UI 字体 | `--font-sans` token |
|
||||
| 编辑器(等宽)字体 | `--font-mono` token + `theme.editor.fontFamily` |
|
||||
| 编辑器字号 | `theme.editor.fontSize` |
|
||||
| SQL 编辑器语法主题 | `theme.editor.theme` |
|
||||
| UI 缩放(宿主窗口级) | 不投递——插件自管缩放 |
|
||||
| 数据网格字体/类型配色、编辑器背景图 | 不投递——DB 域(`--dbx-*` 不在扫取范围) |
|
||||
|
||||
字体"字号"类关注点归插件所有:宿主不规定你的字号;如适合你的 UI,可把 `theme.editor.fontSize` 当默认值消费。所有负载变化都经 env 通道实时推送——字体族变更走主题修订号,字号/语法主题走显式监听——无需刷新。
|
||||
|
||||
@@ -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": [{
|
||||
"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
|
||||
}]
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -33,15 +34,15 @@ 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` |
|
||||
| Official examples or plugin-development documentation | [`t8y2/dbx`](https://github.com/t8y2/dbx) | Yes |
|
||||
| A bug in a specific third-party plugin | That plugin's source repository | Do not submit it to `dbx-store` |
|
||||
| A custom or enterprise private repository | Your private repository | No official Marketplace submission is required |
|
||||
| A vendor JDBC driver JAR | Do not commit it to either repository | Users import it locally or on the server |
|
||||
| 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` |
|
||||
| Official examples or plugin-development documentation | [`t8y2/dbx`](https://github.com/t8y2/dbx) | Yes |
|
||||
| A bug in a specific third-party plugin | That plugin's source repository | Do not submit it to `dbx-store` |
|
||||
| A custom or enterprise private repository | Your private repository | No official Marketplace submission is required |
|
||||
| A vendor JDBC driver JAR | Do not commit it to either repository | Users import it locally or on the server |
|
||||
|
||||
DBX-maintained plugins that need an independent release cadence should normally use separate source repositories as well. Only tightly coupled host examples, SDK verification projects, or components explicitly accepted by maintainers belong in `t8y2/dbx`.
|
||||
|
||||
@@ -84,31 +85,29 @@ 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. |
|
||||
| `name` | Yes | Default display name. |
|
||||
| `icon` | No | Relative path to the default package icon; SVG or PNG is recommended. |
|
||||
| `version` | Yes | Semantic version `major.minor.patch`, optionally with prerelease/build suffixes. Official packages cannot overwrite the same ID and version. |
|
||||
| `publisher` | Yes | Publisher identifier; keep it stable after the first listing. |
|
||||
| `description` | No | Plugin description. |
|
||||
| `source` | No | HTTPS source repository URL, also shown from installed-plugin details. |
|
||||
| `homepage` | No | Project, documentation, or support URL. |
|
||||
| `engines` | Yes | Compatibility declaration; it must contain `host_api`. |
|
||||
| `permissions` | No | Minimal capability set, described below. |
|
||||
| `entrypoints` | No | UI and native-sidecar entrypoints. |
|
||||
| `contributions` | No | Connection, workbench, filesystem, connection-menu, result-view, command/menu, and MCP tool-surface declarations. |
|
||||
| `localizations` | No | Locale-specific names, descriptions, fields, and action labels. |
|
||||
| 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. |
|
||||
| `name` | Yes | Default display name. |
|
||||
| `icon` | No | Relative path to the default package icon; SVG or PNG is recommended. |
|
||||
| `version` | Yes | Semantic version `major.minor.patch`, optionally with prerelease/build suffixes. Official packages cannot overwrite the same ID and version. |
|
||||
| `publisher` | Yes | Publisher identifier; keep it stable after the first listing. |
|
||||
| `description` | No | Plugin description. |
|
||||
| `source` | No | HTTPS source repository URL, also shown from installed-plugin details. |
|
||||
| `homepage` | No | Project, documentation, or support URL. |
|
||||
| `engines` | Yes | Compatibility declaration; it must contain `host_api`. |
|
||||
| `permissions` | No | Minimal capability set, described below. |
|
||||
| `entrypoints` | No | UI and native-sidecar entrypoints. |
|
||||
| `contributions` | No | Connection, workbench, filesystem, connection-menu, result-view, command/menu, and MCP tool-surface declarations. |
|
||||
| `localizations` | No | Locale-specific names, descriptions, fields, and action labels. |
|
||||
|
||||
### Engines, Permissions, and Entrypoints
|
||||
|
||||
@@ -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 }]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -236,12 +242,12 @@ 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. |
|
||||
| `result-view` | `type`, `id`, `label` | Registers a query or task result view. |
|
||||
| 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. |
|
||||
| `result-view` | `type`, `id`, `label` | Registers a query or task result view. |
|
||||
|
||||
A `menu: "table"` contribution is currently exposed when the user opens a context menu on a concrete table node in the Sidebar Tree. A legacy contribution without `action` invokes the required backend entrypoint as `contextMenu/<contribution-id>` with the table identity:
|
||||
|
||||
@@ -282,14 +288,14 @@ 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`. |
|
||||
| `filesystem/createDirectory` | `uri`; requires `mkdir`. |
|
||||
| `filesystem/delete` | `uri`, `recursive`; requires `delete`. |
|
||||
| `filesystem/rename` | `sourceUri`, `targetUri`, `overwrite`; requires `rename`. |
|
||||
| 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`. |
|
||||
| `filesystem/createDirectory` | `uri`; requires `mkdir`. |
|
||||
| `filesystem/delete` | `uri`, `recursive`; requires `delete`. |
|
||||
| `filesystem/rename` | `sourceUri`, `targetUri`, `overwrite`; requires `rename`. |
|
||||
|
||||
Every method carries `providerId` and optional `connectionId`. Entries contain a single filename `name`, full `uri`, `kind` (`file`, `directory`, `symlink`, or `other`), and optional `size`, `modifiedAt`, and `contentType`. Names cannot include `/` or `\\`, or equal `.`/`..`; put full paths in `uri`. Omit `nextCursor` on the last page and never repeat a consumed cursor. Image previews need the actual MIME type and base64 bytes, not UTF-8-decoded binary content.
|
||||
|
||||
@@ -301,18 +307,18 @@ Use locale keys such as `zh-CN` and `en` in `localizations`. You can override th
|
||||
|
||||
```json
|
||||
{
|
||||
"localizations": {
|
||||
"zh-CN": {
|
||||
"name": "示例文件",
|
||||
"contributions": {
|
||||
"com.example.files.connection": {
|
||||
"label": "示例服务",
|
||||
"fields": { "token": { "label": "访问令牌" } }
|
||||
"localizations": {
|
||||
"zh-CN": {
|
||||
"name": "示例文件",
|
||||
"contributions": {
|
||||
"com.example.files.connection": {
|
||||
"label": "示例服务",
|
||||
"fields": { "token": { "label": "访问令牌" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Workbench Context Contract
|
||||
@@ -352,35 +358,37 @@ await window.dbxPlugin.notify("objects/changed", { count: result.items.length })
|
||||
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. |
|
||||
| `request(method, params)` | Calls a Host API method, including `host.getContext` and `ui.readAsset`. |
|
||||
| `invoke(method, params, { timeoutMs })` | Sends an RPC request to the plugin's Sidecar. |
|
||||
| `notify(method, params)` | Sends a notification without a business result. |
|
||||
| `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`. |
|
||||
| `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`. |
|
||||
| `getPlanCapabilities(connectionId)` | Reads what the host and this connection can plan; requires `host.plans:read`. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
| `onInit(fn)` / `onEvent(fn)` | Observes initialization, environment changes, or backend events; forwarding backend events requires `host.events`. |
|
||||
| `storage` | Persistent per-plugin key-value state; requires `host.storage`; see below. |
|
||||
| `fileTransfer` | Streams local files, downloads, and (desktop) OS drops; see below. |
|
||||
| `clipboard` | System clipboard access from the sandbox; see below. |
|
||||
| `floating` | Opens and drives a floating desktop widget window (desktop hosts only); see [Floating Windows](#floating-windows). |
|
||||
| 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. |
|
||||
| `request(method, params)` | Calls a Host API method, including `host.getContext` and `ui.readAsset`. |
|
||||
| `invoke(method, params, { timeoutMs })` | Sends an RPC request to the plugin's Sidecar. |
|
||||
| `notify(method, params)` | Sends a notification without a business result. |
|
||||
| `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`. |
|
||||
| `getPlanCapabilities(connectionId)` | Reads what the host and this connection can plan; requires `host.plans:read`. |
|
||||
| `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, 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`. |
|
||||
| `onInit(fn)` / `onEvent(fn)` | Observes initialization, environment changes, or backend events; forwarding backend events requires `host.events`. |
|
||||
| `storage` | Persistent per-plugin key-value state; requires `host.storage`; see below. |
|
||||
| `fileTransfer` | Streams local files, downloads, and (desktop) OS drops; see below. |
|
||||
| `clipboard` | System clipboard access from the sandbox; see below. |
|
||||
| `floating` | Opens and drives a floating desktop widget window (desktop hosts only); see [Floating Windows](#floating-windows). |
|
||||
|
||||
Failed calls reject their Promise; show a useful error and offer retry. Host methods, parameters, and results are versioned API surface—do not call undocumented DBX internals.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -615,15 +629,15 @@ const offDrop = fileTransfer.onDrop((files) => uploadAll(files));
|
||||
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. |
|
||||
| `write(handleId, offset, data)` | Writes one chunk (transferred binary or base64); resolves `{ written, nextOffset }`. |
|
||||
| `finish(handleId)` / `cancel(handleId)` | Flush-closes a write handle / discards any handle. |
|
||||
| `onDrop(fn)` | Files dropped onto this workbench arrive as read handles. A dropped folder is expanded into the regular files it contains (dotfiles and dot-directories like `.git` are skipped); each expanded file carries `relativePath` — its '/'-separated path starting with the dropped folder's own name (`folder/nested/a.csv`) — so the plugin can rebuild the dragged structure and tell same-named files under different dropped roots apart. Listeners receive `(files, { dropId, truncated })`: `dropId` groups this drop's entries (cancellation and progress bookkeeping), `truncated` is `true` when the 2000-file or 8-depth expansion cap cut the delivery short. |
|
||||
| `onDragState(fn)` | Whether an OS drag is currently over this workbench; drive drop overlays with it. |
|
||||
| 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. |
|
||||
| `write(handleId, offset, data)` | Writes one chunk (transferred binary or base64); resolves `{ written, nextOffset }`. |
|
||||
| `finish(handleId)` / `cancel(handleId)` | Flush-closes a write handle / discards any handle. |
|
||||
| `onDrop(fn)` | Files dropped onto this workbench arrive as read handles. A dropped folder is expanded into the regular files it contains (dotfiles and dot-directories like `.git` are skipped); each expanded file carries `relativePath` — its '/'-separated path starting with the dropped folder's own name (`folder/nested/a.csv`) — so the plugin can rebuild the dragged structure and tell same-named files under different dropped roots apart. Listeners receive `(files, { dropId, truncated })`: `dropId` groups this drop's entries (cancellation and progress bookkeeping), `truncated` is `true` when the 2000-file or 8-depth expansion cap cut the delivery short. |
|
||||
| `onDragState(fn)` | Whether an OS drag is currently over this workbench; drive drop overlays with it. |
|
||||
|
||||
Desktop and web hosts differ in what the namespace can do — gate on the additive `capabilities.fileTransfer` flags (`pick`, `beginSave`, `read`, `drop`, `folderExpansion`) instead of probing calls. On desktop, `pick` / `beginSave` use native dialogs and `onDrop` receives OS drops; dropped entries are opened lazily and streamed in chunks, so very large folders and multi-gigabyte files never exhaust handles or memory, and picking more files than the shared handle registry holds downgrades them to lazily-opened entries instead of dropping any. On the web host, `pick` falls back to a browser file input, `beginSave` buffers in memory and downloads through the browser, `onDrop` / `onDragState` never fire (`drop`/`folderExpansion` are `false` there), and handles carry no `relativePath`. `onDrop` only fires for drops that land inside this plugin's workbench area; drops elsewhere keep the host's own behavior.
|
||||
|
||||
@@ -640,11 +654,11 @@ 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. |
|
||||
| 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. |
|
||||
|
||||
Keys are strings up to 256 characters; values are capped at 256 KiB serialized and the whole store at 1 MiB — this is UI state, not a data sink. Entries live in the plugin's own `plugin-data/<id>` directory, isolated per plugin, and survive upgrades and uninstall. Bulk data, caches, and anything the sidecar produces belong in the sidecar's data directory instead (see [Native Sidecars and RPC](#native-sidecars-and-rpc)). On the web host the same API is backed by the browser profile, so identical code works in both hosts.
|
||||
|
||||
@@ -763,15 +777,15 @@ 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. |
|
||||
| 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:
|
||||
|
||||
@@ -804,16 +818,16 @@ 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 |
|
||||
| UI font family | `--font-sans` token |
|
||||
| Editor (mono) font family | `--font-mono` token + `theme.editor.fontFamily` |
|
||||
| Editor font size | `theme.editor.fontSize` |
|
||||
| SQL editor syntax theme | `theme.editor.theme` |
|
||||
| UI scale (host window zoom) | not delivered — plugins own their own zoom |
|
||||
| DBX setting | Delivered as |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| Light/dark/system mode | `theme.appearance` |
|
||||
| Palette + custom color sets | `--color-*` tokens |
|
||||
| Corner style | `--radius-*` tokens |
|
||||
| UI font family | `--font-sans` token |
|
||||
| Editor (mono) font family | `--font-mono` token + `theme.editor.fontFamily` |
|
||||
| Editor font size | `theme.editor.fontSize` |
|
||||
| SQL editor syntax theme | `theme.editor.theme` |
|
||||
| UI scale (host window zoom) | not delivered — plugins own their own zoom |
|
||||
| Data-grid font / type colors, editor background image | not delivered — DB-domain (`--dbx-*` tokens are excluded from the sweep) |
|
||||
|
||||
Treat `terminal.fontSize`-style concerns as plugin-owned: the host never dictates your font size; consume `theme.editor.fontSize` as a default only if it suits your UI. All payload changes push live on the env channel — font-family edits via the theme revision, size/theme via the explicit editor watcher — so no refresh is needed.
|
||||
@@ -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": [{
|
||||
"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
|
||||
}]
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+8
-10
@@ -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,
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -683,10 +681,10 @@ 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. |
|
||||
| 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. |
|
||||
|
||||
`host.getPlanCapabilities` returns:
|
||||
|
||||
|
||||
@@ -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", {}),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user