diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index b5175a40f3..50ead57126 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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); diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index e9a20f7525..42defea531 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -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) { diff --git a/apps/desktop/src/lib/__tests__/connection/connectionDialogDriverProfileConfig.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionDialogDriverProfileConfig.spec.ts index 895eceb4c2..6916f401d3 100644 --- a/apps/desktop/src/lib/__tests__/connection/connectionDialogDriverProfileConfig.spec.ts +++ b/apps/desktop/src/lib/__tests__/connection/connectionDialogDriverProfileConfig.spec.ts @@ -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))'); }); }); diff --git a/apps/desktop/src/lib/plugins/frontendPlugin.ts b/apps/desktop/src/lib/plugins/frontendPlugin.ts index d563ca016b..92a53a4e0c 100644 --- a/apps/desktop/src/lib/plugins/frontendPlugin.ts +++ b/apps/desktop/src/lib/plugins/frontendPlugin.ts @@ -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, existing?: ConnectionConfig): ConnectionConfig { - const externalConfig: Record = isRecord(existing?.external_config) ? structuredClone(existing.external_config) : {}; + const externalConfig: Record = isRecord(existing?.external_config) ? clonePluginData(existing.external_config) : {}; const connectionSecrets = { ...existing?.connection_secrets }; const config: ConnectionConfig = { id: existing?.id || uuid(), diff --git a/apps/desktop/src/lib/plugins/pluginData.ts b/apps/desktop/src/lib/plugins/pluginData.ts new file mode 100644 index 0000000000..cc8aa8a4c5 --- /dev/null +++ b/apps/desktop/src/lib/plugins/pluginData.ts @@ -0,0 +1,103 @@ +import { isProxy, toRaw } from "vue"; + +export const MAX_PLUGIN_WORKBENCH_CONTEXT_BYTES = 2 * 1024 * 1024; + +export function clonePluginData(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(value: T): T { + const snapshot = snapshotValue(value, "context", new WeakSet()); + 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): 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(value: T, seen = new WeakMap()): 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(); + 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(); + 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; + 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; +} diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts index e3eb5523d4..4337d89a74 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts @@ -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 = {}; + 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; diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.ts index abf8a9f9db..1deb29f95b 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.ts @@ -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 { - 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(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 { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 5d7f227aae..dddfbb8f42 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -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; 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, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 68d843fade..a491d99ac8 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -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; one_time?: boolean; /** * Whether the database password may be persisted locally. When false, the @@ -431,19 +435,155 @@ export interface PluginManifestLocalization { contributions?: Record; } +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; } 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; +} + +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; +} + +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; + }; + 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; diff --git a/docs/content/docs/plugin-development.cn.mdx b/docs/content/docs/plugin-development.cn.mdx index 251ac9b20a..20c6953868 100644 --- a/docs/content/docs/plugin-development.cn.mdx +++ b/docs/content/docs/plugin-development.cn.mdx @@ -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 源码: diff --git a/docs/content/docs/plugin-development.mdx b/docs/content/docs/plugin-development.mdx index 460244ea2d..1056d42243 100644 --- a/docs/content/docs/plugin-development.mdx +++ b/docs/content/docs/plugin-development.mdx @@ -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: