Files
dbx/apps/desktop/src/lib/plugins/frontendPlugin.ts
T

436 lines
23 KiB
TypeScript

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<T extends PluginContribution> {
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<PluginConnectionProviderContribution>[] {
return this.listContributions("connection-provider");
}
listWorkbenches(): PluginContributionEntry<PluginWorkbenchContribution>[] {
return this.listContributions("workbench");
}
listFilesystemProviders(): PluginContributionEntry<PluginFilesystemProviderContribution>[] {
return this.listContributions("filesystem-provider");
}
/** Native context-menu entries declared for a specific menu surface. */
listContextMenuItems(menu: string): PluginContributionEntry<PluginContextMenuContribution>[] {
return this.listContributions("context-menu").filter((entry) => entry.contribution.menu === menu);
}
/** Plugin-rendered query-result views offered from the results toolbar. */
listResultViews(): PluginContributionEntry<PluginResultViewContribution>[] {
return this.listContributions("result-view");
}
findWorkbench(pluginId: string, contributionId: string): PluginContributionEntry<PluginWorkbenchContribution> | undefined {
return this.listWorkbenches().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === contributionId);
}
listCommands(): PluginContributionEntry<PluginCommandContribution>[] {
return this.listContributions("command");
}
findCommand(pluginId: string, contributionId: string): PluginContributionEntry<PluginCommandContribution> | 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<PluginUiContribution> | undefined {
return [...this.listWorkbenches(), ...this.listResultViews()].find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === contributionId);
}
private listContributions<T extends PluginContribution["type"]>(type: T): Array<PluginContributionEntry<Extract<PluginContribution, { type: T }>>> {
return this.definitions
.filter((definition) => definition.plugin.compatibility.compatible)
.flatMap((definition) => definition.contributions.filter((contribution): contribution is Extract<PluginContribution, { type: T }> => 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<PluginConnectionProviderContribution>): 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<string, PluginFormFieldValue> {
// `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<string, PluginFormFieldValue>;
}
export function pluginConnectionFormValues(contribution: PluginConnectionProviderContribution, config?: ConnectionConfig): Record<string, PluginFormFieldValue> {
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<string, PluginFormFieldValue>, existing?: ConnectionConfig): ConnectionConfig {
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(),
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<PluginFormField["binding"]> {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}