import type { ConnectionConfig, InstalledPlugin, PluginCommandContribution, PluginConditionClause, PluginConditionContextKeys, PluginConnectionAction, PluginConnectionProviderContribution, PluginContribution, PluginContributionLocalization, PluginContextMenuContribution, PluginResultViewContribution, PluginFilesystemProviderContribution, PluginFormField, PluginFormFieldLocalization, PluginFormFieldValue, PluginManifestLocalization, PluginUiContribution, PluginWorkbenchContribution, } from "@/types/database"; import { uuid } from "@/lib/common/utils"; import { clonePluginData } from "./pluginData"; const PLUGIN_CONNECTION_PROVIDER_OPTION_PREFIX = "plugin-provider:"; export interface FrontendPluginDefinition { plugin: InstalledPlugin; contributions: PluginContribution[]; diagnostics: string[]; } export interface PluginContributionEntry { plugin: InstalledPlugin; contribution: T; } export class FrontendPluginRegistry { private readonly definitions: FrontendPluginDefinition[]; constructor(plugins: readonly InstalledPlugin[] = [], locale = "en") { this.definitions = plugins.map((plugin) => normalizeFrontendPlugin(plugin, locale)); } listPlugins(): FrontendPluginDefinition[] { return [...this.definitions]; } findPlugin(pluginId: string): FrontendPluginDefinition | undefined { return this.definitions.find((definition) => definition.plugin.manifest.id === pluginId); } listConnectionProviders(): PluginContributionEntry[] { return this.listContributions("connection-provider"); } listWorkbenches(): PluginContributionEntry[] { return this.listContributions("workbench"); } listFilesystemProviders(): PluginContributionEntry[] { return this.listContributions("filesystem-provider"); } /** Native context-menu entries declared for a specific menu surface. */ listContextMenuItems(menu: string): PluginContributionEntry[] { return this.listContributions("context-menu").filter((entry) => entry.contribution.menu === menu); } /** Plugin-rendered query-result views offered from the results toolbar. */ listResultViews(): PluginContributionEntry[] { return this.listContributions("result-view"); } findWorkbench(pluginId: string, contributionId: string): PluginContributionEntry | undefined { return this.listWorkbenches().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === contributionId); } listCommands(): PluginContributionEntry[] { return this.listContributions("command"); } findCommand(pluginId: string, contributionId: string): PluginContributionEntry | undefined { return this.listCommands().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === contributionId); } /** First command of the plugin whose action opens the given workbench (§4.1). */ findCommandTargetingWorkbench(pluginId: string, workbenchContributionId: string): PluginCommandContribution | undefined { for (const contribution of this.findPlugin(pluginId)?.contributions ?? []) { if (contribution.type !== "command") continue; const action = contribution.action; if (action.type === "open-workbench" && action.workbench === workbenchContributionId) return contribution; } return undefined; } /** * PR-A4 appToolbar placements (HOST_PLUGIN_UI_SPEC §5.1): one icon entry * per visible toolbar placement, ordered by `order` then the full command * id so the result never depends on manifest or install order. Toolbar * entries stay hidden unless the manifest sets `default_visible: true` * (§5.2: toolbar items default to hidden). */ listToolbarMenuCommands(): Array<{ plugin: InstalledPlugin; command: PluginCommandContribution; order: number }> { const result: Array<{ plugin: InstalledPlugin; command: PluginCommandContribution; order: number }> = []; for (const definition of this.definitions) { for (const contribution of definition.contributions) { if (contribution.type !== "menus") continue; const placements = contribution.items.filter((item) => item.location === "appToolbar" && item.default_visible === true && evaluateWhen(item.when, "appToolbar")).sort((a, b) => a.order - b.order || a.command.localeCompare(b.command)); for (const item of placements) { const command = definition.contributions.find((candidate): candidate is PluginCommandContribution => candidate.type === "command" && candidate.id === item.command); if (command) result.push({ plugin: definition.plugin, command, order: item.order }); } } } return result; } /** * commandPalette placements (HOST_PLUGIN_UI_SPEC §5): palette declarations have no * a default_visible gate (§5.2 only mandates hidden-by-default for toolbar items). Commands sort by `order` * ascending, then by the fully qualified command id (`${pluginId}.${commandId}`) for a stable order independent of * manifest or install order. */ listPaletteMenuCommands(): Array<{ plugin: InstalledPlugin; command: PluginCommandContribution; order: number }> { const result: Array<{ plugin: InstalledPlugin; command: PluginCommandContribution; order: number }> = []; for (const definition of this.definitions) { for (const contribution of definition.contributions) { if (contribution.type !== "menus") continue; for (const item of contribution.items) { if (item.location !== "commandPalette") continue; if (!evaluateWhen(item.when, "commandPalette")) continue; const command = definition.contributions.find((candidate): candidate is PluginCommandContribution => candidate.type === "command" && candidate.id === item.command); if (command) result.push({ plugin: definition.plugin, command, order: item.order }); } } } return result.sort((left, right) => left.order - right.order || `${left.plugin.manifest.id}.${left.command.id}`.localeCompare(`${right.plugin.manifest.id}.${right.command.id}`)); } /** * Resolve any contribution the plugin UI entrypoint can render, whichever host * surface opened the tab — a `workbench` opened from the sidebar or a * `result-view` opened from the query-result toolbar. Lookups stay scoped to * the renderable contribution types, so an id owned by a native context menu * or a filesystem provider is not a plugin UI surface. */ findUiContribution(pluginId: string, contributionId: string): PluginContributionEntry | undefined { return [...this.listWorkbenches(), ...this.listResultViews()].find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === contributionId); } private listContributions(type: T): Array>> { return this.definitions .filter((definition) => definition.plugin.compatibility.compatible) .flatMap((definition) => definition.contributions.filter((contribution): contribution is Extract => contribution.type === type).map((contribution) => ({ plugin: definition.plugin, contribution }))) .sort((left, right) => `${left.plugin.manifest.name}:${pluginContributionSortLabel(left.contribution)}`.localeCompare(`${right.plugin.manifest.name}:${pluginContributionSortLabel(right.contribution)}`)); } } /** menus contributions carry no label of their own (copy comes from the referenced command); the sort key degrades to the id. */ function pluginContributionSortLabel(contribution: PluginContribution): string { return (contribution as { label?: string }).label || contribution.id; } /** * §5.3/§5.4 condition evaluation (pure): implicit AND within `all`; clauses referencing a missing key evaluate to false for all * operators. contextKeys are snapshots provided by the host per scenario. */ export function evaluatePluginCommandConditions(clauses: PluginConditionClause[] | undefined, contextKeys: PluginConditionContextKeys): boolean { return (clauses ?? []).every((clause) => { const actual = contextKeys[clause.key]; if (actual === undefined) return false; if (clause.operator === "equals") return String(actual) === String(clause.value); if (clause.operator === "notEquals") return String(actual) !== String(clause.value); return Array.isArray(clause.value) && clause.value.map(String).includes(String(actual)); }); } /** Placement render gate: when defaults to visible; evaluated against the placement surface snapshot. */ function evaluateWhen(when: { all: PluginConditionClause[] } | undefined, surface: string): boolean { return evaluatePluginCommandConditions(when?.all, { surface }); } export function createFrontendPluginRegistry(plugins: readonly InstalledPlugin[], locale = "en"): FrontendPluginRegistry { return new FrontendPluginRegistry(plugins, locale); } export function pluginConnectionProviderOptionValue(pluginId: string, providerId: string): string { return `${PLUGIN_CONNECTION_PROVIDER_OPTION_PREFIX}${encodeURIComponent(pluginId)}/${encodeURIComponent(providerId)}`; } export function parsePluginConnectionProviderOptionValue(value: string): { pluginId: string; providerId: string } | null { if (!value.startsWith(PLUGIN_CONNECTION_PROVIDER_OPTION_PREFIX)) return null; const encoded = value.slice(PLUGIN_CONNECTION_PROVIDER_OPTION_PREFIX.length); const separator = encoded.indexOf("/"); if (separator <= 0 || separator === encoded.length - 1) return null; try { const pluginId = decodeURIComponent(encoded.slice(0, separator)); const providerId = decodeURIComponent(encoded.slice(separator + 1)); return pluginId && providerId ? { pluginId, providerId } : null; } catch { return null; } } export function pluginConnectionProviderIcon(entry: PluginContributionEntry): string | undefined { return entry.contribution.icon || entry.plugin.manifest.icon; } /** * Well-known provider field key whose declared default seeds the typed * `ConnectionConfig.connect_timeout_secs`. A plugin knows its own transport * (SSH handshakes on slow links need far more than the generic 10s), so a * declared default wins over the global timeout unless the user explicitly * picks a per-connection value in the dialog's Advanced tab. */ export const PLUGIN_CONNECT_TIMEOUT_FIELD_KEY = "connect_timeout_secs"; export function pluginConnectionConnectTimeoutDefault(contribution: PluginConnectionProviderContribution): number | undefined { const field = contribution.fields.find((candidate) => candidate.key === PLUGIN_CONNECT_TIMEOUT_FIELD_KEY && effectiveFieldBinding(candidate) === "config"); const value = field?.default; if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; return Math.min(300, Math.max(1, Math.trunc(value))); } export function pluginConnectionActionsForDialog(contribution: PluginConnectionProviderContribution, editing: boolean): PluginConnectionAction[] { const actions: PluginConnectionAction[] = [ ...(contribution.actions || []).map((action) => ({ ...action, kind: "custom" as const })), ...((contribution.capabilities || []).includes("test") ? [{ id: "test", kind: "test" as const, variant: "outline" as const }] : []), editing ? ({ id: "save", kind: "save", variant: "default", close_on_success: true } satisfies PluginConnectionAction) : ({ id: "save-and-connect", kind: "save-and-connect", variant: "default", close_on_success: true } satisfies PluginConnectionAction), ]; return actions.filter((action) => action.when === undefined || action.when === "always" || (action.when === "edit" ? editing : !editing)); } export function initialPluginFormValues(contribution: PluginConnectionProviderContribution): Record { // `null` means "no default" (older hosts serialized the absent case that way), // so it must not seed the form with a value the user never typed. return Object.fromEntries(contribution.fields.filter((field) => field.default !== undefined && field.default !== null).map((field) => [field.key, field.default])) as Record; } export function pluginConnectionFormValues(contribution: PluginConnectionProviderContribution, config?: ConnectionConfig): Record { const values = initialPluginFormValues(contribution); if (!config) return values; const externalConfig = isRecord(config.external_config) ? config.external_config : {}; const secrets = config.connection_secrets || {}; for (const field of contribution.fields) { const binding = effectiveFieldBinding(field); const secret = secrets[field.key] ?? externalConfig[field.key]; const value = binding === "name" ? config.name : binding === "host" ? config.host : binding === "port" ? config.port : binding === "username" ? config.username : binding === "password" ? config.password : binding === "database" ? config.database : binding === "secret" ? secret : externalConfig[field.key]; // Port 0 is the stored representation of an optional, automatic port. // Keep that input empty on reopen so its protocol-default hint remains visible. if (binding === "port" && value === 0 && field.default === undefined) delete values[field.key]; else if (isPluginFormFieldValue(value)) values[field.key] = value; } return values; } export function buildPluginConnectionConfig(pluginId: string, contribution: PluginConnectionProviderContribution, values: Record, existing?: ConnectionConfig): ConnectionConfig { const externalConfig: Record = isRecord(existing?.external_config) ? clonePluginData(existing.external_config) : {}; const connectionSecrets = { ...existing?.connection_secrets }; const config: ConnectionConfig = { id: existing?.id || uuid(), name: existing?.name || contribution.label, note: existing?.note, db_type: "plugin", driver_profile: existing?.driver_profile || "plugin", driver_label: contribution.label, host: existing?.host || "", port: existing?.port || 0, username: existing?.username || "", password: existing?.password || "", database: existing?.database, external_config: externalConfig, plugin_id: pluginId, plugin_connection_provider: contribution.id, plugin_connection_type: contribution.database_type, connection_secrets: connectionSecrets, transport_layers: existing?.transport_layers || [], connect_timeout_secs: existing?.connect_timeout_secs || pluginConnectionConnectTimeoutDefault(contribution) || 10, query_timeout_secs: existing?.query_timeout_secs || 60, idle_timeout_secs: existing?.idle_timeout_secs || 60, keepalive_interval_secs: existing?.keepalive_interval_secs || 30, read_only: existing?.read_only || false, save_password: existing?.save_password !== false, is_production: existing?.is_production || false, production_databases: existing?.production_databases || [], }; for (const field of contribution.fields) { // A `null` coming from the form (or from a host that hydrated absent // defaults as `null`) means "unset": fall back to the declared default and // otherwise clear the stored value instead of persisting a null. const raw = values[field.key]; const value = raw === null ? undefined : (raw ?? field.default ?? undefined); const binding = effectiveFieldBinding(field); if (binding === "config") { if (value === undefined) delete externalConfig[field.key]; else externalConfig[field.key] = value; } else if (binding === "secret") { // A plugin may migrate a formerly config-bound field to secret binding. // Load its legacy value above, then remove the plaintext copy on save. delete externalConfig[field.key]; if (value === undefined || value === "") delete connectionSecrets[field.key]; else connectionSecrets[field.key] = String(value); } else if (binding === "name") { config.name = String(value || contribution.label); } else if (binding === "host") { config.host = String(value || ""); } else if (binding === "port") { config.port = typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(65535, Math.trunc(value))) : 0; } else if (binding === "username") { config.username = String(value || ""); } else if (binding === "password") { config.password = String(value || ""); } else if (binding === "database") { config.database = value === undefined || value === "" ? undefined : String(value); } } // A config-bound connect_timeout_secs field is the plugin's own handshake // timeout (the SSH plugin lets advanced users tune it). Mirror the resolved // value into the typed field so the host RPC deadline never fires before the // plugin's own timeout. Only applies while the provider declares the field — // a stale external_config key from an older manifest must not leak through. if (pluginConnectionConnectTimeoutDefault(contribution) !== undefined) { const pluginConnectTimeout = externalConfig[PLUGIN_CONNECT_TIMEOUT_FIELD_KEY]; if (typeof pluginConnectTimeout === "number" && Number.isFinite(pluginConnectTimeout) && pluginConnectTimeout > 0) { config.connect_timeout_secs = Math.min(300, Math.max(1, Math.trunc(pluginConnectTimeout))); } } return config; } function effectiveFieldBinding(field: PluginFormField): NonNullable { return field.binding || (field.type === "password" ? "secret" : "config"); } function normalizeFrontendPlugin(plugin: InstalledPlugin, locale: string): FrontendPluginDefinition { const localization = pluginLocalization(plugin, locale); const localizedPlugin = localizePluginMetadata(plugin, localization); const contributions = (localizedPlugin.manifest.contributions || []).map((contribution) => localizeContribution(contribution, localization?.contributions?.[contribution.id], localizedPlugin.manifest.name)); const diagnostics: string[] = []; if (!plugin.compatibility.compatible) diagnostics.push(...(plugin.compatibility.errors || [])); return { plugin: localizedPlugin, contributions, diagnostics }; } function pluginLocalization(plugin: InstalledPlugin, locale: string): PluginManifestLocalization | undefined { const localizations = plugin.manifest.localizations; if (!localizations || !locale) return undefined; const normalizedLocale = locale.replace("_", "-").toLowerCase(); const exact = Object.entries(localizations).find(([key]) => key.replace("_", "-").toLowerCase() === normalizedLocale)?.[1]; if (exact) return exact; const language = normalizedLocale.split("-")[0]; return Object.entries(localizations).find(([key]) => key.replace("_", "-").toLowerCase() === language)?.[1]; } function localizePluginMetadata(plugin: InstalledPlugin, localization?: PluginManifestLocalization): InstalledPlugin { return { ...plugin, manifest: { ...plugin.manifest, name: localizedRequiredText(plugin.manifest.name, localization?.name), icon: optionalPluginAssetPath(plugin.manifest.icon), description: localizedOptionalText(plugin.manifest.description, localization?.description), }, }; } function localizeContribution(contribution: PluginContribution, localization: PluginContributionLocalization | undefined, pluginName: string): PluginContribution { // Menus entries carry no display text of their own — labels come from the // referenced commands, so they pass through localization untouched. if (contribution.type === "menus") return contribution; const fallbackLabel = contribution.type === "connection-provider" ? optionalTrimmed(contribution.label) || optionalTrimmed(pluginName) || contribution.id : contribution.label; const localized = { ...contribution, label: localizedRequiredText(fallbackLabel, localization?.label), description: localizedOptionalText(contribution.description, localization?.description), } as PluginContribution; if (localized.type === "connection-provider") { localized.icon = optionalPluginAssetPath(localized.icon); localized.fields = localized.fields.map((field) => localizeField(field, localization?.fields?.[field.key])); localized.actions = localized.actions?.map((action) => ({ ...action, label: localizedRequiredText(action.label, localization?.actions?.[action.id]?.label), description: localizedOptionalText(action.description, localization?.actions?.[action.id]?.description), })); } else if (localized.type === "workbench" || localized.type === "command" || localized.type === "result-view") { // Workbench, command and result-view contributions all resolve their icon // asset path the same way. localized.icon = optionalPluginAssetPath(localized.icon); } return localized; } function localizeField(field: PluginFormField, localization?: PluginFormFieldLocalization): PluginFormField { if (!localization) return field; return { ...field, label: localizedRequiredText(field.label, localization.label), description: localizedOptionalText(field.description, localization.description), placeholder: localizedOptionalText(field.placeholder, localization.placeholder), options: field.options?.map((option) => ({ ...option, label: localizedRequiredText(option.label, localization.options?.[option.value]) })), }; } function localizedRequiredText(fallback: string, localized: unknown): string { return nonEmptyString(localized) ? localized.trim() : fallback; } function localizedOptionalText(fallback: string | undefined, localized: unknown): string | undefined { return typeof localized === "string" ? localized.trim() : fallback; } function optionalTrimmed(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed || undefined; } function optionalPluginAssetPath(value: unknown): string | undefined { const path = optionalTrimmed(value); if (!path || path.startsWith("/") || path.startsWith("\\") || path.includes("\\")) return undefined; const parts = path.split("/"); return parts.some((part) => !part || part === "." || part === "..") ? undefined : path; } function nonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } function isPluginFormFieldValue(value: unknown): value is PluginFormFieldValue { return value === undefined || typeof value === "string" || typeof value === "number" || typeof value === "boolean"; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }