mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +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,8 +4163,31 @@ export const useQueryStore = defineStore("query", () => {
|
||||
if (!localizedTitle) continue;
|
||||
const suffix = / \((\d+)\)$/.exec(tab.title)?.[0] || "";
|
||||
tab.title = `${localizedTitle}${suffix}`;
|
||||
if (tab.pluginWorkbench) {
|
||||
tab.pluginWorkbench.defaultTitle = tab.title;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setPluginWorkbenchTabTitle(id: string, title: string | null): boolean {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
if (!tab || tab.mode !== "plugin-workbench" || !tab.pluginWorkbench) return false;
|
||||
const trimmed = typeof title === "string" ? title.trim().slice(0, 256) : "";
|
||||
if (trimmed) {
|
||||
tab.title = trimmed;
|
||||
tab.customTitle = true;
|
||||
} else {
|
||||
tab.title = tab.pluginWorkbench.defaultTitle || tab.pluginWorkbench.contributionId;
|
||||
tab.customTitle = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getPluginWorkbenchTabTitle(id: string): string | undefined {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
if (!tab || tab.mode !== "plugin-workbench") return undefined;
|
||||
return tab.title;
|
||||
}
|
||||
|
||||
function openPluginFilesystem(pluginId: string, providerId: string, options: { title?: string; connectionId?: string; rootUri?: string; currentUri?: string; forceNew?: boolean } = {}) {
|
||||
if (!options.forceNew) {
|
||||
@@ -10722,6 +10747,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
openMqttAdmin,
|
||||
openNacosAdmin,
|
||||
openPluginWorkbench,
|
||||
setPluginWorkbenchTabTitle,
|
||||
getPluginWorkbenchTabTitle,
|
||||
pluginWorkbenchTitles,
|
||||
localizePluginTabTitles,
|
||||
openPluginFilesystem,
|
||||
|
||||
@@ -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。
|
||||
@@ -34,7 +32,7 @@ DBX 插件是独立安装的 `.dbxp` 包,可以提供连接类型、工作台
|
||||
## 应该提交到哪个仓库
|
||||
|
||||
| 你要修改的内容 | 提交位置 | 是否向 DBX 官方提 PR |
|
||||
| --- | --- | --- |
|
||||
| -------------------------------------------------------------- | ----------------------------------------------------- | --------------------------------------- |
|
||||
| 你的插件前端、Rust/Go 后端、测试和发布脚本 | 插件自己的源码仓库 | 不向 `dbx` 提交;在自己的仓库开发和发布 |
|
||||
| 新插件上架、版本更新、商店图标和介绍 | [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store) | 是,目标分支为 `main` |
|
||||
| DBX 插件 Host、Manifest/Marketplace Schema、SDK、CLI、Packager | [`t8y2/dbx`](https://github.com/t8y2/dbx) | 是,目标分支为 `main` |
|
||||
@@ -84,16 +82,14 @@ my-plugin/
|
||||
"engines": { "host_api": "1" },
|
||||
"permissions": [],
|
||||
"entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } },
|
||||
"contributions": [
|
||||
{ "type": "workbench", "id": "com.example.files.main", "label": "Files", "icon": "assets/plugin.svg" }
|
||||
]
|
||||
"contributions": [{ "type": "workbench", "id": "com.example.files.main", "label": "Files", "icon": "assets/plugin.svg" }]
|
||||
}
|
||||
```
|
||||
|
||||
Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含 `..`、反斜杠或重复斜杠。`ui.entry` 必须位于 `ui.root` 内:当 root 为 `ui` 时,入口写 `ui/index.html`,不是 `index.html`。`signingKeyId` 属于商店 artifact metadata,不是 Manifest 字段。
|
||||
|
||||
| 字段 | 必需 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| ------------------ | ---- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `$schema` | 否 | 编辑器使用的 JSON Schema 地址,不参与身份校验。 |
|
||||
| `manifest_version` | 是 | 当前必须为数字 `1`。 |
|
||||
| `id` | 是 | 全局稳定 ID,只能使用小写字母、数字、`.`, `_`, `-`,例如 `io.github.example.files`;发布后不要更改。 |
|
||||
@@ -168,14 +164,21 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含
|
||||
{ "key": "display_name", "label": "Name", "type": "text", "binding": "name", "required": true },
|
||||
{ "key": "endpoint", "label": "Endpoint", "type": "text", "binding": "host", "required": true },
|
||||
{ "key": "port", "label": "Port", "type": "number", "binding": "port", "default": 443 },
|
||||
{ "key": "region", "label": "Region", "type": "radio", "options": [{ "label": "US", "value": "us" }, { "label": "EU", "value": "eu" }], "binding": "config" },
|
||||
{
|
||||
"key": "region",
|
||||
"label": "Region",
|
||||
"type": "radio",
|
||||
"options": [
|
||||
{ "label": "US", "value": "us" },
|
||||
{ "label": "EU", "value": "eu" }
|
||||
],
|
||||
"binding": "config"
|
||||
},
|
||||
{ "key": "token", "label": "Access token", "type": "password", "binding": "secret", "required": true }
|
||||
],
|
||||
"workbench": "com.example.files.main",
|
||||
"capabilities": ["test", "connect", "disconnect"],
|
||||
"actions": [
|
||||
{ "id": "refresh", "label": "Refresh metadata", "variant": "outline", "when": "edit", "requires_valid_form": true, "timeout_ms": 30000 }
|
||||
]
|
||||
"actions": [{ "id": "refresh", "label": "Refresh metadata", "variant": "outline", "when": "edit", "requires_valid_form": true, "timeout_ms": 30000 }]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -237,7 +240,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含
|
||||
### 其他 Contributions
|
||||
|
||||
| 类型 | 必需字段 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| --------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `workbench` | `type`, `id`, `label` | 注册可从侧边栏或插件入口打开的工作台。 |
|
||||
| `filesystem-provider` | `type`, `id`, `label`, `schemes` | 注册虚拟文件系统;`root_uri` 例如 `s3://bucket`,能力可选 `read`、`write`、`delete`、`rename`、`mkdir`。 |
|
||||
| `context-menu` | `type`、`id`、`label`、`menu: "connection"` 或 `"table"`、可选 `action` | 向 Sidebar Tree 的连接或 table 右键菜单增加后端动作,或打开已声明的 Workbench。 |
|
||||
@@ -283,7 +286,7 @@ context-menu 也可以声明由 Host 处理的 Workbench action:
|
||||
使用 DBX 通用文件管理器时,后端实现以下方法;自定义工作台也可以自行通过 RPC 使用这些方法,但不能把声明 Contribution 当成已经实现业务逻辑。
|
||||
|
||||
| 方法 | 参数与返回 |
|
||||
| --- | --- |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `filesystem/list` | `providerId`、可选 `connectionId`、`uri`、可选 `cursor`、`limit`;返回 `{ entries, nextCursor? }`。 |
|
||||
| `filesystem/read` | 同上身份字段、`uri`、`maxBytes`;返回 `{ dataBase64, contentType?, truncated, etag? }`。 |
|
||||
| `filesystem/write` | `uri`、`dataBase64`、`create`、`overwrite`、可选 `etag`;需声明 `write`。 |
|
||||
@@ -353,7 +356,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
|
||||
```
|
||||
|
||||
| API | 作用 |
|
||||
| --- | --- |
|
||||
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ready` | 等待 Host 完成初始化;应用启动逻辑放在 `await dbxPlugin.ready` 之后。 |
|
||||
| `contributionId` | Host 打开的 contribution 的只读 identity;`await ready` 后可读取,且与 `context` 独立。 |
|
||||
| `context` / `onContext(fn)` | 读取或监听当前工作台上下文。 |
|
||||
@@ -365,6 +368,8 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
|
||||
| `sendBinary(channel, data)` / `onBinary(fn)` | 发送或接收二进制数据;需要 `stdio-framed` 和 `host.binary`。 |
|
||||
| `readAsset(path)` / `readAssetUrl(path)` | 读取包内资源;路径不能越出插件资源根目录。 |
|
||||
| `openWorkbench(id, context)` | 打开本插件的另一个工作台;需要 `host.workbench`。 |
|
||||
| `setTitle(title)` | 修改当前工作台页签标题(传入 `null` 恢复默认);需要 `host.workbench`。 |
|
||||
| `getTitle()` | 读取当前工作台页签标题;需要 `host.workbench`。 |
|
||||
| `closeWorkbench()` | 关闭承载当前 UI 的表面——工作台标签、dock 面板条目,或本插件的浮动窗口。与 `Ctrl/Cmd+W` 快捷键走同一条路径,因此插件可以提供自己的关闭或最小化按钮。 |
|
||||
| `executeCommand(commandId, context?)` | 执行本插件自己声明的命令,与菜单执行完全同径——重新检查 `enablement`、按单例复用,并遵循声明的 `presentation`(panel 停靠底部面板,tab 打开工作台标签页)。`context` 合并到命令上下文之上,并解析 `instance_key` 的 `{{path}}` 占位符(如 `instance_key: "logs:{{connectionId}}"` 即每个连接一个面板实例)。预期结果通过 `{ error }` 返回;需要 `host.workbench`。 |
|
||||
| `openFilesystem(id, context)` | 打开本插件的文件系统入口;需要 `host.filesystem`。 |
|
||||
@@ -372,7 +377,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
|
||||
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | 返回 `sql` 的估算执行计划;需要 `host.plans:read`。 |
|
||||
| `getTableMetadata({ connectionId, database?, schema?, table })` | 读取已打开连接的窄化 table schema metadata;需要 `host.schema:read`。 |
|
||||
| `queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` | 在用户授权给插件的连接上执行一条只读 SQL;需要 `host.data:read`。 |
|
||||
| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiRecommendations, clipboardWrite, clipboardRead, floating }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 |
|
||||
| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiRecommendations, clipboardWrite, clipboardRead, floating, workbenchTitle }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 |
|
||||
| `ai.openConversation({ title, prompt, context, send?, mode? })` | 在内置 AI 面板新建插件对话;`send` 默认 `false`,`mode` 默认 `ask`。`mode: "agent"` 会在 context 包含已打开插件连接时使用实时插件工具;需要 `host.ai`,并以 `capabilities.ai` 广播。 |
|
||||
| `ai.setRecommendations({ context, items })` | 覆盖当前工作台的默认推荐问题并立即刷新全局 AI 面板;每次最多 5 条,支持 `{{path.to.value}}` 占位符;需要 `host.ai` 和 `capabilities.aiRecommendations`。 |
|
||||
| `ai.clearRecommendations()` | 清空当前工作台的推荐问题;需要 `host.ai` 和 `capabilities.aiRecommendations`。 |
|
||||
@@ -446,7 +451,7 @@ if (window.dbxPlugin.capabilities.schemaMetadataApi) {
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
table: "users"
|
||||
table: "users",
|
||||
});
|
||||
for (const column of metadata.columns) {
|
||||
renderColumn(column.name, column.dataType, column.nullable);
|
||||
@@ -500,7 +505,7 @@ if (window.dbxPlugin.capabilities.dataApi) {
|
||||
connectionId,
|
||||
database,
|
||||
sql: "SELECT status, count(*) AS total FROM orders GROUP BY status",
|
||||
maxRows: 200
|
||||
maxRows: 200,
|
||||
});
|
||||
renderChart(result.columns, result.rows);
|
||||
}
|
||||
@@ -511,8 +516,14 @@ if (window.dbxPlugin.capabilities.dataApi) {
|
||||
```json
|
||||
{
|
||||
"dbType": "postgres",
|
||||
"columns": [{ "name": "status", "dataType": "text" }, { "name": "total", "dataType": "int8" }],
|
||||
"rows": [["paid", 1204], ["refunded", 37]],
|
||||
"columns": [
|
||||
{ "name": "status", "dataType": "text" },
|
||||
{ "name": "total", "dataType": "int8" }
|
||||
],
|
||||
"rows": [
|
||||
["paid", 1204],
|
||||
["refunded", 37]
|
||||
],
|
||||
"truncated": false,
|
||||
"elapsedMs": 12
|
||||
}
|
||||
@@ -616,7 +627,7 @@ const offDrag = fileTransfer.onDragState((active) => showDropOverlay(active));
|
||||
```
|
||||
|
||||
| API | 用途 |
|
||||
| --- | --- |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `pick({ multiple })` | 原生打开对话框;返回已打开的读句柄 `{ handleId, name, size, contentType }`。 |
|
||||
| `read(handleId, offset, length?)` | 从读句柄流式读取一个分块 `{ dataBase64, length, eof }`。 |
|
||||
| `beginSave({ name, contentType?, size? })` | 原生保存对话框 + 写句柄;返回 `{ handleId, chunkBytes }`,用户取消时为 `null`。 |
|
||||
@@ -641,7 +652,7 @@ if (window.dbxPlugin.capabilities.storage) {
|
||||
```
|
||||
|
||||
| API | 用途 |
|
||||
| --- | --- |
|
||||
| ----------------- | --------------------------------------------------- |
|
||||
| `get(key)` | 返回存储的 JSON 值;键未设置时返回 `null`。 |
|
||||
| `set(key, value)` | 持久化任意 JSON 值(`undefined` 归一化为 `null`)。 |
|
||||
| `delete(key)` | 删除一个键;删除不存在的键同样成功。 |
|
||||
@@ -764,7 +775,7 @@ grip.addEventListener("pointerup", async () => {
|
||||
```
|
||||
|
||||
| API | 作用 |
|
||||
| --- | --- |
|
||||
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `floating.open(options)` | 为本插件的某个 `workbench` contribution 打开(或前置)浮动窗口。`options`:`contributionId`(必填)、`title`、逻辑像素的 `width`/`height`、物理屏幕像素的 `x`/`y`、`alwaysOnTop`(默认 `true`)、`skipTaskbar`(默认 `true`)、`resizable`(默认 `false`)、`context`。返回 `{ windowId, reused }`;需要 `host.workbench`。 |
|
||||
| `floating.close(contributionId?)` | 关闭本插件的浮动窗口——指定某个 contribution,或省略 id 关闭本插件的全部浮动窗口。在小组件内部不带参数调用即关闭自己。 |
|
||||
| `floating.beginDrag()` | 把窗口交给宿主的拖动循环。在 `pointermove` 里、自己的拖动阈值越过之后调用(此时按键还按住)。之后由宿主自己读原生光标,插件没有逐帧调用要做。 |
|
||||
@@ -805,7 +816,7 @@ button {
|
||||
env 消息的 `theme` 结构为 `{ appearance, tokens, editor? }`。`appearance` 是 `"light" | "dark"`;`tokens` 是按前缀扫取的根节点设计变量(`--color-*`、`--radius-*`、`--font-*`);`editor` 是无 CSS 令牌载体的 SQL 编辑器设置结构化快照。投递矩阵:
|
||||
|
||||
| DBX 设置 | 投递形式 |
|
||||
| --- | --- |
|
||||
| ----------------------------------- | ----------------------------------------------- |
|
||||
| 浅色/深色/跟随系统 | `theme.appearance` |
|
||||
| 调色板 + 自定义配色 | `--color-*` tokens |
|
||||
| 圆角风格 | `--radius-*` tokens |
|
||||
@@ -1075,12 +1086,14 @@ candidates/<plugin-id>.json
|
||||
"name": "Example Files",
|
||||
"source": "https://github.com/example/dbx-plugin-files/tree/v0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"targets": [{
|
||||
"targets": [
|
||||
{
|
||||
"target": "universal",
|
||||
"url": "https://github.com/example/dbx-plugin-files/releases/download/v0.1.0/com.example.files-0.1.0-universal.dbxp",
|
||||
"sha256": "<64 位十六进制 SHA-256>",
|
||||
"size": 123456
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ DBX plugins are independently installed `.dbxp` packages that can contribute con
|
||||
</Callout>
|
||||
|
||||
<Callout type="warn">
|
||||
**Marketplace listing pull requests go to [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store), not `t8y2/dbx`.** A plugin's feature code normally remains in its author's source repository. Open a PR against [`t8y2/dbx`](https://github.com/t8y2/dbx) only when changing the DBX host, SDK, CLI, protocol, schemas, documentation, or official examples.
|
||||
**Marketplace listing pull requests go to [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store), not `t8y2/dbx`.** A plugin's feature code normally remains in its author's source repository. Open a PR against [`t8y2/dbx`](https://github.com/t8y2/dbx) only when changing the DBX host, SDK, CLI,
|
||||
protocol, schemas, documentation, or official examples.
|
||||
</Callout>
|
||||
|
||||
## Plugin Shortcuts
|
||||
@@ -34,7 +35,7 @@ Position affects icons only: content retains its existing `panel/tab` presentati
|
||||
## Which Repository Receives the Change
|
||||
|
||||
| Change | Destination | Official DBX PR? |
|
||||
| --- | --- | --- |
|
||||
| -------------------------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| Your plugin frontend, Rust/Go backend, tests, and release scripts | The plugin's source repository | Do not submit it to `dbx`; develop and release it in its own repository |
|
||||
| A new Marketplace listing, version update, icon, or description | [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store) | Yes, target `main` |
|
||||
| DBX plugin host, Manifest/Marketplace schemas, SDK, CLI, or packager | [`t8y2/dbx`](https://github.com/t8y2/dbx) | Yes, target `main` |
|
||||
@@ -84,16 +85,14 @@ my-plugin/
|
||||
"engines": { "host_api": "1" },
|
||||
"permissions": [],
|
||||
"entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } },
|
||||
"contributions": [
|
||||
{ "type": "workbench", "id": "com.example.files.main", "label": "Files", "icon": "assets/plugin.svg" }
|
||||
]
|
||||
"contributions": [{ "type": "workbench", "id": "com.example.files.main", "label": "Files", "icon": "assets/plugin.svg" }]
|
||||
}
|
||||
```
|
||||
|
||||
Manifest paths are relative to the package root. They cannot start with `/`, contain `..`, backslashes, or duplicate slashes. `ui.entry` must be inside `ui.root`: with root `ui`, use `ui/index.html`, not `index.html`. `signingKeyId` belongs to Marketplace artifact metadata, not the Manifest.
|
||||
|
||||
| Field | Required | Meaning |
|
||||
| --- | --- | --- |
|
||||
| ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `$schema` | No | JSON Schema URL for editor support; it does not establish identity. |
|
||||
| `manifest_version` | Yes | Must currently be the number `1`. |
|
||||
| `id` | Yes | Stable global ID using lowercase letters, digits, `.`, `_`, and `-`, such as `io.github.example.files`; never change it after publishing. |
|
||||
@@ -168,14 +167,21 @@ A connection provider declares a form; DBX renders the form and owns the connect
|
||||
{ "key": "display_name", "label": "Name", "type": "text", "binding": "name", "required": true },
|
||||
{ "key": "endpoint", "label": "Endpoint", "type": "text", "binding": "host", "required": true },
|
||||
{ "key": "port", "label": "Port", "type": "number", "binding": "port", "default": 443 },
|
||||
{ "key": "region", "label": "Region", "type": "radio", "options": [{ "label": "US", "value": "us" }, { "label": "EU", "value": "eu" }], "binding": "config" },
|
||||
{
|
||||
"key": "region",
|
||||
"label": "Region",
|
||||
"type": "radio",
|
||||
"options": [
|
||||
{ "label": "US", "value": "us" },
|
||||
{ "label": "EU", "value": "eu" }
|
||||
],
|
||||
"binding": "config"
|
||||
},
|
||||
{ "key": "token", "label": "Access token", "type": "password", "binding": "secret", "required": true }
|
||||
],
|
||||
"workbench": "com.example.files.main",
|
||||
"capabilities": ["test", "connect", "disconnect"],
|
||||
"actions": [
|
||||
{ "id": "refresh", "label": "Refresh metadata", "variant": "outline", "when": "edit", "requires_valid_form": true, "timeout_ms": 30000 }
|
||||
]
|
||||
"actions": [{ "id": "refresh", "label": "Refresh metadata", "variant": "outline", "when": "edit", "requires_valid_form": true, "timeout_ms": 30000 }]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -237,7 +243,7 @@ Action `when` accepts `always`, `create`, or `edit`; `variant` accepts `default`
|
||||
### Other Contributions
|
||||
|
||||
| Type | Required fields | Purpose |
|
||||
| --- | --- | --- |
|
||||
| --------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `workbench` | `type`, `id`, `label` | Registers a workbench opened from the sidebar or plugin entry. |
|
||||
| `filesystem-provider` | `type`, `id`, `label`, `schemes` | Registers a virtual filesystem; `root_uri` can be `s3://bucket`, with optional `read`, `write`, `delete`, `rename`, and `mkdir` capabilities. |
|
||||
| `context-menu` | `type`, `id`, `label`, `menu: "connection"` or `"table"`, optional `action` | Adds a backend action or opens a declared Workbench from a Sidebar Tree context menu. |
|
||||
@@ -283,7 +289,7 @@ Choose `workbench` for a custom file browser: the plugin owns its list, virtuali
|
||||
Implement these backend methods when using the DBX generic file manager. A custom workbench can use them through its own RPC calls as well; declaring a contribution does not implement business logic.
|
||||
|
||||
| Method | Parameters and return values |
|
||||
| --- | --- |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `filesystem/list` | `providerId`, optional `connectionId`, `uri`, optional `cursor`, `limit`; returns `{ entries, nextCursor? }`. |
|
||||
| `filesystem/read` | The same identity fields, `uri`, `maxBytes`; returns `{ dataBase64, contentType?, truncated, etag? }`. |
|
||||
| `filesystem/write` | `uri`, `dataBase64`, `create`, `overwrite`, optional `etag`; requires `write`. |
|
||||
@@ -353,7 +359,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
|
||||
```
|
||||
|
||||
| API | Purpose |
|
||||
| --- | --- |
|
||||
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `ready` | Waits for Host initialization; start application logic after `await dbxPlugin.ready`. |
|
||||
| `contributionId` | Read-only identity of the contribution opened by the Host; available after `await ready` and independent of `context`. |
|
||||
| `context` / `onContext(fn)` | Reads or observes the current workbench context. |
|
||||
@@ -365,6 +371,8 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
|
||||
| `sendBinary(channel, data)` / `onBinary(fn)` | Sends or receives binary data; requires framed transport and `host.binary`. |
|
||||
| `readAsset(path)` / `readAssetUrl(path)` | Reads a packaged asset within the plugin resource root. |
|
||||
| `openWorkbench(id, context)` | Opens another workbench from this plugin; requires `host.workbench`. |
|
||||
| `setTitle(title)` | Updates the current workbench tab title (pass `null` to reset to default); requires `host.workbench`. |
|
||||
| `getTitle()` | Reads the current workbench tab title; requires `host.workbench`. |
|
||||
| `closeWorkbench()` | Closes the surface hosting this UI — a workbench tab, a dock entry, or this plugin's floating window. It is the same path as the `Ctrl/Cmd+W` shortcut, so a plugin can offer its own close or minimize affordance. |
|
||||
| `executeCommand(commandId, context?)` | Executes one of this plugin's own declared commands through the same path as a menu placement — re-checks `enablement`, applies singleton reuse, and follows the declared `presentation` (panel commands dock, tab commands open tabs). `context` merges over the command context and scopes `instance_key` `{{path}}` placeholders, so `instance_key: "logs:{{connectionId}}"` yields one panel instance per connection. Resolves `{ error }` for expected outcomes; requires `host.workbench`. |
|
||||
| `openFilesystem(id, context)` | Opens a filesystem entry; requires `host.filesystem`. |
|
||||
@@ -372,7 +380,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
|
||||
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | Returns the estimated plan for `sql`; requires `host.plans:read`. |
|
||||
| `getTableMetadata({ connectionId, database?, schema?, table })` | Reads narrow table schema metadata from an already-open connection; requires `host.schema:read`. |
|
||||
| `queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` | Runs one read-only SQL statement on a connection the user granted to the plugin; requires `host.data:read`. |
|
||||
| `capabilities` | `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiRecommendations, clipboardWrite, clipboardRead, floating }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. |
|
||||
| `capabilities` | `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiRecommendations, clipboardWrite, clipboardRead, floating, workbenchTitle }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. |
|
||||
| `ai.openConversation({ title, prompt, context, send?, mode? })` | Opens a new plugin conversation in the built-in AI panel; `send` defaults to `false` and `mode` defaults to `ask`. `mode: "agent"` uses live plugin tools when context includes an open plugin connection; requires `host.ai` and is advertised as `capabilities.ai`. |
|
||||
| `ai.setRecommendations({ context, items })` | Replaces the current workbench's default recommendations and refreshes the global AI panel; up to five items are shown and `{{path.to.value}}` placeholders are supported; requires `host.ai` and `capabilities.aiRecommendations`. |
|
||||
| `ai.clearRecommendations()` | Clears recommendations for the current workbench; requires `host.ai` and `capabilities.aiRecommendations`. |
|
||||
@@ -446,7 +454,7 @@ if (window.dbxPlugin.capabilities.schemaMetadataApi) {
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
table: "users"
|
||||
table: "users",
|
||||
});
|
||||
for (const column of metadata.columns) {
|
||||
renderColumn(column.name, column.dataType, column.nullable);
|
||||
@@ -500,7 +508,7 @@ if (window.dbxPlugin.capabilities.dataApi) {
|
||||
connectionId,
|
||||
database,
|
||||
sql: "SELECT status, count(*) AS total FROM orders GROUP BY status",
|
||||
maxRows: 200
|
||||
maxRows: 200,
|
||||
});
|
||||
renderChart(result.columns, result.rows);
|
||||
}
|
||||
@@ -511,8 +519,14 @@ if (window.dbxPlugin.capabilities.dataApi) {
|
||||
```json
|
||||
{
|
||||
"dbType": "postgres",
|
||||
"columns": [{ "name": "status", "dataType": "text" }, { "name": "total", "dataType": "int8" }],
|
||||
"rows": [["paid", 1204], ["refunded", 37]],
|
||||
"columns": [
|
||||
{ "name": "status", "dataType": "text" },
|
||||
{ "name": "total", "dataType": "int8" }
|
||||
],
|
||||
"rows": [
|
||||
["paid", 1204],
|
||||
["refunded", 37]
|
||||
],
|
||||
"truncated": false,
|
||||
"elapsedMs": 12
|
||||
}
|
||||
@@ -616,7 +630,7 @@ const offDrag = fileTransfer.onDragState((active) => showDropOverlay(active));
|
||||
```
|
||||
|
||||
| API | Purpose |
|
||||
| --- | --- |
|
||||
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `pick({ multiple })` | Native open dialog; resolves opened read handles `{ handleId, name, size, contentType }`. |
|
||||
| `read(handleId, offset, length?)` | Streams a chunk `{ dataBase64, length, eof }` from a read handle. |
|
||||
| `beginSave({ name, contentType?, size? })` | Native save dialog + write handle; resolves `{ handleId, chunkBytes }`, or `null` when the user cancels. |
|
||||
@@ -641,7 +655,7 @@ if (window.dbxPlugin.capabilities.storage) {
|
||||
```
|
||||
|
||||
| API | Purpose |
|
||||
| --- | --- |
|
||||
| ----------------- | ---------------------------------------------------------------- |
|
||||
| `get(key)` | Resolves the stored JSON value, or `null` when the key is unset. |
|
||||
| `set(key, value)` | Persists any JSON value (`undefined` normalizes to `null`). |
|
||||
| `delete(key)` | Removes one key; deleting an unset key succeeds. |
|
||||
@@ -764,14 +778,14 @@ grip.addEventListener("pointerup", async () => {
|
||||
```
|
||||
|
||||
| API | Purpose |
|
||||
| --- | --- |
|
||||
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `floating.open(options)` | Opens (or focuses) the floating window for one of this plugin's `workbench` contributions. `options`: `contributionId` (required), `title`, `width`/`height` in logical pixels, `x`/`y` in physical screen pixels, `alwaysOnTop` (default `true`), `skipTaskbar` (default `true`), `resizable` (default `false`), `context`. Resolves `{ windowId, reused }`; requires `host.workbench`. |
|
||||
| `floating.close(contributionId?)` | Closes this plugin's floating window — one contribution, or every floating window of this plugin when the id is omitted. Inside a widget, calling it without an argument closes the widget itself. |
|
||||
| `floating.beginDrag()` | Hands the window to the host's drag loop. Call it from `pointermove` once your drag threshold trips, while the button is still down. The host reads the native cursor itself from here on, so there is no per-move call to make. |
|
||||
| `floating.endDrag({ snap? })` | Ends the drag. With `snap` (default `true`) the host pulls the window flush onto a work-area edge it reached or passed — docking it there, capsule first and tucked behind a thin strip once the cursor has been away — or clamps it fully on screen otherwise, and remembers the position (and the dock) for the next open. Resolves the final rect in physical pixels. Optional in practice: the host also ends a drag on its own once the cursor goes quiet, so a widget that never sees the pointerup cannot leave the window glued to it. |
|
||||
| `floating.setSize(width, height)` | Resizes the hosting window in logical pixels; the host clamps to its own bounds. |
|
||||
|
||||
The widget decides *when* a drag starts (which element is a grip, how far the pointer must travel first); the host moves the window and owns the snap and the remembered position. There is deliberately no per-move bridge call: the host polls the native cursor, which keeps the widget under the pointer even when the webview is busy, and ends the drag on a quiet cursor if the pointerup never arrives. Geometry calls (`beginDrag`/`endDrag`/`setSize`) only work inside a floating window and reject elsewhere, so a tab can never move a window it is not in.
|
||||
The widget decides _when_ a drag starts (which element is a grip, how far the pointer must travel first); the host moves the window and owns the snap and the remembered position. There is deliberately no per-move bridge call: the host polls the native cursor, which keeps the widget under the pointer even when the webview is busy, and ends the drag on a quiet cursor if the pointerup never arrives. Geometry calls (`beginDrag`/`endDrag`/`setSize`) only work inside a floating window and reject elsewhere, so a tab can never move a window it is not in.
|
||||
|
||||
A few rules make the surface predictable:
|
||||
|
||||
@@ -805,7 +819,7 @@ Listen with `window.addEventListener("dbx-plugin-env", handler)` for locale/them
|
||||
The env message's `theme` is `{ appearance, tokens, editor? }`. `appearance` is `"light" | "dark"`; `tokens` are the resolved root design variables swept by prefix (`--color-*`, `--radius-*`, `--font-*`); `editor` is a structured snapshot of the SQL editor settings that have no CSS-token carrier. Delivered settings:
|
||||
|
||||
| DBX setting | Delivered as |
|
||||
| --- | --- |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| Light/dark/system mode | `theme.appearance` |
|
||||
| Palette + custom color sets | `--color-*` tokens |
|
||||
| Corner style | `--radius-*` tokens |
|
||||
@@ -1073,12 +1087,14 @@ The candidate's essential shape is below. It references unsigned assets from the
|
||||
"name": "Example Files",
|
||||
"source": "https://github.com/example/dbx-plugin-files/tree/v0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"targets": [{
|
||||
"targets": [
|
||||
{
|
||||
"target": "universal",
|
||||
"url": "https://github.com/example/dbx-plugin-files/releases/download/v0.1.0/com.example.files-0.1.0-universal.dbxp",
|
||||
"sha256": "<64 hexadecimal SHA-256 characters>",
|
||||
"size": 123456
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+5
-7
@@ -263,10 +263,7 @@ A field may declare `visible_when` and `required_when`. A leaf clause matches wh
|
||||
]
|
||||
},
|
||||
"required_when": {
|
||||
"all_of": [
|
||||
{ "field": "sudo_source", "one_of": ["custom"] },
|
||||
{ "not": { "field": "read_only", "one_of": [true] } }
|
||||
]
|
||||
"all_of": [{ "field": "sudo_source", "one_of": ["custom"] }, { "not": { "field": "read_only", "one_of": [true] } }]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -369,6 +366,7 @@ A workbench opens in a normal persistent DBX tab. The iframe is loaded with `san
|
||||
- `sendBinary(channel, data)` — requires `host.binary`
|
||||
- `readAsset(path)` / `readAssetUrl(path)`
|
||||
- `openWorkbench(contributionId, context)` — requires `host.workbench`
|
||||
- `setTitle(title)` / `getTitle()` — updates (pass `null` to reset) or reads the calling tab title; requires `host.workbench`
|
||||
- `executeCommand(commandId, context?)` — executes one of this plugin's own declared commands through the same path a menu placement takes: `enablement` is re-checked, `presentation: "panel"` commands dock in the bottom panel and `presentation: "tab"` (default) commands open a workbench tab, with §4.1 singleton reuse. `context` merges over the command-declared context (reserved identity fields stay host-owned) and scopes `instance_key` `{{path}}` placeholders — declare `instance_key: "logs:{{connectionId}}"` to get one panel instance per connection; a template that cannot resolve falls back to its literal. Resolves `{ error }` for expected outcomes (unknown command, enablement-blocked); requires `host.workbench`
|
||||
- `openFilesystem(providerId, context)` — requires `host.filesystem`
|
||||
- `getPlanCapabilities(connectionId)` / `explainPlan(request)` — reads an estimated execution plan for one connection; requires `host.plans:read`, see [Estimated execution plans](#estimated-execution-plans)
|
||||
@@ -600,7 +598,7 @@ if (window.dbxPlugin.capabilities.schemaMetadataApi) {
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
table: "users"
|
||||
table: "users",
|
||||
});
|
||||
renderColumns(metadata.columns);
|
||||
}
|
||||
@@ -659,7 +657,7 @@ if (window.dbxPlugin.capabilities.dataApi) {
|
||||
connectionId,
|
||||
database,
|
||||
sql: "SELECT status, count(*) AS total FROM orders GROUP BY status",
|
||||
maxRows: 200
|
||||
maxRows: 200,
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -684,7 +682,7 @@ A plugin can read the **estimated** execution plan of a query without touching a
|
||||
```
|
||||
|
||||
| Request | Purpose |
|
||||
| --- | --- |
|
||||
| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `host.getPlanCapabilities({ connectionId })` | Reports what the host and this connection can plan. It only reads the stored connection config; it never connects or probes the server. The connection must already be open. |
|
||||
| `host.explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | Returns the estimated plan for `sql`. The connection must already be open. |
|
||||
|
||||
|
||||
@@ -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