mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(plugins): preserve workbench snapshots and connection config
Refs: #8403
This commit is contained in:
@@ -976,6 +976,7 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({
|
||||
openDbFilePath,
|
||||
openConnectionDeepLink,
|
||||
openAiConfigDeepLink,
|
||||
closeActiveSurface,
|
||||
});
|
||||
const { showCloseActionPrompt, chooseQuit, chooseMinimize, cancelCloseActionPrompt, performCloseAction, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt({ requestClose: requestAppClose });
|
||||
useVisibilityChange();
|
||||
@@ -2407,6 +2408,19 @@ async function openConnectionQuery(connectionId: string) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (initialTarget.kind === "plugin-workbench") {
|
||||
try {
|
||||
await queryStore.openPluginConnection(connectionId);
|
||||
} catch (e: any) {
|
||||
toast(
|
||||
t("connection.connectFailed", {
|
||||
message: translateBackendError(t, e),
|
||||
}),
|
||||
5000,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (initialTarget.kind === "etcd" || initialTarget.kind === "zookeeper" || initialTarget.kind === "consul") {
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
|
||||
@@ -4020,7 +4020,7 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
|
||||
setGaussdbIdentifierQuoteStyle(config, style);
|
||||
setGaussdbTargetServerType(config, targetServerType);
|
||||
setGaussdbCountQueryDop(config, countQueryDop);
|
||||
} else if (!isDoltDriverProfile(config.driver_profile)) {
|
||||
} else if (config.db_type !== "plugin" && !isDoltDriverProfile(config.driver_profile)) {
|
||||
config.external_config = undefined;
|
||||
}
|
||||
if (config.db_type === "mongodb" && !mongoUseUrl.value) {
|
||||
|
||||
+7
-1
@@ -22,6 +22,12 @@ describe("ConnectionDialog driver profile configuration", () => {
|
||||
const submitConfig = connectionConfigForSubmitSource();
|
||||
|
||||
expect(submitConfig).toContain("setGaussdbTargetServerType(config, targetServerType)");
|
||||
expect(submitConfig).toContain("else if (!isDoltDriverProfile(config.driver_profile))");
|
||||
expect(submitConfig).toContain('else if (config.db_type !== "plugin" && !isDoltDriverProfile(config.driver_profile))');
|
||||
});
|
||||
|
||||
it("preserves plugin external configuration on submit", () => {
|
||||
const submitConfig = connectionConfigForSubmitSource();
|
||||
|
||||
expect(submitConfig).toContain('else if (config.db_type !== "plugin" && !isDoltDriverProfile(config.driver_profile))');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
PluginWorkbenchContribution,
|
||||
} from "@/types/database";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { clonePluginData } from "./pluginData";
|
||||
|
||||
const PLUGIN_CONNECTION_PROVIDER_OPTION_PREFIX = "plugin-provider:";
|
||||
|
||||
@@ -146,7 +147,7 @@ export function pluginConnectionFormValues(contribution: PluginConnectionProvide
|
||||
}
|
||||
|
||||
export function buildPluginConnectionConfig(pluginId: string, contribution: PluginConnectionProviderContribution, values: Record<string, PluginFormFieldValue>, existing?: ConnectionConfig): ConnectionConfig {
|
||||
const externalConfig: Record<string, unknown> = isRecord(existing?.external_config) ? structuredClone(existing.external_config) : {};
|
||||
const externalConfig: Record<string, unknown> = isRecord(existing?.external_config) ? clonePluginData(existing.external_config) : {};
|
||||
const connectionSecrets = { ...existing?.connection_secrets };
|
||||
const config: ConnectionConfig = {
|
||||
id: existing?.id || uuid(),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { isProxy, toRaw } from "vue";
|
||||
|
||||
export const MAX_PLUGIN_WORKBENCH_CONTEXT_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export function clonePluginData<T>(value: T): T {
|
||||
const unwrapped = unwrapVueProxies(value);
|
||||
try {
|
||||
if (typeof structuredClone === "function") return structuredClone(unwrapped);
|
||||
return JSON.parse(JSON.stringify(unwrapped)) as T;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Plugin data cannot be cloned: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotPluginWorkbenchContext<T extends object>(value: T): T {
|
||||
const snapshot = snapshotValue(value, "context", new WeakSet<object>());
|
||||
if (!snapshot || Array.isArray(snapshot) || typeof snapshot !== "object") {
|
||||
throw new Error("Plugin workbench context must be an object");
|
||||
}
|
||||
const size = new TextEncoder().encode(JSON.stringify(snapshot)).byteLength;
|
||||
if (size > MAX_PLUGIN_WORKBENCH_CONTEXT_BYTES) {
|
||||
throw new Error(`Plugin workbench context exceeds ${MAX_PLUGIN_WORKBENCH_CONTEXT_BYTES} bytes`);
|
||||
}
|
||||
return snapshot as T;
|
||||
}
|
||||
|
||||
type PluginContextValue = boolean | number | string | null | PluginContextValue[] | { [key: string]: PluginContextValue } | undefined;
|
||||
|
||||
function snapshotValue(value: unknown, path: string, ancestors: WeakSet<object>): PluginContextValue {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
||||
if (typeof value === "number") {
|
||||
if (Number.isFinite(value)) return value;
|
||||
throw new Error(`Plugin workbench context contains a non-finite number at ${path}`);
|
||||
}
|
||||
if (typeof value !== "object") throw new Error(`Plugin workbench context contains an unsupported value at ${path}`);
|
||||
|
||||
const raw = isProxy(value) ? toRaw(value) : value;
|
||||
if (ancestors.has(raw)) throw new Error(`Plugin workbench context contains a circular reference at ${path}`);
|
||||
const prototype = Object.getPrototypeOf(raw);
|
||||
if (!Array.isArray(raw) && prototype !== Object.prototype && prototype !== null) {
|
||||
throw new Error(`Plugin workbench context requires plain data at ${path}`);
|
||||
}
|
||||
|
||||
ancestors.add(raw);
|
||||
try {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((item, index) => snapshotValue(item, `${path}[${index}]`, ancestors) ?? null);
|
||||
}
|
||||
const result: { [key: string]: PluginContextValue } = {};
|
||||
for (const [key, item] of Object.entries(raw)) {
|
||||
const snapshot = snapshotValue(item, `${path}.${key}`, ancestors);
|
||||
if (snapshot !== undefined) result[key] = snapshot;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
ancestors.delete(raw);
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapVueProxies<T>(value: T, seen = new WeakMap<object, unknown>()): T {
|
||||
const rawValue = isProxy(value) ? toRaw(value) : value;
|
||||
if (!rawValue || typeof rawValue !== "object") return rawValue;
|
||||
|
||||
const object = rawValue as object;
|
||||
const existing = seen.get(object);
|
||||
if (existing) return existing as T;
|
||||
if (rawValue instanceof Date || rawValue instanceof RegExp || rawValue instanceof ArrayBuffer || ArrayBuffer.isView(rawValue)) {
|
||||
return rawValue;
|
||||
}
|
||||
if (Array.isArray(rawValue)) {
|
||||
const result: unknown[] = [];
|
||||
seen.set(object, result);
|
||||
rawValue.forEach((item, index) => {
|
||||
result[index] = unwrapVueProxies(item, seen);
|
||||
});
|
||||
return result as T;
|
||||
}
|
||||
if (rawValue instanceof Map) {
|
||||
const result = new Map<unknown, unknown>();
|
||||
seen.set(object, result);
|
||||
rawValue.forEach((item, key) => result.set(unwrapVueProxies(key, seen), unwrapVueProxies(item, seen)));
|
||||
return result as T;
|
||||
}
|
||||
if (rawValue instanceof Set) {
|
||||
const result = new Set<unknown>();
|
||||
seen.set(object, result);
|
||||
rawValue.forEach((item) => result.add(unwrapVueProxies(item, seen)));
|
||||
return result as T;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(rawValue);
|
||||
if (prototype !== Object.prototype && prototype !== null) return rawValue;
|
||||
|
||||
const result = Object.create(prototype) as Record<PropertyKey, unknown>;
|
||||
seen.set(object, result);
|
||||
for (const key of Reflect.ownKeys(rawValue)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(rawValue, key);
|
||||
if (!descriptor || !("value" in descriptor)) continue;
|
||||
Object.defineProperty(result, key, { ...descriptor, value: unwrapVueProxies(descriptor.value, seen) });
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { reactive, readonly } from "vue";
|
||||
import { PluginHostBridge, pluginSandboxDocument } from "./pluginHostBridge";
|
||||
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
|
||||
|
||||
@@ -57,6 +58,65 @@ describe("PluginHostBridge", () => {
|
||||
expect(messages[0]).toMatchObject({ source: "dbx-host", type: "init", locale: "zh-CN", context: { connectionId: "connection" } });
|
||||
});
|
||||
|
||||
it("snapshots nested Vue reactive context values before sending them to the plugin", () => {
|
||||
const messages: unknown[] = [];
|
||||
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
||||
const context = {
|
||||
connectionId: "connection",
|
||||
values: reactive({ path: "/tmp", options: readonly({ recursive: true }) }),
|
||||
};
|
||||
const bridge = new PluginHostBridge(plugin(), workbench, context, () => target, {
|
||||
invoke: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
sendBinary: vi.fn(),
|
||||
readAsset: vi.fn(),
|
||||
});
|
||||
|
||||
bridge.sendInit();
|
||||
|
||||
expect(messages[0]).toMatchObject({
|
||||
type: "init",
|
||||
context: { connectionId: "connection", values: { path: "/tmp", options: { recursive: true } } },
|
||||
});
|
||||
expect((messages[0] as { context: typeof context }).context).not.toBe(context);
|
||||
});
|
||||
|
||||
it("reports unsupported plugin context values", () => {
|
||||
expect(
|
||||
() =>
|
||||
new PluginHostBridge(plugin(), workbench, { value: () => undefined }, () => null, {
|
||||
invoke: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
sendBinary: vi.fn(),
|
||||
readAsset: vi.fn(),
|
||||
}),
|
||||
).toThrow("Plugin workbench context contains an unsupported value");
|
||||
});
|
||||
|
||||
it("rejects circular and oversized plugin contexts", () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
expect(
|
||||
() =>
|
||||
new PluginHostBridge(plugin(), workbench, circular, () => null, {
|
||||
invoke: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
sendBinary: vi.fn(),
|
||||
readAsset: vi.fn(),
|
||||
}),
|
||||
).toThrow("circular reference");
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new PluginHostBridge(plugin(), workbench, { value: "x".repeat(2 * 1024 * 1024) }, () => null, {
|
||||
invoke: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
sendBinary: vi.fn(),
|
||||
readAsset: vi.fn(),
|
||||
}),
|
||||
).toThrow("exceeds");
|
||||
});
|
||||
|
||||
it("rejects privileged host calls without manifest permission", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { InstalledPlugin, PluginBinaryEvent, PluginEvent, PluginUiAssetPayload, PluginWorkbenchContribution } from "@/types/database";
|
||||
import { clonePluginData, snapshotPluginWorkbenchContext } from "./pluginData";
|
||||
|
||||
const PLUGIN_MESSAGE_SOURCE = "dbx-plugin";
|
||||
const HOST_MESSAGE_SOURCE = "dbx-host";
|
||||
@@ -54,9 +55,9 @@ export class PluginHostBridge {
|
||||
locale = "en",
|
||||
theme?: PluginBridgeTheme,
|
||||
) {
|
||||
this.context = structuredCloneSafe(context);
|
||||
this.context = snapshotPluginWorkbenchContext(context);
|
||||
this.locale = locale;
|
||||
this.theme = theme ? structuredCloneSafe(theme) : undefined;
|
||||
this.theme = theme ? clonePluginData(theme) : undefined;
|
||||
}
|
||||
|
||||
handleWindowMessage(event: MessageEvent): boolean {
|
||||
@@ -80,9 +81,9 @@ export class PluginHostBridge {
|
||||
pluginId: this.plugin.manifest.id,
|
||||
contributionId: this.workbench.id,
|
||||
locale: this.locale,
|
||||
theme: this.theme ? structuredCloneSafe(this.theme) : undefined,
|
||||
theme: this.theme ? clonePluginData(this.theme) : undefined,
|
||||
permissions: [...(this.plugin.manifest.permissions || [])],
|
||||
context: structuredCloneSafe(this.context),
|
||||
context: snapshotPluginWorkbenchContext(this.context),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,8 +93,8 @@ export class PluginHostBridge {
|
||||
* a full reload; context-only changes must not lose plugin state.
|
||||
*/
|
||||
updateContext(context: PluginWorkbenchContext): void {
|
||||
this.context = structuredCloneSafe(context);
|
||||
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "context", context: structuredCloneSafe(this.context) });
|
||||
this.context = snapshotPluginWorkbenchContext(context);
|
||||
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "context", context: snapshotPluginWorkbenchContext(this.context) });
|
||||
}
|
||||
|
||||
/** Notify the plugin UI about a locale change without a reload. */
|
||||
@@ -104,7 +105,7 @@ export class PluginHostBridge {
|
||||
|
||||
/** Push resolved theme tokens so the plugin UI can follow DBX light/dark and palette changes. */
|
||||
updateTheme(theme: PluginBridgeTheme): void {
|
||||
this.theme = structuredCloneSafe(theme);
|
||||
this.theme = clonePluginData(theme);
|
||||
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "env", locale: this.locale, theme: this.theme });
|
||||
}
|
||||
|
||||
@@ -132,7 +133,7 @@ export class PluginHostBridge {
|
||||
}
|
||||
|
||||
private async dispatch(method: string, params: unknown, binary?: ArrayBuffer): Promise<unknown> {
|
||||
if (method === "host.getContext") return structuredCloneSafe(this.context);
|
||||
if (method === "host.getContext") return snapshotPluginWorkbenchContext(this.context);
|
||||
if (method === "backend.invoke") {
|
||||
const input = requireRecord(params, "backend.invoke params");
|
||||
const backendMethod = requireProtocolName(input.method, "backend method");
|
||||
@@ -451,11 +452,6 @@ function enforcePayloadLimit(value: unknown): void {
|
||||
if (bytes > MAX_BRIDGE_PAYLOAD_BYTES) throw new Error("Plugin bridge request is too large");
|
||||
}
|
||||
|
||||
function structuredCloneSafe<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") return structuredClone(value);
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, any> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ import { isRedisMonitorCommand, startRedisMonitor } from "@/lib/redis/redisMonit
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
|
||||
import { sanitizeTabPageUiState } from "@/lib/tabs/tabUiState";
|
||||
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
|
||||
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
|
||||
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
|
||||
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
|
||||
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult, type ExplainPlanDatabaseType } from "@/lib/diagram/explainPlan";
|
||||
@@ -75,6 +74,8 @@ import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
|
||||
import { batchSqlRecoverySql, batchSqlRecoveryState, mergeBatchQueryResults, offsetBatchQueryResultIndexes, prepareBatchSqlRecovery, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery";
|
||||
import { decodeQueryResultArchive, encodeQueryResultArchive, type DecodedQueryResultArchive } from "@/lib/query/queryResultArchive";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||||
import { snapshotPluginWorkbenchContext } from "@/lib/plugins/pluginData";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
@@ -92,7 +93,7 @@ import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeS
|
||||
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
|
||||
import { disposeAllSqlServerActivityTraces, disposeSqlServerActivityTrace } from "@/lib/sqlserver/sqlServerActivityTraceRuntime";
|
||||
import type { SavedSqlFile } from "@/types/database";
|
||||
import i18n from "@/i18n";
|
||||
import i18n, { currentLocale } from "@/i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry";
|
||||
import type { DriverProfileWorkspaceScope } from "@/lib/database/driverProfileExtensions";
|
||||
@@ -3146,6 +3147,104 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.nacosTargetKeyword = undefined;
|
||||
}
|
||||
|
||||
function openPluginWorkbench(pluginId: string, contributionId: string, options: { title?: string; connectionId?: string; database?: string; context?: Record<string, unknown>; forceNew?: boolean } = {}) {
|
||||
if (!options.forceNew) {
|
||||
const existing = tabs.value.find((tab) => tab.mode === "plugin-workbench" && tab.pluginWorkbench?.pluginId === pluginId && tab.pluginWorkbench?.contributionId === contributionId && tab.connectionId === (options.connectionId || ""));
|
||||
if (existing) {
|
||||
if (options.context) existing.pluginWorkbench = { ...existing.pluginWorkbench!, context: snapshotPluginWorkbenchContext(options.context) };
|
||||
switchTab(existing.id);
|
||||
return existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
const id = uuid();
|
||||
const tab: QueryTab = {
|
||||
id,
|
||||
title: options.title || contributionId,
|
||||
connectionId: options.connectionId || "",
|
||||
database: options.database || "",
|
||||
sql: "",
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
mode: "plugin-workbench",
|
||||
pluginWorkbench: {
|
||||
pluginId,
|
||||
contributionId,
|
||||
context: options.context ? snapshotPluginWorkbenchContext(options.context) : undefined,
|
||||
},
|
||||
};
|
||||
tabs.value.push(tab);
|
||||
activeTabId.value = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
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 || ""));
|
||||
if (existing) {
|
||||
if (options.currentUri) existing.pluginFilesystem = { ...existing.pluginFilesystem!, currentUri: options.currentUri };
|
||||
switchTab(existing.id);
|
||||
return existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
const id = uuid();
|
||||
const tab: QueryTab = {
|
||||
id,
|
||||
title: options.title || providerId,
|
||||
connectionId: options.connectionId || "",
|
||||
database: "",
|
||||
sql: "",
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
mode: "plugin-filesystem",
|
||||
pluginFilesystem: {
|
||||
pluginId,
|
||||
providerId,
|
||||
rootUri: options.rootUri,
|
||||
currentUri: options.currentUri,
|
||||
},
|
||||
};
|
||||
tabs.value.push(tab);
|
||||
activeTabId.value = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
async function openPluginConnection(connectionId: string) {
|
||||
const connectionStore = useConnectionStore();
|
||||
const connection = connectionStore.getConfig(connectionId);
|
||||
if (!connection || connection.db_type !== "plugin") throw new Error("Plugin connection config not found");
|
||||
const pluginId = connection.plugin_id;
|
||||
const providerId = connection.plugin_connection_provider;
|
||||
if (!pluginId || !providerId) throw new Error("Plugin connection binding is incomplete");
|
||||
const registry = createFrontendPluginRegistry(await api.listPlugins(), currentLocale());
|
||||
const provider = registry.listConnectionProviders().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === providerId);
|
||||
if (!provider) throw new Error(`Plugin connection provider '${pluginId}/${providerId}' is unavailable`);
|
||||
const workbench = provider.contribution.workbench ? registry.findWorkbench(pluginId, provider.contribution.workbench) : undefined;
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
if (workbench) {
|
||||
return openPluginWorkbench(pluginId, workbench.contribution.id, {
|
||||
title: `${connection.name} · ${workbench.contribution.label}`,
|
||||
connectionId,
|
||||
context: {
|
||||
connectionId,
|
||||
providerId,
|
||||
connectionType: connection.plugin_connection_type,
|
||||
},
|
||||
});
|
||||
}
|
||||
const filesystemProviderId = provider.contribution.filesystem_provider;
|
||||
const filesystem = filesystemProviderId ? registry.listFilesystemProviders().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === filesystemProviderId) : undefined;
|
||||
if (!filesystem) throw new Error(`Plugin connection provider '${pluginId}/${providerId}' does not declare a workbench or filesystem provider`);
|
||||
return openPluginFilesystem(pluginId, filesystem.contribution.id, {
|
||||
title: `${connection.name} · ${filesystem.contribution.label}`,
|
||||
connectionId,
|
||||
rootUri: filesystem.contribution.root_uri,
|
||||
});
|
||||
}
|
||||
|
||||
function applyTableStructureInitialTab(tab: QueryTab, initialTab?: TableInfoTab, initialTarget?: TableStructureEditorTarget) {
|
||||
if (!initialTab && !initialTarget?.name) return;
|
||||
if (initialTab) tab.structureInitialTab = initialTab;
|
||||
@@ -8089,6 +8188,9 @@ export const useQueryStore = defineStore("query", () => {
|
||||
openMqttAdmin,
|
||||
openNacosAdmin,
|
||||
clearNacosNavigationTarget,
|
||||
openPluginWorkbench,
|
||||
openPluginFilesystem,
|
||||
openPluginConnection,
|
||||
openTableStructure,
|
||||
linkSavedSql,
|
||||
linkExternalSqlPath,
|
||||
|
||||
@@ -131,6 +131,10 @@ export interface ConnectionConfig {
|
||||
gbase_server?: string;
|
||||
informix_server?: string;
|
||||
external_config?: unknown;
|
||||
plugin_id?: string;
|
||||
plugin_connection_provider?: string;
|
||||
plugin_connection_type?: string;
|
||||
connection_secrets?: Record<string, string>;
|
||||
one_time?: boolean;
|
||||
/**
|
||||
* Whether the database password may be persisted locally. When false, the
|
||||
@@ -431,19 +435,155 @@ export interface PluginManifestLocalization {
|
||||
contributions?: Record<string, PluginContributionLocalization>;
|
||||
}
|
||||
|
||||
export interface PluginCompatibility {
|
||||
compatible: boolean;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
target?: string;
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
manifest_version?: number;
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
version?: string;
|
||||
publisher?: string;
|
||||
engines?: PluginEngines;
|
||||
permissions?: string[];
|
||||
entrypoints?: PluginEntrypoints;
|
||||
protocol_version?: number;
|
||||
description?: string;
|
||||
executable?: string;
|
||||
drivers: PluginDriverManifest[];
|
||||
contributions?: PluginContribution[];
|
||||
localizations?: Record<string, PluginManifestLocalization>;
|
||||
}
|
||||
|
||||
export interface InstalledPlugin {
|
||||
manifest: PluginManifest;
|
||||
path: string;
|
||||
compatibility: PluginCompatibility;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface PluginTrustedKey {
|
||||
keyId: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
export type PluginRepositoryKind = "official" | "custom" | "enterprise";
|
||||
|
||||
export interface PluginRepository {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: PluginRepositoryKind;
|
||||
catalogUrl?: string;
|
||||
enabled: boolean;
|
||||
managed: boolean;
|
||||
}
|
||||
|
||||
export interface PluginMarketplaceRepositoryMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
homepage?: string;
|
||||
}
|
||||
|
||||
export interface PluginMarketplaceLocalization {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PluginMarketplaceArtifact {
|
||||
target: string;
|
||||
url: string;
|
||||
sha256: string;
|
||||
signingKeyId: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface PluginMarketplaceVersion {
|
||||
version: string;
|
||||
releasedAt?: string;
|
||||
releaseNotes?: string;
|
||||
artifacts: PluginMarketplaceArtifact[];
|
||||
}
|
||||
|
||||
export interface PluginMarketplacePlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
publisher: string;
|
||||
verified: boolean;
|
||||
icon?: string;
|
||||
tags: string[];
|
||||
permissions: string[];
|
||||
source?: string;
|
||||
homepage?: string;
|
||||
license?: string;
|
||||
latestVersion: string;
|
||||
versions: PluginMarketplaceVersion[];
|
||||
localizations?: Record<string, PluginMarketplaceLocalization>;
|
||||
}
|
||||
|
||||
export interface PluginMarketplaceCatalog {
|
||||
catalogVersion: number;
|
||||
repository: PluginMarketplaceRepositoryMetadata;
|
||||
generatedAt?: string;
|
||||
plugins: PluginMarketplacePlugin[];
|
||||
}
|
||||
|
||||
export interface PluginRepositoryCatalogResult {
|
||||
repository: PluginRepository;
|
||||
target: string;
|
||||
catalog?: PluginMarketplaceCatalog;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PluginMarketplaceInstallRequest {
|
||||
repositoryId: string;
|
||||
pluginId: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface ActivePluginSession {
|
||||
pluginId: string;
|
||||
processId?: number;
|
||||
state: "starting" | "running" | "stopping" | "stopped" | "exited";
|
||||
}
|
||||
|
||||
export interface PluginUiAssetPayload {
|
||||
contentType: string;
|
||||
dataBase64: string;
|
||||
etag: string;
|
||||
}
|
||||
|
||||
export interface PluginConnectionActionResult {
|
||||
message?: string;
|
||||
fieldValues?: Record<string, PluginFormFieldValue | null>;
|
||||
}
|
||||
|
||||
export interface PluginInstallResult {
|
||||
plugin: InstalledPlugin;
|
||||
previousVersion?: string;
|
||||
packageSha256: string;
|
||||
signature: { status: "trusted"; key_id: string } | { status: "unsigned" };
|
||||
}
|
||||
|
||||
export interface PluginRollbackResult {
|
||||
plugin: InstalledPlugin;
|
||||
previousVersion: string;
|
||||
}
|
||||
|
||||
export interface PluginEvent {
|
||||
pluginId: string;
|
||||
method: string;
|
||||
params: unknown;
|
||||
}
|
||||
|
||||
export interface PluginBinaryEvent {
|
||||
pluginId: string;
|
||||
channel: string;
|
||||
dataBase64: string;
|
||||
}
|
||||
|
||||
export interface JdbcDriverInfo {
|
||||
@@ -1465,7 +1605,20 @@ export interface QueryTab {
|
||||
| "mysql-dashboard"
|
||||
| "postgres-dashboard"
|
||||
| "xugu-dashboard"
|
||||
| "dolt-version-control";
|
||||
| "dolt-version-control"
|
||||
| "plugin-workbench"
|
||||
| "plugin-filesystem";
|
||||
pluginWorkbench?: {
|
||||
pluginId: string;
|
||||
contributionId: string;
|
||||
context?: Record<string, unknown>;
|
||||
};
|
||||
pluginFilesystem?: {
|
||||
pluginId: string;
|
||||
providerId: string;
|
||||
rootUri?: string;
|
||||
currentUri?: string;
|
||||
};
|
||||
/** Ephemeral navigation intent; it is consumed by HBaseBrowser and is not persisted. */
|
||||
hbaseCreateTableOnOpen?: boolean;
|
||||
mqTenant?: string;
|
||||
|
||||
@@ -43,6 +43,14 @@ my-plugin/
|
||||
- `.dbxp` 是安装包,不是源码仓库。
|
||||
- 前端插件可以发布一个 `universal` 包;包含原生 Sidecar 的插件需要按操作系统和 CPU 架构分别构建。
|
||||
|
||||
## Workbench 上下文契约
|
||||
|
||||
Workbench 上下文跨越 DBX 与插件边界时是 JSON 数据快照。宿主会递归移除 Vue 响应式包装并发送独立副本,因此插件不能依赖 Vue ref、Proxy、DOM 节点、函数、组件实例或凭据。
|
||||
|
||||
上下文可以包含 `null`、布尔值、有限数字、字符串、数组和普通对象。对象中的 `undefined` 字段会省略,数组中的 `undefined` 会转换为 `null`。Date、Map、Set、Symbol、BigInt、非有限数字、循环引用和自定义 class 实例会被拒绝并返回错误。上下文 UTF-8 编码后的大小上限为 2 MiB。
|
||||
|
||||
连接 Provider 使用 `binding: "config"` 的字段会保存到 `external_config`;使用 `binding: "secret"` 的字段会保存到 Secret Store。插件连接的 `external_config` 会在新建、编辑、保存和重新连接流程中保留。
|
||||
|
||||
## 创建插件
|
||||
|
||||
直接安装预编译 CLI,不需要 Rust,也不需要克隆 DBX 源码:
|
||||
|
||||
@@ -43,6 +43,14 @@ my-plugin/
|
||||
- `.dbxp` is an installable artifact, not the source repository.
|
||||
- Frontend-only plugins can publish one `universal` package. Native sidecars require packages for each operating-system and CPU target.
|
||||
|
||||
## Workbench Context Contract
|
||||
|
||||
Workbench context is a JSON data snapshot across the DBX/plugin boundary. The host recursively removes Vue reactivity and sends an independent copy, so plugins must not expect Vue refs, proxies, DOM nodes, functions, component instances, or credentials.
|
||||
|
||||
Context may contain `null`, booleans, finite numbers, strings, arrays, and plain objects. Undefined object properties are omitted and undefined array entries become `null`. Dates, Maps, Sets, symbols, bigints, non-finite numbers, circular references, and custom class instances are rejected with an error. The UTF-8 encoded context is limited to 2 MiB.
|
||||
|
||||
Connection provider fields using `binding: "config"` are persisted in `external_config`; fields using `binding: "secret"` are persisted in the Secret Store. Plugin connection configuration keeps `external_config` through create, edit, save, and reconnect flows.
|
||||
|
||||
## Create a Plugin
|
||||
|
||||
Install the precompiled CLI without Rust or a DBX source checkout:
|
||||
|
||||
Reference in New Issue
Block a user